aa1a26532c
Maven can record a meeting when she is told to, transcribe it through the STT she already has, and write a summary note. The audio lives in the blob store #252 introduced, under the same retention loop. Nothing here listens. Recorder.Append is the only way audio enters and it refuses every frame unless someone explicitly started a session, so audio arriving at an idle core is dropped rather than buffered. The plan document asked for a keyword trigger ("maven record" heard in the room) and that is refused: noticing a keyword means listening to the room, which is the one behaviour this capability must not have. Off unless configured twice over. No media block means nowhere to keep audio, no capture block means no recorder, and in either case the four IPC methods answer ErrUnknownMethod. On an unconfigured box there is no wire path that begins a recording at all. A forgotten session ends itself at max_minutes, checked on every append, and the audio collected before the cap is kept. Stop with discard set is what "забудь, не записывай" maps to and it leaves nothing behind. The verbatim transcript is not saved unless save_transcript says so; the summary is. Long audio against n_ctx 4096 is handled by map-reduce over 3000-rune windows rather than by truncation, because a truncated meeting summary reads as complete and is not. Transcription is windowed at five minutes so the whisper worker stays responsive to the voice path. No second STT: internal/capture takes the stt.Transcriber the voice path already holds. Capture with voice off is refused rather than degraded, since hours of unreadable audio of other people is worse than no recording. The three write methods are AuthWrite, not AuthStepUp: step-up needs a passkey gesture the voice path cannot make, which would leave "запиши встречу" impossible by voice. capture_status is AuthRead. make build and make test both pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
262 lines
9.0 KiB
Go
262 lines
9.0 KiB
Go
package capture
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
// DefaultChunkRunes — how much transcript goes into one summarisation prompt.
|
||
//
|
||
// The resident model runs at n_ctx 4096 and is a Thinking variant, so reasoning
|
||
// tokens need room too. Russian runs roughly 2.5–3 characters per token on a
|
||
// Qwen tokenizer, so 3000 runes is about 1100 tokens of transcript, leaving the
|
||
// prompt, the persona block, the reasoning and the answer comfortable space.
|
||
// This is the same reasoning internal/crawl used to land on 4000 runes, tightened
|
||
// because a meeting transcript is denser in named entities than a web page and
|
||
// the reduce step has to fit several summaries at once.
|
||
const DefaultChunkRunes = 3000
|
||
|
||
// DefaultMaxChunks — how many windows one meeting may be summarised in. Forty
|
||
// chunks at 3000 runes is roughly a two-hour meeting, which is MaxDuration; past
|
||
// that the transcript is truncated and the summary says so, because forty-one
|
||
// sequential model calls on this box is half an hour of work nobody is waiting
|
||
// through.
|
||
const DefaultMaxChunks = 40
|
||
|
||
// ErrNoSummary — the model returned nothing usable for every chunk.
|
||
var ErrNoSummary = errors.New("capture: model produced no summary")
|
||
|
||
// Completer is the one thing the summarizer needs from a model: text in, text
|
||
// out. It is an interface rather than an *llm.Client so this package stays pure
|
||
// and testable, and so the daemon can pass whatever it already has.
|
||
type Completer interface {
|
||
Complete(ctx context.Context, system, user string) (string, error)
|
||
}
|
||
|
||
// Summarizer turns a transcript into something worth reading. It is map-reduce
|
||
// and nothing cleverer: summarise each window, then summarise the summaries.
|
||
//
|
||
// Truncation was the alternative and is rejected. A truncated meeting summary
|
||
// reads as complete and is not, which is worse than no summary at all — he would
|
||
// act on it.
|
||
type Summarizer struct {
|
||
llm Completer
|
||
chunkRunes int
|
||
maxChunks int
|
||
// context is the persona/context block the daemon prepends to every prompt,
|
||
// or empty. Passed in rather than built here so this package does not import
|
||
// internal/persona and the feminine self-reference rules stay in one place.
|
||
context func() string
|
||
}
|
||
|
||
// NewSummarizer wires a summarizer. llm nil ⇒ nil Summarizer, which Recorder
|
||
// treats as "transcript only", the honest degradation with no llama-server.
|
||
// chunkRunes ≤ 0 ⇒ DefaultChunkRunes; maxChunks ≤ 0 ⇒ DefaultMaxChunks.
|
||
func NewSummarizer(llm Completer, chunkRunes, maxChunks int, contextBlock func() string) *Summarizer {
|
||
if llm == nil {
|
||
return nil
|
||
}
|
||
if chunkRunes <= 0 {
|
||
chunkRunes = DefaultChunkRunes
|
||
}
|
||
if maxChunks <= 0 {
|
||
maxChunks = DefaultMaxChunks
|
||
}
|
||
if contextBlock == nil {
|
||
contextBlock = func() string { return "" }
|
||
}
|
||
return &Summarizer{llm: llm, chunkRunes: chunkRunes, maxChunks: maxChunks, context: contextBlock}
|
||
}
|
||
|
||
// chunkPrompt — the map step. Deliberately plain: this is not Maven speaking to
|
||
// him, it is a model condensing text, so there is no first person in it at all
|
||
// and therefore nothing for the persona's gender rules to get wrong. The reply
|
||
// she gives him afterwards is phrased by the ordinary replier, which does carry
|
||
// the persona.
|
||
const chunkPrompt = `Ты обрабатываешь фрагмент расшифровки разговора.
|
||
Сожми его до 2-4 пунктов: о чём говорили, какие решения приняли, какие задачи назвали.
|
||
Без вступлений и выводов. Только по тексту — не придумывай того, чего в нём нет.
|
||
Если во фрагменте нет ничего содержательного, ответь одним словом: пусто.`
|
||
|
||
// reducePrompt — the reduce step. Same rules, over the chunk summaries.
|
||
const reducePrompt = `Ниже — конспекты фрагментов одной встречи, по порядку.
|
||
Собери из них один короткий итог: о чём была встреча, какие решения приняли, что кому делать.
|
||
Не повторяйся, не придумывай, не добавляй вступлений.`
|
||
|
||
// emptyMarker — what the map step answers for a chunk with nothing in it. Such
|
||
// chunks are dropped before the reduce step rather than padding it with noise.
|
||
const emptyMarker = "пусто"
|
||
|
||
// Summarize returns the summary and the number of chunks the transcript was
|
||
// split into. One chunk means it fit in a single prompt and the reduce step was
|
||
// skipped, which is the common case for a short meeting and saves a model call.
|
||
func (s *Summarizer) Summarize(ctx context.Context, label, transcript string) (string, int, error) {
|
||
if s == nil {
|
||
return "", 0, ErrDisabled
|
||
}
|
||
chunks := ChunkText(transcript, s.chunkRunes)
|
||
if len(chunks) == 0 {
|
||
return "", 0, ErrEmptyCapture
|
||
}
|
||
truncated := false
|
||
if len(chunks) > s.maxChunks {
|
||
chunks = chunks[:s.maxChunks]
|
||
truncated = true
|
||
}
|
||
|
||
system := s.context() + chunkPrompt
|
||
parts := make([]string, 0, len(chunks))
|
||
for i, c := range chunks {
|
||
out, err := s.llm.Complete(ctx, system, c)
|
||
if err != nil {
|
||
return "", len(chunks), fmt.Errorf("chunk %d/%d: %w", i+1, len(chunks), err)
|
||
}
|
||
out = strings.TrimSpace(out)
|
||
if out == "" || strings.EqualFold(out, emptyMarker) {
|
||
continue
|
||
}
|
||
parts = append(parts, out)
|
||
}
|
||
if len(parts) == 0 {
|
||
return "", len(chunks), ErrNoSummary
|
||
}
|
||
|
||
summary := parts[0]
|
||
if len(parts) > 1 {
|
||
joined := strings.Join(parts, "\n\n")
|
||
reduced, err := s.llm.Complete(ctx, s.context()+reducePrompt, joined)
|
||
if err != nil {
|
||
// The per-chunk summaries are real work; hand them over rather than
|
||
// losing them to a failure in the last step.
|
||
return joined, len(chunks), fmt.Errorf("reduce: %w", err)
|
||
}
|
||
if r := strings.TrimSpace(reduced); r != "" {
|
||
summary = r
|
||
} else {
|
||
summary = joined
|
||
}
|
||
}
|
||
if label != "" {
|
||
summary = label + "\n\n" + summary
|
||
}
|
||
if truncated {
|
||
// Said in the note, not swallowed: a summary that silently covers the
|
||
// first hour of a three-hour meeting is the failure mode this guards.
|
||
summary += fmt.Sprintf("\n\n(расшифровка обрезана: обработано %d фрагментов из большего числа)", s.maxChunks)
|
||
}
|
||
return summary, len(chunks), nil
|
||
}
|
||
|
||
// ChunkText splits text into windows of at most maxRunes runes, cutting on
|
||
// sentence boundaries where it can and on a word boundary otherwise. Exported
|
||
// because it is the part worth testing on its own and the part a future
|
||
// transcript viewer will want.
|
||
//
|
||
// A sentence longer than maxRunes (a transcript with no punctuation at all,
|
||
// which whisper does produce) is cut on whitespace rather than dropped or run
|
||
// past the limit.
|
||
func ChunkText(text string, maxRunes int) []string {
|
||
text = strings.TrimSpace(text)
|
||
if text == "" {
|
||
return nil
|
||
}
|
||
if maxRunes <= 0 {
|
||
maxRunes = DefaultChunkRunes
|
||
}
|
||
if len([]rune(text)) <= maxRunes {
|
||
return []string{text}
|
||
}
|
||
|
||
var out []string
|
||
var cur []rune
|
||
flush := func() {
|
||
if s := strings.TrimSpace(string(cur)); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
cur = cur[:0]
|
||
}
|
||
for _, sent := range splitSentences(text) {
|
||
sr := []rune(sent)
|
||
if len(sr) > maxRunes {
|
||
// Oversized sentence: emit what is buffered, then cut this one on
|
||
// word boundaries.
|
||
flush()
|
||
for _, piece := range splitWords(sr, maxRunes) {
|
||
out = append(out, piece)
|
||
}
|
||
continue
|
||
}
|
||
if len(cur)+len(sr) > maxRunes {
|
||
flush()
|
||
}
|
||
cur = append(cur, sr...)
|
||
}
|
||
flush()
|
||
return out
|
||
}
|
||
|
||
// splitSentences cuts on sentence-ending punctuation followed by a space,
|
||
// keeping the punctuation with the sentence it ends. Good enough for a
|
||
// transcript: whisper emits periods and question marks, and being wrong about an
|
||
// abbreviation costs a slightly uneven chunk, nothing more.
|
||
func splitSentences(text string) []string {
|
||
runes := []rune(text)
|
||
var out []string
|
||
start := 0
|
||
for i := 0; i < len(runes); i++ {
|
||
if runes[i] != '.' && runes[i] != '!' && runes[i] != '?' && runes[i] != '\n' {
|
||
continue
|
||
}
|
||
// Consume a run of punctuation ("?!", "...") so it stays together.
|
||
j := i
|
||
for j+1 < len(runes) && isSentenceEnd(runes[j+1]) {
|
||
j++
|
||
}
|
||
if j+1 < len(runes) && !unicode.IsSpace(runes[j+1]) {
|
||
i = j
|
||
continue
|
||
}
|
||
end := j + 1
|
||
for end < len(runes) && unicode.IsSpace(runes[end]) {
|
||
end++
|
||
}
|
||
out = append(out, string(runes[start:end]))
|
||
start = end
|
||
i = end - 1
|
||
}
|
||
if start < len(runes) {
|
||
out = append(out, string(runes[start:]))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func isSentenceEnd(r rune) bool {
|
||
return r == '.' || r == '!' || r == '?'
|
||
}
|
||
|
||
// splitWords cuts an oversized run on whitespace, falling back to a hard cut
|
||
// when a single "word" is itself longer than the limit.
|
||
func splitWords(runes []rune, maxRunes int) []string {
|
||
var out []string
|
||
for len(runes) > maxRunes {
|
||
cut := maxRunes
|
||
for cut > 0 && !unicode.IsSpace(runes[cut]) {
|
||
cut--
|
||
}
|
||
if cut == 0 {
|
||
cut = maxRunes
|
||
}
|
||
if s := strings.TrimSpace(string(runes[:cut])); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
runes = runes[cut:]
|
||
}
|
||
if s := strings.TrimSpace(string(runes)); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
return out
|
||
}
|