Files
Maven/internal/capture/summarize.go
kami 612ca8cf1b grammar: the last two model calls that were still free text
The replier and the meeting summariser were the two call sites without a
GBNF. Both are exactly the shape that makes a Thinking variant answer with
its reasoning as prose, and neither had anything downstream that could
remove it.

The replier already parses {"response","mood"}, so it now sends the phraser's
grammar for that contract, exported once as phraser.ResponseGrammar so the
two definitions cannot drift.

The summariser stays text-in/text-out. The JSON wrapper is attached and
unwrapped in the daemon's Completer, so internal/capture is unchanged and a
Completer without a grammar still works.

The simulator told routing from phrasing by "has a grammar", which stopped
being true here; it now looks for the intent enum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 02:28:53 +04:00

271 lines
9.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.53 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}
}
// Both prompts ask for a JSON wrapper because the daemon's Completer attaches a
// grammar of that shape (summaryGrammar in cmd/mavend/capture.go) and unwraps it
// again before the text reaches this package. The wrapper is what keeps a
// Thinking-variant model from answering a summarisation prompt with its
// reasoning. Nothing here parses it: the map and reduce steps see plain prose,
// and a Completer without the grammar still works.
//
// 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 пунктов: о чём говорили, какие решения приняли, какие задачи назвали.
Без вступлений и выводов. Только по тексту — не придумывай того, чего в нём нет.
Если во фрагменте нет ничего содержательного, напиши одно слово: пусто.
Отвечай ТОЛЬКО объектом JSON с одним полем: {"summary": "..."}.`
// reducePrompt — the reduce step. Same rules, over the chunk summaries.
const reducePrompt = `Ниже — конспекты фрагментов одной встречи, по порядку.
Собери из них один короткий итог: о чём была встреча, какие решения приняли, что кому делать.
Не повторяйся, не придумывай, не добавляй вступлений.
Отвечай ТОЛЬКО объектом JSON с одним полем: {"summary": "..."}.`
// 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
}