Files
Maven/internal/phraser/transport.go
T
claude 5fede2acb7 phraser: one POST, not two copies of the same forty lines (V-397)
chatWithSystem and chatWithMessages each built the request, sent it, read
it and unwrapped the choice. Both now call postChat, which does that once.
The copies had already drifted: only chatWithMessages logged the raw
generation, so an unparseable nudge or query left nothing in the log to
read. Every path logs it now, tagged with the caller.
2026-08-06 01:20:20 +04:00

227 lines
9.9 KiB
Go

// phraser/transport.go — the wire to one llama-server: the request and
// response shapes, the GBNF that binds the model to the reply contract, and
// the single POST every phrasing path in this package goes through.
package phraser
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
)
type chatMsg struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatReq struct {
Model string `json:"model"`
Messages []chatMsg `json:"messages"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
// Grammar is llama-server's `grammar` field (GBNF). Same wiring as
// internal/llm.Req.Grammar. Empty ⇒ unconstrained sampling.
Grammar string `json:"grammar,omitempty"`
// RepeatPenalty — defence in depth behind the bounded grammar, not the fix
// for #531. This struct had no such field, so every caller through
// chatWithSystem ran at the server default of 1.0 while Replier.PhraseReply
// sent 1.3 through internal/llm and was protected by accident. Two wire
// structs that disagree about the sampler is the condition that let one
// path run away and the other not, and it should not survive as a
// difference nobody chose.
RepeatPenalty float64 `json:"repeat_penalty,omitempty"`
}
// phraseRepeatPenalty — matches Replier.PhraseReply, which has sent 1.3 since
// it was written. The value is not tuned here and is not what stops the
// whitespace loop; the bounded ws rule is. It is here so the two phrasing
// paths sample alike.
const phraseRepeatPenalty = 1.3
// responseGrammar — GBNF constraining the model to the documented phrasing
// contract and nothing else: {"response": "<text>", "mood": "<enum>"}.
//
// Without it a 0.8B answers roughly one chat turn in three with open reasoning
// as plain text ("Thinking Process:" …), which no tag-stripper can remove and
// which eats the token budget before the JSON closes. Modelled on
// routeGrammar in internal/router/llmrouter.go so the two read alike.
//
// text accepts ANY codepoint except the two JSON must escape and the control
// range — the replies are Russian, so an ASCII-only rule would make every reply
// empty. The escape rule is what lets the model close a string it opened with a
// quote inside. Length is bounded so a repetition loop truncates the field, not
// the JSON object.
//
// The control range is excluded because a raw newline inside a JSON string is
// not JSON (Vikunja #537). The class used to be `[^"\\]`, which let the model
// write a multi-line reply that satisfied the grammar and then failed
// json.Unmarshal with "invalid character '\n' in string literal" — the object
// starts with "{", so it came back as errBrokenJSON and the case answered with
// an empty string. Sixty of the failures in the 2026-08-05 temperature sweep
// were that one error, and it never once hit the token cap, which is why the
// truncation reading was wrong. The escape alternatives are llama.cpp's own
// json.gbnf: a model that wants a line break must write \n, which parses.
//
// That bound was 400 and 400 was too tight. Measured against Qwen3.5-0.8B: on
// "почему гром слышно позже молнии?" the reply came back exactly 400 characters
// long, cut mid-word ("Нужно записать и,"), at every token cap from 256 to 2048.
// So the token cap was never what stopped it — this rule was. 1000 characters is
// roughly six Russian sentences, still short enough to stop a repetition loop.
//
// ws is bounded for the same reason and it is the more expensive of the two.
// `*` let the model open the object and then satisfy ws with whitespace until
// max_tokens, which is 512 here: both interactive turns measured on 2026-08-04
// decoded exactly 512 tokens and spent 24-30 seconds doing it, all of it
// whitespace (Vikunja #531). Nothing on this path sends a repeat penalty —
// chatReq had no field for one — so the sampler never broke the loop. {0,4}
// was measured: three runs, three clean stops at 33 tokens, no penalty needed.
const responseGrammar = `
root ::= "{" ws "\"response\"" ws ":" ws string ws "," ws "\"mood\"" ws ":" ws mood ws "}"
mood ::= "\"neutral\"" | "\"happy\"" | "\"thinking\"" | "\"tired\"" | "\"confused\""
string ::= "\"" ([^"\\\x00-\x1F] | "\\" ["\\/bfnrt] | "\\u" [0-9a-fA-F]{4}){0,1000} "\""
ws ::= [ \t\n]{0,4}
`
// 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 {
if p.cfg.NoGrammar {
return ""
}
return responseGrammar
}
type chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
Reasoning string `json:"reasoning"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
// FinishReason — "stop" when the model chose to end, "length" when the
// token cap cut it off. Parsed since #531, where two turns ran to the
// 512-token cap and both happened to parse anyway: the grammar had
// already closed the JSON, so a truncated generation was indistinguishable
// from a good one at every layer above this struct.
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
// logIfTruncated says so when a generation stopped at the token cap.
//
// A cap hit is never routine. Either the model was looping, which is the #531
// shape, or the reply was genuinely longer than maxTokens, which means she cut
// herself off mid-sentence. Both are worth a line, and neither produced one
// before: the caller sees a parsed string and cannot tell.
func logIfTruncated(where, reason string, maxTokens int) {
if reason == "length" {
log.Printf("phraser: %s hit the %d-token cap (finish_reason=length) — the reply is truncated, or the model was looping", where, maxTokens)
}
}
// postChat sends one message array to the resident llama-server and returns
// what the model wrote, think block stripped.
//
// The only POST in the package. chatWithSystem and chatWithMessages each
// carried their own copy of these forty lines, identical down to the error
// strings, and the copies had already drifted twice: one logged the raw
// content and the other did not, and one labelled a truncation "chat" where
// the other said "chatWithSystem". Two transports mean two chances to
// configure the sampler differently, which is exactly how #531 happened.
//
// where names the calling path, for the truncation line and nothing else.
func (p *LLMPhraser) postChat(ctx context.Context, where string, msgs []chatMsg, maxTokens int) (string, error) {
base, release, err := p.acquire()
if err != nil {
return "", err
}
defer release()
body, err := json.Marshal(chatReq{
Messages: msgs,
Temperature: p.temperature(),
MaxTokens: maxTokens,
Grammar: p.grammar(),
RepeatPenalty: phraseRepeatPenalty,
})
if err != nil {
return "", fmt.Errorf("llm: marshal: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", base+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("llm: request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(httpReq)
if err != nil {
return "", fmt.Errorf("llm: post: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("llm: read: %w", err)
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("llm: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var cr chatResp
if err := json.Unmarshal(raw, &cr); err != nil {
return "", fmt.Errorf("llm: parse: %w", err)
}
if len(cr.Choices) == 0 {
return "", fmt.Errorf("llm: no choices in response")
}
logIfTruncated(where, cr.Choices[0].FinishReason, maxTokens)
content := cr.Choices[0].Message.Content
if content == "" {
content = cr.Choices[0].Message.ReasoningContent
}
// Every phrasing path logs its raw generation now. Only chatWithMessages
// did, so a nudge or a query that came back unparseable left nothing in the
// log to read (V-397).
log.Printf("phraser: %s raw content: %q", where, content)
return stripThink(content), nil
}
// chatWithSystem is the common shape: one system turn, one user turn.
//
// The workstation model first when it will take work, and silently: every
// caller of this helper is on the silent half of the degradation rule. It
// answering is not news, and it being asleep is not news either.
func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, maxTokens int) (string, error) {
if out, ok := p.remoteChat(ctx, system, user, maxTokens); ok {
return out, nil
}
return p.postChat(ctx, "chatWithSystem", []chatMsg{
{Role: "system", Content: system},
{Role: "user", Content: user},
}, maxTokens)
}
// chatWithMessages sends a full message array (system + history + current) to
// the LLM completion endpoint. Like chatWithSystem but for an arbitrary message
// slice — the caller owns the system prompt placement.
func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTokens int) (string, error) {
// Same silent preference as chatWithSystem, when the array is the shape
// llm.Req can carry: one system turn and one user turn. PhraseChat already
// folds the history into a single user message (some chat templates reject
// consecutive user turns), so today that is every call. A longer array goes
// to the resident model rather than get flattened here, because flattening a
// conversation is a decision its owner should make.
if len(msgs) == 2 && msgs[0].Role == "system" && msgs[1].Role == "user" {
if out, ok := p.remoteChat(ctx, msgs[0].Content, msgs[1].Content, maxTokens); ok {
return out, nil
}
}
return p.postChat(ctx, "chat", msgs, maxTokens)
}