5fede2acb7
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.
227 lines
9.9 KiB
Go
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)
|
|
}
|