Risk tiers: take Hexis's tier for a Hexis capability, derive only for local tool rows #162

Closed
claude wants to merge 33 commits from task/523-risk-tiers-take-hexis-s-tier-for-a-hexis into task/449-destructive-confirm-policy-risk-tiers-no
12 changed files with 82 additions and 39 deletions
Showing only changes of commit b2521988e1 - Show all commits
+3 -2
View File
@@ -16,6 +16,7 @@ import (
"log"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
@@ -35,10 +36,10 @@ func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnS
}
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
return "не получилось отметить.", true
return phraser.Ack(phraser.FailAck, nil), true
}
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
return "отлично, отметила.", true
return phraser.Ack(phraser.AckNudge, nil), true
}
// ackFromFact — post-action hook, called once the turn's decision has been
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"strconv"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
@@ -14,7 +15,7 @@ import (
// it for recall, and let pattern detection propose a routine.
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
if !dec.Slots.HasKey {
return "не разобрала, что записать — попробуй иначе."
return phraser.Ack(phraser.FailFactUnparsed, nil)
}
// A question is never a fact about him (#470). "какая последняя версия
// языка Go?" used to land here, and the value stored was whatever the
@@ -62,7 +63,7 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
factID, err := h.api.WriteFact(ctx, req)
if err != nil {
log.Printf("voice: write fact: %v", err)
return "не получилось сохранить факт."
return phraser.Ack(phraser.FailFact, nil)
}
// Index the fact in long-term memory (best-effort, must not fail the fact
// write). Facts aren't in the notes table, so this is the only recall path
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"log"
"strconv"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -23,13 +24,13 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
if err != nil {
log.Printf("voice: embed note: %v", err)
return "не получилось сохранить заметку."
return phraser.Ack(phraser.FailNote, nil)
}
noteTs := h.now()
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
if err != nil {
log.Printf("voice: write note: %v", err)
return "не получилось сохранить заметку."
return phraser.Ack(phraser.FailNote, nil)
}
// Insert into long-term memory (best-effort, must not fail the note write).
// text/ts in the meta make a Search hit self-describing (see bestRecall).
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"log"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -21,13 +22,13 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
}
}
if !dec.Slots.HasTime {
return "не получилось разобрать время напоминания."
return phraser.Ack(phraser.FailReminderTime, nil)
}
}
payload := `{"text":` + jsonString(dec.Utterance) + `}`
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
log.Printf("voice: create reminder: %v", err)
return "не получилось поставить напоминание."
return phraser.Ack(phraser.FailReminder, nil)
}
return ""
}
+5 -4
View File
@@ -5,6 +5,7 @@ import (
"log"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tasks"
@@ -40,18 +41,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
})
if err != nil {
log.Printf("voice: capture task: %v", err)
return "не получилось записать задачу.", true
return phraser.Ack(phraser.FailTask, nil), true
}
if resp.Promoted {
// It was a candidate Maven derived from something she read, and he has
// now said it himself. Saying "уже в списке" here would be answering a
// confirmation with a shrug.
return "поняла, беру в работу: " + cap.Text, true
return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
}
if !resp.Created {
return "это уже в списке.", true
return phraser.Ack(phraser.AckTaskDuplicate, nil), true
}
return "записала: " + cap.Text, true
return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
}
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -108,7 +109,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
if !ok {
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
}
if !strings.Contains(reply, "не получилось") {
if !phraser.IsAck(phraser.FailTask, nil, reply) {
t.Errorf("reply = %q, want an honest failure", reply)
}
}
+4 -3
View File
@@ -11,6 +11,7 @@ import (
"unicode"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
)
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
@@ -32,10 +33,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
return "", false
}
val := "false"
reply := "тихий режим выключен."
reply := phraser.Ack(phraser.AckQuietOff, nil)
if on {
val = "true"
reply = "тихий режим включён. буду реже напоминать."
reply = phraser.Ack(phraser.AckQuietOn, nil)
}
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: h.now(),
@@ -46,7 +47,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
Confidence: 1.0,
}); err != nil {
log.Printf("voice: write quiet_hours: %v", err)
return "не получилось переключить тихий режим.", true
return phraser.Ack(phraser.FailQuiet, nil), true
}
return reply, true
}
+13 -2
View File
@@ -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"
)
@@ -29,12 +30,12 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error")
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error")
}
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
r := newLLMReplier(stubCompleter{out: ""}, nil)
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
}
func TestLLMReplierClarifyUsesStub(t *testing.T) {
@@ -42,6 +43,16 @@ func TestLLMReplierClarifyUsesStub(t *testing.T) {
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
}
// assertAck — the stub picks between variants now, so two calls to it are not
// expected to match. What must hold is that the reply is a line that entry can
// produce, which is the same claim without pinning one wording.
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
t.Helper()
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
t.Errorf("on %s: got %q, want a %q line", what, got, key)
}
}
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
t.Helper()
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
)
@@ -51,10 +52,10 @@ func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src tu
}
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
return "не получилось отложить.", true
return phraser.Ack(phraser.FailSnooze, nil), true
}
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
return "хорошо, вернусь к этому позже.", true
return phraser.Ack(phraser.AckSnooze, nil), true
}
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
+15 -8
View File
@@ -8,13 +8,13 @@ import (
"github.com/kami/maven/internal/phraser"
)
// TestFallbackPersona scores every line in fallbacks_ru_v1.json on the persona
// checks the nudges are already held to. These lines are heard out loud, and
// they live in a JSON file now, so a reworded variant that says "рад" or "вы"
// would otherwise reach him with nothing between it and the speaker.
// TestFallbackPersona scores every line in fallbacks_ru_v1.json and
// ack_ru_v1.json on the persona checks the nudges already pass. These lines are
// heard out loud and they live in a JSON file now, so a reworded variant that
// says "рад" or "вы" would otherwise reach him with nothing in between.
//
// Only the persona checks run. Mood and topic belong to a nudge, and these are
// not nudges: they are what she says when there is no answer.
// not nudges.
func TestFallbackPersona(t *testing.T) {
fb, err := phraser.LoadFallbacks(rand.NewSource(20260804))
if err != nil {
@@ -24,13 +24,20 @@ func TestFallbackPersona(t *testing.T) {
CheckLang: true, CheckFeminine: true, CheckHisGender: true,
CheckAddress: true, CheckCringe: true, CheckLength: true,
}
variants := fb.Variants()
ack, err := phraser.LoadAcks(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadAcks: %v", err)
}
variants := append(fb.Variants(), ack.Variants()...)
if len(variants) == 0 {
t.Fatal("no variants — the file loaded empty")
}
for _, v := range variants {
// {sources} stands for his own notes and never carries persona of its own.
body := strings.ReplaceAll(v, "{sources}", "два литра")
// The placeholders stand for his own words and carry no persona.
body := v
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}"} {
body = strings.ReplaceAll(body, ph, "вода")
}
for _, r := range RunChecks(Case{}, body, "neutral") {
if persona[r.Name] && !r.Pass {
t.Errorf("%q fails %s: %s", v, r.Name, r.Detail)
+17
View File
@@ -55,3 +55,20 @@ func TestFallbacksDoNotRepeat(t *testing.T) {
prev = got
}
}
// The acknowledgements load, fill his words into the frame, and answer from the
// floor when the file is gone.
func TestAcksLoad(t *testing.T) {
a, err := LoadAcks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadAcks: %v", err)
}
got := a.Say(AckFactValue, map[string]string{"key": "вода", "value": "2л"})
if !strings.Contains(got, "вода") || !strings.Contains(got, "2л") {
t.Errorf("Say(%s) = %q, want his key and value in it", AckFactValue, got)
}
var nilAcks *Acks
if got := nilAcks.Say(AckNote, nil); got != ackFloor[AckNote] {
t.Errorf("nil Acks said %q, want the floor %q", got, ackFloor[AckNote])
}
}
+11 -11
View File
@@ -25,7 +25,10 @@
// the daemon seam (config wiring, no CoreAPI or voice-package change).
package voice
import "github.com/kami/maven/internal/router"
import (
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
// Replier — the reactive reply phrasing seam. The daemon's reactive handler
// calls Reply with the router's Decision; the impl produces a terse reply
@@ -60,27 +63,24 @@ func (s *StubReplier) Reply(d router.Decision) string {
if !d.Slots.HasFn {
return "не могу это сделать — не разобрала действие."
}
return "ок, записала действие: " + d.Slots.Fn
return phraser.Ack(phraser.AckAct, map[string]string{"fn": d.Slots.Fn})
case router.IntentReminder:
if d.Slots.HasTime {
return "напомню."
}
return "напомню."
return phraser.Ack(phraser.AckReminder, nil)
case router.IntentFact:
if d.Slots.HasKey {
if d.Slots.Value != "" {
return "отметила: " + d.Slots.Key + " = " + d.Slots.Value
return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value})
}
return "отметила: " + d.Slots.Key
return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key})
}
return "записала факт."
return phraser.Ack(phraser.AckFact, nil)
case router.IntentNote:
return "сохранила заметку."
return phraser.Ack(phraser.AckNote, nil)
case router.IntentQuery:
return "поискала в заметках — ничего не нашла."
case router.IntentChat:
return "поговорили." // stub — LLMReplier replaces this
default:
return "приняла."
return phraser.Ack(phraser.AckGeneric, nil)
}
}