capture: answer the stop before summarising, and always leave a note

capture_stop held the IPC request open for the whole map reduce, up to
twenty minutes. A voice turn that says "хватит" waited for forty model
calls before Maven said anything. Stop now returns the transcript and the
summary runs on a goroutine in the daemon's WaitGroup, on the daemon
context so a client that hung up does not cancel the only readable record
of the meeting.

With no summary and save_transcript false, writeNotes wrote nothing at
all: an hour of meeting left a blob that prunes in seven days and no
trace in the note store. The transcript is written instead when the
summary is missing. That flag decides whether the verbatim record is kept
in addition to a summary, not whether the meeting is remembered.

The wire carries the session token now, and the contract comments say
what the code does: the summary is usually absent from the stop
response, and re running a stored blob is a manual job because no method
takes a blob id. The save_transcript comment says the cost is recall
corpus rather than disk.

Found in review of #73.
This commit is contained in:
kami
2026-08-01 14:36:17 +04:00
parent 77888c1a9c
commit 2ca5ffa4f9
7 changed files with 235 additions and 46 deletions
+9 -4
View File
@@ -27,8 +27,8 @@ import (
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/netscan"
"github.com/kami/maven/internal/smarthome"
"github.com/kami/maven/internal/vision"
"github.com/kami/maven/internal/update"
"github.com/kami/maven/internal/vision"
"github.com/robfig/cron/v3"
)
@@ -787,9 +787,14 @@ type CaptureConfig struct {
MaxChunks int `json:"max_chunks,omitempty"`
// SaveTranscript — write the full transcript as a note alongside the
// summary. Default false: a verbatim record of what other people said in a
// room is a heavier thing to keep than a four-line summary, so it takes a
// deliberate yes. The audio blob is pruned by media.retention either way.
// summary. Default false, and the cost is not disk: a note is embedded and
// becomes recall corpus, so every later question can surface verbatim words
// other people said in a room. That is the reason it takes a deliberate yes.
// The audio blob is pruned by media.retention either way; the notes are not.
//
// A meeting with no summary writes its transcript regardless. The choice
// here is transcript IN ADDITION to a summary, not whether the meeting is
// remembered at all.
SaveTranscript bool `json:"save_transcript,omitempty"`
}
+8 -8
View File
@@ -228,14 +228,14 @@ func TestSpeakerBlockParsesFromJSON(t *testing.T) {
// both fail at startup now.
func TestSensesBlocksAreValidatedAtStartup(t *testing.T) {
bad := map[string]string{
"media with no dir": `{"media":{"retention":"48h"}}`,
"negative budget": `{"media":{"dir":"/srv/media","max_total_bytes":-1}}`,
"blob over the budget": `{"media":{"dir":"/srv/media","max_bytes":100,"max_total_bytes":10}}`,
"vision with no media dir": `{"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`,
"vision endpoint typo": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"127.0.0.1:8081"}}`,
"vision on the wan": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://8.8.8.8:8081"}}`,
"vision, empty endpoint": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true}}`,
"capture with no store": `{"capture":{"enabled":true}}`,
"media with no dir": `{"media":{"retention":"48h"}}`,
"negative budget": `{"media":{"dir":"/srv/media","max_total_bytes":-1}}`,
"blob over the budget": `{"media":{"dir":"/srv/media","max_bytes":100,"max_total_bytes":10}}`,
"vision with no media dir": `{"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`,
"vision endpoint typo": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"127.0.0.1:8081"}}`,
"vision on the wan": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://8.8.8.8:8081"}}`,
"vision, empty endpoint": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true}}`,
"capture with no store": `{"capture":{"enabled":true}}`,
}
for name, body := range bad {
t.Run(name, func(t *testing.T) {
+22 -7
View File
@@ -247,9 +247,14 @@ type CaptureStartReq struct {
// which it stops itself; the caller tells him, so a forgotten recording is his
// own informed choice rather than a surprise.
type CaptureStartResp struct {
Label string `json:"label,omitempty"`
Started time.Time `json:"started"`
MaxSeconds int `json:"max_seconds"`
Label string `json:"label,omitempty"`
Started time.Time `json:"started"`
// Token names THIS session. Every later append, stop and discard has to
// carry it. Without it the recorder is addressed by "whatever is running
// now", and a client whose session already ended on the duration cap goes on
// appending its microphone into the next session someone else started.
Token string `json:"token"`
MaxSeconds int `json:"max_seconds"`
}
// CaptureAppendReq — one chunk of audio for the running session. Refused with
@@ -257,6 +262,9 @@ type CaptureStartResp struct {
// makes an ambient path impossible: audio arriving at an idle core is dropped on
// the floor, not buffered "just in case".
type CaptureAppendReq struct {
// Token from CaptureStartResp. A frame for a session that already ended is
// refused rather than folded into whatever is running now.
Token string `json:"token"`
Audio audio.Audio `json:"audio"`
}
@@ -275,15 +283,22 @@ type CaptureAppendResp struct {
// flag rather than a separate method so the client that says "stop" and the
// client that says "stop and forget" take the same path to the same session.
type CaptureStopReq struct {
Discard bool `json:"discard,omitempty"`
// Token from CaptureStartResp. Stopping by "whatever is running" lets a
// late client end a recording it never started.
Token string `json:"token"`
Discard bool `json:"discard,omitempty"`
}
// CaptureStopResp — the finished capture. BlobID is the stored WAV, kept under
// media.retention like any other blob and pruned with it.
//
// A response with a Transcript and an empty Summary is a degraded success: the
// words exist, only the model failed. A response with a BlobID and neither is
// the audio surviving a transcription failure — the same id can be run again.
// A response with a Transcript and an empty Summary is the normal shape, not a
// failure: summarising a long meeting is a map-reduce of minutes, so stop
// answers with the words and the summary note is written afterwards. Summary is
// filled in only when it happened to be ready. A response with a BlobID and no
// transcript is the audio surviving a transcription failure — the same id can be
// run again by hand off the blob before media.retention prunes it — there is no
// capture method that takes a blob id, so this is not a re-run the wire offers.
// Discarded is true when nothing was kept.
type CaptureStopResp struct {
BlobID string `json:"blob_id,omitempty"`
+4 -3
View File
@@ -500,9 +500,10 @@ func (c *Client) CaptureAppend(ctx context.Context, req CaptureAppendReq) (Captu
return r, nil
}
// CaptureStop ends the session. Slow — it transcribes and summarises the whole
// recording so pass a context with room. Set Discard to throw the recording
// away instead.
// CaptureStop ends the session. It transcribes the whole recording before
// answering, so pass a context with room; the summary is written afterwards by
// the daemon and is usually absent from the response. Set Discard to throw the
// recording away instead. Token comes from CaptureStart.
func (c *Client) CaptureStop(ctx context.Context, req CaptureStopReq) (CaptureStopResp, error) {
var r CaptureStopResp
if err := c.call(ctx, MethodCaptureStop, req, &r); err != nil {