612ca8cf1b
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>
271 lines
9.7 KiB
Go
271 lines
9.7 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}
|
||
}
|
||
|
||
// 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
|
||
}
|