Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6f47fbeb6 | |||
| 2e9b9ec1cf | |||
| bfb57c3148 | |||
| d1f6f6355f | |||
| 92ecb691de | |||
| 4282f6b9a9 | |||
| 7bb9f9be06 | |||
| 1e47eaca5a |
@@ -124,6 +124,55 @@ Two caveats worth saying out loud:
|
|||||||
Phrasing was **not** measured. Whether thinking helps there is still open, and now also blocked
|
Phrasing was **not** measured. Whether thinking helps there is still open, and now also blocked
|
||||||
on the same "can we even turn it off" question.
|
on the same "can we even turn it off" question.
|
||||||
|
|
||||||
|
## Clock and calendar rule — 31-07-2026 (Vikunja #374)
|
||||||
|
|
||||||
|
`routeSystem` never said whether "который час" or "какое число завтра" are `system` or
|
||||||
|
`query`, and `system→query ×4` showed up in every run. The rule added says: the clock and the
|
||||||
|
calendar date themselves are `system`; what is *written in* the calendar or in memory
|
||||||
|
("что у меня завтра", "какие есть напоминания") stays `query`; and a time named inside a
|
||||||
|
request ("напомни завтра…") is just a detail of the request, not a reason for `system`.
|
||||||
|
|
||||||
|
That split is not a preference. In `cmd/mavend/voice.go` only `replySystem` owns the clock and
|
||||||
|
the date formatter, so a clock question routed to `query` falls into the embedder + note RAG
|
||||||
|
and answers "не знаю". The agenda, on the other hand, is answered by `ParseCalendarDate` +
|
||||||
|
`CalendarEvents` *inside* the `query` branch, so that side has to stay `query`. The rule sits
|
||||||
|
above the question test because every one of these utterances carries a question word and a
|
||||||
|
later rule would never be reached.
|
||||||
|
|
||||||
|
The fixture is now 77 cases: one calendar-agenda case was added
|
||||||
|
(`ru-query-019` "что у меня стоит в календаре на послезавтра", intent `query`) specifically so
|
||||||
|
an over-broad system rule cannot pass unnoticed. The clock/date cases (`ru-sys-001/002/005`,
|
||||||
|
`en-sys-001`) already existed.
|
||||||
|
|
||||||
|
Three runs, same box, back to back, never concurrently:
|
||||||
|
|
||||||
|
| | baseline | first rule (too broad) | rule as committed |
|
||||||
|
|---|---|---|---|
|
||||||
|
| llm-only intent-only | 59.2% (45/76) | 54.5% (42/77) | 59.7% (46/77) |
|
||||||
|
| llm-only full | 38.2% | 35.1% | 39.0% |
|
||||||
|
| llm-only route errors | 3 | 4 | 5 |
|
||||||
|
| llm-only p50 | 1.09s | 0.91s | 0.93s |
|
||||||
|
| cascade+llm intent-only | 61.8% (47/76) | 58.4% | 62.3% (48/77) |
|
||||||
|
| cascade+llm full | 57.9% | 54.5% | 59.7% |
|
||||||
|
| cascade+llm route errors | 0 | 0 | 0 |
|
||||||
|
| cascade+llm p50 | 0.91s | 0.80s | 1.04s |
|
||||||
|
|
||||||
|
**The targeted bug is fixed and the headline number did not move.** `system→query ×4` is gone
|
||||||
|
in both LLM configurations — the `time` and `date` tags go from 0/2 and 0/2 to 2/2 and 2/2 —
|
||||||
|
but the model then over-applies the rule, and `query→system ×5` plus `reminder→system ×2`
|
||||||
|
appear where they did not exist before. Net accuracy is a wash, inside the noise of a 77-case
|
||||||
|
fixture.
|
||||||
|
|
||||||
|
The first attempt is shown because it is the honest history: it said "спрашивает время, дату
|
||||||
|
или день недели → system" with no scope, which swept up reminders, and it cost 3-5 points. It
|
||||||
|
was tightened once, on the reasoning that a rule capturing "напомни завтра в 7" is simply
|
||||||
|
wrong, and not tuned further. The remaining `query/reminder → system` over-trigger is a new,
|
||||||
|
separate weakness of the sub-1B model and deserves its own task rather than more prompt
|
||||||
|
kneading against a held-out fixture.
|
||||||
|
|
||||||
|
The rule is kept. It is correct about what the daemon can answer, and the failure it replaces
|
||||||
|
was silent ("не знаю" to "который час") while the one it introduces is loud.
|
||||||
|
|
||||||
## Findings
|
## Findings
|
||||||
|
|
||||||
### 1. The resident model does route better — 50.0% vs 36.8%
|
### 1. The resident model does route better — 50.0% vs 36.8%
|
||||||
|
|||||||
@@ -189,7 +189,9 @@ func (l *lockedAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStat
|
|||||||
func run(args []string) error {
|
func run(args []string) error {
|
||||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||||
|
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap)")
|
||||||
flag.CommandLine.Parse(args)
|
flag.CommandLine.Parse(args)
|
||||||
|
reembedOnStart = *reembed
|
||||||
cfg, err := config.Load(*cfgPath)
|
cfg, err := config.Load(*cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
emb = router.NewHashEmbedder(1024)
|
emb = router.NewHashEmbedder(1024)
|
||||||
}
|
}
|
||||||
w.embedder = emb
|
w.embedder = emb
|
||||||
|
checkStoredEmbedder(dataStore, emb)
|
||||||
|
|
||||||
// ----- tool executor (the enabled act allowlist, store-backed) -----
|
// ----- tool executor (the enabled act allowlist, store-backed) -----
|
||||||
// Config tools are the declarative bootstrap: seed them into the store as
|
// Config tools are the declarative bootstrap: seed them into the store as
|
||||||
@@ -1767,3 +1768,71 @@ func jsonStringImpl(s string) string {
|
|||||||
b = append(b, '"')
|
b = append(b, '"')
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reembedOnStart is the -reembed flag (set in run()). Opt-in on purpose: see
|
||||||
|
// runReembed.
|
||||||
|
var reembedOnStart bool
|
||||||
|
|
||||||
|
// checkStoredEmbedder compares the embedder we just loaded with the one that
|
||||||
|
// wrote the vectors already in the DB (Vikunja #378).
|
||||||
|
//
|
||||||
|
// The two models we have both make 384-dim vectors, so a size check catches
|
||||||
|
// nothing: after a swap, recall silently compares vectors from different
|
||||||
|
// spaces and the scores are noise. So we say it out loud. Recall itself is not
|
||||||
|
// changed here — the fix is `mavend -reembed`.
|
||||||
|
func checkStoredEmbedder(dataStore *store.Store, emb router.Embedder) {
|
||||||
|
if dataStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := router.EmbedderID(emb)
|
||||||
|
if reembedOnStart {
|
||||||
|
runReembed(dataStore, emb, current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stored, mismatch, err := dataStore.CheckEmbedder(context.Background(), current)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: embedder marker check failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mismatch {
|
||||||
|
log.Printf("voice: WARNING embedder MISMATCH — stored vectors were written by %q but the configured embedder is %q; recall scores are noise until the notes and facts are re-embedded — run `mavend -reembed` once (Vikunja #378)", stored, current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("voice: embedder marker ok (%s)", current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runReembed is the one-shot backfill behind -reembed.
|
||||||
|
//
|
||||||
|
// Why a flag and not automatic on mismatch: the embedder is ONNX on the
|
||||||
|
// laptop's CPU, so a few thousand notes is minutes of work. Doing that silently
|
||||||
|
// inside a normal start would look like the daemon hanging on boot. So the user
|
||||||
|
// runs it once, deliberately, after an embedder swap; the mismatch warning
|
||||||
|
// above tells them to. It re-embeds, logs what it did, and then the daemon
|
||||||
|
// carries on serving as usual — no separate binary, no second start needed.
|
||||||
|
func runReembed(dataStore *store.Store, emb router.Embedder, current string) {
|
||||||
|
log.Printf("voice: re-embedding stored notes and facts with %s — this can take a few minutes, do not interrupt", current)
|
||||||
|
res, err := dataStore.ReembedAll(context.Background(), current,
|
||||||
|
// EmbedPassage, not EmbedQuery: these are stored texts being searched
|
||||||
|
// FOR, which is the side they were written with.
|
||||||
|
func(ctx context.Context, text string) ([]float32, error) {
|
||||||
|
return router.EmbedPassage(ctx, emb, text)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: re-embed FAILED, nothing was changed and no marker was written — safe to run again: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.Skipped {
|
||||||
|
log.Printf("voice: re-embed skipped — the stored vectors were already written by %s", current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("voice: re-embed done — %d notes in the notes table, %d notes and %d facts in the memory index, took %s; stored vectors now belong to %s",
|
||||||
|
res.Notes, res.MemNotes, res.Facts, res.Took.Round(time.Second), current)
|
||||||
|
|
||||||
|
// A row with no text cannot be re-embedded, so its vector is still the old
|
||||||
|
// model's noise while the marker now says everything is current. Both write
|
||||||
|
// paths always store the text, so this should be zero — say it loudly
|
||||||
|
// rather than bury it in the line above if it ever isn't.
|
||||||
|
if res.NoText > 0 {
|
||||||
|
log.Printf("voice: WARNING %d stored rows had no text, so their vectors could not be re-embedded and are still noise; they will never match anything useful (Vikunja #378)", res.NoText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
@@ -19,6 +20,24 @@ type Embedder interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IdentifiedEmbedder — an embedder that can name itself. The name goes into
|
||||||
|
// the DB next to the vectors it wrote, so a later model swap is caught instead
|
||||||
|
// of silently returning nonsense scores (Vikunja #378).
|
||||||
|
type IdentifiedEmbedder interface {
|
||||||
|
Embedder
|
||||||
|
ID() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbedderID is the stable string stored alongside the vectors. It comes from
|
||||||
|
// the embedder itself — nobody hand-types a model name twice — and changes
|
||||||
|
// whenever the model or its dimension changes.
|
||||||
|
func EmbedderID(e Embedder) string {
|
||||||
|
if i, ok := e.(IdentifiedEmbedder); ok {
|
||||||
|
return i.ID()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("unknown@%d", e.Dim())
|
||||||
|
}
|
||||||
|
|
||||||
// AsymmetricEmbedder — an embedder that wants to know whether a text is a
|
// AsymmetricEmbedder — an embedder that wants to know whether a text is a
|
||||||
// search query or a stored passage. Recall is asymmetric: a short question
|
// search query or a stored passage. Recall is asymmetric: a short question
|
||||||
// goes in, a longer note comes out. The e5 family is trained for exactly that
|
// goes in, a longer note comes out. The e5 family is trained for exactly that
|
||||||
@@ -70,6 +89,10 @@ func NewHashEmbedder(dim int) *HashEmbedder {
|
|||||||
|
|
||||||
func (h *HashEmbedder) Dim() int { return h.dim }
|
func (h *HashEmbedder) Dim() int { return h.dim }
|
||||||
|
|
||||||
|
// ID names this embedder for the DB marker. The dimension is part of it
|
||||||
|
// because a HashEmbedder of another width is a different vector space.
|
||||||
|
func (h *HashEmbedder) ID() string { return fmt.Sprintf("hash@%d", h.dim) }
|
||||||
|
|
||||||
func (h *HashEmbedder) Close() error { return nil }
|
func (h *HashEmbedder) Close() error { return nil }
|
||||||
|
|
||||||
func (h *HashEmbedder) Embed(_ context.Context, text string) ([]float32, error) {
|
func (h *HashEmbedder) Embed(_ context.Context, text string) ([]float32, error) {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEmbedderIDFromModelPath(t *testing.T) {
|
||||||
|
got := modelIDFromPath("/opt/maven/models/embedder/multilingual-e5-small.onnx")
|
||||||
|
if got != "multilingual-e5-small@384" {
|
||||||
|
t.Fatalf("modelIDFromPath = %q", got)
|
||||||
|
}
|
||||||
|
// A different model file must produce a different id, even at 384 dim.
|
||||||
|
old := modelIDFromPath("/opt/maven/models/embedder/paraphrase-multilingual-MiniLM-L12-v2.onnx")
|
||||||
|
if old == got {
|
||||||
|
t.Fatal("two different models share one id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbedderIDIncludesDim(t *testing.T) {
|
||||||
|
if id := EmbedderID(NewHashEmbedder(1024)); id != "hash@1024" {
|
||||||
|
t.Fatalf("EmbedderID = %q", id)
|
||||||
|
}
|
||||||
|
if EmbedderID(NewHashEmbedder(1024)) == EmbedderID(NewHashEmbedder(384)) {
|
||||||
|
t.Fatal("dimension not part of the id")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
{ "id": "ru-query-011", "utterance": "почему сервер тормозит", "lang": "ru", "intent": "query", "tags": ["homelab", "hard"], "note": "diagnostic question, not a chat opener" },
|
{ "id": "ru-query-011", "utterance": "почему сервер тормозит", "lang": "ru", "intent": "query", "tags": ["homelab", "hard"], "note": "diagnostic question, not a chat opener" },
|
||||||
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] },
|
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] },
|
||||||
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] },
|
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] },
|
||||||
|
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
|
||||||
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
||||||
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
||||||
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
||||||
|
|||||||
@@ -44,10 +44,21 @@ ws ::= [ \t\n]*
|
|||||||
// Changed again 31-07-2026: added the "unknown" escape hatch so the model can
|
// Changed again 31-07-2026: added the "unknown" escape hatch so the model can
|
||||||
// admit it cannot route (Vikunja #359).
|
// admit it cannot route (Vikunja #359).
|
||||||
//
|
//
|
||||||
|
// Changed again 31-07-2026: added the clock/calendar rule (Vikunja #374). The
|
||||||
|
// prompt never said which side "который час" or "какое число завтра" belong on,
|
||||||
|
// so the model guessed — `system→query ×4` in every eval run. The rule sits
|
||||||
|
// above the question test on purpose: these utterances all carry a question
|
||||||
|
// word, so a later rule would never be reached. The boundary is what the
|
||||||
|
// daemon can actually answer: only replySystem in cmd/mavend/voice.go owns the
|
||||||
|
// clock and the calendar formatter, while the agenda ("что у меня завтра") is
|
||||||
|
// answered inside the query branch, so that side stays query.
|
||||||
|
//
|
||||||
// The training workspace keeps its own copy of this prompt for relabelling, and
|
// The training workspace keeps its own copy of this prompt for relabelling, and
|
||||||
// `llm/check_prompt_parity.py` there compares the two. That copy is in another
|
// `llm/check_prompt_parity.py` there compares the two. That copy is in another
|
||||||
// repo and was not touched, so parity will fail until it gets the same edits —
|
// repo and was not touched, so parity will fail until it gets the same edits —
|
||||||
// both the rule reorder and the "unknown" wording (Vikunja #362).
|
// both the rule reorder and the "unknown" wording (Vikunja #362) — and now the
|
||||||
|
// clock/calendar rule too. The training workspace is not checked out on this
|
||||||
|
// box at all, so it could not be updated here; #362 still covers the catch-up.
|
||||||
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
|
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
|
||||||
|
|
||||||
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
||||||
@@ -56,19 +67,21 @@ const routeSystem = `Классифицируй ровно одно сообще
|
|||||||
Классифицируй по цели пользователя. Порядок решения:
|
Классифицируй по цели пользователя. Порядок решения:
|
||||||
1. Хочет напоминание в будущем → reminder
|
1. Хочет напоминание в будущем → reminder
|
||||||
2. Явно просит сохранить информацию → note
|
2. Явно просит сохранить информацию → note
|
||||||
3. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» → query
|
3. Спрашивает только «который час» / «какое число» / «какой день недели» — сами часы или календарная дата, без своих данных → system
|
||||||
4. Хочет получить информацию, в том числе о своих же данных → query
|
4. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» → query
|
||||||
5. Утверждает: сообщает или обновляет текущее состояние/событие → fact
|
5. Хочет получить информацию, в том числе о своих же данных → query
|
||||||
6. Просит выполнить работу → act
|
6. Утверждает: сообщает или обновляет текущее состояние/событие → fact
|
||||||
7. Про ассистента, настройки или память → system
|
7. Просит выполнить работу → act
|
||||||
8. Реплика — обрывок или указание на неназванное («это», «то», «потом»), и без него непонятно, что именно нужно сделать → unknown
|
8. Про ассистента, настройки или память → system
|
||||||
9. Иначе → chat
|
9. Реплика — обрывок или указание на неназванное («это», «то», «потом»), и без него непонятно, что именно нужно сделать → unknown
|
||||||
|
10. Иначе → chat
|
||||||
|
|
||||||
Различия:
|
Различия:
|
||||||
- note — сохранить информацию, без напоминания. text = суть.
|
- note — сохранить информацию, без напоминания. text = суть.
|
||||||
- reminder — уведомить позже. text = что напомнить.
|
- reminder — уведомить позже. text = что напомнить.
|
||||||
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
|
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
|
||||||
- unknown — редкий случай. Ставь его, только если в самой реплике нет ни предмета, ни действия. Короткая, простая или незнакомая тема — это не причина для unknown: приветствие и болтовня — это chat, вопрос на любую тему — это query, просьба сделать что-то названное — это act.
|
- unknown — редкий случай. Ставь его, только если в самой реплике нет ни предмета, ни действия. Короткая, простая или незнакомая тема — это не причина для unknown: приветствие и болтовня — это chat, вопрос на любую тему — это query, просьба сделать что-то названное — это act.
|
||||||
|
- system против query — часы и календарная дата сами по себе (сколько времени, какое число, какой день недели — можно и про завтра, и про другой город) — это system. А что записано в календаре или в памяти («что у меня завтра», «какие есть напоминания») — это query. Если в реплике есть просьба (напомни, запиши, сделай), то названное время — просто деталь просьбы, и это не system.
|
||||||
- query против fact — решает форма реплики, а не тема. Вопрос о состоянии — это query, даже если названо то же самое, что бывает в fact. Только утверждение — это fact.
|
- query против fact — решает форма реплики, а не тема. Вопрос о состоянии — это query, даже если названо то же самое, что бывает в fact. Только утверждение — это fact.
|
||||||
|
|
||||||
Примеры:
|
Примеры:
|
||||||
@@ -82,6 +95,8 @@ const routeSystem = `Классифицируй ровно одно сообще
|
|||||||
"что такое docker?" → {"intent":"query","text":"что такое docker"}
|
"что такое docker?" → {"intent":"query","text":"что такое docker"}
|
||||||
"напиши письмо" → {"intent":"act","verb":"написать письмо"}
|
"напиши письмо" → {"intent":"act","verb":"написать письмо"}
|
||||||
"очисти память" → {"intent":"system"}
|
"очисти память" → {"intent":"system"}
|
||||||
|
"который час?" → {"intent":"system"}
|
||||||
|
"какое число завтра?" → {"intent":"system"}
|
||||||
"привет" → {"intent":"chat","text":"привет"}
|
"привет" → {"intent":"chat","text":"привет"}
|
||||||
"сделай это" → {"intent":"unknown"}
|
"сделай это" → {"intent":"unknown"}
|
||||||
"ну это" → {"intent":"unknown"}
|
"ну это" → {"intent":"unknown"}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const (
|
|||||||
type onnxEmbedder struct {
|
type onnxEmbedder struct {
|
||||||
tokenizer *unigramTokenizer
|
tokenizer *unigramTokenizer
|
||||||
session *ort.DynamicSession[int64, float32]
|
session *ort.DynamicSession[int64, float32]
|
||||||
|
id string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewONNXEmbedder(modelPath, tokenizerPath, libPath string) (*onnxEmbedder, error) {
|
func NewONNXEmbedder(modelPath, tokenizerPath, libPath string) (*onnxEmbedder, error) {
|
||||||
@@ -58,11 +59,31 @@ func NewONNXEmbedder(modelPath, tokenizerPath, libPath string) (*onnxEmbedder, e
|
|||||||
return &onnxEmbedder{
|
return &onnxEmbedder{
|
||||||
tokenizer: tok,
|
tokenizer: tok,
|
||||||
session: session,
|
session: session,
|
||||||
|
id: modelIDFromPath(modelPath),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *onnxEmbedder) Dim() int { return embedDim }
|
func (e *onnxEmbedder) Dim() int { return embedDim }
|
||||||
|
|
||||||
|
// ID names the loaded model for the DB marker (Vikunja #378): the model file's
|
||||||
|
// own name plus the dimension, so pointing the config at another model changes
|
||||||
|
// the string on its own.
|
||||||
|
func (e *onnxEmbedder) ID() string { return e.id }
|
||||||
|
|
||||||
|
// modelIDFromPath turns /opt/.../multilingual-e5-small.onnx into
|
||||||
|
// "multilingual-e5-small@384".
|
||||||
|
func modelIDFromPath(modelPath string) string {
|
||||||
|
name := modelPath
|
||||||
|
if i := strings.LastIndexAny(name, "/\\"); i >= 0 {
|
||||||
|
name = name[i+1:]
|
||||||
|
}
|
||||||
|
name = strings.TrimSuffix(name, ".onnx")
|
||||||
|
if name == "" {
|
||||||
|
name = "onnx"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s@%d", name, embedDim)
|
||||||
|
}
|
||||||
|
|
||||||
// Embed treats the text as a query. The classifier compares one short
|
// Embed treats the text as a query. The classifier compares one short
|
||||||
// utterance to another short seed phrase, so both sides get the same prefix
|
// utterance to another short seed phrase, so both sides get the same prefix
|
||||||
// and the comparison stays fair. The recall path must call EmbedQuery and
|
// and the comparison stays fair. The recall path must call EmbedQuery and
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmbedFunc embeds one piece of stored text. The caller passes
|
||||||
|
// router.EmbedPassage — the STORE side of the query/passage asymmetry, which is
|
||||||
|
// the side every vector in the DB was written with. (Passing the query side
|
||||||
|
// would put the stored vectors in the wrong half of the space and quietly halve
|
||||||
|
// recall.) A func instead of an interface keeps this package free of any
|
||||||
|
// dependency on internal/router.
|
||||||
|
type EmbedFunc func(ctx context.Context, text string) ([]float32, error)
|
||||||
|
|
||||||
|
// BackfillResult is what the re-embed run did, for logging.
|
||||||
|
type BackfillResult struct {
|
||||||
|
Skipped bool // marker already matched — nothing to do
|
||||||
|
Notes int // rows rewritten in the notes table
|
||||||
|
Facts int // fact rows rewritten in memory_vectors
|
||||||
|
MemNotes int // note rows rewritten in memory_vectors
|
||||||
|
NoText int // memory_vectors rows with no text in their meta, left alone
|
||||||
|
Took time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReembedAll rewrites every stored vector with the currently configured
|
||||||
|
// embedder and then records that embedder as the one that owns the DB.
|
||||||
|
//
|
||||||
|
// Both places a vector lives are rewritten in the same pass: the `notes` table
|
||||||
|
// `embedding` column and the `memory_vectors` rows (notes AND facts). Doing
|
||||||
|
// only one would leave the two indexes disagreeing, which is worse than leaving
|
||||||
|
// both stale.
|
||||||
|
//
|
||||||
|
// Safe to re-run: if the marker already names the current embedder there is
|
||||||
|
// nothing to fix, so it returns immediately with Skipped set.
|
||||||
|
//
|
||||||
|
// Crash safety: everything — every vector and the marker — happens inside one
|
||||||
|
// transaction. If anything fails or the process dies partway, the transaction
|
||||||
|
// rolls back: no vectors changed and no marker written, so the next run does
|
||||||
|
// the whole job again. The marker is never set unless the full rewrite
|
||||||
|
// committed.
|
||||||
|
func (s *Store) ReembedAll(ctx context.Context, currentID string, embed EmbedFunc) (BackfillResult, error) {
|
||||||
|
start := time.Now()
|
||||||
|
var res BackfillResult
|
||||||
|
|
||||||
|
stored, err := s.Meta(ctx, metaKeyEmbedderID)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if stored == currentID {
|
||||||
|
res.Skipped = true
|
||||||
|
res.Took = time.Since(start)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("reembed: begin: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() // no-op once committed
|
||||||
|
|
||||||
|
// ----- notes table -----
|
||||||
|
type noteRow struct {
|
||||||
|
id int64
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
var notes []noteRow
|
||||||
|
rows, err := tx.QueryContext(ctx, `SELECT id, text FROM notes WHERE text != ''`)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("reembed: read notes: %w", err)
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var n noteRow
|
||||||
|
if err := rows.Scan(&n.id, &n.text); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return res, fmt.Errorf("reembed: note row: %w", err)
|
||||||
|
}
|
||||||
|
notes = append(notes, n)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return res, fmt.Errorf("reembed: notes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range notes {
|
||||||
|
vec, err := embed(ctx, n.text)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("reembed: embed note %d: %w", n.id, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE notes SET embedding = ? WHERE id = ?`, floatsToBlob(vec), n.id); err != nil {
|
||||||
|
return res, fmt.Errorf("reembed: write note %d: %w", n.id, err)
|
||||||
|
}
|
||||||
|
res.Notes++
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- memory_vectors (the unified index: notes AND facts) -----
|
||||||
|
// The text to re-embed is the one carried in the row's meta blob, which is
|
||||||
|
// exactly the text that was embedded when the row was written.
|
||||||
|
type vecRow struct {
|
||||||
|
id, text, kind string
|
||||||
|
}
|
||||||
|
var vecs []vecRow
|
||||||
|
rows, err = tx.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("reembed: read memory vectors: %w", err)
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, metaJSON string
|
||||||
|
if err := rows.Scan(&id, &metaJSON); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return res, fmt.Errorf("reembed: memory row: %w", err)
|
||||||
|
}
|
||||||
|
meta := map[string]string{}
|
||||||
|
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return res, fmt.Errorf("reembed: meta for %q: %w", id, err)
|
||||||
|
}
|
||||||
|
if meta["text"] == "" {
|
||||||
|
res.NoText++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vecs = append(vecs, vecRow{id: id, text: meta["text"], kind: meta["type"]})
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return res, fmt.Errorf("reembed: memory vectors: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range vecs {
|
||||||
|
vec, err := embed(ctx, v.text)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("reembed: embed %q: %w", v.id, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE memory_vectors SET vec = ? WHERE id = ?`, encodeVec(vec), v.id); err != nil {
|
||||||
|
return res, fmt.Errorf("reembed: write %q: %w", v.id, err)
|
||||||
|
}
|
||||||
|
if v.kind == "fact" {
|
||||||
|
res.Facts++
|
||||||
|
} else {
|
||||||
|
res.MemNotes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same transaction as the rewrite, on purpose: the marker can only exist if
|
||||||
|
// every vector above was written.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO meta (key, value) VALUES (?,?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
|
metaKeyEmbedderID, currentID); err != nil {
|
||||||
|
return res, fmt.Errorf("reembed: write marker: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return res, fmt.Errorf("reembed: commit: %w", err)
|
||||||
|
}
|
||||||
|
res.Took = time.Since(start)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// markerVec is a recognisable vector: nothing in these tests writes it except
|
||||||
|
// the backfill, so finding it proves the row really was rewritten.
|
||||||
|
var markerVec = []float32{9, 9, 9}
|
||||||
|
|
||||||
|
func newEmbedder(calls *int) EmbedFunc {
|
||||||
|
return func(_ context.Context, _ string) ([]float32, error) {
|
||||||
|
*calls++
|
||||||
|
return markerVec, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedOldVectors puts one note (notes table + unified index) and one fact
|
||||||
|
// (unified index only) in the DB, both carrying obviously-old vectors.
|
||||||
|
func seedOldVectors(t *testing.T, s *Store) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
old := []float32{0.1, 0.2, 0.3}
|
||||||
|
id, err := s.WriteNote(ctx, time.Now(), "молоко в холодильнике", old, "voice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteNote: %v", err)
|
||||||
|
}
|
||||||
|
mem := s.VectorMemory()
|
||||||
|
if err := mem.Insert(ctx, "note:1", old, map[string]string{
|
||||||
|
"type": "note", "text": "молоко в холодильнике",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Insert note vector: %v", err)
|
||||||
|
}
|
||||||
|
if err := mem.Insert(ctx, "fact:water:1", old, map[string]string{
|
||||||
|
"type": "fact", "text": "я пил воду",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Insert fact vector: %v", err)
|
||||||
|
}
|
||||||
|
_ = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func noteVec(t *testing.T, s *Store) []float32 {
|
||||||
|
t.Helper()
|
||||||
|
var blob []byte
|
||||||
|
if err := s.db.QueryRow(`SELECT embedding FROM notes LIMIT 1`).Scan(&blob); err != nil {
|
||||||
|
t.Fatalf("read note embedding: %v", err)
|
||||||
|
}
|
||||||
|
return blobToFloats(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
func memVec(t *testing.T, s *Store, id string) []float32 {
|
||||||
|
t.Helper()
|
||||||
|
var blob []byte
|
||||||
|
if err := s.db.QueryRow(`SELECT vec FROM memory_vectors WHERE id = ?`, id).Scan(&blob); err != nil {
|
||||||
|
t.Fatalf("read memory vector %s: %v", id, err)
|
||||||
|
}
|
||||||
|
return decodeVec(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameVec(a, b []float32) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deployed case: old vectors everywhere, no marker. Every vector in both
|
||||||
|
// places must be rewritten and the marker recorded.
|
||||||
|
func TestReembedAllRewritesEveryVector(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedOldVectors(t, s)
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
res, err := s.ReembedAll(ctx, "multilingual-e5-small@384", newEmbedder(&calls))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReembedAll: %v", err)
|
||||||
|
}
|
||||||
|
if res.Skipped {
|
||||||
|
t.Fatal("first run should not skip")
|
||||||
|
}
|
||||||
|
if res.Notes != 1 || res.MemNotes != 1 || res.Facts != 1 {
|
||||||
|
t.Fatalf("counts: notes=%d memNotes=%d facts=%d", res.Notes, res.MemNotes, res.Facts)
|
||||||
|
}
|
||||||
|
if calls != 3 {
|
||||||
|
t.Fatalf("embedder called %d times, want 3", calls)
|
||||||
|
}
|
||||||
|
if !sameVec(noteVec(t, s), markerVec) {
|
||||||
|
t.Fatalf("notes table not rewritten: %v", noteVec(t, s))
|
||||||
|
}
|
||||||
|
if !sameVec(memVec(t, s, "note:1"), markerVec) {
|
||||||
|
t.Fatal("unified index note row not rewritten")
|
||||||
|
}
|
||||||
|
if !sameVec(memVec(t, s, "fact:water:1"), markerVec) {
|
||||||
|
t.Fatal("unified index fact row not rewritten")
|
||||||
|
}
|
||||||
|
got, err := s.Meta(ctx, metaKeyEmbedderID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Meta: %v", err)
|
||||||
|
}
|
||||||
|
if got != "multilingual-e5-small@384" {
|
||||||
|
t.Fatalf("marker = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-running must do nothing at all — not a second pass over the same rows.
|
||||||
|
func TestReembedAllSecondRunIsNoop(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedOldVectors(t, s)
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
if _, err := s.ReembedAll(ctx, "e5@384", newEmbedder(&calls)); err != nil {
|
||||||
|
t.Fatalf("first run: %v", err)
|
||||||
|
}
|
||||||
|
first := calls
|
||||||
|
|
||||||
|
res, err := s.ReembedAll(ctx, "e5@384", newEmbedder(&calls))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second run: %v", err)
|
||||||
|
}
|
||||||
|
if !res.Skipped {
|
||||||
|
t.Fatal("second run should report Skipped")
|
||||||
|
}
|
||||||
|
if calls != first {
|
||||||
|
t.Fatalf("second run embedded %d more rows, want 0", calls-first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failure partway must leave the DB exactly as it was: no marker, and the old
|
||||||
|
// vectors still in place (one transaction, rolled back).
|
||||||
|
func TestReembedAllPartialFailureLeavesMarkerUnset(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedOldVectors(t, s)
|
||||||
|
before := noteVec(t, s)
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
boom := func(_ context.Context, _ string) ([]float32, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 2 {
|
||||||
|
return nil, errors.New("onnx blew up")
|
||||||
|
}
|
||||||
|
return markerVec, nil
|
||||||
|
}
|
||||||
|
if _, err := s.ReembedAll(ctx, "e5@384", boom); err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
got, err := s.Meta(ctx, metaKeyEmbedderID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Meta: %v", err)
|
||||||
|
}
|
||||||
|
if got != "" {
|
||||||
|
t.Fatalf("marker was set to %q after a failed run", got)
|
||||||
|
}
|
||||||
|
if !sameVec(noteVec(t, s), before) {
|
||||||
|
t.Fatal("a failed run left a partially rewritten notes table")
|
||||||
|
}
|
||||||
|
// And the mismatch warning must still fire, so the user knows to re-run.
|
||||||
|
if _, mismatch, err := s.CheckEmbedder(ctx, "e5@384"); err != nil || !mismatch {
|
||||||
|
t.Fatalf("CheckEmbedder after failed backfill: mismatch=%v err=%v", mismatch, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// metaKeyEmbedderID names the embedder that wrote the stored vectors.
|
||||||
|
//
|
||||||
|
// Why one value for the whole DB and not a column on every vector row: the
|
||||||
|
// vectors are only ever rewritten all at once (one backfill re-embeds every
|
||||||
|
// note and fact together), so a per-row marker would hold the same string in
|
||||||
|
// every row and cost a column on two tables for nothing.
|
||||||
|
const metaKeyEmbedderID = "embedder_id"
|
||||||
|
|
||||||
|
// Meta reads a single value from the meta table. Missing key ⇒ empty string.
|
||||||
|
func (s *Store) Meta(ctx context.Context, key string) (string, error) {
|
||||||
|
var v string
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read meta %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMeta writes (or overwrites) a single meta value.
|
||||||
|
func (s *Store) SetMeta(ctx context.Context, key, value string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO meta (key, value) VALUES (?,?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write meta %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbedderUnknown is the stored id reported for a DB that already holds
|
||||||
|
// vectors but never recorded who wrote them.
|
||||||
|
const EmbedderUnknown = "unknown (written before this marker existed)"
|
||||||
|
|
||||||
|
// CheckEmbedder compares the embedder now configured against the one that
|
||||||
|
// wrote the stored vectors. Returns the stored id and whether it differs.
|
||||||
|
//
|
||||||
|
// Vectors from two different models live in different spaces, so cosine
|
||||||
|
// between them is noise rather than a low score — and both of our models are
|
||||||
|
// 384-dimensional, so nothing else catches it.
|
||||||
|
//
|
||||||
|
// Three cases, and the middle one is the one that actually matters:
|
||||||
|
//
|
||||||
|
// - marker present ⇒ compare the two ids.
|
||||||
|
// - marker absent but vectors already stored ⇒ this is a DB from before the
|
||||||
|
// marker, so we cannot know who wrote them. Report a mismatch. This is the
|
||||||
|
// real case on the deployed box: those vectors came from the old embedder,
|
||||||
|
// and claiming them for the current one would hide the exact problem the
|
||||||
|
// marker was added to catch.
|
||||||
|
// - marker absent and no vectors ⇒ fresh DB, claim it, nothing to fix.
|
||||||
|
//
|
||||||
|
// On a mismatch the fix is ReembedAll (backfill.go), run explicitly with
|
||||||
|
// `mavend -reembed`. Nothing is re-embedded here: that work is minutes of CPU
|
||||||
|
// on the laptop and must not stall a normal start.
|
||||||
|
func (s *Store) CheckEmbedder(ctx context.Context, currentID string) (stored string, mismatch bool, err error) {
|
||||||
|
stored, err = s.Meta(ctx, metaKeyEmbedderID)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if stored != "" {
|
||||||
|
return stored, stored != currentID, nil
|
||||||
|
}
|
||||||
|
n, err := s.countVectors(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return EmbedderUnknown, true, nil
|
||||||
|
}
|
||||||
|
return currentID, false, s.SetMeta(ctx, metaKeyEmbedderID, currentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countVectors — how many stored rows carry an embedding. Used only to tell a
|
||||||
|
// fresh DB apart from one that predates the marker.
|
||||||
|
func (s *Store) countVectors(ctx context.Context) (int, error) {
|
||||||
|
var notes, vecs int
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM notes WHERE embedding IS NOT NULL`).Scan(¬es); err != nil {
|
||||||
|
return 0, fmt.Errorf("count note vectors: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM memory_vectors`).Scan(&vecs); err != nil {
|
||||||
|
return 0, fmt.Errorf("count memory vectors: %w", err)
|
||||||
|
}
|
||||||
|
return notes + vecs, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A fresh DB has no marker yet, so the current embedder is recorded and
|
||||||
|
// nothing is flagged.
|
||||||
|
func TestCheckEmbedderFreshDBRecords(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
stored, mismatch, err := s.CheckEmbedder(ctx, "multilingual-e5-small@384")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckEmbedder: %v", err)
|
||||||
|
}
|
||||||
|
if mismatch {
|
||||||
|
t.Fatal("fresh DB reported a mismatch")
|
||||||
|
}
|
||||||
|
if stored != "multilingual-e5-small@384" {
|
||||||
|
t.Fatalf("stored = %q", stored)
|
||||||
|
}
|
||||||
|
got, err := s.Meta(ctx, metaKeyEmbedderID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Meta: %v", err)
|
||||||
|
}
|
||||||
|
if got != "multilingual-e5-small@384" {
|
||||||
|
t.Fatalf("marker not persisted, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deployed box: notes were written by the old embedder, before the marker
|
||||||
|
// existed. Claiming them for the current one would hide exactly the problem
|
||||||
|
// the marker is for, so an unmarked DB that already holds vectors is a
|
||||||
|
// mismatch.
|
||||||
|
func TestCheckEmbedderUnmarkedDBWithVectorsIsMismatch(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := s.WriteNote(ctx, time.Now(), "молоко в холодильнике", []float32{0.1, 0.2}, "voice"); err != nil {
|
||||||
|
t.Fatalf("WriteNote: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, mismatch, err := s.CheckEmbedder(ctx, "multilingual-e5-small@384")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckEmbedder: %v", err)
|
||||||
|
}
|
||||||
|
if !mismatch {
|
||||||
|
t.Fatal("an unmarked DB with stored vectors should report a mismatch")
|
||||||
|
}
|
||||||
|
if stored != EmbedderUnknown {
|
||||||
|
t.Fatalf("stored = %q, want %q", stored, EmbedderUnknown)
|
||||||
|
}
|
||||||
|
// It must NOT claim the DB — that would silence the warning on restart.
|
||||||
|
got, err := s.Meta(ctx, metaKeyEmbedderID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Meta: %v", err)
|
||||||
|
}
|
||||||
|
if got != "" {
|
||||||
|
t.Fatalf("marker written despite unknown provenance: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both models are 384-dim, so this is the only thing that catches the swap.
|
||||||
|
func TestCheckEmbedderDifferentModelMismatch(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := s.SetMeta(ctx, metaKeyEmbedderID, "paraphrase-multilingual-MiniLM-L12-v2@384"); err != nil {
|
||||||
|
t.Fatalf("SetMeta: %v", err)
|
||||||
|
}
|
||||||
|
stored, mismatch, err := s.CheckEmbedder(ctx, "multilingual-e5-small@384")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckEmbedder: %v", err)
|
||||||
|
}
|
||||||
|
if !mismatch {
|
||||||
|
t.Fatal("different embedder not detected")
|
||||||
|
}
|
||||||
|
if stored != "paraphrase-multilingual-MiniLM-L12-v2@384" {
|
||||||
|
t.Fatalf("stored = %q", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same embedder must never raise a false alarm, including on re-check.
|
||||||
|
func TestCheckEmbedderSameModelNoAlarm(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
_, mismatch, err := s.CheckEmbedder(ctx, "multilingual-e5-small@384")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckEmbedder: %v", err)
|
||||||
|
}
|
||||||
|
if mismatch {
|
||||||
|
t.Fatalf("false alarm on pass %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,11 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
|||||||
expires_ts INTEGER NOT NULL
|
expires_ts INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dialogue_sessions_expires ON dialogue_sessions (expires_ts);`, // #10 — the follow-up session survives a restart (Vikunja #363); small, TTL-pruned table, not a history log
|
CREATE INDEX IF NOT EXISTS idx_dialogue_sessions_expires ON dialogue_sessions (expires_ts);`, // #10 — the follow-up session survives a restart (Vikunja #363); small, TTL-pruned table, not a history log
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);`, // #11 — small key/value table for facts about the DB itself; first key is embedder_id (Vikunja #378)
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate applies every migration with a number greater than the DB's current
|
// migrate applies every migration with a number greater than the DB's current
|
||||||
|
|||||||
Reference in New Issue
Block a user