Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9d88c152e | |||
| 1890ff5d5d |
@@ -82,9 +82,23 @@ workspace enforces that the Go and relabelling prompts remain identical.
|
|||||||
|
|
||||||
## Non-goals (hard constraints)
|
## Non-goals (hard constraints)
|
||||||
|
|
||||||
Never phones home. Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||||
self-reference must use feminine forms (the user is male; see memory `maven-persona-gender`).
|
self-reference must use feminine forms (the user is male; see memory `maven-persona-gender`).
|
||||||
|
|
||||||
|
**"Never phones home" is DEPRECATED** (owner's call, 2026-07-31). It used to be a hard
|
||||||
|
constraint and it is not one any more: a 0.8B — and a 1.7B — does not know enough to answer
|
||||||
|
world questions, so she needs to read external sources. What replaces it:
|
||||||
|
|
||||||
|
- **No telemetry, no cloud model, no third-party account.** That part never changes. Nothing
|
||||||
|
about Maven is reported to anyone, and inference stays on the box.
|
||||||
|
- **Local sources first.** Kiwix ZIMs on homesrv (Wikipedia, ifixit) before anything on the
|
||||||
|
network. Reading beats recalling for a small model, and a local read costs nothing.
|
||||||
|
- **External search is allowed and off unless configured**, like the weather and telegram
|
||||||
|
capabilities.
|
||||||
|
- **His notes and facts are never search input.** Looking up why the sky is blue and sending
|
||||||
|
his stored personal notes to an upstream engine are different acts. Only the utterance goes
|
||||||
|
out, never the persona block, history, or matched notes.
|
||||||
|
|
||||||
## Web UI conventions
|
## Web UI conventions
|
||||||
|
|
||||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
|
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
|
|
||||||
**Maven** — self-hosted personal assistant. Manages your day, acts on your
|
**Maven** — self-hosted personal assistant. Manages your day, acts on your
|
||||||
homelab. One daemon on homesrv (always-on, not the workstation), multiple
|
homelab. One daemon on homesrv (always-on, not the workstation), multiple
|
||||||
client surfaces. All local, never phones home.
|
client surfaces. Inference and data stay on the box; she may READ external
|
||||||
|
sources (see Non-goals — "never phones home" is deprecated).
|
||||||
|
|
||||||
Primary name is "Maven", with feminine-gendered Russian self-reference
|
Primary name is "Maven", with feminine-gendered Russian self-reference
|
||||||
("она", "меня", "помогла"). Clients may choose their own UI label. Consistent
|
("она", "меня", "помогла"). Clients may choose their own UI label. Consistent
|
||||||
@@ -35,8 +36,13 @@ Inside boundary — the ones that actually constrain the build:
|
|||||||
she records. A confident wrong fact is worse than a known gap.
|
she records. A confident wrong fact is worse than a known gap.
|
||||||
- **Not a nag** — she'd rather miss a nudge than be mutable. Shuts up when
|
- **Not a nag** — she'd rather miss a nudge than be mutable. Shuts up when
|
||||||
uncertain. Load-bearing.
|
uncertain. Load-bearing.
|
||||||
- **Not a stranger** — runs on your stuff, your model, your data. Never
|
- **Not a stranger** — runs on your stuff, your model, your data. No
|
||||||
phones home.
|
telemetry, no cloud model, no third-party account. She may READ external
|
||||||
|
sources to answer world questions (Kiwix first, then optional search); she
|
||||||
|
never reports anything about you to anyone, and your notes and facts are
|
||||||
|
never used as search input. **"Never phones home" as an absolute is
|
||||||
|
deprecated** — owner's call, 2026-07-31: a small model does not know enough
|
||||||
|
to be useful without reading.
|
||||||
- **Not a relationship** — mom-tone is a function that makes nudges land, not
|
- **Not a relationship** — mom-tone is a function that makes nudges land, not
|
||||||
emotional company. Names the drift a warm small model falls into.
|
emotional company. Names the drift a warm small model falls into.
|
||||||
|
|
||||||
@@ -458,7 +464,7 @@ decides *insistence*. Both are needed.
|
|||||||
|
|
||||||
sev ≤ 2 drops on away, sev ≥ 3 holds: a missed water nudge is noise, a missed
|
sev ≤ 2 drops on away, sev ≥ 3 holds: a missed water nudge is noise, a missed
|
||||||
backup failure isn't. Away-channels (ntfy/telegram) leave the box — the one
|
backup failure isn't. Away-channels (ntfy/telegram) leave the box — the one
|
||||||
path that crosses "never phones home," through your own relay. **Minimal
|
path that leaves the box for a person to see, through your own relay. **Minimal
|
||||||
body** — "disk low on homesrv," not detail; don't make notifications a
|
body** — "disk low on homesrv," not detail; don't make notifications a
|
||||||
shoulder-surf exfil surface.
|
shoulder-surf exfil surface.
|
||||||
|
|
||||||
|
|||||||
@@ -90,4 +90,7 @@ later* is the worker + RAG.
|
|||||||
4. **Deferred work** — larger reasoner, custom Piper voice and other expansions.
|
4. **Deferred work** — larger reasoner, custom Piper voice and other expansions.
|
||||||
|
|
||||||
## Non-goals (unchanged)
|
## Non-goals (unchanged)
|
||||||
Never phones home. Not a nag. Not autonomous. Feminine-gendered RU self-ref.
|
Not a nag. Not autonomous. Feminine-gendered RU self-ref. No telemetry, no
|
||||||
|
cloud model, no third-party account — but she MAY read external sources to
|
||||||
|
answer world questions (Kiwix first, search optional). "Never phones home" as
|
||||||
|
an absolute is deprecated, owner's call 2026-07-31; see CLAUDE.md § Non-goals.
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
)
|
||||||
|
|
||||||
|
// grammarSpy stands in for llama-server: it records the grammar field of every
|
||||||
|
// request and always answers with a contract-shaped reply.
|
||||||
|
type grammarSpy struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
grammars []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGrammarSpy(t *testing.T) *grammarSpy {
|
||||||
|
t.Helper()
|
||||||
|
s := &grammarSpy{}
|
||||||
|
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req chatReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Errorf("spy: decode request: %v", err)
|
||||||
|
}
|
||||||
|
s.grammars = append(s.grammars, req.Grammar)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"choices":[{"message":{"content":"{\"response\": \"ага\", \"mood\": \"neutral\"}"}}]}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(s.srv.Close)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// callAllPhrasingPaths hits every path that expects the JSON contract.
|
||||||
|
func callAllPhrasingPaths(t *testing.T, p *LLMPhraser) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := p.PhraseNudge(ctx, loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1}); err != nil {
|
||||||
|
t.Fatalf("PhraseNudge: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := p.PhraseChat(ctx, "привет", nil); err != nil {
|
||||||
|
t.Fatalf("PhraseChat: %v", err)
|
||||||
|
}
|
||||||
|
// Both branches: no notes (general knowledge) and with notes (grounded).
|
||||||
|
if _, err := p.PhraseQuery(ctx, "сколько воды я выпил", nil); err != nil {
|
||||||
|
t.Fatalf("PhraseQuery (no notes): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := p.PhraseQuery(ctx, "сколько воды я выпил", []string{"два литра"}); err != nil {
|
||||||
|
t.Fatalf("PhraseQuery (notes): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
||||||
|
if strings.TrimSpace(responseGrammar) == "" {
|
||||||
|
t.Fatal("responseGrammar is empty")
|
||||||
|
}
|
||||||
|
spy := newGrammarSpy(t)
|
||||||
|
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||||
|
|
||||||
|
callAllPhrasingPaths(t, p)
|
||||||
|
|
||||||
|
if len(spy.grammars) != 4 {
|
||||||
|
t.Fatalf("expected 4 requests, got %d", len(spy.grammars))
|
||||||
|
}
|
||||||
|
for i, g := range spy.grammars {
|
||||||
|
if g != responseGrammar {
|
||||||
|
t.Errorf("request %d carries grammar %q, want responseGrammar", i, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoGrammarConfigDisablesIt(t *testing.T) {
|
||||||
|
spy := newGrammarSpy(t)
|
||||||
|
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true})
|
||||||
|
|
||||||
|
callAllPhrasingPaths(t, p)
|
||||||
|
|
||||||
|
for i, g := range spy.grammars {
|
||||||
|
if g != "" {
|
||||||
|
t.Errorf("request %d still carries a grammar with NoGrammar set: %q", i, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The grammar's string rule must accept any codepoint, not just ASCII. Replies
|
||||||
|
// are Russian: an ASCII-only class would constrain the model into empty replies.
|
||||||
|
func TestGrammarStringRuleIsNotASCIIOnly(t *testing.T) {
|
||||||
|
if !strings.Contains(responseGrammar, `([^"\\] | "\\" ["\\/bfnrt])`) {
|
||||||
|
t.Error("string rule is not the any-codepoint-except-quote-and-backslash class; Cyrillic replies would be impossible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the grammar describes must survive the parser that reads it back — a
|
||||||
|
// Russian body with an escaped quote inside, hand-built to test the contract.
|
||||||
|
func TestGrammarShapedJSONParses(t *testing.T) {
|
||||||
|
raw := `{"response": "он сказал \"привет\" и ушёл.\nвот так.", "mood": "confused"}`
|
||||||
|
text, mood := parseResponseMood(raw)
|
||||||
|
if want := "он сказал \"привет\" и ушёл.\nвот так."; text != want {
|
||||||
|
t.Errorf("response = %q, want %q", text, want)
|
||||||
|
}
|
||||||
|
if mood != "confused" {
|
||||||
|
t.Errorf("mood = %q, want confused", mood)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every mood the grammar permits is one the contract knows, and all five are there.
|
||||||
|
func TestGrammarMoodEnumMatchesTheContract(t *testing.T) {
|
||||||
|
for _, m := range []string{"neutral", "happy", "thinking", "tired", "confused"} {
|
||||||
|
if !strings.Contains(responseGrammar, `"\"`+m+`\""`) {
|
||||||
|
t.Errorf("mood %q missing from the grammar", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No sixth mood: the enum line lists exactly five alternatives.
|
||||||
|
for _, line := range strings.Split(responseGrammar, "\n") {
|
||||||
|
if strings.HasPrefix(line, "mood") {
|
||||||
|
if n := strings.Count(line, "|") + 1; n != 5 {
|
||||||
|
t.Errorf("mood rule lists %d alternatives, want 5: %s", n, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,13 @@ type Config struct {
|
|||||||
// address him, the time) fresh for each turn. See internal/persona.
|
// address him, the time) fresh for each turn. See internal/persona.
|
||||||
// nil ⇒ no block, the prompts stand alone.
|
// nil ⇒ no block, the prompts stand alone.
|
||||||
ContextBlock func() string
|
ContextBlock func() string
|
||||||
|
|
||||||
|
// NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON).
|
||||||
|
// The escape hatch exists because the target resident model — the
|
||||||
|
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
||||||
|
// ever fights the grammar, the fix should be a config flip on the
|
||||||
|
// deploy box, not a code change and a rebuild.
|
||||||
|
NoGrammar bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig(modelPath string) Config {
|
func DefaultConfig(modelPath string) Config {
|
||||||
@@ -290,6 +297,7 @@ func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTo
|
|||||||
Messages: msgs,
|
Messages: msgs,
|
||||||
Temperature: 0.7,
|
Temperature: 0.7,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
|
Grammar: p.grammar(),
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(req)
|
body, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -369,6 +377,37 @@ type chatReq struct {
|
|||||||
Messages []chatMsg `json:"messages"`
|
Messages []chatMsg `json:"messages"`
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
MaxTokens int `json:"max_tokens"`
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 — 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.
|
||||||
|
const responseGrammar = `
|
||||||
|
root ::= "{" ws "\"response\"" ws ":" ws string ws "," ws "\"mood\"" ws ":" ws mood ws "}"
|
||||||
|
mood ::= "\"neutral\"" | "\"happy\"" | "\"thinking\"" | "\"tired\"" | "\"confused\""
|
||||||
|
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,400} "\""
|
||||||
|
ws ::= [ \t\n]*
|
||||||
|
`
|
||||||
|
|
||||||
|
// 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 {
|
type chatResp struct {
|
||||||
@@ -393,6 +432,7 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma
|
|||||||
},
|
},
|
||||||
Temperature: 0.7,
|
Temperature: 0.7,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
|
Grammar: p.grammar(),
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(req)
|
body, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user