Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 891136c65d | |||
| 41c7c13f42 | |||
| a324e8f624 | |||
| 51805e7f35 | |||
| 533f0acda8 | |||
| 4f59ba78c6 | |||
| d0afd9d4f6 | |||
| 6b67e6f3c2 | |||
| 742b2ad1d7 | |||
| b300ac5c70 | |||
| 13e5170e9e | |||
| ddb658ffbb | |||
| ee3e6a9eaf | |||
| 8acb8a97c6 |
@@ -7,9 +7,20 @@ talking over unix sockets; one resident small model for routing + phrasing; whis
|
|||||||
Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (`n_gpu_layers: 99`,
|
Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (`n_gpu_layers: 99`,
|
||||||
compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way.
|
compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way.
|
||||||
|
|
||||||
**Resident model:** currently **Qwen3.5-0.8B** (`Q4_K_M`), the smallest checkpoint in the gguf
|
**Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one.
|
||||||
library, picked for CPU/iGPU latency. The **target** is the locally CPT'd **Qwen3-1.7B**; that
|
It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have:
|
||||||
training is still in flight (Vikunja #122), so no such gguf exists yet. Model files live in
|
67.5% vs 59.7% intent-only on the 77-case RU routing fixture, and 20/27 vs 11-17/27 on the
|
||||||
|
talk fixture. See `MODEL-BAKEOFF-31-07-2026.md`. It is a Thinking variant, so `n_ctx` is 4096
|
||||||
|
— reasoning tokens need the room, and 4096 is what the scores above were measured at.
|
||||||
|
|
||||||
|
The **target** is still the locally CPT'd **Qwen3-1.7B** (Vikunja #122, training in flight).
|
||||||
|
Stock already speaks good Russian; what it gets wrong is the persona — it writes `я рад`,
|
||||||
|
masculine, where Maven needs `рада`. That is what the CPT is for.
|
||||||
|
|
||||||
|
**Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on 2026-07-31 and
|
||||||
|
both are unusable in Russian: the 350M routes at 5.2% (worse than guessing) and answers
|
||||||
|
"столица Франции?" with the invented non-word "Сторзит"; the 230M replies to Russian in
|
||||||
|
Spanish. Their strong published IFEval/BFCL numbers are English-only. Model files live in
|
||||||
`/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm` — which **shadows** the repo's
|
`/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm` — which **shadows** the repo's
|
||||||
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
||||||
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
||||||
|
|||||||
+118
-3
@@ -1,8 +1,18 @@
|
|||||||
# Resident model bake-off — 31-07-2026
|
# Resident model bake-off — 31-07-2026
|
||||||
|
|
||||||
**Recommendation: keep Qwen3.5-0.8B.** LFM2.5-1.2B is worse at routing (52.6% vs 60.5%
|
**Outcome: the resident model is stock Qwen3-1.7B** (`UD-Q4_K_XL`). Two sweeps ran this
|
||||||
intent accuracy), and the loss is almost entirely Russian (18/61 vs 22/61 RU, while EN is a
|
evening and the second one changed the answer — read to the end before acting on any table
|
||||||
wash). It is also 2.4× slower. The Thinking variant is far worse again.
|
here. [Second sweep](#second-sweep-same-evening--five-models-and-a-resident-model-change)
|
||||||
|
is the one that holds.
|
||||||
|
|
||||||
|
## First sweep — LFM2.5-1.2B vs Qwen3.5-0.8B
|
||||||
|
|
||||||
|
**Verdict, scoped to this pair: keep Qwen3.5-0.8B over LFM2.5-1.2B.** LFM2.5-1.2B is worse
|
||||||
|
at routing (52.6% vs 60.5% intent accuracy), and the loss is almost entirely Russian
|
||||||
|
(18/61 vs 22/61 RU, while EN is a wash). It is also 2.4× slower. The Thinking variant is
|
||||||
|
far worse again. This verdict still stands as written — it rejects LFM2.5-1.2B. It is
|
||||||
|
**not** a recommendation to keep 0.8B as the resident model; the second sweep replaced it
|
||||||
|
with Qwen3-1.7B.
|
||||||
|
|
||||||
Settles Vikunja **#278 / #250**.
|
Settles Vikunja **#278 / #250**.
|
||||||
|
|
||||||
@@ -99,3 +109,108 @@ thinking trace costs time without buying accuracy on a short enum classification
|
|||||||
Routing only. LFM2.5 might still phrase better, and phrasing is the resident model's other
|
Routing only. LFM2.5 might still phrase better, and phrasing is the resident model's other
|
||||||
job — that needs its own fixture. But routing is the load-bearing path and Maven is
|
job — that needs its own fixture. But routing is the load-bearing path and Maven is
|
||||||
Russian-first, so on the evidence here the switch is not worth making.
|
Russian-first, so on the evidence here the switch is not worth making.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Second sweep, same evening — five models, and a resident-model change
|
||||||
|
|
||||||
|
The sections above compared LFM2.5-1.2B against Qwen3.5-0.8B on routing and concluded
|
||||||
|
"the switch is not worth making". That still holds. This sweep asked a different
|
||||||
|
question — whether a *smaller* model could work, since LFM2.5's published
|
||||||
|
instruction-following scores beat Qwen3.5-0.8B badly — and answered it, plus found a
|
||||||
|
better resident model by accident.
|
||||||
|
|
||||||
|
**Outcome: the resident model is now stock Qwen3-1.7B.** Sub-500M is a dead end.
|
||||||
|
|
||||||
|
## Routing — 77 Russian cases, one run each
|
||||||
|
|
||||||
|
| model | on disk | llm-only (full) | llm-only (intent) | cascade + fallback |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| LFM2.5-230M-Q8_0 | 246 MB | 23.4% | 33.8% | 36.4% |
|
||||||
|
| LFM2.5-350M-Q8_0 | 379 MB | 2.6% | **5.2%** | 20.8% |
|
||||||
|
| Qwen3.5-0.8B-Q4_K_M | 527 MB | 36.4% | 59.7% | 61.0% |
|
||||||
|
| Qwen3.5-2B-UD-Q4_K_XL | 1.34 GB | 42.9% | 62.3% | 63.6% |
|
||||||
|
| **Qwen3-1.7B-UD-Q4_K_XL (stock)** | 1.13 GB | **44.2%** | **67.5%** | **72.7%** |
|
||||||
|
|
||||||
|
Qwen3-1.7B wins every column, including against a model 20% larger than it.
|
||||||
|
|
||||||
|
## Talk fixture — 27 cases, three runs each, idle box
|
||||||
|
|
||||||
|
| | Qwen3.5-0.8B | Qwen3-1.7B stock |
|
||||||
|
|---|---|---|
|
||||||
|
| composite | 13, 11, 8 | **20, 21, 18** |
|
||||||
|
| address | 21, 18, 18 | **26, 25, 23** |
|
||||||
|
| feminine | 27, 25, 26 | 26, 27, 26 |
|
||||||
|
| lang | 27, 27, 26 | 26, 27, 27 |
|
||||||
|
| ontopic | 16, 19, 19 | **22, 23, 23** |
|
||||||
|
| canned fallbacks | 8, 5, 6 | **0, 2, 0** |
|
||||||
|
|
||||||
|
This also fills the row `TALK-EVAL-31-07-2026.md` had to void for contamination:
|
||||||
|
**600ch/1024tok on Qwen3.5-0.8B scores 13, 11, 8.**
|
||||||
|
|
||||||
|
`address` is the headline. It sat at 18-22 of 27 on the 0.8B no matter how the prompt
|
||||||
|
was worded — the prompt explicitly forbids "вы" and the model writes `вашей`,
|
||||||
|
`подождите`, `делаете` anyway. That was read as "prompting is out of levers", and it
|
||||||
|
was really "0.8B is out of capacity". The 1.7B mostly holds the constraint.
|
||||||
|
|
||||||
|
The fallback column matters too: 5-8 of 27 turns on the 0.8B end in a hardcoded
|
||||||
|
`"не знаю."`, meaning it failed to emit parseable JSON about a quarter of the time.
|
||||||
|
The 1.7B does that 0-2 times.
|
||||||
|
|
||||||
|
## Latency — the long tail is not the Thinking block
|
||||||
|
|
||||||
|
| | p50 | p95 |
|
||||||
|
|---|---|---|
|
||||||
|
| Qwen3.5-0.8B | 2.4s, 2.9s, 2.0s | 17.4s, 17.6s, 17.4s |
|
||||||
|
| Qwen3-1.7B stock | 2.7s, 2.6s, 2.8s | 16.4s, 6.6s, 3.9s |
|
||||||
|
|
||||||
|
p50 is flat across a 2× size difference. The first instinct on seeing the 1.7B's
|
||||||
|
16s p95 was "that is the reasoning trace, cap it" — wrong. The 0.8B's p95 is a
|
||||||
|
consistent 17s and the 1.7B beat it in two of three runs. The tail is shared and
|
||||||
|
lives somewhere else. Do not spend time on `/no_think` on this evidence.
|
||||||
|
|
||||||
|
## Sub-500M: not close, and the benchmarks say otherwise for a reason
|
||||||
|
|
||||||
|
LFM2.5-350M publishes IFEval 76.96 against Qwen3.5-0.8B's 59.94, and BFCLv3 44.11
|
||||||
|
against 35.08 — better at instruction-following and structured output, at 2/3 the
|
||||||
|
size. Those numbers are real and they are **English**. Every benchmark in that
|
||||||
|
table except Multi-IF is English-only.
|
||||||
|
|
||||||
|
In Russian, with a 300-token budget and temperature 0:
|
||||||
|
|
||||||
|
- **350M**, «Столица Франции? Ответь кратко.» → *«Сторзит в Париже.»* — `Сторзит` is
|
||||||
|
not a word; it is invented morphology.
|
||||||
|
- **350M**, asked to read back a reminder → a fortune cookie about being attentive
|
||||||
|
and confident. No reminder in it.
|
||||||
|
- **230M**, «Привет, как дела?» → answered **in Spanish**.
|
||||||
|
|
||||||
|
The 230M beating the 350M six-fold on routing (33.8% vs 5.2%) is the other tell:
|
||||||
|
when the larger sibling collapses like that it is format compliance failing, not
|
||||||
|
reasoning.
|
||||||
|
|
||||||
|
This is a pretraining gap, not a fine-tuning gap. Teaching Russian to a 350M from
|
||||||
|
near-zero is not an afternoon on a Colab, which was the premise worth checking.
|
||||||
|
|
||||||
|
## Why this vindicates the 1.7B CPT
|
||||||
|
|
||||||
|
Stock Qwen3-1.7B, untrained and unprompted, answers all three probes in fluent
|
||||||
|
correct Russian. What it gets wrong is the persona: *«Привет! Я рад, что ты здесь»*
|
||||||
|
— `рад` is masculine and Maven needs `рада`. That is the right kind of remaining
|
||||||
|
problem, and it is exactly what the CPT (Vikunja #122) is for.
|
||||||
|
|
||||||
|
The 1.7B was the correct model choice. What was wrong was treating it as a
|
||||||
|
**blocker**: stock already beats what was deployed, so it ships now and gets
|
||||||
|
swapped again when the CPT lands.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- Routing is one run per model, not three. The gaps between families are far larger
|
||||||
|
than the run-to-run spread seen on the talk fixture, but the 2B-vs-1.7B gap (62.3
|
||||||
|
vs 67.5) is not safe to call on one run.
|
||||||
|
- The routing numbers only reach production once the LLM router is wired on. It is
|
||||||
|
still `nil`.
|
||||||
|
- `/mnt/hdd1/llms/LFM2.5/Qwen3-1.7B-UD-Q4_K_XL.gguf` is a 293 MB truncated download
|
||||||
|
in the wrong directory. The good 1.13 GB copy is in `qwen3/`. Delete the stray one.
|
||||||
|
- Harness: `scratchpad/bakeoff.sh`, one server at a time, health-checked before each
|
||||||
|
run, `/v1/models` recorded per run. Never run two LLM consumers at once — see the
|
||||||
|
contamination note in `TALK-EVAL-31-07-2026.md`.
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ func run(args []string) error {
|
|||||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||||
NCtx: cfg.Phraser.NCtx,
|
NCtx: cfg.Phraser.NCtx,
|
||||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||||
|
LLMNudges: cfg.Phraser.LLMNudges,
|
||||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
}
|
}
|
||||||
if pc.BinPath == "" {
|
if pc.BinPath == "" {
|
||||||
@@ -448,6 +449,7 @@ func run(args []string) error {
|
|||||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||||
NCtx: cfg.Phraser.NCtx,
|
NCtx: cfg.Phraser.NCtx,
|
||||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||||
|
LLMNudges: cfg.Phraser.LLMNudges,
|
||||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
}
|
}
|
||||||
if pc.BinPath == "" {
|
if pc.BinPath == "" {
|
||||||
|
|||||||
+4
-3
@@ -6,11 +6,12 @@
|
|||||||
"state_dir": "/var/lib/maven",
|
"state_dir": "/var/lib/maven",
|
||||||
|
|
||||||
"phraser": {
|
"phraser": {
|
||||||
"model_path": "/opt/maven/models/llm/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf",
|
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||||
"bin_path": "llama-server",
|
"bin_path": "llama-server",
|
||||||
"n_gpu_layers": 99,
|
"n_gpu_layers": 99,
|
||||||
"n_ctx": 2048,
|
"n_ctx": 4096,
|
||||||
"timeout": "60s"
|
"timeout": "60s",
|
||||||
|
"llm_nudges": false
|
||||||
},
|
},
|
||||||
|
|
||||||
"telegram": {
|
"telegram": {
|
||||||
|
|||||||
@@ -369,6 +369,12 @@ type PhraserConfig struct {
|
|||||||
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
||||||
NCtx int `json:"n_ctx,omitempty"`
|
NCtx int `json:"n_ctx,omitempty"`
|
||||||
Timeout Duration `json:"timeout,omitempty"`
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// LLMNudges — let the model word nudges again. Off by default: nudges are
|
||||||
|
// worded from hand-written Russian templates now (the model broke the
|
||||||
|
// persona and invented units). Chat, query and reminder phrasing always go
|
||||||
|
// through the model regardless. See phraser.Config.LLMNudges.
|
||||||
|
LLMNudges bool `json:"llm_nudges,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
||||||
|
|||||||
@@ -35,6 +35,27 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nudges come from templates unless the config says otherwise.
|
||||||
|
func TestPhraserLLMNudgesDefaultsOff(t *testing.T) {
|
||||||
|
p := writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf"}}`)
|
||||||
|
c, err := Load(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if c.Phraser.LLMNudges {
|
||||||
|
t.Error("llm_nudges defaults on; templates must be the default")
|
||||||
|
}
|
||||||
|
|
||||||
|
p = writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf","llm_nudges":true}}`)
|
||||||
|
c, err = Load(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if !c.Phraser.LLMNudges {
|
||||||
|
t.Error("llm_nudges:true did not parse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadDurationsParse(t *testing.T) {
|
func TestLoadDurationsParse(t *testing.T) {
|
||||||
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
||||||
c, err := Load(p)
|
c, err := Load(p)
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Package kiwix reads a local Kiwix server (offline Wikipedia and friends).
|
||||||
|
//
|
||||||
|
// Why: the resident model is a 0.8B and invents facts. Letting her read a local
|
||||||
|
// article snippet beats letting her recall. Nothing here talks to the internet;
|
||||||
|
// the Kiwix server is on the same box.
|
||||||
|
//
|
||||||
|
// This is search only. Full articles are ~100KB of HTML, far too big for a 4096
|
||||||
|
// token context, so the unit of context is the search snippet (~500 chars).
|
||||||
|
package kiwix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result is one search hit.
|
||||||
|
type Result struct {
|
||||||
|
Title string // article title, e.g. "Rayleigh scattering"
|
||||||
|
Path string // e.g. /content/wikipedia_en_all_maxi_2026-02/Rayleigh_scattering
|
||||||
|
Snippet string // plain text, tags stripped, entities decoded
|
||||||
|
WordCount int // 0 if the server did not say
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a Kiwix HTTP client. Boring on purpose: no retries, no cache.
|
||||||
|
type Client struct {
|
||||||
|
base string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New makes a client for a Kiwix base URL like http://127.0.0.1:8034.
|
||||||
|
func New(baseURL string) *Client {
|
||||||
|
return &Client{
|
||||||
|
base: strings.TrimRight(baseURL, "/"),
|
||||||
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search runs a keyword search in one ZIM (book) and returns up to limit hits.
|
||||||
|
//
|
||||||
|
// Ranking is keyword based, not semantic: "Rayleigh scattering" finds the right
|
||||||
|
// article, "why is the sky blue" finds a TV episode. Pass keywords, not questions.
|
||||||
|
func (c *Client) Search(ctx context.Context, pattern, book string, limit int) ([]Result, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("pattern", pattern)
|
||||||
|
q.Set("books.name", book)
|
||||||
|
q.Set("format", "xml")
|
||||||
|
q.Set("pageLength", strconv.Itoa(limit))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/search?"+q.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("kiwix search: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return ParseSearchRSS(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rss mirrors just the bits of the RSS 2.0 reply we use.
|
||||||
|
type rss struct {
|
||||||
|
Items []struct {
|
||||||
|
Title string `xml:"title"`
|
||||||
|
Link string `xml:"link"`
|
||||||
|
// innerxml keeps the <b> match markers so we can strip them ourselves.
|
||||||
|
Description struct {
|
||||||
|
Inner string `xml:",innerxml"`
|
||||||
|
} `xml:"description"`
|
||||||
|
WordCount string `xml:"wordCount"`
|
||||||
|
} `xml:"channel>item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagRE = regexp.MustCompile(`<[^>]*>`)
|
||||||
|
|
||||||
|
// ParseSearchRSS turns a Kiwix search reply into results. Exported so the parser
|
||||||
|
// is testable from a captured response, with no server running.
|
||||||
|
func ParseSearchRSS(r io.Reader) ([]Result, error) {
|
||||||
|
var doc rss
|
||||||
|
if err := xml.NewDecoder(r).Decode(&doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("kiwix search: bad xml: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Result, 0, len(doc.Items))
|
||||||
|
for _, it := range doc.Items {
|
||||||
|
n, _ := strconv.Atoi(strings.ReplaceAll(it.WordCount, ",", ""))
|
||||||
|
out = append(out, Result{
|
||||||
|
Title: strings.TrimSpace(it.Title),
|
||||||
|
Path: strings.TrimSpace(it.Link),
|
||||||
|
Snippet: plainText(it.Description.Inner),
|
||||||
|
WordCount: n,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainText drops markup and decodes entities, leaving text a model can read.
|
||||||
|
func plainText(s string) string {
|
||||||
|
s = tagRE.ReplaceAllString(s, "")
|
||||||
|
s = html.UnescapeString(s)
|
||||||
|
return strings.TrimSpace(strings.Join(strings.Fields(s), " "))
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package kiwix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A real reply from the live server, trimmed to two items.
|
||||||
|
const sampleRSS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
|
<channel>
|
||||||
|
<title>Search: Rayleigh scattering</title>
|
||||||
|
<opensearch:totalResults>800</opensearch:totalResults>
|
||||||
|
<item>
|
||||||
|
<title>Rayleigh scattering</title>
|
||||||
|
<link>/content/wikipedia_en_all_maxi_2026-02/Rayleigh_scattering</link>
|
||||||
|
<description><b>Rayleigh</b> scattering causes the blue color of the sky & yellow colors near the Sun.[1]</description>
|
||||||
|
<book><title>Wikipedia</title></book>
|
||||||
|
<wordCount>2,818</wordCount>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Hyper–Rayleigh scattering</title>
|
||||||
|
<link>/content/wikipedia_en_all_maxi_2026-02/Hyper%E2%80%93Rayleigh_scattering</link>
|
||||||
|
<description>...<b>Rayleigh</b> scattering" is a nonlinear optical counterpart.</description>
|
||||||
|
<book><title>Wikipedia</title></book>
|
||||||
|
<wordCount>914</wordCount>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>`
|
||||||
|
|
||||||
|
func TestParseSearchRSS(t *testing.T) {
|
||||||
|
got, err := ParseSearchRSS(strings.NewReader(sampleRSS))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("want 2 results, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Title != "Rayleigh scattering" {
|
||||||
|
t.Errorf("title = %q", got[0].Title)
|
||||||
|
}
|
||||||
|
if got[0].Path != "/content/wikipedia_en_all_maxi_2026-02/Rayleigh_scattering" {
|
||||||
|
t.Errorf("path = %q", got[0].Path)
|
||||||
|
}
|
||||||
|
if got[0].WordCount != 2818 {
|
||||||
|
t.Errorf("wordCount = %d", got[0].WordCount)
|
||||||
|
}
|
||||||
|
want := "Rayleigh scattering causes the blue color of the sky & yellow colors near the Sun.[1]"
|
||||||
|
if got[0].Snippet != want {
|
||||||
|
t.Errorf("snippet = %q, want %q", got[0].Snippet, want)
|
||||||
|
}
|
||||||
|
if strings.Contains(got[1].Snippet, "<b>") {
|
||||||
|
t.Errorf("second snippet still has tags: %q", got[1].Snippet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSearchRSSBadXML(t *testing.T) {
|
||||||
|
if _, err := ParseSearchRSS(strings.NewReader("not xml at all")); err == nil {
|
||||||
|
t.Fatal("want an error on junk input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt-in: needs a live Kiwix server. CI has none.
|
||||||
|
// MAVEN_KIWIX_URL=http://127.0.0.1:8034 no_proxy=127.0.0.1,localhost go test -run Retrieval -v ./internal/kiwix/
|
||||||
|
func TestRetrievalEval(t *testing.T) {
|
||||||
|
base := os.Getenv("MAVEN_KIWIX_URL")
|
||||||
|
if base == "" {
|
||||||
|
t.Skip("set MAVEN_KIWIX_URL to run the retrieval eval")
|
||||||
|
}
|
||||||
|
noProxyLoopback(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rep, err := RunRetrievalEval(ctx, New(base), 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eval: %v", err)
|
||||||
|
}
|
||||||
|
// No pass bar on purpose: the number is the finding.
|
||||||
|
t.Log("\n" + rep.String() + rep.Detail())
|
||||||
|
}
|
||||||
|
|
||||||
|
// noProxyLoopback stops the box's SOCKS bridge from eating loopback requests.
|
||||||
|
func noProxyLoopback(t *testing.T) {
|
||||||
|
t.Setenv("no_proxy", "127.0.0.1,localhost")
|
||||||
|
t.Setenv("NO_PROXY", "127.0.0.1,localhost")
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "kiwix-knowledge-v1",
|
||||||
|
"book": "wikipedia_en_all_maxi_2026-02",
|
||||||
|
"note": "The 9 knowledge cases from internal/phraser/eval/talk_v1.json. Queries are hand-written English keywords on purpose: Kiwix ranks by keyword, not meaning, so a natural question fails. Writing them by hand separates 'retrieval is broken' from 'the model writes bad queries'.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "know-sky-blue",
|
||||||
|
"question": "почему небо синее?",
|
||||||
|
"query": "Rayleigh scattering sky blue",
|
||||||
|
"want_titles": ["Rayleigh scattering", "Diffuse sky radiation"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "know-boil-egg",
|
||||||
|
"question": "сколько варить яйцо вкрутую?",
|
||||||
|
"query": "boiled egg cooking",
|
||||||
|
"want_titles": ["Boiled egg", "Egg as food"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "know-ssd-vs-hdd",
|
||||||
|
"question": "чем ssd отличается от hdd?",
|
||||||
|
"query": "solid-state drive",
|
||||||
|
"want_titles": ["Solid-state drive", "Hard disk drive"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "know-cat-purr",
|
||||||
|
"question": "почему кошки мурчат?",
|
||||||
|
"query": "cat purr",
|
||||||
|
"want_titles": ["Purr", "Cat communication"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "know-hiccups",
|
||||||
|
"question": "как быстро избавиться от икоты?",
|
||||||
|
"query": "hiccup",
|
||||||
|
"want_titles": ["Hiccup"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "know-polite-form",
|
||||||
|
"question": "не могли бы вы объяснить, что такое vpn?",
|
||||||
|
"query": "virtual private network",
|
||||||
|
"want_titles": ["Virtual private network"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "know-dont-know",
|
||||||
|
"question": "как зовут моего соседа снизу?",
|
||||||
|
"query": "name of my downstairs neighbour",
|
||||||
|
"want_titles": [],
|
||||||
|
"expect_miss": true,
|
||||||
|
"note": "Unanswerable by design. Retrieval SHOULD find nothing useful. Counted as a hit only when nothing relevant comes back."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "know-water-per-day",
|
||||||
|
"question": "сколько воды в день надо пить?",
|
||||||
|
"query": "human daily water requirement drinking",
|
||||||
|
"want_titles": ["Drinking water", "Water", "Dehydration", "Hydration"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "know-thunder-delay",
|
||||||
|
"question": "почему гром слышно позже молнии?",
|
||||||
|
"query": "thunder speed of sound lightning",
|
||||||
|
"want_titles": ["Thunder", "Lightning"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package kiwix
|
||||||
|
|
||||||
|
// This scores retrieval alone: no LLM. For each general-knowledge question we
|
||||||
|
// hand-write English keywords and ask whether the article that would answer it
|
||||||
|
// comes back in the top N hits. If this score is low, reading Wikipedia cannot
|
||||||
|
// help the model no matter how good the prompt is.
|
||||||
|
//
|
||||||
|
// The unanswerable case (know-dont-know) is not scored. Whether the junk it
|
||||||
|
// returns is "nothing useful" is a human judgement, so the report just prints
|
||||||
|
// the titles and leaves the score to the 8 answerable cases.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed knowledge_v1.json
|
||||||
|
var knowledgeFixtureJSON []byte
|
||||||
|
|
||||||
|
// EvalCase — one question with hand-written keywords.
|
||||||
|
type EvalCase struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Question string `json:"question"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
WantTitles []string `json:"want_titles"`
|
||||||
|
ExpectMiss bool `json:"expect_miss"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixture struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Book string `json:"book"`
|
||||||
|
Cases []EvalCase `json:"cases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outcome — what one case retrieved.
|
||||||
|
type Outcome struct {
|
||||||
|
Case EvalCase
|
||||||
|
Titles []string // titles of the top N hits, in rank order
|
||||||
|
Rank int // 1-based rank of the first wanted title, 0 if none
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit is true when a wanted title came back.
|
||||||
|
func (o Outcome) Hit() bool { return o.Rank > 0 }
|
||||||
|
|
||||||
|
// Report — the score plus per-case detail.
|
||||||
|
type Report struct {
|
||||||
|
Name string
|
||||||
|
Book string
|
||||||
|
TopN int
|
||||||
|
Scored int // answerable cases
|
||||||
|
Hits int
|
||||||
|
Errors int
|
||||||
|
Outcomes []Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accuracy over the answerable cases.
|
||||||
|
func (r Report) Accuracy() float64 {
|
||||||
|
if r.Scored == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(r.Hits) / float64(r.Scored)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunRetrievalEval searches for every fixture case.
|
||||||
|
func RunRetrievalEval(ctx context.Context, c *Client, topN int) (Report, error) {
|
||||||
|
var f fixture
|
||||||
|
if err := json.Unmarshal(knowledgeFixtureJSON, &f); err != nil {
|
||||||
|
return Report{}, err
|
||||||
|
}
|
||||||
|
rep := Report{Name: f.Name, Book: f.Book, TopN: topN}
|
||||||
|
for _, cs := range f.Cases {
|
||||||
|
res, err := c.Search(ctx, cs.Query, f.Book, topN)
|
||||||
|
o := Outcome{Case: cs, Err: err}
|
||||||
|
if err != nil {
|
||||||
|
rep.Errors++
|
||||||
|
}
|
||||||
|
for i, hit := range res {
|
||||||
|
o.Titles = append(o.Titles, hit.Title)
|
||||||
|
if o.Rank == 0 && matches(cs.WantTitles, hit.Title) {
|
||||||
|
o.Rank = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cs.ExpectMiss {
|
||||||
|
rep.Scored++
|
||||||
|
if o.Hit() {
|
||||||
|
rep.Hits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rep.Outcomes = append(rep.Outcomes, o)
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matches(want []string, title string) bool {
|
||||||
|
for _, w := range want {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(title), w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// String — the headline number.
|
||||||
|
func (r Report) String() string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s: %d/%d answerable questions retrieve a wanted article in top %d (%.1f%%), %d errors\n",
|
||||||
|
r.Name, r.Hits, r.Scored, r.TopN, 100*r.Accuracy(), r.Errors)
|
||||||
|
fmt.Fprintf(&b, " book: %s\n", r.Book)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail — per case: what was asked, what was searched, what came back.
|
||||||
|
func (r Report) Detail() string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, o := range r.Outcomes {
|
||||||
|
mark := "MISS"
|
||||||
|
switch {
|
||||||
|
case o.Case.ExpectMiss:
|
||||||
|
mark = "n/a "
|
||||||
|
case o.Hit():
|
||||||
|
mark = fmt.Sprintf("hit@%d", o.Rank)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %-6s %-20s q=%q\n", mark, o.Case.ID, o.Case.Query)
|
||||||
|
if o.Err != nil {
|
||||||
|
fmt.Fprintf(&b, " error: %v\n", o.Err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " got: %s\n", strings.Join(o.Titles, " | "))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package kiwix
|
||||||
|
|
||||||
|
// Turning a Russian question into an English Kiwix search.
|
||||||
|
//
|
||||||
|
// Kiwix ranks by keyword, not by meaning. "why is the sky blue" returns a TV
|
||||||
|
// episode; "Rayleigh scattering sky blue" returns the right article. So the
|
||||||
|
// model's job here is NOT translation — it is naming the English article the
|
||||||
|
// answer lives in.
|
||||||
|
//
|
||||||
|
// The output space is a handful of words, so it is worth locking down hard: a
|
||||||
|
// GBNF grammar for the shape, a tiny token cap, and a cleanup pass that throws
|
||||||
|
// away anything odd rather than handing junk to Kiwix.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Completer — the LLM seam, so tests can fake it. *llm.Client satisfies it.
|
||||||
|
type Completer interface {
|
||||||
|
Complete(ctx context.Context, r llm.Req) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryGrammar — one JSON object holding 1..6 keyword words. Latin letters,
|
||||||
|
// digits and hyphens only, so the model physically cannot answer the question
|
||||||
|
// or reply in Russian.
|
||||||
|
//
|
||||||
|
// Why the JSON wrapper: this model always thinks out loud and this llama-server
|
||||||
|
// build ignores the thinking switch (see ROUTING-EVAL-31-07-2026.md). A bare
|
||||||
|
// word-list grammar just captured the reasoning — every case came back as
|
||||||
|
// "Let me analyze this request carefully". Demanding JSON, like routeGrammar and
|
||||||
|
// responseGrammar already do, gives the reasoning nowhere to go.
|
||||||
|
const queryGrammar = `
|
||||||
|
root ::= "{" ws "\"query\"" ws ":" ws "\"" word (" " word){0,5} "\"" ws "}"
|
||||||
|
word ::= [A-Za-z0-9] [A-Za-z0-9-]{0,23}
|
||||||
|
ws ::= [ \t\n]*
|
||||||
|
`
|
||||||
|
|
||||||
|
// rewriteSystem — asks for search keywords, not an answer and not a translation.
|
||||||
|
const rewriteSystem = `You turn a question into a search query for English Wikipedia.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Output ONLY English search keywords. Never an answer, never an explanation.
|
||||||
|
- Do NOT translate the sentence. Name the thing the answer is about.
|
||||||
|
- The output must be a noun phrase, like a Wikipedia article title.
|
||||||
|
- Never use question words: no why, how, what, when, which, "how much",
|
||||||
|
"how long", "how to", "vs", "reason", "difference".
|
||||||
|
- 2 to 4 words.
|
||||||
|
|
||||||
|
Reply with JSON: {"query":"<keywords>"}
|
||||||
|
|
||||||
|
Good:
|
||||||
|
"почему листья желтеют осенью?" -> {"query":"leaf senescence autumn"}
|
||||||
|
"как работает микроволновка?" -> {"query":"microwave oven"}
|
||||||
|
"не могли бы вы объяснить, что такое блокчейн?" -> {"query":"blockchain"}
|
||||||
|
"сколько живут собаки?" -> {"query":"dog lifespan"}
|
||||||
|
"как избавиться от комаров в квартире?" -> {"query":"mosquito control"}
|
||||||
|
"чем чай отличается от кофе?" -> {"query":"tea"}
|
||||||
|
|
||||||
|
Only JSON, no explanation.`
|
||||||
|
|
||||||
|
// maxQueryTokens — the output is a few words plus the JSON wrapper. A tight cap
|
||||||
|
// is the cheapest guard against the model rambling into an answer.
|
||||||
|
const maxQueryTokens = 32
|
||||||
|
|
||||||
|
// Rewriter asks the resident model for English search keywords.
|
||||||
|
type Rewriter struct{ c Completer }
|
||||||
|
|
||||||
|
func NewRewriter(c Completer) *Rewriter { return &Rewriter{c: c} }
|
||||||
|
|
||||||
|
// Rewrite returns English keywords for a question in any language.
|
||||||
|
// It errors rather than returning something Kiwix should not see.
|
||||||
|
func (r *Rewriter) Rewrite(ctx context.Context, question string) (string, error) {
|
||||||
|
raw, err := r.c.Complete(ctx, llm.Req{
|
||||||
|
System: rewriteSystem,
|
||||||
|
User: strings.TrimSpace(question),
|
||||||
|
Grammar: queryGrammar,
|
||||||
|
MaxTokens: maxQueryTokens,
|
||||||
|
RepeatPenalty: 1.15,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return CleanQuery(unwrapJSON(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// unwrapJSON pulls the query out of {"query":"..."}. If the reply is not that
|
||||||
|
// shape it is returned as-is, and CleanQuery decides whether it is usable.
|
||||||
|
func unwrapJSON(raw string) string {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
if !strings.HasPrefix(s, "{") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var got struct{ Query string }
|
||||||
|
if err := json.Unmarshal([]byte(s), &got); err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return got.Query
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxQueryWords matches the grammar's bound. Anything longer is prose.
|
||||||
|
const maxQueryWords = 6
|
||||||
|
|
||||||
|
// CleanQuery checks and tidies whatever the model produced. The grammar makes
|
||||||
|
// bad output unlikely, not impossible (a server without grammar support, a
|
||||||
|
// different model), so this is the real gate in front of Kiwix.
|
||||||
|
//
|
||||||
|
// Exported so it can be tested without a model.
|
||||||
|
func CleanQuery(raw string) (string, error) {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
// Models like to wrap answers in quotes. Drop surrounding ones.
|
||||||
|
s = strings.Trim(s, "\"'`")
|
||||||
|
// Keep the first line only: everything after it is prose.
|
||||||
|
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
// Keep letters, digits, spaces and hyphens; anything else becomes a space.
|
||||||
|
var b strings.Builder
|
||||||
|
for _, ru := range s {
|
||||||
|
switch {
|
||||||
|
case unicode.IsLetter(ru) || unicode.IsDigit(ru) || ru == '-':
|
||||||
|
b.WriteRune(ru)
|
||||||
|
default:
|
||||||
|
b.WriteRune(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
words := strings.Fields(b.String())
|
||||||
|
if len(words) == 0 {
|
||||||
|
return "", fmt.Errorf("kiwix rewrite: empty query")
|
||||||
|
}
|
||||||
|
if len(words) > maxQueryWords {
|
||||||
|
return "", fmt.Errorf("kiwix rewrite: %d words, want at most %d (looks like prose)", len(words), maxQueryWords)
|
||||||
|
}
|
||||||
|
words = dropStopWords(words)
|
||||||
|
out := strings.Join(words, " ")
|
||||||
|
// The ZIMs are English. Non-Latin letters mean the model ignored the ask.
|
||||||
|
for _, ru := range out {
|
||||||
|
if unicode.IsLetter(ru) && !isLatin(ru) {
|
||||||
|
return "", fmt.Errorf("kiwix rewrite: query is not English: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopWords — question words and filler. The model keeps writing question-shaped
|
||||||
|
// queries ("why is the sky blue", "how much water to drink daily") no matter how
|
||||||
|
// the prompt is worded, and Kiwix ranks on every word, so those words drag in
|
||||||
|
// song and episode titles. Dropping them in code is not a style preference: a
|
||||||
|
// keyword ranker gets nothing from them.
|
||||||
|
var stopWords = map[string]bool{
|
||||||
|
"a": true, "an": true, "the": true, "is": true, "are": true, "was": true,
|
||||||
|
"do": true, "does": true, "did": true, "to": true, "of": true, "in": true,
|
||||||
|
"on": true, "for": true, "and": true, "or": true, "my": true, "me": true,
|
||||||
|
"i": true, "it": true, "its": true, "be": true, "been": true, "get": true,
|
||||||
|
"how": true, "why": true, "what": true, "when": true, "which": true,
|
||||||
|
"who": true, "where": true, "much": true, "many": true, "long": true,
|
||||||
|
"vs": true, "than": true, "rid": true, "from": true, "about": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropStopWords removes filler, but never everything: if the query was nothing
|
||||||
|
// but stop words there is nothing better to search, so the original is kept and
|
||||||
|
// the caller sees whatever Kiwix makes of it.
|
||||||
|
func dropStopWords(words []string) []string {
|
||||||
|
kept := make([]string, 0, len(words))
|
||||||
|
for _, w := range words {
|
||||||
|
if !stopWords[strings.ToLower(w)] {
|
||||||
|
kept = append(kept, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
return words
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLatin(ru rune) bool {
|
||||||
|
return (ru >= 'a' && ru <= 'z') || (ru >= 'A' && ru <= 'Z')
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package kiwix
|
||||||
|
|
||||||
|
// End-to-end score: Russian question -> model rewrite -> Kiwix search -> did a
|
||||||
|
// wanted article come back. Same 9 cases as the retrieval eval, so the two
|
||||||
|
// numbers are directly comparable: retrieval with hand-written keywords is the
|
||||||
|
// ceiling, this is what the model actually reaches.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RewriteOutcome — one case, end to end.
|
||||||
|
type RewriteOutcome struct {
|
||||||
|
Outcome
|
||||||
|
ModelQuery string // what the model asked for ("" if it failed)
|
||||||
|
RewriteErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunRewriteEval rewrites every question with the model, then searches.
|
||||||
|
func RunRewriteEval(ctx context.Context, c *Client, rw *Rewriter, topN int) (RewriteReport, error) {
|
||||||
|
var f fixture
|
||||||
|
if err := json.Unmarshal(knowledgeFixtureJSON, &f); err != nil {
|
||||||
|
return RewriteReport{}, err
|
||||||
|
}
|
||||||
|
rep := RewriteReport{Report: Report{Name: f.Name + "-rewrite", Book: f.Book, TopN: topN}}
|
||||||
|
for _, cs := range f.Cases {
|
||||||
|
out := RewriteOutcome{Outcome: Outcome{Case: cs}}
|
||||||
|
q, err := rw.Rewrite(ctx, cs.Question)
|
||||||
|
out.ModelQuery, out.RewriteErr = q, err
|
||||||
|
if err == nil {
|
||||||
|
res, serr := c.Search(ctx, q, f.Book, topN)
|
||||||
|
out.Err = serr
|
||||||
|
for i, hit := range res {
|
||||||
|
out.Titles = append(out.Titles, hit.Title)
|
||||||
|
if out.Rank == 0 && matches(cs.WantTitles, hit.Title) {
|
||||||
|
out.Rank = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.RewriteErr != nil || out.Err != nil {
|
||||||
|
rep.Errors++
|
||||||
|
}
|
||||||
|
if !cs.ExpectMiss {
|
||||||
|
rep.Scored++
|
||||||
|
if out.Hit() {
|
||||||
|
rep.Hits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rep.Cases = append(rep.Cases, out)
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RewriteReport — the score plus per-case detail.
|
||||||
|
type RewriteReport struct {
|
||||||
|
Report
|
||||||
|
Cases []RewriteOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
// String — the headline number.
|
||||||
|
func (r RewriteReport) String() string {
|
||||||
|
return fmt.Sprintf("%s: %d/%d answerable questions retrieve a wanted article in top %d (%.1f%%), %d errors\n book: %s\n",
|
||||||
|
r.Name, r.Hits, r.Scored, r.TopN, 100*r.Accuracy(), r.Errors, r.Book)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail — per case: hand-written query next to the model's, and what came back.
|
||||||
|
// The point is seeing WHERE the model's phrasing differs, not just the score.
|
||||||
|
func (r RewriteReport) Detail() string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, o := range r.Cases {
|
||||||
|
mark := "MISS"
|
||||||
|
switch {
|
||||||
|
case o.Case.ExpectMiss:
|
||||||
|
mark = "n/a "
|
||||||
|
case o.Hit():
|
||||||
|
mark = fmt.Sprintf("hit@%d", o.Rank)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %-6s %-20s\n", mark, o.Case.ID)
|
||||||
|
fmt.Fprintf(&b, " asked: %s\n", o.Case.Question)
|
||||||
|
fmt.Fprintf(&b, " hand: %q\n", o.Case.Query)
|
||||||
|
fmt.Fprintf(&b, " model: %q\n", o.ModelQuery)
|
||||||
|
if o.RewriteErr != nil {
|
||||||
|
fmt.Fprintf(&b, " rewrite rejected: %v\n", o.RewriteErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if o.Err != nil {
|
||||||
|
fmt.Fprintf(&b, " search error: %v\n", o.Err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " got: %s\n", strings.Join(o.Titles, " | "))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package kiwix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Opt-in: needs a live Kiwix server AND a live llama-server.
|
||||||
|
// MAVEN_KIWIX_URL=http://127.0.0.1:8034 MAVEN_LLM_URL=http://127.0.0.1:18099 \
|
||||||
|
//
|
||||||
|
// no_proxy=127.0.0.1,localhost go test -run RewriteEval -v ./internal/kiwix/
|
||||||
|
func TestRewriteEval(t *testing.T) {
|
||||||
|
kbase, lbase := os.Getenv("MAVEN_KIWIX_URL"), os.Getenv("MAVEN_LLM_URL")
|
||||||
|
if kbase == "" || lbase == "" {
|
||||||
|
t.Skip("set MAVEN_KIWIX_URL and MAVEN_LLM_URL to run the rewrite eval")
|
||||||
|
}
|
||||||
|
noProxyLoopback(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rw := NewRewriter(llm.New(lbase, 3*time.Minute))
|
||||||
|
rep, err := RunRewriteEval(ctx, New(kbase), rw, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eval: %v", err)
|
||||||
|
}
|
||||||
|
// No pass bar on purpose: the number is the finding.
|
||||||
|
t.Log("\n" + rep.String() + rep.Detail())
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package kiwix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bad model output must never reach Kiwix. No model needed for this.
|
||||||
|
func TestCleanQueryRejectsJunk(t *testing.T) {
|
||||||
|
bad := []struct{ name, raw string }{
|
||||||
|
{"empty", ""},
|
||||||
|
{"blank", " \n "},
|
||||||
|
{"russian came back", "почему небо синее"},
|
||||||
|
{"mixed russian", "sky синее scattering"},
|
||||||
|
{"full sentence", "The sky looks blue because of the scattering of sunlight by air molecules"},
|
||||||
|
{"prose with quotes", `Sure! Here is a good search query: "Rayleigh scattering", which explains it.`},
|
||||||
|
}
|
||||||
|
for _, c := range bad {
|
||||||
|
if got, err := CleanQuery(c.raw); err == nil {
|
||||||
|
t.Errorf("%s: want rejection, got %q", c.name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanQueryCleans(t *testing.T) {
|
||||||
|
ok := []struct{ raw, want string }{
|
||||||
|
{"Rayleigh scattering sky", "Rayleigh scattering sky"},
|
||||||
|
{" boiled egg cooking \n", "boiled egg cooking"},
|
||||||
|
{`"virtual private network"`, "virtual private network"},
|
||||||
|
{"solid-state drive", "solid-state drive"},
|
||||||
|
{"cat purr.", "cat purr"},
|
||||||
|
{"hiccup\nAlso: hiccough", "hiccup"},
|
||||||
|
// Question words are filler to a keyword ranker, so they go.
|
||||||
|
{"why is the sky blue", "sky blue"},
|
||||||
|
{"how much water to drink daily", "water drink daily"},
|
||||||
|
{"SSD vs HDD comparison", "SSD HDD comparison"},
|
||||||
|
// Nothing but filler: keep it rather than return nothing.
|
||||||
|
{"what is it", "what is it"},
|
||||||
|
}
|
||||||
|
for _, c := range ok {
|
||||||
|
got, err := CleanQuery(c.raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%q: %v", c.raw, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%q -> %q, want %q", c.raw, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeCompleter struct {
|
||||||
|
out string
|
||||||
|
req llm.Req
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompleter) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||||
|
f.req = r
|
||||||
|
return f.out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteConstrainsTheCall(t *testing.T) {
|
||||||
|
f := &fakeCompleter{out: `{"query":"Rayleigh scattering sky"}`}
|
||||||
|
got, err := NewRewriter(f).Rewrite(context.Background(), "почему небо синее?")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rewrite: %v", err)
|
||||||
|
}
|
||||||
|
if got != "Rayleigh scattering sky" {
|
||||||
|
t.Errorf("query = %q", got)
|
||||||
|
}
|
||||||
|
if f.req.Grammar == "" {
|
||||||
|
t.Error("no grammar sent")
|
||||||
|
}
|
||||||
|
if f.req.MaxTokens == 0 || f.req.MaxTokens > 32 {
|
||||||
|
t.Errorf("max_tokens = %d, want a small cap", f.req.MaxTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteRejectsBadModelOutput(t *testing.T) {
|
||||||
|
bad := []string{
|
||||||
|
`{"query":"почему небо синее"}`, // never translated
|
||||||
|
`{"query":""}`, // empty
|
||||||
|
`{"query":"the sky is blue because sunlight is scattered by air"}`, // an answer
|
||||||
|
// Note: a SHORT English prose fragment ("Let me analyze this request")
|
||||||
|
// is under the word cap and cannot be caught here. The grammar is what
|
||||||
|
// stops that one.
|
||||||
|
}
|
||||||
|
for _, out := range bad {
|
||||||
|
f := &fakeCompleter{out: out}
|
||||||
|
if got, err := NewRewriter(f).Rewrite(context.Background(), "почему небо синее?"); err == nil {
|
||||||
|
t.Errorf("%s: want rejection, got %q", out, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reply that is not the JSON shape but is still usable keywords should pass.
|
||||||
|
func TestRewriteFallsBackToPlainText(t *testing.T) {
|
||||||
|
f := &fakeCompleter{out: "Rayleigh scattering sky"}
|
||||||
|
got, err := NewRewriter(f).Rewrite(context.Background(), "почему небо синее?")
|
||||||
|
if err != nil || got != "Rayleigh scattering sky" {
|
||||||
|
t.Errorf("got %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package eval
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTemplateNudges scores the hand-written Russian templates on the same
|
||||||
|
// fixture the model is scored on. No model, no network — it runs in milliseconds.
|
||||||
|
//
|
||||||
|
// The bar is every case, not most of them: the templates are hand-written, so a
|
||||||
|
// failure is a bug in one line of Russian, not model variance.
|
||||||
|
func TestTemplateNudges(t *testing.T) {
|
||||||
|
f, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
// Fixed seed: the score must not depend on which variant came up.
|
||||||
|
nt, err := phraser.NewNudgeTemplates(rand.NewSource(20260731))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewNudgeTemplates: %v", err)
|
||||||
|
}
|
||||||
|
rep, err := Score(context.Background(), "ru templates", nt, f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Score: %v", err)
|
||||||
|
}
|
||||||
|
t.Log("\n" + rep.String())
|
||||||
|
t.Log("\n" + rep.Messages())
|
||||||
|
if rep.Passed != rep.Total {
|
||||||
|
t.Errorf("templates scored %d/%d, want every case:\n%s",
|
||||||
|
rep.Passed, rep.Total, rep.Failures())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTemplateNudgesEverySeed — one seed passing could be luck. Every variant of
|
||||||
|
// every rule has to pass every check, so sweep seeds until each has been used.
|
||||||
|
func TestTemplateNudgesEverySeed(t *testing.T) {
|
||||||
|
f, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
for seed := int64(0); seed < 60; seed++ {
|
||||||
|
nt, err := phraser.NewNudgeTemplates(rand.NewSource(seed))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewNudgeTemplates: %v", err)
|
||||||
|
}
|
||||||
|
rep, err := Score(context.Background(), "ru templates", nt, f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Score: %v", err)
|
||||||
|
}
|
||||||
|
if rep.Passed != rep.Total {
|
||||||
|
t.Errorf("seed %d: %d/%d\n%s", seed, rep.Passed, rep.Total, rep.Failures())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ func newGrammarSpy(t *testing.T) *grammarSpy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// callAllPhrasingPaths hits every path that expects the JSON contract.
|
// callAllPhrasingPaths hits every path that expects the JSON contract.
|
||||||
|
// LLMNudges must be set on the phraser under test: nudges come from templates
|
||||||
|
// by default and never reach the model at all.
|
||||||
func callAllPhrasingPaths(t *testing.T, p *LLMPhraser) {
|
func callAllPhrasingPaths(t *testing.T, p *LLMPhraser) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -58,7 +60,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
|||||||
t.Fatal("responseGrammar is empty")
|
t.Fatal("responseGrammar is empty")
|
||||||
}
|
}
|
||||||
spy := newGrammarSpy(t)
|
spy := newGrammarSpy(t)
|
||||||
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
||||||
|
|
||||||
callAllPhrasingPaths(t, p)
|
callAllPhrasingPaths(t, p)
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
|||||||
|
|
||||||
func TestNoGrammarConfigDisablesIt(t *testing.T) {
|
func TestNoGrammarConfigDisablesIt(t *testing.T) {
|
||||||
spy := newGrammarSpy(t)
|
spy := newGrammarSpy(t)
|
||||||
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true})
|
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true, LLMNudges: true})
|
||||||
|
|
||||||
callAllPhrasingPaths(t, p)
|
callAllPhrasingPaths(t, p)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ type LLMPhraser struct {
|
|||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
// tmpl — the hand-written Russian nudges. Default path for nudges; see
|
||||||
|
// Config.LLMNudges. nil only if the template file failed to load.
|
||||||
|
tmpl *NudgeTemplates
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -46,6 +50,19 @@ type Config struct {
|
|||||||
// nil ⇒ no block, the prompts stand alone.
|
// nil ⇒ no block, the prompts stand alone.
|
||||||
ContextBlock func() string
|
ContextBlock func() string
|
||||||
|
|
||||||
|
// LLMNudges puts the model back in charge of nudge wording.
|
||||||
|
//
|
||||||
|
// Off by default, and that is a deliberate deprecation of LLM-phrased
|
||||||
|
// nudges: hand-written templates (nudges_ru_v1.json) word every nudge now.
|
||||||
|
// A nudge has nothing to be creative about, and measured over many runs the
|
||||||
|
// 0.8B broke the persona (formal "вы", plural imperatives, masculine
|
||||||
|
// self-reference) and invented facts and units. Templates score 15/15 on the
|
||||||
|
// nudge fixture, the model 11-13/15.
|
||||||
|
//
|
||||||
|
// The LLM path is kept, not deleted: flip this on to get it back. Chat,
|
||||||
|
// query and reminder phrasing are untouched and still go through the model.
|
||||||
|
LLMNudges bool
|
||||||
|
|
||||||
// NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON).
|
// NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON).
|
||||||
// The escape hatch exists because the target resident model — the
|
// The escape hatch exists because the target resident model — the
|
||||||
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
||||||
@@ -71,6 +88,7 @@ func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
client: &http.Client{Timeout: cfg.Timeout},
|
client: &http.Client{Timeout: cfg.Timeout},
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
|
tmpl: loadNudgeTemplates(),
|
||||||
}
|
}
|
||||||
if err := p.start(ctx); err != nil {
|
if err := p.start(ctx); err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
@@ -92,9 +110,22 @@ func NewLLMPhraserAt(baseURL string, cfg Config) *LLMPhraser {
|
|||||||
client: &http.Client{Timeout: cfg.Timeout},
|
client: &http.Client{Timeout: cfg.Timeout},
|
||||||
port: strings.TrimSuffix(baseURL, "/"),
|
port: strings.TrimSuffix(baseURL, "/"),
|
||||||
cancel: func() {},
|
cancel: func() {},
|
||||||
|
tmpl: loadNudgeTemplates(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadNudgeTemplates loads the Russian nudge templates. A broken template file
|
||||||
|
// must not stop the daemon booting, so a failure logs and leaves the LLM path
|
||||||
|
// in charge of nudges.
|
||||||
|
func loadNudgeTemplates() *NudgeTemplates {
|
||||||
|
nt, err := NewNudgeTemplates(nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("phraser: nudge templates unavailable, using the model: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nt
|
||||||
|
}
|
||||||
|
|
||||||
func (p *LLMPhraser) start(ctx context.Context) error {
|
func (p *LLMPhraser) start(ctx context.Context) error {
|
||||||
args := []string{
|
args := []string{
|
||||||
"-m", p.cfg.ModelPath,
|
"-m", p.cfg.ModelPath,
|
||||||
@@ -185,6 +216,10 @@ func (p *LLMPhraser) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
||||||
|
// Templates first — see Config.LLMNudges for why this is the default.
|
||||||
|
if !p.cfg.LLMNudges && p.tmpl != nil {
|
||||||
|
return p.tmpl.PhraseNudge(ctx, c)
|
||||||
|
}
|
||||||
prompt := buildNudgePrompt(c)
|
prompt := buildNudgePrompt(c)
|
||||||
resp, err := p.chat(ctx, prompt)
|
resp, err := p.chat(ctx, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
// Hand-written Russian nudges instead of generated ones.
|
||||||
|
//
|
||||||
|
// Why: on a nudge there is nothing to be creative about. Measured over many
|
||||||
|
// runs, Qwen3.5-0.8B breaks the persona (formal "вы", plural imperatives,
|
||||||
|
// masculine self-reference) and invents facts and units — it once told him to
|
||||||
|
// boil an egg for "90-95 секунд". A nudge is five words of known content, so
|
||||||
|
// wording it with a model buys nothing and risks the persona every time.
|
||||||
|
//
|
||||||
|
// The wording lives in nudges_ru_v1.json so it can be edited without touching
|
||||||
|
// Go. This file only picks one and fills in the values.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed nudges_ru_v1.json
|
||||||
|
var nudgeTemplateJSON []byte
|
||||||
|
|
||||||
|
// NudgeTemplateSchemaVersion — the version this code understands.
|
||||||
|
const NudgeTemplateSchemaVersion = 1
|
||||||
|
|
||||||
|
type nudgeRuleSet struct {
|
||||||
|
Mood string `json:"mood"`
|
||||||
|
Variants []string `json:"variants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nudgeTemplateFile struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Notes []string `json:"notes"`
|
||||||
|
Rules map[string]nudgeRuleSet `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NudgeTemplates picks a hand-written Russian nudge for a candidate.
|
||||||
|
//
|
||||||
|
// Safe for concurrent use. Random, but never the same variant twice in a row
|
||||||
|
// for the same rule — being nagged with identical words is what makes a nudge
|
||||||
|
// easy to tune out.
|
||||||
|
type NudgeTemplates struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rnd *rand.Rand
|
||||||
|
last map[string]string // rule family -> the text used last time
|
||||||
|
file nudgeTemplateFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNudgeTemplates loads the embedded template file. Pass a source to make the
|
||||||
|
// picking reproducible in tests; nil means seed from the clock.
|
||||||
|
func NewNudgeTemplates(src rand.Source) (*NudgeTemplates, error) {
|
||||||
|
var f nudgeTemplateFile
|
||||||
|
if err := json.Unmarshal(nudgeTemplateJSON, &f); err != nil {
|
||||||
|
return nil, fmt.Errorf("nudge templates: parse: %w", err)
|
||||||
|
}
|
||||||
|
if f.SchemaVersion != NudgeTemplateSchemaVersion {
|
||||||
|
return nil, fmt.Errorf("nudge templates: schema_version %d, want %d",
|
||||||
|
f.SchemaVersion, NudgeTemplateSchemaVersion)
|
||||||
|
}
|
||||||
|
if len(f.Rules) == 0 {
|
||||||
|
return nil, fmt.Errorf("nudge templates: no rules")
|
||||||
|
}
|
||||||
|
if src == nil {
|
||||||
|
src = rand.NewSource(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return &NudgeTemplates{
|
||||||
|
rnd: rand.New(src),
|
||||||
|
last: map[string]string{},
|
||||||
|
file: f,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhraseNudge implements the nudge half of the Phraser interface, so the
|
||||||
|
// templates can be scored by the same harness as the model.
|
||||||
|
func (t *NudgeTemplates) PhraseNudge(_ context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
||||||
|
body, mood := t.Nudge(c)
|
||||||
|
return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: body, Mood: mood}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nudge returns the text and the mood for one candidate. Never fails: if no
|
||||||
|
// template fits it uses the plain per-rule fallback.
|
||||||
|
func (t *NudgeTemplates) Nudge(c loop.Candidate) (body, mood string) {
|
||||||
|
rule := c.Rule.Name
|
||||||
|
family := t.family(rule)
|
||||||
|
set, ok := t.file.Rules[family]
|
||||||
|
if !ok {
|
||||||
|
return fallbackNudge(c), "neutral"
|
||||||
|
}
|
||||||
|
vals := nudgeValues(c)
|
||||||
|
|
||||||
|
// Only variants whose placeholders all have a value.
|
||||||
|
usable := make([]string, 0, len(set.Variants))
|
||||||
|
for _, v := range set.Variants {
|
||||||
|
if text, ok := fillTemplate(v, vals); ok {
|
||||||
|
usable = append(usable, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(usable) == 0 {
|
||||||
|
return fallbackNudge(c), "neutral"
|
||||||
|
}
|
||||||
|
|
||||||
|
mood = set.Mood
|
||||||
|
if mood == "" {
|
||||||
|
mood = "neutral"
|
||||||
|
}
|
||||||
|
return t.pick(family, usable), mood
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick chooses at random, skipping whatever this rule said last time.
|
||||||
|
func (t *NudgeTemplates) pick(family string, usable []string) string {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
choices := usable
|
||||||
|
if len(usable) > 1 {
|
||||||
|
choices = make([]string, 0, len(usable))
|
||||||
|
for _, v := range usable {
|
||||||
|
if v != t.last[family] {
|
||||||
|
choices = append(choices, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(choices) == 0 { // every variant equals the last one
|
||||||
|
choices = usable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := choices[t.rnd.Intn(len(choices))]
|
||||||
|
t.last[family] = got
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// family maps a rule name to a block in the template file: an exact match
|
||||||
|
// first, then the prefix of "routine:зарядка" / "morning:утро", then "default".
|
||||||
|
func (t *NudgeTemplates) family(rule string) string {
|
||||||
|
if _, ok := t.file.Rules[rule]; ok {
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(rule, ':'); i > 0 {
|
||||||
|
if _, ok := t.file.Rules[rule[:i]]; ok {
|
||||||
|
return rule[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholderRE — the {name} slots a template may use.
|
||||||
|
var placeholderRE = regexp.MustCompile(`\{([a-z]+)\}`)
|
||||||
|
|
||||||
|
// nudgeValues collects what this candidate can fill in. A key missing here
|
||||||
|
// means every template needing it is skipped, so nothing half-filled is ever
|
||||||
|
// spoken.
|
||||||
|
func nudgeValues(c loop.Candidate) map[string]string {
|
||||||
|
vals := map[string]string{}
|
||||||
|
rule := c.Rule.Name
|
||||||
|
|
||||||
|
// {since} — only at hour scale. Below an hour the phrase would be minutes,
|
||||||
|
// and none of the templates read well with "сорок минут".
|
||||||
|
if d, ok := c.State.Since(rule); ok && d >= time.Hour {
|
||||||
|
if s := ruSinceWords(d); s != "" {
|
||||||
|
vals["since"] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// {service} — the aggregate fact's key carries the service name.
|
||||||
|
if f, ok := c.State.Fact(rule); ok && f.Key != "" && f.Key != rule {
|
||||||
|
vals["service"] = f.Key
|
||||||
|
}
|
||||||
|
// {what} — the Russian suffix of "routine:таблетки" / "morning:утро".
|
||||||
|
if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
|
||||||
|
vals["what"] = rule[i+1:]
|
||||||
|
}
|
||||||
|
return vals
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillTemplate substitutes the placeholders. Returns false when a value is
|
||||||
|
// missing, so a raw "{since}" can never reach the text-to-speech voice.
|
||||||
|
func fillTemplate(tmpl string, vals map[string]string) (string, bool) {
|
||||||
|
missing := false
|
||||||
|
out := placeholderRE.ReplaceAllStringFunc(tmpl, func(m string) string {
|
||||||
|
name := m[1 : len(m)-1]
|
||||||
|
v, ok := vals[name]
|
||||||
|
if !ok || v == "" {
|
||||||
|
missing = true
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
})
|
||||||
|
if missing || strings.ContainsAny(out, "{}%") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return capitalizeFirst(out), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// capitalizeFirst — a placeholder can start the sentence, and "полтора часа без
|
||||||
|
// перерыва" should be spoken as a sentence, not a fragment.
|
||||||
|
func capitalizeFirst(s string) string {
|
||||||
|
for i, r := range s {
|
||||||
|
return string(unicode.ToUpper(r)) + s[i+len(string(r)):]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// hourWords — hours spelled out. "3 ч" is fine on a screen and wrong in a
|
||||||
|
// Russian voice, so the number goes out as words.
|
||||||
|
var hourWords = []string{
|
||||||
|
"ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь",
|
||||||
|
"девять", "десять", "одиннадцать", "двенадцать", "тринадцать",
|
||||||
|
"четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать",
|
||||||
|
"девятнадцать", "двадцать", "двадцать один", "двадцать два", "двадцать три",
|
||||||
|
}
|
||||||
|
|
||||||
|
// hourPlural — час / часа / часов by Russian counting rules.
|
||||||
|
func hourPlural(h int) string {
|
||||||
|
if h%100 >= 11 && h%100 <= 14 {
|
||||||
|
return "часов"
|
||||||
|
}
|
||||||
|
switch h % 10 {
|
||||||
|
case 1:
|
||||||
|
return "час"
|
||||||
|
case 2, 3, 4:
|
||||||
|
return "часа"
|
||||||
|
default:
|
||||||
|
return "часов"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruSinceWords — "полтора часа", "два с половиной часа", "семь часов".
|
||||||
|
// Empty string means "do not say it" (under an hour, or over a day).
|
||||||
|
func ruSinceWords(d time.Duration) string {
|
||||||
|
if d < time.Hour {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(d.Minutes()) % 60
|
||||||
|
if m >= 45 {
|
||||||
|
h++
|
||||||
|
m = 0
|
||||||
|
}
|
||||||
|
if h >= len(hourWords) {
|
||||||
|
return "больше суток"
|
||||||
|
}
|
||||||
|
if h == 1 {
|
||||||
|
if m >= 15 {
|
||||||
|
return "полтора часа"
|
||||||
|
}
|
||||||
|
return "час"
|
||||||
|
}
|
||||||
|
if m >= 15 {
|
||||||
|
return hourWords[h] + " с половиной часа"
|
||||||
|
}
|
||||||
|
return hourWords[h] + " " + hourPlural(h)
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cand builds a candidate the way a tick would.
|
||||||
|
func cand(rule string, sinceMin int, factKey string) loop.Candidate {
|
||||||
|
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
|
||||||
|
st := loop.State{Now: now, Facts: map[string]store.Fact{}}
|
||||||
|
if sinceMin > 0 || factKey != "" {
|
||||||
|
key := rule
|
||||||
|
if factKey != "" {
|
||||||
|
key = factKey
|
||||||
|
}
|
||||||
|
st.Facts[rule] = store.Fact{Key: key, Ts: now.Add(-time.Duration(sinceMin) * time.Minute)}
|
||||||
|
}
|
||||||
|
return loop.Candidate{Rule: loop.Rule{Name: rule, Severity: loop.Sev1}, Severity: loop.Sev1, State: st}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestTemplates(t *testing.T, seed int64) *NudgeTemplates {
|
||||||
|
t.Helper()
|
||||||
|
nt, err := NewNudgeTemplates(rand.NewSource(seed))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewNudgeTemplates: %v", err)
|
||||||
|
}
|
||||||
|
return nt
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNudgeTemplatesLoad(t *testing.T) {
|
||||||
|
nt := newTestTemplates(t, 1)
|
||||||
|
for _, rule := range []string{"water", "meal", "break", "service_down", "netdata_critical", "routine", "morning", "default"} {
|
||||||
|
set, ok := nt.file.Rules[rule]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("no templates for %q", rule)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(set.Variants) < 5 {
|
||||||
|
t.Errorf("%s: only %d variants", rule, len(set.Variants))
|
||||||
|
}
|
||||||
|
// Every rule needs one variant that needs no value, or a candidate
|
||||||
|
// without context has nothing to say. routine and morning are exempt:
|
||||||
|
// they always carry a name and must always say it.
|
||||||
|
plain := 0
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, v := range set.Variants {
|
||||||
|
if !placeholderRE.MatchString(v) {
|
||||||
|
plain++
|
||||||
|
}
|
||||||
|
if seen[v] {
|
||||||
|
t.Errorf("%s: duplicate variant %q", rule, v)
|
||||||
|
}
|
||||||
|
seen[v] = true
|
||||||
|
}
|
||||||
|
if plain == 0 && rule != "routine" && rule != "morning" {
|
||||||
|
t.Errorf("%s: every variant needs a placeholder value", rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the picker: never the same words twice in a row.
|
||||||
|
func TestNudgeNoImmediateRepeat(t *testing.T) {
|
||||||
|
nt := newTestTemplates(t, 7)
|
||||||
|
prev := ""
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
body, _ := nt.Nudge(cand("water", 200, ""))
|
||||||
|
if body == prev {
|
||||||
|
t.Fatalf("repeat at %d: %q", i, body)
|
||||||
|
}
|
||||||
|
prev = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same seed, same sequence — otherwise the fixture score would drift run to run.
|
||||||
|
func TestNudgeDeterministicWithSeed(t *testing.T) {
|
||||||
|
var runs [2][]string
|
||||||
|
for r := range runs {
|
||||||
|
nt := newTestTemplates(t, 42)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
body, _ := nt.Nudge(cand("break", 100, ""))
|
||||||
|
runs[r] = append(runs[r], body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range runs[0] {
|
||||||
|
if runs[0][i] != runs[1][i] {
|
||||||
|
t.Fatalf("run %d differs: %q vs %q", i, runs[0][i], runs[1][i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A variant is only used when its value exists, and nothing half-filled ships.
|
||||||
|
func TestNudgeNoLeftoverPlaceholders(t *testing.T) {
|
||||||
|
nt := newTestTemplates(t, 3)
|
||||||
|
cases := []loop.Candidate{
|
||||||
|
cand("water", 0, ""), // no duration
|
||||||
|
cand("water", 30, ""), // under an hour
|
||||||
|
cand("water", 200, ""), // hours
|
||||||
|
cand("service_down", 3, "vaultwarden"),
|
||||||
|
cand("service_down", 3, ""), // no service name
|
||||||
|
cand("routine:таблетки", 0, ""),
|
||||||
|
cand("morning:утро", 0, ""),
|
||||||
|
cand("unknown_rule", 0, ""),
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
body, mood := nt.Nudge(c)
|
||||||
|
if body == "" {
|
||||||
|
t.Fatalf("%s: empty body", c.Rule.Name)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(body, "{}%") {
|
||||||
|
t.Fatalf("%s: unfilled template %q", c.Rule.Name, body)
|
||||||
|
}
|
||||||
|
if mood != "neutral" {
|
||||||
|
t.Fatalf("%s: mood %q", c.Rule.Name, mood)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The routine name must actually land in the text.
|
||||||
|
func TestNudgeSubstitutesWhat(t *testing.T) {
|
||||||
|
nt := newTestTemplates(t, 11)
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
body, _ := nt.Nudge(cand("routine:таблетки", 0, ""))
|
||||||
|
if !strings.Contains(strings.ToLower(body), "таблетки") {
|
||||||
|
t.Fatalf("routine text lost the name: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuSinceWords(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
min int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{30, ""},
|
||||||
|
{60, "час"},
|
||||||
|
{95, "полтора часа"},
|
||||||
|
{150, "два с половиной часа"},
|
||||||
|
{190, "три часа"},
|
||||||
|
{240, "четыре часа"},
|
||||||
|
{430, "семь часов"},
|
||||||
|
{660, "одиннадцать часов"},
|
||||||
|
{60 * 30, "больше суток"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := ruSinceWords(time.Duration(c.min) * time.Minute)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%d min: got %q want %q", c.min, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates are the default: a nudge must not reach the model at all.
|
||||||
|
func TestLLMPhraserUsesTemplatesByDefault(t *testing.T) {
|
||||||
|
spy := newGrammarSpy(t)
|
||||||
|
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||||
|
pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PhraseNudge: %v", err)
|
||||||
|
}
|
||||||
|
if len(spy.grammars) != 0 {
|
||||||
|
t.Errorf("nudge hit the model %d times, want 0", len(spy.grammars))
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(pn.Body), "вод") {
|
||||||
|
t.Errorf("nudge is not the water template: %q", pn.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...and the flag brings the model back.
|
||||||
|
func TestLLMNudgesFlagRestoresTheModel(t *testing.T) {
|
||||||
|
spy := newGrammarSpy(t)
|
||||||
|
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
||||||
|
pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PhraseNudge: %v", err)
|
||||||
|
}
|
||||||
|
if len(spy.grammars) != 1 {
|
||||||
|
t.Fatalf("nudge hit the model %d times, want 1", len(spy.grammars))
|
||||||
|
}
|
||||||
|
if pn.Body != "ага" {
|
||||||
|
t.Errorf("body = %q, want the model's reply", pn.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNudgeTemplatesPhraseNudge(t *testing.T) {
|
||||||
|
nt := newTestTemplates(t, 5)
|
||||||
|
pn, err := nt.PhraseNudge(context.Background(), cand("water", 200, ""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PhraseNudge: %v", err)
|
||||||
|
}
|
||||||
|
if pn.Body == "" || pn.Summary != pn.Body || pn.Mood != "neutral" {
|
||||||
|
t.Fatalf("bad nudge: %+v", pn)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "russian nudge templates v1",
|
||||||
|
"notes": [
|
||||||
|
"Hand-written Russian nudges. Edit the wording here, no Go changes needed.",
|
||||||
|
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives (выпейте), never он/его about him.",
|
||||||
|
"One short sentence. No questions, no emoji, no pet names, no emotional support.",
|
||||||
|
"Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine and morning: those only exist for rules like routine:таблетки that always carry a name, and a routine nudge that drops the name is useless.",
|
||||||
|
"mood must be one of: neutral, happy, thinking, tired, confused."
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"water": {
|
||||||
|
"mood": "neutral",
|
||||||
|
"variants": [
|
||||||
|
"Ты не пил воду {since} — выпей стакан.",
|
||||||
|
"Пора выпить воды.",
|
||||||
|
"Стакан воды не помешает.",
|
||||||
|
"Воду ты не пил уже {since}.",
|
||||||
|
"Напоминаю про воду.",
|
||||||
|
"Сходи за водой, дела подождут.",
|
||||||
|
"Сделай глоток воды, пока помнишь.",
|
||||||
|
"Между делом выпей воды.",
|
||||||
|
"Вода — простое дело: выпей стакан.",
|
||||||
|
"Отвлекись на стакан воды."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"meal": {
|
||||||
|
"mood": "neutral",
|
||||||
|
"variants": [
|
||||||
|
"Ты не ел {since} — поешь.",
|
||||||
|
"Пора поесть, сделай перекус.",
|
||||||
|
"Еда важнее ещё одного часа за столом.",
|
||||||
|
"Без еды уже {since}, поешь.",
|
||||||
|
"Напоминаю про еду — поешь.",
|
||||||
|
"Возьми перерыв на обед.",
|
||||||
|
"Сделай себе перекус, это пять минут.",
|
||||||
|
"Поешь, потом вернёшься к работе.",
|
||||||
|
"Поешь нормально, а не на ходу.",
|
||||||
|
"Еды не было {since} — разогрей что-нибудь."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"break": {
|
||||||
|
"mood": "neutral",
|
||||||
|
"variants": [
|
||||||
|
"Ты за столом {since} — встань и разомнись.",
|
||||||
|
"Пора сделать перерыв.",
|
||||||
|
"Встань на пять минут.",
|
||||||
|
"{since} без перерыва — отойди от экрана.",
|
||||||
|
"Напоминаю про перерыв.",
|
||||||
|
"Разомни спину, потом продолжишь.",
|
||||||
|
"Короткая пауза не сорвёт дела.",
|
||||||
|
"Отойди от компьютера на минуту.",
|
||||||
|
"Сидишь без перерыва {since}.",
|
||||||
|
"Встань, пройдись, вернись."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"service_down": {
|
||||||
|
"mood": "neutral",
|
||||||
|
"variants": [
|
||||||
|
"Сервис {service} не отвечает.",
|
||||||
|
"{service} упал — сервис не отвечает.",
|
||||||
|
"{service} не отвечает, сервис нужно поднимать.",
|
||||||
|
"Сервис {service} недоступен.",
|
||||||
|
"Проверь {service}: сервис не отвечает.",
|
||||||
|
"Сервис перестал отвечать.",
|
||||||
|
"Сервис {service} лежит, нужно смотреть.",
|
||||||
|
"{service} не отвечает уже {since}.",
|
||||||
|
"Мониторинг сообщает: {service} лежит.",
|
||||||
|
"Сервис {service} не отвечает, посмотри логи."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"netdata_critical": {
|
||||||
|
"mood": "neutral",
|
||||||
|
"variants": [
|
||||||
|
"Netdata: критический алярм, проверь диск.",
|
||||||
|
"Критический алярм в netdata — посмотри диск.",
|
||||||
|
"Netdata поднял тревогу по диску.",
|
||||||
|
"Проверь диск: netdata ругается.",
|
||||||
|
"Алярм от netdata, критический.",
|
||||||
|
"Netdata: критический уровень, дело в диске.",
|
||||||
|
"Диск требует внимания — критический алярм в netdata.",
|
||||||
|
"Критический алярм: проверь место на диске.",
|
||||||
|
"Netdata сообщает о критической проблеме с диском.",
|
||||||
|
"Открой netdata: там критический алярм по диску."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"routine": {
|
||||||
|
"mood": "neutral",
|
||||||
|
"variants": [
|
||||||
|
"По распорядку: {what}.",
|
||||||
|
"Пора — {what}.",
|
||||||
|
"Напоминаю: {what}.",
|
||||||
|
"В списке на сейчас: {what}.",
|
||||||
|
"{what} — сейчас самое время.",
|
||||||
|
"Не пропусти: {what}.",
|
||||||
|
"{what}: пора сделать.",
|
||||||
|
"Сейчас по плану {what}.",
|
||||||
|
"Твой распорядок: {what}.",
|
||||||
|
"{what} — по распорядку сейчас."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"morning": {
|
||||||
|
"mood": "neutral",
|
||||||
|
"variants": [
|
||||||
|
"{what} — пора начать день.",
|
||||||
|
"{what}: пройди утренний список.",
|
||||||
|
"Начни {what} со списка.",
|
||||||
|
"{what}. Осталось пройти чеклист.",
|
||||||
|
"Утренний список ещё не пройден: {what}.",
|
||||||
|
"{what}: первый пункт списка за тобой.",
|
||||||
|
"{what} идёт, а список стоит.",
|
||||||
|
"{what}: не забудь про утренние дела.",
|
||||||
|
"По утреннему чеклисту ещё есть дела: {what}.",
|
||||||
|
"{what} — утренний список дел ещё ждёт."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"mood": "neutral",
|
||||||
|
"variants": [
|
||||||
|
"Напоминаю: есть дело.",
|
||||||
|
"Пора вернуться к отложенному делу.",
|
||||||
|
"Одно дело ждёт тебя.",
|
||||||
|
"Напоминаю про дело из списка.",
|
||||||
|
"В списке осталось дело.",
|
||||||
|
"Дело всё ещё не сделано."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user