Compare commits

..

2 Commits

Author SHA1 Message Date
claude c47881106e phraser: say "даже не знаю, что сказать" when there is nothing to say (V-397)
Review of #108: "поговорили." reads as a summary of a conversation that did
not happen. One exported constant now, so the Stub, the LLMPhraser fallback
and the daemon all say the same thing.

internal/voice/replier.go keeps its own copy — that is the separate replier
seam, not this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:50:46 +04:00
claude 9a70f7378b phraser: move errEmptyResponse next to its only caller (V-397)
It sat in world.go, which is about the workstation model; it is a phrasing
error and belongs in llmphraser.go. Also trims the PhraseQuery doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:31 +04:00
5 changed files with 24 additions and 16 deletions
+2 -1
View File
@@ -40,6 +40,7 @@ import (
"context"
"log"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -65,7 +66,7 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
log.Printf("voice: chat: %v", err)
}
if reply == "" {
return "поговорили."
return phraser.ChatFallback
}
return reply
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
)
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
// PhraseQuery keep the turn alive with canned text — "поговорили.", "не знаю.",
// PhraseQuery keep the turn alive with canned text — ChatFallback, "не знаю.",
// "вот что я нашла: …" — and every one of those is also a legitimate reply, so
// the text alone cannot say which happened. The error is the only signal, and
// before Vikunja #397 it was dropped: the talk scorer reported a full run with
@@ -28,7 +28,7 @@ func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) {
}{
{"chat", func() (string, error) {
return p.PhraseChat(context.Background(), "как дела", nil)
}, "поговорили."},
}, ChatFallback},
{"knowledge", func() (string, error) {
return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
}, "не знаю."},
+13 -7
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -26,6 +27,11 @@ import (
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
// errEmptyResponse — the server answered and said nothing. Separate from a
// transport failure: the model is up and produced no tokens, which is still not
// an answer and must not score as one.
var errEmptyResponse = errors.New("phraser: empty response from the model")
type LLMPhraser struct {
cfg Config
client *http.Client
@@ -428,10 +434,10 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver
}
// PhraseQuery prompts the LLM with the user's utterance and matching notes to
// compose a natural answer. On any LLM error it returns BOTH the fallback text
// ("вот что я нашла: <notes>", or "не знаю." with no notes) AND the error, so a
// caller that wants to keep the turn alive uses the text and a caller that is
// measuring counts the failure. Until Vikunja #397 the error was swallowed and a
// compose a natural answer. On any LLM error it returns the fallback text
// "вот что я нашла: <notes>", or "не знаю." with no notes — and the error
// together. The daemon uses the text and keeps the turn alive; a caller that is
// measuring counts the failure. Until Vikunja #397 the error was dropped, so a
// dead server scored as bad phrasing.
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
// Blank sources are no sources. A caller that hands over one empty string —
@@ -477,7 +483,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
// PhraseChat uses the LLM to respond conversationally, building a multi-turn
// message array from dialogue history + the current user utterance. On any LLM
// error it returns both "поговорили." and the error, on the same rule as
// error it returns both ChatFallback and the error, on the same rule as
// PhraseQuery: the fallback keeps the turn alive, the error stays visible.
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
sys := chatSystemPrompt(p.cfg.ContextBlock)
@@ -495,11 +501,11 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
resp, err := p.chatWithMessages(ctx, msgs, 768)
if err != nil {
return "поговорили.", fmt.Errorf("phrase chat: %w", err)
return ChatFallback, fmt.Errorf("phrase chat: %w", err)
}
text, _, perr := parseResponseMood(resp)
if perr != nil {
return "поговорили.", fmt.Errorf("phrase chat: %w", perr)
return ChatFallback, fmt.Errorf("phrase chat: %w", perr)
}
if text != "" {
return text, nil
+7 -1
View File
@@ -66,11 +66,17 @@ type Stub struct{}
// NewStub builds the floor phraser. no config — the Stub is stateless.
func NewStub() *Stub { return &Stub{} }
// ChatFallback — what she says on the chat path when the model gave her
// nothing to say. It replaced "поговорили.", which reads as a summary of a
// conversation that did not happen. Said out loud this one is an admission,
// which is what it is.
const ChatFallback = "даже не знаю, что сказать."
// PhraseChat returns a stub reply — the LLMPhraser replaces this with a
// prompted response from the model. The history parameter is accepted but
// ignored at the stub level (the production impl uses it for multi-turn).
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
return "поговорили.", nil
return ChatFallback, nil
}
// PhraseQuery returns a deterministic summary of the best matching notes.
-5
View File
@@ -37,11 +37,6 @@ type Remote interface {
// (docs/evals/2026-08-02-workstation-gemma4-12b.md).
var ErrNoWorldModel = errors.New("phraser: no world model available")
// errEmptyResponse — the server answered and said nothing. A separate error from
// a transport failure because it means the model is up and produced no tokens,
// which is still not an answer and must not score as one.
var errEmptyResponse = errors.New("phraser: empty response from the model")
// chatTemperature — what the phraser's own transport has always sampled at.
// Named so the remote path cannot drift from it silently. Whether 0.7 is right
// at all is Vikunja #402, and answering that here would hide a phrasing change