901354002e
Sweep of internal/capture and internal/dialogue. Both packages were already in good shape, so this is four small corrections rather than a rework. windowBytes truncated the STT window to whole seconds. A sub-second window therefore came out as zero bytes, which transcribeFile reads as "no window" and answers by handing the transcriber the whole meeting in one call. The multiplication is now done in float, so a fractional window is a real window. Peek's comment claimed expired entries below the top are left alone. The code deletes the whole stack, which is what Pop and TakeExpired both document and what the clock argues for. The comment now says so, and it names the ordering the silent drop depends on: TakeExpired has to run before Peek on a turn or the expiry notice becomes unreachable. The two store default TTLs were unnamed literals. They are DefaultClarifyTTL and DefaultSessionTTL now, next to DefaultMaxAttempts, and the comment on each says why the clarify one is the shorter of the two. PendingQuestion was not gofmt clean and ChunkText copied a slice one element at a time. Full suite passes with -race. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
269 lines
9.6 KiB
Go
269 lines
9.6 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()
|
||
out = append(out, splitWords(sr, maxRunes)...)
|
||
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
|
||
}
|