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 }