diff --git a/cmd/mavend/capture.go b/cmd/mavend/capture.go index 2393976..604f96a 100644 --- a/cmd/mavend/capture.go +++ b/cmd/mavend/capture.go @@ -31,9 +31,11 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "log" + "strings" "sync" "time" @@ -54,16 +56,65 @@ import ( // asks Maven to stop recording gets the transcript back in seconds. const captureSummaryTimeout = 20 * time.Minute +// summaryGrammar — GBNF pinning a summarisation call to one JSON object holding +// the summary and nothing else. Same reasoning as responseGrammar and memeval's +// evalGrammar: the resident model is a Thinking variant, and a summarisation +// prompt is exactly the shape that invites it to answer with its reasoning as +// plain text. Demanding JSON leaves the reasoning nowhere to go. +// +// The bound is 2000 characters, twice the phraser's, because a reduce step over +// a two-hour meeting is a paragraph and not a sentence. Newlines are escaped by +// the escape rule, so the bullet list the prompt asks for survives the wrapper. +const summaryGrammar = ` +root ::= "{" ws "\"summary\"" ws ":" ws string ws "}" +string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,2000} "\"" +ws ::= [ \t\n]* +` + // llmCompleter adapts *llm.Client to capture.Completer. The pure package names // the two strings it needs and stays free of the llm request struct; the client // itself is the swap-aware one from llmClientFor, so a model swap re-points it. +// +// The JSON wrapper lives here, not in internal/capture: that package is +// text-in/text-out by design, and the map/reduce steps still see plain prose. type llmCompleter struct { c *llm.Client maxTokens int } func (l llmCompleter) Complete(ctx context.Context, system, user string) (string, error) { - return l.c.Complete(ctx, llm.Req{System: system, User: user, MaxTokens: l.maxTokens}) + out, err := l.c.Complete(ctx, llm.Req{ + System: system, + User: user, + Grammar: summaryGrammar, + MaxTokens: l.maxTokens, + }) + if err != nil { + return "", err + } + return unwrapSummary(out), nil +} + +// unwrapSummary takes the summary out of the JSON object the grammar produced. +// Anything that does not parse is returned as-is: an operator running without a +// grammar, or a llama-server too old to honour one, gets the plain text it used +// to get rather than an empty meeting summary. +func unwrapSummary(raw string) string { + s := stripThink(strings.TrimSpace(raw)) + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start < 0 || end <= start { + return s + } + var parsed struct { + Summary string `json:"summary"` + } + if err := json.Unmarshal([]byte(s[start:end+1]), &parsed); err != nil { + return s + } + // An empty field is the model saying nothing, so hand back nothing. Returning + // the raw object here would write `{"summary":""}` into his notes. + return strings.TrimSpace(parsed.Summary) } // captureWiring — the recorder plus what it needs to write the result down. diff --git a/cmd/mavend/capture_test.go b/cmd/mavend/capture_test.go index e09bdad..56c688d 100644 --- a/cmd/mavend/capture_test.go +++ b/cmd/mavend/capture_test.go @@ -116,3 +116,29 @@ func TestStopReturnsTranscriptAndNotesItWithoutASummary(t *testing.T) { t.Fatalf("the meeting left no note behind: %+v", notes) } } + +// The summary path is JSON-wrapped by summaryGrammar, and internal/capture must +// keep seeing plain prose. These cover the wrapper and every way it can be +// absent or broken, because a meeting summary is written once and not retried. +func TestUnwrapSummary(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"grammar output", `{"summary": "решили купить насос"}`, "решили купить насос"}, + {"multiline field", `{"summary": "- насос\n- бюджет"}`, "- насос\n- бюджет"}, + {"empty marker survives", `{"summary": "пусто"}`, "пусто"}, + {"empty field says nothing", `{"summary": ""}`, ""}, + {"thinking prefix", "hm\n{\"summary\": \"итог\"}", "итог"}, + {"no grammar, plain prose", "решили купить насос", "решили купить насос"}, + {"broken json falls back", `{"summary": "обрыв`, `{"summary": "обрыв`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := unwrapSummary(c.in); got != c.want { + t.Errorf("unwrapSummary(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go index 7b73e42..db6110d 100644 --- a/cmd/mavend/replier_llm.go +++ b/cmd/mavend/replier_llm.go @@ -8,6 +8,7 @@ import ( "github.com/kami/maven/internal/llm" "github.com/kami/maven/internal/persona" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/voice" ) @@ -47,6 +48,7 @@ func (r *llmReplier) Reply(d router.Decision) string { out, err := r.c.Complete(ctx, llm.Req{ System: persona.Prepend(r.block, replySystem), User: replyContext(d), + Grammar: phraser.ResponseGrammar, MaxTokens: 512, RepeatPenalty: 1.3, }) diff --git a/cmd/mavend/replier_llm_test.go b/cmd/mavend/replier_llm_test.go index 6e1f08c..5e084f9 100644 --- a/cmd/mavend/replier_llm_test.go +++ b/cmd/mavend/replier_llm_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/kami/maven/internal/llm" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/voice" ) @@ -67,3 +68,20 @@ var errTestLLMDown = errTest("llm down") type errTest string func (e errTest) Error() string { return string(e) } + +// grammarRecorder captures the request so the grammar can be asserted on. +type grammarRecorder struct{ req llm.Req } + +func (g *grammarRecorder) Complete(_ context.Context, r llm.Req) (string, error) { + g.req = r + return `{"response":"записала","mood":"neutral"}`, nil +} + +func TestLLMReplierCarriesTheResponseGrammar(t *testing.T) { + rec := &grammarRecorder{} + r := newLLMReplier(rec, nil) + r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}}) + if rec.req.Grammar != phraser.ResponseGrammar { + t.Errorf("grammar = %q, want phraser.ResponseGrammar", rec.req.Grammar) + } +} diff --git a/cmd/mavend/simulator_test.go b/cmd/mavend/simulator_test.go index 04a314c..f790d43 100644 --- a/cmd/mavend/simulator_test.go +++ b/cmd/mavend/simulator_test.go @@ -328,7 +328,10 @@ func (s *scriptedLLM) Complete(_ context.Context, r llm.Req) (string, error) { s.mu.Lock() defer s.mu.Unlock() s.calls = append(s.calls, r) - routing := r.Grammar != "" + // A grammar no longer separates the two contracts — the replier carries one + // too since phraser.ResponseGrammar was attached to it. Only the router's + // grammar names the intent enum, so that is what tells them apart. + routing := strings.Contains(r.Grammar, "intent") for _, e := range s.entries { if e.Match != "" && !strings.Contains(strings.ToLower(r.User), strings.ToLower(e.Match)) { continue diff --git a/internal/capture/summarize.go b/internal/capture/summarize.go index d539472..9dfa399 100644 --- a/internal/capture/summarize.go +++ b/internal/capture/summarize.go @@ -71,6 +71,13 @@ func NewSummarizer(llm Completer, chunkRunes, maxChunks int, contextBlock func() 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 @@ -79,12 +86,14 @@ func NewSummarizer(llm Completer, chunkRunes, maxChunks int, contextBlock func() 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. diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index bd8855d..51697d3 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -603,6 +603,11 @@ string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,1000} "\"" ws ::= [ \t\n]* ` +// ResponseGrammar exposes responseGrammar to the other callers that emit the +// same {"response","mood"} contract — cmd/mavend's reactive replier, which is +// parsed by the same two fields. One definition, so the two cannot drift. +const ResponseGrammar = responseGrammar + // grammar returns the GBNF to attach to a phrasing request, or "" when the // operator turned it off. func (p *LLMPhraser) grammar() string {