Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccc5cba2a3 | |||
| 89d83c0b11 | |||
| a97f554802 | |||
| f4de2fc5e1 | |||
| eef5d4da4f | |||
| 09f1696fce | |||
| 80f7322294 | |||
| fa5aebfbe4 | |||
| 59cec63da1 | |||
| 02e8786695 | |||
| 0272dc9d89 | |||
| a788ca3915 | |||
| e9ff2c4912 |
@@ -103,6 +103,35 @@ but a large part of the jump is that failure now degrades into Russian instead o
|
||||
The two remaining failures: one `"..."` recurrence (`routine-stretch`) and one meal nudge
|
||||
that never says food.
|
||||
|
||||
## Tried and reverted: an example-led nudge prompt (#393)
|
||||
|
||||
The idea was that a 0.8B copies examples better than it follows rules, so the nudge prompt
|
||||
was rewritten to lead with five on-topic examples (water, break, pills, morning, service) and
|
||||
the prose rules were compressed to pay for the tokens: 1190 chars down to 986.
|
||||
|
||||
It measured **worse**, three runs each side, same llama-server, same fixture:
|
||||
|
||||
| run | before | after |
|
||||
|---|---|---|
|
||||
| 1 | 12/15 (address 14) | 11/15 (address 13) |
|
||||
| 2 | 13/15 (address 15) | 12/15 (address 15) |
|
||||
| 3 | 14/15 (address 15) | 11/15 (address 12) |
|
||||
|
||||
`feminine` and `hisgender` were 15/15 on all six runs, so they measure nothing here. The
|
||||
regression is all in `address`: 44/45 before, 40/45 after. Formal "вы"/"ваше" and plural
|
||||
imperatives came back, and so did `"..."`.
|
||||
|
||||
Two likely causes, both about the same thing — **examples do not carry a prohibition**. The
|
||||
old prompt spent a whole sentence on «говоришь на "ты", в единственном числе»; the new one
|
||||
demoted that to one item in a long "никогда" list, and the model stopped obeying it. And
|
||||
making the examples on-topic let their *wording* leak: a break case came back as
|
||||
«Вы давно не пили воду. Выпей стакан.» — the water example, verbatim, in the wrong slot.
|
||||
That is exactly the failure the laundry/laptop examples were chosen to avoid.
|
||||
|
||||
Change reverted. What survives is the measurement: a rule the model must obey needs its own
|
||||
sentence, and examples must stay off-topic. Also note the before side alone spans 12–14 of
|
||||
15 — this fixture cannot resolve anything smaller than about three cases.
|
||||
|
||||
## Broken, found, not fixed
|
||||
|
||||
1. ~~**`checkFeminine` only catches half the constraint.**~~ **Fixed** (#381). It scanned for
|
||||
|
||||
@@ -29,7 +29,7 @@ func newLLMReplier(c completer) *llmReplier {
|
||||
return &llmReplier{c: c, stub: voice.NewStubReplier()}
|
||||
}
|
||||
|
||||
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
|
||||
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
|
||||
Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"}
|
||||
Никогда не пиши "..." в поле response.`
|
||||
|
||||
|
||||
@@ -137,9 +137,10 @@ func NewDispatcher(cfg Config) *Dispatcher {
|
||||
// picks for (severity, presence), sends via the matching sink, and records
|
||||
// one nudge row per successful send. returns the dispatches (one per channel).
|
||||
//
|
||||
// a Drop channel = no send, no record (the nudge was suppressed by routing,
|
||||
// not by a failure — "a missed water nudge is noise"). a nil sink = channel
|
||||
// not wired, skip silently. a send error stops the dispatch and returns what
|
||||
// a Drop channel = no send (the nudge was suppressed by routing, not by a
|
||||
// failure — "a missed water nudge is noise"), but it does leave a 'dropped'
|
||||
// outbox row so the suppression is visible. a nil sink = channel not wired,
|
||||
// skip silently. a send error stops the dispatch and returns what
|
||||
// got through — the daemon decides whether to retry.
|
||||
func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now time.Time) ([]Dispatch, error) {
|
||||
c := pn.Candidate
|
||||
@@ -148,6 +149,16 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
|
||||
for i := 0; i < len(channels); i++ {
|
||||
ch := channels[i]
|
||||
if ch == ChannelDrop {
|
||||
// the routing table suppressed this nudge on purpose (a care nudge
|
||||
// while you're away is noise). that stays — but it must not be
|
||||
// invisible, or "she dropped it" and "the rule never fired" look
|
||||
// the same afterwards. no nudges row: that table feeds the
|
||||
// ignored_rate signal, and a nudge nobody could see must not
|
||||
// count as ignored.
|
||||
id := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, pn.Summary, now)
|
||||
d.completeOutbox(ctx, id, store.DeliveryDropped, now)
|
||||
log.Printf("dispatcher: dropped %s (sev%d, presence=%s) — routing table suppressed it",
|
||||
c.Rule.Name, c.Severity, c.State.Presence)
|
||||
continue
|
||||
}
|
||||
s := Sendable{
|
||||
@@ -396,6 +407,13 @@ func messageForChannel(s Sendable) string {
|
||||
if !isAway(s.Channel) {
|
||||
return s.Body
|
||||
}
|
||||
return AwayMessage(s)
|
||||
}
|
||||
|
||||
// AwayMessage — the only text an off-box channel may ever carry. Exported so
|
||||
// the away sinks share this one rule instead of each inventing a fallback: the
|
||||
// summary if we have one, otherwise a fixed generic line. Never the body.
|
||||
func AwayMessage(s Sendable) string {
|
||||
if s.Summary != "" {
|
||||
return s.Summary
|
||||
}
|
||||
|
||||
@@ -37,10 +37,12 @@ func TestVoiceNoSessionFallthroughLeavesOutboxTrail(t *testing.T) {
|
||||
[]string{"voice", "ntfy"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||
{"sev4 falls through to telegram", loop.Sev4,
|
||||
[]string{"voice", "telegram"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||
{"sev1 does not fall through", loop.Sev1,
|
||||
[]string{"voice"}, []string{store.DeliveryFailed}},
|
||||
{"sev2 does not fall through", loop.Sev2,
|
||||
[]string{"voice"}, []string{store.DeliveryFailed}},
|
||||
// care severities still don't reach an away channel; since #370 the
|
||||
// drop itself is a visible row instead of nothing.
|
||||
{"sev1 drops instead of falling through", loop.Sev1,
|
||||
[]string{"voice", "drop"}, []string{store.DeliveryFailed, store.DeliveryDropped}},
|
||||
{"sev2 drops instead of falling through", loop.Sev2,
|
||||
[]string{"voice", "drop"}, []string{store.DeliveryFailed, store.DeliveryDropped}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//
|
||||
// ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard)
|
||||
// nudges when present (alongside voice), and reminders when away. the
|
||||
// message body is the Sendable's Summary — the minimal-body rule from the
|
||||
// message body is delivery.AwayMessage — the minimal-body rule from the
|
||||
// spec ("disk low on homesrv," not detail; no shoulder-surf exfil through
|
||||
// the relay). voice gets Body; away channels get Summary, enforced at the
|
||||
// sink so a phraser bug can't exfil.
|
||||
// the relay). the dispatcher already strips detail off away sendables; the
|
||||
// sink uses the same helper so it can't leak the body on its own either.
|
||||
//
|
||||
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
|
||||
// with a dedicated user (write-only to maven-* topics) — the credential is a
|
||||
@@ -69,18 +69,14 @@ func New(cfg Config) (*Sink, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send publishes one notification to ntfy. the body is the Sendable's Summary
|
||||
// (minimal body); Title is "maven" (consistent sender identity on the lock
|
||||
// screen — the content is in the body). Priority maps from severity/kind so
|
||||
// Send publishes one notification to ntfy. the body is the minimal away
|
||||
// message (never the full body); Title is "maven" (consistent sender identity
|
||||
// on the lock screen — the content is in the body). Priority maps from severity/kind so
|
||||
// the phone client can ring differently for an alarm vs a soft ops nudge.
|
||||
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||
body := d.Summary
|
||||
if body == "" {
|
||||
body = d.Body // terse full message beats no message
|
||||
}
|
||||
if body == "" {
|
||||
return fmt.Errorf("ntfysink: empty message for %s", d.Channel)
|
||||
}
|
||||
// never fall back to d.Body: ntfy leaves the box, so an empty summary gets
|
||||
// a generic line instead of the full detail.
|
||||
body := delivery.AwayMessage(d)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body))
|
||||
if err != nil {
|
||||
|
||||
@@ -147,9 +147,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
||||
// a terse full message is better than no message; the phraser should
|
||||
// produce a summary for away-bound severities, but don't silently drop.
|
||||
func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
|
||||
// #368: this used to fall back to the full body. ntfy leaves the box, so
|
||||
// an empty summary gets a fixed generic line plus the rule name instead.
|
||||
rs := newRecordingServer(t, 200, "")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
@@ -160,12 +160,15 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
_, _, body, _, _, _ := rs.snapshot()
|
||||
if body != s.Body {
|
||||
t.Fatalf("fallback body: want %q, got %q", s.Body, body)
|
||||
want := delivery.GenericAwayMessage + ": service_down"
|
||||
if body != want {
|
||||
t.Fatalf("body: want %q, got %q", want, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendRejectsEmptyMessage(t *testing.T) {
|
||||
func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
|
||||
// with nothing at all to say we still send the generic line — an away
|
||||
// channel can never carry detail, but it also never goes out blank.
|
||||
rs := newRecordingServer(t, 200, "")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
@@ -173,9 +176,13 @@ func TestSendRejectsEmptyMessage(t *testing.T) {
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
s := nudgeSendable(loop.Sev3, "")
|
||||
s.Body = ""
|
||||
err := sink.Send(context.Background(), s)
|
||||
if err == nil {
|
||||
t.Fatal("want error for empty message")
|
||||
s.RuleName = ""
|
||||
if err := sink.Send(context.Background(), s); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
_, _, body, _, _, _ := rs.snapshot()
|
||||
if body != delivery.GenericAwayMessage {
|
||||
t.Fatalf("body: want %q, got %q", delivery.GenericAwayMessage, body)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -208,10 +208,9 @@ func TestAwayChannelsGetMinimalBody(t *testing.T) {
|
||||
// TestCareAwayDropIsRecorded — DESIGN.md's drop is a decision ("a missed water
|
||||
// nudge is noise, a missed backup failure isn't"), so it should be visible
|
||||
// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox
|
||||
// attempt, no log — nothing an operator can see afterwards.
|
||||
// attempt, no log — nothing an operator can see afterwards. now it leaves a
|
||||
// 'dropped' outbox row.
|
||||
func TestCareAwayDropIsRecorded(t *testing.T) {
|
||||
t.Skip("not implemented: dispatcher.go:149-151 skips a Drop channel with no record; there is no 'dropped' outcome in store/delivery.go:16-21")
|
||||
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Voice: &fakeSink{}, Nudges: &fakeNudgeRecorder{}, Outbox: ob})
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
//
|
||||
// telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm
|
||||
// at 2am routes to telegram, repeat til ack." the message body is the
|
||||
// Sendable's Summary — the minimal-body rule from the spec ("disk low on
|
||||
// homesrv," not detail; no shoulder-surf exfil through the relay). voice gets
|
||||
// Body; away channels get Summary, enforced at the sink so a phraser bug can't
|
||||
// exfil. additionally, protect_content=true is passed on every send so the
|
||||
// message can't be forwarded out of the chat — locks the minimal body further.
|
||||
// delivery.AwayMessage — the minimal-body rule from the spec ("disk low on
|
||||
// homesrv," not detail; no shoulder-surf exfil through the relay). the
|
||||
// dispatcher already strips detail off away sendables; the sink uses the same
|
||||
// helper so it can't leak the body on its own either. additionally,
|
||||
// protect_content=true is passed on every send so the message can't be
|
||||
// forwarded out of the chat — locks the minimal body further.
|
||||
//
|
||||
// telegram's bot API is region-restricted for this homesrv — direct egress to
|
||||
// api.telegram.org is unreliable. the spec's "away channels leave the box —
|
||||
@@ -140,18 +141,13 @@ type telegramResp struct {
|
||||
}
|
||||
|
||||
// Send publishes one message to the configured telegram chat. the body is the
|
||||
// Sendable's Summary (minimal body); empty Summary falls back to Body (terse
|
||||
// full message beats no message). protect_content=true so a phraser bug (Body
|
||||
// leaking detail through Summary) can't be forwarded onward by the user or a
|
||||
// chat observer — locks the minimal-body rule at the channel's own last mile.
|
||||
// minimal away message (never the full body). protect_content=true so even
|
||||
// that can't be forwarded onward by the user or a chat observer — locks the
|
||||
// minimal-body rule at the channel's own last mile.
|
||||
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||
body := d.Summary
|
||||
if body == "" {
|
||||
body = d.Body
|
||||
}
|
||||
if body == "" {
|
||||
return fmt.Errorf("telegramsink: empty message for %s", d.Channel)
|
||||
}
|
||||
// never fall back to d.Body: telegram leaves the box, so an empty summary
|
||||
// gets a generic line instead of the full detail.
|
||||
body := delivery.AwayMessage(d)
|
||||
|
||||
payload := sendMessageReq{
|
||||
ChatID: s.cfg.ChatID,
|
||||
|
||||
@@ -173,9 +173,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
||||
// terse full message beats none; the phraser should produce a summary for
|
||||
// away-bound severities, but don't silently drop.
|
||||
func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
|
||||
// #368: this used to fall back to the full body. telegram leaves the box,
|
||||
// so an empty summary gets a fixed generic line plus the rule name.
|
||||
rs := newRecordingServer(t, 200, "")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
@@ -188,12 +188,14 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
||||
_, _, body, _, _ := rs.snapshot()
|
||||
var req sendMessageReq
|
||||
_ = json.Unmarshal([]byte(body), &req)
|
||||
if req.Text != s.Body {
|
||||
t.Fatalf("fallback text: want %q, got %q", s.Body, req.Text)
|
||||
want := delivery.GenericAwayMessage + ": service_down"
|
||||
if req.Text != want {
|
||||
t.Fatalf("text: want %q, got %q", want, req.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendRejectsEmptyMessage(t *testing.T) {
|
||||
func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
|
||||
// with nothing at all to say we still send the generic line.
|
||||
rs := newRecordingServer(t, 200, "")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
@@ -201,9 +203,15 @@ func TestSendRejectsEmptyMessage(t *testing.T) {
|
||||
sink, _ := New(sinkCfg(srv.URL))
|
||||
s := nudgeSendable(loop.Sev4, "")
|
||||
s.Body = ""
|
||||
err := sink.Send(context.Background(), s)
|
||||
if err == nil {
|
||||
t.Fatal("want error for empty message")
|
||||
s.RuleName = ""
|
||||
if err := sink.Send(context.Background(), s); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
_, _, body, _, _ := rs.snapshot()
|
||||
var req sendMessageReq
|
||||
_ = json.Unmarshal([]byte(body), &req)
|
||||
if req.Text != delivery.GenericAwayMessage {
|
||||
t.Fatalf("text: want %q, got %q", delivery.GenericAwayMessage, req.Text)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package eval
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,8 +6,20 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UnknownModel is the label to print when the server would not say what it has
|
||||
// loaded. Deliberately ugly: an honest "unknown" is fine, a plausible-looking
|
||||
// but wrong model name is the bug this whole file exists to prevent.
|
||||
const UnknownModel = "unknown-model"
|
||||
|
||||
// llama-server is local, so never send this through a proxy: this box's
|
||||
// http_proxy answers 503 for loopback, which would look like "server won't say
|
||||
// which model it has" when the server is right there and fine.
|
||||
// A Transport with no Proxy set bypasses http_proxy entirely.
|
||||
var modelHTTP = &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{}}
|
||||
|
||||
// ModelID asks llama-server which model it has loaded, so a scoring run can
|
||||
// label itself. Without this a bake-off between two models produces two tables
|
||||
// that look identical, and the operator has to remember which server was up.
|
||||
@@ -19,7 +31,7 @@ func ModelID(ctx context.Context, base string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := modelHTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -38,14 +50,21 @@ func ModelID(ctx context.Context, base string) (string, error) {
|
||||
if len(out.Data) == 0 {
|
||||
return "", fmt.Errorf("models: empty list")
|
||||
}
|
||||
return shortModelID(out.Data[0].ID), nil
|
||||
short := shortModelID(out.Data[0].ID)
|
||||
if short == "" {
|
||||
// Server answered but the id field was missing or blank. Say so
|
||||
// instead of handing back an empty label that reads as a real name.
|
||||
return "", fmt.Errorf("models: no id in response")
|
||||
}
|
||||
return short, nil
|
||||
}
|
||||
|
||||
// shortModelID trims the path and the .gguf suffix — llama-server reports the
|
||||
// file name it was started with, which is too long for a table header.
|
||||
func shortModelID(id string) string {
|
||||
id = strings.TrimSpace(id)
|
||||
if i := strings.LastIndexAny(id, "/\\"); i >= 0 {
|
||||
id = id[i+1:]
|
||||
}
|
||||
return strings.TrimSuffix(id, ".gguf")
|
||||
return strings.TrimSpace(strings.TrimSuffix(id, ".gguf"))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The point of these tests: a wrong-but-plausible model label is the bug, so
|
||||
// every path that cannot learn the real name must return an error instead of a
|
||||
// guess. No llama-server needed — a stub server stands in.
|
||||
func TestModelID(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
code int
|
||||
want string // "" ⇒ expect an error
|
||||
}{
|
||||
{"full path", `{"data":[{"id":"/mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf"}]}`, 200, "Qwen3.5-0.8B.Q4_K_M"},
|
||||
{"bare name", `{"data":[{"id":"LFM2.5-1.2B"}]}`, 200, "LFM2.5-1.2B"},
|
||||
{"empty list", `{"data":[]}`, 200, ""},
|
||||
{"id missing", `{"data":[{}]}`, 200, ""},
|
||||
{"id blank", `{"data":[{"id":" "}]}`, 200, ""},
|
||||
{"server error", `nope`, 500, ""},
|
||||
{"not json", `<html>`, 200, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
t.Errorf("asked for %s, want /v1/models", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(c.code)
|
||||
_, _ = w.Write([]byte(c.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := ModelID(context.Background(), srv.URL+"/")
|
||||
if c.want == "" {
|
||||
if err == nil {
|
||||
t.Fatalf("want an error, got label %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ModelID: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelIDUnreachable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
url := srv.URL
|
||||
srv.Close() // nothing listening now
|
||||
|
||||
if got, err := ModelID(context.Background(), url); err == nil {
|
||||
t.Fatalf("want an error from a dead server, got label %q", got)
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,19 @@ func TestAddressTimeWordDoesNotBlind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddressVerbIsNotAnAntecedent — a nudge is mostly verbs, and a verb is
|
||||
// never who "он" refers to. This exact string passed the check before.
|
||||
func TestAddressVerbIsNotAnAntecedent(t *testing.T) {
|
||||
s := "попробуй встать и отдохнуть — у него есть перерыв"
|
||||
if r := checkAddress(s); r.Pass {
|
||||
t.Errorf("checkAddress(%q) passed, want a third-person failure", s)
|
||||
}
|
||||
// Still missed, and this is the documented hole: "выпей воды, он не пил" has
|
||||
// a real noun ("воды") before the pronoun, so the scan believes somebody
|
||||
// else was named. Telling that apart needs a parser, not a suffix rule.
|
||||
// A named third party still wins over the verbs around it.
|
||||
if r := checkAddress("сервис упал, он не отвечает"); !r.Pass {
|
||||
t.Errorf("checkAddress on a real third party failed: %s", r.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +340,9 @@ func prevWord(words []string, i int) string {
|
||||
// - it only looks BACKWARD. "Он не отвечает, сервис упал" names the subject
|
||||
// after the pronoun and is flagged wrongly.
|
||||
// - any noun earlier in the message counts as an antecedent, even when it is
|
||||
// not one ("после обеда он не ел" reads as legitimate and is missed). The
|
||||
// not one ("после обеда он не ел", "выпей воды, он не пил" — both missed).
|
||||
// Verbs and time words no longer count, which covers the usual nudge, but a
|
||||
// plain noun before the pronoun still blinds it. The
|
||||
// common time words are stoplisted so the usual nudge opening does not
|
||||
// blind it, but a message with any other noun in front still slips through.
|
||||
// This is the check's real hole; widening it further would start flagging
|
||||
@@ -407,14 +409,26 @@ var notAnAntecedent = map[string]bool{
|
||||
"твой": true, "твоя": true, "твоё": true, "твое": true, "твои": true, "твою": true,
|
||||
}
|
||||
|
||||
// looksPastVerb — a past-tense verb needs a subject of its own, so it is not an
|
||||
// antecedent either. Keeps "сервис упал, он не отвечает" working off "сервис".
|
||||
func looksPastVerb(w string) bool {
|
||||
if len([]rune(w)) < 3 {
|
||||
// looksVerb — a verb is never the thing "он" refers to, so it must not count as
|
||||
// an antecedent. Past tense keeps "сервис упал, он не отвечает" working off
|
||||
// "сервис"; the infinitive and imperative endings are here because a nudge is
|
||||
// mostly made of them ("попробуй встать и отдохнуть — у него есть перерыв"
|
||||
// slipped through with "попробуй" taken for the person being talked about).
|
||||
func looksVerb(w string) bool {
|
||||
r := []rune(w)
|
||||
if len(r) < 3 {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(w, "л") || strings.HasSuffix(w, "ла") ||
|
||||
strings.HasSuffix(w, "ло") || strings.HasSuffix(w, "ли")
|
||||
for _, suf := range []string{
|
||||
"л", "ла", "ло", "ли", // past tense
|
||||
"ть", "ться", "ти", "чь", // infinitive
|
||||
"й", "йся", "йте", // imperative
|
||||
} {
|
||||
if strings.HasSuffix(w, suf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkAddress(body string) Result {
|
||||
@@ -441,7 +455,7 @@ func checkAddress(body string) Result {
|
||||
if !unicode.Is(unicode.Cyrillic, []rune(p)[0]) && !isLatinWord(p) {
|
||||
continue // punctuation
|
||||
}
|
||||
if notAnAntecedent[p] || prepositions[p] || thirdPersonHim[p] || looksPastVerb(p) {
|
||||
if notAnAntecedent[p] || prepositions[p] || thirdPersonHim[p] || looksVerb(p) {
|
||||
continue
|
||||
}
|
||||
named = true
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,7 @@ func TestLLMPhrasingBaseline(t *testing.T) {
|
||||
// case as a phrasing error and read as "the model cannot phrase".
|
||||
noProxyLoopback(t)
|
||||
|
||||
ctx := context.Background()
|
||||
f, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
@@ -44,7 +46,19 @@ func TestLLMPhrasingBaseline(t *testing.T) {
|
||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||
defer p.Close()
|
||||
|
||||
rep, err := Score(context.Background(), "llm (0.8B, built-in persona)", p, f)
|
||||
// Label the run with whatever gguf the server actually has loaded. It used
|
||||
// to say "0.8B" no matter what, so two runs of two different models came
|
||||
// out named the same and were easy to mix up when comparing.
|
||||
model, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
// An unlabelled score is still a score, but say so loudly — a made-up
|
||||
// name in a bake-off table is worse than no name.
|
||||
t.Logf("could not read model id from %s: %v — report will say %q", base, err, llm.UnknownModel)
|
||||
model = llm.UnknownModel
|
||||
}
|
||||
t.Logf("scoring model %s at %s", model, base)
|
||||
|
||||
rep, err := Score(ctx, "llm ("+model+", built-in persona)", p, f)
|
||||
if err != nil {
|
||||
t.Fatalf("Score: %v", err)
|
||||
}
|
||||
|
||||
@@ -439,8 +439,10 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma
|
||||
// as "..." before this. See PHRASING-EVAL-31-07-2026.md.
|
||||
//
|
||||
// Russian only, feminine self-reference, second person masculine (the owner is
|
||||
// a man). One short sentence — the nudge is spoken aloud.
|
||||
// a man). She talks TO him, informally, singular — never "вы", never "он".
|
||||
// One short sentence — the nudge is spoken aloud.
|
||||
const nudgeSystem = `Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде ("я проверила", "я записала"). Владелец — мужчина, обращайся к нему в мужском роде ("ты пил", "ты забыл").
|
||||
Говоришь с ним на "ты", в единственном числе ("выпей", "встань"). Никогда не "вы"/"вас"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём.
|
||||
|
||||
Пиши ОДНО короткое напоминание по-русски: не больше 120 символов и не больше 16 слов. Только по делу.
|
||||
|
||||
|
||||
@@ -61,12 +61,12 @@ func TestLLMRouterBaseline(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
model, err := ModelID(ctx, base)
|
||||
model, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
// Not fatal: an unlabelled score is still a score. But say so loudly,
|
||||
// because an unlabelled row in a bake-off table is worthless.
|
||||
t.Logf("could not read model id from %s: %v — reports will say %q", base, err, "unknown-model")
|
||||
model = "unknown-model"
|
||||
t.Logf("could not read model id from %s: %v — reports will say %q", base, err, llm.UnknownModel)
|
||||
model = llm.UnknownModel
|
||||
}
|
||||
t.Logf("scoring model %s at %s", model, base)
|
||||
lr := router.NewLLMRouter(client)
|
||||
|
||||
@@ -13,11 +13,15 @@ import (
|
||||
// unknown = a pending row found stale at startup: the process that started it
|
||||
// is gone, and the send may or may not have reached the external channel.
|
||||
// Never auto-resolved into sent or failed — that would be guessing.
|
||||
// dropped = the routing table deliberately suppressed this one (a care nudge
|
||||
// while you're away). Nothing was sent and nothing went wrong; the row exists
|
||||
// so "she dropped it" and "the rule never fired" don't look the same later.
|
||||
const (
|
||||
DeliveryPending = "pending"
|
||||
DeliverySent = "sent"
|
||||
DeliveryFailed = "failed"
|
||||
DeliveryUnknown = "unknown"
|
||||
DeliveryDropped = "dropped"
|
||||
)
|
||||
|
||||
// BeginDeliveryAttempt durably records intent to send BEFORE the external
|
||||
@@ -43,10 +47,11 @@ func (s *Store) BeginDeliveryAttempt(ctx context.Context, kind, rule string, rem
|
||||
}
|
||||
|
||||
// CompleteDeliveryAttempt records the sink's outcome for a prior
|
||||
// BeginDeliveryAttempt. status is "sent" or "failed" — never "pending" or
|
||||
// "unknown" (those are set only by Begin and reconciliation respectively).
|
||||
// BeginDeliveryAttempt. status is "sent", "failed" or "dropped" — never
|
||||
// "pending" or "unknown" (those are set only by Begin and reconciliation
|
||||
// respectively).
|
||||
func (s *Store) CompleteDeliveryAttempt(ctx context.Context, id int64, status string, now time.Time) error {
|
||||
if status != DeliverySent && status != DeliveryFailed {
|
||||
if status != DeliverySent && status != DeliveryFailed && status != DeliveryDropped {
|
||||
return fmt.Errorf("store: invalid delivery completion status %q", status)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDroppedDeliveryAttemptRoundTrips — Vikunja #370. A suppressed nudge is
|
||||
// recorded as 'dropped'. The status column has a CHECK constraint, so this
|
||||
// only works if migration #12 widened it; a fake outbox in a unit test would
|
||||
// not catch that.
|
||||
func TestDroppedDeliveryAttemptRoundTrips(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
id, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "drop", "abc123", now)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginDeliveryAttempt: %v", err)
|
||||
}
|
||||
if err := s.CompleteDeliveryAttempt(ctx, id, DeliveryDropped, now); err != nil {
|
||||
t.Fatalf("CompleteDeliveryAttempt: %v", err)
|
||||
}
|
||||
|
||||
var status string
|
||||
err = s.db.QueryRowContext(ctx, `SELECT status FROM delivery_attempts WHERE id = ?`, id).Scan(&status)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if status != DeliveryDropped {
|
||||
t.Fatalf("status: want %q, got %q", DeliveryDropped, status)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,30 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);`, // #11 — small key/value table for facts about the DB itself; first key is embedder_id (Vikunja #378)
|
||||
|
||||
// #12 — a suppressed nudge gets a 'dropped' row (Vikunja #370). sqlite
|
||||
// can't widen a CHECK constraint in place, so the table is rebuilt; the
|
||||
// index goes with the old table and is recreated. The columns are listed
|
||||
// out rather than `SELECT *` — copying by position would silently shuffle
|
||||
// every row if the old table's column order ever differed from this one.
|
||||
`CREATE TABLE delivery_attempts_v12 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('nudge','reminder')),
|
||||
rule TEXT NOT NULL DEFAULT '',
|
||||
reminder_id INTEGER NOT NULL DEFAULT 0,
|
||||
channel TEXT NOT NULL,
|
||||
body_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','unknown','dropped')),
|
||||
created_ts INTEGER NOT NULL,
|
||||
completed_ts INTEGER
|
||||
);
|
||||
INSERT INTO delivery_attempts_v12
|
||||
(id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts)
|
||||
SELECT id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts
|
||||
FROM delivery_attempts;
|
||||
DROP TABLE delivery_attempts;
|
||||
ALTER TABLE delivery_attempts_v12 RENAME TO delivery_attempts;
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_status ON delivery_attempts (status);`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
+79
-4
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unified script to stop all Maven services.
|
||||
# Usage: ./kill-maven.sh
|
||||
# - Graceful SIGTERM is attempted first.
|
||||
# - If any process lingers, force with SIGKILL.
|
||||
# - Docker deploy: `docker compose stop` (see why below).
|
||||
# - Bare-metal / dev run: graceful SIGTERM first, SIGKILL if anything lingers.
|
||||
# Exits non-zero if it cannot confirm everything is stopped. It must never say
|
||||
# "stopped" unless it checked.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -24,6 +26,73 @@ else
|
||||
LLM='llama-server.*\.gguf'
|
||||
fi
|
||||
|
||||
COMPOSE_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/docker-compose.yml"
|
||||
|
||||
# --- containerised deploy ------------------------------------------------
|
||||
# docker-compose.yml does not set `pid: host`, so each container has its own
|
||||
# PID namespace: pkill on the host sees nothing inside them. This script used
|
||||
# to print "all stopped" while every daemon was still happily running. Stop the
|
||||
# containers through compose instead — that actually reaches them.
|
||||
#
|
||||
# running_containers prints the ids of the project's running containers, or
|
||||
# nothing. Empty output plus a non-zero return means "could not ask docker",
|
||||
# which is different from "nothing is running" and is handled below.
|
||||
running_containers() {
|
||||
docker compose -f "$COMPOSE_FILE" ps -q --status running 2>/dev/null
|
||||
}
|
||||
|
||||
DOCKER_OK=0
|
||||
CONTAINERS=""
|
||||
if command -v docker >/dev/null 2>&1 && [ -f "$COMPOSE_FILE" ]; then
|
||||
if CONTAINERS="$(running_containers)"; then
|
||||
DOCKER_OK=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$DOCKER_OK" = 1 ] && [ -n "$CONTAINERS" ]; then
|
||||
echo "--- Maven is running in containers: stopping via docker compose ---"
|
||||
if ! docker compose -f "$COMPOSE_FILE" stop; then
|
||||
echo "ERROR: 'docker compose stop' failed. Containers may still be running." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "--- Verifying containers are gone ---"
|
||||
LEFT="$(running_containers || true)"
|
||||
if [ -n "$LEFT" ]; then
|
||||
echo "ERROR: containers still running after stop:" >&2
|
||||
docker compose -f "$COMPOSE_FILE" ps >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "All containers stopped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- bare-metal / dev run -----------------------------------------------
|
||||
# pgrep -f matches whole command lines, so a shell that merely mentions
|
||||
# "mavend" (this script's own parent, for one) shows up. Drop ourselves and our
|
||||
# parent, otherwise the SIGKILL sweep can take out the terminal you ran this in.
|
||||
host_pids() {
|
||||
pgrep -f "$PAT|$LLM" | grep -v -e "^$$\$" -e "^$PPID\$" | paste -sd, - || true
|
||||
}
|
||||
HOST_PIDS=$(host_pids)
|
||||
|
||||
if [ -z "$HOST_PIDS" ]; then
|
||||
# Nothing on the host. Whether that means "already down" depends on whether
|
||||
# we managed to ask docker, and the two must not read the same.
|
||||
if [ "$DOCKER_OK" = 1 ]; then
|
||||
# Docker answered and named no running containers, and there is nothing
|
||||
# on the host either. That is a real answer: Maven is already stopped.
|
||||
echo "Nothing to stop: no Maven processes and no running containers."
|
||||
exit 0
|
||||
fi
|
||||
# We could not ask docker, so Maven may be alive in a container we cannot
|
||||
# see. Saying "stopped" here is the exact false success this script had.
|
||||
echo "ERROR: no Maven processes on this host, and docker could not be asked." >&2
|
||||
echo " If this is the container deploy it may still be running:" >&2
|
||||
echo " docker compose -f $COMPOSE_FILE stop" >&2
|
||||
echo " Nothing was stopped. Check by hand before assuming Maven is down." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Sending graceful SIGTERM to Maven services ---"
|
||||
pkill -TERM -f "$PAT" || true
|
||||
# mavend's Pdeathsig SIGKILLs its llama-server on exit, but sweep strays too
|
||||
@@ -32,12 +101,18 @@ pkill -TERM -f "$LLM" || true
|
||||
|
||||
echo "--- Verifying processes are gone ---"
|
||||
sleep 1
|
||||
PIDS=$(pgrep -d ',' -f "$PAT|$LLM") || PIDS=""
|
||||
PIDS=$(host_pids)
|
||||
if [ -n "$PIDS" ]; then
|
||||
echo "Warning: some processes still alive. PIDs: $PIDS"
|
||||
echo "--- Force killing with SIGKILL ---"
|
||||
echo "$PIDS" | tr ',' '\n' | xargs -r kill -9
|
||||
sleep 1
|
||||
LEFT=$(host_pids)
|
||||
if [ -n "$LEFT" ]; then
|
||||
echo "ERROR: still alive after SIGKILL. PIDs: $LEFT" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Done (SIGKILL)."
|
||||
else
|
||||
echo "All services gracefully stopped."
|
||||
fi
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user