Compare commits

...

98 Commits

Author SHA1 Message Date
cosmistack-bot
54ee30a449 docs(release): finalize v1.35.1 release notes [skip ci] 2026-10-05 22:30:13 +00:00
cosmistack-bot
0dbbd21c2f chore(release): 1.35.1 [skip ci] 2026-10-05 22:29:51 +00:00
jakeaturner
f257d54a29
chore(docs): update release notes 2026-10-05 22:25:22 +00:00
Chris Sherwood
26f40baf58 fix(ai): probe a model the list reports without vision before disabling images
Ollama 0.33.3 gives two answers for gemma3: /api/tags lists it as
['completion'], while /api/show for the same model reports
['completion', 'vision']. getModelCapabilities trusted the list outright
and cached it for the life of the process, so image upload stayed disabled
for gemma3, one of the most common small vision models, even though the
model downloader correctly shows it as text + image. Re-pulling does not
help: the local digest matches the registry.

A list that reports vision is still trusted without probing. A list that
omits it is no longer conclusive: the model is probed through /api/show
(then llama.cpp /props), and the list's answer stands only when the probes
have nothing better, which keeps hybrid routers without /api/show working.
The service's own copy of the short-circuit is removed so the rule lives in
the one tested function.

Verified on NOMAD3 (v1.35.0 + this patch): installed-models moves gemma3:4b
from unsupported to supported, every other model unchanged, and a chat with
an attached image is answered correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:25:28 -07:00
Chris Sherwood
ee203df485 fix(translate): download every released language, and list them in the docs
fetch_models.py sorted versions with float(), and Firefox Remote Settings
now publishes Nightly pre-releases ("1.0a1") alongside each release. The
first one crashed the whole fetch, so adding Swedish, or 21 of the 34 named
languages, silently did nothing and the button never appeared.

Only take records desktop Firefox would use on the release channel, pick
the newest complete version per direction instead of mixing files across
versions, support pairs that publish a separate source and target vocab
(Japanese), and keep one failing language from stopping the rest.

That opens 50 languages, up from the ~40 the docs claimed, including
Arabic, Japanese, Korean and Thai, which the docs said were unavailable.
All 50 downloaded, loaded and translated on the bergamot 0.4.5 engine in
the shipped image (NOMAD3). Chinese stays out: it is published as
zh-Hans/zh-Hant, which the proxy's two-letter codes cannot carry yet.

Translated pages now send Cache-Control: no-cache and put the installed
language set in the ETag. Kiwix's max-age=3600 kept the old language bar
for up to an hour after a language was added, and a normal refresh does
not reach the article frame.

Docs: step-by-step instructions and the full code table in the Supply
Depot section; sizes corrected to 45-140 MB per language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:19:27 -07:00
Chris Sherwood
eef5b6cd37 fix(translate): forward upstream redirects instead of following them
urlopen() follows redirects silently. Kiwix answers a book root such as
/content/<book> with a 302 to the ZIM's main page, so the proxy served
that page's HTML at the original, shallower URL. Every relative link and
stylesheet then resolved one directory too high: the page lost its
styling and every article link 404'd (e.g. atenolol/ -> /content/atenolol/).
Kiwix's /random is a 302 too and broke the same way.

Use an opener that returns 3xx to the browser like any other non-200
response, and keep an absolute Location on this origin rather than the
upstream container name.

Bumps the catalog image to 0.1.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:19:27 -07:00
jakeaturner
a483e15f7d
chore(attribution): credit Gabe Graves for the vision attachments work
Some checks failed
Validate Collection URLs / validate-urls (push) Has been cancelled
The vision attachment feature landed in edbfe1a with Gabe Graves as the
commit author, but under the legacy gabegraves@users.noreply.github.com
address rather than the ID-prefixed noreply GitHub associates with the
account. GitHub therefore did not count it toward his contributions.

This empty commit carries the correct trailer so the credit is restored
once dev fast-forwards onto main. No files change.

Original implementation: #1178. Integration and rebase: #1288.

Co-authored-by: Gabe Graves <37971265+gabegraves@users.noreply.github.com>
2026-09-30 03:59:53 +00:00
Chris Sherwood
5e1702efeb fix(collections): repoint 11 curated ZIMs whose upstream files are gone
Some checks failed
Validate Collection URLs / validate-urls (push) Has been cancelled
openZIM keeps only the two newest builds of each title and deletes the rest,
so 10 URLs in kiwix-categories.json now return 404. Manifests are fetched live
from main, so every install that picks an affected tier sits retrying a dead
download today (reported in #1312 and #1359).

Repointed to the newest build of each. All URLs verified 200 and
range-resumable (206); sizes are decimal MB from Content-Length:

- devdocs_en_node 2026-08 (1), devdocs_en_python 2026-08 (4)
- freecodecamp_en_all 2026-08 (8), based.cooking_en_all 2026-08 (16)
- librepathology_en_all_maxi 2026-09 (95, was 76)
- ted_mul_ted-ed 2026-09 (6381, was 5610)
- ted_mul_ted-conference 2026-09 (18136, was 16500)
- urban-prepper_en_all 2026-08 (2245)

The three Canadian Prepper titles were re-published under openZIM's new
YouTube naming (canadian-prepper_en_<playlist>), so their ids change to match
the filename base, which parseZimFilename() requires:

- canadian-prepper_en_winterprepping 2026-08 (1342)
- canadian-prepper_en_bugoutconcepts 2026-08 (2886)
- canadian-prepper_en_bugoutroll 2026-07 (879). The old-style 2025-08 file
  still resolves, but repointing now avoids the next 404.

A box that already has one of the old Canadian Prepper files will see that
resource as not installed and download the new one alongside it.

Full sweep after the change: all 118 URLs across collections/*.json return 200.

Fixes #1359
Refs #1312

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:56:08 -07:00
cosmistack-bot
b54cb55c1a docs(release): finalize v1.35.0 release notes [skip ci] 2026-09-29 22:56:41 +00:00
cosmistack-bot
d3c7f3bac2 chore(release): 1.35.0 [skip ci] 2026-09-29 22:55:42 +00:00
jakeaturner
ddb0235887 docs: update release notes 2026-09-29 15:54:41 -07:00
jakeaturner
c65198c7c3 fix(kb): keep chunk active flags in sync with toggles during embedding
embedAndStoreText read the file's active flag, then upserted its
chunks. A toggle landing between those two steps set its payload
before the new points existed, so the new points kept the old value.
Retrieval and the KB panel then disagreed.

Both toggles (setFileActive and setKnowledgeCollectionActive) now write
kb_ingest_state before calling Qdrant setPayload, and restore the rows
they changed if setPayload fails. embedAndStoreText re-reads the flag
after its upsert and sets active on its own point IDs if the flag
changed. Whichever order the two run in, one of them writes the final
value to the new points.

Also coerce the flag to a boolean before stamping it into the payload.
MySQL returns tinyint(1) as 0/1, and a raw 0 got past the search
filter's must_not active == false, so an inactive file that was
re-embedded became searchable again.
2026-09-29 15:54:41 -07:00
jakeaturner
152d1ee198 fix(kb): persist source active toggle when no ingest state row exists
setFileActive updated the Qdrant payload but skipped the DB write when
a source had no kb_ingest_state row. ZIMs hit this during embedding
(markIndexed runs only after the final batch), after a lost row, or on
pre-RFC installs. The API returned success, getStoredFiles still showed
the file as active, and the switch could never turn it back on. All
chunks stayed excluded from retrieval.

When the row is missing, create it the same way the scanner backfills:
indexed if Qdrant has chunks for the source, pending_decision if not.
Then save the flag.

Creating the row does not affect retrieval, since search filters only
on the Qdrant payload. It also leaves failed or partial embeds no worse
off:
- A failed job already leaves a row via markFailed. Only active changes.
- A crashed worker leaves partial chunks and no row. The next scan
  writes the same indexed row through backfill_indexed.
- For an in-flight job, markIndexed/markFailed reuse the row and keep
  active, so later batches stop stamping chunks active: true.
- The UI already treats a null state as indexed, and stall/zero-chunk
  warnings come from Qdrant counts, so Re-embed still appears.

A partial ZIM with no row is still labeled indexed, as before.
markStalled has no callers, so stalls are not tracked in the row.
2026-09-29 15:54:41 -07:00
jakeaturner
bc24508795 fix(maps): stop world basemap provisioning from running pmtiles extract twice 2026-09-29 15:54:41 -07:00
Yi-111-a
25a5f82a01 fix(install): match HawkPoint1 lspci string for gfx1103 AMD GPU detection 2026-09-29 15:54:41 -07:00
jakeaturner
ae1eae78e7 fix(kb): stop orphan sweep from purging an empty but present scan root
Boot runs ensureDirectoryExists() on the zim root, so a separate volume
that failed to mount leaves an empty mountpoint behind. The scan walks it
without error and reports it as scanned. One upload in kb_uploads keeps
the overall scan non-empty, so the next manual sync purged the vectors
for every ZIM under that root.

decideOrphans() now decides per scanned root and returns
{ orphans, withheld }. A root keeps its indexed sources when:

- it holds no embeddable files (kiwix-library.xml does not count)
- the purge would remove more than 50% of its indexed sources and at
  least 5 of them, which points to a wrong or stale mount rather than
  hand deletions

scanAndSyncStorage() logs each withheld root and appends a note to the
sync result message, so the user sees a likely unmounted drive instead
of losing hours of embeddings.

Replaces the unit test that asserted the old empty-root purge with
regression coverage for #1378 and both guards.

Closes #1378
2026-09-29 15:54:41 -07:00
jakeaturner
a1b899f49a fix(gpu): keep an existing AMD HSA override across reinstall and update
Upgraded AMD iGPU installs sometimes carry a working
HSA_OVERRIDE_GFX_VERSION on nomad_ollama, set by hand or by an older
install path, but have no ai.amdHsaOverride KV and no .nomad-amd-gfx
marker. The CPU-fallback banner offered Fix: Reinstall, and the
reinstall rebuilt the container without the override, so a 780M
(gfx1103) stayed on CPU.

- Add pickAmdHsaOverride to utils/amd_hsa_override.ts with the
  resolution order KV, marker, existing container override, none.
  A marker still wins even when it maps to none. KV "none" still
  disables the override.
- forceReinstall inspects nomad_ollama before removing it and saves a
  container-only override to ai.amdHsaOverride. Saving it keeps the
  value through a reinstall that fails after the removal and shows it
  in the Set GFX Override modal.
- The update path passes the old container env to the resolver, so a
  routine Update keeps the override too.
- The GPU health check passes the container env, so
  diffAmdOllamaConfig compares against what a reinstall would build.
  On the reported box, the only drift is OLLAMA_IGPU_ENABLE=1.
- diagnoseAmdCpuFallback suggests the container's current override
  when one is set. With an override applied, Ollama logs the coerced
  target (gfx1100), which maps to none and would suggest removing a
  working value.
- Remove the unused _mapGfxToHsaOverride wrapper.

parseAmdGfxTargetFromLogs is unchanged. It still matches only
gpu_type=. The "dropping integrated GPU ... compute=gfx..." line needs
a full sample from an affected box before we can write a regex for it.
We'll address in a later PR.
2026-09-29 15:54:41 -07:00
Chris Sherwood
4c75eb512b chore(branding): replace the retired N.O.M.A.D. badge with the new logo
The Command Center header, settings sidebar, chat sidebar and README still
showed the old dotted "PROJECT N.O.M.A.D." badge with the retired backronym.
The earlier naming sweep removed the text strings but could not catch a
raster asset.

Swap in the vector primary badge from the brand kit (nomad-primary.svg) and
add object-contain so the hexagon keeps its true 0.866 aspect instead of
being stretched to fill the square boxes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
cosmistack-bot
959b43d24e chore(release): 1.35.0-rc.2 [skip ci] 2026-09-29 15:54:41 -07:00
jakeaturner
ac437f8429 fix(gpu): detect Ollama CPU fallback on AMD and offer the right fix
GpuPassthroughRemediationProvider returned early unless the NVIDIA
runtime was registered with Docker. AMD doesn't register one, so the
log-based CPU-fallback detection added in v1.35.0-rc.1 never ran on
AMD boxes. On a Phoenix1 iGPU running ollama:rocm with /dev/kfd and
/dev/dri passed through, Ollama logged library=cpu while gpuHealth
reported status "ok".

The gpuHealth side had its own bug. hasNvidiaRuntime and
hasRocmRuntime were set only inside the lspci probe branch. When lspci
returned a plausible controller, the else branch reported "ok" without
probing and left both flags false. A blind reinstall would not have
helped either: gfx1103 needs HSA_OVERRIDE_GFX_VERSION=11.0.0, and
without the gfx marker or ai.amdHsaOverride a reinstall rebuilds the
same CPU-bound container.

- Add resolveExpectedGpuVendor(). It reads the nomad_ollama container
  config first (nvidia DeviceRequest, ROCm image tag, /dev/kfd), then
  the nvidia runtime, then the AMD GPU type when no container exists.
  /dev/dri alone is not treated as AMD. A CPU container on an AMD box
  is trusted as deliberate (#1232 fallback, acceleration disabled).
- Gate the provider on the expected vendor. NVIDIA auto-reinstall is
  unchanged but now skips when the container requests a GPU with no
  runtime registered. AMD is detect-only: the provider logs whether a
  reinstall would change the container and never reinstalls itself.
- In gpuHealth, set the runtime flags up front and let Ollama's
  startup log decide health whenever a GPU is expected, whatever lspci
  returned. A CPU-only log now reports passthrough_failed and skips
  the nvidia-smi exec. lspci stays the display source when clean.
- Parse the first GPU "inference compute" line instead of the last
  line, which could be the CPU fallback device, and recognize Vulkan.
  Move the startup-log reader into ollama_compute so both callers
  share it.
- Report amdReinstallWouldChange, amdGfxTarget, currentHsaOverride and
  suggestedHsaOverride on AMD passthrough_failed. The gfx target is
  parsed from Ollama's "amdgpu is not supported" line (best-effort).
- Add GpuPassthroughAlert, shared by Settings > System and Settings >
  Models. When a reinstall would rebuild the same AMD container, it
  offers "Fix: Set GFX Override" instead of a reinstall. The dialog
  saves ai.amdHsaOverride and then reinstalls.
- Expose ai.amdHsaOverride through the settings API. The value lands
  in the container env, so only x.y.z, "none" or empty is accepted.
- Silence the HSA override resolver's logging on the gpuHealth poll.

The #1325 case where no GPU runtime is expected and lspci reports a
controller still returns "ok" unprobed.

Fixes #1344
Refs #1325, #1232
2026-09-29 15:54:41 -07:00
dajiaohuang
7dabc40c85 fix: bound custom URL length before persistence 2026-09-29 15:54:41 -07:00
jakeaturner
a915cdcfc9 feat(AI): add opt-in relevance check for off-topic retrieval
The post-rerank relevance floor rejects noise but passes coherent
off-topic questions, and it gets worse as the library grows. Adding
Gutenberg prose, the Python tutorial and MDN pages to the eval corpus
took the off-topic leak rate from 22% to 67% at the 0.62 floor.

Score cutoff does not fix it. The weakest correct top hit scores 0.666
while "write me a haiku about autumn leaves" pulls Walden at 0.705.
A cosine floor and a top-vs-median contrast test both cost recall
faster than they cut leaks.

- Add rag.relevanceCheck (default off). After the floor, the tasks
  model, or the chat model when none is set, gives one grammar-constrained
  on_topic verdict for the whole retrieved set. Off topic means no
  context and no citations. Any failure keeps the chunks unjudged.
- Add a Settings > Models toggle recommending an 8B+ tasks model.
  On llama3:8b the check cuts off-topic leaks from 67% to 6% for about
  5 points of core recall@5 and ~0.6s per turn. On 3B models it rejects
  most valid questions, which is why it is opt-in.
- Add 6 distractor documents and an off_topic suite (18 unanswerable
  questions, 7 distractor controls) to the eval corpus.
- Report per-tag leak rates and leaked cases with top scores in
  eval:retrieval. Add --judge-model to measure the check and --dump to
  export pre-floor candidate pools for offline gate simulation.
- Pin the check off in eval:generation so a local setting cannot move
  scores.
- Record the distractor sweep on RAG_MIN_FINAL_SCORE. The 0.62 default
  stays: 0.65 halves leaks at no measured recall cost but leaves only
  0.016 of margin.

The corpus fingerprint changes to b4ebe5dce8699f5d. Retrieval reports
from before this commit are not comparable, and a new baseline is
committed.

Refs #1341
2026-09-29 15:54:41 -07:00
jakeaturner
a013eb6394 fix(chat): stop capping answers at 1024 tokens and surface truncation
planPrompt() returned the response reserve as num_predict, and the
reserve was clamped by MAX_RESPONSE_RESERVE = 1024. Chat answers
stopped at 1024 output tokens regardless of window size, often
mid-sentence, and the normalized stream chunks dropped Ollama's
done_reason, so neither the UI nor API clients could discern between
a cut-off answer and a finished one.

- Decouple the generation cap from the reserve. num_predict is now the
  space left in the window after the prompt, less a 5% (min 64 token)
  margin for estimator error, with the reserve as its floor. Raise
  MAX_RESPONSE_RESERVE to 8192 so it only bounds prompt hold-back.
- Carry done_reason through all four OllamaService chat paths. Native
  forwards Ollama's done_reason; compat maps OpenAI's finish_reason.
- Log length-limited stops in OllamaController with num_predict,
  num_ctx and tokens generated.
- Show a truncation notice on cut-off answers, with a Continue button
  on the latest one that sends a normal follow-up turn.

Generation evals before and after this change are not length
comparable.

Closes #1342
2026-09-29 15:54:41 -07:00
Tiancheng-Xu
99fe2ef5e0 fix(ui): cover dark accent interaction states 2026-09-29 15:54:41 -07:00
Tiancheng-Xu
8d821f3ada fix(ui): restore dark mode contrast 2026-09-29 15:54:41 -07:00
Chris Sherwood
52d12a222e fix(kb): confine the orphan sweep to roots the scan actually walked
decideOrphans guards on the disk scan coming back empty, but the failure
mode it needs to defend against is per-root. _discoverKbFiles() walks
kb_uploads and zim in a loop and skips a missing root rather than
failing, because a fresh install legitimately has no kb_uploads yet.

So if the zim root is relocated, renamed, or not yet mounted (#1050),
the scan still returns a non-empty list from kb_uploads. The empty-scan
guard never fires, filterOrphanCandidates has no way to tell a root that
scanned clean from one that was never there, and every ZIM in the index
is classified as an orphan and purged in a single batch. On a
Wikipedia-scale knowledge base that is millions of vectors and a
multi-hour re-embed. It is reachable from the UI: scanAndSyncStorage is
POST /rag/sync, so the button pressed when the knowledge base looks
wrong is the button that empties it.

_discoverKbFilesWithRoots() now reports which roots were walked, and
filterOrphanCandidates allowlists against that list instead of the
configured roots. A root that wasn't there contributes no candidates; a
root that scanned clean and held no files still yields its orphans, so
the legitimate purge path is unchanged. The empty-scan guard becomes the
degenerate case of the same rule rather than a separate one.

Also builds the unit-test paths with join() instead of POSIX literals.
Written as '/data/...' the filterOrphanCandidates cases pass on Linux CI
and fail on Windows, where sep is backslash and no source ever matches a
prefix, which is exactly where they would be run while changing this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
John Cortright
d4f0f078c5 test(kb): extract orphan allowlist into a tested pure fn
Self-review pass over the last few commits:

- Extracted the kb_uploads/zim allowlist check out of scanAndSyncStorage()
  into a new filterOrphanCandidates() in kb_orphan_decision.ts, alongside
  the existing decideOrphans(). It was untested logic sitting inline in a
  large method; pulling it out next to decideOrphans() makes it a pure,
  I/O-free function the same way decideOrphans() already is, and lets it
  be covered directly instead of only indirectly through scanAndSyncStorage.
- Added unit tests for it: keeps sources under the scanned roots, excludes
  sources outside them (e.g. bundled docs), and a dedicated case for the
  sibling-directory false-positive a naive prefix check (no trailing
  separator) would let through.
- Also fixed two prettier violations introduced earlier in this PR (an
  over-length logger.info call and an unwrapped ternary in
  scanAndSyncStorage) that weren't caught by the per-edit lint checks I
  ran at the time.
2026-09-29 15:54:41 -07:00
John Cortright
6712251109 fix(kb): batch the orphan sweep's Qdrant/state deletes into one round trip
scanAndSyncStorage()'s orphan sweep purged each orphaned source in its
own sequential await, re-running _ensureCollection and a separate
Qdrant delete call per orphan. Add purgeIndexedSources() /
_deletePointsBySources(), which use Qdrant's match.any filter and a
single whereIn() query to purge a whole batch in one round trip each,
and point the sweep at it. purgeIndexedSource() now delegates to the
batched form for a single source, so there's one implementation
instead of two.
2026-09-29 15:54:41 -07:00
John Cortright
50b42fe367 fix(kb): make purge self-healing and allowlist the orphan sweep's roots
purgeIndexedSource() deleted Qdrant points before removing the
KbIngestState row. If the row-delete failed or the process died in
between, the source vanished from Qdrant's orphan-detection signal but
the ghost state row stayed forever — nothing else ever revisits a row
with no matching file and no matching Qdrant source. Reversing the
order means a failure instead leaves points in Qdrant, which the next
sync's reverse orphan sweep will find and retry; both steps are
already idempotent, so a retry from either partial state is safe.

Also switch scanAndSyncStorage()'s orphan-candidate filter from
denylisting known non-scanned roots (README.md/docs) to allowlisting
the two roots _discoverKbFiles() actually scans (kb_uploads, zim),
via a new shared _kbScanRoots() helper. A denylist silently treats any
future source root outside kb_uploads/zim as an orphan the first time
it's added — the same class of bug the denylist itself was written to
fix for docs.
2026-09-29 15:54:41 -07:00
John Cortright
7e24c7aeb8 fix(kb): guard ZIM delete purge and dedupe reconcile's delete pair
ZimService.delete() was calling purgeIndexedSource() unconditionally,
so installs without the AI Assistant (Qdrant) feature logged a false
"offline" error on every ZIM delete. Guard it on Qdrant being
installed, same as reconcileReplacedContentFile() already does.

Also point reconcileReplacedContentFile() at purgeIndexedSource()
instead of its own inline _deletePointsBySource + KbIngestState.remove
pair, so there's one shared implementation instead of two.
2026-09-29 15:54:41 -07:00
John Cortright
8eb172e684 fix(kb): purge qdrant points when a zim is deleted (#1170)
delete() removed the file, the kiwix library entry, and the
InstalledResource row, but never touched Qdrant or KbIngestState. A
deleted ZIM kept surfacing as a search citation indefinitely. The sync
sweep added in the previous commit would eventually catch these too,
but no reason to wait for the next sync when we already know exactly
what needs cleaning up right here.
2026-09-29 15:54:41 -07:00
John Cortright
18986e0527 fix(rag): reap orphaned qdrant sources during sync (#1170)
scanAndSyncStorage builds sourcesInQdrant and embeddableFiles in the
same pass but only ever checks one direction - is this on-disk file
already embedded? It never checks the reverse, so points left behind
by a deleted or replaced ZIM just sit in Qdrant forever and keep
showing up as search hits for content that doesn't exist anymore.

Added a reverse sweep that diffs the two sets and purges anything left
in Qdrant with no file backing it. Guarded so it's a no-op when the
disk scan comes back empty, so a filesystem hiccup can't get misread
as "delete everything."

Also has to exclude README.md/docs, since those get embedded by
discoverNomadDocs and live outside the kb_uploads/zim paths the
scanner actually walks. First pass without that exclusion would have
wiped the docs KB on every single sync - caught it by testing against
a live install where those sources were already indexed.

Verified live: found exactly one real orphan out of 29 indexed
sources (an old devdocs_en_bash zim superseded by a newer download),
purged it, and left the other 28 - including all the docs - alone.
2026-09-29 15:54:41 -07:00
jakeaturner
5bc47a4aff fix(drug-reference): backfill the missing dataset install row on boot
On 1.34.0 every FDA ingest finished with its labels loaded but lost the
installed_resources 'dataset' row, because resource_type was still
enum('zim','map'). The enum migration in #1243 fixes new writes but can't recreate
the lost row. Re-selecting the tier doesn't fix it either, because ZimService
skips a dataset that already has rows. So Medicine never resolved its
Standard tier.

- Add DrugInstallRowProvider. On each boot it backfills the row when an
  ingest has finished (rows present, export date set, no download
  marker, no drug job running) and no row exists.
- Put the decision in decideDrugRowReconcile() so it has unit tests.
- Add DrugReferenceService.recordInstalledRow() as the one writer used
  by the ingest job, the provider, and ZimService.
- ZimService now writes the row when a Medicine tier re-select finds a
  finished ingest with no row.

Manual ingests and reset-and-reingest runs, which dispatch without
resourceMeta, also get the row on the next boot.
2026-09-29 15:54:41 -07:00
rcorvus
9fcade9203 fix(db): allow 'dataset' in installed_resources.resource_type
The model declares `'zim' | 'map' | 'dataset'` and IngestDrugDataJob writes a
'dataset' row for the openFDA drug labels, but the column was still
enum('zim','map') from the table's original migration. Every write failed:

  Data truncated for column 'resource_type' at row 1
  insert into `installed_resources` (... `resource_type`, ...) values
  (..., 'openfda-drug-labels', 'dataset', ...)

The ingest itself succeeded — 261,671 rows landed in drug_labels — but the
install-state write-back at the end of the job was lost every time. Since
getCategoriesWithStatus() resolves a tier only when every resource id is
accounted for, and it already queries whereIn('resource_type', ['zim',
'dataset']), the missing row kept the Medicine category from ever resolving
its Standard or Comprehensive tier. Only the enum was left behind.

Verified against a live install: after the migration the previously failing
insert succeeds, and Medicine resolves installedTierSlug
'medicine-comprehensive'.

down() drops 'dataset' rows before narrowing the column, since MySQL would
otherwise truncate them to '' or fail under STRICT_TRANS_TABLES.
2026-09-29 15:54:41 -07:00
Chris Sherwood
fa34d6df4c fix(chat): render the user's message before creating the session
handleSendMessage awaited createChatSession before the optimistic render,
so on a slow request the message the user just sent did not appear at all,
with no spinner and nothing to retry. A failed session creation was worse:
a bare `return` left no message and no error, so the app looked like it had
ignored the user.

Render first, and surface a failure the same way a failed response is
surfaced. Completes #1211 alongside the stream-abort fix in #1220.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
jakeaturner
27be71b46e feat(AI): give chat a sampler instead of the backend's defaults
Chat sent num_ctx, num_predict and nothing else, so every turn decoded at
whatever the backend picked: temperature 0.8, top_p 0.9, no min_p. On a
heavily quantized 1-3B model that is the wandering, repetitive output people
read as "the local model is bad".

Adds `ai.responseStyle` with four profiles. min_p is the lever, not
temperature: it floors relative to the top token, so it truncates hard on a
confident distribution and barely at all on a flat one, which is where small
models start to wander. top_p stays at 1.0 rather than stacked on top of it.
repeat_penalty moves with the style, because a grounded answer legitimately
repeats a part number inside the 64-token window: `focused` relaxes it to
1.05, `creative` tightens it to 1.15.

`auto` is the only style that defers to the model. Plenty of library models
ship author-tuned samplers (qwen3 sets 0.6/0.95/20), so those win key by key
and NOMAD fills only the gap, which in practice is min_p. A style the user
picked by name overrides them, or it would silently do nothing on half
their models.

The compat transport drops min_p, so a profile carries a separate compatTopP.
Passing top_p 1.0 to a backend with no min_p behind it would sample the raw
distribution, which is worse than the 0.9 it replaced.

Resolved in the controller, so the grammar-constrained task calls keep their
temperature 0. An explicit temperature still outranks a profile's, which is
what keeps the eval harness deterministic.

`eval:generation --style` is new and load-bearing: temperature 0 makes min_p,
top_p and top_k inert, so a default run cannot see this change at all.
`--style=off` sends no samplers and no temperature, and is the control.

Verified against Ollama 0.32.9, min_p accepted. 22 unit tests.
2026-09-29 15:54:41 -07:00
dajiaohuang
5e69350b55 fix(install): time out Redis readiness probes 2026-09-29 15:54:41 -07:00
Chris Sherwood
447b174832 fix(ai): don't classify the backend as non-Ollama when it is merely unreachable
getModels() and the _isNativeBackend() probe both set isOllamaNative =
false in their catch blocks for ANY failure of GET /api/tags - including
a plain 5s timeout while Ollama is busy or still starting, or a refused
connection while the container is down. The probe memoized that verdict
for the process lifetime. Every model download then failed with 'Model
is not available in your AI host... (model pulling is only supported
for Ollama backends)' on stock single-Ollama installs (#1349, two
reporters on GA).

The fix separates 'the backend answered and is not Ollama' from 'the
request failed'. Only an actual HTTP response - a non-2xx status, or a
200 whose body fails the Ollama shape check - proves a live non-Ollama
server and classifies false. No response at all leaves the
classification untouched (null, or a previously proven value), the
probe unmemoized for re-probing, and the download path falls through to
the pull attempt, whose existing error handling reports the real
network error as a retryable failure.

Verified live (v1.35.0-rc.1 + this patch, Ollama container stopped):
- stock behavior reproduced verbatim: dispatch fails with the
  'only supported for Ollama backends' message
- patched: the same dispatch fails with 'Failed to download model:
  getaddrinfo ENOTFOUND nomad_ollama' and stays retryable
- after starting Ollama again, the retrying BullMQ job recovered on
  its own and installed the model with no user action

Fixes #1349

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
Chris Sherwood
18e403bbaa fix(ai): bump the Ollama pin to 0.33.3 to fix the disconnect hang
ollama/ollama:0.24.0 never cancels in-flight generation when a client
disconnects: the runner process is left spinning at 300%+ CPU with no
active requests and never recovers. NOMAD triggers this organically via
its own 60s RAG embedding timeouts, so slow CPU-only boxes can wedge
themselves after a single slow request. Reported with a standalone
container repro in #1321; the cancellation fix landed upstream between
0.24 and 0.33.

Regression-tested on 0.33.3 (RTX 5060 box):
- model listing via /api/tags (NOMAD backend detection intact)
- GPU acceleration (100% GPU placement, 82.8 tok/s on llama3.1:8b)
- model pull through the NOMAD download pipeline (NDJSON progress)
- embeddings via /api/embed with nomic-embed-text (768-dim)
- streaming chat end-to-end through the Command Center UI
- the disconnect hang itself: container idles at 0.06% CPU 20s after
  a mid-generation client drop

The AMD path is unaffected: it always overrides to ollama/ollama:rocm.
The seeder does not sync container_image on existing rows, so this pin
reaches fresh installs; existing installs pick it up via the opt-in app
auto-update or a manual update.

Fixes #1321

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
cosmistack-bot
d15bb9d5ec chore(release): 1.35.0-rc.1 [skip ci] 2026-09-29 15:54:41 -07:00
chriscrosstalk
1b67a87254 feat(translate): offline translation for the Information Library (#1292)
* feat(translate): offline translation for the Information Library

Implements #1272. Ships as a Supply Depot app so users who do not need
translation never see it.

A path-preserving reverse proxy in front of kiwix-serve. /viewer, /skin/*,
/search, /random and /catalog/* are forwarded byte for byte; only text/html
under /content/ is rewritten. Preserving the path is the whole design:
viewer.js keeps the content iframe in sync with location.hash by comparing
contentWindow.location.pathname, so moving the path starts the ping-pong its
own comment warns about and forces us to patch Kiwix's JavaScript on every
Kiwix bump. Because the path never moves, no Kiwix JS is modified, and Kiwix's
header, search, library and random keep working because they are Kiwix.
Language is a cookie, so links inside the ZIM need no rewriting either.

Bergamot rather than the AI Assistant: measured on the same box and the same
passage, 12,818 words/s on CPU against llama3.1:8b at 8 words/s on an RTX 5060.
It is also more careful with names, where the LLM translated the product name
and a smaller model hallucinated a clause. No GPU, so it works on CPU-only
boxes where the LLM path is unusable.

Models come from Firefox Remote Settings, the endpoint Firefox itself uses,
not the mozilla/firefox-translations-models repo, which was archived on
2026-08-21. MPL-2.0, about 37 MB per direction.

Failure handling is deliberate throughout: a failed model fetch is non-fatal, a
translation error returns the original article with a comment rather than an
error page, and a box with no models still starts and still forwards the
library. Translation being unavailable must never make the Information Library
unreachable.

amd64 only, and pinned as such in the build workflow. Bergamot's intgemm
backend is x86 specific and there is no aarch64 wheel, so a multi-arch build
would produce an ARM image that pulls cleanly and dies at start.

Also adds .gitattributes pinning *.sh to LF. The repo stores LF but Windows
checks out CRLF, and a CRLF after a shebang makes the interpreter
unresolvable inside the image. CI builds on Linux so this never bit, but it
breaks a local image build and would have bitten the existing sidecar script
too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(translate): vary the cache on the language cookie

Found by clicking the language buttons in a browser rather than trusting curl.

Kiwix sends ETag and Cache-Control: max-age=3600, and the proxy forwarded both
unchanged on a response whose content now varies by cookie. The browser cached
the English article for an hour and served it straight back when the reader
picked a language, so the feature looked like it simply did not work. A shared
cache would have been worse, serving one reader's language to another.

Translatable responses now send Vary: Cookie, and their ETag is namespaced by
language so revalidation still works per language rather than being turned off.
Forwarded paths are untouched, so /viewer, /skin/* and /catalog/* stay byte for
byte identical to Kiwix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(translate): move off port 8480, which Vaultwarden already uses

Caught by seeing the two cards side by side in Supply Depot with the same port.

My port survey grepped ui_location for a bare number, and Vaultwarden's is
'https:8480' because it needs an https Open link, so it did not match and 8480
looked free. Moved to 8460, which fits the existing spacing between Meshtastic
Web on 8450 and Homebox on 8470.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(translate): pin the base image to python 3.10

The build fails outright on 3.12: bergamot publishes no wheel for it and pip
resolves no further than 0.1.2. The spike used 3.10 for this reason and I had
modernised it without checking. Caught by building the image rather than
reading it.

Moving off this pin means moving off the 2022 PyPI wheel to a build from
mozilla/translations, which is the follow-up already noted in the Dockerfile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(translate): translate text that lives in a bare div

Sotoki-generated StackExchange ZIMs put every question excerpt in a
<div class="...excerpt">, so on a questions list the titles translated and the
descriptions underneath them did not. Reported against the ham radio ZIM.

div could not simply be added to BLOCK_TAGS: the outermost-block rule would
claim a top-level layout wrapper and send the whole page as one job. It is now
claimed speculatively and abandoned the moment anything block-level opens
inside it, so a wrapper yields to the blocks it contains and only a genuine
text leaf is translated.

Also moves the test harness's reporting block back to the end of the file. New
checks had been appended after it, so they executed but their failures were
never reported: verified that removing div from the set now fails 3 checks,
where before it reported success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(translate): decode numeric character references before translating

Bergamot's HTML mode decodes named entities (`&amp;` arrives as "&") but not
numeric ones. It treats `&#x27;` as literal text and escapes the ampersand on
the way out, so "If I&#x27;m in the wilderness" came back as
"Si I&amp;#x27;m dans la nature" and rendered the escape sequence visibly to
the user. Confirmed by round-tripping fragments through the model directly.

Numeric refs are now decoded before the round trip. `&`, `<` and `>` are
deliberately left encoded: decoding those would inject real markup into the
fragment and fail the balance check that gates HTML mode, dropping the block to
plain text and losing its inline links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
NgoQuocViet2001
a131e63204 fix(downloads): guard orphaned model jobs 2026-09-29 15:54:41 -07:00
cclavin
e3679672c7 ci(collections): add weekly schedule trigger to url validation
validate-collection-urls.yml only ran on push/pull_request for
collections/**.json, so a URL going stale after merge (Kiwix rotating
a dated filename, openZIM renaming a resource) was never re-checked
until a user hit a 404. #127 proposed a weekly check for exactly this
reason; only the push/PR trigger ended up shipping.

No change to the validation logic, which already checks every URL
correctly via a range request (hardened in #1188). This only adds a
weekly cron trigger alongside the existing ones.
2026-09-29 15:54:41 -07:00
NgoQuocViet2001
613ad9a9d3 fix(chat): abort abandoned response streams 2026-09-29 15:54:41 -07:00
Osamaali313
dd8df4c1ee fix(content): re-download content updates after a failed attempt
applyUpdate guarded with the raw RunDownloadJob.getByUrl and only bailed
out for active/waiting/delayed states, letting failed/completed states
fall through to dispatch. But dispatch adds the job under a deterministic
jobId (sha256 of the URL) with no removeOnFail, so once a download
exhausts its retries the failed job persists in Redis. The next apply
then hits "job already exists", dispatch returns the stale failed job,
and applyUpdate reports success while nothing is re-downloaded — the
resource is stuck forever.

Use getActiveByUrl instead, which removes any terminal job for the URL
before returning, so a fresh job is dispatched. This matches every other
dispatch site (map_service, zim_service, creator_pack_service).
2026-09-29 15:54:41 -07:00
Fabricio Policarpo
c3663009ae fix(gpu): detect Ollama CPU fallback from logs so auto-remediation fires
The GpuPassthroughRemediationProvider probed GPU passthrough by exec'ing
`nvidia-smi` inside nomad_ollama. When Ollama was created CPU-only — most
commonly because the NVIDIA container runtime was registered with Docker
only AFTER the AI Assistant was first installed (DockerService attaches a
GPU DeviceRequest only when 'nvidia' is in docker.info().Runtimes at
install time) — the container ships no nvidia-smi, so the exec returned an
error string whose letters the alphabetic-output check mistook for
"passthrough healthy". That false negative suppressed all auto-remediation,
leaving Ollama silently stuck on CPU (models run at a fraction of a token
per second) until the user manually clicked "Fix: Reinstall AI Assistant".

Replace the nvidia-smi probe with Ollama's own "inference compute" startup
log line — the ground truth for whether a GPU backend actually loaded — via
a new, unit-tested classifyOllamaComputeBackend() helper. One signal covers
both the "created CPU-only" case and the original "DeviceRequests present
but the toolkit binding tore" case. Add a cooldown on gpu.autoRemediatedAt
so a GPU that genuinely cannot be accelerated (an architecture Ollama has
no kernels for) does not trigger a reinstall on every admin boot.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
Chris Sherwood
576464abc4 feat(zim): let users browse the Kiwix library in any language
Content Explorer hardcoded `lang: 'eng'`, so it could only ever show
about 1,300 of the catalog's ~10,900 books. Adds a language selector
sourced live from the catalog's own language feed, with book counts,
defaulting to English and remembered per browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
chriscrosstalk
880c80f52f feat(kb): per-file active toggle + collection grouping/bulk toggle (#1286)
* feat(kb): add per-file active/inactive toggle for search inclusion

Implements the per-file toggle agreed on in #1119, in place of disabling
ZIM auto-discovery by default. Any indexed file (ZIM, upload, or other)
can now be excluded from RAG search results instantly, without deleting
or re-embedding its vectors.

- Vectors stay in Qdrant permanently; each point's payload carries an
  active boolean. Toggling flips this payload in place, both directions,
  with no re-embedding cost.
- Search filter is a denylist (must_not: active == false), not an
  allowlist, so a point missing the field entirely is still found by
  default -- search correctness never depends on backfill timing.
- embedAndStoreText() looks up a file's existing active state before
  writing new points, so retries, force re-embeds, and replaced-content
  reindexing never silently reactivate a file the user explicitly
  toggled off.
- Applies uniformly to all non-admin_docs buckets (ZIM, uploads, other),
  matching how the rest of this pipeline already treats buckets, rather
  than special-casing ZIM.

New POST /api/rag/files/active endpoint and KB panel toggle switch,
following the existing updateFileCollection pattern.

Real vector deletion for genuinely deleted/replaced files remains
#1170's separate concern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(kb): group personal KB uploads by collection with bulk toggle

Groups personal KB uploads into collapsible per-collection header
rows (with an "Uncategorized" bucket for files with no collection) to
reduce clutter in the file table. Each collection header also exposes
a bulk active/inactive toggle to flip every file in that collection at
once.

Also fixes three pre-existing bugs found in the collection-assignment
dropdown while building this: the popover was clipped by table cell
truncation, clipped again by the table wrapper's overflow, and the
combobox query was stale-filtering results on open.

Refs #1193

* fix(kb): widen the modal for the Active column and coerce active to a boolean

Both found by browser-verifying on NOMAD3.

The Active column adds ~57px, which put the table back over the modal width and
reintroduced the exact defect #1198 fixed: the Delete button falls off the right
edge at every viewport. Measured on the box at 1018px of table against 961px of
usable width at max-w-5xl. Widened to 6xl.

MySQL returns tinyint(1) as 0/1 rather than a boolean, and the raw 1 reached the
DOM as aria-checked="1". That is not a valid ARIA value, so a screen reader is
told nothing useful about whether the toggle is on. Coerced in the service so
every consumer sees a real boolean, with a !! guard in Switch as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(kb): validate the bulk collection toggle's input before sanitising

sanitizeCollectionName ran before the type check, so a form-encoded request
sending collection[] as an array reached .trim() on a non-string and produced
an uncaught TypeError, surfacing as a 500. The vision controller already
guards this the same way.

Also documents why requiring `active` to be a real boolean is load-bearing
rather than mere input hygiene: a cross-site HTML form can only send the string
"true", so that check is what keeps a bulk Qdrant write across every point in
the index out of reach of a form POST from another origin. Worth a comment,
because coercing it would look like a harmless usability fix.

Found in a security review of the release PRs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

---------

Co-authored-by: John Cortright <jcortright@zscaler.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
Chris Sherwood
1b5d73f64e install: add Project NOMAD ASCII banner to the installer
Prints a green ASCII wordmark and tagline once at the top of the main
install flow, before the confirmation prompt. Nothing in that flow clears
the screen, so it stays visible while the install runs.

The art lives in a quoted heredoc so the backslashes survive verbatim
without escaping; the colour codes are emitted either side of it rather
than inline for the same reason. Pure ASCII, max 44 columns, so it is safe
over SSH regardless of terminal encoding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
f97de2cb3f fix(zim): drop the dead keyword-scan fallback
`q=` searches an entry's title and summary, never its filename, so it
cannot match a resource id. It cost up to 5 paged catalog requests per
unresolved book on every check and resolved none. Verified on a test box:
identical results with and without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
41c955125f fix(zim): resolve flavoured and non-English ZIMs in the update check
OPDS indexes a book under its base name with the flavour in a separate
element, so `name=wikibooks_en_all_nopic` matched nothing and no
maxi/mini/nopic book could ever resolve an update. Retry against the
base. Also drop the `lang: 'eng'` filter, which made every non-English
installed ZIM permanently unresolvable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
jakeaturner
a1b39cdcf2 fix(zim): extract Kiwix catalog URL into system-wide constant 2026-09-29 15:54:41 -07:00
Chris Sherwood
d1a4e9ade5 fix(zim): use the OPDS catalog host, not Kiwix's gated browse host
browse.library.kiwix.org grew an anti-crawler interstitial on 2026-08-29
that answers HTTP 200 with an HTML confirmation page. Content Explorer's
remote listing 500s on it and ZIM auto-update silently reports "no
updates". opds.library.kiwix.org serves the same OPDS API ungated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
jakeaturner
e468cf363a fix(seeder): derive ui_location from the merged config, not the live row 2026-09-29 15:54:41 -07:00
Chris Sherwood
32eea2ec22 fix(seeder): keep an installed app's host port across catalog sync
The catalog-sync loop overwrote container_config and ui_location for every
non-user-modified curated service, resetting the published host port of an
install that had been moved off the catalog default. The row then disagreed
with the running container: the Open link pointed at a dead port and the
next recreate collided.

Preserve the live host ports for installed rows and apply every other
catalog change, so these installs keep receiving catalog updates instead of
being opted out of them.

Diagnosis and the original fix are @johno10661's work in #1005.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
34b4542da4 fix(dashboard): show the color picker swatches solid
The swatches reused the tile tint, which is 10% opacity. That reads well across
a 190px card and makes six 28px squares indistinguishable from one another, so
the picker looked like six empty outlines. Caught by looking at it.

Each color now carries a solid class alongside its tint: the picker shows the
color at full strength, the tile stays subtle. A test asserts the swatch class
is solid rather than an opacity variant, since reaching for the tint again is
the obvious mistake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
ce7c82a72f chore(dashboard): label the link tile field URL, drop the scheme hint
"Address" was vague for a field that takes a URL. "No scheme? We'll default
to http://" named a concept most people do not have a word for, and it was not
needed: the live preview already shows http:// appearing the moment someone
types a bare address, which demonstrates the behaviour rather than describing
it. The preview stays, the sentence goes.

The two link tile validation messages follow the same wording. The custom URL
override message is left alone; it belongs to a different feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
c33af6a605 chore(dashboard): reword the link tile color helper, and use US spelling
"still tellable apart from" read badly; it is now "distinguishable from".

Also switches the spelling I had introduced to American, matching the rest of
the product and the existing schema and constants (custom_color, PIN_COLORS).
Limited to the files this branch adds; the two pre-existing benchmark
components that use the British form are left alone rather than churned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
400d64ab55 feat(dashboard): let a link tile carry a brand colour
A link tile rendered on a flat surface colour, which read as a stark white card
against the filled green app tiles. Tiles now take one of six colours from the
brand palette, chosen when creating or editing one, and the card is tinted from
it.

Tiles stay visually distinct from managed apps regardless of colour: the dashed
border and the external-link marker carry that, not the hue, and the tint is
deliberately light so the distinction survives every option.

Class names are written out in full rather than interpolated, because Tailwind
scans source text for literal class strings and would silently drop a computed
one, leaving the tile untinted. A test asserts that.

Added as a separate migration from the one introducing is_link_tile, since that
has already run on the test box and editing it in place would leave fresh and
upgraded installs with different schemas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
a6edaf7df7 fix(dashboard): exempt link tiles from container reconciliation
_syncContainersWithDatabase flips any service marked installed to not-installed
when no container exists for it. A link tile has no container by definition, so
every tile was silently unset on the next sync and disappeared from the
dashboard within seconds of being created.

Caught by creating one in the browser and watching it not appear.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
b7b973e9b8 feat(dashboard): user-added link tiles for services NOMAD does not manage
Users want things they already run to appear on the dashboard: a NAS, a router
UI, a printer, a Pi, a container they started themselves. Adds a tile they
address themselves, with a name, one URL field, an optional description and an
icon from the curated set.

Chosen over adopting containers from the Docker daemon, which is what community
PR #1239 proposed. Adoption only reaches containers on this Docker host and has
to guess: it took the lowest published host port and assumed http, which picks
the wrong port for anything publishing several (Portainer 8000 against 9443,
Jellyfin 8096 against 8920) and breaks entirely for HTTPS. A tile the user
addresses has no guessing in it, covers the whole LAN rather than one Docker
host, and has no lifecycle that can break.

One URL field rather than separate host, port and path, because people paste
URLs and it gets https and sub-paths for free. A bare "192.168.1.50:8080" gains
http automatically, which is what most LAN devices need. normalizeCustomUrl and
getServiceLink already did exactly this work for the custom app URL override, so
both are reused rather than duplicated.

Stored as a service row with no container behind it: is_link_tile marks it,
custom_url carries the destination, and the service_name is namespaced
nomad_link_* so it can never collide with a curated entry. The seeder only
iterates its own DEFAULT_SERVICES and never prunes unknown rows, so a reseed
cannot touch or remove a tile.

The tile is deliberately styled differently from a managed app, outlined and
dashed with an external-link marker rather than filled, because if it looked the
same users would expect Start, Stop and Update and file bugs when those controls
are not there. Removing a tile arms a confirm first, since it is user-entered
data with no undo.

Icons come from a fixed 36 shared by the picker and the server-side validator,
so the two cannot disagree about what is selectable. DynamicIcon renders nothing
at all for a name it does not know, which would leave a silent hole in a tile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
d417590252 copy(dashboard): tighten the Drug Reference tile description
Drops the condition examples from the dashboard tile. Also brings it in
line with the length and punctuation of the neighbouring tiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
jakeaturner
1d1945dae4 fix(maps): undef func call, flyTo issue, visible column, and silent save failures 2026-09-29 15:54:41 -07:00
Chris Sherwood
78017a333d fix(maps): cap marker notes at 500 characters server-side
The UI enforces 500 characters but the validator did not, so the API accepted
up to the MySQL TEXT ceiling. The API needs no authentication by design, so an
uncapped free-text column reachable from any page a user visits is a cheap way
to bloat the database. Matches the limit the form already shows.

Found in a security review of the release PRs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
821f952c18 chore(deps): restore dev's package-lock
react-icons was added and then removed again during review, and npm on Windows
rewrote the lockfile in passing, stripping the libc platform annotations that
npm on Linux had written. package.json ends up identical to dev, so the correct
lockfile is dev's -- restored verbatim rather than shipping a Windows-rewritten
one into a file that governs platform-specific binaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
f1e442fc98 feat(maps): confirm before deleting a pin, after flying to it
Deleting a pin was a single click on a trash icon with no confirmation and no
undo. Pin names are short and repetitive by nature ("Well", "Cache", "Fishing
bridge", "Fishing bridge west"), so the name in the row is not enough to tell
the user which place they are about to lose.

The trash icon now arms rather than deletes: it selects the pin and flies the
map to it, then the row offers Delete or Cancel. The user sees the actual
location before confirming.

Escape cancels. The armed state also clears if that pin stops being listed --
deleted from elsewhere, or filtered out by a search -- so a stray confirm can
never end up pointing at a different pin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
20f65c06fc fix(maps): restore the icon picker's width so the grid does not collapse
Dropping w-72 when the picker was rewritten left the absolutely positioned
popover to shrink-wrap its content. The six 1fr columns collapsed to a few
pixels each and the 18px glyphs overflowed and overlapped, so the grid rendered
as a smear. Restored the explicit width, added shrink-0 to the cells so a
narrow container can never squash them again, and centred them in their tracks.

Caught by looking at it. The DOM assertions I had run -- 36 buttons in a
grid-cols-6 -- all passed while the thing was visually broken, which is the
whole reason a frontend change needs eyes on it and not just a query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
9c1e945815 refactor(maps): curate the marker icon set, drop the tree view
Review changes from looking at the feature running on NOMAD3.

The icon picker offered every Tabler icon *and* every Font Awesome icon via
namespace imports: 160 pages, whose first page included the Amazon and Amazon
Pay logos, an "Ad" badge and text-alignment glyphs. It also took the maps chunk
from 864 kB to 5,774 kB. Replaced with a hand-written set of 36 map-relevant
icons in a 6x6 grid -- water, shelter, medical, power, comms, transport,
terrain, hazards -- with no search box or paging, because 36 fit on screen.
Maps chunk is back to 904 kB, +40 kB over dev rather than +4,910 kB, and the
react-icons dependency is gone entirely since Tabler was already present.

Icon resolution moved into marker_icons.ts so the panel and the pin share one
definition, with a caller-supplied fallback (the pin's inner glyph defaults to
a filled circle, the list to a map pin). An icon later removed from the set
still renders as the fallback rather than blanking the marker.

Dropped the tree view and the List/Tree band: grouping pins by hue or by icon
organises them by an attribute the user picked arbitrarily, and grouping by
first letter is worse than sorting. Search and sort cover the real need. "Hide
all" lived inside the tree view, so it moved up into the header where it
applies to whatever the list shows -- it is the one bulk control worth keeping.

The title bar now closes the panel, matching the "Pins" button that opens it;
clicking a title to open but having to hit a small X to close is an asymmetry
that makes the panel feel fiddly. The list also reclaims the height the removed
band was using.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Kenneth Brewer
72e0fcf4dd feat(maps): map marker management
Salvages @kennethbrewer3's work from #836, which had drifted onto a stale base
(GitHub showed 231 files; the real change is the map UI plus a migration).

Marker visibility (individually and Hide All), per-marker icon and custom hex
colour with contrast-adjusted icon colour, search, sorting and a tree view over
the marker list, coordinate search and fly-to with URL parameters
(?lat=&lng=&zoom=), creating a marker from typed coordinates, and markers
rendered as SVGs anchored to true coordinates rather than approximately.

Name, notes and preset colour already existed; the new columns are
custom_color, icon, icon_color and visible.

Dropped from the original as stale-base noise rather than his work: unrelated
duplicate changes to the Ollama, Docker, download, RAG and ZIM services, the
API reference and getting-started docs, README/FAQ/CONTRIBUTING, the installer
script, and a revert of AppLayout to a version predating the compact sidebar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
0a93e7de80 test(ai): run the controller vision spec under Japa
It constructs OllamaController, which imports the Adonis logger service at
module scope, so it cannot run under the plain node:test runner. It failed
identically on the original branch -- the coverage has never executed. Moving
it to the Japa runner puts it in the same bucket as the other app-importing
specs, where a booted app is available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Gabe Graves
8a920188de feat(ai): support local vision attachments
Closes #1176. Salvages @gabegraves's work from #1178, rebased onto the
RagPipelineService refactor that landed after it was written.

Adds JPEG/PNG/WebP attachments to chat over multipart, leaving text-only JSON
requests on their existing path. Images are normalized server-side to bounded
JPEG with metadata stripped, capped on count/size/pixels/output, and the
temporary uploads are cleaned up on every validation path. They are attached
only to the latest user message, are excluded from RAG, titles, logs and
persisted history, and are never written to storage.

Vision support is resolved per model from what the backend actually reports --
OpenAI-compatible /v1/models metadata, then Ollama /api/show, then llama.cpp
/props -- never inferred from a model name. A model that reports no vision
support has attachments disabled with a visible explanation; a backend that
reports nothing is allowed with a warning and its rejection is translated into
plain compatibility guidance rather than a raw error.

Changes from the original:

- Prompt assembly moved into RagPipelineService in the meantime, so the
  controller half is rebuilt against the trace and the multipart-aware
  collection filter is now the only one (dev's request.input() version would
  have silently dropped the collection on every image request).
- Added a native-path converter. dev split chat into native and OpenAI-compat
  paths after this was written; Ollama's native /api/chat does not take OpenAI
  content parts, it takes a sibling `images` array of bare base64. Without this
  every image request on a native Ollama backend would fail.
- Dropped the model-recommendation change (selectRecommendedModels and the
  Qwen3-VL fallback entry). Displacing a recommended model at first-time setup
  is a product decision and is being taken separately. The "Supports images"
  label in Easy Setup is kept: it describes what is already on offer rather
  than changing it.
- Dropped the DEFAULT_QUERY_REWRITE_MODEL download prompt his branch carried,
  which dev deleted in d022c2d.
- Removed a duplicate checkModelHasThinking; kept dev's memoized getModelInfo
  path, since thinking detection is not part of this feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Jake Turner
e23417b0fb Revert "fix(zim): use the OPDS catalog host, not Kiwix's gated browse host"
This reverts commit 9ed3ac6a46.
2026-09-29 15:54:41 -07:00
Jake Turner
318fe07d66 Revert "fix(zim): extract Kiwix catalog URL into system-wide constant"
This reverts commit fa301dcfb5.
2026-09-29 15:54:41 -07:00
John Cortright
9fde7f7747 feat: show source document and date as citations under RAG chat answers
Closes #1179.

Surfaces the documents an answer was drawn from as a "Sources" list under the
assistant message, with the archive date where one is known, and persists it so
reopening a past conversation still shows what backed each answer.

Offline there is no second opinion to check an answer against, so provenance is
the only signal the user has that a claim came from a real document rather than
from the model. This is the other half of retrieval being allowed to decline:
one says when nothing relevant was found, this says what was found.

Citations are built from the chunks actually injected into the prompt, not from
everything retrieval returned. A chunk dropped by the budget planner never
reached the model, so citing it would credit the answer to a document it was
not based on -- the exact failure the list exists to prevent.

The archive title and date have been written into the vector payload by the ZIM
ingestion path all along and were simply never read back, so this works on
already-embedded content with no re-embed.

Rebased onto the RagPipelineService refactor, which landed after this was
written: the citation list now comes off the pipeline trace rather than the
inline retrieval block that used to live in the controller. Entries with
neither a path nor a title are skipped rather than shown as "Unknown source" --
a row the user cannot act on makes an answer look sourced when it is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
bf7e4b3b48 fix(system): resolve a placeholder GPU name on the System page too
#1165 taught the benchmark path to reject an unresolved PCI id as a GPU
model, but isUnresolvedGpuModel lived inside benchmark_service.ts and the
Settings > System display path never learned the same thing. It still shows
whatever lspci handed back.

The two symptoms of a stale pci.ids database are independent, which is why
the existing guard misses this. system_service already re-probes when a
discrete-GPU controller reports an implausible BAR0 reading as VRAM, and
that happens to fix the name as a side effect. But lspci can return a
perfectly plausible VRAM figure alongside a name it could not resolve, and
then nothing fires.

Confirmed live on an AMD box. The Radeon 780M reports:

  model: "Device 1900", vram: 512

512 MiB clears the 256 MiB bogus-VRAM threshold, so no probe runs and the
System page renders "Device 1900". The same box's Ollama startup log carries
description="AMD Radeon 780M Graphics", so the probe the guard declined to
run had the real name available the whole time. NVIDIA hides this because
its path tends to be reached for other reasons; AMD has no equivalent.

Adds an unresolved-name trigger alongside the VRAM one, and moves
isUnresolvedGpuModel to app/utils/gpu_model.ts so the submission path and the
display path share one definition instead of diverging again. Tests cover
both placeholder shapes and, more importantly, a set of real product names
that must never be rejected.

Stacked on #1277, so the shared helper carries the WSL placeholders too.

Closes #1196

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
Chris Sherwood
b0be0dec43 fix(ai): don't probe the native embed endpoint on a non-Ollama backend
_embedWithFallback() always tried Ollama's native /api/embed first and only
reached /v1/embeddings from the catch block. Against a backend that only
speaks the OpenAI embeddings API, every batch therefore paid a
guaranteed-failing round trip plus a warn line before doing the real work.

Invisible on a handful of documents. Expensive on a bulk file-embeddings
job: the reporter is indexing several million chunks through an
OpenAI-compatible proxy, which doubles request volume and log noise against
their embedding host for the entire lifetime of the ingest.

Gates the native attempt on _isNativeBackend(), matching how _doDownloadModel
already skips /api/pull. Used the probe rather than reading isOllamaNative
directly: the field is null until something populates it, and an embed job
can easily be the first thing to touch the service after a restart, which is
exactly when a long ingest is starting and the saving matters most. The probe
memoizes for the process lifetime, so this costs one request overall rather
than one per batch.

Behaviour is otherwise unchanged. A native backend still tries /api/embed
first and still falls back if it fails for a non-context reason, and
context-length errors still bubble to embed() for the truncated retry. The
response-shape validation stays: the probe establishes that /api/tags
answered like Ollama, not that /api/embed will.

Reported by @horsleyb.

Closes #1279

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
Chris Sherwood
c1d527dbbd docs: trim the quickstart note to one line plus a link
Three copies of a figure that varies is worse than one. Keep the two
numbers a reader needs before running the install command, and move the
detail down to Device Requirements where it sits with the specs.

Suggested by @mk-pmb on #1271.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
4aa70c7038 docs: answer the RAM question alongside disk
Measured on a running install rather than estimated. The README already
gave 4 GB and 32 GB; what was missing is why the gap is that wide, which
is the model rather than NOMAD, and whether it comes out of VRAM or
system RAM.

Asked for by @mk-pmb on #1271.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
f7e6041d40 docs: state how much disk NOMAD needs before any content
The quickstart never said how much free space is required to install, which is
the first thing anyone plans around on a project whose whole purpose is
downloading large offline libraries. The FAQ's storage question only covered
content sizes, so it answered "how big is Wikipedia" but not "can I install
this at all".

Measured on a v1.34.0 install: admin 2.81GB, MySQL 1.1GB, plus redis, dozzle,
the disk collector and the sidecar updater at roughly 300MB combined. That is
about 4.2GB of images, so ~5GB installed and 10GB free to install comfortably.
The AI Assistant adds the Ollama image at 10.6GB plus a model (llama3.1:8b is
~4.9GB), which is where the ~25GB figure comes from.

Reported in #1271, which proposed the README sentence with the number left
blank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtGWCVMPYn6qPWVYiycK74
2026-09-29 15:54:41 -07:00
Chris Sherwood
4c4071e7c7 fix(benchmark): WSL2 submits "Microsoft Basic Render Driver" as the GPU model
Under WSL2 the GPU is reached through /dev/dxg rather than the real adapter, so
si.graphics() returns Microsoft's generic placeholder even while CUDA work runs
on a physical card. isUnresolvedGpuModel() caught blanks, "Device <hex>" and
"Unknown" but not this, so the placeholder was accepted as a legitimate model
name and the authoritative detection path was skipped.

The reporter on #1218 has an RTX 3090 doing real work (92% utilisation, 11.6 GB
VRAM, 352 W, 127.9 tok/s) that reaches the public leaderboard labelled
"Microsoft Basic Render Driver". A wrong label is worse than none: it fragments
per-hardware grouping, which is the same failure #1165 fixed for raw PCI ids.

Adds Microsoft's two placeholder adapter names to the same rejection list.

Scoped so it cannot affect anyone off WSL: these are Microsoft's own placeholder
names and appear nowhere outside a Windows/WSL graphics stack, so on a native
Linux host the function returns exactly what it returns today. Checked against
real product names from all four vendors, including "Microsoft Basic Renderer
Pro 9000" as a near-miss, to confirm the anchored pattern cannot swallow one.

Note this makes the model resolve to unknown rather than to "RTX 3090": the
admin container has no nvidia-smi and WSL's copy lives at /usr/lib/wsl/lib,
outside the container. Honest-unknown is the correct outcome here; naming the
card under WSL is a separate problem.

Refs #1218

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtGWCVMPYn6qPWVYiycK74
2026-09-29 15:54:41 -07:00
Chris Sherwood
9b90fcf9e8 fix(gpu): a missing /dev/kfd blocks the AI Assistant install entirely
When an AMD GPU is detected, the install switches to the ROCm image and passes
/dev/kfd and /dev/dri into the container. _discoverAMDDevices() returns both
unconditionally, with no check that they exist. Detection reads PCI data, which
says nothing about whether the ROCm kernel interface was ever loaded, so on a
pre-ROCm card or a host without amdgpu the container is rejected:

    (HTTP code 500) server error - error gathering device information while
    adding custom device "/dev/kfd": no such file or directory

and the install dies. There is an opt-out, ai.amdGpuAcceleration, but it is a
backend-only KV key with no settings toggle, no route and no UI, so an affected
user cannot install the AI Assistant at all through the interface even though
CPU-only would work fine on their machine.

Fall back to the CPU image instead, with a message explaining why.

Two things about the shape of this fix, both measured rather than assumed:

- It hooks start(), not createContainer(). The daemon accepts a container whose
  --device path does not exist and only resolves devices on start. Verified
  against Docker 29.6.2 over the socket: create returns 201, start returns 500
  with the message above.

- It retries rather than pre-checking. The admin runs in its own container, so
  stat()ing /dev/kfd describes the admin's namespace, not the host's. Verified
  on NOMAD6, a working ROCm box (Ollama running ollama/ollama:rocm with both
  devices attached): /dev/kfd is PRESENT on the host and ABSENT inside the admin
  container. A pre-check would therefore have stripped GPU passthrough from a
  machine where AMD acceleration works.

The matcher keys on the device-gathering phrase rather than a bare "no such file
or directory", which Docker also emits for missing bind-mount sources; falling
back to CPU on one of those would hide a real failure behind a degraded install.
Checked against all three formats the daemon/dockerode produce, and against a
port-conflict message, to confirm it discriminates.

The success path is unchanged: the create payload is built by one helper used by
both attempts, and the fallback only runs after a start() failure that matches.

Fixes #1232

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtGWCVMPYn6qPWVYiycK74
2026-09-29 15:54:41 -07:00
Chris Sherwood
c2fb809c81 fix(ai): a failed Ollama model pull is reported to the user as a success
Ollama's /api/pull returns HTTP 200 and reports failures IN-BAND, as a JSON
line inside the NDJSON stream body:

    HTTP_STATUS=200
    {"status":"pulling manifest"}
    {"error":"pull model manifest: file does not exist"}

The stream handler only ever read completed/total/digest off each line and
silently dropped everything else, including that error line. The stream then
ended normally, on('end') resolved unconditionally, and we logged the model as
downloaded successfully. on('error') never fires for this, because at the HTTP
layer the request succeeded.

Every failed model pull, for any reason, was reported as a success. On #1071
that surfaced as an ~800 GB fp16 model "downloading" in two seconds and nothing
being installed, which had been triaged twice as disk space or a stale blob.
Those explanations produce workarounds that cannot work, since the pull never
reaches the disk at all.

Three changes:

- Check parsed.error in the data handler and destroy the stream with it, so the
  existing catch reports a real failure.
- Verify the model is actually in getModels() before returning success. This is
  the load-bearing half: it catches silent failures we have not anticipated,
  not only the ones Ollama labels. includeEmbeddings must be true or
  nomic-embed-text fails verification after a good pull, and names are
  normalised because Ollama resolves a bare name to ":latest".
- Treat an in-band "model does not exist" as permanent. It was retryable, so a
  bad model name spun through attempts: 10 on exponential backoff and the user
  watched a delayed job for hours instead of being told the name was wrong.

Verified on NOMAD3 against the shipped v1.34.0 build, which carries the same
defect:

- bogus tag, before: 'Model "..." downloaded successfully.'
- bogus tag, after:  'Failed to download model "...": pull model manifest:
  file does not exist', and the job stops at 1 attempt instead of retrying
- real model (qwen2.5:0.5b): still succeeds, passes verification, and is
  present in `ollama list`

Fixes #1273
Refs #1071

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtGWCVMPYn6qPWVYiycK74
2026-09-29 15:54:41 -07:00
Chris Sherwood
08e1b86aef fix(queue): clear terminal jobs before re-dispatch so downloads aren't silently dropped
Three dispatch sites pin a deterministic jobId. `queue.add` with an existing
custom jobId returns the existing job rather than throwing, so any retained
terminal record makes later dispatches a silent no-op while still reporting
success to the caller.

- download_model_job: only `failed` records were cleared, so once a model
  downloaded successfully its retained completed job blocked every later
  request for that model. Deleting the model and reinstalling it did nothing,
  and a restart did not help because the record is persisted in Redis. Adds
  getActiveByModelName, mirroring RunDownloadJob.getActiveByUrl.
- download_service.retryFailedJob: the model branch dispatched before removing,
  so remove() targeted the job just enqueued under the same id instead of the
  old record. Flipped to remove-then-dispatch, matching the file branch.
- embed_file_job: a retained failed record made re-indexing that file a no-op,
  returning 202 "Indexing queued" with nothing enqueued.

In-flight jobs are still returned as-is, so re-clicking during an active
download or embed stays idempotent.

Diagnosis by @caweis in #1214, including the retry-ordering race and the embed
case. Reimplemented in-house rather than ported, as noted there.

Closes #1214
2026-09-29 15:54:41 -07:00
Chris Sherwood
baf94bbd87 fix(queue): stop stalled-job recovery forking the embedding chain
Fixes #1269. Fixes #1203.

`file-embeddings` and `downloads` both routinely outlive the default
5 minute lock, so BullMQ calls healthy jobs stalled on both. They need
opposite recovery policies, which is why neither was covered by the
existing drug-queue override.

Embedding is a self-continuing chain, so re-queueing a stalled job while
the original is alive runs two chains over the same file. Both write to
Qdrant with freshly minted point ids, so the second cannot overwrite the
first, and the queue's concurrency of 2 means the fork never contends
for a slot. maxStalledCount 0 fails on the first stall instead.

A download is resumable, so recovery is safe there; the default of 1 was
failing jobs outright and bypassing their own attempts: 10 policy.

The decision moves to a pure, unit-tested util alongside the other
decision helpers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtGWCVMPYn6qPWVYiycK74
2026-09-29 15:54:41 -07:00
rcorvus
a3811ad49f fix(rag): don't abandon a ZIM after one sparse batch
The batch continuation gated on articles that produced chunks rather than
articles the extractor consumed. Articles whose text is empty after HTML
cleaning — redirect stubs, category listings, media and PDF wrappers —
return no chunks and so no documentIds, which made a normal sparse batch
look like the end of the archive. Ingestion stopped after the first batch
and the rest of the file was silently skipped.

Medicine LibreTexts embedded 16 chunks out of 23,171 articles: its first
50 entries are navigation pages, only 10 of which had text, so 10 >= 50
was false and no continuation was ever dispatched. Measured against the
same install, WikiMed reached 28% of its articles, Libre Pathology 31%,
MedlinePlus 84%, and CDC Travelers' Health 2.7%.

The same value was also returned as `articlesProcessed`, which
EmbedFileJob uses to advance the next batch offset. Advancing by the
content-bearing count re-read the overlap on every sparse batch, and a
batch yielding no text at all would have advanced by zero and re-run the
same window indefinitely.

Both now use the extractor's consumed-article count, which it already
computed and logged but discarded. The end-of-archive signal is the
iterator running dry (articlesProcessed < batchSize); archive.articleCount
cannot serve as a bound because iterByPath() legitimately yields more
article entries than that figure for some archives.

The log line now reports both counts (content-bearing/consumed) so a
sparse batch is visible without re-deriving it.
2026-09-29 15:54:41 -07:00
Chris Sherwood
01ca77c912 fix(benchmark): show the v2 score in Benchmark History
The history table rendered nomad_score for every row, so a v2 run showed its
legacy score while the Benchmark Details card directly above it showed the v2
one. The same run read as 65.0 in the table and 1036.1 in the card.

Rows predating v2 have no v2 score and can only show the legacy number, so the
two scales necessarily share a column. They differ by more than 10x, and
without the scale on screen a v2 run sitting above older runs reads as a
collapse rather than a rescale. Legacy rows now carry a "/ 100" suffix, which
names the scale in place and reuses the wording the details card already uses
("Legacy scale: X / 100").

This compounds the re-run guidance in the release notes: someone re-runs on
1.34 as asked, gets a real v2 score, and their own history then shows the old
number as though the machine got slower.

Frontend only. getAllResults() already returns full rows, so nomad_score_v2
was in the payload the whole time.

Verified on the NOMAD2 dev environment with a seeded history matching a real
mixed case (one v2 run at 1036.1 plus two pre-v2 runs): the v2 row shows
1036.1 with no suffix and agrees with the details card, and the legacy rows
show 71.1 / 100 and 86.5 / 100. Inertia typecheck shows the same 30
pre-existing errors as dev with none in this file.
2026-09-29 15:54:41 -07:00
Chris Sherwood
5ecec5137e docs: add SECURITY.md and tighten .gitignore for credential files
Adds a security policy so vulnerability reports are routed to the private
advisory form instead of public issues, and defines scope so the intentional
no-auth LAN design is not re-reported as a vulnerability.

Also adds a security contact link to the issue template chooser (blank issues
are disabled, so there was previously no route for a private report), and
extends .gitignore to cover .env variants, key/cert files, .npmrc/.netrc and
local copies of the deployed compose file. A local management compose with real
generated DB passwords was committed to a branch once before; secret scanning
push protection now covers that case too.

No tracked file is affected by the new ignore rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
jakeaturner
ae06a295de feat(AI): let retrieval decline instead of making the prompt paper over it
There was one cutoff, Qdrant's score_threshold at 0.3, and nomic scores
unrelated text well above it, so every question retrieved *something*
(nonEmptyRateOnRefusal 1.0) and rag_context wasted four lines asking a 3B
model to "silently judge" relevance. That is a classification a number
should decide, not a micro model.

Adds `RAG_MIN_FINAL_SCORE`, a floor on the post-rerank score, applied before
source diversity. The floor judges relevance, the 0.85^n penalty judges
redundancy. Nothing clears it and nothing is injected. `score_threshold` stays
at 0.3 as the candidate net. Exposed as `rag.minRelevance` with a module-
scoped TTL cache invalidated from `updateSetting`.

The floor is calibrated and the harness was describing the wrong number:
aggregate() bucketed semanticScore, so its percentiles measured raw cosine
while the new knob filters the reranked score. Both axes now, and the gap
matters: recall falls at 0.60 on the semantic axis, 0.66 on the final one.

  floor  recall@5  precision@5  emptyOnAnswerable  nonEmptyOnRefusal
  0.00     0.991      0.221            0%               100%
  0.62     0.991      0.524            0%                60%
  0.66     0.991      0.695            0%                60%
  0.67     0.972      0.704            1%                60%

Default 0.62, not the last-lossless 0.66: the refusal win is complete at
0.62 and 28 documents is a thin basis for spending margin. 0.60 is the
honest floor. Three of five refusal goldens are near-corpus by
construction, where declining is the generation tier's job.

Both eval tiers pin the floor to the constant and ignore the setting, for
the same reason the harness never sets skipRetrieval.

4 metrics improved against baseline, no regressions.
2026-09-29 15:54:41 -07:00
jakeaturner
06941e71c5 fix(AI): tell a broken grammar apart from a backend that never had one 2026-09-29 15:54:41 -07:00
jakeaturner
5e9bd5a6ca feat(AI): constrain the structured task calls instead of asking nicely
Ollama's format parameter turns a formatting request into a decoder
constraint, and NOMAD's three ancillary calls — chat title, suggestion
chips, RAG query rewrite — were the ones paying for not having it.
The rewrite is particularly important: whatever it returned got embedded
and sent to Qdrant, so a malformed response degraded retrieval for the
rest of the conversation.

Three explicit design choices made:
- Native-only. The compat path sends nothing new and keeps its string
parsing, so a non-Ollama backend that rejects response_format can't
take out title generation entirely.
- parseStructured returning null is the control flow. It's how each
caller falls back to the parser it already had, which is why it's written
to be total and never throw.
- {queries: []} not {query: ""} for the rewrite (tee-ing up future
improvements)
2026-09-29 15:54:41 -07:00
Jake Turner
5c97bf605a fix(AI): stop <think> output leaking into titles, chips, and the RAG query (#1254)
ThinkTagSplitter was wired into both streaming paths but neither
non-streaming one, and every ancillary call goes through non-streaming
chat(). So a reasoning tasks model put its reasoning transcript into the
sidebar title, into the suggestion chips, and — worst — into the string
that gets embedded and sent to Qdrant, silently corrupting retrieval for
the rest of the conversation. splitThinkTags() already existed and was
already unit tested; it just had no production callers.

Fixed in three layers:

1. Strip at the transport. New normalizeNonStreamed() in think_stream.ts
   merges structured reasoning (native `thinking`, /v1 `reasoning`) with
   anything the splitter pulls out of inline tags, and both _chatNative
   and _chatCompat now return through it. That covers every caller at
   once, including the legacy controller path and the eval harness.

2. Suppress at the source. _chatNative and _chatStreamNative forwarded
   `think` only when truthy, so `think: false` was never sent and Ollama
   left reasoning on for capable models. Guard is now definedness. This
   also fixes the ai.autoThinking=off setting, which had no effect on
   the native streaming transport.

3. Guard the call sites. Title, suggestions, and query rewrite now pass
   think:false plus thinkingCapable (from the memoized capability check,
   so no extra round-trip), and handle an empty-after-strip result. The
   rewrite matters most: QUERY_REWRITE_MAX_TOKENS caps it at 120, which
   a reasoning model burns entirely on thinking, so stripping alone
   would have turned a corrupt query into an empty one. It now falls
   back to the raw user message instead of embedding "".

Verified: think_stream.spec.ts 17/17 (6 new cases, including the
truncated-mid-thought case that drives the fallback), typecheck clean,
full test:unit failures byte-identical to the pre-change baseline.
2026-09-29 15:54:41 -07:00
Jake Turner
9a95f7b2b8 feat(AI): budget the prompt and actually set the context window (#1253)
Chat was run through Ollama's OpenAI-compatible /v1 endpoint, which has no
context-size field, so the num_ctx we sent was silently discarded and the
entire NUM_CTX_LADDER was dead code. Every conversation ran at the backend
default and was truncated from the middle (where history and
retrieved context actually sit). Measured with identical ~7k-token prompts:
2,060 tokens processed against 7,049 on the native endpoint, and with the
needed fact in the discarded middle the old path hallucinated a plausible
wrong number.

Transport: add a native /api/chat path using the already-installed ollama
package, sending a real options object (num_ctx, num_predict, temperature,
seed) plus keep_alive. The OpenAI path is still used for LM Studio and llama.cpp,
minus the ineffective num_ctx and with include_usage so both paths return
prompt token counts. /api/show is now read for trained context, parameter
size, quantization and any modelfile num_ctx. The <think> stream normalizer
moves to app/utils/think_stream.ts so both transports share it; it gains a
flush() that fixes a dangling partial tag being dropped at end of stream.

Token accounting: replace chars/N with a structural segmenter scaled by a
per-model factor learned by EWMA from the prompt_eval_count every response
already returned and threw away. End-to-end estimator error is 4.6% against
18-20% for a fixed divisor — qwen2.5 converges to k=1.26 where llama3 sits
at 1.00, so no single global constant serves both.

Context window: resolved once per model from trained context, the exact KV
cost derived from GGUF metadata (head_count_kv, not head_count — GQA makes
them differ 4x), and available VRAM, then memoized. Memoization is
load-bearing: asking for a different num_ctx makes Ollama unload and reload
the model, stalling the turn and discarding the KV cache. The floor never
overrides the model's trained ceiling — tinyllama trains to 2048, and a
blind 4096 floor pushes RoPE past anything the weights saw.

Prompt budget: planPrompt allocates explicitly instead of delegating
overflow to silent backend truncation — response reserve, then fixed blocks
(system and current query, never dropped), then RAG chunks whole and
best-first, then history in whole user+assistant turns, evicted newest-first
in blocks with an elision marker. Block eviction keeps the prefix stable for
several turns rather than shifting on every one.

KV-cache ordering: the per-turn retrieved block moves behind history to the
tail, so the stable prefix stays byte-identical turn to turn. At turn 14 of
a long conversation, prefill is 231ms at the tail against 539ms at the front
(~1ms/turn growth against ~25ms). The win is conditional — with a large
retrieved block and short history the two measure the same — so both
orderings ship behind RAG_PLACEMENT. The per-turn query rewrite also moves
to the tasks model; on a single-slot Ollama it was evicting the chat model's
cached prefix on every single turn.

Also: OLLAMA_CONTEXT_LENGTH and OLLAMA_KV_CACHE_TYPE=q8_0 on the container
(q8_0 is treated as headroom, never as a budgeting assumption, since
unsupported architectures fall back to f16 silently), a Context Window
setting in Settings > Models, and budget fields on PipelineTrace.

Eval: golden suites (--suite) so the new long_context goldens cannot move
the aggregates the committed baselines were recorded against, plus budget
and estimator-error metrics through the report path. Retrieval is unchanged
and eval:compare reports no metric outside the tolerance band; the
long_context suite scores correctness 1.000 with historyElided 0.500 (the
two eviction cases, by design). Unit tests 337 -> 386 passing, same 7
pre-existing failures.

Ingest-side CHAR_TO_TOKEN_RATIO is deliberately untouched: changing it
re-chunks the corpus and invalidates retrieval baselines, which belongs in
its own measured change.
2026-09-29 15:54:41 -07:00
jakeaturner
3eaddfe6bf fix(UI): remove deprecated prop from type dec 2026-09-29 15:54:41 -07:00
jakeaturner
b3e695f4bc fix(AI): removes long-dead default rewrite model warning 2026-09-29 15:54:41 -07:00
Jake Turner
251362e018 feat(AI): make RAG retreival optional per chat (#1247) 2026-09-29 15:54:41 -07:00
Jake Turner
86ef96b200 feat(AI): specify a model for ancillary tasks (#1244)
Allows users to optionally specify a model to use for ancillary tasks (chat suggestions, chat titles, etc.) instead of strictly using the last used model. This means aesthetic/non-critical work can be passed to a small, lightweight model instead of a heavy reasoning model used for chats.

If a model is not selected in the AI Assistant settings (`/settings/models`), the existing behavior of using the last used model is retained.
2026-09-29 15:54:41 -07:00
Jake Turner
81db4e8d6c feat(eval): standardized RAG quality evaluation harness (#1233) 2026-09-29 15:54:41 -07:00
Chris Sherwood
85da928019 ci: add Build Translate Proxy Image workflow
workflow_dispatch workflows are only dispatchable if the file exists on
the default branch, so this must land on main before the translate proxy
image can be published.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-13 10:47:50 -07:00
243 changed files with 48454 additions and 1820 deletions

5
.gitattributes vendored Normal file
View File

@ -0,0 +1,5 @@
# Shell scripts must keep LF endings. These are copied into Linux container
# images, where a CRLF after the shebang makes the interpreter unresolvable and
# the container exits immediately. Checkouts on Windows would otherwise convert
# them, which only shows up when building an image locally rather than in CI.
*.sh text eol=lf

View File

@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 🔒 Report a Security Vulnerability
url: https://github.com/Crosstalk-Solutions/project-nomad/security/advisories/new
about: Report a security issue privately. Please do not open a public issue for a vulnerability.
- name: 💬 Discord Community
url: https://discord.com/invite/crosstalksolutions
about: Join our Discord community for general questions, support, and discussions

View File

@ -0,0 +1,52 @@
name: Build Translate Proxy Image
on:
workflow_dispatch:
inputs:
version:
description: 'Semantic version to label the Docker image under (no "v" prefix, e.g. "1.2.3")'
required: true
type: string
tag_latest:
description: 'Also tag this image as :latest?'
required: false
type: boolean
default: false
jobs:
check_authorization:
name: Check authorization to publish new Docker image
runs-on: ubuntu-latest
outputs:
isAuthorized: ${{ steps.check-auth.outputs.is_authorized }}
steps:
- name: check-auth
id: check-auth
run: echo "is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}" >> $GITHUB_OUTPUT
build:
name: Build translate-proxy image
needs: check_authorization
if: needs.check_authorization.outputs.isAuthorized == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: install/nomad-translate
platforms: linux/amd64
push: true
tags: |
ghcr.io/crosstalk-solutions/project-nomad-translate:${{ inputs.version }}
ghcr.io/crosstalk-solutions/project-nomad-translate:v${{ inputs.version }}
${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad-translate:latest' || '' }}

View File

@ -7,6 +7,8 @@ on:
pull_request:
paths:
- 'collections/**.json'
schedule:
- cron: '17 6 * * 1'
jobs:
validate-urls:

35
.gitignore vendored
View File

@ -16,6 +16,31 @@ node_modules/
# dotenv environment variables file
.env
.env.*
!.env.example
# Credentials and keys. Never commit these — the installer generates every
# password and app key locally at install time, so nothing real belongs in the
# repo. See SECURITY.md.
*.pem
*.key
*.p12
*.pfx
*.ppk
id_rsa
id_ed25519
.npmrc
.netrc
.htpasswd
# Local copies of the deployed compose file, which contain the real generated
# DB passwords and APP_KEY. The tracked install/management_compose.yaml template
# is the only compose file that belongs in git.
compose.yml
compose.yaml
docker-compose.yml
docker-compose.yaml
management_compose.local.yaml
# Build / Dist
dist
@ -36,8 +61,18 @@ server/temp
.vscode
.idea
# Agent Files
.agents
.claude
.codegraph
.mcp.json
# Frontend assets compiled code
admin/public/assets
# Admin specific development files
admin/storage
# Python bytecode (install/nomad-translate helper scripts)
__pycache__/
*.pyc

View File

@ -95,7 +95,21 @@ Because NOMAD relies heavily on Docker, we actually recommend against installing
3. **Make your changes.** Follow existing code style and conventions. Test your changes locally against a running NOMAD instance before submitting.
4. **Add release notes** (see [Release Notes](#release-notes) below).
4. **If you touched the AI Assistant or RAG, measure it.** Anything affecting
chunking, embedding, retrieval, reranking, thresholds, the system prompts, or
context assembly should be backed by numbers rather than a spot check —
"it seemed better in the chat window" is how a regression ships. From `admin/`:
```bash
node ace eval:corpus --ingest # once; safe, never touches your real knowledge base
node ace eval:retrieval --ablate # seconds, deterministic, no chat model
node ace eval:generation --model=<your model> --all-modes
```
Include the before/after numbers in your pull request. See
[`admin/tests/eval/README.md`](admin/tests/eval/README.md) for what the
metrics mean, how the three generation modes separate a code bug from a model
that is simply too small, and the harness's known limitations.
5. **Commit your changes** using [Conventional Commits](#commit-messages).

View File

@ -1,5 +1,5 @@
<div align="center">
<img src="admin/public/project_nomad_logo.webp" width="200" height="200"/>
<img src="admin/public/nomad-primary.svg" width="200" alt="Project NOMAD"/>
# Project NOMAD
### Knowledge That Never Goes Offline
@ -17,6 +17,8 @@ Project NOMAD is a self-contained, offline-first knowledge and education server
## Installation & Quickstart
Project NOMAD can be installed on any Debian-based operating system (we recommend Ubuntu 26.04 LTS; 24.04 LTS and Debian 12 are also supported). Installation is completely terminal-based, and all tools and resources are designed to be accessed through the browser, so there's no need for a desktop environment if you'd rather setup NOMAD as a "server" and access it through other clients.
**Before you start:** NOMAD itself needs about **5 GB of disk and under 1 GB of RAM**. The offline content you add afterwards is what actually fills a drive, and the AI Assistant is what drives the memory requirement up. See [Device Requirements](#device-requirements) for both, and the [FAQ](https://www.projectnomad.us/docs/faq) for individual content sizes.
*Note: sudo/root privileges are required to run the install script*
### Quick Install (Debian-based OS Only)
@ -75,11 +77,13 @@ At its core, however, NOMAD is still very lightweight. For a barebones installat
#### Minimum Specs
- Processor: 2 GHz dual-core processor or better
- RAM: 4GB system memory
- Storage: At least 5 GB free disk space
- RAM: 4GB system memory (the whole stack without AI runs in under 1 GB)
- Storage: At least 5 GB free disk space (plan for 10 GB to install comfortably)
- OS: Debian-based (Ubuntu 26.04 LTS recommended)
- Stable internet connection (required during install only)
Adding the AI Assistant brings the install to roughly **25 GB**, because Ollama and a general-purpose model are both large, and a model needs about its download size in memory while it answers. That memory comes from VRAM if you have a discrete GPU and from system RAM if you do not, which is the single biggest reason the figures below are so much higher.
To run LLMs and other included AI tools:
#### Optimal Specs

98
SECURITY.md Normal file
View File

@ -0,0 +1,98 @@
# Security Policy
## Supported Versions
Only the latest released version of Project NOMAD receives security fixes. If
you are running an older release, please update before reporting an issue.
## Reporting a Vulnerability
**Please do not open a public issue for a security vulnerability.**
Report it privately using GitHub's built-in reporting form:
1. Go to the [Security tab](https://github.com/Crosstalk-Solutions/project-nomad/security)
2. Click **Report a vulnerability**
This creates a private advisory that only the maintainers can see. It stays
private until a fix is available and we choose to publish it.
If you cannot use that form for any reason, email
**chris@crosstalksolutions.com** instead. Please do not include exploit details
in a Discord message or a public issue.
### What to include
The more of this you can provide, the faster we can confirm and fix it:
- The version of NOMAD you tested against, and the host OS
- Which component is affected (Command Center, installer, updater sidecar, a
Supply Depot app, the benchmark submission path, and so on)
- Steps to reproduce, ideally with the exact request or command
- What an attacker gains, and what access they need to start with
- Any suggested fix, if you have one
### What to expect
Project NOMAD is maintained by a very small team, so we do not offer a
guaranteed response time. We read every report. If a report is valid, we will
work with you on a fix and credit you in the published advisory unless you
would rather stay anonymous.
We do not run a bug bounty program and cannot offer payment for reports.
## Scope
### In scope
- Remote code execution, container escape, or privilege escalation on the host
- Any path where a remote party who is **not** on the local network can affect a
NOMAD instance, including attacks delivered through a user's browser
- Unauthenticated access to data outside the NOMAD storage root
- Path traversal, SSRF that reaches beyond the intended target, or injection in
the Command Center API
- Supply chain problems in our build and release pipeline
- Credentials or secrets committed to this repository
### Out of scope
Some things that look like vulnerabilities are deliberate design decisions for
an offline, single-appliance, local-network product. Reports covering the
following will usually be closed:
- **No authentication on the Command Center.** This is intentional and
documented in the [README](README.md#about-security). NOMAD is designed to be
open on a trusted local network. If you need access control, use
network-level controls. There is an open roadmap item if you want to vote for
optional authentication:
https://roadmap.projectnomad.us/posts/1/user-authentication-please-build-in-user-auth-with-admin-user-roles
- **Anything that requires exposing NOMAD directly to the internet.** This is
explicitly unsupported and advised against.
- **Access by someone who is already on the local network.** Local network
access is the trust boundary by design.
- **Requests to internal or private addresses.** NOMAD is expected to reach
other hosts on the local network, so RFC1918 destinations are not treated as
SSRF.
- The benchmark submission signing key. It ships inside the image because an
offline appliance cannot hold a server-side secret. Forged submissions are
handled by moderation on the leaderboard, not by the key.
- Missing security headers, missing rate limits, or similar findings with no
demonstrated impact on an appliance of this design.
- Vulnerabilities in third-party Supply Depot applications. Please report those
to the upstream project. Tell us anyway if the issue is caused by how NOMAD
configures or deploys the app.
- Findings from an automated scanner with no working proof of concept.
If you are not sure whether something is in scope, report it. We would rather
read an out-of-scope report than miss a real one.
## Secrets in this Repository
Secret scanning and push protection are enabled on this repository. If you
believe a credential has been committed, report it privately using the process
above rather than opening an issue, so it can be rotated before it is
advertised.
Note that the installer generates every database password and application key
locally at install time. The placeholder values in
`install/management_compose.yaml` are not real credentials.

View File

@ -58,6 +58,7 @@ export default defineConfig({
() => import('#providers/qdrant_restart_policy_provider'),
() => import('#providers/version_check_provider'),
() => import('#providers/gpu_passthrough_remediation_provider'),
() => import('#providers/drug_install_row_provider'),
],
/*

View File

@ -186,46 +186,68 @@ export default class MapsController {
longitude: vine.number().min(-180).max(180),
latitude: vine.number().min(-90).max(90),
color: vine.string().trim().maxLength(20).optional(),
notes: vine.string().trim().nullable().optional(),
custom_color: vine.string().trim().maxLength(7).nullable().optional(),
icon: vine.string().trim().maxLength(50).nullable().optional(),
icon_color: vine.string().trim().maxLength(7).nullable().optional(),
visible: vine.boolean().optional(),
notes: vine.string().trim().maxLength(500).nullable().optional(),
marker_type: vine.string().trim().maxLength(20).optional(),
})
)
)
const marker = await MapMarker.create({
name: payload.name,
longitude: payload.longitude,
latitude: payload.latitude,
color: payload.color ?? 'orange',
custom_color: payload.custom_color ?? null,
icon: payload.icon ?? null,
icon_color: payload.icon_color ?? null,
visible: payload.visible ?? true,
notes: payload.notes ?? null,
marker_type: payload.marker_type ?? 'pin',
})
return marker
}
async updateMarker({ request, response }: HttpContext) {
const { id } = request.params()
const marker = await MapMarker.find(id)
if (!marker) {
return response.status(404).send({ message: 'Marker not found' })
}
const payload = await request.validateUsing(
vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(255).optional(),
color: vine.string().trim().maxLength(20).optional(),
custom_color: vine.string().trim().maxLength(7).nullable().optional(),
icon: vine.string().trim().maxLength(50).nullable().optional(),
icon_color: vine.string().trim().maxLength(7).nullable().optional(),
visible: vine.boolean().optional(),
longitude: vine.number().min(-180).max(180).optional(),
latitude: vine.number().min(-90).max(90).optional(),
notes: vine.string().trim().nullable().optional(),
notes: vine.string().trim().maxLength(500).nullable().optional(),
marker_type: vine.string().trim().maxLength(20).optional(),
})
)
)
if (payload.name !== undefined) marker.name = payload.name
if (payload.color !== undefined) marker.color = payload.color
if (payload.custom_color !== undefined) marker.custom_color = payload.custom_color
if (payload.icon !== undefined) marker.icon = payload.icon
if (payload.icon_color !== undefined) marker.icon_color = payload.icon_color
if (payload.visible !== undefined) marker.visible = payload.visible
if (payload.longitude !== undefined) marker.longitude = payload.longitude
if (payload.latitude !== undefined) marker.latitude = payload.latitude
if (payload.notes !== undefined) marker.notes = payload.notes
if (payload.marker_type !== undefined) marker.marker_type = payload.marker_type
await marker.save()
return marker
}

View File

@ -1,7 +1,8 @@
import { ChatService } from '#services/chat_service'
import { DockerService } from '#services/docker_service'
import { NomadMdService } from '#services/nomad_md_service'
import { OllamaService } from '#services/ollama_service'
import { OllamaService, type NomadChatUsage } from '#services/ollama_service'
import { TokenCalibrationService } from '#services/token_calibration_service'
import { RagPipelineService } from '#services/rag_pipeline_service'
import { RagService } from '#services/rag_service'
import Service from '#models/service'
import KVStore from '#models/kv_store'
@ -10,10 +11,23 @@ import { chatSchema, getAvailableModelsSchema, unloadChatModelsSchema } from '#v
import { assertNotCloudMetadataUrl } from '#validators/common'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { RAG_CONTEXT_LIMITS, SYSTEM_PROMPTS } from '../../constants/ollama.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { DEFAULT_KEEP_ALIVE } from '../../constants/ollama.js'
import { resolveResponseStyle } from '../utils/response_style.js'
import { resolveSamplerProfile } from '../utils/sampler.js'
import type { PipelineTrace } from '../../types/rag.js'
import { buildCitations } from '../utils/rag_prompt.js'
import logger from '@adonisjs/core/services/logger'
type Message = { role: 'system' | 'user' | 'assistant'; content: string }
import { rm } from 'node:fs/promises'
import {
attachImagesToLatestUserMessage,
ChatImageError,
normalizeChatImages,
type NormalizedChatImage,
} from '../utils/chat_images.js'
const unknownVisionCompatibilityMessage = (model: string) =>
`NOMAD cannot confirm that "${model}" accepts images, and this image request failed. Choose a model marked "Supports images", or remove the image and try again.`
@inject()
export default class OllamaController {
@ -21,8 +35,9 @@ export default class OllamaController {
private chatService: ChatService,
private dockerService: DockerService,
private ollamaService: OllamaService,
private ragPipelineService: RagPipelineService,
private ragService: RagService,
private nomadMdService: NomadMdService
private tokenCalibration: TokenCalibrationService
) { }
async availableModels({ request }: HttpContext) {
@ -50,7 +65,78 @@ export default class OllamaController {
}
async chat({ request, response }: HttpContext) {
const reqData = await request.validateUsing(chatSchema)
const uploadedImages = request.files('images', {
size: '8mb',
extnames: ['jpg', 'jpeg', 'png', 'webp'],
})
const cleanupUploadedImages = () =>
Promise.all(
uploadedImages
.filter((file) => file.tmpPath)
.map((file) => rm(file.tmpPath!, { force: true }).catch(() => undefined))
)
let multipartPayload: unknown
if (uploadedImages.length > 0) {
const rawPayload = request.input('payload')
if (typeof rawPayload !== 'string') {
await cleanupUploadedImages()
return response.status(422).send({ message: 'Multipart chat requests require a JSON payload.' })
}
try {
multipartPayload = JSON.parse(rawPayload)
} catch {
await cleanupUploadedImages()
return response.status(422).send({ message: 'The multipart chat payload is not valid JSON.' })
}
}
// A multipart chat carries the collection inside the JSON payload rather than
// as a form field, so request.input() alone would silently drop it and every
// image request would search the whole knowledge base.
const rawCollection =
uploadedImages.length > 0 &&
multipartPayload &&
typeof multipartPayload === 'object' &&
'collection' in multipartPayload
? (multipartPayload as { collection?: unknown }).collection
: request.input('collection', null)
const collectionFilter: string | null =
typeof rawCollection === 'string' && rawCollection ? rawCollection : null
let reqData: Awaited<ReturnType<typeof chatSchema.validate>>
try {
reqData =
uploadedImages.length > 0
? await chatSchema.validate(multipartPayload)
: await request.validateUsing(chatSchema)
} catch (error) {
await cleanupUploadedImages()
throw error
}
let normalizedImages: NormalizedChatImage[]
try {
normalizedImages = await normalizeChatImages(uploadedImages)
} catch (error) {
if (error instanceof ChatImageError) {
return response.status(error.status).send({ message: error.message })
}
throw error
} finally {
await cleanupUploadedImages()
}
const modelCapabilities = await this.ollamaService.getModelCapabilities(reqData.model)
if (
normalizedImages.length > 0 &&
!reqData.messages.some((message) => message.role === 'user')
) {
return response.status(422).send({ message: 'Images require a user message.' })
}
if (normalizedImages.length > 0 && modelCapabilities.vision === 'unsupported') {
return response.status(422).send({
message: `The selected model "${reqData.model}" does not support image input.`,
})
}
// Flush SSE headers immediately so the client connection is open while
// pre-processing (query rewriting, RAG lookup) runs in the background.
@ -61,108 +147,40 @@ export default class OllamaController {
response.response.flushHeaders()
}
let unknownVisionUpstreamRejected = false
try {
// If there are no system messages in the chat inject system prompts
const hasSystemMessage = reqData.messages.some((msg) => msg.role === 'system')
if (!hasSystemMessage) {
const systemPrompt = {
role: 'system' as const,
content: SYSTEM_PROMPTS.default,
}
logger.debug('[OllamaController] Injecting system prompt')
reqData.messages.unshift(systemPrompt)
}
// Inject the user-managed NOMAD.md as its own leading system message so the
// user's persistent instructions take precedence, while the default
// formatting prompt and any RAG context below remain intact. A missing or
// blank file yields null and changes nothing.
const nomadPrompt = await this.nomadMdService.getSystemPrompt()
if (nomadPrompt) {
logger.debug('[OllamaController] Injecting NOMAD.md system prompt')
reqData.messages.unshift({ role: 'system' as const, content: nomadPrompt })
}
// Query rewriting for better RAG retrieval with manageable context
// Will return user's latest message if no rewriting is needed
const rewrittenQuery = await this.rewriteQueryWithContext(reqData.messages, reqData.model)
logger.debug(`[OllamaController] Rewritten query for RAG: "${rewrittenQuery}"`)
if (rewrittenQuery) {
const collectionFilter: string | null = request.input('collection', null)
const relevantDocs = await this.ragService.searchSimilarDocuments(
rewrittenQuery,
5, // Top 5 most relevant chunks
0.3, // Minimum similarity score of 0.3
collectionFilter ?? undefined
)
logger.debug(`[RAG] Retrieved ${relevantDocs.length} relevant documents for query: "${rewrittenQuery}"`)
// If relevant context is found, inject as a system message with adaptive limits
if (relevantDocs.length > 0) {
// Determine context budget based on model size
const { maxResults, maxTokens } = this.getContextLimitsForModel(reqData.model)
let trimmedDocs = relevantDocs.slice(0, maxResults)
// Apply token cap if set (estimate ~3.5 chars per token)
// Always include the first (most relevant) result — the cap only gates subsequent results
if (maxTokens > 0) {
const charCap = maxTokens * 3.5
let totalChars = 0
trimmedDocs = trimmedDocs.filter((doc, idx) => {
totalChars += doc.text.length
return idx === 0 || totalChars <= charCap
})
}
logger.debug(
`[RAG] Injecting ${trimmedDocs.length}/${relevantDocs.length} results (model: ${reqData.model}, maxResults: ${maxResults}, maxTokens: ${maxTokens || 'unlimited'})`
)
// Label each context block with its source title when available (a neutral,
// honest provenance signal) but never the raw relevance score — nomic cosine
// scores for genuinely relevant passages sit ~0.4-0.6, and surfacing e.g.
// "42%" primes the model to distrust correct context. Scores stay in the logs
// above for debugging.
const contextText = trimmedDocs
.map((doc, idx) => {
const title = doc.metadata?.full_title || doc.metadata?.article_title
const label = title ? `[Context ${idx + 1} — ${title}]` : `[Context ${idx + 1}]`
return `${label}\n${doc.text}`
})
.join('\n\n')
const systemMessage = {
role: 'system' as const,
content: SYSTEM_PROMPTS.rag_context(contextText),
}
// Insert system message at the beginning (after any existing system messages)
const firstNonSystemIndex = reqData.messages.findIndex((msg) => msg.role !== 'system')
const insertIndex = firstNonSystemIndex === -1 ? 0 : firstNonSystemIndex
reqData.messages.splice(insertIndex, 0, systemMessage)
}
}
// If system messages are large (e.g. due to RAG context), request a context window big
// enough to fit them. Ollama respects num_ctx per-request; LM Studio ignores it gracefully.
const systemChars = reqData.messages
.filter((m) => m.role === 'system')
.reduce((sum, m) => sum + m.content.length, 0)
const estimatedSystemTokens = Math.ceil(systemChars / 3.5)
let numCtx: number | undefined
if (estimatedSystemTokens > 3000) {
const needed = estimatedSystemTokens + 2048 // leave room for conversation + response
numCtx = [8192, 16384, 32768, 65536].find((n) => n >= needed) ?? 65536
logger.debug(`[OllamaController] Large system prompt (~${estimatedSystemTokens} tokens), requesting num_ctx: ${numCtx}`)
}
// Everything from system-prompt assembly through query rewriting,
// retrieval, context trimming and the num_ctx decision lives in
// RagPipelineService so the eval harness exercises this exact code path.
// Knowledge base retrieval is user-toggleable (chat header + AI Assistant
// settings, both writing rag.enabled). Unset means on, preserving the
// behaviour from before the toggle existed.
const ragEnabled = (await KVStore.getValue('rag.enabled')) ?? true
const trace = await this.ragPipelineService.buildPrompt(reqData.messages, reqData.model, {
collection: collectionFilter ?? undefined,
skipRetrieval: !ragEnabled,
})
reqData.messages = trace.messages
const numCtx = trace.numCtx
const numPredict = trace.numPredict
// Provenance for the answer about to be generated (#1179). Built from
// trace.injected -- what the model actually read -- and surfaced under the
// answer as "Sources". Empty whenever retrieval was skipped or declined,
// which is the honest result: no context, no citations.
const sources = buildCitations(trace.injected)
// Keeping the model resident is what makes the KV cache worth building:
// Ollama's default evicts after 5 minutes, which is well inside the time a
// user spends reading an answer and typing the next question.
const keepAlive = (await KVStore.getValue('ai.keepAlive')) || DEFAULT_KEEP_ALIVE
// Check if the model supports "thinking" capability for enhanced response generation.
// Thinking is only enabled when the model supports it AND the user wants it: the explicit
// per-request preference wins, otherwise the global default (ai.autoThinking, default OFF).
// If gpt-oss model, it requires a text param for "think" https://docs.ollama.com/api/chat
const thinkingCapability = await this.ollamaService.checkModelHasThinking(reqData.model)
// One /api/show read, memoized per model, for two decisions: whether the
// model can think, and what sampler values its author baked in.
const modelInfo = await this.ollamaService.getModelInfo(reqData.model)
const thinkingCapability = modelInfo.hasThinking
let thinkingEnabled = false
if (thinkingCapability) {
thinkingEnabled = reqData.think ?? ((await KVStore.getValue('ai.autoThinking')) ?? false)
@ -170,9 +188,23 @@ export default class OllamaController {
const think: boolean | 'medium' =
thinkingEnabled ? (reqData.model.startsWith('gpt-oss') ? 'medium' : true) : false
// Sampler settings for this turn. Chat used to send none of these and
// inherit the backend's defaults, which on a heavily quantized small model
// is the wandering, repetitive output people read as "the local model is
// bad". Resolved here rather than in OllamaService so the ancillary
// structured calls, which want temperature 0 and a grammar, stay untouched.
const sampler = resolveSamplerProfile(
await resolveResponseStyle(),
modelInfo.modelfileSamplers
)
// Separate sessionId and the resolved thinking preference from the Ollama request payload —
// Ollama rejects unknown fields, and `think` is re-derived above (not forwarded raw).
const { sessionId, think: _thinkPref, ...ollamaRequest } = reqData
const upstreamMessages = attachImagesToLatestUserMessage(
ollamaRequest.messages,
normalizedImages
)
// Save user message to DB before streaming if sessionId provided
let userContent: string | null = null
@ -192,19 +224,30 @@ export default class OllamaController {
// blocks every later chat/RAG request until the model is manually stopped (#1065).
const abortController = new AbortController()
response.response.on('close', () => abortController.abort())
const stream = await this.ollamaService.chatStream({
...ollamaRequest,
think,
thinkingCapable: thinkingCapability,
numCtx,
signal: abortController.signal,
})
let fullContent = ''
let doneReason: string | undefined
let completionTokens: number | undefined
try {
const stream = await this.ollamaService.chatStream({
...ollamaRequest,
messages: upstreamMessages,
think,
thinkingCapable: thinkingCapability,
numCtx,
numPredict,
keepAlive,
sampler,
signal: abortController.signal,
})
for await (const chunk of stream) {
if (chunk.message?.content) {
fullContent += chunk.message.content
}
if (chunk.usage) {
completionTokens = chunk.usage.completionTokens
this._recordUsage(reqData.model, trace, chunk.usage)
}
if (chunk.done_reason) doneReason = chunk.done_reason
response.response.write(`data: ${JSON.stringify(chunk)}\n\n`)
}
} catch (err) {
@ -212,13 +255,25 @@ export default class OllamaController {
logger.debug('[OllamaController] Client disconnected; aborted upstream Ollama generation')
return
}
unknownVisionUpstreamRejected =
normalizedImages.length > 0 && modelCapabilities.vision === 'unknown'
throw err
}
if (doneReason === 'length') {
this._logLengthStop(reqData.model, numCtx, numPredict, completionTokens)
}
// Trailing citation event, written before end(). It carries no `message`
// key, which is how the client tells it apart from Ollama's own chunks.
if (sources.length > 0) {
response.response.write(`data: ${JSON.stringify({ sources })}
`)
}
response.response.end()
// Save assistant message and optionally generate title
if (sessionId && fullContent) {
await this.chatService.addMessage(sessionId, 'assistant', fullContent)
await this.chatService.addMessage(sessionId, 'assistant', fullContent, sources)
const messageCount = await this.chatService.getMessageCount(sessionId)
if (messageCount <= 2 && userContent) {
this.chatService.generateTitle(sessionId, userContent, fullContent, reqData.model).catch((err) => {
@ -230,10 +285,35 @@ export default class OllamaController {
}
// Non-streaming (legacy) path
const result = await this.ollamaService.chat({ ...ollamaRequest, think, thinkingCapable: thinkingCapability, numCtx })
let result
try {
result = await this.ollamaService.chat({
...ollamaRequest,
messages: upstreamMessages,
think,
thinkingCapable: thinkingCapability,
numCtx,
numPredict,
keepAlive,
sampler,
})
} catch (err) {
// A backend that never advertised vision support rejecting a request that
// carried images is the signal that it cannot see them. Recorded here and
// translated into the compatibility message by the outer catch.
unknownVisionUpstreamRejected =
normalizedImages.length > 0 && modelCapabilities.vision === 'unknown'
throw err
}
if (result?.usage) {
this._recordUsage(reqData.model, trace, result.usage)
}
if (result?.done_reason === 'length') {
this._logLengthStop(reqData.model, numCtx, numPredict, result.usage?.completionTokens)
}
if (sessionId && result?.message?.content) {
await this.chatService.addMessage(sessionId, 'assistant', result.message.content)
await this.chatService.addMessage(sessionId, 'assistant', result.message.content, sources)
const messageCount = await this.chatService.getMessageCount(sessionId)
if (messageCount <= 2 && userContent) {
this.chatService.generateTitle(sessionId, userContent, result.message.content, reqData.model).catch((err) => {
@ -242,17 +322,75 @@ export default class OllamaController {
}
}
return result
return { ...result, sources }
} catch (error) {
if (reqData.stream) {
response.response.write(`data: ${JSON.stringify({ error: true })}\n\n`)
const streamError =
unknownVisionUpstreamRejected
? {
error: true,
message: unknownVisionCompatibilityMessage(reqData.model),
}
: { error: true }
response.response.write(`data: ${JSON.stringify(streamError)}\n\n`)
response.response.end()
return
}
if (unknownVisionUpstreamRejected) {
return response.status(422).send({
message: unknownVisionCompatibilityMessage(reqData.model),
})
}
throw error
}
}
/**
* A reply that hit the generation cap reaches the user cut off. The client
* shows that from done_reason; this puts it in the admin log with the numbers
* needed to tell a small window from a cap that is set too low (#1342).
*/
private _logLengthStop(
model: string,
numCtx: number | undefined,
numPredict: number | undefined,
completionTokens: number | undefined
): void {
logger.info(
`[OllamaController] ${model} stopped at the length limit: ` +
`${completionTokens ?? '?'} tokens generated, num_predict=${numPredict ?? 'unset'}, num_ctx=${numCtx ?? 'unset'}`
)
}
/**
* Close the loop on token accounting.
*
* The backend just told us exactly how many tokens the prompt really was.
* Comparing that against what we estimated is what lets the estimator correct
* itself per model — free ground truth that used to be discarded. Also logs
* the prefill rate, which is the practical prefix-cache-hit signal: a reused
* KV prefix means many prompt tokens for very little prefill time.
*
* Best-effort throughout; a calibration failure must never affect the reply.
*/
private _recordUsage(model: string, trace: PipelineTrace, usage: NomadChatUsage): void {
if (trace.uncalibratedPromptTokens && usage.promptTokens) {
this.tokenCalibration
.record(model, trace.uncalibratedPromptTokens, usage.promptTokens)
.catch((err) => {
logger.debug(`[OllamaController] Token calibration failed: ${err?.message ?? err}`)
})
if (usage.promptEvalMs !== undefined && usage.promptEvalMs > 0) {
const tokensPerMs = usage.promptTokens / usage.promptEvalMs
logger.debug(
`[OllamaController] Prefill: ${usage.promptTokens} tokens in ${usage.promptEvalMs.toFixed(0)}ms ` +
`(${tokensPerMs.toFixed(1)} tok/ms; a high rate means the KV prefix was reused)`
)
}
}
}
async remoteStatus() {
const remoteUrl = await KVStore.getValue('ai.remoteOllamaUrl')
if (!remoteUrl) {
@ -409,99 +547,13 @@ export default class OllamaController {
}
}
async installedModels({ }: HttpContext) {
async installedModels({}: HttpContext) {
const models = await this.ollamaService.getModels()
// Enrich each model with its thinking capability so the chat picker knows which models
// to show the per-model thinking toggle for. checkModelHasThinking memoizes /api/show
// results, so this stays cheap on repeat loads. Best-effort per model.
const thinking = await Promise.all(
models.map((m) => this.ollamaService.checkModelHasThinking(m.name))
// Enrich from backend-reported capabilities; never guess from model names.
const capabilities = await Promise.all(
models.map((m) => this.ollamaService.getModelCapabilities(m.name, m))
)
return models.map((m, i) => ({ ...m, thinking: thinking[i] }))
return models.map((m, i) => ({ ...m, ...capabilities[i] }))
}
/**
* Determines RAG context limits based on model size extracted from the model name.
* Parses size indicators like "1b", "3b", "8b", "70b" from model names/tags.
*/
private getContextLimitsForModel(modelName: string): { maxResults: number; maxTokens: number } {
// Extract parameter count from model name (e.g., "llama3.2:3b", "qwen2.5:1.5b", "gemma:7b")
const sizeMatch = modelName.match(/(\d+\.?\d*)[bB]/)
const paramBillions = sizeMatch ? parseFloat(sizeMatch[1]) : 8 // default to 8B if unknown
for (const tier of RAG_CONTEXT_LIMITS) {
if (paramBillions <= tier.maxParams) {
return { maxResults: tier.maxResults, maxTokens: tier.maxTokens }
}
}
// Fallback: no limits
return { maxResults: 5, maxTokens: 0 }
}
private async rewriteQueryWithContext(
messages: Message[],
model: string
): Promise<string | null> {
const lastUserMessage = [...messages].reverse().find(msg => msg.role === 'user')
try {
// Skip the entire RAG pipeline if there are no documents to search
const hasDocuments = await this.ragService.hasDocuments()
if (!hasDocuments) {
return null
}
// Get recent conversation history (last 6 messages for 3 turns)
const recentMessages = messages.slice(-6)
// Skip rewriting on the very first turn — with only one user message
// there is no prior context to fold in, so the rewrite would just echo
// the message back at the cost of an extra LLM round-trip. From the
// first follow-up onward we need the rewrite so the RAG query carries
// entities and topics from earlier turns ("the bars" → "Hershey's bars
// chocolate poisoning dog"); without it, embeddings match nothing and
// the assistant loses the thread.
const userMessages = recentMessages.filter(msg => msg.role === 'user')
if (userMessages.length < 2) {
return lastUserMessage?.content || null
}
const conversationContext = recentMessages
.map(msg => {
const role = msg.role === 'user' ? 'User' : 'Assistant'
// Truncate assistant messages to first 200 chars to keep context manageable
const content = msg.role === 'assistant'
? msg.content.slice(0, 200) + (msg.content.length > 200 ? '...' : '')
: msg.content
return `${role}: "${content}"`
})
.join('\n')
const response = await this.ollamaService.chat({
model,
messages: [
{
role: 'system',
content: SYSTEM_PROMPTS.query_rewrite,
},
{
role: 'user',
content: `Conversation:\n${conversationContext}\n\nRewritten Query:`,
},
],
})
const rewrittenQuery = response.message.content.trim()
logger.info(`[RAG] Query rewritten: "${rewrittenQuery}"`)
return rewrittenQuery
} catch (error) {
logger.error(
`[RAG] Query rewriting failed: ${error instanceof Error ? error.message : error}`
)
// Fallback to last user message if rewriting fails
return lastUserMessage?.content || null
}
}
}

View File

@ -94,6 +94,57 @@ export default class RagController {
return response.status(200).json({ message: result.message })
}
public async setFileActive({ request, response }: HttpContext) {
const source: string | null = request.input('source', null)
const active: boolean | null = request.input('active', null)
if (!source || typeof active !== 'boolean') {
return response.status(400).json({ error: 'source and active are required.' })
}
const result = await this.ragService.setFileActive(source, active)
if (!result.success) {
return response.status(500).json({ error: result.message })
}
return response.status(200).json({ message: result.message })
}
/** Bulk active/inactive toggle for every file tagged with a KB collection
* (the personal-upload `collection` field) -- distinct from the unrelated
* curated ZIM-pack "collections" feature. `collection: null` targets the
* "Uncategorized" bucket. */
public async setKnowledgeCollectionActive({ request, response }: HttpContext) {
const rawCollection = request.input('collection', undefined)
const active: boolean | null = request.input('active', null)
// Validate before sanitising. Form-encoded input can yield an array
// (`collection[]=a&collection[]=b`), and sanitizeCollectionName would then
// throw on `.trim()` and surface as an uncaught 500.
//
// Requiring `active` to be a real boolean is also load-bearing beyond input
// hygiene: a cross-site HTML form can only send the string "true", so this
// check is what keeps a bulk Qdrant write across every point in the index
// out of reach of a form POST from another origin. Do not "helpfully"
// coerce it.
if (rawCollection === undefined || typeof active !== 'boolean') {
return response.status(400).json({ error: 'collection and active are required.' })
}
if (rawCollection !== null && typeof rawCollection !== 'string') {
return response.status(400).json({ error: 'collection must be a string or null.' })
}
const collection = rawCollection === null ? null : sanitizeCollectionName(rawCollection)
const result = await this.ragService.setKnowledgeCollectionActive(collection, active)
if (!result.success) {
return response.status(500).json({ error: result.message })
}
return response
.status(200)
.json({ message: result.message, affectedCount: result.affectedCount })
}
public async renameKnowledgeCollection({ request, response }: HttpContext) {
const oldName = sanitizeCollectionName(request.input('oldName', null))
const newName = sanitizeCollectionName(request.input('newName', null))

View File

@ -1,5 +1,6 @@
import KVStore from '#models/kv_store'
import { BenchmarkService } from '#services/benchmark_service'
import { ContextWindowService } from '#services/context_window_service'
import { MapService } from '#services/map_service'
import { OllamaService } from '#services/ollama_service'
import { SystemService } from '#services/system_service'
@ -7,6 +8,10 @@ import { getSettingSchema, updateSettingSchema, validateSettingValue } from '#va
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import env from '#start/env'
import { parseMinRelevance } from '../utils/misc.js'
import { isRelevanceCheckEnabled } from '../utils/relevance_judge.js'
import { parseResponseStyle } from '../utils/sampler.js'
import { RAG_MIN_FINAL_SCORE } from '../../constants/ollama.js'
@inject()
export default class SettingsController {
@ -14,7 +19,8 @@ export default class SettingsController {
private systemService: SystemService,
private mapService: MapService,
private benchmarkService: BenchmarkService,
private ollamaService: OllamaService
private ollamaService: OllamaService,
private contextWindowService: ContextWindowService
) {}
async system({ inertia }: HttpContext) {
@ -71,6 +77,25 @@ export default class SettingsController {
const remoteOllamaUrl = await KVStore.getValue('ai.remoteOllamaUrl')
const ollamaFlashAttention = await KVStore.getValue('ai.ollamaFlashAttention')
const autoThinking = await KVStore.getValue('ai.autoThinking')
const tasksModel = await KVStore.getValue('ai.tasksModel')
const ragEnabled = await KVStore.getValue('rag.enabled')
const contextWindow = await KVStore.getValue('ai.contextWindow')
const minRelevance = await KVStore.getValue('rag.minRelevance')
const relevanceCheck = await KVStore.getValue('rag.relevanceCheck')
const responseStyle = await KVStore.getValue('ai.responseStyle')
// Resolved window per installed model, so the setting shows what "Auto"
// actually produced rather than leaving the user to guess. Best-effort:
// a model whose metadata can't be read simply doesn't get a badge.
const resolvedContextWindows: Record<string, number> = {}
await Promise.all(
(installedModels || []).map(async (model) => {
try {
resolvedContextWindows[model.name] = await this.contextWindowService.windowFor(model.name)
} catch {
/* leave unset */
}
})
)
return inertia.render('settings/models', {
models: {
availableModels: availableModels?.models || [],
@ -81,7 +106,18 @@ export default class SettingsController {
remoteOllamaUrl: remoteOllamaUrl ?? '',
ollamaFlashAttention: ollamaFlashAttention ?? true,
autoThinking: autoThinking ?? false,
tasksModel: tasksModel ?? '',
ragEnabled: ragEnabled ?? true,
contextWindow: contextWindow ?? 'auto',
// Sent as the resolved number so the select can match an option
// without duplicating the "unset means the default" rule in the UI.
minRelevance: parseMinRelevance(minRelevance, RAG_MIN_FINAL_SCORE),
relevanceCheck: isRelevanceCheckEnabled(relevanceCheck),
// Resolved rather than raw, for the same reason: unset means 'auto',
// and the select shouldn't have to know that.
responseStyle: parseResponseStyle(responseStyle),
},
resolvedContextWindows,
},
})
}

View File

@ -23,6 +23,9 @@ import {
updateServiceValidator,
setServiceAutoUpdateValidator,
setServiceCustomUrlValidator,
createLinkTileValidator,
updateLinkTileValidator,
deleteLinkTileValidator,
normalizeCustomUrl,
} from '#validators/system'
import {
@ -30,10 +33,12 @@ import {
DEFAULT_MEMORY_MB,
evaluateCustomApp,
} from '#services/custom_app_guard'
import { DEFAULT_LINK_TILE_ICON, isLinkTileIcon } from '../../constants/link_tile_icons.js'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import Service from '#models/service'
import { DEFAULT_LINK_TILE_COLOR } from '../../constants/link_tile_colors.js'
@inject()
export default class SystemController {
@ -529,6 +534,125 @@ export default class SystemController {
return response.send({ success: true, custom_url: service.custom_url })
}
/**
* Create a dashboard link tile: a shortcut to something the user already runs.
*
* Stored as a service row with no container behind it. `installed` is true so
* the dashboard renders it, `custom_url` carries the destination (getServiceLink
* already prefers custom_url over a computed link), and `is_link_tile` marks it
* so the UI never offers Start/Stop/Update/Uninstall for something with no
* lifecycle. `is_custom` stays false: that flag means a container NOMAD installs.
*/
async createLinkTile({ request, response }: HttpContext) {
const payload = await request.validateUsing(createLinkTileValidator)
const normalized = normalizeCustomUrl(payload.url)
if (!normalized) {
return response.status(422).send({
success: false,
message: 'Enter a valid URL, for example 192.168.1.50:8080 or https://nas.local.',
})
}
if (payload.icon && !isLinkTileIcon(payload.icon)) {
return response.status(422).send({ success: false, message: 'Unknown icon.' })
}
// Namespaced so a tile can never collide with a curated catalog entry. The
// seeder only ever touches names in its own DEFAULT_SERVICES list, so a
// `nomad_link_` row is invisible to a reseed.
const slug = payload.friendly_name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
if (!slug) {
return response.status(422).send({ success: false, message: 'Enter a name using letters or numbers.' })
}
const serviceName = `nomad_link_${slug}`
const existing = await Service.query().where('service_name', serviceName).first()
if (existing) {
return response.status(409).send({
success: false,
message: `A link named "${payload.friendly_name}" already exists. Choose a different name.`,
})
}
const service = await Service.create({
service_name: serviceName,
container_image: '',
container_config: null,
friendly_name: payload.friendly_name,
description: payload.description ?? null,
icon: payload.icon || DEFAULT_LINK_TILE_ICON,
display_order: payload.display_order ?? 90,
installed: true,
installation_status: 'idle',
is_dependency_service: false,
ui_location: null,
custom_url: normalized,
is_custom: false,
is_user_modified: true,
is_link_tile: true,
link_color: payload.link_color ?? DEFAULT_LINK_TILE_COLOR,
category: 'Links',
})
return response.status(201).send({ success: true, service_name: service.service_name })
}
/** Reconfigure an existing link tile. service_name is immutable so the tile keeps its identity. */
async updateLinkTile({ request, response }: HttpContext) {
const payload = await request.validateUsing(updateLinkTileValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ success: false, message: 'Link not found' })
}
if (!service.is_link_tile) {
return response.status(403).send({ success: false, message: 'That app is not a link.' })
}
const normalized = normalizeCustomUrl(payload.url)
if (!normalized) {
return response.status(422).send({
success: false,
message: 'Enter a valid URL, for example 192.168.1.50:8080 or https://nas.local.',
})
}
if (payload.icon && !isLinkTileIcon(payload.icon)) {
return response.status(422).send({ success: false, message: 'Unknown icon.' })
}
service.friendly_name = payload.friendly_name
service.description = payload.description ?? null
service.icon = payload.icon || DEFAULT_LINK_TILE_ICON
service.display_order = payload.display_order ?? service.display_order ?? 90
service.custom_url = normalized
service.link_color = payload.link_color ?? service.link_color ?? DEFAULT_LINK_TILE_COLOR
await service.save()
return response.send({ success: true })
}
/** Remove a link tile. Nothing to uninstall: there is no container, image or volume. */
async deleteLinkTile({ request, response }: HttpContext) {
const payload = await request.validateUsing(deleteLinkTileValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ success: false, message: 'Link not found' })
}
if (!service.is_link_tile) {
return response.status(403).send({ success: false, message: 'That app is not a link.' })
}
await service.delete()
return response.send({ success: true })
}
/** Re-pull a custom app's image and recreate its container in place (preserving volumes). */
async updateCustomApp_pullLatest({ request, response }: HttpContext) {
const payload = await request.validateUsing(installServiceValidator)

View File

@ -25,8 +25,12 @@ export default class ZimController {
async listRemote({ request }: HttpContext) {
const payload = await request.validateUsing(listRemoteZimValidator)
const { start = 0, count = 12, query } = payload
return await this.zimService.listRemote({ start, count, query })
const { start = 0, count = 12, query, language } = payload
return await this.zimService.listRemote({ start, count, query, language })
}
async listCatalogLanguages({}: HttpContext) {
return { languages: await this.zimService.listCatalogLanguages() }
}
async downloadRemote({ request }: HttpContext) {

View File

@ -160,17 +160,49 @@ export class DownloadModelJob {
return await queue.getJob(jobId)
}
/**
* Returns the job for this model only when it is genuinely in flight.
*
* A terminal record (completed or failed) is removed instead, because the
* jobId is a hash of the model name and `queue.add` with an existing custom
* jobId returns the existing job rather than throwing. Leaving a terminal
* record in place therefore makes every later dispatch for that model a
* silent no-op. Mirrors RunDownloadJob.getActiveByUrl.
*/
static async getActiveByModelName(modelName: string): Promise<Job | undefined> {
const job = await this.getByModelName(modelName)
if (!job) return undefined
const state = await job.getState()
if (state === 'active' || state === 'waiting' || state === 'delayed') {
return job
}
// Terminal state -- clean up so it doesn't block a re-download
try {
await job.remove()
} catch {
// May already be gone
}
return undefined
}
static async dispatch(params: DownloadModelJobParams) {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const jobId = this.getJobId(params.modelName)
// Clear any previous failed job so a fresh attempt can be dispatched
const existing = await queue.getJob(jobId)
if (existing) {
const state = await existing.getState()
if (state === 'failed') {
await existing.remove()
// Return an in-flight download as-is, and clear any terminal record so a
// fresh attempt can be dispatched. Previously this only cleared `failed`,
// so once a model had been downloaded successfully its retained completed
// job deduped every later request: the API still reported success and no
// worker ran, leaving the model uninstallable until Redis was flushed.
const inFlight = await this.getActiveByModelName(params.modelName)
if (inFlight) {
return {
job: inFlight,
created: false,
message: `Download already in progress for model ${params.modelName}`,
}
}

View File

@ -389,6 +389,33 @@ export class EmbedFileJob {
jobOptions.jobId = initialJobId
}
// Deterministic-jobId dispatches must clear a terminal record before adding.
// `queue.add` with an existing custom jobId returns that job instead of
// throwing, so a retained failed entry (held by `removeOnFail: { count: 20 }`)
// made re-indexing that file a silent no-op: the caller got 202 "Indexing
// queued" and nothing was enqueued. In-flight jobs are still returned as-is
// so a re-click during an active embed stays idempotent.
if (!isContinuation && !force) {
const existing = await queue.getJob(initialJobId)
if (existing) {
const state = await existing.getState()
if (state === 'active' || state === 'waiting' || state === 'delayed') {
logger.info(`[EmbedFileJob] Job already in progress for file: ${params.fileName}`)
return {
job: existing,
created: false,
jobId: initialJobId,
message: `Embedding job already exists for: ${params.fileName}`,
}
}
try {
await existing.remove()
} catch {
// May already be gone
}
}
}
try {
const job = await queue.add(this.key, params, jobOptions)

View File

@ -749,37 +749,25 @@ export class IngestDrugDataJob {
// Install-state write-back: when this ingest was kicked off by a curated-tier
// install, record an `installed_resources` row so the tier-status math (which
// is row-driven for ZIM/map) recognizes the dataset uniformly — the home-tile
// gate and the tier "installed" badge both read these rows. resource_type
// 'dataset' was widened onto the model in the foundational slice. The row's
// gate and the tier "installed" badge both read these rows. The row's
// `version` is the openFDA export_date (the real freshness key), not the
// manifest placeholder. Manual downloads pass no resourceMeta → no row, which
// is correct: install-state belongs to the curated-tier path only.
// manifest placeholder. Manual ingests pass no resourceMeta and write no row
// here; DrugInstallRowProvider backfills that case on the next admin boot.
if (!resourceMeta) return
try {
const { default: InstalledResource } = await import('#models/installed_resource')
const { DateTime } = await import('luxon')
const totalBytes = await this.totalDownloadedBytes()
await InstalledResource.updateOrCreate(
{ resource_id: resourceMeta.resourceId, resource_type: 'dataset' },
{
version: exportDate,
collection_ref: resourceMeta.collectionRef,
url: 'https://api.fda.gov/download.json',
// No single on-disk file — the parts are deleted after ingest; the
// installed artifact is the DB table. Record the staging dir for
// provenance; uninstall keys off resource_id, never this path.
file_path: STORAGE_BASE,
file_size_bytes: totalBytes,
installed_at: DateTime.now(),
}
)
const { DrugReferenceService } = await import('#services/drug_reference_service')
await new DrugReferenceService().recordInstalledRow({
version: exportDate,
collectionRef: resourceMeta.collectionRef,
fileSizeBytes: await this.totalDownloadedBytes(),
})
logger.info(
`[IngestDrugDataJob] Wrote installed_resources row for ${resourceMeta.resourceId} (export_date=${exportDate})`
)
} catch (err) {
// A failed row write must NOT abort a completed ingest — the data is
// already searchable. Log loud; the tier badge will simply read
// not-installed until the next install reconcile.
// not-installed until DrugInstallRowProvider backfills it on the next boot.
logger.error(
`[IngestDrugDataJob] Failed to write installed_resources row for ${resourceMeta.resourceId}: ${
err instanceof Error ? err.message : String(err)

View File

@ -18,6 +18,9 @@ export default class ChatMessage extends BaseModel {
@column()
declare content: string
@column()
declare sources: string | null
@belongsTo(() => ChatSession, { foreignKey: 'session_id', localKey: 'id' })
declare session: BelongsTo<typeof ChatSession>

View File

@ -31,6 +31,9 @@ export default class KbIngestState extends BaseModel {
@column()
declare collection: string | null
@column()
declare active: boolean
@column()
declare last_error: string | null
@ -43,7 +46,7 @@ export default class KbIngestState extends BaseModel {
static async getOrCreate(filePath: string, collection?: string): Promise<KbIngestState> {
return this.firstOrCreate(
{ file_path: filePath },
{ file_path: filePath, state: 'pending_decision', chunks_embedded: 0, collection: collection ?? null }
{ file_path: filePath, state: 'pending_decision', chunks_embedded: 0, collection: collection ?? null, active: true }
)
}

View File

@ -35,6 +35,22 @@ export default class MapMarker extends BaseModel {
@column()
declare notes: string | null
@column()
declare custom_color: string | null
@column()
declare icon: string | null
@column()
declare icon_color: string | null
@column({
// MySQL stores this as tinyint(1) and mysql2 hands back 1/0, not true/false.
// Without this, strict comparisons (visible === false) silently never match.
consume: (value: number | boolean) => Boolean(value),
})
declare visible: boolean
@column.dateTime({ autoCreate: true })
declare created_at: DateTime

View File

@ -82,6 +82,20 @@ export default class Service extends BaseModel {
})
declare is_user_modified: boolean
// True when this row is only a dashboard shortcut: a name, icon and URL with no
// container behind it. Distinct from is_custom, which is still a real container
// NOMAD installs and manages. A link tile has no lifecycle, so no Start/Stop/
// Update/Uninstall may be offered for one.
@column({
serialize(value) {
return Boolean(value)
},
})
declare is_link_tile: boolean
@column()
declare link_color: string | null
@column()
declare category: string | null

View File

@ -30,6 +30,7 @@ import type {
} from '../../types/benchmark.js'
import KVStore from '#models/kv_store'
import { normalizeArchitecture, deriveOsName } from '../utils/platform_metadata.js'
import { isUnresolvedGpuModel } from '../utils/gpu_model.js'
import { getFreeBytes } from '../utils/image_disk_preflight.js'
import { readFile } from 'node:fs/promises'
import { randomUUID, createHmac } from 'node:crypto'
@ -186,25 +187,8 @@ function isSelfHostedOllamaUrl(rawUrl: string): boolean {
return false
}
/**
* Is this GPU "model" a placeholder rather than a real name?
*
* systeminformation resolves PCI ids against the container's pci.ids database.
* When a card is newer than that file it reports the raw id verbatim — an RTX
* 5060 comes back as "Device 2d05". Vendor detection still succeeds, so these
* strings otherwise pass as legitimate model names and reach the leaderboard.
*
* Matches the "Device <hex>" shape plus the empty/unknown cases. Deliberately
* narrow: it must never reject a real product name, and no shipping GPU is
* called "Device" followed by four hex digits.
*/
function isUnresolvedGpuModel(model: string): boolean {
const s = model.trim()
if (s === '') return true
if (/^device\s+[0-9a-f]{4}$/i.test(s)) return true
if (/^unknown$/i.test(s)) return true
return false
}
// Moved to app/utils/gpu_model.ts so the Settings > System display path can
// share the same definition — see the note there (#1196).
// Minimum free disk required before pulling the AI model on first run. llama3.1:8b
// (Q4) is ~4.9 GB; this covers the model plus headroom for the transient sysbench

View File

@ -7,11 +7,44 @@ import { inject } from '@adonisjs/core'
import { OllamaService } from './ollama_service.js'
import { SYSTEM_PROMPTS } from '../../constants/ollama.js'
import { toTitleCase } from '../utils/misc.js'
import { resolveTasksModel } from '../utils/tasks_model.js'
import {
SUGGESTIONS_SCHEMA,
TITLE_SCHEMA,
pickSuggestions,
pickTitle,
resolveStructured,
} from '../utils/structured_output.js'
import type { ChatSource } from '../../types/chat.js'
/** Sidebar width, near enough. Applied once, to whichever candidate title won. */
const TITLE_MAX_LENGTH = 57
function truncateTitle(value: string): string {
return value.length > TITLE_MAX_LENGTH ? value.slice(0, TITLE_MAX_LENGTH) + '...' : value
}
@inject()
export class ChatService {
constructor(private ollamaService: OllamaService) {}
/**
* The model to use for ancillary work — chat titles and chat suggestions.
*
* Prefers the user's `ai.tasksModel` setting so a 30B reasoning model isn't
* spending seconds "thinking" to produce a three-word sidebar title. Falls
* back to `fallback` when the setting is unset (the default, which preserves
* the previous behaviour) or when the configured model is no longer
* installed. `installed` is passed in by callers that already listed models,
* to avoid a second round-trip.
*/
private async resolveTasksModel(
fallback: string | null,
installed?: { name: string }[]
): Promise<string | null> {
return resolveTasksModel(this.ollamaService, fallback, installed, '[ChatService]')
}
async getAllSessions() {
try {
const sessions = await ChatSession.query().orderBy('updated_at', 'desc')
@ -37,11 +70,12 @@ export class ChatService {
return [] // If no models are available, return empty suggestions
}
// Prefer the user's selected chat model. Fall back to the smallest
// The user's dedicated tasks model wins when set — suggestions are short
// aesthetic prompts that don't benefit from a flagship model. Otherwise
// prefer the user's selected chat model, and fall back to the smallest
// installed model — picking the largest by file size is unsafe: if any
// installed model exceeds available VRAM (e.g. llama3.1:405b on a 96 GB
// GPU), Ollama spends minutes trying to load it and the request 500s.
// Suggestions are short prompts that don't benefit from a flagship model.
const lastModel = await KVStore.getValue('chat.lastModel')
const preferred = lastModel ? models.find((m) => m.name === lastModel) : undefined
const chosen =
@ -52,8 +86,16 @@ export class ChatService {
return []
}
const model = (await this.resolveTasksModel(chosen.name, models)) ?? chosen.name
// Suggestions are a formatting task, not a reasoning one. A reasoning tasks model
// would spend the whole response thinking and leave nothing to parse, so suppress
// it at the source; `thinkingCapable` is what lets the compat transport pick
// reasoning_effort:'none' rather than sending nothing. Memoized per model name.
const thinkingCapable = await this.ollamaService.checkModelHasThinking(model)
const response = await this.ollamaService.chat({
model: chosen.name,
model,
messages: [
{
role: 'user',
@ -61,11 +103,37 @@ export class ChatService {
}
],
stream: false,
think: false,
thinkingCapable,
// Grammar-constrained on the native transport, so the response is three
// strings in an array rather than whatever prose the model felt like.
format: SUGGESTIONS_SCHEMA,
// The default of 0.8 is actively hostile to format stability, and there is
// nothing creative about picking three canned opening questions.
temperature: 0,
})
if (response && response.message && response.message.content) {
const content = response.message.content.trim()
const structured = resolveStructured(content, pickSuggestions, response.structured === true)
if (structured.ok) {
return structured.value.map((s) => toTitleCase(s))
}
if (structured.reason === 'constrained-parse-failed') {
// The grammar was applied and the model broke it anyway, so what came back
// is a broken JSON object rather than prose. Splitting that on commas would
// surface `{"suggestions": ["How Do I` as a chip. No chips is the better
// failure — they are decorative, and the empty state is already designed.
logger.warn(
`[ChatService] Model "${model}" broke the suggestion grammar; returning no suggestions`
)
return []
}
logger.warn(
`[ChatService] Model "${model}" returned no suggestion JSON; falling back to text parsing`
)
// Handle both comma-separated and newline-separated formats
let suggestions: string[] = []
@ -91,6 +159,9 @@ export class ChatService {
return filtered.map((s) => toTitleCase(s))
} else {
// Empty content after the <think> split means the model produced reasoning and
// nothing else. Log it rather than silently returning no chips.
logger.warn(`[ChatService] Model "${model}" returned no usable suggestion text`)
return []
}
} catch (error) {
@ -121,6 +192,7 @@ export class ChatService {
role: msg.role,
content: msg.content,
timestamp: msg.created_at.toJSDate(),
sources: msg.sources ? JSON.parse(msg.sources) : undefined,
})),
}
} catch (error) {
@ -183,12 +255,18 @@ export class ChatService {
}
}
async addMessage(sessionId: number, role: 'system' | 'user' | 'assistant', content: string) {
async addMessage(
sessionId: number,
role: 'system' | 'user' | 'assistant',
content: string,
sources?: ChatSource[]
) {
try {
const message = await ChatMessage.create({
session_id: sessionId,
role,
content,
sources: sources && sources.length > 0 ? JSON.stringify(sources) : null,
})
// Update session's updated_at timestamp
@ -201,6 +279,7 @@ export class ChatService {
role: message.role,
content: message.content,
timestamp: message.created_at.toJSDate(),
sources: sources && sources.length > 0 ? sources : undefined,
}
} catch (error) {
logger.error(
@ -241,22 +320,66 @@ export class ChatService {
async generateTitle(sessionId: number, userMessage: string, assistantMessage: string, model: string) {
try {
let title: string
// Titles are aesthetic work; route them to the tasks model when one is
// configured rather than the chat model that just answered.
const titleModel = (await this.resolveTasksModel(model)) ?? model
// Naming a chat needs no reasoning; see the note in getChatSuggestions.
const thinkingCapable = await this.ollamaService.checkModelHasThinking(titleModel)
const response = await this.ollamaService.chat({
model,
model: titleModel,
messages: [
{ role: 'system', content: SYSTEM_PROMPTS.title_generation },
{ role: 'user', content: userMessage },
{ role: 'assistant', content: assistantMessage },
],
think: false,
thinkingCapable,
format: TITLE_SCHEMA,
// See the note on suggestions: naming a chat is not a creative task, and
// the backend default of 0.8 makes the format wobble.
temperature: 0,
})
title = response?.message?.content?.trim()
if (!title) {
title = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')
const content = response?.message?.content?.trim() ?? ''
const structured = resolveStructured(content, pickTitle, response?.structured === true)
let title: string
if (!content) {
// Nothing left once reasoning was split out. Checked before the grammar branch
// so the log says what actually happened rather than blaming the schema.
logger.warn(
`[ChatService] Model "${titleModel}" returned no usable title text; using the user message`
)
title = userMessage
} else if (structured.ok) {
title = structured.value
} else if (structured.reason === 'constrained-parse-failed') {
// A truncated object would otherwise be stored verbatim, leaving `{"title": ...`
// in the sidebar. The user's own words are a worse title than the model's but a
// far better one than a JSON fragment.
logger.warn(
`[ChatService] Model "${titleModel}" broke the title grammar; using the user message`
)
title = userMessage
} else {
// Unconstrained backend: the response is meant to be the bare title.
title = content
}
if (!title) {
// An unconstrained response that was pure punctuation or whitespace.
logger.warn(
`[ChatService] Model "${titleModel}" returned no usable title text; using the user message`
)
title = userMessage
}
// Applied once, to whichever string won: "under 50 characters" is a request the
// model can ignore on the schema path and the text path alike.
title = truncateTitle(title)
await this.updateSession(sessionId, { title })
logger.info(`[ChatService] Generated title for session ${sessionId}: "${title}"`)
} catch (error) {
@ -265,8 +388,7 @@ export class ChatService {
)
// Fall back to truncated user message
try {
const fallbackTitle = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')
await this.updateSession(sessionId, { title: fallbackTitle })
await this.updateSession(sessionId, { title: truncateTitle(userMessage) })
} catch {
// Silently fail - session keeps "New Chat" title
}

View File

@ -99,15 +99,19 @@ export class CollectionUpdateService {
update: ResourceUpdateInfo,
options?: { auto?: boolean }
): Promise<{ success: boolean; jobId?: string; error?: string }> {
// Check if a download is already in progress for this URL
const existingJob = await RunDownloadJob.getByUrl(update.download_url)
// Only block when a download is genuinely in progress. getActiveByUrl
// removes any terminal (failed/completed) job for this URL first, so a
// previous failed attempt doesn't leave a stale job that blocks the
// re-dispatch below. Using the raw getByUrl here (unlike every other
// dispatch site) meant a failed download stuck the resource forever: the
// failed job survived under the deterministic jobId, dispatch hit "job
// already exists" and returned it, and applyUpdate reported success while
// nothing was re-downloaded.
const existingJob = await RunDownloadJob.getActiveByUrl(update.download_url)
if (existingJob) {
const state = await existingJob.getState()
if (state === 'active' || state === 'waiting' || state === 'delayed') {
return {
success: false,
error: `A download is already in progress for ${update.resource_id}`,
}
return {
success: false,
error: `A download is already in progress for ${update.resource_id}`,
}
}

View File

@ -0,0 +1,160 @@
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import os from 'node:os'
import KVStore from '#models/kv_store'
import { OllamaService } from '#services/ollama_service'
import { SystemService } from '#services/system_service'
import {
UNKNOWN_BACKEND_CONTEXT,
computeKvBytesPerToken,
estimateKvBytesPerToken,
parseParameterBillions,
parseUserContextCap,
resolveContextWindow,
type ContextWindowDecision,
} from '../utils/context_window.js'
/**
* Decides how large a context window each chat model gets, and then holds that
* answer still.
*
* The "holds it still" part is the whole point. Ollama unloads and reloads a
* model whenever a request asks for a different `num_ctx` than the loaded
* instance — so a window that grew with the conversation would stall a turn and
* throw away the KV cache exactly when the conversation got interesting. The
* decision is therefore made once per model and memoized for the process
* lifetime; it is a property of (model, hardware), not of the current prompt.
*
* Inputs, in decreasing order of authority:
* - the user's cap (`ai.contextWindow`), which can only lower the result
* - the model's trained context length, from /api/show `model_info`
* - what the hardware can actually afford, from the exact per-token KV cost in
* the GGUF metadata against detected VRAM (or a share of system RAM)
*/
@inject()
export class ContextWindowService {
private decisions: Map<string, Promise<ContextWindowDecision>> = new Map()
private availableBytes: Promise<number | undefined> | null = null
constructor(
private ollamaService: OllamaService,
private systemService: SystemService
) {}
/**
* The context window to request for `model`. Memoized — repeat calls within a
* process return the identical number, which is what keeps the model loaded.
*/
async windowFor(model: string): Promise<number> {
return (await this.decisionFor(model)).contextWindow
}
async decisionFor(model: string): Promise<ContextWindowDecision> {
const cached = this.decisions.get(model)
if (cached) return cached
const pending = this._resolve(model).catch((error) => {
logger.warn(
`[ContextWindow] Falling back to ${UNKNOWN_BACKEND_CONTEXT} for ${model}: ${error instanceof Error ? error.message : error}`
)
return { contextWindow: UNKNOWN_BACKEND_CONTEXT, limitedBy: 'default' as const }
})
this.decisions.set(model, pending)
return pending
}
private async _resolve(model: string): Promise<ContextWindowDecision> {
const [info, userCapRaw] = await Promise.all([
this.ollamaService.getModelInfo(model),
KVStore.getValue('ai.contextWindow'),
])
const userCap = parseUserContextCap(userCapRaw)
// No /api/show means a non-Ollama backend, which sets its context at server
// start and ignores anything we send. Budget against a conservative value so
// the prompt planner still bounds itself rather than trusting the backend to
// truncate gracefully — it won't; it will truncate silently.
if (!info.contextLength && !info.rawModelInfo) {
const decision: ContextWindowDecision = {
contextWindow: userCap ?? UNKNOWN_BACKEND_CONTEXT,
limitedBy: userCap ? 'user' : 'default',
}
logger.info(
`[ContextWindow] ${model}: no model metadata available, budgeting at ${decision.contextWindow}`
)
return decision
}
const kvBytesPerToken =
computeKvBytesPerToken(info.rawModelInfo) ??
estimateKvBytesPerToken(parseParameterBillions(info.parameterSize, model))
const decision = resolveContextWindow({
modelMaxCtx: info.contextLength,
modelfileNumCtx: info.modelfileNumCtx,
kvBytesPerToken,
availableBytes: await this._availableBytes(),
modelBytes: await this._modelBytes(model),
userCap,
})
logger.info(
`[ContextWindow] ${model}: ${decision.contextWindow} tokens (limited by ${decision.limitedBy}; ` +
`${(kvBytesPerToken / 1024).toFixed(0)} KiB/token KV` +
`${decision.affordableTokens !== undefined ? `, ~${decision.affordableTokens} affordable` : ''})`
)
return decision
}
/**
* Memory available for inference, in bytes.
*
* Prefers the VRAM figure Ollama itself reports at startup — it is the number
* that actually governs whether a window fits, and it already accounts for
* whichever device Ollama chose. Falls back to a conservative share of system
* RAM for CPU-only hosts, which is the low end of the hardware NOMAD targets
* and precisely where getting this wrong hurts most.
*/
private async _availableBytes(): Promise<number | undefined> {
if (!this.availableBytes) {
this.availableBytes = (async () => {
try {
const compute = await this.systemService.getOllamaInferenceComputeFromLogs()
if (compute && compute.vramMiB > 0) {
return compute.vramMiB * 1024 * 1024
}
} catch (error) {
logger.debug(
`[ContextWindow] GPU probe failed, assuming CPU inference: ${error instanceof Error ? error.message : error}`
)
}
// CPU inference shares host RAM with MySQL, Redis, Qdrant and every other
// container on the box, so claim only a modest slice of it.
return os.totalmem() * CPU_RAM_SHARE
})()
}
return this.availableBytes
}
private async _modelBytes(model: string): Promise<number | undefined> {
try {
const models = await this.ollamaService.getModels(true)
return models.find((m) => m.name === model)?.size || undefined
} catch {
return undefined
}
}
/** Drop memoized decisions — call when the backend or the user's cap changes. */
invalidate(): void {
this.decisions.clear()
this.availableBytes = null
}
}
/**
* Share of system RAM assumed available for CPU inference. The rest of the NOMAD
* stack (MySQL, Redis, Qdrant, Kiwix, the admin app itself) lives in the same
* pool, and a machine that starts swapping is worse than one with a small window.
*/
const CPU_RAM_SHARE = 0.5

View File

@ -4,7 +4,7 @@ import logger from '@adonisjs/core/services/logger'
import { inject } from '@adonisjs/core'
import transmit from '@adonisjs/transmit/services/main'
import { doResumableDownloadWithRetry } from '../utils/downloads.js'
import { mapGfxToHsaOverride } from '../utils/amd_hsa_override.js'
import { pickAmdHsaOverride } from '../utils/amd_hsa_override.js'
import { join } from 'path'
import os from 'node:os'
import env from '#start/env'
@ -26,6 +26,10 @@ import { randomBytes } from 'node:crypto'
import KVStore from '#models/kv_store'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
import { DEFAULT_OLLAMA_CONTEXT_LENGTH } from '../../constants/ollama.js'
// Written by install_nomad.sh with the host AMD GPU's gfx target.
const AMD_GFX_MARKER_PATH = '/app/storage/.nomad-amd-gfx'
@inject()
export class DockerService {
@ -387,6 +391,17 @@ export class DockerService {
if (container) {
const dockerContainer = this.docker.getContainer(container.Id)
if (serviceName === SERVICE_NAMES.OLLAMA) {
// The recreate builds env from scratch, so capture a working HSA override
// before this container, the only place it lives, is removed (#1377).
try {
const inspect = await dockerContainer.inspect()
await this._adoptContainerHsaOverride(inspect.Config?.Env)
} catch (error: any) {
logger.warn(`[DockerService] Could not preserve the Ollama HSA override: ${error.message}`)
}
}
// Only try to stop if it's running
if (container.State === 'running') {
this._broadcast(serviceName, 'stopping', `Stopping container...`)
@ -810,7 +825,18 @@ export class DockerService {
const flashAttentionEnabled = await KVStore.getValue('ai.ollamaFlashAttention')
if (flashAttentionEnabled !== false) {
ollamaEnv.push('OLLAMA_FLASH_ATTENTION=1')
// KV cache quantization requires flash attention. q8_0 roughly halves the
// memory a context window costs for a perplexity delta in the noise, which is
// what makes an 8-16k window affordable on modest hardware. Ollama silently
// falls back to f16 on architectures that don't support it, so this is treated
// as headroom — the context-window resolver still budgets against f16.
ollamaEnv.push('OLLAMA_KV_CACHE_TYPE=q8_0')
}
// Floor for any request that doesn't carry its own num_ctx (the OpenAI-compatible
// path can't set one at all). Ollama's own default is 4096 on machines under 24GB
// VRAM, and anything past the window is silently truncated with no error — so a
// conversation that outgrows it just quietly loses its early turns.
ollamaEnv.push(`OLLAMA_CONTEXT_LENGTH=${DEFAULT_OLLAMA_CONTEXT_LENGTH}`)
if (amdGpuConfigured) {
// gfx-aware HSA override — only set for cards that actually need it. See
// _resolveAmdHsaOverride() for the resolution order and gfx → version mapping.
@ -838,8 +864,10 @@ export class DockerService {
'creating',
`Creating Docker container for service ${service.service_name}...`
)
const container = await this.docker.createContainer({
Image: finalImage,
// Built once and reused, so the AMD fallback below cannot drift from the
// real payload as this config grows.
const buildCreateOptions = (image: string, hostConfig: any, env: string[]) => ({
Image: image,
name: service.service_name,
Labels: {
...(containerConfig?.Labels ?? {}),
@ -847,10 +875,10 @@ export class DockerService {
'io.project-nomad.managed': 'true',
},
...(containerConfig?.User && { User: containerConfig.User }),
HostConfig: gpuHostConfig,
HostConfig: hostConfig,
...(containerConfig?.WorkingDir && { WorkingDir: containerConfig.WorkingDir }),
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
Env: [...(containerConfig?.Env ?? []), ...ollamaEnv, ...appEnv],
Env: env,
...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}),
// Ensure container is attached to the Nomad docker network in production
...(process.env.NODE_ENV === 'production' && {
@ -862,12 +890,76 @@ export class DockerService {
}),
})
let container = await this.docker.createContainer(
buildCreateOptions(finalImage, gpuHostConfig, [
...(containerConfig?.Env ?? []),
...ollamaEnv,
...appEnv,
])
)
this._broadcast(
service.service_name,
'starting',
`Starting Docker container for service ${service.service_name}...`
)
await container.start()
try {
await container.start()
} catch (error: any) {
// An AMD GPU whose ROCm device nodes are absent must not block the install
// outright (#1232). GPU detection reads PCI data, so "an AMD GPU is present"
// and "ROCm is usable" are different questions, and only the daemon can
// answer the second. CPU-only Ollama works fine on these machines.
//
// This has to sit on start(), not createContainer(): the daemon accepts a
// container whose --device path does not exist (create returns 201) and only
// resolves devices when the container starts. Measured on Docker 29.6.2.
//
// Retrying rather than pre-checking is also deliberate. The admin runs in its
// own container, so stat()ing /dev/kfd here describes the admin's namespace,
// not the host's. Verified on NOMAD6, a working ROCm box: /dev/kfd is present
// on the host and absent inside the admin container, so a pre-check would
// strip GPU passthrough from a machine where it works.
if (!amdGpuConfigured || !this._isMissingDeviceError(error)) throw error
logger.warn(
`[DockerService] AMD device passthrough rejected by the daemon (${error?.message}); ` +
`recreating ${service.service_name} CPU-only on ${service.container_image}`
)
this._broadcast(
service.service_name,
'gpu-config',
`AMD GPU detected, but this system has no usable ROCm device (/dev/kfd is missing, ` +
`which means the amdgpu/ROCm kernel driver is not loaded or does not support this GPU). ` +
`Installing CPU-only instead so the AI Assistant still works.`
)
// The created container carries the rejected device config, so it cannot be
// started as-is and has to go before the name can be reused.
await container.remove({ force: true }).catch((removeError: any) => {
logger.warn(`[DockerService] Could not remove the failed container: ${removeError?.message}`)
})
const { Devices, ...cpuHostConfig } = gpuHostConfig as any
// Drop the ROCm-only env along with the devices. HSA_OVERRIDE_GFX_VERSION and
// OLLAMA_IGPU_ENABLE mean nothing to the CPU build, and leaving them behind is
// misleading to anyone who later reads `docker inspect`.
const cpuOllamaEnv = ollamaEnv.filter(
(e) => !e.startsWith('HSA_OVERRIDE_GFX_VERSION=') && !e.startsWith('OLLAMA_IGPU_ENABLE=')
)
amdGpuConfigured = false
// service.container_image is the CPU tag, already pulled earlier in this
// function before the AMD branch overrode finalImage to :rocm.
finalImage = service.container_image
container = await this.docker.createContainer(
buildCreateOptions(finalImage, cpuHostConfig, [
...(containerConfig?.Env ?? []),
...cpuOllamaEnv,
...appEnv,
])
)
await container.start()
}
this._broadcast(
service.service_name,
@ -1472,6 +1564,17 @@ export class DockerService {
}
}
/**
* The HSA_OVERRIDE_GFX_VERSION an AMD install or reinstall would apply right now.
* Exposed so GpuPassthroughRemediationProvider can tell whether a reinstall would
* change the running container.
*/
async getAmdHsaOverride(
options: { quiet?: boolean; containerEnv?: string[] | null } = {}
): Promise<string | null> {
return this._resolveAmdHsaOverride(options)
}
/**
* Resolve the HSA_OVERRIDE_GFX_VERSION value for the host's AMD GPU.
*
@ -1485,9 +1588,11 @@ export class DockerService {
* Resolution order:
* 1. KV `ai.amdHsaOverride` — manual user override; accepts 'none' (disable) or a semver-style value.
* 2. Marker file `/app/storage/.nomad-amd-gfx` written by install_nomad.sh.
* 3. Default: none — let ROCm discover the GPU natively. Users on hardware that still
* 3. The override the existing container runs with, when the caller passes its env (#1377).
* 4. Default: none — let ROCm discover the GPU natively. Users on hardware that still
* needs coercion can force a value via the KV. A hardcoded default gets more wrong
* as ROCm adds native targets, so null is the safer forward-looking default.
* The pure ordering lives in pickAmdHsaOverride (../utils/amd_hsa_override.ts).
*
* Returns null when no override should be applied.
*/
@ -1511,43 +1616,76 @@ export class DockerService {
return pepper
}
private async _resolveAmdHsaOverride(): Promise<string | null> {
const manualRaw = await KVStore.getValue('ai.amdHsaOverride')
if (manualRaw !== null && manualRaw !== undefined && String(manualRaw).trim() !== '') {
const manual = String(manualRaw).trim().toLowerCase()
if (manual === 'none' || manual === 'off' || manual === 'false') {
private async _resolveAmdHsaOverride({
quiet = false,
containerEnv,
}: { quiet?: boolean; containerEnv?: string[] | null } = {}): Promise<string | null> {
const manual = await KVStore.getValue('ai.amdHsaOverride')
const markerGfx = await this._readAmdGfxMarker()
const resolved = pickAmdHsaOverride({ manual, markerGfx, containerEnv })
if (quiet) return resolved.value
if (resolved.invalidManual !== undefined) {
logger.warn(`[DockerService] Ignoring invalid ai.amdHsaOverride value: ${resolved.invalidManual}`)
}
switch (resolved.source) {
case 'kv-disabled':
logger.info('[DockerService] HSA override disabled via ai.amdHsaOverride')
return null
}
if (/^\d+\.\d+\.\d+$/.test(manual)) {
logger.info(`[DockerService] HSA override forced to ${manual} via ai.amdHsaOverride`)
return manual
}
logger.warn(`[DockerService] Ignoring invalid ai.amdHsaOverride value: ${manualRaw}`)
break
case 'kv':
logger.info(`[DockerService] HSA override forced to ${resolved.value} via ai.amdHsaOverride`)
break
case 'marker':
logger.info(
`[DockerService] AMD gfx marker '${markerGfx}' → HSA override ${resolved.value ?? 'none'}`
)
break
case 'container':
logger.info(
`[DockerService] Keeping HSA override ${resolved.value} from the existing container`
)
break
case 'default':
logger.warn(
`[DockerService] AMD GPU configured but no gfx marker (${AMD_GFX_MARKER_PATH}) and no ` +
'ai.amdHsaOverride KV; relying on native ROCm discovery. iGPUs not on the bundled rocblas ' +
'allowlist (e.g. 780M/gfx1103, 680M/gfx1035) will silently fall back to CPU. Set the ' +
'ai.amdHsaOverride KV (e.g. 11.0.0 for a 780M) and force-reinstall the AI service if so.'
)
break
}
try {
const gfx = (await readFile('/app/storage/.nomad-amd-gfx', 'utf8')).trim()
const mapped = this._mapGfxToHsaOverride(gfx)
logger.info(`[DockerService] AMD gfx marker '${gfx}' → HSA override ${mapped ?? 'none'}`)
return mapped
} catch {
// Marker absent — most likely an existing install upgraded without re-running
// install_nomad.sh. Fall through to the default.
}
logger.warn(
'[DockerService] AMD GPU configured but no gfx marker (/app/storage/.nomad-amd-gfx) and no ' +
'ai.amdHsaOverride KV; relying on native ROCm discovery. iGPUs not on the bundled rocblas ' +
'allowlist (e.g. 780M/gfx1103, 680M/gfx1035) will silently fall back to CPU. Set the ' +
'ai.amdHsaOverride KV (e.g. 11.0.0 for a 780M) and force-reinstall the AI service if so.'
)
return null
return resolved.value
}
private _mapGfxToHsaOverride(gfx: string): string | null {
// Pure mapping lives in ../utils/amd_hsa_override.ts so it stays unit-testable.
return mapGfxToHsaOverride(gfx)
/**
* Before a force reinstall removes nomad_ollama, persist the HSA override it runs with
* when nothing else would supply one (#1377). The recreate builds env from scratch, so
* without this an upgraded box with a hand-set override loses it. Persisting rather than
* passing it along in memory keeps it through a reinstall that fails after removal, and
* shows it in the Set GFX Override modal.
*/
private async _adoptContainerHsaOverride(containerEnv: string[] | null | undefined): Promise<void> {
const resolved = pickAmdHsaOverride({
manual: await KVStore.getValue('ai.amdHsaOverride'),
markerGfx: await this._readAmdGfxMarker(),
containerEnv,
})
if (resolved.source !== 'container' || !resolved.value) return
await KVStore.setValue('ai.amdHsaOverride', resolved.value)
logger.info(
`[DockerService] Persisted HSA override ${resolved.value} from the existing container to ai.amdHsaOverride`
)
}
private async _readAmdGfxMarker(): Promise<string | null> {
try {
return (await readFile(AMD_GFX_MARKER_PATH, 'utf8')).trim()
} catch {
// Marker absent: most likely an existing install upgraded without re-running
// install_nomad.sh.
return null
}
}
/**
@ -1569,6 +1707,28 @@ export class DockerService {
]
}
/**
* Whether a container-create failure was the daemon refusing a `--device` path
* that does not exist on the host, e.g.
*
* (HTTP code 500) server error - error gathering device information while
* adding custom device "/dev/kfd": no such file or directory
*
* GPU *detection* reads PCI data, which says nothing about whether the ROCm
* kernel interface was ever loaded. An AMD card with no `/dev/kfd` is a
* perfectly normal machine — old pre-ROCm silicon, or amdgpu not loaded — and
* on those the create fails outright and the whole install dies (#1232).
*
* Deliberately keyed on the device-gathering phrase rather than a bare "no
* such file or directory", which Docker also emits for missing bind-mount
* sources and image layers. Falling back to CPU on one of those would hide a
* real failure behind a degraded install.
*/
private _isMissingDeviceError(error: any): boolean {
const raw: string = error?.message ?? String(error)
return /error gathering device information while adding custom device/i.test(raw)
}
/**
* Update a service container to a new image version while preserving volumes and data.
* Includes automatic rollback if the new container fails health checks.
@ -1727,7 +1887,7 @@ export class DockerService {
const baseEnv = inspectData.Config?.Env || []
let finalEnv = baseEnv
if (updatedAmdGpuConfigured) {
const hsaOverride = await this._resolveAmdHsaOverride()
const hsaOverride = await this._resolveAmdHsaOverride({ containerEnv: baseEnv })
finalEnv = baseEnv.filter(
(e: string) =>
!e.startsWith('HSA_OVERRIDE_GFX_VERSION=') && !e.startsWith('OLLAMA_IGPU_ENABLE=')

View File

@ -56,11 +56,10 @@ export class DownloadService {
}
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
const [fileTagged, extractTagged, modelJobs, drugTagged] = await Promise.all([
const [fileTagged, extractTagged, modelTagged, drugTagged] = await Promise.all([
this.fetchJobsWithStates(RunDownloadJob.queue),
this.fetchJobsWithStates(RunExtractPmtilesJob.queue),
modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed']),
this.fetchJobsWithStates(DownloadModelJob.queue),
this.fetchJobsWithStates(DownloadDrugDataJob.queue),
])
@ -98,7 +97,7 @@ export class DownloadService {
}
})
const modelDownloads = modelJobs.map((job) => ({
const modelDownloads = modelTagged.map(({ job }) => ({
jobId: job.id!.toString(),
url: job.data.modelName || 'Unknown Model',
progress: parseInt(job.progress.toString(), 10),
@ -191,8 +190,13 @@ export class DownloadService {
if (!modelName) {
return { success: false, message: 'Cannot retry: model name not found in job data' }
}
await DownloadModelJob.dispatch({ modelName })
// Remove the old failed job first, then dispatch a fresh one. The
// model jobId is a hash of the model name, so dispatching first meant
// this remove() targeted the job we had just enqueued under that same
// id rather than the old record, deleting the retry we were creating.
// Matches the file-download branch below.
await job.remove().catch(() => {})
await DownloadModelJob.dispatch({ modelName })
return { success: true, message: `Retrying download for model ${modelName}` }
}

View File

@ -566,6 +566,60 @@ export class DrugReferenceService {
return { ...result, nothingDownloaded: false }
}
/**
* Write (or refresh) the `installed_resources` 'dataset' row that the tier-
* status math and the curated-install filter read. Shared by the ingest job's
* final pass, the ZimService tier-select path, and DrugInstallRowProvider's
* boot backfill so all three write the same shape. Throws; callers log.
*/
async recordInstalledRow(opts: {
version: string
collectionRef: string | null
fileSizeBytes: number | null
}): Promise<void> {
const { default: InstalledResource } = await import('#models/installed_resource')
const { DateTime } = await import('luxon')
await InstalledResource.updateOrCreate(
{ resource_id: DRUG_DATASET_RESOURCE_ID, resource_type: 'dataset' },
{
version: opts.version,
collection_ref: opts.collectionRef,
url: 'https://api.fda.gov/download.json',
// No single on-disk file — the parts are deleted after ingest; the
// installed artifact is the DB table. Record the staging dir for
// provenance; uninstall keys off resource_id, never this path.
file_path: STORAGE_BASE,
file_size_bytes: opts.fileSizeBytes,
installed_at: DateTime.now(),
}
)
}
/** Whether the `installed_resources` 'dataset' row exists. */
async hasInstalledRow(): Promise<boolean> {
const { default: InstalledResource } = await import('#models/installed_resource')
const row = await InstalledResource.query()
.where('resource_type', 'dataset')
.where('resource_id', DRUG_DATASET_RESOURCE_ID)
.first()
return row !== null
}
/**
* Whether either drug queue holds an active/waiting/delayed job. Counts the
* whole queue rather than the deterministic jobIds, since ingest continuations
* run without one. Throws when Redis is unreachable.
*/
async isJobInFlight(): Promise<boolean> {
for (const queueName of [DownloadDrugDataJob.queue, IngestDrugDataJob.queue]) {
const counts = await QueueService.getInstance()
.getQueue(queueName)
.getJobCounts('active', 'waiting', 'delayed')
if ((counts.active ?? 0) + (counts.waiting ?? 0) + (counts.delayed ?? 0) > 0) return true
}
return false
}
/**
* Uninstall the offline FDA drug dataset — the curated-tier "remove" path.
*

View File

@ -0,0 +1,202 @@
import { RagService } from '#services/rag_service'
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import { createHash } from 'node:crypto'
import { readdir, readFile } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { KB_EVAL_COLLECTION } from '../../constants/kb_collections.js'
import { EMBEDDING_MODEL_NAME } from '../../constants/ollama.js'
import {
assertGoldensMatchCorpus,
computeCorpusFingerprint,
parseGoldens,
type Golden,
} from '../utils/eval/golden_set.js'
import { EVAL_CORPUS_DIR } from '../utils/eval/corpus_source.js'
/** Where the golden question set lives, relative to the app root. */
export const EVAL_GOLDENS_DIR = 'tests/eval/goldens'
/**
* The suite that runs when none is named — i.e. `core.jsonl`. Every other
* `.jsonl` in the goldens directory is opt-in via `--suite`, so adding one
* cannot move the numbers an existing baseline was recorded against.
*/
export const DEFAULT_GOLDEN_SUITE = 'core'
// EVAL_CORPUS_DIR and docIdFromSource live in app/utils/eval/corpus_source.ts:
// they are pure path logic, and keeping them out of this service is what lets
// the leak guard be unit-tested without booting AdonisJS.
export { docIdFromSource, EVAL_CORPUS_DIR } from '../utils/eval/corpus_source.js'
export type CorpusDocument = {
/** Filename without extension. This is the id goldens refer to. */
docId: string
/** Absolute path on disk; becomes the Qdrant `source` payload. */
path: string
text: string
}
export type IngestSummary = {
fingerprint: string
documents: number
chunks: number
removedBeforeIngest: number
failures: Array<{ docId: string; reason: string }>
}
/**
* Owns the frozen evaluation corpus: reading it off disk, fingerprinting it,
* and pushing it through NOMAD's real ingest path into the reserved
* `__nomad_eval__` collection tag.
*
* Ingesting through `RagService.embedAndStoreText` rather than writing vectors
* directly is the whole point — chunk size, the token-estimate ratio, the
* search_document prefix, and the embedding model are all in scope of the
* measurement, so a change to any of them shows up as a retrieval score
* movement instead of hiding.
*/
@inject()
export class EvalCorpusService {
constructor(private ragService: RagService) {}
private corpusPath(): string {
return resolve(join(process.cwd(), EVAL_CORPUS_DIR))
}
/** Read every markdown document in the corpus, sorted for determinism. */
async loadCorpus(): Promise<CorpusDocument[]> {
const dir = this.corpusPath()
const entries = (await readdir(dir)).filter((f) => f.endsWith('.md')).sort()
if (entries.length === 0) {
throw new Error(`No corpus documents found in ${dir}`)
}
return Promise.all(
entries.map(async (file) => {
const path = join(dir, file)
return { docId: basename(file, '.md'), path, text: await readFile(path, 'utf8') }
})
)
}
/**
* Load and validate golden files, cross-checked against the corpus.
*
* Each `.jsonl` in the goldens directory is a *suite*, named after the file.
* Only `core` loads by default. That matters for comparability: aggregate
* metrics are means over whatever ran, so silently adding goldens to the
* default set would move recall@k and correctness for reasons unrelated to any
* change under test, and quietly invalidate every committed baseline. Opting
* in keeps a suite's numbers separate until someone asks for them.
*/
async loadGoldens(suites: string[] = [DEFAULT_GOLDEN_SUITE]): Promise<Golden[]> {
const dir = resolve(join(process.cwd(), EVAL_GOLDENS_DIR))
const available = (await readdir(dir)).filter((f) => f.endsWith('.jsonl')).sort()
if (available.length === 0) throw new Error(`No golden files found in ${dir}`)
// '*' loads every suite. Used by `eval:corpus`, which validates the fixtures
// rather than measuring anything — a malformed golden should fail validation
// whether or not its suite is part of the default run.
const wanted = new Set(suites)
const files = wanted.has('*')
? available
: available.filter((f) => wanted.has(basename(f, '.jsonl')))
if (files.length === 0) {
throw new Error(
`No golden suites matched [${suites.join(', ')}]. Available: ` +
available.map((f) => basename(f, '.jsonl')).join(', ')
)
}
const goldens: Golden[] = []
const seen = new Set<string>()
for (const file of files) {
const parsed = parseGoldens(await readFile(join(dir, file), 'utf8'), file)
for (const g of parsed) {
// parseGoldens dedupes within a file; this catches collisions across files.
if (seen.has(g.id)) throw new Error(`Duplicate golden id "${g.id}" in ${file}`)
seen.add(g.id)
goldens.push(g)
}
}
const corpus = await this.loadCorpus()
assertGoldensMatchCorpus(
goldens,
corpus.map((d) => d.docId)
)
return goldens
}
/**
* Hash the corpus together with the ingest parameters that shaped it.
* Reports carry this; two reports with different fingerprints are not
* comparable and `eval:compare` refuses to pretend otherwise.
*/
async fingerprint(): Promise<string> {
const corpus = await this.loadCorpus()
return computeCorpusFingerprint(
{
documents: new Map(corpus.map((d) => [d.docId, d.text])),
chunkTokens: RagService.TARGET_TOKENS_PER_CHUNK,
chunkOverlapTokens: RagService.CHUNK_OVERLAP_TOKENS,
charToTokenRatio: RagService.CHAR_TO_TOKEN_RATIO,
embeddingModel: EMBEDDING_MODEL_NAME,
embeddingDimension: RagService.EMBEDDING_DIMENSION,
},
(input) => createHash('sha256').update(input).digest('hex')
)
}
/** Remove every eval point. Never touches user content. */
async reset(): Promise<number> {
const removed = await this.ragService.deleteCollectionPoints(KB_EVAL_COLLECTION)
logger.info(`[Eval] Removed ${removed} eval corpus chunks`)
return removed
}
/** How many eval chunks are currently in the vector store. */
async count(): Promise<number> {
return this.ragService.countChunksInCollection(KB_EVAL_COLLECTION)
}
/**
* Wipe and rebuild the eval corpus.
*
* Always a full rebuild: a partial re-ingest would leave the vector store in
* a state no fingerprint describes, and a fingerprint that does not describe
* the store is worse than no fingerprint at all.
*/
async ingest(onProgress?: (docId: string, index: number, total: number) => void): Promise<IngestSummary> {
const corpus = await this.loadCorpus()
const removedBeforeIngest = await this.reset()
let chunks = 0
const failures: IngestSummary['failures'] = []
for (const [index, doc] of corpus.entries()) {
onProgress?.(doc.docId, index + 1, corpus.length)
try {
const result = await this.ragService.embedAndStoreText(doc.text, {
source: doc.path,
collection: KB_EVAL_COLLECTION,
})
if (!result) {
failures.push({ docId: doc.docId, reason: 'embedAndStoreText returned null' })
continue
}
chunks += result.chunks
} catch (error) {
failures.push({ docId: doc.docId, reason: error instanceof Error ? error.message : String(error) })
}
}
return {
fingerprint: await this.fingerprint(),
documents: corpus.length,
chunks,
removedBeforeIngest,
failures,
}
}
}

View File

@ -0,0 +1,489 @@
import { EvalCorpusService } from '#services/eval_corpus_service'
import { OllamaService, type NomadChatUsage } from '#services/ollama_service'
import { RagPipelineService } from '#services/rag_pipeline_service'
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import { KB_EVAL_COLLECTION } from '../../constants/kb_collections.js'
import { RAG_MIN_FINAL_SCORE } from '../../constants/ollama.js'
import { resolveSamplerProfile } from '../utils/sampler.js'
import type { OllamaChatMessage, ResponseStyle, SamplerProfile } from '../../types/ollama.js'
import type { PipelineOptions, RetrievedChunk } from '../../types/rag.js'
import { docIdFromSource } from '../utils/eval/corpus_source.js'
import type { Golden } from '../utils/eval/golden_set.js'
import {
scoreAnswer,
summarizeNumeric,
summarizeRepeats,
type GenerationScores,
type NumericSummary,
type RepeatStats,
} from '../utils/eval/generation_metrics.js'
/**
* The three ways to run a question, and what each one isolates.
*
* This is the part of the harness that answers "code bug or weak model?".
* Running the same question all three ways turns one ambiguous score into a
* decomposition:
*
* - `oracle` gives the model perfect context by construction. A low score here
* is the model (or the prompt) failing to use good context — retrieval is
* provably not at fault.
* - `e2e` is the real product. `oracle - e2e` is the cost of imperfect retrieval.
* - `noretrieval` is the model's parametric baseline. `e2e - noretrieval` is
* what RAG is actually buying, and on the fictional questions it should be
* nearly everything, since no model can know them.
*/
export type GenerationMode = 'oracle' | 'e2e' | 'noretrieval'
/**
* Sentinel model name for the extractive reference run.
*
* `--model=mock` needs no Ollama at all: it answers by echoing whatever context
* the pipeline injected, and refuses when nothing was injected. That makes it
* two useful things at once — a way to exercise scoring and reporting with zero
* models installed (so the harness itself is CI-testable), and a genuine
* *ceiling* line: the best a perfectly extractive model could score given this
* retrieval. A real model below the mock line is the bottleneck; a mock line
* that is itself low means retrieval is.
*/
export const MOCK_MODEL = 'mock'
/**
* Fixed sampling for eval runs. Never used by production chat.
*
* Temperature 0 is the default because a repeatable number is worth more than a
* realistic one for most of what this harness gates. But greedy decoding makes
* top_p, top_k and min_p inert — the highest-probability token wins whatever
* they say — so a run left at 0 cannot see a Response Style change at all. That
* is what `--style` is for: it sets `samplerStyle`, which replaces this
* temperature with the style's and sends the real truncation settings, so the
* chat path can actually be measured. The seed stays fixed either way, which
* makes a sampled run approximately rather than exactly reproducible; `--repeats`
* and the `unstable` counter are how that gets accounted for.
*/
export const EVAL_TEMPERATURE = 0
export const EVAL_SEED = 42
/**
* What a run sends to the decoder: a fixed temperature, a sampler profile, or
* neither. Exactly one of the three, which is why it is a shape rather than two
* loose optional arguments threaded through runCase.
*/
type EvalSampling = { temperature?: number; sampler?: SamplerProfile }
function resolveEvalSampling(style: ResponseStyle | undefined): EvalSampling {
if (!style) return { temperature: EVAL_TEMPERATURE }
const sampler = resolveSamplerProfile(style)
// `off` resolves to no profile, and sends no temperature either — that is the
// pre-setting behaviour this harness needs to be able to reproduce.
return sampler ? { sampler } : {}
}
export type GenerationRunOptions = {
mode: GenerationMode
model: string
repeats?: number
topK?: number
scoreThreshold?: number
/** Post-rerank relevance floor. Defaults to the constant, never to the setting. */
minFinalScore?: number
/** Skip the history-aware rewrite even on multi-turn goldens. */
skipQueryRewrite?: boolean
/**
* Sample the way real chat does, under this Response Style, instead of at
* temperature 0.
*
* Three states, not two. Unset is the deterministic default and the right
* choice for anything being gated on. A style is the chat path as a user
* experiences it. `off` sends no sampler settings *and* no temperature, which
* is the pre-setting behaviour and therefore the honest control to compare a
* style against: `--style=auto` against `--style=off` measures the change,
* where `--style=auto` against a temperature-0 run measures the change plus
* the difference between greedy and sampled decoding.
*
* Model-declared samplers are deliberately not merged in even under `auto`:
* the harness scores a style, and a number that moved because one machine's
* model shipped a different modelfile is not a number worth committing.
*/
samplerStyle?: ResponseStyle
onProgress?: (id: string, index: number, total: number) => void
}
export type GenerationCaseResult = {
id: string
tags: string[]
expectRefusal: boolean
/** One entry per repeat. */
answers: string[]
scores: GenerationScores[]
correctness: RepeatStats
refusalCorrectness: RepeatStats
leakageFree: RepeatStats
groundedness: NumericSummary | null
retrievedDocIds: string[]
injectedChunks: number
/**
* What the budget planner did with the window on this case.
*
* Without this a low score is ambiguous: it could mean the model answered
* badly, or it could mean the model never saw the material because the
* relevant turns were evicted. These fields tell the two apart.
*/
budget?: {
contextWindow: number
estimatedPromptTokens: number
promptBudget: number
turnsDropped: number
chunksDropped: number
historyElided: boolean
queryTruncated: boolean
}
/**
* Estimated vs. the backend's real prompt token count. The estimator is the
* foundation every budget decision rests on, so its error is worth gating on.
*/
promptTokenError?: number
/** Non-null only when something went wrong talking to the model. */
error?: string
}
export type GenerationAggregate = {
cases: number
/** Cases whose pass/fail flipped across repeats; excluded from gating. */
unstable: number
errors: number
correctness: number | null
refusalCorrectness: number | null
leakageRate: number | null
thinkTagLeakRate: number | null
markdownRate: number | null
groundedness: NumericSummary | null
meanAnswerLength: number | null
/** Share of cases where history had to be trimmed to fit the window. */
historyElidedRate: number | null
/** Share of cases where a retrieved chunk didn't fit the context budget. */
chunksDroppedRate: number | null
/** Mean absolute error of the token estimator against real prompt counts. */
promptTokenError: NumericSummary | null
meanPromptTokens: number | null
}
export type GenerationRunResult = {
params: {
mode: GenerationMode
model: string
repeats: number
/** Null when the run sent no temperature at all, under `--style=off`. */
temperature: number | null
seed: number
/** The Response Style sampled under; null for the deterministic default. */
samplerStyle: ResponseStyle | null
topK?: number
scoreThreshold?: number
minFinalScore: number
}
overall: GenerationAggregate
byTag: Record<string, GenerationAggregate>
cases: GenerationCaseResult[]
elapsedMs: number
}
@inject()
export class EvalGenerationService {
constructor(
private ollamaService: OllamaService,
private pipeline: RagPipelineService,
private corpusService: EvalCorpusService
) {}
async run(goldens: Golden[], options: GenerationRunOptions): Promise<GenerationRunResult> {
const repeats = Math.max(1, options.repeats ?? 3)
const started = Date.now()
const isMock = options.model === MOCK_MODEL
const sampling = resolveEvalSampling(options.samplerStyle)
// Oracle mode needs the corpus text on hand to synthesize perfect context.
const corpusText = options.mode === 'oracle' ? await this.loadCorpusText() : null
if (!isMock) await this.prepareModel(options.model)
const cases: GenerationCaseResult[] = []
for (const [index, golden] of goldens.entries()) {
options.onProgress?.(golden.id, index + 1, goldens.length)
cases.push(await this.runCase(golden, options, repeats, corpusText, isMock, sampling))
}
return {
params: {
mode: options.mode,
model: options.model,
repeats,
temperature: sampling.sampler?.temperature ?? sampling.temperature ?? null,
seed: EVAL_SEED,
samplerStyle: options.samplerStyle ?? null,
topK: options.topK,
scoreThreshold: options.scoreThreshold,
minFinalScore: options.minFinalScore ?? RAG_MIN_FINAL_SCORE,
},
overall: aggregateGeneration(cases),
byTag: aggregateGenerationByTag(cases),
cases,
elapsedMs: Date.now() - started,
}
}
/**
* Evict other resident models and burn one throwaway generation.
*
* Both borrowed from BenchmarkService, and for the same reasons it added
* them: a cold first run is dramatically slower and behaves differently, and
* leftover models in VRAM change how the one under test is scheduled. Quality
* runs are less timing-sensitive than throughput runs, but a first-token
* timeout or an OOM-driven CPU fallback absolutely does change the answer.
*/
private async prepareModel(model: string): Promise<void> {
try {
await this.ollamaService.unloadAllChatModelsExcept(model)
} catch (error) {
logger.warn(`[Eval] Could not evict resident models: ${errorText(error)}`)
}
try {
await this.ollamaService.chat({
model,
messages: [{ role: 'user', content: 'Reply with the single word: ready' }],
temperature: EVAL_TEMPERATURE,
seed: EVAL_SEED,
})
} catch (error) {
// Best-effort: if warm-up fails the scored runs will surface the real
// problem with a better message than we could produce here.
logger.warn(`[Eval] Warm-up generation failed: ${errorText(error)}`)
}
}
private async loadCorpusText(): Promise<Map<string, { text: string; path: string }>> {
const docs = await this.corpusService.loadCorpus()
return new Map(docs.map((d) => [d.docId, { text: d.text, path: d.path }]))
}
private async runCase(
golden: Golden,
options: GenerationRunOptions,
repeats: number,
corpusText: Map<string, { text: string; path: string }> | null,
isMock: boolean,
sampling: EvalSampling
): Promise<GenerationCaseResult> {
const messages: OllamaChatMessage[] = [
...golden.turns.map((t) => ({ role: t.role, content: t.content })),
{ role: 'user' as const, content: golden.query },
]
const pipelineOptions: PipelineOptions = {
collection: KB_EVAL_COLLECTION,
topK: options.topK,
scoreThreshold: options.scoreThreshold,
// Pinned to the constant, not left to fall through to resolveMinFinalScore()
// — otherwise a `rag.minRelevance` slider set on one developer's machine
// would silently move every generation score. Same reasoning as the
// retrieval tier; see EvalRetrievalService.run.
minFinalScore: options.minFinalScore ?? RAG_MIN_FINAL_SCORE,
// The mock run must not touch Ollama at all — that is what makes it
// usable with no models installed. The rewrite is a chat-model call, so
// it is always skipped there (it would 404 and silently fall back, which
// works but quietly makes the "no model needed" claim untrue).
skipQueryRewrite: options.skipQueryRewrite || isMock,
// A developer's personal NOMAD.md would silently skew every score.
skipNomadMd: true,
// Pinned off rather than read from `rag.relevanceCheck`, for the same
// reason as minFinalScore. eval:retrieval --judge-model measures it.
relevanceCheckModel: null,
}
if (options.mode === 'oracle') {
pipelineOptions.oracleContext = golden.relevantDocIds.map((docId) => {
const doc = corpusText?.get(docId)
return {
text: doc?.text ?? '',
score: 1,
metadata: { source: doc?.path },
} satisfies RetrievedChunk
})
} else if (options.mode === 'noretrieval') {
// An empty oracle context short-circuits retrieval without injecting
// anything — the model answers from parametric memory alone.
pipelineOptions.oracleContext = []
}
const answers: string[] = []
const scores: GenerationScores[] = []
let retrievedDocIds: string[] = []
let injectedChunks = 0
let budget: GenerationCaseResult['budget']
let promptTokenError: number | undefined
let error: string | undefined
for (let attempt = 0; attempt < repeats; attempt++) {
try {
const trace = await this.pipeline.buildPrompt(messages, options.model, pipelineOptions)
retrievedDocIds = uniqueDocIds(trace.retrieved)
injectedChunks = trace.injected.length
const context = trace.injected.map((c) => c.text).join('\n\n')
if (trace.budget) {
budget = {
contextWindow: trace.budget.contextWindow,
estimatedPromptTokens: trace.budget.estimatedPromptTokens,
promptBudget: trace.budget.promptBudget,
turnsDropped: trace.budget.turnsDropped,
chunksDropped: trace.budget.chunksDropped,
historyElided: trace.budget.historyElided,
queryTruncated: trace.budget.queryTruncated,
}
}
const generated = isMock
? { answer: mockAnswer(context), usage: undefined }
: await this.generate(options.model, trace.messages, trace.numCtx, trace.numPredict, sampling)
const answer = generated.answer
// The backend just reported the prompt's real token count. Comparing it
// to the estimate is the only way to know whether the budget above was
// built on a number worth trusting.
if (generated.usage?.promptTokens && trace.uncalibratedPromptTokens) {
promptTokenError =
Math.abs(generated.usage.promptTokens - trace.uncalibratedPromptTokens) /
generated.usage.promptTokens
}
answers.push(answer)
scores.push(
scoreAnswer({
answer,
context,
mustInclude: golden.mustInclude,
mustNotInclude: golden.mustNotInclude,
expectRefusal: golden.expectRefusal,
})
)
} catch (err) {
error = errorText(err)
break
}
}
return {
id: golden.id,
tags: golden.tags,
expectRefusal: golden.expectRefusal,
answers,
scores,
correctness: summarizeRepeats(scores.map((s) => s.correct)),
refusalCorrectness: summarizeRepeats(scores.map((s) => s.refusalCorrect)),
leakageFree: summarizeRepeats(scores.map((s) => s.leakage.length === 0)),
groundedness: summarizeNumeric(scores.map((s) => s.numericGroundedness)),
retrievedDocIds,
injectedChunks,
budget,
promptTokenError,
error,
}
}
private async generate(
model: string,
messages: OllamaChatMessage[],
numCtx?: number,
numPredict?: number,
sampling: EvalSampling = { temperature: EVAL_TEMPERATURE }
): Promise<{ answer: string; usage?: NomadChatUsage }> {
const response = await this.ollamaService.chat({
model,
messages,
numCtx,
numPredict,
// Spread rather than passed as fields: an explicit temperature outranks a
// profile's inside OllamaService, so under a style it has to be absent
// entirely or the run would carry the style's truncation settings at
// temperature 0 and measure a decoder nobody uses.
...sampling,
seed: EVAL_SEED,
})
return { answer: response.message.content.trim(), usage: response.usage }
}
}
/**
* The extractive reference answer: echo the context, or decline when there is
* none. Deterministic, model-free, and an honest ceiling for the current
* retrieval.
*/
export function mockAnswer(context: string): string {
if (!context.trim()) return "I don't have information about that."
return context
}
function uniqueDocIds(chunks: RetrievedChunk[]): string[] {
const ids: string[] = []
const seen = new Set<string>()
for (const chunk of chunks) {
const docId = docIdFromSource(chunk.metadata?.source)
if (docId && !seen.has(docId)) {
seen.add(docId)
ids.push(docId)
}
}
return ids
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
const rate = (values: boolean[]): number | null =>
values.length === 0 ? null : values.filter(Boolean).length / values.length
export function aggregateGeneration(cases: GenerationCaseResult[]): GenerationAggregate {
const scored = cases.filter((c) => c.scores.length > 0)
const allScores = scored.flatMap((c) => c.scores)
return {
cases: cases.length,
unstable: scored.filter((c) => c.correctness.unstable).length,
errors: cases.filter((c) => c.error).length,
// Mean pass-rate rather than all-or-nothing, so a case that passes 2 of 3
// is reported as 0.67 instead of being silently rounded either way.
correctness: scored.length === 0 ? null : mean(scored.map((c) => c.correctness.passRate)),
refusalCorrectness:
scored.length === 0 ? null : mean(scored.map((c) => c.refusalCorrectness.passRate)),
leakageRate: rate(allScores.map((s) => s.leakage.length > 0)),
thinkTagLeakRate: rate(allScores.map((s) => s.thinkTagLeak)),
markdownRate: rate(allScores.map((s) => s.markdownFormatted)),
groundedness: summarizeNumeric(allScores.map((s) => s.numericGroundedness)),
meanAnswerLength: allScores.length === 0 ? null : mean(allScores.map((s) => s.length)),
historyElidedRate: rate(cases.filter((c) => c.budget).map((c) => c.budget!.historyElided)),
chunksDroppedRate: rate(cases.filter((c) => c.budget).map((c) => c.budget!.chunksDropped > 0)),
promptTokenError: summarizeNumeric(
cases.map((c) => c.promptTokenError).filter((e): e is number => e !== undefined)
),
meanPromptTokens: (() => {
const values = cases
.map((c) => c.budget?.estimatedPromptTokens)
.filter((v): v is number => v !== undefined)
return values.length === 0 ? null : mean(values)
})(),
}
}
export function aggregateGenerationByTag(
cases: GenerationCaseResult[]
): Record<string, GenerationAggregate> {
const tags = new Set(cases.flatMap((c) => c.tags))
const out: Record<string, GenerationAggregate> = {}
for (const tag of [...tags].sort()) {
out[tag] = aggregateGeneration(cases.filter((c) => c.tags.includes(tag)))
}
return out
}
const mean = (values: number[]) => values.reduce((a, b) => a + b, 0) / values.length

View File

@ -0,0 +1,158 @@
import { inject } from '@adonisjs/core'
import { execFile } from 'node:child_process'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import type { GenerationRunResult } from './eval_generation_service.js'
import type { RetrievalRunResult } from './eval_retrieval_service.js'
import { flattenByK, type EvalReport, type ReportMeta } from '../utils/eval/report.js'
const run = promisify(execFile)
export const EVAL_REPORTS_DIR = 'tests/eval/reports'
export const EVAL_BASELINES_DIR = 'tests/eval/baselines'
/**
* Turns run results into durable, comparable report files.
*
* Two formats, on purpose: JSON is what `eval:compare` diffs, and Markdown is
* what a human actually reads when a number moved and they need to know which
* question broke and what the model said.
*/
@inject()
export class EvalReportService {
async buildMeta(
kind: 'retrieval' | 'generation',
corpusFingerprint: string,
params: Record<string, unknown>
): Promise<ReportMeta> {
const [git, nomadVersion, platform] = await Promise.all([
readGitState(),
readNomadVersion(),
readPlatform(),
])
return {
kind,
createdAt: new Date().toISOString(),
corpusFingerprint,
gitSha: git.sha,
gitBranch: git.branch,
gitDirty: git.dirty,
nomadVersion,
platform,
params,
}
}
fromRetrieval(meta: ReportMeta, result: RetrievalRunResult): EvalReport {
const flatten = (agg: RetrievalRunResult['overall']) => ({
...flattenByK('recall', agg.recall),
...flattenByK('hitRate', agg.hitRate),
...flattenByK('precision', agg.precision),
...flattenByK('ndcg', agg.ndcg),
mrr: agg.mrr,
emptyRateOnAnswerable: agg.emptyRateOnAnswerable,
nonEmptyRateOnRefusal: agg.nonEmptyRateOnRefusal,
})
return {
meta,
metrics: flatten(result.overall),
byTag: Object.fromEntries(Object.entries(result.byTag).map(([tag, agg]) => [tag, flatten(agg)])),
cases: result.cases,
}
}
fromGeneration(meta: ReportMeta, result: GenerationRunResult): EvalReport {
const flatten = (agg: GenerationRunResult['overall']) => ({
correctness: agg.correctness,
refusalCorrectness: agg.refusalCorrectness,
leakageRate: agg.leakageRate,
thinkTagLeakRate: agg.thinkTagLeakRate,
markdownRate: agg.markdownRate,
groundedness: agg.groundedness?.mean ?? null,
historyElidedRate: agg.historyElidedRate,
chunksDroppedRate: agg.chunksDroppedRate,
promptTokenError: agg.promptTokenError?.mean ?? null,
meanPromptTokens: agg.meanPromptTokens,
})
return {
meta,
metrics: flatten(result.overall),
byTag: Object.fromEntries(Object.entries(result.byTag).map(([tag, agg]) => [tag, flatten(agg)])),
// Answers are kept in full. When a score moves, the only question anyone
// actually asks is "what did it say?", and a truncated answer cannot
// answer it.
cases: result.cases,
}
}
/** Write `<slug>.json` and `<slug>.md`; returns the JSON path. */
async write(report: EvalReport, slug: string, markdown: string): Promise<string> {
const dir = resolve(join(process.cwd(), EVAL_REPORTS_DIR))
await mkdir(dir, { recursive: true })
const jsonPath = join(dir, `${slug}.json`)
await writeFile(jsonPath, JSON.stringify(report, null, 2))
await writeFile(join(dir, `${slug}.md`), markdown)
return jsonPath
}
async read(path: string): Promise<EvalReport> {
const parsed = JSON.parse(await readFile(resolve(path), 'utf8'))
if (!parsed?.meta?.kind || !parsed?.metrics) {
throw new Error(`${path} is not an eval report`)
}
return parsed as EvalReport
}
/**
* Baselines are filed under their corpus fingerprint. That is not cosmetic:
* it makes it structurally impossible to overwrite the baseline for one
* corpus with a run against another.
*/
baselinePath(report: EvalReport, name: string): string {
return resolve(
join(process.cwd(), EVAL_BASELINES_DIR, report.meta.corpusFingerprint, `${name}.json`)
)
}
async promoteToBaseline(report: EvalReport, name: string): Promise<string> {
const path = this.baselinePath(report, name)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, JSON.stringify(report, null, 2))
return path
}
}
async function readGitState() {
const git = async (args: string[]) => (await run('git', args)).stdout.trim()
try {
const [sha, branch, status] = await Promise.all([
git(['rev-parse', 'HEAD']),
git(['rev-parse', '--abbrev-ref', 'HEAD']),
git(['status', '--porcelain']),
])
// A dirty tree matters: it means the report does not correspond to any
// commit, so a later "this regressed at sha X" claim would be unfounded.
return { sha, branch, dirty: status.length > 0 }
} catch {
return { sha: null, branch: null, dirty: false }
}
}
async function readNomadVersion(): Promise<string | null> {
try {
const pkg = JSON.parse(await readFile(resolve(join(process.cwd(), '..', 'package.json')), 'utf8'))
return pkg.version ?? null
} catch {
return null
}
}
async function readPlatform() {
const os = await import('node:os')
return {
cpuArchitecture: os.arch(),
osName: `${os.type()} ${os.release()}`,
nodeVersion: process.version,
}
}

View File

@ -0,0 +1,236 @@
import { EvalCorpusService } from '#services/eval_corpus_service'
import { RagService } from '#services/rag_service'
import { RelevanceJudgeService } from '#services/relevance_judge_service'
import { inject } from '@adonisjs/core'
import { KB_EVAL_COLLECTION } from '../../constants/kb_collections.js'
import {
RAG_DEFAULT_SCORE_THRESHOLD,
RAG_DEFAULT_TOP_K,
RAG_MIN_FINAL_SCORE,
} from '../../constants/ollama.js'
import type { RetrievalStages } from '../../types/rag.js'
import { docIdFromSource } from '../utils/eval/corpus_source.js'
import type { Golden } from '../utils/eval/golden_set.js'
import {
aggregate,
aggregateByTag,
DEFAULT_K_VALUES,
scoreCase,
type RetrievalAggregate,
type RetrievalCase,
type RetrievalCaseResult,
type ScoredChunk,
} from '../utils/eval/retrieval_metrics.js'
export type RetrievalRunOptions = {
topK?: number
scoreThreshold?: number
/** Post-rerank relevance floor. Defaults to the constant, never to the setting. */
minFinalScore?: number
kValues?: number[]
/** Score the raw dense / reranked / diversified orderings separately. */
ablate?: boolean
/** Also return every golden's full pre-floor candidate pool (see RetrievalStages.candidates). */
dump?: boolean
/**
* Run the relevance check on this model after the floor, as chat does when it
* is enabled. Unset keeps this tier model-free and deterministic.
*/
judgeModel?: string
}
/** What the relevance check cost and did, when `judgeModel` was set. */
export type JudgeStats = {
model: string
calls: number
/** Calls that failed open (timeout, error, unusable verdict). */
failures: number
rejected: number
meanMs: number
maxMs: number
}
/** One golden's full candidate pool, for simulating a relevance gate offline. */
export type CandidatePool = {
id: string
tags: string[]
expectRefusal: boolean
relevantDocIds: string[]
candidates: Array<{ docId: string | null; score: number; semanticScore: number | null }>
}
export type StageAblation = {
dense: RetrievalAggregate
reranked: RetrievalAggregate
diversified: RetrievalAggregate
}
export type RetrievalRunResult = {
params: { topK: number; scoreThreshold: number; minFinalScore: number; kValues: number[] }
overall: RetrievalAggregate
byTag: Record<string, RetrievalAggregate>
cases: RetrievalCaseResult[]
ablation: StageAblation | null
/**
* Chunks that came back without a resolvable eval doc id. Non-zero means the
* collection filter leaked and the run is measuring the wrong corpus.
*/
unresolvedChunks: number
/** Every golden's candidate pool, when `dump` was requested. */
pools: CandidatePool[] | null
judge: JudgeStats | null
}
/**
* Runs the golden question set through NOMAD's real retrieval path and scores
* the result.
*
* Deliberately does not touch the chat model. The only model call is the
* embedding of each query, whose output is stable, so two runs over the same
* corpus produce identical numbers on any hardware. That is what makes this the
* fast inner loop and the only tier worth gating CI on: a movement here is a
* code change, full stop.
*
* Multi-turn goldens are scored on their raw final message. Resolving the
* coreference would need the chat model, which would make this tier
* non-deterministic — so the multi-turn bucket here reports the honest floor,
* and the rewrite's contribution is measured in the generation tier instead.
*/
@inject()
export class EvalRetrievalService {
constructor(
private ragService: RagService,
private corpusService: EvalCorpusService,
private judgeService: RelevanceJudgeService
) {}
async run(goldens: Golden[], options: RetrievalRunOptions = {}): Promise<RetrievalRunResult> {
const topK = options.topK ?? RAG_DEFAULT_TOP_K
const scoreThreshold = options.scoreThreshold ?? RAG_DEFAULT_SCORE_THRESHOLD
// RAG_MIN_FINAL_SCORE, deliberately — NOT resolveMinFinalScore(). The chat
// path reads the user's `rag.minRelevance` setting; this tier must not, or a
// slider position on one developer's machine would silently move the numbers
// and the committed baseline would stop being reproducible anywhere else.
// Same reasoning as the harness omitting `skipRetrieval`.
const minFinalScore = options.minFinalScore ?? RAG_MIN_FINAL_SCORE
const kValues = options.kValues ?? DEFAULT_K_VALUES
const cases: RetrievalCase[] = []
const denseCases: RetrievalCase[] = []
const rerankedCases: RetrievalCase[] = []
const diversifiedCases: RetrievalCase[] = []
const pools: CandidatePool[] = []
const recordStages = options.ablate || options.dump
const judgeMs: number[] = []
let judgeFailures = 0
let judgeRejected = 0
let unresolvedChunks = 0
for (const golden of goldens) {
const stages: RetrievalStages = {}
let docs = await this.ragService.searchSimilarDocuments(
golden.query,
topK,
scoreThreshold,
KB_EVAL_COLLECTION,
recordStages ? stages : undefined,
minFinalScore
)
if (options.judgeModel && docs.length > 0) {
const verdict = await this.judgeService.judge(golden.query, docs, options.judgeModel)
judgeMs.push(verdict.ms)
if (!verdict.judged) judgeFailures++
judgeRejected += verdict.rejected
docs = verdict.kept
}
const retrieved: ScoredChunk[] = docs.map((d) => {
const docId = docIdFromSource(d.metadata?.source)
if (!docId) unresolvedChunks++
return { docId, score: d.score, semanticScore: d.metadata?.semantic_score }
})
cases.push(toCase(golden, retrieved))
if (options.dump) {
pools.push({
id: golden.id,
tags: golden.tags,
expectRefusal: golden.expectRefusal,
relevantDocIds: golden.relevantDocIds,
candidates: (stages.candidates ?? []).map((c) => ({
docId: docIdFromSource(c.source),
score: c.score,
semanticScore: c.semanticScore ?? null,
})),
})
}
if (options.ablate) {
denseCases.push(toCase(golden, stageToChunks(stages.dense)))
rerankedCases.push(toCase(golden, stageToChunks(stages.reranked)))
diversifiedCases.push(toCase(golden, stageToChunks(stages.diversified)))
}
}
const results = cases.map((c) => scoreCase(c, kValues))
const aggregateStage = (stageCases: RetrievalCase[]) =>
aggregate(stageCases, stageCases.map((c) => scoreCase(c, kValues)), kValues)
return {
params: { topK, scoreThreshold, minFinalScore, kValues },
overall: aggregate(cases, results, kValues),
byTag: aggregateByTag(cases, results, kValues),
cases: results,
ablation: options.ablate
? {
dense: aggregateStage(denseCases),
reranked: aggregateStage(rerankedCases),
diversified: aggregateStage(diversifiedCases),
}
: null,
unresolvedChunks,
pools: options.dump ? pools : null,
judge: options.judgeModel
? {
model: options.judgeModel,
calls: judgeMs.length,
failures: judgeFailures,
rejected: judgeRejected,
meanMs: judgeMs.length ? judgeMs.reduce((a, b) => a + b, 0) / judgeMs.length : 0,
maxMs: judgeMs.length ? Math.max(...judgeMs) : 0,
}
: null,
}
}
/** Confirm the corpus is actually ingested before reporting a score of zero. */
async assertCorpusReady(): Promise<number> {
const chunks = await this.corpusService.count()
if (chunks === 0) {
throw new Error(
'The eval corpus is not ingested — every score would be zero. Run: node ace eval:corpus --ingest'
)
}
return chunks
}
}
function toCase(golden: Golden, retrieved: ScoredChunk[]): RetrievalCase {
return {
id: golden.id,
tags: golden.tags,
retrieved,
relevantDocIds: golden.relevantDocIds,
expectRefusal: golden.expectRefusal,
}
}
function stageToChunks(stage: Array<{ source?: string; score: number }> | undefined): ScoredChunk[] {
return (stage ?? []).map((entry) => ({
docId: docIdFromSource(entry.source),
score: entry.score,
}))
}

View File

@ -29,9 +29,6 @@ const GITHUB_PMTILES_URL =
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad-maps/contents/pmtiles'
const CATALOG_TIMEOUT_MS = 15000
/** Bounded paginated fallback scan when the exact `name=` lookup comes up empty. */
const KIWIX_PAGE_SIZE = 60
const MAX_KIWIX_FETCHES = 5
/** Concurrent ZIM catalog lookups — keep small to avoid hammering the mirror. */
const ZIM_CHECK_CONCURRENCY = 4
@ -113,14 +110,46 @@ export class KiwixCatalogService {
async getLatestZim(resourceId: string): Promise<CatalogResult | null> {
const pattern = new RegExp(`^${escapeRegex(resourceId)}_(\\d{4}-\\d{2})\\.zim$`)
// 1. Exact-name lookup (the robust path).
// 1. Exact-name lookup (the robust path). Resolves most books, whose OPDS `<name>`
// is the full filename base: devdocs_en_python, zimgit-water_en,
// freecodecamp_en_all, nhs.uk_en_medicines ...
const named = await this.fetchZimEntries({ name: resourceId, count: 50, start: 0 })
const exact = this.pickNewestZim(named, pattern)
if (exact) return exact
// 2. Fallback: bounded keyword scan in case the catalog ignored `name=` or
// indexes the book under a slightly different name.
return this.scanZimByQuery(resourceId, pattern)
// 2. Flavoured books index under the base WITHOUT the flavour, which the catalog
// carries in a separate `<flavour>` element: `wikibooks_en_all_nopic_2026-04.zim`
// is `<name>wikibooks_en_all</name><flavour>nopic</flavour>`. Our resource ids are
// filename bases, so `name=wikibooks_en_all_nopic` matched nothing and every
// maxi/mini/nopic book -- including every Wikipedia -- was unresolvable.
// Retry against the base; `pattern` still decides, so a broader query is safe.
const base = this.stripFlavourSuffix(resourceId)
if (base) {
const byBase = await this.fetchZimEntries({ name: base, count: 50, start: 0 })
const flavoured = this.pickNewestZim(byBase, pattern)
if (flavoured) return flavoured
}
// Nothing in the catalog matches this id. There used to be a third step here: a
// bounded `q=` keyword scan, up to 5 paged requests per unresolved book on every
// check. It was removed because `q=` searches an entry's title and summary, never
// its filename, so it cannot match a resource id -- across every id tested it
// resolved nothing while the two `name=` lookups above resolved everything that
// was resolvable. It was pure repeat traffic aimed at a mirror that has just had
// to deploy anti-crawler measures.
return null
}
/**
* Drop the trailing `_<flavour>` segment from a resource id, or null when there is
* nothing to drop. Deliberately not matched against a flavour allowlist: openZIM adds
* flavours over time, and a wrong guess costs one extra catalog request because the
* authoritative filename check still gates every result.
*/
private stripFlavourSuffix(resourceId: string): string | null {
const cut = resourceId.lastIndexOf('_')
if (cut <= 0) return null
return resourceId.slice(0, cut)
}
/** Newest catalog version of a single PMTiles map, or null if none/older. */
@ -144,36 +173,8 @@ export class KiwixCatalogService {
return latest
}
private async scanZimByQuery(
resourceId: string,
pattern: RegExp
): Promise<CatalogResult | null> {
let start = 0
let total = 0
let latest: CatalogResult | null = null
for (let i = 0; i < MAX_KIWIX_FETCHES; i++) {
const { entries, totalResults } = await this.fetchZimEntriesPage({
q: resourceId,
count: KIWIX_PAGE_SIZE,
start,
})
total = totalResults
if (entries.length === 0) break
start += entries.length
const candidate = this.pickNewestZim(entries, pattern)
if (candidate && (!latest || candidate.version > latest.version)) {
latest = candidate
}
if (start >= total) break
}
return latest
}
private async fetchZimEntries(params: {
name?: string
q?: string
count: number
start: number
}): Promise<CatalogZimEntry[]> {
@ -183,7 +184,6 @@ export class KiwixCatalogService {
private async fetchZimEntriesPage(params: {
name?: string
q?: string
count: number
start: number
}): Promise<{ entries: CatalogZimEntry[]; totalResults: number }> {
@ -191,9 +191,11 @@ export class KiwixCatalogService {
params: {
start: params.start,
count: params.count,
lang: 'eng',
// No `lang` filter. This is a freshness check for an ALREADY-INSTALLED book, so
// the language is whatever the user installed; `lang: 'eng'` made every
// non-English ZIM permanently unresolvable here. The authoritative
// `^<id>_YYYY-MM.zim$` filename check is what resolves the right book.
...(params.name ? { name: params.name } : {}),
...(params.q ? { q: params.q } : {}),
},
responseType: 'text',
timeout: CATALOG_TIMEOUT_MS,

View File

@ -389,11 +389,7 @@ export class MapService implements IMapService {
}
async ensureBaseAssets(): Promise<boolean> {
const exists = await this.checkBaseAssetsExist()
if (!exists) {
const downloaded = await this.downloadBaseAssets()
if (!downloaded) return false
}
if (!(await this.ensureBaseAssetFiles())) return false
try {
await this.ensureWorldBasemap()
@ -426,8 +422,9 @@ export class MapService implements IMapService {
* base map" action so the one network dependency can be satisfied deliberately while online (#1030).
*/
async provisionWorldBasemap(): Promise<boolean> {
const baseAssetsExist = await this.ensureBaseAssets()
if (!baseAssetsExist) return false
// Skip ensureBaseAssets(): it runs its own swallowed ensureWorldBasemap(), so a failure there
// would clear the in-flight promise and the call below would launch a second extract.
if (!(await this.ensureBaseAssetFiles())) return false
await this.ensureWorldBasemap()
return this.checkWorldBasemapExists()
}
@ -450,6 +447,13 @@ export class MapService implements IMapService {
return this.worldBasemapInFlight
}
/** Download the style/sprite/font base assets if missing. Does not touch the world basemap. */
private async ensureBaseAssetFiles(): Promise<boolean> {
const exists = await this.checkBaseAssetsExist()
if (exists) return true
return this.downloadBaseAssets()
}
private async _setupWorldBasemap(): Promise<void> {
const basePath = resolve(join(this.baseDirPath, 'pmtiles'))
const filepath = resolve(join(basePath, WORLD_BASEMAP_FILENAME))

View File

@ -0,0 +1,245 @@
import type Docker from 'dockerode'
import { mapGfxToHsaOverride, readHsaOverrideFromEnv } from '../utils/amd_hsa_override.js'
/**
* Pure helpers for classifying which compute backend Ollama actually loaded,
* parsed from its container logs. Extracted so both SystemService (GPU health
* display) and GpuPassthroughRemediationProvider (auto-reinstall decision) share
* one source of truth, and so the logic is unit-testable without Docker.
* readOllamaStartupLogs is the one Docker-facing helper, kept here so both
* callers read the same log window.
*
* Ollama writes one `inference compute` log line per detected device a few seconds
* after startup, e.g.:
* GPU: ... msg="inference compute" id=0 library=CUDA compute=12.1 name=CUDA0 description="NVIDIA GB10" ...
* CPU: ... msg="inference compute" id=cpu library=cpu compute="" name=cpu ...
*/
export type OllamaComputeBackend = 'gpu' | 'cpu' | 'unknown'
const INFERENCE_LINE_MARKER = 'msg="inference compute"'
const GPU_LIBRARY_RE = /library=(CUDA|ROCm|Vulkan)\b/
/**
* Classify Ollama's compute backend from raw container log text.
*
* - 'gpu' if any `inference compute` line reports a GPU backend
* (CUDA/ROCm/Vulkan). Ollama may emit a CPU line alongside a GPU
* line (CPU is always listed as a fallback device), so a GPU line
* anywhere means the GPU is usable.
* - 'cpu' if `inference compute` lines exist but none report a GPU backend —
* i.e. Ollama fell back to / was created with CPU only.
* - 'unknown' if no `inference compute` line is present (container too fresh,
* logs rotated, or Ollama not started).
*/
export function classifyOllamaComputeBackend(logText: string): OllamaComputeBackend {
const lines = logText.split('\n').filter((line) => line.includes(INFERENCE_LINE_MARKER))
if (lines.length === 0) return 'unknown'
if (lines.some((line) => GPU_LIBRARY_RE.test(line))) return 'gpu'
// Lines exist but none matched a GPU library. A `library=cpu` line is a
// definitive CPU fallback; anything else (unrecognized library) stays unknown.
if (lines.some((line) => /library=cpu\b/.test(line))) return 'cpu'
return 'unknown'
}
export type OllamaGpuLibrary = 'CUDA' | 'ROCm' | 'Vulkan'
export type OllamaGpuInfo = {
library: OllamaGpuLibrary
name: string
vramMiB: number
}
/**
* Parse the GPU device Ollama reported at startup, or null when no GPU line is
* present. Takes the first GPU line rather than the last `inference compute`
* line: Ollama also lists a CPU fallback device, and when that line lands last
* a working GPU would otherwise read as no GPU at all.
*/
export function parseOllamaGpuFromLogs(logText: string): OllamaGpuInfo | null {
const line = logText
.split('\n')
.find((l) => l.includes(INFERENCE_LINE_MARKER) && GPU_LIBRARY_RE.test(l))
if (!line) return null
const library = line.match(GPU_LIBRARY_RE)![1] as OllamaGpuLibrary
const descMatch = line.match(/description="([^"]+)"/)
const totalMatch = line.match(/total="([0-9.]+)\s*GiB"/)
const fallbackName = library === 'CUDA' ? 'NVIDIA GPU' : library === 'ROCm' ? 'AMD GPU' : 'GPU'
return {
library,
name: descMatch?.[1] || fallbackName,
vramMiB: totalMatch ? Math.round(Number.parseFloat(totalMatch[1]) * 1024) : 0,
}
}
export type GpuVendor = 'nvidia' | 'amd'
/**
* The subset of `docker inspect nomad_ollama` the GPU checks read. Structural so
* dockerode's ContainerInspectInfo satisfies it and tests can pass plain objects.
*/
export type OllamaContainerSnapshot = {
Config?: { Image?: string; Env?: string[] | null }
HostConfig?: {
Devices?: Array<{ PathOnHost?: string }> | null
DeviceRequests?: Array<{ Driver?: string }> | null
}
}
function hasDevice(container: OllamaContainerSnapshot, path: string): boolean {
return (container.HostConfig?.Devices ?? []).some((d) => d.PathOnHost === path)
}
// Matches rocm in the tag only (ollama/ollama:rocm, :0.12.0-rocm), not the repo path.
function isRocmImage(image: string | undefined): boolean {
return /:[^/:]*rocm[^/:]*$/i.test(image ?? '')
}
/**
* Which GPU vendor, if any, Ollama is expected to run on.
*
* The container's own config comes first because it records what the install
* actually set up, independent of how the host is detected today:
* - an nvidia DeviceRequest means NVIDIA
* - a ROCm image tag or a /dev/kfd device means AMD (/dev/dri alone is not
* AMD-specific, Intel and Vulkan setups pass it too)
* A registered nvidia runtime comes next, which covers a container created
* CPU-only before the runtime was added.
*
* The persisted AMD GPU type counts only when no container exists. An AMD
* container without ROCm config is deliberate: either acceleration was turned
* off, or the daemon rejected /dev/kfd and the install fell back to CPU (#1232).
* Flagging it would offer a reinstall that rebuilds the same CPU container. AMD
* never registers a Docker runtime, so gating on the runtime alone skips every
* AMD box (#1344).
*/
export function resolveExpectedGpuVendor(input: {
hasNvidiaRuntime: boolean
amdConfigured: boolean
container: OllamaContainerSnapshot | null
}): GpuVendor | null {
const { container } = input
if (container) {
if ((container.HostConfig?.DeviceRequests ?? []).some((r) => r.Driver === 'nvidia')) {
return 'nvidia'
}
if (isRocmImage(container.Config?.Image) || hasDevice(container, '/dev/kfd')) {
return 'amd'
}
}
if (input.hasNvidiaRuntime) return 'nvidia'
if (!container && input.amdConfigured) return 'amd'
return null
}
/**
* Where a running AMD nomad_ollama container differs from what a reinstall would
* create. An empty list means a reinstall would rebuild the same container, so it
* cannot move Ollama off the CPU and the fix lies elsewhere (usually the HSA
* override for an iGPU missing from ROCm's allowlist).
*/
export function diffAmdOllamaConfig(
container: OllamaContainerSnapshot,
desiredHsaOverride: string | null
): string[] {
const drift: string[] = []
const env = container.Config?.Env ?? []
if (!isRocmImage(container.Config?.Image)) {
drift.push(`image is ${container.Config?.Image ?? 'unknown'}, not the ROCm build`)
}
if (!hasDevice(container, '/dev/kfd')) drift.push('/dev/kfd is not passed through')
if (!hasDevice(container, '/dev/dri')) drift.push('/dev/dri is not passed through')
const currentOverride = readHsaOverrideFromEnv(env)
if (currentOverride !== desiredHsaOverride) {
drift.push(
`HSA_OVERRIDE_GFX_VERSION is ${currentOverride ?? 'unset'}, expected ${desiredHsaOverride ?? 'unset'}`
)
}
if (!env.includes('OLLAMA_IGPU_ENABLE=1')) drift.push('OLLAMA_IGPU_ENABLE=1 is not set')
return drift
}
/**
* The gfx target Ollama named when it rejected an AMD GPU, e.g. 'gfx1103' from
* msg="amdgpu is not supported (supported types:[gfx1030 ...])" gpu_type=gfx1103 ...
* Best-effort: the exact wording varies across Ollama releases, so callers treat
* null as "unknown" and fall back to asking the user.
*/
export function parseAmdGfxTargetFromLogs(logText: string): string | null {
return logText.match(/gpu_type=(gfx[0-9a-f]+)\b/i)?.[1]?.toLowerCase() ?? null
}
/**
* Why an AMD container expected on the GPU is running on CPU, in the shape
* GpuHealthStatus reports to the UI. desiredHsaOverride must come from
* DockerService.getAmdHsaOverride with this container's env, so the diff matches
* what a reinstall would build.
*/
export function diagnoseAmdCpuFallback(
container: OllamaContainerSnapshot,
logText: string,
desiredHsaOverride: string | null
) {
const gfx = parseAmdGfxTargetFromLogs(logText)
const current = readHsaOverrideFromEnv(container.Config?.Env)
// With an override applied, Ollama logs the coerced target (gfx1100 for a 780M on
// 11.0.0), which maps to "no override" and would suggest removing a working value.
// The override the container carries is the better suggestion then (#1377).
const suggested = current ?? (gfx ? mapGfxToHsaOverride(gfx) : null)
return {
amdReinstallWouldChange: diffAmdOllamaConfig(container, desiredHsaOverride).length > 0,
...(gfx && { amdGfxTarget: gfx }),
...(current && { currentHsaOverride: current }),
...(suggested && { suggestedHsaOverride: suggested }),
}
}
/**
* Read nomad_ollama's logs from its startup window (the five minutes after it
* booted), where the "inference compute" line is emitted. A tail:N read is
* fragile here: under embedding load the container writes >1000 lines/min,
* which pushes the line out of any reasonable tail within minutes, while the
* startup window is bounded and never ages out. Falls back to tail:500 when
* StartedAt is missing. Returns '' on any error so callers classify the backend
* as 'unknown' rather than throwing.
*/
export async function readOllamaStartupLogs(
container: Docker.Container,
inspect?: { State?: { StartedAt?: string } }
): Promise<string> {
try {
const state = inspect ?? (await container.inspect())
const startedAtRaw = state?.State?.StartedAt
const startedAtMs = startedAtRaw ? new Date(startedAtRaw).getTime() : NaN
const logsOpts: {
stdout: true
stderr: true
follow: false
since?: number
until?: number
tail?: number
} = { stdout: true, stderr: true, follow: false }
if (Number.isFinite(startedAtMs) && startedAtMs > 0) {
const startedAtSec = Math.floor(startedAtMs / 1000)
logsOpts.since = startedAtSec
logsOpts.until = startedAtSec + 300
} else {
logsOpts.tail = 500
}
const buf = (await container.logs(logsOpts)) as unknown as Buffer
return buf.toString('utf8')
} catch {
return ''
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,363 @@
import { ContextWindowService } from '#services/context_window_service'
import { NomadMdService } from '#services/nomad_md_service'
import { OllamaService } from '#services/ollama_service'
import { RagService } from '#services/rag_service'
import { RelevanceJudgeService } from '#services/relevance_judge_service'
import { TokenCalibrationService } from '#services/token_calibration_service'
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import {
RAG_CONTEXT_LIMITS,
RAG_DEFAULT_SCORE_THRESHOLD,
RAG_DEFAULT_TOP_K,
RAG_PLACEMENT,
QUERY_REWRITE_MAX_TOKENS,
SYSTEM_PROMPTS,
} from '../../constants/ollama.js'
import type { OllamaChatMessage } from '../../types/ollama.js'
import type { PipelineOptions, PipelineTrace, RetrievalFloorStats, RetrievedChunk } from '../../types/rag.js'
import { planPrompt } from '../utils/context_budget.js'
import { estimateMessagesTokens } from '../utils/token_estimate.js'
import { resolveMinFinalScore } from '../utils/rag_relevance.js'
import { resolveTasksModel } from '../utils/tasks_model.js'
import { buildContextBlock, getContextLimitsForModel } from '../utils/rag_prompt.js'
import { QUERIES_SCHEMA, pickQueries, resolveStructured } from '../utils/structured_output.js'
/**
* Everything that happens between "a user sent a message" and "a payload goes
* to Ollama": system-prompt assembly, history-aware query rewriting, retrieval,
* model-size-aware context trimming, and the num_ctx decision.
*
* This used to live inline in OllamaController.chat. It was moved here so there
* is exactly one implementation of the prompt-building pipeline — the chat
* endpoint and the eval harness both call `buildPrompt`, so a measurement of
* the harness is a measurement of production, not of a copy that drifts.
*
* The behaviour is a verbatim port. Every quirk preserved below is marked; the
* quirks are worth fixing but each one changes output, and the point of the
* harness is to stop changing output without measuring it.
*/
@inject()
export class RagPipelineService {
constructor(
private ollamaService: OllamaService,
private ragService: RagService,
private nomadMdService: NomadMdService,
private contextWindowService: ContextWindowService,
private tokenCalibration: TokenCalibrationService,
private relevanceJudge: RelevanceJudgeService
) {}
/**
* Build the exact message array to send to Ollama, plus a trace of every
* decision made along the way.
*
* The caller passes the conversation as received; this never mutates it.
*/
async buildPrompt(
messages: OllamaChatMessage[],
model: string,
opts: PipelineOptions = {}
): Promise<PipelineTrace> {
// Split the caller's conversation into the pieces the budget planner needs.
// Caller-supplied system messages are treated as stable prefix material and
// stay in front of everything, as before.
const callerSystem = messages.filter((msg) => msg.role === 'system')
const conversation = messages.filter((msg) => msg.role !== 'system')
const systemBlocks: OllamaChatMessage[] = [...callerSystem]
// Default formatting prompt, only when the caller supplied no system message.
if (callerSystem.length === 0) {
logger.debug('[RagPipeline] Injecting system prompt')
systemBlocks.push({ role: 'system', content: SYSTEM_PROMPTS.default })
}
// The user-managed NOMAD.md goes in front of the formatting prompt so the
// user's persistent instructions take precedence. Skipped in evals, where a
// developer's personal NOMAD.md would silently skew every score.
if (!opts.skipNomadMd) {
const nomadPrompt = await this.nomadMdService.getSystemPrompt()
if (nomadPrompt) {
logger.debug('[RagPipeline] Injecting NOMAD.md system prompt')
systemBlocks.unshift({ role: 'system', content: nomadPrompt })
}
}
// The current question is the last user message; everything before it is
// history. A conversation with no user message at all still has to produce a
// valid payload, so fall back to an empty question.
const lastUserIndex = conversation.map((m) => m.role).lastIndexOf('user')
const query: OllamaChatMessage =
lastUserIndex >= 0 ? conversation[lastUserIndex] : { role: 'user', content: '' }
const history = lastUserIndex >= 0 ? conversation.slice(0, lastUserIndex) : conversation
// Retrieval reads the conversation as the user wrote it — system prompts are
// deliberately excluded. They used to be inside the rewrite window, where
// they were transcribed as "Assistant" turns and polluted the rewritten query.
const working: OllamaChatMessage[] = [...systemBlocks, ...conversation]
const trace: PipelineTrace = {
rewrittenQuery: null,
didRewrite: false,
retrieved: [],
injected: [],
messages: working,
numCtx: undefined,
numPredict: undefined,
contextLimits: { maxResults: RAG_DEFAULT_TOP_K, maxTokens: 0 },
timings: { rewriteMs: 0, retrievalMs: 0, relevanceCheckMs: 0 },
minFinalScore: 0,
chunksBelowFloor: 0,
relevanceCheckModel: null,
chunksRejectedByCheck: 0,
}
// --- Retrieval -------------------------------------------------------
// oracleContext bypasses retrieval entirely (eval `oracle` mode).
let relevantDocs: RetrievedChunk[] = []
if (opts.oracleContext) {
relevantDocs = opts.oracleContext
trace.retrieved = relevantDocs
} else if (opts.skipRetrieval) {
// Retrieval turned off by the user (rag.enabled). Bail before the
// hasDocuments check, the rewrite LLM call and the vector search — the
// whole point is to spend nothing here. relevantDocs stays empty, so no
// context block is injected below.
logger.debug('[RagPipeline] Retrieval disabled by setting, skipping')
} else {
const rewriteStart = Date.now()
const { query: retrievalQuery, didRewrite } = await this.resolveRetrievalQuery(
conversation,
model,
opts
)
trace.timings.rewriteMs = Date.now() - rewriteStart
trace.rewrittenQuery = retrievalQuery
trace.didRewrite = didRewrite
if (retrievalQuery) {
const retrievalStart = Date.now()
// An explicit option always wins, so the eval harness never inherits
// whatever this machine's `rag.minRelevance` slider happens to be set to.
const minFinalScore = opts.minFinalScore ?? (await resolveMinFinalScore())
const floor: RetrievalFloorStats = { candidates: 0, belowFloor: 0 }
relevantDocs = await this.ragService.searchSimilarDocuments(
retrievalQuery,
opts.topK ?? RAG_DEFAULT_TOP_K,
opts.scoreThreshold ?? RAG_DEFAULT_SCORE_THRESHOLD,
opts.collection,
undefined,
minFinalScore,
floor
)
trace.timings.retrievalMs = Date.now() - retrievalStart
trace.retrieved = relevantDocs
trace.minFinalScore = minFinalScore
trace.chunksBelowFloor = floor.belowFloor
logger.debug(
`[RAG] Retrieved ${relevantDocs.length} relevant documents for query: "${retrievalQuery}"` +
(floor.belowFloor > 0
? ` (${floor.belowFloor} of ${floor.candidates} dropped below the ${minFinalScore} relevance floor)`
: '')
)
// Opt-in relevance check (#1341). The floor cannot tell a coherent
// off-topic question from a real one on a large general-prose library;
// a model can, at ~8B. Judged against the rewritten query, which is the
// standalone form of what the user is asking this turn.
if (relevantDocs.length > 0) {
const checkModel =
opts.relevanceCheckModel !== undefined
? opts.relevanceCheckModel
: await this.relevanceJudge.resolveModel(model)
if (checkModel) {
const verdict = await this.relevanceJudge.judge(retrievalQuery, relevantDocs, checkModel)
relevantDocs = verdict.kept
trace.retrieved = relevantDocs
trace.relevanceCheckModel = checkModel
trace.chunksRejectedByCheck = verdict.rejected
trace.timings.relevanceCheckMs = verdict.ms
}
}
}
}
// --- Budgeted assembly -------------------------------------------------
//
// Everything above decided *what* is available; this decides what fits. The
// model-size tier still caps how many chunks a small model is asked to read
// (a 1B model does not get better answers from five chunks), and the budget
// planner then enforces the hard token limit on top of that.
const modelInfo = await this.ollamaService.getModelInfo(model).catch(() => undefined)
const limits = getContextLimitsForModel(model, RAG_CONTEXT_LIMITS, modelInfo?.parameterSize)
trace.contextLimits = limits
const candidateDocs = relevantDocs.slice(0, limits.maxResults)
const contextWindow = await this.contextWindowService.windowFor(model)
const ratio = await this.tokenCalibration.ratioFor(model)
const plan = planPrompt({
systemBlocks,
history,
query,
ragChunks: candidateDocs,
renderRagBlock: (chunks) =>
SYSTEM_PROMPTS.rag_context(buildContextBlock(chunks as RetrievedChunk[])),
contextWindow,
ratio,
ragPlacement: opts.ragPlacement ?? RAG_PLACEMENT,
})
trace.messages = plan.messages as OllamaChatMessage[]
trace.injected = candidateDocs.slice(0, plan.trace.chunksKept)
trace.budget = plan.trace
trace.numCtx = contextWindow
trace.numPredict = plan.numPredict
// Recorded uncalibrated so TokenCalibrationService can compare like with like;
// applying the ratio here and again there would make the EWMA chase itself.
trace.uncalibratedPromptTokens = estimateMessagesTokens(plan.messages, 1)
if (plan.trace.turnsDropped > 0 || plan.trace.chunksDropped > 0 || plan.trace.queryTruncated) {
logger.info(
`[RagPipeline] Budget for ${model}: ${plan.trace.estimatedPromptTokens}/${plan.trace.promptBudget} tokens ` +
`(window ${contextWindow}); dropped ${plan.trace.turnsDropped} turn(s), ` +
`${plan.trace.chunksDropped} chunk(s)${plan.trace.queryTruncated ? ', truncated the question' : ''}`
)
}
return trace
}
/**
* Decide what string to hand to retrieval.
*
* Returns null when the RAG pipeline should be skipped entirely — an empty
* knowledge base, or a conversation with no user message at all.
*/
private async resolveRetrievalQuery(
messages: OllamaChatMessage[],
model: string,
opts: PipelineOptions
): Promise<{ query: string | null; didRewrite: boolean }> {
const lastUserMessage = [...messages].reverse().find((msg) => msg.role === 'user')
try {
// Skip the entire RAG pipeline if there are no documents to search.
const hasDocuments = await this.ragService.hasDocuments()
if (!hasDocuments) {
return { query: null, didRewrite: false }
}
if (opts.skipQueryRewrite) {
return { query: lastUserMessage?.content ?? null, didRewrite: false }
}
// Last 6 messages ≈ 3 turns.
//
// The caller now passes the conversation *without* system messages, so the
// window can no longer scoop up system prompts and transcribe them as
// "Assistant" turns — which is what the previous slice did on short
// conversations, feeding the rewriter the formatting rules as if the model
// had said them.
const recentMessages = messages.slice(-6)
// Skip rewriting on the very first turn — with only one user message there
// is no prior context to fold in, so the rewrite would just echo the
// message back at the cost of an extra LLM round-trip. From the first
// follow-up onward the rewrite carries entities from earlier turns
// ("the bars" -> "Hershey's bars chocolate poisoning dog"); without it,
// embeddings match nothing and the assistant loses the thread.
const userMessages = recentMessages.filter((msg) => msg.role === 'user')
if (userMessages.length < 2) {
return { query: lastUserMessage?.content ?? null, didRewrite: false }
}
const conversationContext = recentMessages
.map((msg) => {
const role = msg.role === 'user' ? 'User' : 'Assistant'
// Truncate assistant messages to keep the rewrite prompt manageable.
const content =
msg.role === 'assistant'
? msg.content.slice(0, 200) + (msg.content.length > 200 ? '...' : '')
: msg.content
return `${role}: "${content}"`
})
.join('\n')
// Route to the tasks model when one is configured. This is the ancillary
// call that actually costs something: it runs every turn, before the
// answer, and it sends a prompt that shares no prefix with the chat. On a
// single-slot Ollama (OLLAMA_NUM_PARALLEL=1, the default) running it on the
// chat model evicts that model's cached prefix, so the conversation is
// re-prefilled from scratch on every turn no matter how stable we keep the
// ordering. A small dedicated model keeps the chat model's cache intact.
const rewriteModel = (await resolveTasksModel(this.ollamaService, model, undefined, '[RAG]')) ?? model
// A rewrite is a mechanical transformation, and QUERY_REWRITE_MAX_TOKENS is far
// too small a budget for a reasoning model to think and then answer — it would
// spend the whole cap thinking and get truncated with no query at all. Suppress
// reasoning at the source; `thinkingCapable` is what lets the compat transport
// send reasoning_effort:'none' instead of nothing. Memoized per model name.
const thinkingCapable = await this.ollamaService.checkModelHasThinking(rewriteModel)
const response = await this.ollamaService.chat({
model: rewriteModel,
messages: [
{ role: 'system', content: SYSTEM_PROMPTS.query_rewrite },
{
role: 'user',
content: `Conversation:\n${conversationContext}\n\nRewritten Query:`,
},
],
// A rewrite is a short, mechanical transformation. Cap it so a chatty
// small model can't spend a thousand tokens restating the question, and
// pin the sampler so the same conversation rewrites the same way.
numPredict: QUERY_REWRITE_MAX_TOKENS,
temperature: 0,
think: false,
thinkingCapable,
// Grammar-constrained on the native transport. An array rather than a bare
// string because multi-query fusion then costs a prompt change and nothing
// else; only the first entry is used today.
format: QUERIES_SCHEMA,
})
const raw = response.message.content.trim()
const structured = resolveStructured(raw, pickQueries, response.structured === true)
if (!structured.ok && structured.reason === 'constrained-parse-failed') {
// The grammar was applied and the output still didn't parse, which here means a
// rewrite truncated mid-object by QUERY_REWRITE_MAX_TOKENS. `raw` is a JSON
// fragment: embedding it would send the brace and the schema key to Qdrant as
// if they were the question. Losing the rewrite for this turn is the cheaper
// failure, so take the same path as a rewrite that produced nothing at all.
logger.warn(
`[RAG] Model "${rewriteModel}" broke the query grammar; falling back to the user message`
)
return { query: lastUserMessage?.content ?? null, didRewrite: false }
}
// Unconstrained backends never had the grammar applied, so the raw text is the
// rewrite and the old bare-string behaviour is still correct.
const rewrittenQuery = structured.ok ? structured.value[0] : raw
// Empty means the response was reasoning and nothing else (or was truncated
// mid-thought). Embedding an empty string would search the corpus for nothing
// and quietly poison retrieval for the rest of the conversation, so fall back
// to the raw message — the same shape as the first-turn skip above.
if (!rewrittenQuery) {
logger.warn(
`[RAG] Model "${rewriteModel}" produced no query text; falling back to the user message`
)
return { query: lastUserMessage?.content ?? null, didRewrite: false }
}
logger.info(`[RAG] Query rewritten: "${rewrittenQuery}"`)
return { query: rewrittenQuery, didRewrite: true }
} catch (error) {
logger.error(
`[RAG] Query rewriting failed: ${error instanceof Error ? error.message : error}`
)
// Fall back to the last user message rather than losing retrieval entirely.
return { query: lastUserMessage?.content ?? null, didRewrite: false }
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,121 @@
import { OllamaService } from '#services/ollama_service'
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import KVStore from '#models/kv_store'
import { RELEVANCE_CHECK_MAX_TOKENS, SYSTEM_PROMPTS } from '../../constants/ollama.js'
import type { RetrievedChunk } from '../../types/rag.js'
import {
buildJudgeInput,
isRelevanceCheckEnabled,
ON_TOPIC_SCHEMA,
pickOnTopic,
} from '../utils/relevance_judge.js'
import { resolveStructured } from '../utils/structured_output.js'
import { resolveTasksModel } from '../utils/tasks_model.js'
export type JudgeResult = {
/** Every chunk when the model said they fit the question, none when it said not. */
kept: RetrievedChunk[]
/** How many were dropped. 0 when the check failed open. */
rejected: number
/** False when the call failed and every chunk was kept unjudged. */
judged: boolean
ms: number
}
/**
* Asks a model whether the retrieved chunks are about what the user asked, and
* drops them all when it says no (#1341). Runs after the relevance floor, so it
* only ever sees the few chunks that were about to be injected and cited.
*
* Fails open by design. The floor has already removed the noise; this removes
* the coherent-but-off-topic remainder. A timeout or a garbled verdict costing
* one turn that precision is a much smaller failure than an answerable question
* losing its context because a model misbehaved.
*/
@inject()
export class RelevanceJudgeService {
constructor(private ollamaService: OllamaService) {}
/**
* The model to run the check on this turn, or null to skip it. Opt-in via
* `rag.relevanceCheck`; runs on the tasks model when one is configured and on
* the chat model otherwise.
*/
async resolveModel(chatModel: string): Promise<string | null> {
try {
if (!isRelevanceCheckEnabled(await KVStore.getValue('rag.relevanceCheck'))) return null
} catch (error) {
logger.warn(
`[RAG] Failed to read rag.relevanceCheck, skipping the check: ${error instanceof Error ? error.message : error}`
)
return null
}
return resolveTasksModel(this.ollamaService, chatModel, undefined, '[RAG]')
}
async judge(question: string, chunks: RetrievedChunk[], model: string): Promise<JudgeResult> {
const started = Date.now()
const unjudged = (): JudgeResult => ({
kept: chunks,
rejected: 0,
judged: false,
ms: Date.now() - started,
})
if (chunks.length === 0 || !question.trim()) return unjudged()
try {
const thinkingCapable = await this.ollamaService.checkModelHasThinking(model)
const response = await this.ollamaService.chat({
model,
messages: [
{ role: 'system', content: SYSTEM_PROMPTS.relevance_check },
{
role: 'user',
content: buildJudgeInput(
question,
chunks.map((c) => ({
text: c.text,
// Titles only from content metadata. A filename is not a claim
// about what a passage says, and in the eval corpus it would
// hand the judge the answer ("distractor-…").
title:
c.metadata?.full_title || c.metadata?.article_title || c.metadata?.archive_title,
}))
),
},
],
numPredict: RELEVANCE_CHECK_MAX_TOKENS,
temperature: 0,
think: false,
thinkingCapable,
format: ON_TOPIC_SCHEMA,
})
const verdict = resolveStructured(
response.message.content.trim(),
pickOnTopic,
response.structured === true
)
if (!verdict.ok) {
logger.warn(
`[RAG] Relevance check on "${model}" returned no usable verdict (${verdict.reason}); keeping all chunks`
)
return unjudged()
}
const ms = Date.now() - started
logger.debug(
`[RAG] Relevance check on "${model}": ${verdict.value ? 'on topic' : 'off topic'} (${chunks.length} chunk(s), ${ms}ms)`
)
return verdict.value
? { kept: chunks, rejected: 0, judged: true, ms }
: { kept: [], rejected: chunks.length, judged: true, ms }
} catch (error) {
logger.warn(
`[RAG] Relevance check on "${model}" failed, keeping all chunks: ${error instanceof Error ? error.message : error}`
)
return unjudged()
}
}
}

View File

@ -21,7 +21,18 @@ import env from '#start/env'
import KVStore from '#models/kv_store'
import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'
import { isNewerVersion } from '../utils/version.js'
import { isUnresolvedGpuModel } from '../utils/gpu_model.js'
import {
classifyOllamaComputeBackend,
diagnoseAmdCpuFallback,
parseOllamaGpuFromLogs,
readOllamaStartupLogs,
resolveExpectedGpuVendor,
type OllamaGpuInfo,
} from '#services/ollama_compute'
import { invalidateAssistantNameCache } from '../../config/inertia.js'
import { invalidateMinRelevanceCache } from '../utils/rag_relevance.js'
import { invalidateResponseStyleCache } from '../utils/response_style.js'
import { KiwixLibraryService } from '#services/kiwix_library_service'
@inject()
@ -100,6 +111,20 @@ export class SystemService {
return false
}
/**
* The running nomad_ollama container and its inspect data, or null when it is
* not running. The inspect data records the GPU config the install actually
* applied (DeviceRequests, /dev/kfd, the ROCm image), which is what the GPU
* health check keys on.
*/
private async _findRunningOllamaContainer() {
const containers = await this.dockerService.docker.listContainers({ all: false })
const summary = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
if (!summary) return null
const container = this.dockerService.docker.getContainer(summary.Id)
return { container, inspect: await container.inspect() }
}
/**
* Probe Ollama startup logs for the canonical "inference compute" line that records
* which compute backend was selected. This catches silent CPU fallback (e.g. when
@ -111,70 +136,11 @@ export class SystemService {
* - the line has not been emitted (Ollama still starting up)
* - logs show CPU-only operation (no GPU detected)
*/
async getOllamaInferenceComputeFromLogs(): Promise<{
library: 'CUDA' | 'ROCm'
name: string
vramMiB: number
} | null> {
async getOllamaInferenceComputeFromLogs(): Promise<OllamaGpuInfo | null> {
try {
const containers = await this.dockerService.docker.listContainers({ all: false })
const ollamaContainer = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
if (!ollamaContainer) return null
const container = this.dockerService.docker.getContainer(ollamaContainer.Id)
// Read logs only from the first 5 minutes after container start. The
// "inference compute" line is written once during Ollama's GPU discovery
// phase, within seconds of startup. Using tail:N here is fragile: under
// active embedding workloads we've seen >1000 lines/min, which pushes the
// line past any reasonable tail in minutes. Pinning to the startup window
// is bounded (~5 min of logs regardless of container uptime) and never
// ages out.
//
// Fall back to the previous tail:500 strategy if StartedAt is missing or
// unparseable — we can't construct a since/until window without it, but
// tail:500 is still useful when the container just started and the line
// is still recent.
const inspect = await container.inspect()
const startedAtRaw = inspect?.State?.StartedAt
const startedAtMs = startedAtRaw ? new Date(startedAtRaw).getTime() : NaN
const hasValidStartedAt = Number.isFinite(startedAtMs) && startedAtMs > 0
const logsOpts: { stdout: true; stderr: true; follow: false; since?: number; until?: number; tail?: number } = {
stdout: true,
stderr: true,
follow: false,
}
if (hasValidStartedAt) {
const startedAtSec = Math.floor(startedAtMs / 1000)
logsOpts.since = startedAtSec
logsOpts.until = startedAtSec + 300 // 5-minute window
} else {
logger.warn(
`[SystemService] nomad_ollama State.StartedAt missing or invalid (${startedAtRaw ?? 'undefined'}); falling back to tail:500 for inference-compute probe`
)
logsOpts.tail = 500
}
const buf = (await container.logs(logsOpts)) as unknown as Buffer
const logs = buf.toString('utf8')
const lines = logs.split('\n').filter((l) => l.includes('msg="inference compute"'))
if (lines.length === 0) return null
const lastLine = lines[lines.length - 1]
const libraryMatch = lastLine.match(/library=(CUDA|ROCm)/)
if (!libraryMatch) return null
const descMatch = lastLine.match(/description="([^"]+)"/)
const totalMatch = lastLine.match(/total="([0-9.]+)\s*GiB"/)
return {
library: libraryMatch[1] as 'CUDA' | 'ROCm',
name:
descMatch?.[1] ||
(libraryMatch[1] === 'CUDA' ? 'NVIDIA GPU' : 'AMD GPU'),
vramMiB: totalMatch ? Math.round(Number.parseFloat(totalMatch[1]) * 1024) : 0,
}
const ollama = await this._findRunningOllamaContainer()
if (!ollama) return null
return parseOllamaGpuFromLogs(await readOllamaStartupLogs(ollama.container, ollama.inspect))
} catch (error) {
logger.warn(
`[SystemService] Failed to probe Ollama logs for inference compute line: ${error instanceof Error ? error.message : error}`
@ -341,6 +307,8 @@ export class SystemService {
'is_custom',
'is_user_modified',
'is_deprecated',
'is_link_tile',
'link_color',
'category'
)
.where('is_dependency_service', false)
@ -381,6 +349,8 @@ export class SystemService {
is_custom: service.is_custom,
is_user_modified: service.is_user_modified,
is_deprecated: service.is_deprecated,
is_link_tile: service.is_link_tile,
link_color: service.link_color,
category: service.category,
})
}
@ -500,91 +470,125 @@ export class SystemService {
}
}
// Run the probes when controllers are empty (common inside Docker) or
// when lspci gave us bogus discrete-GPU BAR0 values that need replacing.
if (
// The same pci.ids staleness that produces bogus VRAM also produces a
// placeholder model name — a card newer than the container's pci.ids is
// reported as its raw id, e.g. "Device 2d05" for an RTX 5060 (#1165).
//
// These are independent symptoms, not one. lspci can hand back a
// perfectly plausible BAR0 reading alongside an unresolved name, and in
// that case nothing above fires, no probe runs, and Settings > System
// renders the raw PCI id as the GPU model (#1196). NVIDIA usually hides
// this because the nvidia-smi path tends to be reached for other
// reasons; AMD has no equivalent, so it shows the raw id.
//
// The probes below resolve a real name from Ollama's own startup log,
// so trigger them on an unresolved name too rather than only on VRAM.
const hasUnresolvedGpuName = (graphics.controllers || []).some((c) =>
isUnresolvedGpuModel(c.model || '')
)
// Run the probes when controllers are empty (common inside Docker),
// when lspci gave us bogus discrete-GPU BAR0 values that need replacing,
// or when it named a card it couldn't resolve. Only in those cases do
// probe results replace the lspci controllers; a clean lspci reading
// stays as the display source.
const lspciNeedsProbe =
!graphics.controllers ||
graphics.controllers.length === 0 ||
hasLspciBogusDgpuVram
) {
const runtimes = dockerInfo.Runtimes || {}
gpuHealth.hasNvidiaRuntime = 'nvidia' in runtimes
hasLspciBogusDgpuVram ||
hasUnresolvedGpuName
const useProbeControllers = (controllers: typeof graphics.controllers) => {
if (lspciNeedsProbe) graphics.controllers = controllers
}
const toControllers = (gpus: Array<{ vendor: string; model: string; vram: number }>) =>
gpus.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
// AMD doesn't register a Docker runtime. Detection sources, in priority order:
// 1. KV 'gpu.type' (set by DockerService._detectGPUType after first Ollama install)
// 2. Marker file at /app/storage/.nomad-gpu-type (written by install_nomad.sh)
// The marker file matters because the System page should reflect AMD presence
// even before AI Assistant has been installed for the first time.
let savedGpuType: string | null | undefined = await KVStore.getValue('gpu.type') as string | undefined
if (!savedGpuType) {
try {
savedGpuType = (await readFile('/app/storage/.nomad-gpu-type', 'utf8')).trim()
} catch {}
}
const amdEnabledRaw = await KVStore.getValue('ai.amdGpuAcceleration')
const amdAccelerationEnabled = String(amdEnabledRaw) !== 'false'
gpuHealth.hasRocmRuntime = savedGpuType === 'amd' && amdAccelerationEnabled
// Runtime flags are reported whichever path runs below. They used to be set
// only inside the probe branch, so a box whose lspci reading looked clean
// reported hasRocmRuntime: false even with an AMD GPU configured (#1344).
const runtimes = dockerInfo.Runtimes || {}
gpuHealth.hasNvidiaRuntime = 'nvidia' in runtimes
if (gpuHealth.hasNvidiaRuntime || gpuHealth.hasRocmRuntime) {
gpuHealth.gpuVendor = gpuHealth.hasNvidiaRuntime ? 'nvidia' : 'amd'
// AMD doesn't register a Docker runtime. Detection sources, in priority order:
// 1. KV 'gpu.type' (set by DockerService._detectGPUType after first Ollama install)
// 2. Marker file at /app/storage/.nomad-gpu-type (written by install_nomad.sh)
// The marker file matters because the System page should reflect AMD presence
// even before AI Assistant has been installed for the first time.
let savedGpuType: string | null | undefined = await KVStore.getValue('gpu.type') as string | undefined
if (!savedGpuType) {
try {
savedGpuType = (await readFile('/app/storage/.nomad-gpu-type', 'utf8')).trim()
} catch {}
}
const amdEnabledRaw = await KVStore.getValue('ai.amdGpuAcceleration')
const amdAccelerationEnabled = String(amdEnabledRaw) !== 'false'
gpuHealth.hasRocmRuntime = savedGpuType === 'amd' && amdAccelerationEnabled
// When Ollama is expected on a GPU, its startup log decides health whatever
// lspci returned. A clean lspci reading says a GPU exists, not that Ollama
// uses it: an AMD iGPU silently on CPU used to land in the else branch below
// and report 'ok' (#1344).
const ollama = await this._findRunningOllamaContainer().catch(() => null)
const expectedVendor = resolveExpectedGpuVendor({
hasNvidiaRuntime: gpuHealth.hasNvidiaRuntime,
amdConfigured: gpuHealth.hasRocmRuntime,
container: ollama?.inspect ?? null,
})
if (expectedVendor || lspciNeedsProbe) {
if (expectedVendor) {
gpuHealth.gpuVendor = expectedVendor
// Primary probe: Ollama log parsing — works for both vendors and catches silent fallback
const logInfo = await this.getOllamaInferenceComputeFromLogs()
const ollamaLogs = ollama
? await readOllamaStartupLogs(ollama.container, ollama.inspect)
: ''
const logInfo = parseOllamaGpuFromLogs(ollamaLogs)
if (logInfo) {
graphics.controllers = [
useProbeControllers([
{
model: logInfo.name,
vendor: logInfo.library === 'CUDA' ? 'NVIDIA' : 'AMD',
vendor:
logInfo.library === 'CUDA' ? 'NVIDIA' : logInfo.library === 'ROCm' ? 'AMD' : '',
bus: '',
vram: logInfo.vramMiB,
vramDynamic: false,
},
]
])
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else if (gpuHealth.hasNvidiaRuntime) {
} else if (classifyOllamaComputeBackend(ollamaLogs) === 'cpu') {
// Ollama reported its devices and none was a GPU. Definitive for both
// vendors, so skip the nvidia-smi exec, which cannot run in a CPU-only
// container anyway.
gpuHealth.status = 'passthrough_failed'
logger.warn(
`${expectedVendor === 'amd' ? 'AMD' : 'NVIDIA'} GPU expected but Ollama reported a CPU-only backend at startup`
)
} else if (expectedVendor === 'nvidia') {
// NVIDIA secondary path: nvidia-smi exec preserves prior behavior when
// the log parser hasn't seen a startup line yet (e.g. log rotation,
// very fresh container). Distinguishes "no Ollama container" from
// "container exists but GPU broken".
const nvidiaInfo = await this.getNvidiaSmiInfo()
if (Array.isArray(nvidiaInfo)) {
graphics.controllers = nvidiaInfo.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
useProbeControllers(toControllers(nvidiaInfo))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else if (nvidiaInfo === 'OLLAMA_NOT_FOUND') {
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else {
gpuHealth.status = 'ollama_not_installed'
}
} else {
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
useProbeControllers(toControllers(externalOllamaGpu))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else if (nvidiaInfo === 'OLLAMA_NOT_FOUND') {
gpuHealth.status = 'ollama_not_installed'
} else {
gpuHealth.status = 'passthrough_failed'
logger.warn(
@ -592,38 +596,46 @@ export class SystemService {
)
}
}
} else {
} else if (!ollama) {
// AMD path: no nvidia-smi equivalent worth running — log parser is authoritative.
// Distinguish "Ollama not running" from "Ollama running but no GPU log line".
const containers = await this.dockerService.docker.listContainers({ all: false })
const ollamaRunning = containers.some((c) =>
c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`)
)
if (!ollamaRunning) {
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else {
gpuHealth.status = 'ollama_not_installed'
}
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
useProbeControllers(toControllers(externalOllamaGpu))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else {
gpuHealth.status = 'passthrough_failed'
logger.warn(
'AMD GPU detected but Ollama logs show no ROCm initialization — passthrough or HSA override may have failed'
)
gpuHealth.status = 'ollama_not_installed'
}
} else {
gpuHealth.status = 'passthrough_failed'
logger.warn(
'AMD GPU detected but Ollama logs show no ROCm initialization — passthrough or HSA override may have failed'
)
}
// Tell the UI whether a reinstall would help or an HSA override is needed.
// Reinstalling an AMD container whose config already matches rebuilds the
// same CPU-bound container, so the banner must not offer that as the fix.
if (gpuHealth.status === 'passthrough_failed' && expectedVendor === 'amd' && ollama) {
Object.assign(
gpuHealth,
diagnoseAmdCpuFallback(
ollama.inspect,
ollamaLogs,
// Pass the container env so the diff matches what a reinstall would
// really build, including an override it keeps from the container (#1377).
await this.dockerService.getAmdHsaOverride({
quiet: true,
containerEnv: ollama.inspect.Config?.Env,
})
)
)
}
}
} else {
// si.graphics() returned controllers (host install, not Docker) — GPU is working
// si.graphics() returned usable controllers and no GPU runtime is expected
// (host install, not Docker). Still unprobed, see #1325.
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
}
@ -920,6 +932,12 @@ export class SystemService {
if (key === 'ai.assistantCustomName') {
invalidateAssistantNameCache()
}
if (key === 'rag.minRelevance') {
invalidateMinRelevanceCache()
}
if (key === 'ai.responseStyle') {
invalidateResponseStyleCache()
}
// Re-enabling auto-update after a backoff-driven auto-disable clears the
// failure state so it gets a fresh start instead of immediately re-tripping.
if (key === 'autoUpdate.enabled' && (value === true || value === 'true')) {
@ -960,6 +978,12 @@ export class SystemService {
const serviceStatusList = await this.dockerService.getServicesStatus()
for (const service of allServices) {
// Link tiles are shortcuts with no container behind them, so container
// reconciliation does not apply: they are always "installed" in the only
// sense that matters, which is that the dashboard should show them.
// Without this they are marked not-installed on the next sync and vanish.
if (service.is_link_tile) continue
const containerExists = serviceStatusList.find(
(s) => s.service_name === service.service_name
)

View File

@ -0,0 +1,123 @@
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import KVStore from '#models/kv_store'
import { clampRatio, updateEwma } from '../utils/token_estimate.js'
/**
* Learns, per model, how far the token estimator is off — and corrects it.
*
* The estimator in `token_estimate.ts` cannot tokenize exactly (no vocabulary,
* no tokenize endpoint, offline appliance). But every chat response already
* carries the exact answer: Ollama reports `prompt_eval_count`, the true token
* count of the prompt we just sent. That is free ground truth on every single
* turn, and before this it was thrown away.
*
* So: record what we estimated, compare it to what the backend actually counted,
* and fold `actual / estimated` into a per-model EWMA. The budget planner
* multiplies by that factor from then on. Measured convergence is a couple of
* turns, and it matters — Qwen tokenizes ~26% finer than Llama on the same text,
* which no single global constant can absorb.
*
* Persisted as one JSON blob (`ai.tokenRatios`) rather than a key per model,
* because KVStoreKey is a closed union. The data is pure cache: deleting the row
* costs nothing but a short re-learn.
*/
@inject()
export class TokenCalibrationService {
/** Model name -> learned correction factor. Authoritative once loaded. */
private ratios: Map<string, number> = new Map()
private loaded: Promise<void> | null = null
/** Last persisted value per model, so we only write when the ratio really moved. */
private persisted: Map<string, number> = new Map()
/**
* A ratio has to move by more than this before it earns a database write.
* Once converged the factor barely twitches, and a row update per chat turn
* would be pure noise on a machine that may be running from an SD card.
*/
private static PERSIST_THRESHOLD = 0.02
private async _load(): Promise<void> {
if (!this.loaded) {
this.loaded = (async () => {
try {
const raw = await KVStore.getValue('ai.tokenRatios')
if (!raw) return
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
for (const [model, value] of Object.entries(parsed)) {
if (typeof value === 'number' && Number.isFinite(value)) {
const clamped = clampRatio(value)
this.ratios.set(model, clamped)
this.persisted.set(model, clamped)
}
}
}
} catch (error) {
// Corrupt or hand-edited JSON. This is a cache; start over rather than
// failing a chat request over it.
logger.warn(
`[TokenCalibration] Could not read stored ratios, starting fresh: ${error instanceof Error ? error.message : error}`
)
}
})()
}
return this.loaded
}
/**
* The correction factor to apply when estimating for `model`.
* Returns 1 for a model we've never seen — an uncalibrated estimate, which is
* still materially better than a fixed chars-per-token divisor.
*/
async ratioFor(model: string): Promise<number> {
await this._load()
return this.ratios.get(model) ?? 1
}
/**
* Record one observation.
*
* `estimatedTokens` must be the *uncalibrated* estimate for exactly the
* messages that were sent — otherwise the factor gets applied twice and the
* EWMA chases its own tail.
*/
async record(model: string, estimatedTokens: number, actualTokens: number | undefined): Promise<void> {
if (!actualTokens || !estimatedTokens || estimatedTokens <= 0 || actualTokens <= 0) return
// Tiny prompts are dominated by chat-template overhead and teach us little
// about how this model tokenizes real content.
if (actualTokens < 32) return
await this._load()
const observed = clampRatio(actualTokens / estimatedTokens)
const previous = this.ratios.get(model) ?? null
const next = clampRatio(updateEwma(previous, observed))
this.ratios.set(model, next)
logger.debug(
`[TokenCalibration] ${model}: estimated ${estimatedTokens}, actual ${actualTokens} ` +
`(observed ${observed.toFixed(3)}, ratio ${next.toFixed(3)})`
)
const lastWritten = this.persisted.get(model)
if (lastWritten === undefined || Math.abs(next - lastWritten) > TokenCalibrationService.PERSIST_THRESHOLD) {
await this._persist(model, next)
}
}
private async _persist(model: string, ratio: number): Promise<void> {
try {
const blob: Record<string, number> = {}
for (const [name, value] of this.ratios) blob[name] = Number(value.toFixed(4))
await KVStore.setValue('ai.tokenRatios', JSON.stringify(blob))
this.persisted.set(model, ratio)
} catch (error) {
// Calibration is an optimization. Losing a write means a slightly worse
// estimate next boot, never a failed chat.
logger.warn(
`[TokenCalibration] Failed to persist ratios: ${error instanceof Error ? error.message : error}`
)
}
}
}

View File

@ -44,7 +44,7 @@ export class ZIMExtractionService {
async extractZIMContent(
filePath: string,
opts: ExtractZIMContentOptions = {}
): Promise<{ chunks: ZIMContentChunk[]; totalArticles: number }> {
): Promise<{ chunks: ZIMContentChunk[]; totalArticles: number; articlesProcessed: number }> {
try {
logger.info(`[ZIMExtractionService]: Processing ZIM file at path: ${filePath}`)
@ -165,7 +165,7 @@ export class ZIMExtractionService {
textPreview: c.text.substring(0, 100)
})))
logger.debug("Total structured sections extracted:", toReturn.length)
return { chunks: toReturn, totalArticles: archive.articleCount }
return { chunks: toReturn, totalArticles: archive.articleCount, articlesProcessed }
} catch (error) {
logger.error('Error processing ZIM file:', error)
throw error

View File

@ -1,4 +1,5 @@
import {
CatalogLanguage,
ListRemoteZimFilesResponse,
RawRemoteZimFileEntry,
RemoteZimFileEntry,
@ -33,6 +34,8 @@ import { SERVICE_NAMES } from '../../constants/service_names.js'
import { CollectionManifestService } from './collection_manifest_service.js'
import { KiwixCatalogService } from './kiwix_catalog_service.js'
import { KiwixLibraryService } from './kiwix_library_service.js'
import { RagService } from './rag_service.js'
import { OllamaService } from './ollama_service.js'
import type { CategoryWithStatus } from '../../types/collections.js'
import CustomLibrarySource from '#models/custom_library_source'
import { assertNotPrivateUrl } from '#validators/common'
@ -77,10 +80,18 @@ export class ZimService {
start,
count,
query,
language = 'eng',
}: {
start: number
count: number
query?: string
/**
* ISO-639-3 code to filter the catalog by, or `all` for no filter. Defaults to
* English, which is what this browser has always shown -- but it is now a default
* the user can change, not a hardcode. English is ~1,300 of the catalog's ~10,900
* books, so the filter was hiding the large majority of the library.
*/
language?: string
}): Promise<ListRemoteZimFilesResponse> {
// Kiwix returns pages of content unaware of what the user has installed locally. When
// the installed set is large, a single 12-item Kiwix page can come back with everything
@ -111,7 +122,8 @@ export class ZimService {
params: {
start: currentStart,
count: KIWIX_PAGE_SIZE,
lang: 'eng',
// `all` means "no filter", which the catalog expresses by omitting `lang`.
...(language && language !== 'all' ? { lang: language } : {}),
...(query ? { q: query } : {}),
},
responseType: 'text',
@ -191,6 +203,62 @@ export class ZimService {
}
}
/**
* The languages the Kiwix catalog actually holds books in, newest count first.
*
* Sourced live from the catalog rather than a bundled ISO list, so the options are
* always exactly what can be browsed, and each carries a real book count. Titles are
* the catalog's own endonyms ("français", "中文"), which is what a reader scanning for
* their own language recognises.
*
* Requires internet, like the rest of this browser. Failures return an empty list
* rather than throwing: a missing language filter should degrade to "English only",
* never take down the page that lists the books.
*/
async listCatalogLanguages(): Promise<CatalogLanguage[]> {
const LANGUAGES_URL = 'https://opds.library.kiwix.org/catalog/v2/languages'
try {
const res = await axios.get(LANGUAGES_URL, { responseType: 'text', timeout: 15000 })
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
textNodeName: '#text',
})
const parsed = parser.parse(res.data)
const rawEntries = parsed?.feed?.entry
? Array.isArray(parsed.feed.entry)
? parsed.feed.entry
: [parsed.feed.entry]
: []
const languages: CatalogLanguage[] = []
for (const raw of rawEntries) {
if (!raw || typeof raw !== 'object') continue
// `dc:language` is the ISO-639-3 code; `thr:count` is how many books carry it.
const code = raw['dc:language']
const label = raw.title
const bookCount = Number(raw['thr:count'])
if (typeof code !== 'string' || !code.trim()) continue
if (!Number.isFinite(bookCount) || bookCount <= 0) continue
languages.push({
code: code.trim(),
label: typeof label === 'string' && label.trim() ? label.trim() : code.trim(),
book_count: bookCount,
})
}
languages.sort((a, b) => b.book_count - a.book_count)
return languages
} catch (error) {
logger.warn(
`[ZimService] Catalog language list unavailable: ${
error instanceof Error ? error.message : error
}`
)
return []
}
}
async downloadRemote(url: string, metadata?: { title?: string; summary?: string; author?: string; size_bytes?: number }): Promise<{ filename: string; jobId?: string }> {
const parsed = new URL(url)
if (!parsed.pathname.endsWith('.zim')) {
@ -293,6 +361,26 @@ export class ZimService {
const status = await drugReferenceService.getIngestStatus()
if (status.phase === 'ready' || status.rowCount > 0) {
logger.info('[ZimService] Drug dataset already ingested, skipping dispatch.')
// Reaching here means no 'dataset' row exists (the installed filter
// above would have dropped the resource). A finished ingest that lost
// its row write (1.34.0's narrow enum, or a manual ingest) gets one now,
// so re-selecting the tier resolves it without waiting for a reboot.
if (status.phase === 'ready' && status.lastUpdated) {
try {
await drugReferenceService.recordInstalledRow({
version: status.lastUpdated,
collectionRef: categorySlug,
fileSizeBytes: null,
})
logger.info('[ZimService] Backfilled installed_resources row for the drug dataset.')
} catch (err) {
logger.error(
`[ZimService] Failed to backfill drug dataset row: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
continue
}
// DownloadDrugDataJob.dispatch() is idempotent on its deterministic
@ -659,6 +747,25 @@ export class ZimService {
await deleteFileIfExists(fullPath)
// Purge this file's Qdrant points and KbIngestState row directly so a
// deleted ZIM stops surfacing as a stale search citation immediately,
// rather than waiting for the next scanAndSyncStorage reverse sweep to
// catch it (#1170). Never touched Qdrant here before — the file's points
// and state row would otherwise linger indefinitely.
//
// Guarded on the AI Assistant being installed at all — otherwise every
// delete on an install without it hits _initializeQdrantClient()'s
// "offline" throw and logs a misleading error for the common no-AI case.
const qdrantInstalled = !!(await this.dockerService.getServiceURL(SERVICE_NAMES.QDRANT))
if (qdrantInstalled) {
try {
const ragService = new RagService(this.dockerService, new OllamaService())
await ragService.purgeIndexedSource(fullPath)
} catch (err) {
logger.error(`[ZimService] Failed to purge knowledge-base entries for ${fullPath}:`, err)
}
}
// Remove from kiwix library XML so --monitorLibrary stops serving the deleted file
const kiwixLibraryService = new KiwixLibraryService()
await kiwixLibraryService.removeBook(fileName).catch((err) => {

View File

@ -50,3 +50,57 @@ export function mapGfxToHsaOverride(gfx: string): string | null {
// Unknown/newer target: prefer native discovery over a coercion that's likely wrong.
return null
}
const HSA_OVERRIDE_ENV_PREFIX = 'HSA_OVERRIDE_GFX_VERSION='
const HSA_OVERRIDE_VALUE_RE = /^\d+\.\d+\.\d+$/
/** The raw HSA_OVERRIDE_GFX_VERSION in a container env, or null when it is not set. */
export function readHsaOverrideFromEnv(env: string[] | null | undefined): string | null {
const entry = (env ?? []).find((e) => e.startsWith(HSA_OVERRIDE_ENV_PREFIX))
return entry ? entry.slice(HSA_OVERRIDE_ENV_PREFIX.length) : null
}
export type AmdHsaOverrideResolution = {
value: string | null
source: 'kv' | 'kv-disabled' | 'marker' | 'container' | 'default'
/** Set when the KV held something unusable and was skipped. */
invalidManual?: string
}
/**
* Pick the HSA_OVERRIDE_GFX_VERSION an AMD install should apply. Resolution order:
* 1. KV `ai.amdHsaOverride`: a version forces it, 'none'/'off'/'false' disables it.
* 2. The gfx marker install_nomad.sh writes, mapped through mapGfxToHsaOverride. A
* marker is authoritative even when it maps to none.
* 3. The override the existing container already runs with (#1377). Upgraded installs
* that got it by hand or from an older install path have neither 1 nor 2, and
* dropping a working override would push the GPU back to CPU.
* 4. None, letting ROCm discover the GPU natively.
*/
export function pickAmdHsaOverride(input: {
manual: unknown
markerGfx: string | null
containerEnv?: string[] | null
}): AmdHsaOverrideResolution {
let invalidManual: string | undefined
if (input.manual !== null && input.manual !== undefined && String(input.manual).trim() !== '') {
const manual = String(input.manual).trim().toLowerCase()
if (manual === 'none' || manual === 'off' || manual === 'false') {
return { value: null, source: 'kv-disabled' }
}
if (HSA_OVERRIDE_VALUE_RE.test(manual)) return { value: manual, source: 'kv' }
invalidManual = String(input.manual)
}
const extra = invalidManual !== undefined ? { invalidManual } : {}
if (input.markerGfx !== null) {
return { value: mapGfxToHsaOverride(input.markerGfx), source: 'marker', ...extra }
}
const existing = readHsaOverrideFromEnv(input.containerEnv)?.trim()
if (existing && HSA_OVERRIDE_VALUE_RE.test(existing)) {
return { value: existing, source: 'container', ...extra }
}
return { value: null, source: 'default', ...extra }
}

View File

@ -0,0 +1,125 @@
import type { MultipartFile } from '@adonisjs/bodyparser/types'
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.js'
import sharp from 'sharp'
export const MAX_CHAT_IMAGES = 4
export const MAX_CHAT_IMAGE_BYTES = 8 * 1024 * 1024
export const MAX_CHAT_IMAGE_PIXELS = 40_000_000
export const MAX_NORMALIZED_IMAGE_BYTES = 4 * 1024 * 1024
export const MAX_NORMALIZED_IMAGE_DIMENSION = 2048
const SUPPORTED_IMAGE_FORMATS = new Set(['jpeg', 'png', 'webp'])
export class ChatImageError extends Error {
constructor(
message: string,
public readonly status: 413 | 415 | 422
) {
super(message)
this.name = 'ChatImageError'
}
}
export type NormalizedChatImage = {
name: string
dataUrl: string
}
export async function normalizeChatImages(files: MultipartFile[]): Promise<NormalizedChatImage[]> {
if (files.length > MAX_CHAT_IMAGES) {
throw new ChatImageError(`Attach no more than ${MAX_CHAT_IMAGES} images.`, 422)
}
const normalized: NormalizedChatImage[] = []
for (const file of files) {
normalized.push(await normalizeChatImage(file))
}
return normalized
}
async function normalizeChatImage(file: MultipartFile): Promise<NormalizedChatImage> {
if (!file.tmpPath) {
throw new ChatImageError(`Could not process "${file.clientName}".`, 422)
}
if (file.size > MAX_CHAT_IMAGE_BYTES) {
throw new ChatImageError(`"${file.clientName}" exceeds the 8 MB per-image limit.`, 413)
}
if (!file.isValid) {
throw new ChatImageError(`"${file.clientName}" is not a valid image upload.`, 422)
}
try {
const pipeline = sharp(file.tmpPath, {
animated: false,
failOn: 'warning',
limitInputPixels: MAX_CHAT_IMAGE_PIXELS,
})
const metadata = await pipeline.metadata()
if (!metadata.format || !SUPPORTED_IMAGE_FORMATS.has(metadata.format)) {
throw new ChatImageError(
`"${file.clientName}" is not supported. Use JPEG, PNG, or WebP.`,
415
)
}
if ((metadata.pages ?? 1) > 1) {
throw new ChatImageError(`Animated images are not supported.`, 415)
}
const normalized = await pipeline
.rotate()
.resize({
width: MAX_NORMALIZED_IMAGE_DIMENSION,
height: MAX_NORMALIZED_IMAGE_DIMENSION,
fit: 'inside',
withoutEnlargement: true,
})
.flatten({ background: '#ffffff' })
.jpeg({ quality: 85 })
.toBuffer()
if (normalized.byteLength > MAX_NORMALIZED_IMAGE_BYTES) {
throw new ChatImageError(
`"${file.clientName}" remains too large after image processing.`,
413
)
}
return {
name: file.clientName,
dataUrl: `data:image/jpeg;base64,${normalized.toString('base64')}`,
}
} catch (error) {
if (error instanceof ChatImageError) throw error
throw new ChatImageError(`"${file.clientName}" could not be decoded as an image.`, 422)
}
}
export function attachImagesToLatestUserMessage(
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
images: NormalizedChatImage[]
): ChatCompletionMessageParam[] {
if (images.length === 0) return messages
let latestUserIndex = -1
messages.forEach((message, index) => {
if (message.role === 'user') latestUserIndex = index
})
if (latestUserIndex < 0) {
throw new ChatImageError('Images require a user message.', 422)
}
return messages.map((message, index) => {
if (index !== latestUserIndex) return message
return {
role: 'user',
content: [
{ type: 'text', text: message.content },
...images.map((image) => ({
type: 'image_url' as const,
image_url: { url: image.dataUrl },
})),
],
}
})
}

View File

@ -0,0 +1,321 @@
/**
* Decides what actually goes into the prompt, and what gets left out.
*
* Before this existed, nothing trimmed anything: the browser posted the whole
* session, the pipeline copied it verbatim, and overflow was left to the
* backend. That is the worst possible arrangement, because backend truncation is
* *silent* and *positional* — llama.cpp drops from the middle of the window, so
* what gets discarded is conversation history and retrieved knowledge-base
* context, while the model gives no indication anything is missing. Measured on
* a live Ollama, a 7,000-token prompt came back having processed 2,060 tokens,
* and the model confabulated an answer from the fragment that survived.
*
* So the rule here is: never hand the backend more than it can hold. Decide
* explicitly what to drop, drop it in a way the model can reason about, and
* record what happened so the eval harness can see it.
*
* Allocation order, most protected first:
* 1. Response reserve — a floor held back for the answer itself. The actual
* generation cap is whatever the window has left once
* the prompt is assembled, which is usually far more.
* 2. Fixed blocks — system prompts and the current question. Never dropped.
* 3. Retrieved context — whole chunks, best-first, until its share is spent.
* 4. History — whole turns, newest-first, into whatever remains.
*
* Pure and dependency-free so it runs under bare `node --test`, the same
* convention as `rag_prompt.ts`.
*/
import { estimateMessagesTokens, estimateTokens } from './token_estimate.js'
export type BudgetRole = 'system' | 'user' | 'assistant'
export type BudgetMessage = { role: BudgetRole; content: string }
/** A retrieved chunk, already ranked — index 0 is the best match. */
export type BudgetChunk = { text: string; metadata?: Record<string, any> }
/**
* Where the per-turn retrieved-context block goes.
*
* `tail` places it immediately before the current question, leaving
* [system][history] as a byte-identical prefix across turns so the backend can
* reuse its KV cache. `front` is the historical behaviour: a system message
* ahead of all history, which changes content every turn and therefore
* invalidates the cached prefix for the entire conversation behind it.
*
* The common advice to "inject RAG into the system prompt" assumes the context
* is fixed for the session (an attached document), where a fixed position does
* preserve the prefix. When context is re-retrieved every turn, the opposite is
* true. Both are kept so `eval:generation` can measure the difference rather
* than the choice resting on argument alone.
*/
export type RagPlacement = 'tail' | 'front'
export type BudgetInputs = {
/** Stable system blocks (NOMAD.md, formatting rules), in final order. */
systemBlocks: BudgetMessage[]
/** Prior conversation, oldest first, excluding the current question. */
history: BudgetMessage[]
/** The current user message. Never dropped. */
query: BudgetMessage
/** Retrieved chunks, best first. May be empty. */
ragChunks: BudgetChunk[]
/** Renders surviving chunks into the final context message. */
renderRagBlock: (chunks: BudgetChunk[]) => string
/** The resolved context window, in tokens. */
contextWindow: number
/** Learned per-model token-estimator correction. */
ratio?: number
ragPlacement?: RagPlacement
/** Overrides for the response reserve and RAG share; mostly for tests. */
responseReserve?: number
ragShare?: number
}
export type BudgetTrace = {
contextWindow: number
responseReserve: number
promptBudget: number
estimatedPromptTokens: number
systemTokens: number
queryTokens: number
ragTokens: number
historyTokens: number
chunksKept: number
chunksDropped: number
turnsKept: number
turnsDropped: number
historyElided: boolean
queryTruncated: boolean
ragPlacement: RagPlacement
numPredict: number
}
export type BudgetResult = {
messages: BudgetMessage[]
trace: BudgetTrace
/**
* What to send as num_predict: the space the window actually has left after
* the prompt, less a margin for estimator error. Never below the reserve.
*/
numPredict: number
}
/**
* Ceiling on tokens reserved for the answer.
*
* This only bounds how much prompt space is held back; it is not the
* generation cap. It used to double as one, which pinned every answer at 1024
* tokens regardless of window size and cut long answers off mid-sentence
* (#1342).
*/
export const MAX_RESPONSE_RESERVE = 8192
/** Share of the window reserved for the answer when that is smaller. */
export const RESPONSE_RESERVE_FRACTION = 0.25
/**
* Headroom left between the estimated prompt and the end of the window when
* sizing num_predict. The estimator is calibrated per model but still only an
* estimate; undershooting here means llama.cpp context-shifts mid-answer.
*/
export const NUM_PREDICT_SAFETY_FRACTION = 0.05
export const NUM_PREDICT_SAFETY_MIN = 64
/** Share of the remaining budget the retrieved context may claim. */
export const DEFAULT_RAG_SHARE = 0.35
/**
* Marker inserted where turns were dropped.
*
* Worth the handful of tokens: without it the model sees a conversation that
* appears to begin mid-thought and has no way to tell that it is missing
* context, which is exactly when small models start confidently inventing what
* "we discussed earlier".
*/
export const ELISION_MARKER =
'[Earlier messages in this conversation have been omitted to fit the context window.]'
/**
* Turns are dropped in blocks rather than one at a time.
*
* Dropping a single turn per request would shift the prompt prefix on *every*
* subsequent turn, invalidating the backend's KV cache each time — trading a
* small saving in tokens for a full re-prefill. Dropping several at once means
* the prefix then stays stable for several turns.
*/
export const HISTORY_EVICTION_BLOCK = 4
/**
* Group a flat message list into whole turns.
*
* A turn is a user message plus everything that answers it. Truncating on a
* message boundary instead can leave the transcript starting with an assistant
* reply to a question the model can no longer see — which reads, to the model,
* as though it said something unprompted.
*/
export function groupIntoTurns(history: BudgetMessage[]): BudgetMessage[][] {
const turns: BudgetMessage[][] = []
for (const message of history) {
if (message.role === 'user' || turns.length === 0) {
turns.push([message])
} else {
turns[turns.length - 1].push(message)
}
}
return turns
}
/**
* Plan the prompt.
*
* Never returns a message array estimated to exceed `contextWindow -
* responseReserve`, except in the degenerate case where the fixed blocks alone
* are too large — and in that case the query is truncated visibly rather than
* being handed to the backend to cut silently.
*/
export function planPrompt(inputs: BudgetInputs): BudgetResult {
const ratio = inputs.ratio ?? 1
const placement = inputs.ragPlacement ?? 'tail'
const contextWindow = inputs.contextWindow
const responseReserve =
inputs.responseReserve ??
Math.max(256, Math.min(MAX_RESPONSE_RESERVE, Math.floor(contextWindow * RESPONSE_RESERVE_FRACTION)))
const promptBudget = Math.max(0, contextWindow - responseReserve)
const cost = (messages: BudgetMessage[]) => estimateMessagesTokens(messages, ratio)
// --- Fixed blocks: system prompts and the question --------------------
const systemTokens = cost(inputs.systemBlocks)
let query = inputs.query
let queryTokens = cost([query])
let queryTruncated = false
// Degenerate case: the system prompts plus the question alone overflow the
// window. Truncating here is a bad outcome, but it is a *visible* one — the
// alternative is the backend silently cutting the question in half.
if (systemTokens + queryTokens > promptBudget) {
const available = Math.max(0, promptBudget - systemTokens)
if (available > 0) {
const perToken = query.content.length / Math.max(1, estimateTokens(query.content, ratio))
const keepChars = Math.max(0, Math.floor(available * perToken) - ELISION_MARKER.length)
query = {
...query,
content:
query.content.slice(0, keepChars) +
'\n\n[This message was truncated because it exceeds the context window.]',
}
queryTokens = cost([query])
queryTruncated = true
}
}
let remaining = promptBudget - systemTokens - queryTokens
// --- Retrieved context: whole chunks, best first ----------------------
//
// Whole chunks, deliberately. The previous behaviour capped on a running
// character count, which could hand the model a chunk cut off mid-sentence —
// the worst of both worlds, since it costs tokens without carrying a complete
// fact. A chunk that doesn't fit is dropped, not shortened.
const ragBudget = Math.max(0, Math.floor(Math.max(0, remaining) * (inputs.ragShare ?? DEFAULT_RAG_SHARE)))
const keptChunks: BudgetChunk[] = []
let ragTokens = 0
if (inputs.ragChunks.length > 0 && ragBudget > 0) {
for (const chunk of inputs.ragChunks) {
const candidate = [...keptChunks, chunk]
const rendered = inputs.renderRagBlock(candidate)
const candidateTokens = cost([{ role: 'system', content: rendered }])
if (candidateTokens <= ragBudget) {
keptChunks.push(chunk)
ragTokens = candidateTokens
}
// Keep scanning: a later, smaller chunk may still fit where this one didn't.
}
}
const ragMessage: BudgetMessage | null =
keptChunks.length > 0
? { role: 'system', content: inputs.renderRagBlock(keptChunks) }
: null
remaining -= ragTokens
// --- History: whole turns, newest first -------------------------------
const turns = groupIntoTurns(inputs.history)
const keptTurns: BudgetMessage[][] = []
let historyTokens = 0
for (let i = turns.length - 1; i >= 0; i--) {
const turnTokens = cost(turns[i])
if (historyTokens + turnTokens > remaining) break
keptTurns.unshift(turns[i])
historyTokens += turnTokens
}
const turnsDropped = turns.length - keptTurns.length
// Chunky eviction: once we're dropping anything, drop a whole block, so the
// surviving prefix stays put for the next several turns instead of shifting
// on every request and forcing a re-prefill each time.
if (turnsDropped > 0 && keptTurns.length > 0) {
const overshoot = turnsDropped % HISTORY_EVICTION_BLOCK
const extra = overshoot === 0 ? 0 : HISTORY_EVICTION_BLOCK - overshoot
for (let i = 0; i < extra && keptTurns.length > 1; i++) {
const removed = keptTurns.shift()!
historyTokens -= cost(removed)
}
}
const historyElided = keptTurns.length < turns.length
const historyMessages = keptTurns.flat()
// --- Assemble, stability-descending -----------------------------------
const messages: BudgetMessage[] = []
messages.push(...inputs.systemBlocks)
if (historyElided) {
messages.push({ role: 'system', content: ELISION_MARKER })
}
if (placement === 'front' && ragMessage) {
// Historical ordering, kept only so the two can be compared under eval.
messages.splice(inputs.systemBlocks.length, 0, ragMessage)
messages.push(...historyMessages)
} else {
messages.push(...historyMessages)
if (ragMessage) messages.push(ragMessage)
}
messages.push(query)
const estimatedPromptTokens = cost(messages)
// The planner already kept the prompt within `contextWindow - responseReserve`,
// so the reserve floor can never push generation past the window.
const safetyMargin = Math.max(
NUM_PREDICT_SAFETY_MIN,
Math.ceil(estimatedPromptTokens * NUM_PREDICT_SAFETY_FRACTION)
)
const numPredict = Math.max(responseReserve, contextWindow - estimatedPromptTokens - safetyMargin)
return {
messages,
numPredict,
trace: {
contextWindow,
responseReserve,
promptBudget,
estimatedPromptTokens,
systemTokens,
queryTokens,
ragTokens,
historyTokens,
chunksKept: keptChunks.length,
chunksDropped: inputs.ragChunks.length - keptChunks.length,
turnsKept: keptTurns.length,
turnsDropped: turns.length - keptTurns.length,
historyElided,
queryTruncated,
ragPlacement: placement,
numPredict,
},
}
}

View File

@ -0,0 +1,282 @@
/**
* Pure helpers for deciding how large a model's context window should be.
*
* Kept out of OllamaService so they can run under bare `node --test` with no
* Ollama, Docker, or MySQL — the same convention as `rag_prompt.ts`. The service
* supplies the facts (what /api/show said, how much memory is free); everything
* here is a function of its arguments.
*/
/**
* Ladder of context sizes we're willing to request.
*
* Snapping to a ladder is not cosmetic. Ollama unloads and reloads a model
* whenever a request asks for a different `num_ctx` than the loaded instance, so
* a continuously-varying window would stall a turn and throw away the KV cache
* every time the conversation grew. Snapping (plus per-model memoization in the
* resolver) keeps the value constant for a session.
*/
export const CONTEXT_LADDER = [4096, 8192, 16384, 32768, 65536, 131072] as const
/** Never go below this: it is roughly a system prompt plus a few real turns. */
export const MIN_CONTEXT = 4096
/**
* Fallback window when the backend can't tell us anything — a non-Ollama
* OpenAI-compatible server, or /api/show unavailable. Deliberately modest:
* budgeting too small wastes capacity, budgeting too large silently truncates.
*/
export const UNKNOWN_BACKEND_CONTEXT = 8192
/**
* Read the trained context length out of an /api/show `model_info` blob.
*
* Keys are architecture-prefixed (`llama.context_length`, `qwen3.context_length`,
* `gemma3.context_length`, ...), so prefer the prefix named by
* `general.architecture` and fall back to any `*.context_length` key. Ollama's
* client types this as a Map but the wire format is a plain object; handle both.
*/
export function readContextLength(modelInfo: unknown): number | undefined {
if (!modelInfo) return undefined
const entries: [string, any][] =
modelInfo instanceof Map
? [...modelInfo.entries()]
: typeof modelInfo === 'object'
? Object.entries(modelInfo as Record<string, any>)
: []
if (entries.length === 0) return undefined
const lookup = new Map(entries)
const arch = lookup.get('general.architecture')
if (typeof arch === 'string') {
const exact = lookup.get(`${arch}.context_length`)
if (typeof exact === 'number' && exact > 0) return exact
}
for (const [key, value] of entries) {
if (key.endsWith('.context_length') && typeof value === 'number' && value > 0) {
return value
}
}
return undefined
}
/**
* Pull `num_ctx` out of the `parameters` string /api/show returns (the modelfile
* PARAMETER lines, e.g. `num_ctx 8192`). When a model
* author baked in a window, that is a deliberate signal worth respecting as a
* ceiling hint.
*/
export function readModelfileNumCtx(parameters: unknown): number | undefined {
if (typeof parameters !== 'string') return undefined
const match = parameters.match(/^\s*num_ctx\s+(\d+)\s*$/m)
if (!match) return undefined
const value = Number.parseInt(match[1], 10)
return Number.isFinite(value) && value > 0 ? value : undefined
}
/**
* Parse Ollama's `details.parameter_size` ("8.0B", "1.5B", "70B") into billions.
* Falls back to the tag-name regex — which is what the codebase used to rely on
* exclusively, and which guesses wrong on names like "phi3" that carry no size.
*/
export function parseParameterBillions(
parameterSize: string | undefined,
modelName?: string
): number | undefined {
const fromDetails = parameterSize?.match(/([\d.]+)\s*B/i)
if (fromDetails) {
const value = Number.parseFloat(fromDetails[1])
if (Number.isFinite(value)) return value
}
const fromName = modelName?.match(/(\d+\.?\d*)\s*[bB](?![a-zA-Z])/)
if (fromName) {
const value = Number.parseFloat(fromName[1])
if (Number.isFinite(value)) return value
}
return undefined
}
/** Round a token count down to the largest ladder rung that fits. */
export function snapToLadder(tokens: number): number {
let chosen = MIN_CONTEXT
for (const rung of CONTEXT_LADDER) {
if (rung <= tokens) chosen = rung
}
return chosen
}
// ---------------------------------------------------------------------------
// How much memory a context window actually costs
// ---------------------------------------------------------------------------
/**
* Bytes per element in the KV cache.
*
* Budgeted at f16 (2 bytes) even though the Ollama container is started with
* `OLLAMA_KV_CACHE_TYPE=q8_0`. Ollama silently falls back to f16 on
* architectures that don't support cache quantization, so treating q8_0 as
* guaranteed would double the real cost of every window we hand out. Quantized
* cache is headroom, not a budgeting assumption.
*/
const KV_BYTES_PER_ELEMENT = 2
/**
* Exact KV-cache cost per token, from the model's own GGUF metadata.
*
* 2 (K and V) x layers x kv_heads x head_dim x bytes_per_element
*
* For llama3:8b that is 2 x 32 x 8 x 128 x 2 = 128 KiB per token, so an 8k
* window costs 1 GiB — which is why "just set num_ctx high" is not free advice
* on the hardware NOMAD targets.
*
* Returns undefined when the metadata is missing (non-Ollama backend, or an
* architecture that names its keys differently), leaving the caller to fall back
* to `estimateKvBytesPerToken`.
*/
export function computeKvBytesPerToken(modelInfo: unknown): number | undefined {
if (!modelInfo) return undefined
const entries: [string, any][] =
modelInfo instanceof Map
? [...modelInfo.entries()]
: typeof modelInfo === 'object'
? Object.entries(modelInfo as Record<string, any>)
: []
if (entries.length === 0) return undefined
const info = new Map(entries)
const arch = info.get('general.architecture')
if (typeof arch !== 'string') return undefined
const num = (suffix: string): number | undefined => {
const value = info.get(`${arch}.${suffix}`)
return typeof value === 'number' && value > 0 ? value : undefined
}
const layers = num('block_count')
// Grouped-query attention means KV heads are usually far fewer than attention
// heads; using head_count here would overestimate cost several-fold.
const kvHeads = num('attention.head_count_kv') ?? num('attention.head_count')
if (!layers || !kvHeads) return undefined
// Prefer explicit key/value lengths; otherwise derive head_dim from the
// embedding width and the attention head count.
let headDim = num('attention.key_length')
if (!headDim) {
const embedding = num('embedding_length')
const heads = num('attention.head_count')
if (embedding && heads) headDim = embedding / heads
}
if (!headDim) return undefined
return 2 * layers * kvHeads * headDim * KV_BYTES_PER_ELEMENT
}
/**
* Fallback KV cost per token when GGUF metadata is unavailable, interpolated
* from the common architectures at each size. Deliberately pessimistic — an
* overestimate costs a smaller window, an underestimate costs an OOM at load.
*/
export function estimateKvBytesPerToken(paramBillions: number | undefined): number {
const b = paramBillions ?? 8
if (b <= 1.5) return 32 * 1024
if (b <= 4) return 64 * 1024
if (b <= 9) return 128 * 1024
if (b <= 15) return 192 * 1024
if (b <= 35) return 256 * 1024
return 512 * 1024
}
/**
* Fraction of memory left free for weights-adjacent overhead: compute buffers,
* activations, the graph, and whatever else shares the device. Without this the
* arithmetic says a window fits right up to the byte and the model fails to load.
*/
const MEMORY_SAFETY_FRACTION = 0.8
export type ContextWindowInputs = {
/** Trained context length from /api/show. The hard ceiling. */
modelMaxCtx?: number
/** num_ctx baked into the modelfile, treated as an author's ceiling hint. */
modelfileNumCtx?: number
/** Cost of one token of KV cache, exact or estimated. */
kvBytesPerToken: number
/** Memory available for the model, in bytes (VRAM, or a share of system RAM). */
availableBytes?: number
/** On-disk size of the weights, in bytes — they occupy the same pool. */
modelBytes?: number
/** User cap from `ai.contextWindow`. Only ever lowers the result. */
userCap?: number
}
export type ContextWindowDecision = {
contextWindow: number
/** Which constraint actually bound the result — surfaced in logs and the UI. */
limitedBy: 'user' | 'model' | 'memory' | 'floor' | 'default'
affordableTokens?: number
}
/**
* Decide the context window for a model.
*
* The result must be *stable for the life of the process*: Ollama unloads and
* reloads a model whenever a request asks for a different num_ctx than the
* loaded instance, which stalls the turn and discards the KV cache. Callers
* memoize per model; this function is pure so that memoization is safe.
*/
export function resolveContextWindow(inputs: ContextWindowInputs): ContextWindowDecision {
const ceilings: Array<{ value: number; source: ContextWindowDecision['limitedBy'] }> = []
if (inputs.modelMaxCtx && inputs.modelMaxCtx > 0) {
ceilings.push({ value: inputs.modelMaxCtx, source: 'model' })
}
if (inputs.modelfileNumCtx && inputs.modelfileNumCtx > 0) {
ceilings.push({ value: inputs.modelfileNumCtx, source: 'model' })
}
if (inputs.userCap && inputs.userCap > 0) {
ceilings.push({ value: inputs.userCap, source: 'user' })
}
let affordableTokens: number | undefined
if (inputs.availableBytes && inputs.availableBytes > 0 && inputs.kvBytesPerToken > 0) {
const forKv = inputs.availableBytes * MEMORY_SAFETY_FRACTION - (inputs.modelBytes ?? 0)
affordableTokens = Math.max(0, Math.floor(forKv / inputs.kvBytesPerToken))
ceilings.push({ value: affordableTokens, source: 'memory' })
}
if (ceilings.length === 0) {
return { contextWindow: UNKNOWN_BACKEND_CONTEXT, limitedBy: 'default' }
}
const binding = ceilings.reduce((lowest, c) => (c.value < lowest.value ? c : lowest))
const snapped = snapToLadder(binding.value)
// The floor wins over a *memory* shortfall: a window under 4k is not worth
// having, and Ollama will offload to host RAM rather than refuse. It must
// never win over the model itself, though — asking for more than the trained
// context pushes RoPE past what the weights ever saw and degrades output.
// tinyllama (trained 2048) is the case that catches this.
const hardCeiling = Math.min(
...[inputs.modelMaxCtx, inputs.modelfileNumCtx, inputs.userCap].filter(
(v): v is number => typeof v === 'number' && v > 0
),
Number.POSITIVE_INFINITY
)
if (snapped <= MIN_CONTEXT) {
const floored = Math.min(MIN_CONTEXT, hardCeiling)
return {
contextWindow: floored,
limitedBy: floored < MIN_CONTEXT ? 'model' : 'floor',
affordableTokens,
}
}
return { contextWindow: snapped, limitedBy: binding.source, affordableTokens }
}
/** Parse the `ai.contextWindow` setting. "auto"/unset/garbage all mean "no cap". */
export function parseUserContextCap(raw: string | null | undefined): number | undefined {
if (!raw || raw === 'auto') return undefined
const value = Number.parseInt(raw, 10)
return Number.isFinite(value) && value >= MIN_CONTEXT ? value : undefined
}

View File

@ -0,0 +1,66 @@
import type { ZimCategoriesSpec } from '../../types/collections.js'
/**
* Observed state of the FDA drug dataset, gathered at admin boot by
* DrugInstallRowProvider.
*/
export interface DrugRowReconcileInput {
/** Rows in `drug_labels`. */
rowCount: number
/** Whether the `installed_resources` 'dataset' row already exists. */
hasInstallRow: boolean
/** KV `drugReference.lastUpdatedExportDate`; only the final ingest pass sets it. */
lastUpdatedExportDate: string | null
/** Whether KV `drugReference.downloadState` still holds a marker. */
hasDownloadMarker: boolean
/** Whether either drug queue has an active/waiting/delayed job. */
jobInFlight: boolean
}
export type DrugRowReconcileDecision =
| { action: 'backfill'; version: string }
| { action: 'skip'; reason: string }
/**
* Decide whether a completed drug ingest is missing its `installed_resources`
* row and should get one.
*
* On 1.34.0 the column was still enum('zim','map'), so every ingest finished
* `ready` with 261k labels and a failed row write. The tier-status math reads
* that row, so Medicine never resolved its Standard tier. The same state also
* follows a manual ingest (no resourceMeta), including a reset-and-reingest.
*
* Backfill only when the ingest provably finished: the export-date marker is
* written by the final pass, and the download marker is cleared right after it
* (the same "completed" inference getIngestStatus() makes). A running job is
* left alone, since it writes its own row on `ready`.
*/
export function decideDrugRowReconcile(input: DrugRowReconcileInput): DrugRowReconcileDecision {
if (input.hasInstallRow) return { action: 'skip', reason: 'install row present' }
if (input.rowCount <= 0) return { action: 'skip', reason: 'no drug labels ingested' }
if (input.jobInFlight) return { action: 'skip', reason: 'drug download/ingest in flight' }
if (input.hasDownloadMarker) {
return { action: 'skip', reason: 'download marker present (ingest not finished)' }
}
const version = input.lastUpdatedExportDate?.trim()
if (!version) return { action: 'skip', reason: 'no export date (ingest never completed)' }
return { action: 'backfill', version }
}
/**
* Slug of the curated category whose tiers carry the given dataset resource,
* for the row's `collection_ref`. Null when the spec is absent or no tier lists it.
*/
export function findDatasetCategorySlug(
spec: ZimCategoriesSpec | null,
resourceId: string
): string | null {
for (const category of spec?.categories ?? []) {
for (const tier of category.tiers ?? []) {
if ((tier.resources ?? []).some((r) => r.type === 'dataset' && r.id === resourceId)) {
return category.slug
}
}
}
return null
}

View File

@ -0,0 +1,38 @@
import { basename, join, resolve, sep } from 'node:path'
/** Where the frozen corpus lives, relative to the app root. */
export const EVAL_CORPUS_DIR = 'tests/eval/corpus'
/**
* Map a retrieved chunk's `source` payload back to its corpus document id, or
* null if the chunk did not come from the eval corpus.
*
* The eval corpus shares the `nomad_knowledge_base` Qdrant collection with the
* developer's real documents — NOMAD "collections" are a payload tag, not
* separate Qdrant collections — and is isolated by a
* `collection: __nomad_eval__` filter that Qdrant applies during search. That
* filter does hold, but a harness whose every number depends on a filter should
* be able to *prove* the filter held rather than assume it. This function is
* that proof: the caller counts every unresolved chunk and fails the run if the
* count is non-zero.
*
* The check is on the resolved **path**, not the file extension. An earlier
* version accepted any `*.md`, which was worse than useless here: NOMAD embeds
* its own `admin/docs/*.md` into the knowledge base on first run, so a leaked
* `faq.md` would have resolved to the plausible-looking document id "faq", been
* counted as a merely-irrelevant chunk, and quietly depressed precision with no
* indication that anything had gone wrong.
*
* Mirrors the resolve-then-prefix-check guard in `RagService.resolveUploadPath`.
* `corpusDir` is injectable so this stays testable without a real corpus on disk.
*/
export function docIdFromSource(source: unknown, corpusDir?: string): string | null {
if (typeof source !== 'string') return null
const dir = resolve(corpusDir ?? join(process.cwd(), EVAL_CORPUS_DIR))
const abs = resolve(source)
// The trailing separator matters: without it, a sibling directory such as
// "…/corpus-backup" shares a string prefix with "…/corpus" and would pass.
if (!abs.startsWith(dir + sep)) return null
if (!abs.endsWith('.md')) return null
return basename(abs, '.md')
}

View File

@ -0,0 +1,254 @@
/**
* Deterministic scorers for generated answers.
*
* Every metric here is computable offline with no judge model, which is what
* makes the generation tier gateable. An LLM judge can be layered on later for
* the things regex genuinely cannot see (nuanced faithfulness, completeness),
* but nothing in this file needs one — and a number you can compute without a
* model is a number that cannot drift because the judge changed.
*
* These are intentionally *conservative*. Each one is a proxy, and each proxy's
* blind spot is documented where it lives. A proxy you understand the limits of
* beats a score you cannot explain.
*/
/** Answer-level scoring of one generated response against one golden. */
export type GenerationScores = {
/** Every mustInclude pattern matched and no mustNotInclude pattern did. */
correct: boolean
matchedRequired: string[]
missedRequired: string[]
/** Forbidden patterns that appeared. Non-empty means a specific wrong claim. */
hitForbidden: string[]
/** The model declined to answer. */
refused: boolean
/**
* Refusal behaviour was right: declined an out-of-corpus question, or
* answered an answerable one.
*/
refusalCorrect: boolean
/** The answer narrated its own retrieval ("according to Context 1"). */
leakage: string[]
/** Reasoning tags survived into the user-visible answer. */
thinkTagLeak: boolean
/**
* Fraction of the answer's numeric claims that also appear in the injected
* context. null when the answer makes no numeric claims.
*/
numericGroundedness: number | null
/** Numbers asserted by the answer that the context does not support. */
ungroundedNumbers: string[]
/** The answer used at least some markdown structure. */
markdownFormatted: boolean
/** Characters. Useful for spotting a model that answers by writing an essay. */
length: number
}
/**
* Phrases that mean "I am not answering this".
*
* Tuned against NOMAD's actual rag_context prompt, which instructs the model to
* answer from general knowledge rather than hedge — so a hedge here is either a
* correct refusal on an out-of-corpus question or a prompt regression on an
* answerable one. Which of those it is, is exactly what `refusalCorrect` says.
*/
const REFUSAL_PATTERNS: RegExp[] = [
/\bi (?:don'?t|do not) (?:know|have)\b/i,
/\bi(?:'m| am) (?:not able|unable) to\b/i,
/\bi (?:couldn'?t|could not|can'?t|cannot) find\b/i,
/\b(?:no|not enough) (?:information|details|data)\b/i,
/\b(?:does|do) not (?:contain|include|mention|specify|provide|cover)\b/i,
/\b(?:isn'?t|is not|aren'?t|are not) (?:mentioned|specified|covered|available|provided)\b/i,
/\bnot (?:mentioned|specified|covered|stated|documented)\b/i,
/\bunable to (?:answer|determine|find)\b/i,
/\bthere (?:is|'s) no (?:information|mention|record)\b/i,
// "I'd" has no space before the contraction, so this cannot reuse the
// `i (?:would|'d)` shape used above.
/\bi(?:'d| would) need more\b/i,
]
/**
* Phrases that narrate the retrieval machinery.
*
* The rag_context prompt explicitly forbids these ("Never narrate your
* retrieval or reasoning process"), so any hit is a measurable prompt
* regression. This is the cheapest, sharpest signal in the whole harness: pure
* regex, zero ambiguity, and it catches a bad prompt edit on the first run.
*/
const LEAKAGE_PATTERNS: RegExp[] = [
/\b(?:according to|based on|per|from) the (?:provided |retrieved |given |supplied )?context\b/i,
/\bcontext \d+\b/i,
/\bthe knowledge ?base\b/i,
/\bthe (?:provided|retrieved|supplied|given) (?:documents?|passages?|excerpts?|text)\b/i,
/\bthe context (?:does not|doesn'?t|is|was|seems|appears)\b/i,
/\b(?:in|from) the (?:documents?|passages?) (?:provided|above|below)\b/i,
// "I wasn't able to find specific context regarding X, but here's a general
// answer" is the verbatim symptom that started NOMAD's RAG work. It is both a
// refusal and a leak, and it is the single most important string this
// detector has to catch — a prompt change that brings it back must fail
// loudly on the very next run.
/\b(?:no|any|specific|relevant) context\b/i,
/\bsearch results?\b/i,
]
/** Reasoning-model tags that must never reach the user. */
const THINK_TAG = /<\/?(?:think|thought|thinking|reasoning)\b[^>]*>/i
export function detectRefusal(answer: string): boolean {
return REFUSAL_PATTERNS.some((re) => re.test(answer))
}
export function detectLeakage(answer: string): string[] {
return LEAKAGE_PATTERNS.filter((re) => re.test(answer)).map((re) => re.source)
}
export function hasThinkTagLeak(answer: string): boolean {
return THINK_TAG.test(answer)
}
/**
* Loose markdown check: a header, a list, emphasis, a table, or a code fence.
*
* Deliberately loose. SYSTEM_PROMPTS.default asks for markdown "for
* readability", not for a specific structure, so requiring headers would fail
* perfectly good one-sentence answers. This only catches a model that has
* stopped formatting entirely.
*/
export function isMarkdownFormatted(answer: string): boolean {
return /(^|\n)\s{0,3}#{1,6}\s|(^|\n)\s*[-*+]\s|(^|\n)\s*\d+\.\s|\*\*[^*]+\*\*|`[^`]+`|(^|\n)\s*\|/.test(
answer
)
}
/**
* Numeric tokens the answer asserts, normalized for comparison.
*
* Numbers are the highest-value fabrication signal in this domain: a wrong
* boiling time, bleach dose, or canner pressure is a wrong answer with real
* consequences, and it is exactly the kind of specific the rag_context prompt
* forbids inventing.
*
* Thousands separators are stripped so "2,000" and "2000" compare equal.
*/
export function extractNumbers(text: string): string[] {
const matches = text.match(/\d[\d,]*(?:\.\d+)?/g) ?? []
const normalized = matches.map((m) => m.replace(/,/g, '').replace(/\.0+$/, ''))
return [...new Set(normalized)]
}
/**
* Fraction of the answer's numeric claims that the injected context supports.
*
* Returns null when the answer contains no numbers — a qualitative answer is
* not ungrounded, it is just not measurable this way, and folding it in as 1.0
* would quietly inflate the score.
*
* **Known limitation, stated plainly:** this only sees numbers. An answer that
* fabricates a procedure or a proper noun scores a perfect 1.0 here. It is a
* fabrication *detector*, not a faithfulness *guarantee* — the LLM-judge tier
* exists for the rest. Small integers (0-10) are excluded because they appear
* incidentally in almost any prose ("3 layers", "step 2") and would swamp the
* signal with false grounding.
*/
export function numericGroundedness(
answer: string,
context: string
): { score: number | null; ungrounded: string[] } {
const contextNumbers = new Set(extractNumbers(context))
const claimed = extractNumbers(answer).filter((n) => {
const value = Number.parseFloat(n)
return !Number.isNaN(value) && value > 10
})
if (claimed.length === 0) return { score: null, ungrounded: [] }
const ungrounded = claimed.filter((n) => !contextNumbers.has(n))
return { score: (claimed.length - ungrounded.length) / claimed.length, ungrounded }
}
/** Which of a golden's patterns matched, using case-insensitive regex semantics. */
export function matchPatterns(answer: string, patterns: string[]): { matched: string[]; missed: string[] } {
const matched: string[] = []
const missed: string[] = []
for (const pattern of patterns) {
if (new RegExp(pattern, 'i').test(answer)) matched.push(pattern)
else missed.push(pattern)
}
return { matched, missed }
}
export type ScoreAnswerInput = {
answer: string
/** The context text actually injected into the prompt; '' when none was. */
context: string
mustInclude: string[]
mustNotInclude: string[]
expectRefusal: boolean
}
export function scoreAnswer(input: ScoreAnswerInput): GenerationScores {
const { answer, context, mustInclude, mustNotInclude, expectRefusal } = input
const required = matchPatterns(answer, mustInclude)
const forbidden = matchPatterns(answer, mustNotInclude)
const refused = detectRefusal(answer)
const grounding = numericGroundedness(answer, context)
return {
// An out-of-corpus question has no mustInclude patterns, so `correct` there
// is carried entirely by refusalCorrect below rather than by assertions.
correct: required.missed.length === 0 && forbidden.matched.length === 0,
matchedRequired: required.matched,
missedRequired: required.missed,
hitForbidden: forbidden.matched,
refused,
refusalCorrect: expectRefusal ? refused : !refused,
leakage: detectLeakage(answer),
thinkTagLeak: hasThinkTagLeak(answer),
numericGroundedness: grounding.score,
ungroundedNumbers: grounding.ungrounded,
markdownFormatted: isMarkdownFormatted(answer),
length: answer.length,
}
}
// --- aggregation over repeats -------------------------------------------------
export type RepeatStats = {
/** How many of N repeats passed. */
passes: number
repeats: number
passRate: number
/**
* True when the outcome was neither always-pass nor always-fail. Unstable
* cases are excluded from gating — treating a coin flip as a regression is
* how a harness loses the team's trust in one afternoon.
*/
unstable: boolean
}
export function summarizeRepeats(outcomes: boolean[]): RepeatStats {
const repeats = outcomes.length
const passes = outcomes.filter(Boolean).length
return {
passes,
repeats,
passRate: repeats === 0 ? 0 : passes / repeats,
unstable: repeats > 1 && passes > 0 && passes < repeats,
}
}
export type NumericSummary = { mean: number; stddev: number; n: number }
/**
* Mean and population standard deviation, ignoring nulls.
*
* The stddev is not decoration: with temperature 0 it should be near zero, and
* a non-trivial value is the harness telling you the run is noisier than the
* difference you are about to interpret.
*/
export function summarizeNumeric(values: Array<number | null>): NumericSummary | null {
const defined = values.filter((v): v is number => v !== null && Number.isFinite(v))
if (defined.length === 0) return null
const mean = defined.reduce((a, b) => a + b, 0) / defined.length
const variance = defined.reduce((acc, v) => acc + (v - mean) ** 2, 0) / defined.length
return { mean, stddev: Math.sqrt(variance), n: defined.length }
}

View File

@ -0,0 +1,190 @@
/**
* Loading and validating the golden question set, plus the corpus fingerprint.
*
* Pure and dependency-free so it runs under bare node in unit tests. Callers
* supply file contents; nothing here touches the filesystem except through the
* explicit `readFile`-shaped arguments the service passes in.
*/
/** A prior conversation turn, for multi-turn / coreference cases. */
export type GoldenTurn = { role: 'user' | 'assistant'; content: string }
export type Golden = {
/** Stable identifier. Used as the join key across reports and baselines. */
id: string
/** The user's question, as they would actually type it. */
query: string
/** Conversation history preceding `query`. Empty for single-turn cases. */
turns: GoldenTurn[]
/**
* Documents that genuinely answer the question, by corpus doc id (the
* markdown filename without its extension). Empty for out-of-corpus cases.
*/
relevantDocIds: string[]
/**
* Patterns the answer must contain. Each entry is a case-insensitive
* **regular expression** — plain text is a valid regex, and alternation lets
* one entry accept "3 minutes" or "three minutes" without inflating the list.
*/
mustInclude: string[]
/** Patterns the answer must NOT contain. Same regex semantics. */
mustNotInclude: string[]
/**
* True when the corpus genuinely cannot answer the question and the correct
* behaviour is to decline rather than invent. Scored as refusal-correctness.
*/
expectRefusal: boolean
/** Free-form buckets for per-slice reporting (single-hop, acronym, ...). */
tags: string[]
}
export class GoldenSetError extends Error {}
/**
* Parse a JSONL golden file, validating hard enough that a typo fails loudly at
* load rather than silently scoring zero for the rest of the project's life.
*/
export function parseGoldens(jsonl: string, sourceName = 'goldens'): Golden[] {
const goldens: Golden[] = []
const seen = new Set<string>()
jsonl.split('\n').forEach((rawLine, idx) => {
const line = rawLine.trim()
if (!line || line.startsWith('//')) return
const where = `${sourceName}:${idx + 1}`
let parsed: any
try {
parsed = JSON.parse(line)
} catch (err) {
throw new GoldenSetError(`${where}: not valid JSON — ${(err as Error).message}`)
}
const req = (field: string) => {
if (parsed[field] === undefined) throw new GoldenSetError(`${where}: missing "${field}"`)
return parsed[field]
}
const id = req('id')
if (typeof id !== 'string' || !id) throw new GoldenSetError(`${where}: "id" must be a non-empty string`)
if (seen.has(id)) throw new GoldenSetError(`${where}: duplicate id "${id}"`)
seen.add(id)
const query = req('query')
if (typeof query !== 'string' || !query.trim()) {
throw new GoldenSetError(`${where}: "query" must be a non-empty string`)
}
const strArray = (field: string): string[] => {
const v = parsed[field] ?? []
if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) {
throw new GoldenSetError(`${where}: "${field}" must be an array of strings`)
}
return v
}
const mustInclude = strArray('mustInclude')
const mustNotInclude = strArray('mustNotInclude')
// Compile every pattern now. A bad regex that only blows up on the one run
// where it finally matches is far worse than one that fails at load.
for (const pattern of [...mustInclude, ...mustNotInclude]) {
try {
new RegExp(pattern, 'i')
} catch (err) {
throw new GoldenSetError(`${where}: invalid regex ${JSON.stringify(pattern)} — ${(err as Error).message}`)
}
}
const turns = (parsed.turns ?? []) as GoldenTurn[]
if (!Array.isArray(turns) || turns.some((t) => t?.role !== 'user' && t?.role !== 'assistant')) {
throw new GoldenSetError(`${where}: "turns" must be an array of {role: user|assistant, content}`)
}
const expectRefusal = Boolean(parsed.expectRefusal)
const relevantDocIds = strArray('relevantDocIds')
if (expectRefusal && relevantDocIds.length > 0) {
throw new GoldenSetError(
`${where}: "${id}" expects a refusal but also lists relevant documents — one of those is wrong`
)
}
if (!expectRefusal && relevantDocIds.length === 0) {
throw new GoldenSetError(
`${where}: "${id}" lists no relevant documents and does not expect a refusal — it can never be scored`
)
}
goldens.push({
id,
query,
turns,
relevantDocIds,
mustInclude,
mustNotInclude,
expectRefusal,
tags: strArray('tags'),
})
})
if (goldens.length === 0) throw new GoldenSetError(`${sourceName}: no goldens found`)
return goldens
}
/**
* Every doc id a golden refers to must exist in the corpus, or recall is being
* computed against a target that can never be hit.
*/
export function assertGoldensMatchCorpus(goldens: Golden[], corpusDocIds: Iterable<string>): void {
const corpus = new Set(corpusDocIds)
const missing = new Set<string>()
for (const g of goldens) {
for (const docId of g.relevantDocIds) if (!corpus.has(docId)) missing.add(docId)
}
if (missing.size > 0) {
throw new GoldenSetError(
`goldens reference documents that are not in the corpus: ${[...missing].sort().join(', ')}`
)
}
}
/**
* Inputs to the corpus fingerprint. Anything that changes what ends up in the
* vector store belongs here — if it changes, prior reports are not comparable.
*/
export type FingerprintInputs = {
/** docId -> raw file contents. */
documents: Map<string, string>
chunkTokens: number
chunkOverlapTokens: number
charToTokenRatio: number
embeddingModel: string
embeddingDimension: number
}
/**
* A stable hash over the corpus and the ingest parameters that shaped it.
*
* Reports carry this. Comparing two reports with different fingerprints is
* meaningless, and the compare command refuses to do it — which is the whole
* point: it makes "I changed the chunk size and the score moved" impossible to
* confuse with "I changed the prompt and the score moved".
*
* `hash` is injected so this stays pure and testable; the service passes a
* node:crypto sha256.
*/
export function computeCorpusFingerprint(
inputs: FingerprintInputs,
hash: (input: string) => string
): string {
const docIds = [...inputs.documents.keys()].sort()
const parts: string[] = [
`chunkTokens=${inputs.chunkTokens}`,
`chunkOverlapTokens=${inputs.chunkOverlapTokens}`,
`charToTokenRatio=${inputs.charToTokenRatio}`,
`embeddingModel=${inputs.embeddingModel}`,
`embeddingDimension=${inputs.embeddingDimension}`,
]
for (const id of docIds) {
parts.push(`doc=${id}\n${inputs.documents.get(id)}`)
}
return hash(parts.join('\n---\n')).slice(0, 16)
}

View File

@ -0,0 +1,22 @@
import logger from '@adonisjs/core/services/logger'
/**
* Turn down the application logger for the duration of an eval run.
*
* RagService logs every keyword extraction, every batch, and the text of every
* chunk at debug level. That is the right default for diagnosing an ingest, and
* completely wrong for a tool whose entire output is a score table — the
* numbers scroll off the top of the terminal before you can read them.
*
* Returns a restore function so the level is put back even if the caller throws.
* Pass `debug: true` (from `--debug`) to leave the logger alone when you are
* actually trying to see the pipeline internals.
*/
export function quietLogging(debug = false): () => void {
if (debug) return () => {}
const previous = logger.level
logger.level = 'warn'
return () => {
logger.level = previous
}
}

View File

@ -0,0 +1,264 @@
/**
* Report shape, baseline diffing, and the gate decision.
*
* Pure functions over plain objects — the service layer does the file I/O. The
* comparison logic in particular has to be testable without a filesystem,
* because it is the piece that decides whether a pull request is blocked.
*/
export type ReportKind = 'retrieval' | 'generation'
/**
* Provenance. Every field here exists to answer "is this report comparable to
* that one?", and `corpusFingerprint` is the one that can veto the comparison
* outright.
*/
export type ReportMeta = {
kind: ReportKind
createdAt: string
corpusFingerprint: string
gitSha: string | null
gitBranch: string | null
gitDirty: boolean
nomadVersion: string | null
/** Host platform. Recorded for context; never used to justify a score. */
platform: {
cpuArchitecture: string | null
osName: string | null
nodeVersion: string
}
params: Record<string, unknown>
}
/**
* A report is metadata plus a flat bag of named metrics plus the per-case
* detail. Flattening the metrics is deliberate: it means the diff logic does
* not need to know anything about retrieval versus generation, so adding a
* metric later requires no change to the comparison or the gate.
*/
export type EvalReport = {
meta: ReportMeta
/** metric name -> value. null means "not measurable in this run". */
metrics: Record<string, number | null>
/** tag -> metric name -> value. */
byTag: Record<string, Record<string, number | null>>
/** Per-question detail, for the "what did it actually say" question. */
cases: unknown[]
}
/**
* Whether a metric going up is good.
*
* Without this the diff cannot tell an improvement from a regression, and a
* leakage rate falling to zero would be reported as a failure.
*/
export type MetricDirection = 'higher-is-better' | 'lower-is-better'
export const METRIC_DIRECTIONS: Record<string, MetricDirection> = {
// retrieval
'recall@1': 'higher-is-better',
'recall@3': 'higher-is-better',
'recall@5': 'higher-is-better',
'recall@10': 'higher-is-better',
'hitRate@1': 'higher-is-better',
'hitRate@3': 'higher-is-better',
'hitRate@5': 'higher-is-better',
'hitRate@10': 'higher-is-better',
'precision@1': 'higher-is-better',
'precision@3': 'higher-is-better',
'precision@5': 'higher-is-better',
'precision@10': 'higher-is-better',
'ndcg@1': 'higher-is-better',
'ndcg@3': 'higher-is-better',
'ndcg@5': 'higher-is-better',
'ndcg@10': 'higher-is-better',
mrr: 'higher-is-better',
emptyRateOnAnswerable: 'lower-is-better',
nonEmptyRateOnRefusal: 'lower-is-better',
// generation
correctness: 'higher-is-better',
refusalCorrectness: 'higher-is-better',
leakageRate: 'lower-is-better',
thinkTagLeakRate: 'lower-is-better',
markdownRate: 'higher-is-better',
groundedness: 'higher-is-better',
// context budgeting
//
// These are diagnostics as much as gates. A rise in historyElidedRate means
// conversations are being trimmed more often, which explains a correctness
// drop that would otherwise look like the model getting worse. promptTokenError
// gates the token estimator itself: every budget decision rests on it, so a
// regression there quietly degrades everything downstream.
historyElidedRate: 'lower-is-better',
chunksDroppedRate: 'lower-is-better',
promptTokenError: 'lower-is-better',
}
export type MetricDelta = {
metric: string
baseline: number | null
current: number | null
delta: number | null
direction: MetricDirection
/** Signed so that positive always means "better", whatever the direction. */
improvement: number | null
regressed: boolean
/** Present in one report but not the other. */
onlyIn?: 'baseline' | 'current'
}
export type ComparisonResult = {
comparable: boolean
/** Why the comparison was refused, when it was. */
incomparableReason?: string
tolerance: number
deltas: MetricDelta[]
regressions: MetricDelta[]
improvements: MetricDelta[]
}
/**
* Default tolerance band.
*
* Not zero, on purpose. Even at temperature 0 the generation tier moves a
* little between runs, and a gate that fires on 0.001 gets switched off within
* a week. 0.02 is roughly "one question in fifty" on a 99-question set — small
* enough to catch a real regression, large enough to ignore a coin flip.
*/
export const DEFAULT_TOLERANCE = 0.02
/**
* Slack for floating-point representation error at the tolerance boundary.
*
* Without it, `0.9 - 0.02` evaluates to a delta of -0.020000000000000018, which
* is "greater than the tolerance" by 1.8e-17 and would block a pull request. A
* gate that fires on the last bit of a double is a gate people learn to ignore.
*/
const BOUNDARY_EPSILON = 1e-9
export function compareReports(
baseline: EvalReport,
current: EvalReport,
tolerance: number = DEFAULT_TOLERANCE
): ComparisonResult {
if (baseline.meta.kind !== current.meta.kind) {
return incomparable(
`cannot compare a ${baseline.meta.kind} report against a ${current.meta.kind} report`,
tolerance
)
}
// The hard veto. Different corpora, chunk sizes, or embedding models mean the
// two numbers were produced by different experiments, and diffing them would
// manufacture a regression (or hide one) out of nothing.
if (baseline.meta.corpusFingerprint !== current.meta.corpusFingerprint) {
return incomparable(
`corpus fingerprint changed (${baseline.meta.corpusFingerprint} -> ${current.meta.corpusFingerprint}). ` +
'The corpus, chunk size, or embedding model differs, so these runs are not measuring the same thing. Re-baseline instead.',
tolerance
)
}
const names = [...new Set([...Object.keys(baseline.metrics), ...Object.keys(current.metrics)])].sort()
const deltas: MetricDelta[] = names.map((metric) => {
const b = baseline.metrics[metric] ?? null
const c = current.metrics[metric] ?? null
const direction = METRIC_DIRECTIONS[metric] ?? 'higher-is-better'
const onlyIn =
!(metric in baseline.metrics) ? ('current' as const)
: !(metric in current.metrics) ? ('baseline' as const)
: undefined
if (b === null || c === null) {
return { metric, baseline: b, current: c, delta: null, direction, improvement: null, regressed: false, onlyIn }
}
const delta = c - b
const improvement = direction === 'higher-is-better' ? delta : -delta
return {
metric,
baseline: b,
current: c,
delta,
direction,
improvement,
regressed: improvement < -(tolerance + BOUNDARY_EPSILON),
onlyIn,
}
})
return {
comparable: true,
tolerance,
deltas,
regressions: deltas.filter((d) => d.regressed),
improvements: deltas.filter(
(d) => d.improvement !== null && d.improvement > tolerance + BOUNDARY_EPSILON
),
}
}
function incomparable(reason: string, tolerance: number): ComparisonResult {
return {
comparable: false,
incomparableReason: reason,
tolerance,
deltas: [],
regressions: [],
improvements: [],
}
}
/** Flatten a `Record<number, number|null>` into `name@k` keys. */
export function flattenByK(
prefix: string,
values: Record<number, number | null>
): Record<string, number | null> {
return Object.fromEntries(Object.entries(values).map(([k, v]) => [`${prefix}@${k}`, v]))
}
const pct = (v: number | null) => (v === null ? 'n/a' : v.toFixed(4))
/** Human-readable summary, worst regressions first. */
export function renderComparisonMarkdown(
baselinePath: string,
currentPath: string,
result: ComparisonResult
): string {
const lines: string[] = ['# Eval comparison', '']
lines.push(`- baseline: \`${baselinePath}\``)
lines.push(`- current: \`${currentPath}\``)
lines.push(`- tolerance: ${result.tolerance}`)
lines.push('')
if (!result.comparable) {
lines.push('## Not comparable', '', result.incomparableReason ?? 'unknown reason', '')
return lines.join('\n')
}
lines.push(
result.regressions.length > 0
? `## ${result.regressions.length} regression(s)`
: '## No regressions'
)
lines.push('')
lines.push('| metric | baseline | current | delta | verdict |')
lines.push('|---|---:|---:|---:|---|')
// Worst first — the thing a developer needs is at the top, not sorted
// alphabetically halfway down a wall of unchanged rows.
const ordered = [...result.deltas].sort((a, b) => (a.improvement ?? 0) - (b.improvement ?? 0))
for (const d of ordered) {
const verdict =
d.onlyIn === 'current' ? 'new'
: d.onlyIn === 'baseline' ? 'removed'
: d.delta === null ? '—'
: d.regressed ? '**REGRESSED**'
: (d.improvement ?? 0) > result.tolerance ? 'improved'
: 'within tolerance'
const arrow = d.delta === null ? 'n/a' : `${d.delta >= 0 ? '+' : ''}${d.delta.toFixed(4)}`
lines.push(`| ${d.metric} | ${pct(d.baseline)} | ${pct(d.current)} | ${arrow} | ${verdict} |`)
}
lines.push('')
return lines.join('\n')
}

View File

@ -0,0 +1,356 @@
/**
* Information-retrieval metrics for the RAG eval harness.
*
* Pure functions over ranked results — no I/O, no models, no clock. Everything
* here is deterministic, which is the point: a change in these numbers is
* unambiguously a change in the code, never in the weather.
*
* ## Two levels of measurement, and why both
*
* Retrieval returns *chunks*, but a golden answer lives in a *document*. The
* two questions we care about are different, so they are measured differently:
*
* - **Document level** (recall, MRR, nDCG): "did the answer's document make it
* into the context at all, and how near the top?" Chunks are collapsed to
* their document, keeping each document's best rank.
* - **Chunk level** (precision): "how much of what we injected is noise?" This
* is deliberately *not* deduped — five chunks from one irrelevant document
* cost five slots of a small model's context and should be counted five
* times.
*
* Reporting only one of these hides a real failure mode in the other.
*/
/** One retrieved chunk, reduced to what scoring needs. */
export type ScoredChunk = {
/** Corpus document this chunk came from; null if it could not be resolved. */
docId: string | null
/** The pipeline's final (post-rerank) score. */
score: number
/** The raw pre-rerank cosine score, when available. */
semanticScore?: number
}
export type RetrievalCase = {
id: string
tags: string[]
/** Ranked chunks, best first, exactly as the pipeline returned them. */
retrieved: ScoredChunk[]
/** Documents that genuinely answer the question. */
relevantDocIds: string[]
/** True when the right behaviour is to retrieve nothing useful. */
expectRefusal: boolean
}
/**
* Collapse a ranked chunk list to a ranked document list, keeping each
* document's best (earliest) position. Chunks with no resolvable document are
* dropped — they still occupy a rank in the chunk-level metrics, but they
* cannot be credited to any document.
*/
export function toDocumentRanking(retrieved: ScoredChunk[]): string[] {
const seen = new Set<string>()
const ranking: string[] = []
for (const chunk of retrieved) {
if (!chunk.docId || seen.has(chunk.docId)) continue
seen.add(chunk.docId)
ranking.push(chunk.docId)
}
return ranking
}
/**
* Fraction of the relevant documents that appear within the top `k` chunks.
*
* Returns null when there are no relevant documents (an out-of-corpus case) —
* recall is undefined there, and returning 0 would drag the mean down for
* questions that are *supposed* to retrieve nothing.
*/
export function recallAtK(retrieved: ScoredChunk[], relevantDocIds: string[], k: number): number | null {
if (relevantDocIds.length === 0) return null
const relevant = new Set(relevantDocIds)
const found = new Set<string>()
for (const chunk of retrieved.slice(0, k)) {
if (chunk.docId && relevant.has(chunk.docId)) found.add(chunk.docId)
}
return found.size / relevant.size
}
/**
* 1 if *any* relevant document appears in the top `k`, else 0.
*
* Distinct from recall on multi-hop questions: finding one of two required
* documents is a hit but only 0.5 recall. Reporting both is what separates
* "found something" from "found enough to answer".
*/
export function hitRateAtK(retrieved: ScoredChunk[], relevantDocIds: string[], k: number): number | null {
if (relevantDocIds.length === 0) return null
const relevant = new Set(relevantDocIds)
return retrieved.slice(0, k).some((c) => c.docId && relevant.has(c.docId)) ? 1 : 0
}
/**
* Fraction of the top `k` *chunks* that come from a relevant document.
*
* Not deduped, on purpose — this measures context pollution, and a small model
* drowning in four irrelevant chunks does not care that they share a source.
* Denominator is min(k, retrieved.length) so a pipeline returning 2 good chunks
* is not punished for the 3 it correctly declined to return.
*/
export function precisionAtK(retrieved: ScoredChunk[], relevantDocIds: string[], k: number): number | null {
if (relevantDocIds.length === 0) return null
const window = retrieved.slice(0, k)
if (window.length === 0) return 0
const relevant = new Set(relevantDocIds)
const hits = window.filter((c) => c.docId && relevant.has(c.docId)).length
return hits / window.length
}
/**
* Reciprocal of the rank of the first relevant chunk (1-indexed); 0 if none.
* Averaged over cases this is MRR.
*/
export function reciprocalRank(retrieved: ScoredChunk[], relevantDocIds: string[]): number | null {
if (relevantDocIds.length === 0) return null
const relevant = new Set(relevantDocIds)
const idx = retrieved.findIndex((c) => c.docId && relevant.has(c.docId))
return idx === -1 ? 0 : 1 / (idx + 1)
}
/**
* Normalized discounted cumulative gain at `k`, over the **document** ranking,
* with binary gain.
*
* DCG@k = Σ_{i=1..k} rel_i / log2(i + 1)
* IDCG@k = Σ_{i=1..min(R, k)} 1 / log2(i + 1) where R = |relevant|
* nDCG@k = DCG@k / IDCG@k
*
* The ideal ranking is defined against the *known* number of relevant documents
* rather than against however many happened to be retrieved. That distinction
* matters: normalizing against the retrieved set would score a run that found
* one of three required documents, and ranked it first, as a perfect 1.0.
*
* This is the metric that catches "right documents, wrong order" — a reranking
* regression that leaves recall untouched while pushing the answer to position
* five, where a 1B model with a 2-result budget will never see it.
*/
export function ndcgAtK(retrieved: ScoredChunk[], relevantDocIds: string[], k: number): number | null {
if (relevantDocIds.length === 0) return null
const relevant = new Set(relevantDocIds)
const docRanking = toDocumentRanking(retrieved).slice(0, k)
let dcg = 0
docRanking.forEach((docId, i) => {
if (relevant.has(docId)) dcg += 1 / Math.log2(i + 2) // i is 0-indexed, so rank = i + 1
})
let idcg = 0
for (let i = 0; i < Math.min(relevant.size, k); i++) {
idcg += 1 / Math.log2(i + 2)
}
return idcg === 0 ? 0 : dcg / idcg
}
/**
* Score separation: the gap between what relevant and irrelevant chunks score.
*
* This is how the similarity threshold gets calibrated with evidence instead of
* intuition. If the relevant p10 sits below the irrelevant p90, no threshold
* can cleanly separate them and the honest conclusion is that the *retriever*
* needs work, not the cutoff.
*
* ## Two axes, because there are two cutoffs
*
* A chunk carries two scores and they are not interchangeable:
*
* - the **semantic** score is raw cosine, and it is what Qdrant's
* `score_threshold` filters on — before reranking ever runs;
* - the **final** score is what reranking and source diversity produced, and it
* is what `RAG_MIN_FINAL_SCORE` filters on.
*
* Reranking's boosts are score-scaled and additive (up to ~1.275x), so the two
* distributions sit at different heights. Calibrating one cutoff against the
* other axis' percentiles is off by exactly that factor, which is why both are
* reported separately rather than collapsed into one number.
*/
export type ScoreDistribution = {
count: number
min: number
p10: number
median: number
p90: number
max: number
mean: number
}
export function describeScores(scores: number[]): ScoreDistribution | null {
if (scores.length === 0) return null
const sorted = [...scores].sort((a, b) => a - b)
const pct = (p: number) => {
// Nearest-rank percentile: no interpolation, so a reported value is always
// a value that actually occurred.
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
return sorted[idx]
}
return {
count: sorted.length,
min: sorted[0],
p10: pct(10),
median: pct(50),
p90: pct(90),
max: sorted[sorted.length - 1],
mean: sorted.reduce((a, b) => a + b, 0) / sorted.length,
}
}
/** Mean over the entries that are actually defined; null if none are. */
export function meanOf(values: Array<number | null>): number | null {
const defined = values.filter((v): v is number => v !== null)
if (defined.length === 0) return null
return defined.reduce((a, b) => a + b, 0) / defined.length
}
export type RetrievalCaseResult = {
id: string
tags: string[]
retrievedCount: number
relevantDocIds: string[]
retrievedDocIds: string[]
expectRefusal: boolean
recall: Record<number, number | null>
hitRate: Record<number, number | null>
precision: Record<number, number | null>
ndcg: Record<number, number | null>
reciprocalRank: number | null
/** True when this case retrieved nothing at all. */
empty: boolean
/**
* The highest-ranked chunk's scores, or null when nothing was retrieved. Shown
* for out-of-corpus leaks: the gap between the two says how much of a false
* positive the reranker's lexical boosts supplied.
*/
top: { score: number; semanticScore: number | null } | null
}
export type RetrievalAggregate = {
cases: number
/** Cases with at least one relevant document (i.e. excluding refusal cases). */
answerable: number
recall: Record<number, number | null>
hitRate: Record<number, number | null>
precision: Record<number, number | null>
ndcg: Record<number, number | null>
mrr: number | null
/**
* Fraction of *answerable* questions that retrieved nothing. A non-zero value
* means the score threshold is filtering out real answers.
*/
emptyRateOnAnswerable: number | null
/**
* Fraction of *out-of-corpus* questions that retrieved something anyway. This
* is the other half of the threshold trade-off: context handed to the model
* for a question the corpus cannot answer is exactly what produces a
* confident, wrong reply.
*/
nonEmptyRateOnRefusal: number | null
/** Semantic (raw cosine) scores — the axis Qdrant's score_threshold filters on. */
relevantScores: ScoreDistribution | null
irrelevantScores: ScoreDistribution | null
/** Post-rerank scores — the axis RAG_MIN_FINAL_SCORE filters on. */
relevantFinalScores: ScoreDistribution | null
irrelevantFinalScores: ScoreDistribution | null
}
export const DEFAULT_K_VALUES = [1, 3, 5, 10]
export function scoreCase(c: RetrievalCase, kValues: number[] = DEFAULT_K_VALUES): RetrievalCaseResult {
const byK = <T>(fn: (k: number) => T): Record<number, T> =>
Object.fromEntries(kValues.map((k) => [k, fn(k)]))
return {
id: c.id,
tags: c.tags,
retrievedCount: c.retrieved.length,
relevantDocIds: c.relevantDocIds,
retrievedDocIds: toDocumentRanking(c.retrieved),
expectRefusal: c.expectRefusal,
recall: byK((k) => recallAtK(c.retrieved, c.relevantDocIds, k)),
hitRate: byK((k) => hitRateAtK(c.retrieved, c.relevantDocIds, k)),
precision: byK((k) => precisionAtK(c.retrieved, c.relevantDocIds, k)),
ndcg: byK((k) => ndcgAtK(c.retrieved, c.relevantDocIds, k)),
reciprocalRank: reciprocalRank(c.retrieved, c.relevantDocIds),
empty: c.retrieved.length === 0,
top:
c.retrieved.length === 0
? null
: { score: c.retrieved[0].score, semanticScore: c.retrieved[0].semanticScore ?? null },
}
}
export function aggregate(
cases: RetrievalCase[],
results: RetrievalCaseResult[],
kValues: number[] = DEFAULT_K_VALUES
): RetrievalAggregate {
const answerable = results.filter((r) => !r.expectRefusal)
const refusals = results.filter((r) => r.expectRefusal)
const byK = (pick: (r: RetrievalCaseResult, k: number) => number | null): Record<number, number | null> =>
Object.fromEntries(kValues.map((k) => [k, meanOf(results.map((r) => pick(r, k)))]))
// Split every retrieved chunk's score by whether its document was relevant.
// Refusal cases contribute only to the irrelevant side — by definition
// nothing they retrieve is relevant, and that is precisely the population a
// threshold needs to exclude.
const relevantScores: number[] = []
const irrelevantScores: number[] = []
const relevantFinalScores: number[] = []
const irrelevantFinalScores: number[] = []
for (const c of cases) {
const relevant = new Set(c.relevantDocIds)
for (const chunk of c.retrieved) {
const isRelevant = Boolean(chunk.docId && relevant.has(chunk.docId))
;(isRelevant ? relevantScores : irrelevantScores).push(chunk.semanticScore ?? chunk.score)
;(isRelevant ? relevantFinalScores : irrelevantFinalScores).push(chunk.score)
}
}
return {
cases: results.length,
answerable: answerable.length,
recall: byK((r, k) => r.recall[k]),
hitRate: byK((r, k) => r.hitRate[k]),
precision: byK((r, k) => r.precision[k]),
ndcg: byK((r, k) => r.ndcg[k]),
mrr: meanOf(results.map((r) => r.reciprocalRank)),
emptyRateOnAnswerable:
answerable.length === 0 ? null : answerable.filter((r) => r.empty).length / answerable.length,
nonEmptyRateOnRefusal:
refusals.length === 0 ? null : refusals.filter((r) => !r.empty).length / refusals.length,
relevantScores: describeScores(relevantScores),
irrelevantScores: describeScores(irrelevantScores),
relevantFinalScores: describeScores(relevantFinalScores),
irrelevantFinalScores: describeScores(irrelevantFinalScores),
}
}
/** Aggregate restricted to cases carrying a given tag, for per-slice reporting. */
export function aggregateByTag(
cases: RetrievalCase[],
results: RetrievalCaseResult[],
kValues: number[] = DEFAULT_K_VALUES
): Record<string, RetrievalAggregate> {
const tags = new Set(results.flatMap((r) => r.tags))
const byId = new Map(cases.map((c) => [c.id, c]))
const out: Record<string, RetrievalAggregate> = {}
for (const tag of [...tags].sort()) {
const tagged = results.filter((r) => r.tags.includes(tag))
out[tag] = aggregate(
tagged.map((r) => byId.get(r.id)!).filter(Boolean),
tagged,
kValues
)
}
return out
}

View File

@ -0,0 +1,36 @@
/**
* Is this GPU "model" a placeholder rather than a real name?
*
* systeminformation resolves PCI ids against the container's pci.ids database.
* When a card is newer than that file it reports the raw id verbatim — an RTX
* 5060 comes back as "Device 2d05". Vendor detection still succeeds, so these
* strings otherwise pass as legitimate model names.
*
* Deliberately narrow: it must never reject a real product name. No shipping
* GPU is called "Device" followed by four hex digits, and the Microsoft entries
* below are placeholder adapter names that appear nowhere outside a Windows or
* WSL graphics stack.
*
* Lives in its own module rather than beside its first caller because both the
* leaderboard submission path and the Settings > System display path need the
* same answer, and they diverged once already: #1165 fixed the submission and
* left the System page reporting the raw id (#1196). One definition, two
* callers, so the next placeholder shape only has to be added once.
*/
export function isUnresolvedGpuModel(model: string): boolean {
const s = model.trim()
if (s === '') return true
if (/^device\s+[0-9a-f]{4}$/i.test(s)) return true
if (/^unknown$/i.test(s)) return true
// WSL2 exposes the GPU through /dev/dxg rather than the real adapter, so
// si.graphics() reports Microsoft's generic placeholder even while CUDA work
// is running on a physical card. Left unhandled, an RTX 3090 reaches the
// public leaderboard labelled "Microsoft Basic Render Driver", which is worse
// than no label: it is wrong, and it fragments per-hardware grouping (#1218).
//
// These are Microsoft's own placeholder adapter names and appear nowhere
// outside a Windows/WSL graphics stack, so this cannot reject a real product
// name on a native Linux host.
if (/^microsoft basic (render driver|display adapter)$/i.test(s)) return true
return false
}

View File

@ -0,0 +1,140 @@
import { sep } from 'node:path'
/**
* Past this share of a root's indexed sources, a single sync refuses to purge
* that root at all. Losing most of a root at once is far likelier to mean the
* root points somewhere wrong (a different disk, a stale copy, a half-finished
* data-path move per #1050) than that the user deleted most of their library
* by hand. Real deletions through the UI purge their own vectors immediately
* (ZimService.delete, deleteFileBySource), so the sweep only ever mops up
* stragglers and has no business removing the bulk of a root.
*/
export const ORPHAN_PURGE_MAX_FRACTION = 0.5
/**
* The mass-removal guard only engages once this many sources would go. Below
* it, the fraction is too noisy to mean anything (1 of 2 is 50%) and the cost
* of a wrong purge is a few minutes of re-embedding, not hours.
*/
export const ORPHAN_PURGE_MIN_GUARDED = 5
export type WithheldOrphans = {
/** Scan root whose orphans were left in place. */
root: string
/** How many indexed sources under it would have been purged. */
count: number
/**
* `empty_root`: the root was walked but held no embeddable files.
* `mass_removal`: the purge would remove more than ORPHAN_PURGE_MAX_FRACTION
* of the root's indexed sources.
*/
reason: 'empty_root' | 'mass_removal'
}
export type OrphanDecision = {
/** Sources safe to purge. */
orphans: string[]
/** Roots whose missing sources were held back, for the operator to see. */
withheld: WithheldOrphans[]
}
/**
* Decision for the reverse sweep in `RagService.scanAndSyncStorage`.
*
* This is the pure, I/O-free core of the orphan check described in issue
* #1170: `scanAndSyncStorage` already builds `sourcesInQdrant` (from a facet
* query) and `embeddableFiles` (from a disk scan) in the same pass, but only
* ever asked "is this on-disk file already embedded?" — never the reverse
* "does this Qdrant source still have a file on disk?" Sources left behind by
* `ZimService.delete()` (which never touched Qdrant) or by
* `reconcileReplacedContentFile`'s `qdrant_not_running` no-op therefore never
* got reaped.
*
* Decided per scanned root, because the roots fail independently. Two guards
* apply to each, and either one withholds the whole root for this cycle:
*
* 1. A root with zero embeddable files keeps its sources (#1378). Walking a
* directory successfully does not prove it holds the content: boot runs
* ensureDirectoryExists() on the zim root, so a separate volume that
* failed to mount leaves a real, empty mountpoint behind (often holding a
* freshly regenerated kiwix-library.xml, which is why this counts
* embeddable files rather than directory entries). An empty root is
* indistinguishable from an unmounted one, and a single upload in
* kb_uploads is enough to make the overall scan non-empty.
*
* 2. A purge that would remove more than ORPHAN_PURGE_MAX_FRACTION of a
* root's indexed sources (and at least ORPHAN_PURGE_MIN_GUARDED of them) is
* withheld. This catches the root pointing at the wrong place while still
* holding a few files, which guard 1 can't see.
*
* Both guards trade a missed cleanup for safety: re-embedding a wrongly purged
* library takes hours to days, while a stale source costs nothing until the
* next sync or an explicit reset.
*/
export function decideOrphans(
sourcesInQdrant: string[],
embeddableFiles: string[],
scannedRoots: string[]
): OrphanDecision {
const onDisk = new Set(embeddableFiles)
const orphans: string[] = []
const withheld: WithheldOrphans[] = []
for (const root of scannedRoots) {
const indexed = filterOrphanCandidates(sourcesInQdrant, [root])
const missing = indexed.filter((source) => !onDisk.has(source))
if (missing.length === 0) continue
if (filterOrphanCandidates(embeddableFiles, [root]).length === 0) {
withheld.push({ root, count: missing.length, reason: 'empty_root' })
continue
}
if (
missing.length >= ORPHAN_PURGE_MIN_GUARDED &&
missing.length / indexed.length > ORPHAN_PURGE_MAX_FRACTION
) {
withheld.push({ root, count: missing.length, reason: 'mass_removal' })
continue
}
orphans.push(...missing)
}
return { orphans, withheld }
}
/**
* Narrows paths down to the ones under a root the disk scan genuinely walked.
* decideOrphans() applies it per root, both to Qdrant sources and to the files
* found on disk.
*
* `scannedRoots` is deliberately the roots that were *walked*, not the roots
* that were *configured*. Two different failure modes collapse into that one
* rule:
*
* 1. A source root outside the scan entirely — Nomad's own bundled docs
* (README.md + docs/), or any root added in future. Allowlisting rather
* than denylisting those by name means a new root doesn't get treated as
* orphaned the first time it appears (see #1170's docs-collision near-miss).
*
* 2. A configured root that wasn't there at scan time. `_discoverKbFiles()`
* skips a missing root rather than failing, so a relocated or not-yet-
* mounted zim directory (#1050) still leaves the scan non-empty via
* kb_uploads. A root we couldn't read tells us nothing about what belongs
* under it. (A root that exists but is empty is the same problem one step
* removed; decideOrphans' per-root guard handles that, see #1378.)
*
* Passing an empty `scannedRoots` therefore yields no candidates, which is the
* correct reading of "we couldn't see any of the storage."
*/
export function filterOrphanCandidates(
sourcesInQdrant: string[],
scannedRoots: string[]
): string[] {
// The trailing separator is what makes this a subpath test rather than a
// naive string prefix match, so `<root>-backup/x.zim` doesn't qualify.
const prefixes = scannedRoots.map((root) => root + sep)
if (prefixes.length === 0) return []
return sourcesInQdrant.filter((source) => prefixes.some((prefix) => source.startsWith(prefix)))
}

View File

@ -12,6 +12,30 @@ export function toTitleCase(str: string): string {
.join(' ')
}
/**
* Decide which model runs an ancillary AI task (chat titles, chat suggestions).
*
* `configured` is the user's `ai.tasksModel` setting. It only wins when the
* model is still installed — a model can be deleted from /settings/models long
* after it was picked here, and a request for a missing model 404s. In every
* other case the caller's existing `fallback` applies, so an unset setting
* leaves prior behaviour untouched.
*/
export function pickTasksModel(
configured: string | null | undefined,
installedNames: string[],
fallback: string | null
): { model: string | null; staleConfigured: string | null } {
const trimmed = configured?.trim()
if (!trimmed) {
return { model: fallback, staleConfigured: null }
}
if (installedNames.includes(trimmed)) {
return { model: trimmed, staleConfigured: null }
}
return { model: fallback, staleConfigured: trimmed }
}
export function parseBoolean(value: any): boolean {
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
@ -22,4 +46,48 @@ export function parseBoolean(value: any): boolean {
return value === 1
}
return false
}
}
/**
* Interpret a stored `rag.minRelevance` value as a retrieval relevance floor.
*
* Pure, and separate from the cached KV read in rag_relevance.ts, for the same
* reason pickTasksModel is separate from resolveTasksModel: every interesting
* case here is a bad or absent value, and none of them need a database.
*
* Unset, empty, 'auto' and unparseable all mean "use the recommended default" —
* a corrupt row must not silently disable filtering. An explicit 0 is different:
* it is a real choice, and it turns the floor off.
*/
export function parseMinRelevance(raw: string | null | undefined, fallback: number): number {
if (raw === null || raw === undefined) return fallback
const trimmed = String(raw).trim()
if (trimmed === '' || trimmed === 'auto') return fallback
const parsed = Number(trimmed)
if (!Number.isFinite(parsed)) return fallback
return Math.min(1, Math.max(0, parsed))
}
/**
* Apply the retrieval relevance floor to a reranked candidate list.
*
* Pure, and called from RagService.searchSimilarDocuments between reranking and
* the source-diversity penalty. The ordering is the interesting part: the floor
* is a judgement about *relevance*, and the reranked score is where that
* judgement is best informed. Diversity is about *redundancy* — it multiplies by
* 0.85^n per repeated source — so flooring afterwards would drop the fourth
* chunk of the one document that actually answers the question and blame a knob
* labelled "relevance" for it.
*
* An empty result is a real answer, not a failure: it means nothing retrieved
* was relevant enough, and the caller should inject no context block at all
* rather than hand the model passages it has to be talked out of using.
*/
export function applyRelevanceFloor<T extends { finalScore: number }>(
results: T[],
minFinalScore: number
): { survivors: T[]; belowFloor: number } {
if (!(minFinalScore > 0)) return { survivors: results, belowFloor: 0 }
const survivors = results.filter((r) => r.finalScore >= minFinalScore)
return { survivors, belowFloor: results.length - survivors.length }
}

View File

@ -0,0 +1,90 @@
import type { ModelVisionCapability } from '../../types/ollama.js'
export type ModelCapabilities = {
thinking: boolean
vision: ModelVisionCapability
}
export type ModelCapabilityProbes = {
ollamaShow: () => Promise<unknown>
llamaProps: () => Promise<unknown>
}
export type InstalledModelMetadata = {
name: string
size: number
capabilities?: string[]
}
export function capabilitiesFromOllamaShow(value: unknown): ModelCapabilities | null {
if (!value || typeof value !== 'object') return null
const capabilities = (value as { capabilities?: unknown }).capabilities
if (!Array.isArray(capabilities) || !capabilities.every((item) => typeof item === 'string')) {
return null
}
return {
thinking: capabilities.includes('thinking'),
vision: capabilities.includes('vision') ? 'supported' : 'unsupported',
}
}
export function visionCapabilityFromLlamaProps(value: unknown): ModelVisionCapability | null {
if (!value || typeof value !== 'object') return null
const modalities = (value as { modalities?: unknown }).modalities
if (modalities && typeof modalities === 'object' && !Array.isArray(modalities)) {
const vision = (modalities as { vision?: unknown }).vision
return typeof vision === 'boolean' ? (vision ? 'supported' : 'unsupported') : null
}
if (Array.isArray(modalities) && modalities.every((item) => typeof item === 'string')) {
return modalities.includes('image') || modalities.includes('vision')
? 'supported'
: 'unsupported'
}
return null
}
export async function resolveModelCapabilities(
advertisedMetadata: unknown,
probes: ModelCapabilityProbes
): Promise<ModelCapabilities | null> {
const advertised = capabilitiesFromOllamaShow(advertisedMetadata)
// A model list that reports vision is trusted outright. One that does not is
// not conclusive: Ollama's /api/tags lists gemma3 as ['completion'] while its
// /api/show for the same model reports ['completion', 'vision'], which left
// image upload disabled for one of the most common small vision models.
if (advertised?.vision === 'supported') return advertised
try {
const ollama = capabilitiesFromOllamaShow(await probes.ollamaShow())
if (ollama) return ollama
} catch {}
try {
const vision = visionCapabilityFromLlamaProps(await probes.llamaProps())
if (vision) return { thinking: advertised?.thinking ?? false, vision }
} catch {}
return advertised
}
export function installedModelsFromOpenAIResponse(value: unknown): InstalledModelMetadata[] {
if (!value || typeof value !== 'object') return []
const data = (value as { data?: unknown }).data
if (!Array.isArray(data)) return []
return data.flatMap((entry) => {
if (!entry || typeof entry !== 'object') return []
const { id, capabilities } = entry as { id?: unknown; capabilities?: unknown }
if (typeof id !== 'string') return []
const model: InstalledModelMetadata = { name: id, size: 0 }
if (
Array.isArray(capabilities) &&
capabilities.every((capability) => typeof capability === 'string')
) {
model.capabilities = capabilities
}
return [model]
})
}

View File

@ -0,0 +1,68 @@
/**
* Per-queue BullMQ stall-recovery options.
*
* The pure, dependency-free core of `getStallOptionsForQueue` in
* `commands/queue/work.ts` (mirrors `decideScanAction` in
* `kb_ingest_decision.ts`).
*
* Two questions decide a queue's entry, and they pull in opposite directions:
*
* 1. **How long does one job legitimately hold the lock?** If that can exceed
* `lockDuration`, BullMQ calls a perfectly healthy job stalled.
* 2. **Is re-running a job safe?** On a stall BullMQ increments the job's `stc`
* counter and re-queues it, failing it only once the counter passes
* `maxStalledCount` (`moveStalledJobsToWait`). It does **not** check whether
* the original is still running. For an idempotent job that recovery is what
* we want; for a job that writes non-idempotent data it is worse than the
* failure it is preventing.
*
* Note that `maxStalledCount: 0` is meaningful and is not the same as leaving
* it unset: the comparison is `stalledCount > maxStalledCount` after an
* increment, so 0 fails the job on the FIRST stall rather than re-queueing it,
* while the BullMQ default of 1 re-queues once.
*/
export const DEFAULT_LOCK_DURATION = 300_000
export const LONG_LOCK_DURATION = 1_800_000
export interface StallOptions {
lockDuration: number
/** Left undefined to keep BullMQ's default of 1. */
maxStalledCount?: number
}
export interface StallQueueNames {
/** ZIM/document embedding — a self-continuing batch chain. */
embedFile: string
/** Content downloads — resumable. */
download: string
drugDownload: string
drugIngest: string
}
export function stallOptionsForQueue(queueName: string, queues: StallQueueNames): StallOptions {
// Long single streams (a ~150 MB resumable HTTP pull, then an unzip +
// JSON-stream ingest at concurrency 1). Re-running a part is safe: the
// download resumes and the ingest upserts by key.
if (queueName === queues.drugDownload || queueName === queues.drugIngest) {
return { lockDuration: LONG_LOCK_DURATION, maxStalledCount: 3 }
}
// NEVER auto-recover a stalled job on this queue (#1269). Embedding is a
// self-continuing chain, so re-queueing while the original is alive produces
// two chains walking the same file, both writing to Qdrant with freshly
// minted point ids. A failed chain is visible and re-indexable; a forked one
// is silent and corrupts the index.
if (queueName === queues.embedFile) {
return { lockDuration: LONG_LOCK_DURATION, maxStalledCount: 0 }
}
// The opposite call to embeddings (#1203). A download is resumable and
// re-running one is harmless, but the default maxStalledCount of 1 fails the
// job on a second stall and bypasses its own `attempts: 10` retry policy.
if (queueName === queues.download) {
return { lockDuration: LONG_LOCK_DURATION, maxStalledCount: 3 }
}
return { lockDuration: DEFAULT_LOCK_DURATION }
}

View File

@ -0,0 +1,111 @@
/**
* Pure helpers for shaping the retrieved-context block.
*
* These live here rather than in RagPipelineService so they can be exercised
* under bare `node --experimental-strip-types` with no MySQL, Redis, Qdrant, or
* Ollama — the same reason `kb_ratio_lookup.ts` is shaped this way. The service
* supplies the config; everything below is a function of its arguments.
*
* Token budgeting used to live here too, as a chars-per-token estimate feeding a
* `num_ctx` ladder. Both are gone: the estimate now comes from
* `token_estimate.ts` (calibrated against real token counts) and the allocation
* from `context_budget.ts`. The ladder in particular was dead code — it fed a
* `num_ctx` that the OpenAI-compatible endpoint silently discarded, so no chat
* ever ran at the window it computed.
*/
import { parseParameterBillions } from './context_window.js'
import type { ChatSource } from '../../types/chat.js'
export type ContextLimits = { maxResults: number; maxTokens: number }
export type ContextLimitTier = { maxParams: number; maxResults: number; maxTokens: number }
/** The minimum shape these helpers need; the real chunks carry more. */
export type BudgetableChunk = { text: string; metadata?: Record<string, any> }
export type BudgetableMessage = { role: 'system' | 'user' | 'assistant'; content: string }
/**
* Determine RAG context limits from the model's parameter count.
*
* `parameterSize` is Ollama's own `details.parameter_size` ("8.0B") and is the
* honest source. The model name is only a fallback: it used to be the *only*
* source, which meant a tag carrying no size — "phi3", "mistral-nemo", anything
* custom — was silently assumed to be 8B and handed far more context than a
* small model can actually use. When neither source knows, 8B remains the
* assumption, but now it is one made explicitly rather than by a failed regex.
*/
export function getContextLimitsForModel(
modelName: string,
tiers: readonly ContextLimitTier[],
parameterSize?: string
): ContextLimits {
const paramBillions = parseParameterBillions(parameterSize, modelName) ?? 8
for (const tier of tiers) {
if (paramBillions <= tier.maxParams) {
return { maxResults: tier.maxResults, maxTokens: tier.maxTokens }
}
}
return { maxResults: 5, maxTokens: 0 }
}
/**
* Render retrieved chunks into the block the rag_context prompt wraps.
*
* Each block is labelled with its source title when one is available — a
* neutral, honest provenance signal — but never with the raw relevance score.
* nomic cosine scores for genuinely relevant passages sit around 0.4-0.6, and
* surfacing e.g. "42%" primes the model to distrust correct context. Scores
* stay in the logs and in the eval report.
*/
export function buildContextBlock(docs: BudgetableChunk[]): string {
return docs
.map((doc, idx) => {
const title =
doc.metadata?.archive_title || doc.metadata?.full_title || doc.metadata?.article_title
const date = doc.metadata?.archive_date
const label = title
? `[Context ${idx + 1} — ${title}${date ? ` (${date})` : ''}]`
: `[Context ${idx + 1}]`
return `${label}\n${doc.text}`
})
.join('\n\n')
}
/**
* Build the deduplicated "Sources" list shown under an assistant answer.
*
* Deliberately fed from what was *injected* into the prompt rather than from
* everything retrieval returned: a chunk dropped by the budget planner never
* reached the model, so citing it would credit the answer to a document it was
* not based on -- the exact failure the citation list exists to prevent.
*
* Dedupes on the originating path so a dozen chunks out of one archive collapse
* to one entry, falling back to the title when a point carries no path. Points
* with neither are skipped rather than shown as "Unknown source": an entry the
* user cannot act on is worse than one fewer entry.
*/
export function buildCitations(docs: BudgetableChunk[]): ChatSource[] {
const seen = new Set<string>()
const sources: ChatSource[] = []
for (const doc of docs) {
const title =
doc.metadata?.archive_title || doc.metadata?.full_title || doc.metadata?.article_title
const path = doc.metadata?.source as string | undefined
const key = path || title
if (!key || seen.has(key)) continue
seen.add(key)
sources.push({
// A user-uploaded PDF carries no embedded title, so fall back to its
// filename -- which is what the user named it and will recognise.
title: title || path!.split('/').pop() || path!,
date: doc.metadata?.archive_date,
source: path,
})
}
return sources
}

View File

@ -0,0 +1,49 @@
import logger from '@adonisjs/core/services/logger'
import KVStore from '#models/kv_store'
import { RAG_MIN_FINAL_SCORE } from '../../constants/ollama.js'
import { parseMinRelevance } from './misc.js'
/**
* Resolves the user's retrieval relevance floor (`rag.minRelevance`), cached.
*
* Read on every chat turn, changed roughly never, so a KV round-trip per turn
* would be pure overhead on the critical path. Cached at module scope rather
* than on a service instance: NOMAD's services are `@inject()` but are not
* registered as singletons, so instance state lives and dies with a single
* request — which is why ContextWindowService's "memoized for the process
* lifetime" memo isn't, and why its invalidate() never had a caller to matter to.
*
* Belt and braces, mirroring the assistant-name cache in config/inertia.ts: the
* TTL bounds how stale this can get if an invalidation is ever missed, and
* SystemService.updateSetting calls invalidateMinRelevanceCache() so the common
* case takes effect immediately.
*/
const CACHE_TTL_MS = 60_000
let cache: { value: number; expiresAt: number } | null = null
export function invalidateMinRelevanceCache(): void {
cache = null
}
/** The relevance floor to apply to this turn's retrieval. */
export async function resolveMinFinalScore(): Promise<number> {
const now = Date.now()
if (cache && now < cache.expiresAt) return cache.value
let raw: string | null = null
try {
raw = await KVStore.getValue('rag.minRelevance')
} catch (error) {
// A KV read failure must not take retrieval out, and must not be cached —
// fall back to the default and let the next turn try again.
logger.warn(
`[RAG] Failed to read rag.minRelevance, using ${RAG_MIN_FINAL_SCORE}: ${error instanceof Error ? error.message : error}`
)
return RAG_MIN_FINAL_SCORE
}
const value = parseMinRelevance(raw, RAG_MIN_FINAL_SCORE)
cache = { value, expiresAt: now + CACHE_TTL_MS }
return value
}

View File

@ -0,0 +1,107 @@
/**
* The pure half of the retrieval relevance check (#1341): the grammar, the
* setting, and what the judge gets to read.
*
* Why a model and not another threshold: on a library holding general prose
* (Gutenberg, Wikipedia), the nearest chunk to *any* question scores as high as
* a genuinely relevant one. Measured on the eval corpus with its distractors,
* the weakest correct top hit scored 0.666 post-rerank while "write me a haiku
* about autumn leaves" pulled Walden at 0.705. No cutoff on that axis separates
* the two, so the decision has to be a judgement about the question.
*
* Why one verdict for the whole set rather than one per passage: asked
* passage-by-passage, every model tried (0.5B to 8B) rejected passages that
* were on-topic but did not spell the answer out, and cost 9-17 points of
* recall@5. "Are these about what was asked?" is the easier question, and on an
* 8B model it took off-topic leaks from 67% to 6% for ~5 points of recall. On a
* 3B model it rejects most real questions, which is why the check is opt-in.
*
* Pure so it unit-tests under bare node; RelevanceJudgeService owns the call.
*/
/**
* Longest excerpt of each passage the judge reads. At 700 characters the window
* routinely stopped a sentence short of the answer (wound packing, counterpoise
* length) and the judge rightly said the passage didn't cover it.
*/
export const JUDGE_EXCERPT_CHARS = 1500
/** Anything shorter carries no topical signal; don't let it steer the excerpt. */
const MIN_TERM_LENGTH = 4
export type JudgeablePassage = { text: string; title?: string }
export const ON_TOPIC_SCHEMA = {
type: 'object',
properties: { on_topic: { type: 'boolean' } },
required: ['on_topic'],
additionalProperties: false,
} as const
export function pickOnTopic(value: any): boolean | null {
return typeof value?.on_topic === 'boolean' ? value.on_topic : null
}
/**
* Interpret the stored `rag.relevanceCheck` setting. Off unless explicitly on:
* the check costs a model call per turn and hurts recall on small models, so
* nothing short of a deliberate choice should turn it on.
*/
export function isRelevanceCheckEnabled(raw: unknown): boolean {
return raw === true || raw === 'true'
}
/**
* The stretch of `text` most about the question, at most `maxChars` long.
*
* Chunks run ~3000 characters and the judge sees half of each, so which half
* matters. Picks the window holding the most occurrences of the question's
* longer terms, falling back to the start when none appear.
*/
export function excerptFor(text: string, question: string, maxChars = JUDGE_EXCERPT_CHARS): string {
const clean = text.replace(/\s+/g, ' ').trim()
if (clean.length <= maxChars) return clean
const terms = [
...new Set(
question
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((t) => t.length >= MIN_TERM_LENGTH)
),
]
const lower = clean.toLowerCase()
const hits: number[] = []
for (const term of terms) {
for (let at = lower.indexOf(term); at !== -1; at = lower.indexOf(term, at + term.length)) {
hits.push(at)
}
}
if (hits.length === 0) return clean.slice(0, maxChars) + '…'
hits.sort((a, b) => a - b)
// Densest window: two pointers over the sorted hit positions.
let bestStart = hits[0]
let bestCount = 0
for (let lo = 0, hi = 0; hi < hits.length; hi++) {
while (hits[hi] - hits[lo] > maxChars) lo++
if (hi - lo + 1 > bestCount) {
bestCount = hi - lo + 1
bestStart = hits[lo]
}
}
// Open a little before the first hit so the sentence it sits in reads whole.
const start = Math.max(0, Math.min(bestStart - Math.floor(maxChars / 5), clean.length - maxChars))
const end = start + maxChars
return (start > 0 ? '…' : '') + clean.slice(start, end) + (end < clean.length ? '…' : '')
}
/** The user turn of the judge call: the question, then each passage. */
export function buildJudgeInput(question: string, passages: JudgeablePassage[]): string {
const blocks = passages.map((p, i) => {
const heading = p.title ? `p${i + 1} (from "${p.title}")` : `p${i + 1}`
return `${heading}:\n${excerptFor(p.text, question)}`
})
return `Question: ${question.trim()}\n\n${blocks.join('\n\n')}`
}

View File

@ -0,0 +1,47 @@
import logger from '@adonisjs/core/services/logger'
import KVStore from '#models/kv_store'
import { DEFAULT_RESPONSE_STYLE } from '../../constants/ollama.js'
import type { ResponseStyle } from '../../types/ollama.js'
import { parseResponseStyle } from './sampler.js'
/**
* Resolves the user's Response Style (`ai.responseStyle`), cached.
*
* Same shape and same reasoning as the relevance-floor cache next door: read on
* every chat turn, changed roughly never, and NOMAD's services are `@inject()`
* but not registered as singletons, so caching on a service instance would give
* a cache that lives and dies inside one request.
*
* The TTL bounds how stale this gets if an invalidation is ever missed;
* SystemService.updateSetting calls invalidateResponseStyleCache() so the common
* case takes effect on the next turn rather than a minute later.
*/
const CACHE_TTL_MS = 60_000
let cache: { value: ResponseStyle; expiresAt: number } | null = null
export function invalidateResponseStyleCache(): void {
cache = null
}
/** The response style to sample this turn under. */
export async function resolveResponseStyle(): Promise<ResponseStyle> {
const now = Date.now()
if (cache && now < cache.expiresAt) return cache.value
let raw: string | null = null
try {
raw = await KVStore.getValue('ai.responseStyle')
} catch (error) {
// A KV read failure must not take chat out, and must not be cached — fall
// back to the default and let the next turn try again.
logger.warn(
`[Ollama] Failed to read ai.responseStyle, using ${DEFAULT_RESPONSE_STYLE}: ${error instanceof Error ? error.message : error}`
)
return DEFAULT_RESPONSE_STYLE
}
const value = parseResponseStyle(raw)
cache = { value, expiresAt: now + CACHE_TTL_MS }
return value
}

133
admin/app/utils/sampler.ts Normal file
View File

@ -0,0 +1,133 @@
/**
* Pure helpers for deciding which sampler settings a chat request carries.
*
* Kept out of OllamaService for the same reason `context_window.ts` is: every
* interesting case here is a bad stored value or a model that declares its own
* parameters, and none of them need Ollama, Docker or MySQL to exercise. The
* service supplies the facts (what the stored setting says, what /api/show
* returned); everything here is a function of its arguments.
*/
import { DEFAULT_RESPONSE_STYLE, SAMPLER_PRESETS } from '../../constants/ollama.js'
import type { ResponseStyle, SamplerProfile } from '../../types/ollama.js'
const RESPONSE_STYLES: ResponseStyle[] = ['auto', 'focused', 'creative', 'off']
/**
* Interpret a stored `ai.responseStyle` value.
*
* Unset, empty and unrecognised all mean the default. A corrupt row must not
* silently drop chat back to unsampled backend defaults, because that is the
* failure this setting exists to fix and it would be invisible from the UI.
* `off` is only ever reached by someone choosing it.
*/
export function parseResponseStyle(raw: string | null | undefined): ResponseStyle {
if (raw === null || raw === undefined) return DEFAULT_RESPONSE_STYLE
const trimmed = String(raw).trim().toLowerCase()
if (!trimmed) return DEFAULT_RESPONSE_STYLE
return (RESPONSE_STYLES as string[]).includes(trimmed)
? (trimmed as ResponseStyle)
: DEFAULT_RESPONSE_STYLE
}
/**
* Pull sampler values out of the `parameters` string /api/show returns (the
* modelfile PARAMETER lines, e.g. `temperature 0.6`).
*
* Sibling of `readModelfileNumCtx`, and there for a sharper reason than it: a
* good number of Ollama library models ship author-tuned samplers (qwen3 sets
* 0.6/0.95/20, deepseek-r1 sets 0.6), and stamping a generic house profile over
* those would make exactly the models someone tuned carefully worse. Under the
* `auto` style these values win, key by key.
*
* Repeated PARAMETER lines are legal in a modelfile and the last one wins in
* Ollama, so the scan does not stop at the first match.
*/
export function readModelfileSamplers(parameters: unknown): SamplerProfile {
const profile: SamplerProfile = {}
if (typeof parameters !== 'string') return profile
const keys: [RegExp, keyof SamplerProfile][] = [
[/^\s*temperature\s+(-?[\d.]+)\s*$/gm, 'temperature'],
[/^\s*top_p\s+(-?[\d.]+)\s*$/gm, 'topP'],
[/^\s*top_k\s+(-?[\d.]+)\s*$/gm, 'topK'],
[/^\s*min_p\s+(-?[\d.]+)\s*$/gm, 'minP'],
[/^\s*repeat_penalty\s+(-?[\d.]+)\s*$/gm, 'repeatPenalty'],
]
for (const [pattern, field] of keys) {
let match: RegExpExecArray | null
while ((match = pattern.exec(parameters)) !== null) {
const value = Number.parseFloat(match[1])
if (Number.isFinite(value) && value >= 0) profile[field] = value
}
}
return profile
}
/**
* The sampler settings to send for one request.
*
* `off` sends nothing. `auto` is NOMAD's baseline with the model author's own
* values layered over it, which in practice means the model keeps its tuned
* temperature and top_p and gains the min_p it was missing. `focused` and
* `creative` are explicit user choices and replace the model's values outright,
* because a style the user picked by name that quietly did nothing on half
* their models would be worse than not offering it.
*
* A model that declares its own `top_p` under `auto` also gets it on the compat
* transport: an author-set nucleus is a better substitute for the missing min_p
* than the preset's generic fallback.
*/
export function resolveSamplerProfile(
style: ResponseStyle,
modelfileSamplers?: SamplerProfile
): SamplerProfile | undefined {
if (style === 'off') return undefined
const preset = SAMPLER_PRESETS[style]
if (style !== 'auto') return { ...preset }
const declared = modelfileSamplers ?? {}
const merged: SamplerProfile = { ...preset, ...declared }
if (declared.topP !== undefined) merged.compatTopP = declared.topP
return merged
}
/**
* Map a profile onto Ollama's native `options` keys.
*
* `temperature` is deliberately absent: it is carried on ChatInput itself, so an
* explicit caller value (eval runs and the grammar-constrained task calls, both
* of which pin it to 0) keeps beating whatever style the user has chosen.
*/
export function nativeSamplerOptions(profile: SamplerProfile | undefined): Record<string, number> {
if (!profile) return {}
const options: Record<string, number> = {}
if (profile.topP !== undefined) options.top_p = profile.topP
if (profile.topK !== undefined) options.top_k = profile.topK
if (profile.minP !== undefined) options.min_p = profile.minP
if (profile.repeatPenalty !== undefined) options.repeat_penalty = profile.repeatPenalty
return options
}
/**
* Map a profile onto the OpenAI-compatible endpoint's params.
*
* Only `top_p` survives the trip. min_p, top_k and repeat_penalty have no
* equivalent there, and presence/frequency_penalty are not stand-ins for
* repeat_penalty — they accumulate over the whole completion rather than a
* 64-token window, so borrowing one to fake the other would punish a grounded
* answer for citing the same identifier twice.
*
* `compatTopP` rather than `topP` is the value sent, for the reason documented
* on the type: the presets set `topP: 1.0` and truncate with min_p, and passing
* that through unchanged to a backend that drops min_p would leave the raw
* distribution untruncated, which is worse than the default it replaced.
*/
export function compatSamplerParams(profile: SamplerProfile | undefined): Record<string, number> {
if (!profile) return {}
const params: Record<string, number> = {}
const topP = profile.compatTopP ?? profile.topP
if (topP !== undefined) params.top_p = topP
return params
}

View File

@ -0,0 +1,152 @@
/**
* Merging catalog config onto an already-installed curated service.
*
* `ServiceSeeder.run()` keeps curated services in sync with the catalog by
* overwriting `container_config` and `ui_location` on every boot. That is correct
* for anything not yet installed, and wrong for anything that is: an install whose
* published host port differs from the catalog default gets its port reset, which
* desyncs the row from the container that is actually running. The Open link then
* points at a port with nothing behind it, and the next recreate collides with
* whatever process took the catalog port in the meantime.
*
* A host port diverges for an ordinary reason: the default was already taken on
* that machine, so the user moved it. That is a property of the machine, not a
* preference the catalog should get a vote on.
*
* These helpers keep the running install's host ports while still applying every
* other catalog change, so an app on an alternate port continues to receive image,
* env, metadata and scheme updates. The alternative considered was flagging such
* rows `is_user_modified` so the sync skips them entirely, which also protects the
* port but permanently opts that install out of all future catalog updates. This
* is the narrower fix: preserve the one field the machine owns, sync the rest.
*/
/** `{ "8080/tcp": [{ "HostPort": "8090" }] }` keyed by container port. */
type PortBindings = Record<string, Array<{ HostPort?: string }> | undefined>
/**
* `services.container_config` is a MySQL **json** column, so the driver hands back
* an already-parsed object at runtime even though the model declares it
* `string | null`. The catalog side of the comparison is a `JSON.stringify` string.
* Both shapes reach these helpers, so accept either.
*
* This is not a hypothetical: a string-only version of this function silently
* no-opped against a real database while passing every string-fixture unit test.
*/
type SerializedConfig = string | Record<string, any> | null | undefined
function parseConfig(value: SerializedConfig): Record<string, any> | null {
if (!value) return null
if (typeof value === 'object') return value
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
// A row we cannot parse gets the catalog value verbatim, which is the
// pre-existing behaviour and no worse than what it has now.
return null
}
}
function readPortBindings(containerConfig: SerializedConfig): PortBindings | null {
const bindings = parseConfig(containerConfig)?.HostConfig?.PortBindings
if (!bindings || typeof bindings !== 'object') return null
return bindings as PortBindings
}
function firstHostPort(bindings: PortBindings | null): string | null {
if (!bindings) return null
for (const list of Object.values(bindings)) {
const port = Array.isArray(list) ? list[0]?.HostPort : undefined
if (port) return String(port)
}
return null
}
/**
* Catalog `container_config` with the live install's published host ports kept.
*
* Matching is per container-side port, so if the catalog changes which port the
* app listens on *inside* the container, that is a genuine catalog change and the
* catalog value wins. Only the host side of a binding the install already has is
* preserved.
*/
export function mergeContainerConfigPreservingHostPorts<T extends string | null>(
catalogConfig: T,
liveConfig: SerializedConfig
): T {
if (!catalogConfig) return catalogConfig
const live = readPortBindings(liveConfig)
if (!live) return catalogConfig
const parsed = parseConfig(catalogConfig)
if (!parsed) return catalogConfig
const catalogBindings: PortBindings | undefined = parsed?.HostConfig?.PortBindings
if (!catalogBindings || typeof catalogBindings !== 'object') return catalogConfig
let changed = false
for (const [containerPort, catalogList] of Object.entries(catalogBindings)) {
const livePort = Array.isArray(live[containerPort]) ? live[containerPort]![0]?.HostPort : undefined
if (!livePort) continue
const catalogPort = Array.isArray(catalogList) ? catalogList[0]?.HostPort : undefined
if (catalogPort === undefined || String(catalogPort) === String(livePort)) continue
catalogBindings[containerPort] = [{ ...(Array.isArray(catalogList) ? catalogList[0] : {}), HostPort: String(livePort) }]
changed = true
}
return (changed ? JSON.stringify(parsed) : catalogConfig) as T
}
/**
* Catalog `ui_location` with the live install's port kept, when it is a port at all.
*
* `ui_location` is one of `"8090"`, `"https:8480"`, or a path like `"/chat"`. Only
* the first two forms carry a port, and only those are rewritten. The catalog's
* scheme is always taken, so a catalog change like Vaultwarden moving to `https:`
* still reaches an install running on an alternate port.
*
* `mergedConfig` must be the container_config this sync is about to WRITE, meaning
* the output of `mergeContainerConfigPreservingHostPorts`, not the live row. The
* live port is only honoured when that config actually publishes it, which does
* two things: it keeps a stale or hand-edited `ui_location` from pinning the link
* to a port nothing is listening on, and it keeps the link in step with the config
* in the one case the catalog wins outright. When the catalog moves the
* container-side port, no live host port is preserved, so the link takes the
* catalog port rather than pointing at a port the recreate will not bind.
*/
export function mergeUiLocationPreservingHostPort(
catalogUiLocation: string | null,
liveUiLocation: string | null | undefined,
mergedConfig: SerializedConfig
): string | null {
if (!catalogUiLocation || !liveUiLocation) return catalogUiLocation
const catalogMatch = catalogUiLocation.match(/^(?:(https?):)?(\d+)$/)
const liveMatch = liveUiLocation.match(/^(?:(https?):)?(\d+)$/)
if (!catalogMatch || !liveMatch) return catalogUiLocation
const livePort = liveMatch[2]
if (livePort === catalogMatch[2]) return catalogUiLocation
const bindings = readPortBindings(mergedConfig)
const publishedPorts = new Set(
bindings
? Object.values(bindings)
.flatMap((list) => (Array.isArray(list) ? list : []))
.map((b) => (b?.HostPort ? String(b.HostPort) : null))
.filter((p): p is string => p !== null)
: []
)
if (!publishedPorts.has(livePort)) return catalogUiLocation
const scheme = catalogMatch[1]
return scheme ? `${scheme}:${livePort}` : livePort
}
/** Exported for tests: the first published host port in a serialized config. */
export function firstPublishedHostPort(containerConfig: SerializedConfig): string | null {
return firstHostPort(readPortBindings(containerConfig))
}

View File

@ -0,0 +1,158 @@
/**
* JSON Schemas and a tolerant parser for the structured ancillary calls —
* chat titles, suggestion chips, and the RAG query rewrite.
*
* The prompts used to *ask* for a format and the callers recovered the answer by
* string surgery: split on comma, else on newline, strip `1.`, strip bullets,
* strip quotes. That is a request, not a constraint, and the failure mode was
* silent — an empty chip list, or a paragraph as the sidebar title.
*
* Ollama's `format` parameter compiles a JSON Schema to a GBNF grammar, so the
* decoder cannot emit anything else. It only exists on the native /api/chat
* transport, so every caller keeps its old parser as the fallback for other
* backends; `parseStructured` returning null is what selects that path.
*
* Pure functions, no framework imports — see `think_stream.ts` / `rag_prompt.ts`.
*/
/** Sidebar chat title. */
export const TITLE_SCHEMA = {
type: 'object',
properties: {
title: { type: 'string' },
},
required: ['title'],
additionalProperties: false,
} as const
/** The three conversation-starter chips on an empty chat. */
export const SUGGESTIONS_SCHEMA = {
type: 'object',
properties: {
suggestions: {
type: 'array',
items: { type: 'string' },
minItems: 3,
maxItems: 3,
},
},
required: ['suggestions'],
additionalProperties: false,
} as const
/**
* History-aware query rewrite. An array rather than a single string even though
* only the first entry is used today: multi-query / RAG-Fusion (gap analysis 3.2)
* then needs a prompt change and nothing else.
*/
export const QUERIES_SCHEMA = {
type: 'object',
properties: {
queries: {
type: 'array',
items: { type: 'string' },
minItems: 1,
},
},
required: ['queries'],
additionalProperties: false,
} as const
/**
* Pull a JSON object out of a model response and narrow it with `pick`.
*
* Total by construction — every failure returns null so the caller falls through
* to its string parser. This runs on the chat critical path via the rewrite, so
* it must never throw.
*
* Tolerates the things an unconstrained model does anyway: a ```json fence, a
* sentence of preamble, trailing commentary. Grammar-constrained output needs
* none of that, but the same helper is used on backends where the grammar was
* never applied.
*/
export function parseStructured<T>(raw: string, pick: (value: any) => T | null): T | null {
if (!raw) return null
// Outermost braces: the first `{` to the last `}`. Preamble and trailing prose
// both fall outside it, and a nested object stays inside.
const start = raw.indexOf('{')
const end = raw.lastIndexOf('}')
if (start === -1 || end <= start) return null
let parsed: any
try {
parsed = JSON.parse(raw.slice(start, end + 1))
} catch {
return null
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
try {
return pick(parsed)
} catch {
return null
}
}
/**
* The two ways a structured call can fail to yield a value.
*
* `unconstrained` — no grammar reached the model (a non-native backend, or a call
* that never asked for one). The response is prose by design and the caller's
* legacy string parser is the correct recovery.
*
* `constrained-parse-failed` — the grammar *was* applied and the output still did
* not yield a value. In practice that means a truncated object: the model hit its
* token cap mid-JSON. Feeding that to a parser written for prose is what produces
* `{"title": ...` in the sidebar or a JSON blob in the embedded query, so the
* caller must take its safe path instead.
*/
export type StructuredResult<T> =
| { ok: true; value: T }
| { ok: false; reason: 'constrained-parse-failed' | 'unconstrained' }
/**
* `parseStructured` plus the reason it failed, so callers branch on what actually
* happened rather than sniffing the raw text for braces.
*
* `constrained` is `NomadChatResponse.structured` — whether the decoder was really
* grammar-constrained for the request, which only the transport knows.
*
* Total, like `parseStructured`: a picker that throws is a failed parse, not an
* exception on the chat critical path.
*/
export function resolveStructured<T>(
raw: string,
pick: (value: any) => T | null,
constrained: boolean
): StructuredResult<T> {
const value = parseStructured(raw, pick)
if (value !== null) return { ok: true, value }
return { ok: false, reason: constrained ? 'constrained-parse-failed' : 'unconstrained' }
}
/** Non-empty trimmed string, or null. */
function cleanString(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
/** Trimmed, non-empty entries of a string array. Null when nothing survives. */
function cleanStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null
const cleaned = value.map(cleanString).filter((s): s is string => s !== null)
return cleaned.length > 0 ? cleaned : null
}
/** `{title: string}` -> the title. */
export const pickTitle = (value: any): string | null => cleanString(value?.title)
/** `{suggestions: string[]}` -> up to three non-empty suggestions. */
export const pickSuggestions = (value: any): string[] | null => {
const suggestions = cleanStringArray(value?.suggestions)
return suggestions ? suggestions.slice(0, 3) : null
}
/** `{queries: string[]}` -> every non-empty query, best first. */
export const pickQueries = (value: any): string[] | null => cleanStringArray(value?.queries)

View File

@ -0,0 +1,64 @@
import logger from '@adonisjs/core/services/logger'
import KVStore from '#models/kv_store'
import type { OllamaService } from '#services/ollama_service'
import { pickTasksModel } from './misc.js'
/**
* Resolve the model to use for ancillary work — chat titles, suggestions, and
* the per-turn retrieval query rewrite.
*
* Lifted out of ChatService so the RAG pipeline can use it too. The rewrite is
* the ancillary call that matters most and was the one still running on the main
* chat model: it happens on *every* turn, on the critical path, and it sends a
* completely different prompt. With Ollama's default OLLAMA_NUM_PARALLEL=1 there
* is a single slot, so that call evicted the chat model's cached prefix every
* turn and guaranteed a full re-prefill of the conversation. Routing it to a
* small dedicated model keeps the chat model's KV cache intact.
*
* Falls back to `fallback` when the setting is unset (preserving the previous
* behaviour) or when the configured model is no longer installed. `installed` is
* passed by callers that already listed models, to avoid a second round-trip.
*/
export async function resolveTasksModel(
ollamaService: OllamaService,
fallback: string | null,
installed?: { name: string }[],
logPrefix = '[TasksModel]'
): Promise<string | null> {
let configured: string | null = null
try {
configured = await KVStore.getValue('ai.tasksModel')
} catch (error) {
logger.error(
`${logPrefix} Failed to read ai.tasksModel: ${error instanceof Error ? error.message : error}`
)
return fallback
}
if (!configured?.trim()) {
return fallback
}
let models = installed
if (!models) {
try {
models = await ollamaService.getModels()
} catch (error) {
logger.error(
`${logPrefix} Failed to list models while resolving the tasks model: ${error instanceof Error ? error.message : error}`
)
return fallback
}
}
const { model, staleConfigured } = pickTasksModel(
configured,
(models ?? []).map((m) => m.name),
fallback
)
if (staleConfigured) {
logger.warn(
`${logPrefix} Configured tasks model "${staleConfigured}" is not installed; falling back to "${fallback ?? 'none'}"`
)
}
return model
}

View File

@ -0,0 +1,122 @@
/**
* Stateful splitter for inline `<think>...</think>` reasoning tags.
*
* Ollama's native `/api/chat` and its `/v1` shim both surface reasoning as a
* dedicated field (`message.thinking` / `delta.reasoning`), but other
* OpenAI-compatible backends (LM Studio, llama.cpp, plain GGUF servers) emit
* the tags inline in the content stream. This splits them back out so the UI
* gets a clean `content` channel and a separate `thinking` channel regardless
* of which backend answered.
*
* Lives here rather than inside OllamaService because both transports need it
* and because it is the only real logic in the streaming path — as a pure
* class it can be exercised under bare `node --test` with no Ollama, the same
* reason `rag_prompt.ts` is shaped this way.
*
* The hard part is that a tag can be split across chunk boundaries: a chunk may
* end with `"<thi"`. Any trailing text that could still turn out to be the start
* of a tag is held back in the buffer and re-examined when the next chunk lands.
*/
const OPEN_TAG = '<think>'
const CLOSE_TAG = '</think>'
/**
* How many trailing characters of `text` could be the beginning of `tag`.
* Returns 0 when no suffix of `text` is a prefix of `tag`.
*/
export function partialTagSuffix(tag: string, text: string): number {
for (let len = Math.min(tag.length - 1, text.length); len >= 1; len--) {
if (text.endsWith(tag.slice(0, len))) return len
}
return 0
}
export type ThinkSplit = { content: string; thinking: string }
export class ThinkTagSplitter {
private buffer = ''
private inThink = false
/**
* Feed the next slice of raw model output. Returns only what could be
* resolved from this call; text that might be a partial tag stays buffered.
*/
push(raw: string): ThinkSplit {
this.buffer += raw
let content = ''
let thinking = ''
while (this.buffer.length > 0) {
if (this.inThink) {
const closeIdx = this.buffer.indexOf(CLOSE_TAG)
if (closeIdx !== -1) {
thinking += this.buffer.slice(0, closeIdx)
this.buffer = this.buffer.slice(closeIdx + CLOSE_TAG.length)
this.inThink = false
} else {
const hold = partialTagSuffix(CLOSE_TAG, this.buffer)
thinking += this.buffer.slice(0, this.buffer.length - hold)
this.buffer = this.buffer.slice(this.buffer.length - hold)
break
}
} else {
const openIdx = this.buffer.indexOf(OPEN_TAG)
if (openIdx !== -1) {
content += this.buffer.slice(0, openIdx)
this.buffer = this.buffer.slice(openIdx + OPEN_TAG.length)
this.inThink = true
} else {
const hold = partialTagSuffix(OPEN_TAG, this.buffer)
content += this.buffer.slice(0, this.buffer.length - hold)
this.buffer = this.buffer.slice(this.buffer.length - hold)
break
}
}
}
return { content, thinking }
}
/**
* Flush whatever is still buffered at end of stream. A held-back partial tag
* that never completed was never a tag, so it belongs to whichever channel
* we were in when the stream ended — losing it would silently truncate the
* answer.
*/
flush(): ThinkSplit {
const pending = this.buffer
this.buffer = ''
return this.inThink ? { content: '', thinking: pending } : { content: pending, thinking: '' }
}
}
/**
* One-shot convenience for non-streaming responses.
*/
export function splitThinkTags(raw: string): ThinkSplit {
const splitter = new ThinkTagSplitter()
const streamed = splitter.push(raw)
const tail = splitter.flush()
return {
content: streamed.content + tail.content,
thinking: streamed.thinking + tail.thinking,
}
}
/**
* Non-streaming counterpart to the merge the streaming normalizers do inline.
*
* A backend may report reasoning structurally (native `message.thinking`, or
* `reasoning` on the /v1 shim), inline as `<think>` tags, or both at once —
* and the ancillary calls (title, suggestions, query rewrite) all go through
* the non-streaming path. Without this the tags reached the sidebar title and,
* worse, the string that gets embedded and sent to Qdrant.
*/
export function normalizeNonStreamed(rawContent: string, nativeThinking?: string): ThinkSplit {
const split = splitThinkTags(rawContent ?? '')
return {
content: split.content,
thinking: (nativeThinking ?? '') + split.thinking,
}
}

View File

@ -0,0 +1,191 @@
/**
* Token estimation for prompt budgeting.
*
* NOMAD cannot tokenize exactly. The model's real vocabulary lives inside a GGUF
* on the Ollama side, Ollama exposes no tokenize endpoint (ollama#12030 is still
* open), and bundling a BPE vocabulary would mean shipping the *wrong* vocabulary
* for whatever Llama/Qwen/Gemma/Granite model the user actually pulled. Fetching
* the right one per model needs network access, which an offline-first appliance
* does not have.
*
* So this estimates — but it estimates in a way that corrects itself. Two parts:
*
* 1. A structural segmenter (below). BPE vocabularies are built from natural text,
* so in practice a token is roughly "a word, or a punctuation mark, or a chunk
* of a long/rare word". Counting those directly tracks a real tokenizer far
* better than dividing character count by a constant, because the constant is
* only right for the kind of text it was tuned on. Prose runs ~4 chars/token;
* dense JSON, markdown tables and code run closer to 2.5. Those are exactly the
* payloads that overflow a window, so that is exactly where a fixed ratio is
* most wrong and most dangerous.
*
* 2. A per-model correction factor learned from ground truth. Every Ollama
* response reports `prompt_eval_count` — the real token count of the prompt we
* just sent. TokenCalibrationService folds the observed error into a per-model
* EWMA and feeds it back here as `ratio`. Costs nothing: no extra inference, no
* extra dependency, and it converges on the actual tokenizer within a couple of
* turns.
*
* Measured against real `prompt_eval_count` over a six-fixture set (prose,
* markdown table, TypeScript, JSON, bullet list, mixed long-form), mean absolute
* error after per-model calibration:
*
* llama3:8b segmenter 5.7% chars/3.5 18.0%
* qwen2.5:0.5b segmenter 6.2% chars/3.5 20.0%
* granite4.1:8b segmenter 6.7% chars/3.5 18.4%
*
* The learned factors were k=1.02, k=1.26 and k=1.00 respectively — which is the
* argument for making it per-model rather than a global constant. Qwen tokenizes
* noticeably finer than Llama, and no single tuned divisor can serve both.
*
* Kept pure and dependency-free so it runs under bare `node --test`, the same
* convention as `rag_prompt.ts`.
*/
/**
* Chars-per-token used by the *ingestion* path when sizing chunks for the
* embedding model.
*
* Deliberately unchanged from RagService's historical value. Chunk size is baked
* into the eval corpus fingerprint, so touching it re-chunks the corpus and
* invalidates every committed retrieval baseline. Re-tuning it is a legitimate
* change, but it is a *retrieval* change and belongs to its own measured PR — not
* folded silently into a context-management refactor. Re-exported here only so
* there is one obvious place to find every token constant.
*/
export const INGEST_CHARS_PER_TOKEN = 2
/**
* Per-message overhead from the chat template: role marker, turn delimiters, and
* the trailing newline the template adds. Real templates cost roughly 3-5 tokens
* a message. The old char-based math ignored this entirely, which quietly
* under-counted a 40-turn conversation by well over a hundred tokens.
*/
export const PER_MESSAGE_OVERHEAD_TOKENS = 4
/** Tokens the template adds once, for the generation prompt that follows the messages. */
export const CONVERSATION_OVERHEAD_TOKENS = 3
/**
* Safety margin applied when a budget decision must not overflow.
*
* Under-estimating is strictly worse than over-estimating: an over-estimate wastes
* a little window, an under-estimate overflows it and hands the backend a silent
* truncation — the exact failure this whole subsystem exists to prevent.
*/
export const ESTIMATE_SAFETY_MARGIN = 1.1
/**
* Character-class fragments, kept as strings so the CJK ranges appear exactly
* once and the regexes below stay readable.
*/
const CJK = '\\u3040-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\uac00-\\ud7af'
const WORD = 'A-Za-z0-9\\u00c0-\\u024f'
/**
* Segments that a BPE tokenizer tends to split on. Order matters: the first
* alternative that matches wins.
*
* - CJK/Hangul codepoints are usually one token each (often more), so they are
* matched singly rather than as words.
* - Runs of letters/digits are word-ish; long ones get sub-split below.
* - Runs of punctuation/symbols are matched as a *run*, not per character. BPE
* vocabularies contain merged punctuation pieces (`":`, `",`, `);`, `],`), so
* charging one token per punctuation character over-counts structured text
* badly -- measured at +48% on a JSON payload before this was split out.
*/
const SEGMENT_RE = new RegExp(`[${CJK}]|[${WORD}]+|[^\\s${WORD}${CJK}]+`, 'gu')
/** Identifies the punctuation-run alternative when walking matches. */
const PUNCT_RUN_RE = new RegExp(`^[^\\s${WORD}${CJK}]+$`, 'u')
/**
* Above this length, a run of letters/digits is a rare word, an identifier, or a
* hash, and a real tokenizer will break it into several pieces.
*/
const LONG_WORD_CHARS = 7
/** Roughly how many characters of a punctuation run one merged token covers. */
const PUNCT_CHARS_PER_TOKEN = 2
/**
* Estimate the token count of a string.
*
* `ratio` is the learned per-model correction (1.0 = uncalibrated). Pass the value
* from TokenCalibrationService.ratioFor(model).
*/
export function estimateTokens(text: string, ratio = 1): number {
if (!text) return 0
let tokens = 0
const matches = text.match(SEGMENT_RE)
if (matches) {
for (const segment of matches) {
if (PUNCT_RUN_RE.test(segment)) {
// Punctuation merges: `":` and `},` are usually single tokens.
tokens += Math.ceil(segment.length / PUNCT_CHARS_PER_TOKEN)
} else if (segment.length > LONG_WORD_CHARS) {
// Long words split into roughly one piece per LONG_WORD_CHARS characters.
tokens += Math.ceil(segment.length / LONG_WORD_CHARS)
} else {
tokens += 1
}
}
}
// Leading whitespace is folded into the following token by most BPE
// vocabularies, but newlines are usually tokens in their own right and matter
// for the markdown-heavy content NOMAD deals in.
const newlines = (text.match(/\n/g) || []).length
tokens += newlines
return Math.max(1, Math.ceil(tokens * ratio))
}
export type EstimatableMessage = { role: string; content: string }
/**
* Estimate the token cost of a full message array as the backend will see it,
* including chat-template overhead.
*/
export function estimateMessagesTokens(messages: EstimatableMessage[], ratio = 1): number {
if (messages.length === 0) return 0
let total = CONVERSATION_OVERHEAD_TOKENS
for (const message of messages) {
total += estimateTokens(message.content, ratio) + PER_MESSAGE_OVERHEAD_TOKENS
}
return total
}
/**
* Estimate with the safety margin applied — use this wherever exceeding the
* budget causes silent truncation rather than merely wasting space.
*/
export function estimateTokensConservative(text: string, ratio = 1): number {
return Math.ceil(estimateTokens(text, ratio) * ESTIMATE_SAFETY_MARGIN)
}
/**
* Fold a fresh observation into an exponentially-weighted moving average.
*
* `alpha` is intentionally low: a single odd turn (a huge code paste, a burst of
* CJK) should nudge the ratio, not redefine it.
*/
export function updateEwma(previous: number | null, observed: number, alpha = 0.25): number {
if (previous === null || !Number.isFinite(previous) || previous <= 0) return observed
return previous * (1 - alpha) + observed * alpha
}
/**
* Guard rail on the learned ratio. A calibration ratio far outside this range
* means something other than tokenization differs — a backend injecting a hidden
* system prompt, a tool schema we didn't count, a reasoning preamble. Clamping
* keeps one bad reading from destroying the budget for every later turn.
*/
export const MIN_CALIBRATION_RATIO = 0.5
export const MAX_CALIBRATION_RATIO = 2.5
export function clampRatio(ratio: number): number {
if (!Number.isFinite(ratio) || ratio <= 0) return 1
return Math.min(MAX_CALIBRATION_RATIO, Math.max(MIN_CALIBRATION_RATIO, ratio))
}

View File

@ -0,0 +1,41 @@
/**
* Decision for whether a batched ZIM ingestion should dispatch a continuation.
*
* This is the pure, I/O-free core of the batch loop in
* `RagService.processZIMFile` (mirrors `decideScanAction` in
* `kb_ingest_decision.ts`).
*
* The signal MUST be the number of articles the extractor *consumed*, never the
* number that produced chunks. An article yields zero chunks whenever its text
* is empty after HTML cleaning — redirect stubs, category listings, image/video
* wrappers, PDF containers. Those are perfectly normal ZIM entries, but they are
* invisible to any count derived from the returned chunks.
*
* Gating on chunk-derived counts silently truncates ingestion the first time a
* window of `batchSize` articles happens to contain mostly empty ones: the
* continuation is never dispatched and every remaining article in the archive is
* skipped. That is not a rare edge case — it is the norm for scraped-site and
* media-heavy archives, where the opening entries are usually navigation pages.
*
* `articlesProcessed < batchSize` means the extractor's iterator ran dry, which
* is the only reliable end-of-archive signal available: `archive.articleCount`
* cannot serve as an upper bound, because `iterByPath()` legitimately yields
* more article entries than that figure for some archives.
*
* A full final batch costs one extra dispatch that extracts nothing and stops.
* That is deliberate: over-running by a single empty batch is cheap, while
* stopping one batch early loses the remainder of the archive.
*/
export interface ZimBatchProgress {
/** Articles the extractor consumed in this batch (not articles that produced chunks). */
articlesProcessed: number
/** The article ceiling requested for this batch. */
batchSize: number
}
export function hasMoreArticleBatches({
articlesProcessed,
batchSize,
}: ZimBatchProgress): boolean {
return articlesProcessed >= batchSize
}

View File

@ -1,6 +1,8 @@
import vine from "@vinejs/vine";
import { SETTINGS_KEYS } from "../../constants/kv_store.js";
import type { KVStoreKey } from "../../types/kv_store.js";
import { CONTEXT_LADDER } from "../utils/context_window.js";
import { RESPONSE_STYLE_PRESETS } from "../../constants/ollama.js";
export const getSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),
@ -62,6 +64,73 @@ export function validateSettingValue(key: KVStoreKey, value: unknown): string |
}
return null
}
case 'ai.contextWindow': {
// "auto" (or empty) hands sizing to ContextWindowService. An explicit
// value is a *cap*, so it only ever lowers the resolved window — but it
// still has to be a real ladder rung, since a value the backend can't
// honour would silently fall back to the default and confuse the user.
if (value === '' || value === 'auto' || value === undefined || value === null) {
return null
}
const num = Number(value)
if (!CONTEXT_LADDER.includes(num as (typeof CONTEXT_LADDER)[number])) {
return `Context window must be "auto" or one of: ${CONTEXT_LADDER.join(', ')}.`
}
return null
}
case 'rag.minRelevance': {
// Empty/'auto' clears the setting and reverts to RAG_MIN_FINAL_SCORE.
// An explicit 0 is a real choice — it turns the floor off — so it is
// deliberately not treated as "unset".
// Trimmed first so the accepted set matches parseMinRelevance's:
// Number(' ') is 0, which would otherwise validate as "floor off"
// for a value the parser reads as unset.
const raw = typeof value === 'string' ? value.trim() : value
if (raw === '' || raw === 'auto' || raw === undefined || raw === null) {
return null
}
const num = Number(raw)
if (!Number.isFinite(num) || num < 0 || num > 1) {
return 'Relevance threshold must be a number between 0 and 1, or empty to use the recommended default.'
}
return null
}
case 'ai.responseStyle': {
// Empty/unset means 'auto'. Anything else has to be a style we know,
// since an unrecognised value silently resolves back to the default
// and would leave the select showing a choice nothing honours.
if (value === '' || value === undefined || value === null) {
return null
}
const styles = RESPONSE_STYLE_PRESETS.map((preset) => preset.value)
if (typeof value !== 'string' || !styles.includes(value as (typeof styles)[number])) {
return `Response style must be one of: ${styles.join(', ')}.`
}
return null
}
case 'ai.keepAlive': {
// Ollama duration format: "10m", "1h", "30s", "-1" (forever), "0" (evict).
if (value === '' || value === undefined || value === null) {
return null
}
if (typeof value !== 'string' || !/^(-?\d+|\d+(\.\d+)?(ms|s|m|h))$/.test(value)) {
return 'Keep-alive must be a duration like "10m", "1h", or "-1" to keep the model loaded indefinitely.'
}
return null
}
case 'ai.amdHsaOverride': {
// Lands in the Ollama container env as HSA_OVERRIDE_GFX_VERSION, so only
// the forms DockerService._resolveAmdHsaOverride honours are accepted.
// Empty clears it and 'none' disables the gfx-marker default.
const raw = typeof value === 'string' ? value.trim().toLowerCase() : value
if (raw === '' || raw === undefined || raw === null || raw === 'none') {
return null
}
if (typeof raw !== 'string' || !/^\d{1,2}\.\d{1,2}\.\d{1,2}$/.test(raw)) {
return 'GFX override must be a version like "11.0.0", "none", or empty to use the detected default.'
}
return null
}
default:
return null
}

View File

@ -1,4 +1,5 @@
import vine from '@vinejs/vine'
import { LINK_TILE_COLOR_IDS } from '../../constants/link_tile_colors.js'
export const installServiceValidator = vine.compile(
vine.object({
@ -100,7 +101,7 @@ export const customAppValidator = vine.compile(
export const setServiceCustomUrlValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
custom_url: vine.string().trim().nullable(),
custom_url: vine.string().trim().maxLength(255).nullable(),
})
)
@ -117,12 +118,52 @@ export function normalizeCustomUrl(input: string | null | undefined): string | n
try {
const url = new URL(withScheme)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
return url.href
// services.custom_url uses the database driver's default VARCHAR(255) size.
// Check the normalized value because prepending a scheme or URL canonicalization
// can make a valid-looking input longer than the stored column.
return url.href.length <= 255 ? url.href : null
} catch {
return null
}
}
/**
* Dashboard link tile: a shortcut to something the user already runs, with no
* container behind it. One `url` field rather than separate host/port/path,
* because people paste URLs and it gets https and sub-paths for free. The URL is
* normalized and http(s)-restricted by normalizeCustomUrl in the controller,
* which is what keeps javascript:/data: out of an href.
*/
export const createLinkTileValidator = vine.compile(
vine.object({
friendly_name: vine.string().trim().minLength(1).maxLength(60),
url: vine.string().trim().minLength(1).maxLength(2048),
description: vine.string().trim().maxLength(200).nullable().optional(),
icon: vine.string().trim().maxLength(60).nullable().optional(),
display_order: vine.number().min(0).max(999).optional(),
link_color: vine.enum(LINK_TILE_COLOR_IDS).optional(),
})
)
/** Reconfigure an existing link tile. Identified by service_name, which is immutable. */
export const updateLinkTileValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
friendly_name: vine.string().trim().minLength(1).maxLength(60),
url: vine.string().trim().minLength(1).maxLength(2048),
description: vine.string().trim().maxLength(200).nullable().optional(),
icon: vine.string().trim().maxLength(60).nullable().optional(),
display_order: vine.number().min(0).max(999).optional(),
link_color: vine.enum(LINK_TILE_COLOR_IDS).optional(),
})
)
export const deleteLinkTileValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
})
)
export const deleteCustomAppValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),

View File

@ -5,6 +5,14 @@ export const listRemoteZimValidator = vine.compile(
start: vine.number().min(0).optional(),
count: vine.number().min(1).max(100).optional(),
query: vine.string().optional(),
// An ISO-639-3 code (`eng`, `fra`, `zho`) or the literal `all`. Constrained to a
// short alphabetic token so this can never smuggle anything else into the upstream
// catalog query string.
language: vine
.string()
.trim()
.regex(/^(all|[a-z]{2,8})$/)
.optional(),
})
)

View File

@ -0,0 +1,127 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
/**
* Diff an eval report against a baseline and fail on regression.
*
* This is the "did my change help?" command, and the one CI would call.
*
* node ace eval:compare tests/eval/baselines/<fp>/retrieval.json tests/eval/reports/latest.json
* node ace eval:compare base.json current.json --tolerance=0.05
* node ace eval:compare --promote=retrieval tests/eval/reports/latest.json
*/
export default class EvalCompare extends BaseCommand {
static commandName = 'eval:compare'
static description = 'Diff an eval report against a baseline; exits non-zero on regression'
@args.string({ description: 'Baseline report JSON (or the report to promote, with --promote)' })
declare baseline: string
@args.string({ description: 'Current report JSON', required: false })
declare current: string
@flags.string({ description: 'Regression tolerance per metric (default: 0.02)' })
declare tolerance: string
@flags.string({
description: 'Promote the given report to a committed baseline under this name, then exit',
})
declare promote: string
@flags.boolean({ description: 'Print the full metric table, not just the regressions' })
declare verbose: boolean
/**
* Deliberately does not boot the application.
*
* Comparing two report files is pure I/O — it needs no MySQL, no Redis, no
* Qdrant, and no Ollama. Keeping it that way is what lets CI run the gate on
* a committed baseline without standing up the whole stack, and it avoids the
* app's noisy Redis teardown on exit.
*/
static options: CommandOptions = {
startApp: false,
}
async run() {
const { EvalReportService } = await import('#services/eval_report_service')
const { compareReports, DEFAULT_TOLERANCE } = await import('../../app/utils/eval/report.js')
const service = new EvalReportService()
try {
if (this.promote) {
const report = await service.read(this.baseline)
const path = await service.promoteToBaseline(report, this.promote)
this.logger.success(`Promoted to baseline: ${path}`)
this.logger.info(`Corpus fingerprint: ${report.meta.corpusFingerprint}`)
this.logger.info('Commit this file so the whole team gates against the same numbers.')
return
}
if (!this.current) {
this.logger.error('Two report paths are required (or use --promote=<name> with one).')
this.exitCode = 1
return
}
const [baseline, current] = await Promise.all([
service.read(this.baseline),
service.read(this.current),
])
const tolerance = this.tolerance ? Number.parseFloat(this.tolerance) : DEFAULT_TOLERANCE
const result = compareReports(baseline, current, tolerance)
if (!result.comparable) {
// Refusing is the correct outcome, not a failure of the tool — diffing
// runs from different corpora would manufacture a regression.
this.logger.error('Reports are not comparable:')
this.logger.error(` ${result.incomparableReason}`)
this.exitCode = 1
return
}
this.logger.info(
`baseline ${short(baseline.meta.gitSha)} (${baseline.meta.createdAt}) -> current ${short(current.meta.gitSha)} (${current.meta.createdAt})`
)
this.logger.info(`corpus ${current.meta.corpusFingerprint} · tolerance ${tolerance}`)
if (current.meta.gitDirty) {
this.logger.warning('Current report was produced from a dirty working tree.')
}
this.logger.info('')
const rows = this.verbose
? [...result.deltas].sort((a, b) => (a.improvement ?? 0) - (b.improvement ?? 0))
: [...result.regressions, ...result.improvements]
if (rows.length === 0) {
this.logger.success('No metric moved outside the tolerance band.')
} else {
for (const d of rows) {
const line = ` ${d.metric.padEnd(24)} ${fmt(d.baseline)} -> ${fmt(d.current)} ${signed(d.delta)}`
if (d.regressed) this.logger.error(`${line} REGRESSED`)
else if ((d.improvement ?? 0) > tolerance) this.logger.success(`${line} improved`)
else this.logger.info(line)
}
}
this.logger.info('')
if (result.regressions.length > 0) {
this.logger.error(
`${result.regressions.length} metric(s) regressed beyond the ${tolerance} tolerance.`
)
this.exitCode = 1
} else {
this.logger.success(
`No regressions. ${result.improvements.length} metric(s) improved beyond tolerance.`
)
}
} catch (error) {
this.logger.error(error instanceof Error ? error.message : String(error))
this.exitCode = 1
}
}
}
const fmt = (v: number | null) => (v === null ? ' n/a' : v.toFixed(4).padStart(6))
const signed = (v: number | null) => (v === null ? ' ' : `${v >= 0 ? '+' : ''}${v.toFixed(4)}`)
const short = (sha: string | null) => (sha ? sha.slice(0, 7) : 'unknown')

View File

@ -0,0 +1,155 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
/**
* Manage the frozen evaluation corpus.
*
* # Validate the corpus and goldens without touching the vector store
* node ace eval:corpus --check
*
* # Show what is currently ingested
* node ace eval:corpus --status
*
* # Wipe and rebuild (the normal path; safe — never touches user content)
* node ace eval:corpus --ingest
*
* # Remove the eval corpus entirely
* node ace eval:corpus --reset
*/
export default class EvalCorpus extends BaseCommand {
static commandName = 'eval:corpus'
static description = 'Manage the frozen RAG evaluation corpus (ingest, reset, status, check)'
@flags.boolean({ description: 'Wipe and re-ingest the corpus into the reserved eval collection' })
declare ingest: boolean
@flags.boolean({ description: 'Remove every eval corpus chunk from the vector store' })
declare reset: boolean
@flags.boolean({ description: 'Show the fingerprint and current chunk count' })
declare status: boolean
@flags.boolean({
description: 'Validate the corpus and goldens only — no Qdrant, no Ollama, no writes',
})
declare check: boolean
@flags.boolean({ description: 'Leave application debug logging on (very noisy)' })
declare debug: boolean
static options: CommandOptions = {
startApp: true,
}
async run() {
const { EvalCorpusService } = await import('#services/eval_corpus_service')
const { quietLogging } = await import('../../app/utils/eval/quiet.js')
const service = await this.app.container.make(EvalCorpusService)
const restoreLogging = quietLogging(this.debug)
try {
await this.dispatch(service)
} finally {
restoreLogging()
}
}
private async dispatch(service: any) {
// Default to --status so a bare invocation is always safe.
if (!this.ingest && !this.reset && !this.check) this.status = true
if (this.check) {
return this.runCheck(service)
}
if (this.reset) {
const removed = await service.reset()
this.logger.success(`Removed ${removed} eval corpus chunks`)
return
}
if (this.ingest) {
return this.runIngest(service)
}
const [fingerprint, chunks, goldens] = await Promise.all([
service.fingerprint(),
service.count(),
service.loadGoldens(['*']),
])
this.logger.info(`Fingerprint: ${fingerprint}`)
this.logger.info(`Chunks in KB: ${chunks}`)
this.logger.info(`Goldens: ${goldens.length}`)
if (chunks === 0) {
this.logger.warning('Corpus is not ingested. Run: node ace eval:corpus --ingest')
}
}
/**
* Validation-only path. Deliberately avoids the container-resolved services'
* network dependencies so a contributor can check their golden edits without
* a running stack.
*/
private async runCheck(service: any) {
try {
const corpus = await service.loadCorpus()
const goldens = await service.loadGoldens(['*'])
const fingerprint = await service.fingerprint()
const tagCounts = new Map<string, number>()
for (const g of goldens) {
for (const tag of g.tags) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1)
}
// A document no golden asks about is dead weight in the corpus: it costs
// ingest time and adds distractor noise nobody chose deliberately.
const referenced = new Set<string>(goldens.flatMap((g: any) => g.relevantDocIds))
const unreferenced = corpus
.map((d: any) => d.docId)
.filter((id: string) => !referenced.has(id))
this.logger.success(`Corpus OK: ${corpus.length} documents, ${goldens.length} goldens`)
this.logger.info(`Fingerprint: ${fingerprint}`)
this.logger.info(
`Tags: ${[...tagCounts.entries()]
.sort((a, b) => b[1] - a[1])
.map(([t, n]) => `${t}=${n}`)
.join(' ')}`
)
const refusals = goldens.filter((g: any) => g.expectRefusal).length
this.logger.info(`Refusal cases: ${refusals} / ${goldens.length}`)
if (unreferenced.length > 0) {
this.logger.warning(`Documents no golden references: ${unreferenced.join(', ')}`)
}
} catch (error) {
this.logger.error(error instanceof Error ? error.message : String(error))
this.exitCode = 1
}
}
private async runIngest(service: any) {
this.logger.info('Rebuilding the eval corpus (user content is never touched)...')
// Validate before destroying anything — a typo in a golden should not cost
// you the ingest you were about to run.
await service.loadGoldens(['*'])
const summary = await service.ingest((docId: string, index: number, total: number) => {
this.logger.info(` [${index}/${total}] ${docId}`)
})
this.logger.info('')
if (summary.removedBeforeIngest > 0) {
this.logger.info(`Removed ${summary.removedBeforeIngest} chunks from the previous ingest`)
}
this.logger.success(`Ingested ${summary.documents} documents into ${summary.chunks} chunks`)
this.logger.info(`Fingerprint: ${summary.fingerprint}`)
if (summary.failures.length > 0) {
this.logger.error(`${summary.failures.length} document(s) failed to ingest:`)
for (const f of summary.failures) this.logger.error(` ${f.docId}: ${f.reason}`)
// A partial corpus produces scores that look real and are not.
this.logger.error('The corpus is incomplete — results from this state are not comparable.')
this.exitCode = 1
}
}
}

View File

@ -0,0 +1,294 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import type {
GenerationAggregate,
GenerationMode,
} from '../../app/services/eval_generation_service.js'
import type { ResponseStyle } from '../../types/ollama.js'
const MODES: GenerationMode[] = ['oracle', 'e2e', 'noretrieval']
const STYLES: ResponseStyle[] = ['auto', 'focused', 'creative', 'off']
/**
* Score generated answers against the golden set.
*
* The `--mode` flag is the whole point of this command:
*
* oracle perfect context injected by construction -> isolates the MODEL
* e2e real retrieval -> the real product
* noretrieval no context at all -> parametric baseline
*
* Run all three on the same model and the ambiguous complaint "the AI gave a
* bad answer" decomposes into a number that can be acted on.
*
* node ace eval:generation --model=llama3.2:latest --mode=oracle
* node ace eval:generation --model=llama3:8b --all-modes
* node ace eval:generation --model=mock # no Ollama needed
*/
export default class EvalGeneration extends BaseCommand {
static commandName = 'eval:generation'
static description = 'Score generated answers, with oracle/e2e/noretrieval ablation'
@flags.string({ description: 'Ollama model to evaluate, or "mock" for the extractive ceiling' })
declare model: string
@flags.string({ description: `One of: ${MODES.join(', ')} (default: e2e)` })
declare mode: string
@flags.boolean({ description: 'Run all three modes and print the decomposition' })
declare allModes: boolean
@flags.string({ description: 'Repeats per question, for stability (default: 3)' })
declare repeats: string
@flags.string({
description:
'Comma-separated golden suites to run, by filename without .jsonl (default: core). ' +
'Extra suites are opt-in so they cannot move the numbers a baseline was recorded against.',
})
declare suite: string
@flags.string({
description:
`Sample the way real chat does, under one of: ${STYLES.join(', ')}. ` +
'Omit for the deterministic temperature-0 default. Greedy decoding makes ' +
'min_p, top_p and top_k inert, so a Response Style is only visible with ' +
'this flag set, and "off" (no samplers, no temperature) is the control to ' +
'compare a style against.',
})
declare style: string
@flags.string({ description: 'Only run goldens carrying this tag' })
declare tag: string
@flags.string({ description: 'Limit to the first N goldens (for a quick smoke run)' })
declare limit: string
@flags.boolean({ description: 'Print every failing question with the answer the model gave' })
declare verbose: boolean
@flags.boolean({ description: 'Leave application debug logging on (very noisy)' })
declare debug: boolean
static options: CommandOptions = {
startApp: true,
}
async run() {
const { EvalCorpusService } = await import('#services/eval_corpus_service')
const { EvalGenerationService, MOCK_MODEL } = await import('#services/eval_generation_service')
const { quietLogging } = await import('../../app/utils/eval/quiet.js')
const corpusService = await this.app.container.make(EvalCorpusService)
const generationService = await this.app.container.make(EvalGenerationService)
const restoreLogging = quietLogging(this.debug)
try {
if (!this.model) {
this.logger.error('--model is required (use --model=mock to run without Ollama)')
this.exitCode = 1
return
}
const modes: GenerationMode[] = this.allModes
? MODES
: [(this.mode as GenerationMode) || 'e2e']
for (const mode of modes) {
if (!MODES.includes(mode)) {
this.logger.error(`Unknown mode "${mode}". Expected one of: ${MODES.join(', ')}`)
this.exitCode = 1
return
}
}
if (this.style && !STYLES.includes(this.style as ResponseStyle)) {
this.logger.error(`Unknown style "${this.style}". Expected one of: ${STYLES.join(', ')}`)
this.exitCode = 1
return
}
const samplerStyle = this.style ? (this.style as ResponseStyle) : undefined
let goldens = await corpusService.loadGoldens(this.suite ? this.suite.split(',').map((s) => s.trim()) : undefined)
if (this.tag) goldens = goldens.filter((g) => g.tags.includes(this.tag))
if (this.limit) goldens = goldens.slice(0, Number.parseInt(this.limit, 10))
if (goldens.length === 0) {
this.logger.error('No goldens matched the given filters')
this.exitCode = 1
return
}
// e2e and oracle both need the corpus present; noretrieval does not, but
// requiring it uniformly keeps the three modes comparable.
const chunks = await corpusService.count()
if (chunks === 0 && modes.some((m) => m !== 'noretrieval')) {
this.logger.error('The eval corpus is not ingested. Run: node ace eval:corpus --ingest')
this.exitCode = 1
return
}
const fingerprint = await corpusService.fingerprint()
const repeats = this.repeats ? Number.parseInt(this.repeats, 10) : 3
this.logger.info(
`Corpus ${fingerprint} · ${goldens.length} goldens · model=${this.model} · repeats=${repeats}` +
(samplerStyle ? ` · style=${samplerStyle}` : ' · style=deterministic (temp 0)')
)
if (samplerStyle) {
this.logger.info(
'Sampling is on: the seed is fixed but the run is only approximately reproducible. Watch the unstable count.'
)
}
if (this.model === MOCK_MODEL) {
this.logger.info(
'Mock model: answers are the injected context verbatim — this is the extractive ceiling for the current retrieval, not a real model.'
)
}
this.logger.info('')
const summaries: Array<{ mode: GenerationMode; agg: GenerationAggregate }> = []
for (const mode of modes) {
const result = await generationService.run(goldens, {
mode,
model: this.model,
repeats,
samplerStyle,
onProgress: (id, index, total) => {
if (this.verbose) this.logger.info(` [${index}/${total}] ${mode}: ${id}`)
},
})
summaries.push({ mode, agg: result.overall })
this.printAggregate(mode, result.overall, result.elapsedMs)
if (result.overall.errors > 0) {
this.logger.error(
`${result.overall.errors} question(s) errored talking to the model — the scores above are incomplete.`
)
const firstError = result.cases.find((c) => c.error)
if (firstError) this.logger.error(` first error (${firstError.id}): ${firstError.error}`)
this.exitCode = 1
}
if (this.verbose) this.printFailures(result.cases)
}
if (summaries.length > 1) this.printDecomposition(summaries)
} catch (error) {
this.logger.error(error instanceof Error ? error.message : String(error))
this.exitCode = 1
} finally {
restoreLogging()
}
}
private printAggregate(mode: GenerationMode, agg: GenerationAggregate, elapsedMs: number) {
this.logger.info(`=== ${mode.toUpperCase()} (${(elapsedMs / 1000).toFixed(1)}s) ===`)
this.logger.info(` correctness ${fmt(agg.correctness)}`)
this.logger.info(` refusal correct ${fmt(agg.refusalCorrectness)}`)
this.logger.info(` leakage rate ${fmt(agg.leakageRate)} (lower is better)`)
this.logger.info(` think-tag leak ${fmt(agg.thinkTagLeakRate)} (should be 0)`)
this.logger.info(` markdown formatted ${fmt(agg.markdownRate)}`)
this.logger.info(
` numeric grounding ${agg.groundedness ? `${agg.groundedness.mean.toFixed(3)} (n=${agg.groundedness.n})` : ' n/a'}`
)
this.logger.info(
` mean answer length ${agg.meanAnswerLength === null ? 'n/a' : Math.round(agg.meanAnswerLength)} chars`
)
// Context budgeting. These explain *why* a correctness number moved: an
// answer the model never had the material for is a different failure from
// an answer it got wrong, and only these two lines tell them apart.
if (agg.meanPromptTokens !== null) {
this.logger.info(` mean prompt tokens ${Math.round(agg.meanPromptTokens)}`)
}
if (agg.historyElidedRate !== null) {
this.logger.info(
` history elided ${fmt(agg.historyElidedRate)} (share of cases needing a trim)`
)
}
if (agg.chunksDroppedRate !== null) {
this.logger.info(
` context dropped ${fmt(agg.chunksDroppedRate)} (share of cases where a chunk didn't fit)`
)
}
if (agg.promptTokenError) {
// Every budget decision rests on the estimate; if this drifts, the trims
// above are being made against a number that is no longer true.
this.logger.info(
` token estimate err ${(agg.promptTokenError.mean * 100).toFixed(1)}% (vs the backend's real count)`
)
}
if (agg.unstable > 0) {
this.logger.warning(
` ${agg.unstable} question(s) flipped between repeats — excluded from gating, do not read them as a regression.`
)
}
this.logger.info('')
}
/**
* The reason this command exists: turn three scores into an attribution.
*/
private printDecomposition(summaries: Array<{ mode: GenerationMode; agg: GenerationAggregate }>) {
const get = (mode: GenerationMode) => summaries.find((s) => s.mode === mode)?.agg.correctness ?? null
const oracle = get('oracle')
const e2e = get('e2e')
const none = get('noretrieval')
this.logger.info('=== Decomposition ===')
if (oracle !== null && e2e !== null) {
const cost = oracle - e2e
this.logger.info(` retrieval cost ${cost.toFixed(3)} (oracle ${oracle.toFixed(3)} - e2e ${e2e.toFixed(3)})`)
this.logger.info(` model ceiling ${oracle.toFixed(3)} (what this model manages with perfect context)`)
}
if (e2e !== null && none !== null) {
this.logger.info(` RAG contribution ${(e2e - none).toFixed(3)} (e2e ${e2e.toFixed(3)} - noretrieval ${none.toFixed(3)})`)
}
if (oracle !== null && e2e !== null) {
// The actual triage rule, stated so nobody has to re-derive it.
const cost = oracle - e2e
if (oracle < 0.6) {
this.logger.warning(
' Oracle is low: this model struggles even with perfect context. Retrieval work will not fix it — try a larger model.'
)
} else if (cost > 0.15) {
this.logger.warning(
' Large retrieval cost: the model can use good context but is not being given it. Work on retrieval.'
)
} else {
this.logger.success(' Model and retrieval are both holding up on this corpus.')
}
}
if (none !== null && e2e !== null && e2e <= none) {
this.logger.warning(
' RAG is not adding anything over the bare model on this set — check that retrieval is actually reaching the prompt.'
)
}
}
private printFailures(cases: Array<any>) {
const failures = cases.filter((c) => c.correctness.passRate < 1 || c.refusalCorrectness.passRate < 1)
if (failures.length === 0) return
this.logger.info('--- failures ---')
for (const f of failures) {
this.logger.info(` ${f.id} correct=${f.correctness.passes}/${f.correctness.repeats} refusal=${f.refusalCorrectness.passes}/${f.refusalCorrectness.repeats}`)
const score = f.scores[0]
if (score?.missedRequired?.length) {
this.logger.info(` missed: ${score.missedRequired.join(' | ')}`)
}
if (score?.hitForbidden?.length) {
this.logger.info(` said forbidden: ${score.hitForbidden.join(' | ')}`)
}
if (score?.leakage?.length) {
this.logger.info(` narrated retrieval`)
}
if (f.answers[0]) {
const preview = f.answers[0].replace(/\s+/g, ' ').slice(0, 220)
this.logger.info(` said: ${preview}${f.answers[0].length > 220 ? '…' : ''}`)
}
}
this.logger.info('')
}
}
const fmt = (v: number | null) => (v === null ? ' n/a' : v.toFixed(3))

View File

@ -0,0 +1,178 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import type { GenerationAggregate } from '../../app/services/eval_generation_service.js'
type Row = {
model: string
oracle: GenerationAggregate | null
e2e: GenerationAggregate | null
error?: string
}
/**
* Build the model capability table.
*
* This is the artifact that answers a GitHub issue. When a user reports "the
* AI gave me a bad answer", look up their model in this table:
*
* - scoring at or near its row -> the model is at its ceiling. The honest
* answer is "run a larger model", not a bug.
* - scoring well below its row -> something is wrong with their config or
* our code, and it is worth investigating.
*
* Without it, every quality concern is unfalsifiable.
*
* node ace eval:matrix --models=llama3.2:latest,llama3:8b --limit=30
* node ace eval:matrix --models=... --promote # commit as the reference table
*/
export default class EvalMatrix extends BaseCommand {
static commandName = 'eval:matrix'
static description = 'Score several models side by side to build the capability reference table'
@flags.string({ description: 'Comma-separated Ollama model names' })
declare models: string
@flags.string({ description: 'Repeats per question (default: 1 — the matrix is already long)' })
declare repeats: string
@flags.string({ description: 'Limit to the first N goldens' })
declare limit: string
@flags.string({ description: 'Only run goldens carrying this tag' })
declare tag: string
@flags.boolean({ description: 'Write the table to tests/eval/baselines/<fingerprint>/matrix.json' })
declare promote: boolean
@flags.boolean({ description: 'Leave application debug logging on (very noisy)' })
declare debug: boolean
static options: CommandOptions = {
startApp: true,
}
async run() {
const { EvalCorpusService } = await import('#services/eval_corpus_service')
const { EvalGenerationService } = await import('#services/eval_generation_service')
const { quietLogging } = await import('../../app/utils/eval/quiet.js')
const { mkdir, writeFile } = await import('node:fs/promises')
const { dirname, join, resolve } = await import('node:path')
const corpusService = await this.app.container.make(EvalCorpusService)
const generationService = await this.app.container.make(EvalGenerationService)
const restoreLogging = quietLogging(this.debug)
try {
if (!this.models) {
this.logger.error('--models is required, e.g. --models=llama3.2:latest,llama3:8b')
this.exitCode = 1
return
}
const models = this.models.split(',').map((m) => m.trim()).filter(Boolean)
let goldens = await corpusService.loadGoldens()
if (this.tag) goldens = goldens.filter((g) => g.tags.includes(this.tag))
if (this.limit) goldens = goldens.slice(0, Number.parseInt(this.limit, 10))
const fingerprint = await corpusService.fingerprint()
const repeats = this.repeats ? Number.parseInt(this.repeats, 10) : 1
this.logger.info(
`Corpus ${fingerprint} · ${goldens.length} goldens · ${models.length} models · repeats=${repeats}`
)
this.logger.info('Running oracle and e2e for each model. This takes a while.')
this.logger.info('')
const rows: Row[] = []
for (const model of models) {
this.logger.info(`--- ${model} ---`)
const row: Row = { model, oracle: null, e2e: null }
try {
// Only oracle and e2e: those two are what the triage rule needs, and
// adding noretrieval would half again the runtime of an already long
// command for a number that does not change the verdict.
for (const mode of ['oracle', 'e2e'] as const) {
const started = Date.now()
const result = await generationService.run(goldens, { mode, model, repeats })
row[mode] = result.overall
this.logger.info(
` ${mode.padEnd(6)} correctness=${fmt(result.overall.correctness)} refusal=${fmt(result.overall.refusalCorrectness)} (${((Date.now() - started) / 1000).toFixed(0)}s)`
)
if (result.overall.errors > 0) {
this.logger.warning(` ${result.overall.errors} question(s) errored in ${mode}`)
}
}
} catch (error) {
row.error = error instanceof Error ? error.message : String(error)
this.logger.error(` failed: ${row.error}`)
}
rows.push(row)
}
this.logger.info('')
this.printTable(rows)
if (this.promote) {
const path = resolve(join(process.cwd(), 'tests/eval/baselines', fingerprint, 'matrix.json'))
await mkdir(dirname(path), { recursive: true })
await writeFile(
path,
JSON.stringify(
{
corpusFingerprint: fingerprint,
createdAt: new Date().toISOString(),
goldens: goldens.length,
repeats,
tag: this.tag ?? null,
rows,
},
null,
2
)
)
this.logger.success(`Capability table written: ${path}`)
this.logger.info('Commit it — this is the reference a support triage compares against.')
}
} catch (error) {
this.logger.error(error instanceof Error ? error.message : String(error))
this.exitCode = 1
} finally {
restoreLogging()
}
}
private printTable(rows: Row[]) {
this.logger.info('=== Capability matrix ===')
this.logger.info('')
const width = Math.max(20, ...rows.map((r) => r.model.length + 2))
this.logger.info(
`${'model'.padEnd(width)}${'oracle'.padStart(8)}${'e2e'.padStart(8)}${'ret.cost'.padStart(10)}${'refusal'.padStart(9)}${'leakage'.padStart(9)}${'ground'.padStart(8)}`
)
for (const row of rows) {
if (row.error) {
this.logger.info(`${row.model.padEnd(width)} ERROR: ${row.error}`)
continue
}
const cost =
row.oracle?.correctness !== null && row.oracle?.correctness !== undefined &&
row.e2e?.correctness !== null && row.e2e?.correctness !== undefined
? row.oracle.correctness - row.e2e.correctness
: null
this.logger.info(
row.model.padEnd(width) +
fmt(row.oracle?.correctness ?? null).padStart(8) +
fmt(row.e2e?.correctness ?? null).padStart(8) +
(cost === null ? ' n/a' : `${cost >= 0 ? '+' : ''}${cost.toFixed(3)}`).padStart(10) +
fmt(row.e2e?.refusalCorrectness ?? null).padStart(9) +
fmt(row.e2e?.leakageRate ?? null).padStart(9) +
fmt(row.e2e?.groundedness?.mean ?? null).padStart(8)
)
}
this.logger.info('')
this.logger.info('oracle = correctness with perfect context (the model\'s ceiling)')
this.logger.info('e2e = correctness with real retrieval (what a user gets)')
this.logger.info('ret.cost = oracle - e2e (what imperfect retrieval costs this model)')
this.logger.info('leakage = rate of narrating retrieval; lower is better')
}
}
const fmt = (v: number | null) => (v === null ? ' n/a' : v.toFixed(3))

View File

@ -0,0 +1,364 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import type { RetrievalAggregate } from '../../app/utils/eval/retrieval_metrics.js'
/**
* Score NOMAD's retrieval against the frozen golden set.
*
* No chat model is involved, so this is deterministic and hardware-independent:
* a movement in these numbers is a code change, not a slow machine or an unlucky
* sample. It is the fast inner loop for anything touching chunking, embedding,
* thresholds, or reranking.
*
* node ace eval:retrieval
* node ace eval:retrieval --ablate # is the reranker helping?
* node ace eval:retrieval --threshold=0.5 # sweep the Qdrant cutoff
* node ace eval:retrieval --min-final-score=0.6 # sweep the post-rerank floor
* node ace eval:retrieval --tag=multi-hop # one slice only
*/
export default class EvalRetrieval extends BaseCommand {
static commandName = 'eval:retrieval'
static description = 'Score RAG retrieval against the golden set (deterministic, no chat model)'
@flags.string({ description: 'Chunks to retrieve per query (default: the production value)' })
declare topK: string
@flags.string({ description: 'Minimum similarity score (default: the production value)' })
declare threshold: string
@flags.string({
description:
'Post-rerank relevance floor; chunks below it are dropped (default: the production value). ' +
'Reads RAG_MIN_FINAL_SCORE, never the rag.minRelevance setting — see EvalRetrievalService.',
})
declare minFinalScore: string
@flags.string({
description:
'Run the retrieval relevance check on this model after the floor (default: off, keeping this tier model-free)',
})
declare judgeModel: string
@flags.boolean({ description: 'Also score the raw dense, reranked, and diversified orderings' })
declare ablate: boolean
@flags.string({
description:
'Comma-separated golden suites to run, by filename without .jsonl (default: core). ' +
'Extra suites are opt-in so they cannot move the numbers a baseline was recorded against.',
})
declare suite: string
@flags.string({ description: 'Only run goldens carrying this tag' })
declare tag: string
@flags.boolean({ description: 'Print each failing question and what it retrieved' })
declare verbose: boolean
@flags.boolean({ description: 'Leave application debug logging on (very noisy)' })
declare debug: boolean
@flags.boolean({ description: 'Write a JSON + Markdown report to tests/eval/reports/' })
declare report: boolean
@flags.string({
description:
"Write every golden's full pre-floor candidate pool (boosted + cosine scores) to this JSON path, " +
'for simulating a relevance gate offline',
})
declare dump: string
static options: CommandOptions = {
startApp: true,
}
async run() {
const { EvalCorpusService } = await import('#services/eval_corpus_service')
const { EvalRetrievalService } = await import('#services/eval_retrieval_service')
const { EvalReportService } = await import('#services/eval_report_service')
const { quietLogging } = await import('../../app/utils/eval/quiet.js')
const corpusService = await this.app.container.make(EvalCorpusService)
const retrievalService = await this.app.container.make(EvalRetrievalService)
const reportService = await this.app.container.make(EvalReportService)
const restoreLogging = quietLogging(this.debug)
try {
const chunks = await retrievalService.assertCorpusReady()
const fingerprint = await corpusService.fingerprint()
let goldens = await corpusService.loadGoldens(this.suite ? this.suite.split(',').map((s) => s.trim()) : undefined)
if (this.tag) {
goldens = goldens.filter((g) => g.tags.includes(this.tag))
if (goldens.length === 0) {
this.logger.error(`No goldens carry the tag "${this.tag}"`)
this.exitCode = 1
return
}
}
this.logger.info(`Corpus ${fingerprint} · ${chunks} chunks · ${goldens.length} goldens`)
this.logger.info('')
const started = Date.now()
const result = await retrievalService.run(goldens, {
topK: this.topK ? Number.parseInt(this.topK, 10) : undefined,
scoreThreshold: this.threshold ? Number.parseFloat(this.threshold) : undefined,
minFinalScore: this.minFinalScore ? Number.parseFloat(this.minFinalScore) : undefined,
ablate: this.ablate,
dump: Boolean(this.dump),
judgeModel: this.judgeModel || undefined,
})
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
this.logger.info(
`Params: topK=${result.params.topK} threshold=${result.params.scoreThreshold} ` +
`minFinalScore=${result.params.minFinalScore} (${elapsed}s)`
)
if (result.judge) {
const j = result.judge
this.logger.info(
`Relevance check: ${j.model} · ${j.calls} calls · rejected ${j.rejected} chunk(s) · ` +
`${j.failures} failed open · mean ${Math.round(j.meanMs)}ms max ${Math.round(j.maxMs)}ms`
)
}
this.logger.info('')
this.printAggregate('OVERALL', result.overall, result.params.kValues)
if (result.unresolvedChunks > 0) {
// Every retrieved chunk should belong to the eval corpus. Anything else
// means the collection filter leaked and the numbers above describe a
// corpus nobody chose.
this.logger.error(
`${result.unresolvedChunks} retrieved chunk(s) did not belong to the eval corpus — the collection filter leaked.`
)
this.exitCode = 1
}
this.printThresholdGuidance(result.overall)
if (result.ablation) {
this.logger.info('')
this.logger.info('=== Stage ablation (does each heuristic earn its place?) ===')
const k = result.params.kValues.includes(5) ? 5 : result.params.kValues[0]
const row = (name: string, agg: RetrievalAggregate) =>
this.logger.info(
` ${name.padEnd(14)} recall@${k}=${fmt(agg.recall[k])} ndcg@${k}=${fmt(agg.ndcg[k])} mrr=${fmt(agg.mrr)} prec@${k}=${fmt(agg.precision[k])}`
)
row('dense only', result.ablation.dense)
row('+ rerank', result.ablation.reranked)
row('+ diversity', result.ablation.diversified)
this.explainAblation(result.ablation, k)
}
this.logger.info('')
this.logger.info('=== By tag ===')
for (const [tag, agg] of Object.entries(result.byTag).sort()) {
const k = result.params.kValues.includes(5) ? 5 : result.params.kValues[0]
// A refusal-only tag has no recall to report; what it has is the leak rate,
// which is the number #1341 is about.
const leak =
agg.nonEmptyRateOnRefusal === null ? '' : ` leaked=${pct(agg.nonEmptyRateOnRefusal)}`
this.logger.info(
` ${tag.padEnd(20)} n=${String(agg.cases).padStart(3)} recall@${k}=${fmt(agg.recall[k])} ndcg@${k}=${fmt(agg.ndcg[k])}${leak}`
)
}
const k = result.params.kValues.includes(5) ? 5 : result.params.kValues[0]
const misses = result.cases.filter((c) => !c.expectRefusal && (c.recall[k] ?? 1) < 1)
this.logger.info('')
this.logger.info(`${misses.length} of ${result.overall.answerable} answerable questions missed at k=${k}`)
if (this.verbose && misses.length > 0) {
this.logger.info('')
this.logger.info('=== Misses ===')
for (const miss of misses) {
this.logger.info(` ${miss.id}`)
this.logger.info(` wanted: ${miss.relevantDocIds.join(', ') || '(none)'}`)
this.logger.info(` retrieved: ${miss.retrievedDocIds.join(', ') || '(nothing)'}`)
}
} else if (misses.length > 0) {
this.logger.info('Re-run with --verbose to see which questions and what they retrieved.')
}
// The other failure: a question the corpus cannot answer that retrieved
// something anyway. Each of these becomes a citation the user sees.
const leaks = result.cases.filter((c) => c.expectRefusal && !c.empty)
if (leaks.length > 0) {
this.logger.info('')
this.logger.info(`${leaks.length} out-of-corpus question(s) retrieved something anyway`)
if (this.verbose) {
this.logger.info('')
this.logger.info('=== Leaks ===')
for (const leak of leaks) {
this.logger.info(` ${leak.id}`)
const top = leak.top
? ` (top final=${leak.top.score.toFixed(3)} semantic=${leak.top.semanticScore?.toFixed(3) ?? 'n/a'})`
: ''
this.logger.info(` retrieved: ${leak.retrievedDocIds.join(', ')}${top}`)
}
}
}
if (this.dump && result.pools) {
const { writeFile } = await import('node:fs/promises')
await writeFile(this.dump, JSON.stringify({ fingerprint, params: result.params, pools: result.pools }, null, 2))
this.logger.info('')
this.logger.success(`Candidate pools written: ${this.dump}`)
}
if (this.report) {
const meta = await reportService.buildMeta('retrieval', fingerprint, {
...result.params,
tag: this.tag ?? null,
})
const doc = reportService.fromRetrieval(meta, result)
const slug = `retrieval-${meta.createdAt.replace(/[:.]/g, '-')}`
const path = await reportService.write(doc, slug, renderRetrievalMarkdown(doc, result, misses))
this.logger.info('')
this.logger.success(`Report written: ${path}`)
}
} catch (error) {
this.logger.error(error instanceof Error ? error.message : String(error))
this.exitCode = 1
} finally {
restoreLogging()
}
}
private printAggregate(label: string, agg: RetrievalAggregate, kValues: number[]) {
this.logger.info(`=== ${label} (${agg.answerable} answerable of ${agg.cases}) ===`)
const row = (name: string, values: Record<number, number | null>) =>
this.logger.info(
` ${name.padEnd(10)} ${kValues.map((k) => `@${k}=${fmt(values[k])}`).join(' ')}`
)
row('recall', agg.recall)
row('hit rate', agg.hitRate)
row('precision', agg.precision)
row('ndcg', agg.ndcg)
this.logger.info(` mrr ${fmt(agg.mrr)}`)
}
/**
* Turn the score distributions into an actual recommendation. The raw
* percentiles are the evidence; this is the reading of them, which is what
* the threshold constants have never had.
*
* Printed per axis, because the two cutoffs filter different numbers:
* `--threshold` is applied by Qdrant to the raw cosine score, and
* `--min-final-score` is applied after reranking. Reading one off the other's
* percentiles is wrong by the reranker's boost factor.
*/
private printThresholdGuidance(agg: RetrievalAggregate) {
this.logger.info('')
this.logger.info('=== Score separation (how to calibrate the cutoffs) ===')
this.printAxis(
'semantic, pre-rerank — calibrates --threshold',
agg.relevantScores,
agg.irrelevantScores
)
this.printAxis(
'final, post-rerank — calibrates --min-final-score',
agg.relevantFinalScores,
agg.irrelevantFinalScores
)
if (agg.emptyRateOnAnswerable !== null && agg.emptyRateOnAnswerable > 0) {
this.logger.warning(
` ${pct(agg.emptyRateOnAnswerable)} of answerable questions retrieved nothing — threshold may be too high.`
)
}
if (agg.nonEmptyRateOnRefusal !== null && agg.nonEmptyRateOnRefusal > 0) {
this.logger.warning(
` ${pct(agg.nonEmptyRateOnRefusal)} of out-of-corpus questions retrieved something anyway — that context is what produces confident wrong answers.`
)
}
}
/** One axis of the separation table, plus the reading of it. */
private printAxis(
label: string,
rel: RetrievalAggregate['relevantScores'],
irr: RetrievalAggregate['irrelevantScores']
) {
if (!rel && !irr) return
this.logger.info(` ${label}`)
const row = (name: string, d: NonNullable<typeof rel>) =>
this.logger.info(
` ${name.padEnd(18)} n=${d.count} min=${d.min.toFixed(3)} p10=${d.p10.toFixed(3)} median=${d.median.toFixed(3)} p90=${d.p90.toFixed(3)}`
)
if (rel) row('relevant chunks', rel)
if (irr) row('irrelevant chunks', irr)
if (rel && irr) {
if (rel.p10 > irr.p90) {
this.logger.success(
` Clean separation: a cutoff between ${irr.p90.toFixed(3)} and ${rel.p10.toFixed(3)} splits them.`
)
} else {
this.logger.warning(
` Overlapping: relevant p10 (${rel.p10.toFixed(3)}) sits below irrelevant p90 (${irr.p90.toFixed(3)}); ` +
`a cutoff near ${rel.min.toFixed(3)} keeps every relevant chunk seen here.`
)
}
}
}
private explainAblation(ablation: { dense: RetrievalAggregate; reranked: RetrievalAggregate; diversified: RetrievalAggregate }, k: number) {
const verdict = (name: string, before: number | null, after: number | null) => {
if (before === null || after === null) return
const delta = after - before
if (Math.abs(delta) < 1e-6) {
this.logger.warning(` ${name} changed nothing at k=${k} — it is complexity with no measured benefit.`)
} else if (delta < 0) {
this.logger.warning(` ${name} made ndcg@${k} worse by ${Math.abs(delta).toFixed(4)}.`)
} else {
this.logger.success(` ${name} improved ndcg@${k} by ${delta.toFixed(4)}.`)
}
}
verdict('Reranking', ablation.dense.ndcg[k], ablation.reranked.ndcg[k])
verdict('Source diversity', ablation.reranked.ndcg[k], ablation.diversified.ndcg[k])
}
}
const fmt = (v: number | null) => (v === null ? ' n/a' : v.toFixed(3))
const pct = (v: number) => `${(v * 100).toFixed(0)}%`
/**
* The human-readable half of a report. Leads with the misses, because when a
* number moves the next question is always "which questions?" and a table of
* aggregates cannot answer it.
*/
function renderRetrievalMarkdown(doc: any, result: any, misses: any[]): string {
const lines: string[] = ['# Retrieval eval', '']
lines.push(`- corpus: \`${doc.meta.corpusFingerprint}\``)
lines.push(`- commit: \`${doc.meta.gitSha ?? 'unknown'}\`${doc.meta.gitDirty ? ' (dirty tree)' : ''}`)
lines.push(`- when: ${doc.meta.createdAt}`)
lines.push(
`- params: topK=${result.params.topK} threshold=${result.params.scoreThreshold} minFinalScore=${result.params.minFinalScore}`
)
lines.push('')
lines.push('## Metrics', '')
lines.push('| metric | value |', '|---|---:|')
for (const [name, value] of Object.entries(doc.metrics)) {
lines.push(`| ${name} | ${value === null ? 'n/a' : (value as number).toFixed(4)} |`)
}
lines.push('')
lines.push(`## Misses (${misses.length})`, '')
if (misses.length === 0) {
lines.push('None.')
} else {
for (const m of misses) {
lines.push(`### \`${m.id}\``)
lines.push(`- wanted: ${m.relevantDocIds.join(', ') || '_none_'}`)
lines.push(`- retrieved: ${m.retrievedDocIds.join(', ') || '_nothing_'}`)
lines.push('')
}
}
lines.push('## By tag', '')
lines.push('| tag | recall@5 | ndcg@5 |', '|---|---:|---:|')
for (const [tag, metrics] of Object.entries(doc.byTag).sort()) {
const m = metrics as Record<string, number | null>
const cell = (v: number | null | undefined) => (v === null || v === undefined ? 'n/a' : v.toFixed(4))
lines.push(`| ${tag} | ${cell(m['recall@5'])} | ${cell(m['ndcg@5'])} |`)
}
return lines.join('\n') + '\n'
}

View File

@ -14,6 +14,7 @@ import { AppAutoUpdateJob } from '#jobs/app_auto_update_job'
import { ContentAutoUpdateJob } from '#jobs/content_auto_update_job'
import { DownloadDrugDataJob } from '#jobs/download_drug_data_job'
import { IngestDrugDataJob } from '#jobs/ingest_drug_data_job'
import { stallOptionsForQueue, type StallOptions } from '../../app/utils/queue_stall_options.js'
export default class QueueWork extends BaseCommand {
static commandName = 'queue:work'
@ -68,11 +69,10 @@ export default class QueueWork extends BaseCommand {
{
connection: queueConfig.connection,
concurrency: this.getConcurrencyForQueue(queueName),
// lockDuration/maxStalledCount are per-queue. Non-drug queues keep
// the existing default (300000, BullMQ's default maxStalledCount).
// The drug download/ingest queues are NEW per-queue overrides
// (1_800_000 / 3) — see getStallOptionsForQueue — not a change to
// the default applied to every other queue.
// lockDuration/maxStalledCount are per-queue — see
// getStallOptionsForQueue for which queues override the default
// (300000, BullMQ's default maxStalledCount) and why. Queues without
// an entry there are unaffected.
lockDuration: stall.lockDuration,
...(stall.maxStalledCount !== undefined
? { maxStalledCount: stall.maxStalledCount }
@ -203,26 +203,17 @@ export default class QueueWork extends BaseCommand {
}
/**
* Per-queue BullMQ stall-recovery options.
*
* Every queue except the two drug queues keeps the branch default
* (lockDuration 300000, and BullMQ's default maxStalledCount of 1 — left
* unset). The drug download/ingest queues are the ONLY per-queue override:
* each part is a long single stream (a ~150 MB resumable HTTP pull, then an
* unzip + JSON-stream ingest at concurrency 1), so a longer lock plus a
* higher stalled tolerance keeps a transient lock-renewal miss from killing
* the continuation chain ("job stalled more than allowable limit").
* Per-queue BullMQ stall-recovery options. See `queue_stall_options.ts` for
* the reasoning behind each queue's entry; the decision itself is kept pure
* and unit-tested there.
*/
private getStallOptionsForQueue(
queueName: string
): { lockDuration: number; maxStalledCount?: number } {
if (
queueName === DownloadDrugDataJob.queue ||
queueName === IngestDrugDataJob.queue
) {
return { lockDuration: 1_800_000, maxStalledCount: 3 }
}
return { lockDuration: 300000 }
private getStallOptionsForQueue(queueName: string): StallOptions {
return stallOptionsForQueue(queueName, {
embedFile: EmbedFileJob.queue,
download: RunDownloadJob.queue,
drugDownload: DownloadDrugDataJob.queue,
drugIngest: IngestDrugDataJob.queue,
})
}
/**

View File

@ -21,6 +21,21 @@ export const KB_COLLECTIONS = [
export type KbCollection = (typeof KB_COLLECTIONS)[number]
/**
* Reserved collection tag for the developer evaluation corpus (`ace eval:*`).
*
* The eval fixtures live in the same Qdrant collection as everything else —
* NOMAD "collections" are a payload tag, not separate Qdrant collections — so
* this tag is what keeps them out of the user's Knowledge Base UI and out of
* ordinary chat retrieval. Every read path that enumerates user content filters
* it out, and `sanitizeCollectionName` refuses to mint it, so a user cannot
* create a colliding tag by accident.
*
* The leading/trailing underscores are deliberate: they make the tag obviously
* internal if it ever does surface in a log or a raw Qdrant query.
*/
export const KB_EVAL_COLLECTION = '__nomad_eval__'
/** Hard cap on a user-created tag's length, enforced client- and server-side. */
export const KB_COLLECTION_NAME_MAX_LENGTH = 40
@ -36,5 +51,9 @@ export function sanitizeCollectionName(raw: string | null | undefined): string |
if (!raw) return null
const trimmed = raw.trim().toLowerCase()
if (!trimmed) return null
// The eval tag is reserved. Treating a collision as "uncategorized" rather
// than throwing keeps this a pure normalizer, and the user's documents stay
// visible in the KB instead of vanishing into a hidden internal collection.
if (trimmed === KB_EVAL_COLLECTION) return null
return trimmed.slice(0, KB_COLLECTION_NAME_MAX_LENGTH)
}

View File

@ -11,7 +11,15 @@ export const SETTINGS_KEYS: KVStoreKey[] = [
'ai.remoteOllamaUrl',
'ai.ollamaFlashAttention',
'ai.autoThinking',
'ai.tasksModel',
'ai.contextWindow',
'ai.keepAlive',
'ai.responseStyle',
'ai.amdHsaOverride',
'rag.defaultIngestPolicy',
'rag.enabled',
'rag.minRelevance',
'rag.relevanceCheck',
'autoUpdate.enabled',
'autoUpdate.windowStart',
'autoUpdate.windowEnd',

View File

@ -0,0 +1,81 @@
/**
* The colors a user can give a dashboard link tile.
*
* Drawn from the brand palette rather than arbitrary hex, so a tile always sits
* on-theme next to the managed app cards. The default white-on-green contrast
* read as jarring on the dashboard, which is why this exists.
*
* Tiles stay visually distinct from managed apps regardless of color: the
* dashed border and the external-link marker are what carry that, not the hue.
* The tint is deliberately light so the distinction survives every option.
*
* `bg` is the tile tint and `swatch` is the same color at full strength. They
* differ on purpose: a 10% tint reads well across a 190px card but makes six
* 28px picker swatches indistinguishable from each other, so the picker shows
* the color solid while the tile stays subtle.
*
* Class names are written out in full and never interpolated. Tailwind scans
* source text for literal class strings, so a computed name like
* `bg-desert-${id}/10` would be silently dropped from the build.
*/
export const LINK_TILE_COLORS = [
{
id: 'green',
swatch: 'bg-desert-green',
label: 'Green',
border: 'border-desert-green/70',
bg: 'bg-desert-green/10',
marker: 'text-desert-green',
},
{
id: 'olive',
swatch: 'bg-desert-olive',
label: 'Olive',
border: 'border-desert-olive/70',
bg: 'bg-desert-olive/10',
marker: 'text-desert-olive',
},
{
id: 'orange',
swatch: 'bg-desert-orange',
label: 'Orange',
border: 'border-desert-orange/70',
bg: 'bg-desert-orange/10',
marker: 'text-desert-orange',
},
{
id: 'red',
swatch: 'bg-desert-red',
label: 'Red',
border: 'border-desert-red/70',
bg: 'bg-desert-red/10',
marker: 'text-desert-red',
},
{
id: 'sand',
swatch: 'bg-desert-sand',
label: 'Sand',
border: 'border-desert-sand/70',
bg: 'bg-desert-sand/20',
marker: 'text-desert-stone-dark',
},
{
id: 'stone',
swatch: 'bg-desert-stone',
label: 'Stone',
border: 'border-desert-stone/70',
bg: 'bg-desert-stone/10',
marker: 'text-desert-stone-dark',
},
] as const
export type LinkTileColorId = (typeof LINK_TILE_COLORS)[number]['id']
export const DEFAULT_LINK_TILE_COLOR: LinkTileColorId = 'green'
export const LINK_TILE_COLOR_IDS: readonly string[] = LINK_TILE_COLORS.map((c) => c.id)
/** Resolve a stored id, falling back to the default for anything unknown. */
export function linkTileColor(id?: string | null) {
return LINK_TILE_COLORS.find((c) => c.id === id) ?? LINK_TILE_COLORS[0]
}

View File

@ -0,0 +1,72 @@
/**
* The icons offered when creating a dashboard link tile: a curated 36, laid out
* six across and six down.
*
* Every name here must exist in the `DynamicIcon` registry (`inertia/lib/icons.ts`).
* `DynamicIcon` renders nothing at all for a name it does not know, so an icon
* that is not in that registry produces a tile with a silent hole where its icon
* should be. Shared by the picker and the server-side validator so the two can
* never disagree about what is selectable.
*
* Chosen for "a thing on my network": storage, media, documents, tooling,
* infrastructure. Not a general icon browser. The point of a fixed set is that
* the user picks from something that fits on one screen.
*/
export const LINK_TILE_ICONS = [
// Infrastructure
'IconServer',
'IconDatabase',
'IconWorld',
'IconWifi',
'IconAntenna',
'IconBroadcast',
// Machines and containers
'IconBrandDocker',
'IconCpu',
'IconHome',
'IconBox',
'IconFolderOpen',
'IconMovie',
// Reading and reference
'IconBooks',
'IconBook',
'IconLibrary',
'IconMap',
'IconNotes',
'IconFileDescription',
// Tools and admin
'IconCode',
'IconTool',
'IconSettings',
'IconShieldCheck',
'IconShieldLock',
'IconRobot',
// Subjects
'IconBrain',
'IconPlant',
'IconChefHat',
'IconSchool',
'IconStethoscope',
'IconSearch',
// Transfer and misc
'IconCloudDownload',
'IconCloudUpload',
'IconLogs',
'IconPlayerPlay',
'IconExternalLink',
'IconWand',
] as const
export type LinkTileIconName = (typeof LINK_TILE_ICONS)[number]
/** The icon a tile gets when none was chosen, or when a stored name is no longer offered. */
export const DEFAULT_LINK_TILE_ICON: LinkTileIconName = 'IconExternalLink'
export function isLinkTileIcon(value: unknown): value is LinkTileIconName {
return typeof value === 'string' && (LINK_TILE_ICONS as readonly string[]).includes(value)
}

View File

@ -1,4 +1,4 @@
import { NomadOllamaModel } from '../types/ollama.js'
import { NomadOllamaModel, ResponseStyle, SamplerProfile } from '../types/ollama.js'
/**
* Fallback basic recommended Ollama models in case fetching from the service fails.
@ -62,10 +62,25 @@ export const FALLBACK_RECOMMENDED_OLLAMA_MODELS: NomadOllamaModel[] = [
},
]
export const DEFAULT_QUERY_REWRITE_MODEL = 'qwen2.5:3b' // default to qwen2.5 for query rewriting with good balance of text task performance and resource usage
export const EMBEDDING_MODEL_NAME = 'nomic-embed-text:v1.5'
/**
* Server-side context floor set as `OLLAMA_CONTEXT_LENGTH` on the nomad_ollama
* container.
*
* Ollama defaults to 4096 tokens on machines under 24GB VRAM and silently
* truncates anything longer — no error, no warning, the model simply never sees
* the start of the conversation. This raises the floor for every request that
* can't carry its own `num_ctx`, which notably includes the whole
* OpenAI-compatible path (that endpoint has no context-size field at all).
*
* Deliberately conservative rather than clever: it is allocated at model load on
* whatever hardware NOMAD happens to be running, so it has to be affordable on a
* small box. Per-model, hardware-aware sizing happens per request via
* ContextWindowResolver, which can go well above this.
*/
export const DEFAULT_OLLAMA_CONTEXT_LENGTH = 8192
/**
* Adaptive RAG context limits based on model size.
* Smaller models get overwhelmed with too much context, so we cap it.
@ -76,6 +91,228 @@ export const RAG_CONTEXT_LIMITS: { maxParams: number; maxResults: number; maxTok
{ maxParams: Infinity, maxResults: 5, maxTokens: 0 }, // 13B+ (no cap)
]
/**
* Retrieval defaults for the chat pipeline. These were previously inline
* literals at the `searchSimilarDocuments` call site in OllamaController, which
* made them impossible to sweep or record in a report. They are named here so
* the pipeline and the eval harness read the same numbers.
*/
export const RAG_DEFAULT_TOP_K = 5
export const RAG_DEFAULT_SCORE_THRESHOLD = 0.3
/**
* Relevance floor applied to the post-rerank score. Chunks below it are
* dropped, and when nothing clears it no context block is injected at all.
*
* Retrieval used to have exactly one cutoff — RAG_DEFAULT_SCORE_THRESHOLD,
* applied by Qdrant to the raw cosine score. nomic scores unrelated text well
* above 0.3, so every question retrieved something and the system prompt was
* left to compensate in prose: "silently judge whether the context genuinely
* addresses the question". That is a relevance classification, handed to a 3B
* model, that a number should have decided.
*
* RAG_DEFAULT_SCORE_THRESHOLD deliberately stays low. It is the candidate net,
* and pruning it would starve the reranker (worth +0.0125 ndcg@5 on the golden
* set) and, later, the sparse leg of hybrid retrieval. This is the decisive
* cutoff instead, applied once at the end where the score means the most.
*
* Calibrated with `node ace eval:retrieval --min-final-score=<x>` against the
* 99-golden set at corpus fingerprint 51e642964facf251:
*
* floor recall@5 precision@5 emptyOnAnswerable nonEmptyOnRefusal
* 0.00 0.991 0.221 0% 100%
* 0.55 0.991 0.289 0% 80%
* 0.62 0.991 0.524 0% 60%
* 0.65 0.991 0.659 0% 60%
* 0.66 0.991 0.695 0% 60%
* 0.67 0.972 0.704 1% 60%
* 0.72 0.888 0.796 6% 60%
*
* 0.66 is the last value that costs no recall, and 0.62 is where the refusal
* metric reaches its floor. Everything between them buys precision only, so the
* default sits at 0.62 rather than at the edge: this corpus is 28 documents and
* a real ZIM will not score identically, and the two failure modes are not
* symmetric. A dropped relevant chunk means no answer to a question the corpus
* *can* answer; an extra irrelevant chunk means noise the model mostly ignores
* — measured correctness was fine at 0.221 precision. Buy margin, not precision.
*
* Note this is a higher cutoff than the same sweep supports on the *semantic*
* axis, where recall starts falling at 0.60. That gap is the reranker earning
* its place: it separates relevant from irrelevant better than raw cosine does,
* so the floor can sit higher without cutting real answers.
*
* Note the honest ceiling on the refusal metric: three of the five refusal
* goldens are tagged `adversarial` and are near-corpus by construction ("what
* is the TR-88 pump's warranty period?" against a corpus that documents the
* TR-88 but not its warranty). Retrieving that document is *correct*; declining
* to answer from it is the generation tier's job, scored by refusalCorrectness.
* So nonEmptyRateOnRefusal bottoms out at 0.6 here, and a cutoff tuned to drive
* it lower is cutting real documents.
*
* What this floor cannot do (#1341). With general-prose distractors added to the
* corpus (fingerprint b4ebe5dce8699f5d: Gutenberg excerpts, the Python tutorial,
* MDN), the `off_topic` suite measures:
*
* floor core recall@5 off-topic questions that retrieve something
* 0.62 0.991 67% (22% without the distractors)
* 0.65 0.991 33%
* 0.69 0.934 11%
*
* The weakest correct top hit scores 0.666 while "write me a haiku about autumn
* leaves" pulls Walden at 0.705, so no value here separates the two, and a
* better-stocked library only makes it worse. 0.65 halves the leaks at no
* measured recall cost but sits 0.016 under that weakest hit, which is not the
* margin this default was chosen for. The coherent-but-off-topic case is left to
* the opt-in relevance check (app/utils/relevance_judge.ts). A cosine floor
* alongside this one was also tried and loses recall faster than it cuts leaks:
* the reranker's boosts lift relevant chunks more than irrelevant ones.
*
* Unset `rag.minRelevance` resolves to this value; see app/utils/rag_relevance.ts.
*/
export const RAG_MIN_FINAL_SCORE = 0.62
/**
* Preset relevance floors offered in Settings > Models, and the labels for them.
* The stored setting is the number, not the label, so retuning these presets
* later cannot invalidate a value someone has already saved.
*/
export const RAG_MIN_RELEVANCE_PRESETS = [
{ value: 0, label: 'Off — use every passage retrieved' },
{ value: 0.55, label: 'Lenient' },
{ value: RAG_MIN_FINAL_SCORE, label: 'Balanced (recommended)' },
// The last floor that costs no recall on the golden set. Stricter than this
// starts dropping answers, which is a choice to offer, not one to default to.
{ value: 0.66, label: 'Strict' },
] as const
/**
* Where the per-turn retrieved-context block sits in the prompt.
*
* `tail` places it immediately before the current question, so
* [system][history] stays byte-identical from turn to turn and the backend can
* reuse its KV cache for the whole conversation. `front` is the historical
* placement — a system message ahead of all history — which changes content
* every turn and therefore invalidates the cached prefix behind it, making
* follow-ups progressively slower as the conversation grows.
*
* Measured on llama3:8b over a 14-turn conversation (~400-token retrieved block
* per turn), prefill time for the same prompt:
*
* turn 1 front 214 ms tail 214 ms
* turn 7 front 347 ms tail 225 ms
* turn 14 front 539 ms tail 231 ms
*
* front grows about 25 ms per turn; tail grows about 1 ms. By turn 14 that is
* 2.3x the time-to-first-token, and the gap keeps widening — the familiar
* "first answer fast, follow-ups get slower" complaint.
*
* Caveat worth knowing before trusting this: the saving is proportional to how
* much of the prompt is *stable*. With a very large retrieved block and a short
* history the block dominates, must be reprocessed either way, and the two
* placements measure the same. Tail placement never loses, but it only clearly
* wins once a conversation has accumulated history — which is exactly when
* responsiveness starts to matter.
*
* Both are kept so `eval:generation` can measure the answer-quality difference
* rather than the choice resting on the cache argument alone. Flip to 'front'
* to reproduce pre-change behaviour.
*/
export const RAG_PLACEMENT: 'tail' | 'front' = 'tail'
/**
* Token cap on the query-rewrite call. A rewrite is one short sentence; the
* prompt already asks for under 150 words. Without a cap a small model can
* ramble, and every one of those tokens is latency the user waits through
* before retrieval even starts. Sized to leave room for the JSON envelope the
* rewrite is now grammar-constrained to emit — a truncated object parses to
* nothing and costs the turn its rewrite entirely.
*/
export const QUERY_REWRITE_MAX_TOKENS = 160
/**
* Token cap on the retrieval relevance check. The answer is `{"on_topic": true}`,
* under ten tokens; the rest is headroom, since a truncated verdict is discarded
* and the turn keeps its chunks unjudged.
*/
export const RELEVANCE_CHECK_MAX_TOKENS = 32
/**
* How long Ollama keeps a chat model — and its KV cache — resident after a
* request.
*
* Ollama's default is 5 minutes, which is comfortably shorter than the time a
* user spends reading an answer and typing a follow-up. Evicting in that gap
* throws away the cached prefix and forces a cold reload plus a full re-prefill
* on the next message, which is the single most obvious "why is it slow again?"
* moment in a conversation. Overridable via the `ai.keepAlive` setting.
*/
export const DEFAULT_KEEP_ALIVE = '15m'
/**
* Sampler settings per Response Style.
*
* Chat used to send none of these, so every turn ran at whatever the backend
* picked: temperature 0.8, top_p 0.9, top_k 40 and no min_p on a bare Ollama
* model. On a heavily quantized 1-3B model, which is NOMAD's realistic target,
* those defaults are what produces the wandering, repetitive output that reads
* as "the local model is bad".
*
* Three things are worth knowing before retuning these numbers.
*
* min_p is the actual missing lever, not temperature. It is a floor relative to
* the top token's probability, so it truncates hard when the model is confident
* and barely at all when it isn't. top_p can't do that: a fixed nucleus keeps
* the same share of mass either way, which is why a flat distribution at the end
* of a long generation is where small models start to wander. With min_p doing
* the work, top_p is left at 1.0 rather than stacked on top of it, and a higher
* temperature stays safe.
*
* repeat_penalty moves with the style instead of sitting at Ollama's 1.1. The
* penalty applies over the last 64 tokens with no idea what a token means, and
* a grounded answer out of this corpus legitimately repeats a part number, a
* band designator or a drug name inside that window. So the factual style
* relaxes it and the creative style, which is where genuine loops happen,
* tightens it.
*
* `off` is not a preset, it is the absence of one: no options are sent and the
* backend decides, exactly as it did before this setting existed.
*/
export const SAMPLER_PRESETS: Record<Exclude<ResponseStyle, 'off'>, SamplerProfile> = {
auto: { temperature: 0.6, topP: 1.0, topK: 40, minP: 0.05, repeatPenalty: 1.1, compatTopP: 0.9 },
focused: {
temperature: 0.2,
topP: 1.0,
topK: 40,
minP: 0.1,
repeatPenalty: 1.05,
compatTopP: 0.85,
},
creative: {
temperature: 1.0,
topP: 1.0,
topK: 100,
minP: 0.03,
repeatPenalty: 1.15,
compatTopP: 0.95,
},
}
/** Unset `ai.responseStyle` resolves to this; see app/utils/response_style.ts. */
export const DEFAULT_RESPONSE_STYLE: ResponseStyle = 'auto'
/**
* The Response Style options offered in Settings > Models, and their labels.
*
* Ordered least to most adventurous, with the default first, matching how the
* Context Window and Knowledge Base Relevance selects above it are ordered.
*/
export const RESPONSE_STYLE_PRESETS: { value: ResponseStyle; label: string }[] = [
{ value: 'auto', label: 'Auto (recommended)' },
{ value: 'focused', label: 'Focused (factual, repeatable)' },
{ value: 'creative', label: 'Creative (varied, exploratory)' },
{ value: 'off', label: 'Backend defaults (send nothing)' },
]
export const SYSTEM_PROMPTS = {
default: `
Format all responses using markdown for better readability. Vanilla markdown or GitHub-flavored markdown is preferred.
@ -86,27 +323,24 @@ export const SYSTEM_PROMPTS = {
- Use tables when presenting structured data.
`,
rag_context: (context: string) => `
Information has been retrieved from the NOMAD knowledge base that MAY be relevant to the
user's question. It was selected by automated similarity search, which is imperfect — some
or all of it may be unrelated to what the user actually asked.
Information has been retrieved from the NOMAD knowledge base for the user's question. It
cleared a relevance filter before reaching you, so it is likely on topic — but it was
selected by automated search, so it may still not contain the specific detail asked for.
[Knowledge Base Context]
${context}
HOW TO ANSWER:
1. First, silently judge whether the context genuinely addresses the user's question. Use
it ONLY when it really contains relevant information. Do not force a connection that
isn't there: poetic, narrative, tangential, or topically-unrelated passages are NOT
relevant just because they share a word with the question — ignore them.
2. When the context is relevant, base your answer on it and answer directly and specifically.
3. When the context does not actually address the question, ignore it completely and answer
from your own general knowledge. Do this silently — do not mention the knowledge base,
the context, or the fact that it lacked an answer, and do not apologize.
4. Never narrate your retrieval or reasoning process. Do not write "according to Context 1",
1. When the context contains what was asked for, base your answer on it and answer
directly and specifically.
2. When it does not, ignore it and answer from your own general knowledge. Do this
silently — do not mention the knowledge base, the context, or the fact that it lacked
an answer, and do not apologize.
3. Never narrate your retrieval or reasoning process. Do not write "according to Context 1",
"the context is unrelated, but", "I couldn't find specific context", or similar. Just
give the answer as if you simply knew it.
5. Do not fabricate specifics (numbers, names, procedures) that are neither supported by
genuinely relevant context nor part of your reliable knowledge.
4. Do not fabricate specifics (numbers, names, procedures) that are neither supported by
the context nor part of your reliable knowledge.
Format your response using markdown for readability.
`,
@ -128,13 +362,20 @@ Do NOT use:
- Statements that are not suggestions themselves, such as praise for asking the question
- Direct questions or commands to the user
Return ONLY the 3 suggestions as a comma-separated list with no additional text, formatting, numbering, or quotation marks.
The suggestions should be in title case.
Ensure that your suggestions are comma-separated with no conjunctions like "and" or "or".
Do not use line breaks, new lines, or extra spacing to separate the suggestions.
Format: suggestion1, suggestion2, suggestion3
Respond with JSON: {"suggestions": ["...", "...", "..."]}
`,
title_generation: `You are a title generator. Given the start of a conversation, generate a concise, descriptive title under 50 characters. Return ONLY the title text with no quotes, punctuation wrapping, or extra formatting.`,
title_generation: `You are a title generator. Given the start of a conversation, generate a concise, descriptive title under 50 characters.
Respond with JSON: {"title": "..."}`,
relevance_check: `You check whether search results are about what a user asked.
Answer true if at least one passage is about the same specific subject as the question, even if it does not state the exact answer.
Answer false if the passages only share a word or a loose theme with the question. Also answer false if the user is not asking for information at all: greetings, chit-chat, or asking you to produce something new such as a poem, a joke, a translation, or a letter.
Respond with JSON: {"on_topic": true} or {"on_topic": false}`,
query_rewrite: `
You are a query rewriting assistant. Your task is to reformulate the user's latest question to include relevant context from the conversation history.
@ -145,7 +386,7 @@ Rules:
2. Include key entities, topics, and context from previous messages
3. Make it a clear, searchable query
4. Do NOT answer the question - only rewrite the user's query to be more effective for retrieval
5. Output ONLY the rewritten query, nothing else
5. Respond with JSON: {"queries": ["..."]} — a single rewritten query in the array
Examples:
@ -154,7 +395,7 @@ User: "How do I install Gentoo?"
Assistant: [detailed installation guide]
User: "Is an internet connection required to install?"
Rewritten Query: "Is an internet connection required to install Gentoo Linux?"
Rewritten Query: {"queries": ["Is an internet connection required to install Gentoo Linux?"]}
---
@ -163,6 +404,6 @@ User: "What's the best way to preserve meat?"
Assistant: [preservation methods]
User: "How long does it last?"
Rewritten Query: "How long does preserved meat last using curing or smoking methods?"
Rewritten Query: {"queries": ["How long does preserved meat last using curing or smoking methods?"]}
`,
}

View File

@ -18,4 +18,5 @@ export const SERVICE_NAMES = {
HOMEBOX: 'nomad_homebox',
VAULTWARDEN: 'nomad_vaultwarden',
JELLYFIN: 'nomad_jellyfin',
TRANSLATE: 'nomad_translate',
}

View File

@ -20,6 +20,7 @@ export const SUPPLY_DEPOT_DOC_ANCHORS: Record<string, string> = {
[SERVICE_NAMES.KOLIBRI]: 'kolibri',
[SERVICE_NAMES.KOLIBRI_GEN2]: 'kolibri',
[SERVICE_NAMES.MESHCORE_WEB]: 'meshcore-web',
[SERVICE_NAMES.TRANSLATE]: 'offline-translation',
}
// Returns the in-app docs link for a service, or null if it has no documentation section.

View File

@ -9,7 +9,11 @@ export default class extends BaseSchema {
table.string('name').notNullable()
table.double('longitude').notNullable()
table.double('latitude').notNullable()
table.string('color', 20).notNullable().defaultTo('orange')
table.string('color', 20).notNullable().defaultTo('orange') // retaining this for backward compatibility
table.string('custom_color', 7).nullable() // "#aabbcc"
table.string('icon', 50).nullable() // "circle", "star", "flag", etc.
table.string('icon_color', 7).nullable()
table.boolean('visible').notNullable().defaultTo(true)
table.string('marker_type', 20).notNullable().defaultTo('pin')
table.string('route_id').nullable()
table.integer('route_order').nullable()

View File

@ -0,0 +1,28 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class AddVisibilityAndIconFieldsToMapMarkers extends BaseSchema {
protected tableName = 'map_markers'
async up() {
// create_map_markers_table now includes these columns on fresh installs.
if (await this.schema.hasColumn(this.tableName, 'custom_color')) {
return
}
this.schema.alterTable(this.tableName, (table) => {
table.string('custom_color', 7).nullable()
table.string('icon', 50).nullable()
table.string('icon_color', 7).nullable()
table.boolean('visible').notNullable().defaultTo(true)
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('custom_color')
table.dropColumn('icon')
table.dropColumn('icon_color')
table.dropColumn('visible')
})
}
}

View File

@ -0,0 +1,34 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Widen `installed_resources.resource_type` to accept 'dataset'.
*
* The model already declares `'zim' | 'map' | 'dataset'` and
* `IngestDrugDataJob` writes a 'dataset' row for the openFDA drug labels, but
* the column was still `enum('zim','map')` from the table's original
* migration. Every write failed with "Data truncated for column
* 'resource_type'", so the drug reference ingested its 261k labels and then
* left no install-state row — the tier-status math and the home-tile gate both
* read those rows, so the resource stayed invisible to them.
*/
export default class extends BaseSchema {
protected tableName = 'installed_resources'
async up() {
await this.db.rawQuery(
"ALTER TABLE installed_resources MODIFY COLUMN resource_type " +
"enum('zim','map','dataset') NOT NULL"
)
}
async down() {
// Rows that only exist because of this widening have to go before the
// column can be narrowed again; MySQL would otherwise truncate them to ''
// (or fail outright under STRICT_TRANS_TABLES).
await this.db.rawQuery("DELETE FROM installed_resources WHERE resource_type = 'dataset'")
await this.db.rawQuery(
"ALTER TABLE installed_resources MODIFY COLUMN resource_type " +
"enum('zim','map') NOT NULL"
)
}
}

View File

@ -0,0 +1,17 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'chat_messages'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.text('sources').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('sources')
})
}
}

View File

@ -0,0 +1,17 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'kb_ingest_state'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.boolean('active').notNullable().defaultTo(true)
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('active')
})
}
}

View File

@ -0,0 +1,22 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// Marks a row that is only a dashboard shortcut: a name, an icon and a URL,
// with no container behind it. Distinct from is_custom, which still means a
// user-defined container NOMAD installs and manages. A link tile has no
// image, no ports and no lifecycle, so the UI must not offer Start, Stop,
// Update or Uninstall for one.
table.boolean('is_link_tile').notNullable().defaultTo(false)
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('is_link_tile')
})
}
}

View File

@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// Brand-palette colour for a dashboard link tile. Null means the default.
// A separate migration from the one that added is_link_tile, because that
// one has already run on the test box; editing it in place would leave
// fresh installs and upgraded ones with different schemas.
table.string('link_color', 20).nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('link_color')
})
}
}

View File

@ -4,6 +4,10 @@ import { ModelAttributes } from '@adonisjs/lucid/types/model'
import env from '#start/env'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
import {
mergeContainerConfigPreservingHostPorts,
mergeUiLocationPreservingHostPort,
} from '../../app/utils/service_catalog_merge.js'
type ServiceSeedRecord = Omit<
ModelAttributes<Service>,
@ -15,6 +19,8 @@ type ServiceSeedRecord = Omit<
| 'metadata'
| 'is_user_modified'
| 'is_deprecated'
| 'is_link_tile'
| 'link_color'
| 'custom_url'
| 'auto_update_enabled'
| 'available_update_first_seen_at'
@ -92,7 +98,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 3,
description: 'Local AI chat that runs entirely on your hardware - no internet required',
icon: 'IconWand',
container_image: 'ollama/ollama:0.24.0',
container_image: 'ollama/ollama:0.33.3',
source_repo: 'https://github.com/ollama/ollama',
container_command: 'serve',
container_config: JSON.stringify({
@ -536,6 +542,51 @@ export default class ServiceSeeder extends BaseSeeder {
depends_on: null,
metadata: JSON.stringify({ minMemoryMB: 2048, minDiskMB: 20480 }),
},
{
service_name: SERVICE_NAMES.TRANSLATE,
friendly_name: 'Translated Library',
powered_by: 'Bergamot',
display_order: 28,
description:
'Read the Information Library in another language. Machine translation that works offline, on CPU',
icon: 'IconWorld',
container_image: 'ghcr.io/crosstalk-solutions/project-nomad-translate:0.1.1',
source_repo: 'https://github.com/browsermt/bergamot-translator',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '8391/tcp': [{ HostPort: '8460' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/translate/models:/models`],
},
ExposedPorts: { '8391/tcp': {} },
// TRANSLATE_LANGS is the language set fetched on first start, about
// 45-140 MB per language for the pair in both directions. Editable via
// Manage > Edit; the container re-checks on restart and only fetches
// what is missing.
Env: [
// Reached by container name on the shared NOMAD network, which
// DockerService attaches every managed container to. Using the name
// rather than a host port means this survives the library being
// remapped.
'KIWIX=http://nomad_kiwix_server:8080',
'TRANSLATE_LANGS=fr,es,de',
'WORKERS=8',
],
}),
ui_location: '8460',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'education',
// Translating an article it cannot fetch is meaningless, so the library
// has to be there first.
depends_on: SERVICE_NAMES.KIWIX,
// Peak RSS measured at 729 MB with three language pairs resident; models
// are about 45-140 MB per language on disk.
metadata: JSON.stringify({ minMemoryMB: 1536, minDiskMB: 1024 }),
},
]
async run() {
@ -543,6 +594,10 @@ export default class ServiceSeeder extends BaseSeeder {
'service_name',
'is_custom',
'is_user_modified',
// Needed to keep an installed app's published host port across catalog sync.
'installed',
'container_config',
'ui_location',
])
const existingServiceMap = new Map(existingServices.map((s) => [s.service_name, s]))
@ -562,12 +617,36 @@ export default class ServiceSeeder extends BaseSeeder {
for (const service of ServiceSeeder.DEFAULT_SERVICES) {
const existing = existingServiceMap.get(service.service_name)
if (existing && !existing.is_custom && !existing.is_user_modified) {
// An installed app's published host port belongs to the machine, not the
// catalog: it diverges because the default was already taken on that host.
// Overwriting it desyncs the row from the running container, which breaks
// the Open link now and collides on the next recreate (#1005). Keep the
// live host ports and apply every other catalog change. Not-yet-installed
// rows take the catalog verbatim, since nothing is running to conflict.
const containerConfig = existing.installed
? mergeContainerConfigPreservingHostPorts(
service.container_config,
existing.container_config
)
: service.container_config
// Derive the link from the config we are about to WRITE, not the live one. When a
// catalog change to the container-side port means the live host port was not
// preserved above, the link has to follow the catalog too, or the row ships a
// container bound to one port and an Open button pointing at another.
const uiLocation = existing.installed
? mergeUiLocationPreservingHostPort(
service.ui_location,
existing.ui_location,
containerConfig
)
: service.ui_location
await Service.query().where('service_name', service.service_name).update({
container_config: service.container_config,
container_config: containerConfig,
container_command: service.container_command ?? null,
metadata: (service as any).metadata ?? null,
category: service.category,
ui_location: service.ui_location,
ui_location: uiLocation,
})
}
}

View File

@ -29,8 +29,32 @@ NOMAD is designed for capable hardware, especially if you want to use the AI fea
**For detailed build recommendations at three price points ($150–$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
### How much RAM do I need?
**Without the AI Assistant, the whole stack sits under 1GB.** Measured on a running
install: the Command Center 241MB, MySQL 444MB, Redis 9MB, Kiwix 101MB, and the
knowledge base index 155MB. That is why 4GB is a real minimum rather than a
defensive one.
**The AI is the variable, and it is the model rather than NOMAD.** Ollama idles at
roughly 1.2GB, and a model needs about its download size resident while it answers,
so an 8B model at ~4.6GB wants about that much on top. **8GB is workable for a small
model, 16GB is comfortable, and 32GB is the recommendation if you want to run larger
ones.**
Where that memory comes from depends on your hardware. With a discrete GPU the model
loads into VRAM and never touches system RAM, so VRAM is the number that limits which
models you can run. With an integrated GPU or no GPU at all, it comes out of system
RAM, which is why a CPU-only box needs more of it.
### How much storage do I need?
It depends on what you download:
**To install NOMAD itself: about 5GB, so leave 10GB free.** That covers the
Command Center and its database, before any content. Adding the AI Assistant
brings the total to roughly 25GB, because Ollama and a general-purpose model are
both large.
After that it depends entirely on what you download:
- Full Wikipedia: ~95GB
- Khan Academy courses: ~50GB
- Medical references: ~500MB

View File

@ -1,5 +1,77 @@
# Release Notes
## Version 1.35.1 - October 5, 2026
### Features
### Bug Fixes
- **AI**: probe a model the list reports without vision before disabling image attachments, so `gemma3` accepts images again (#1409). Thanks @chriscrosstalk for the contribution!
- **Translate**: forward upstream redirects instead of following them, so books open styled and their article links resolve (#1379). Thanks @chriscrosstalk for the contribution!
- **Translate**: download every released language instead of crashing on pre-release models, and list all supported languages in the docs (#1405). Thanks @chriscrosstalk for the contribution!
### Improvements
## Version 1.35.0 - September 29, 2026
### Features
- **AI**: support local vision attachments in chat (#1288). Thanks @gabegraves and @chriscrosstalk for the contribution!
- **AI**: show source document and date as citations under RAG answers (#1287). Thanks @chriscrosstalk for the contribution!
- **AI**: budget the prompt and actually set the context window on a native Ollama chat path (#1253). Thanks @jakeaturner for the contribution!
- **AI**: give chat a sampler with four response-style profiles instead of backend defaults (#1282). Thanks @jakeaturner for the contribution!
- **AI**: constrain the structured task calls (titles, chips, query rewrite) instead of asking nicely (#1259). Thanks @jakeaturner for the contribution!
- **AI**: let retrieval decline via a post-rerank relevance floor (#1281). Thanks @jakeaturner for the contribution!
- **AI**: add opt-in relevance check for off-topic retrieval (#1365). Thanks @jakeaturner for the contribution!
- **AI**: specify a lightweight model for ancillary tasks (#1244). Thanks @jakeaturner for the contribution!
- **AI**: make RAG retrieval optional per chat (#1247). Thanks @jakeaturner for the contribution!
- **Dashboard**: user-added link tiles for services NOMAD does not manage (#1293). Thanks @chriscrosstalk for the contribution!
- **KB**: per-file active toggle plus collection grouping and bulk toggle (#1286). Thanks @just-jbc and @chriscrosstalk for the contribution!
- **Kiwix Library**: browse the remote Kiwix catalog in any language, not just English (#1299). Thanks @chriscrosstalk for the contribution!
- **Maps**: full marker management (visibility, search/sort, per-marker icon and colour, coordinate navigation, delete confirmation) (#1291). Thanks @kennethbrewer3 and @chriscrosstalk for the contribution!
- **Translate**: offline machine translation for the Information Library, shipped as a Supply Depot app (#1292). Thanks @chriscrosstalk for the contribution!
### Bug Fixes
- **AI**: stop `<think>` output leaking into titles, chips, and the RAG rewrite (#1254). Thanks @jakeaturner for the contribution!
- **AI**: report a failed Ollama model pull as a failure instead of a success (#1274). Thanks @chriscrosstalk for the contribution!
- **AI**: don't probe the native embed endpoint on a non-Ollama backend (#1284). Thanks @chriscrosstalk for the contribution!
- **AI**: don't classify the backend as non-Ollama when it is merely unreachable (#1357). Thanks @chriscrosstalk for the contribution!
- **AI**: bump the Ollama pin to 0.33.3 to fix the disconnect hang (#1356). Thanks @chriscrosstalk for the contribution!
- **AI**: remove the long-dead default rewrite model warning (#1249). Thanks @jakeaturner for the contribution!
- **Benchmark**: show the v2 score in Benchmark History (#1210). Thanks @chriscrosstalk for the contribution!
- **Benchmark**: stop WSL2 submitting "Microsoft Basic Render Driver" as the GPU model (#1277). Thanks @chriscrosstalk for the contribution!
- **Chat**: abort abandoned response streams (#1220). Thanks @NgoQuocViet2001 for the contribution!
- **Chat**: stop capping answers at 1024 tokens and surface truncation (#1360). Thanks @jakeaturner for the contribution!
- **Chat**: render the user's message before creating the session (#1301). Thanks @chriscrosstalk for the contribution!
- **Content**: re-download content updates after a failed attempt (#1213). Thanks @Osamaali313 for the contribution!
- **Database**: allow `dataset` in `installed_resources.resource_type` (#1243). Thanks @rcorvus for the contribution!
- **Downloads**: guard orphaned model jobs (#1245). Thanks @NgoQuocViet2001 for the contribution!
- **GPU**: detect Ollama CPU fallback from logs so auto-remediation fires (#1109). Thanks @fpolica91 for the contribution!
- **GPU**: detect Ollama CPU fallback on AMD and offer the right fix (#1370). Thanks @jakeaturner for the contribution!
- **GPU**: stop a missing `/dev/kfd` blocking the AI Assistant install entirely (#1276). Thanks @chriscrosstalk for the contribution!
- **GPU**: keep an existing AMD HSA override across reinstall and update (#1390). Thanks @jakeaturner for the contribution!
- **Install**: time out Redis readiness probes (#1347). Thanks @dajiaohuang for the contribution!
- **Install**: match the HawkPoint1 lspci string for gfx1103 AMD GPU detection (#1385). Thanks @Yi-111-a for the contribution!
- **KB**: stop leaking Qdrant vectors when a ZIM is deleted or replaced (#1227). Thanks @just-jbc for the contribution!
- **KB**: stop the orphan sweep purging an empty but present scan root (#1393). Thanks @jakeaturner for the contribution!
- **KB**: persist the source active toggle when no ingest state row exists (#1395). Thanks @jakeaturner for the contribution!
- **Maps**: stop world basemap provisioning running pmtiles extract twice (#1394). Thanks @jakeaturner for the contribution!
- **Queue**: clear terminal jobs before re-dispatch so downloads aren't silently dropped (#1225). Thanks @chriscrosstalk for the contribution!
- **Queue**: stop stalled-job recovery forking the embedding chain (#1270). Thanks @chriscrosstalk for the contribution!
- **RAG**: don't abandon a ZIM after one sparse batch (#1242). Thanks @rcorvus for the contribution!
- **Security**: reject oversized custom URLs (#1354). Thanks @dajiaohuang for the contribution!
- **Seeder**: keep an installed app's host port across catalog sync (#1300). Thanks @johno10661 and @chriscrosstalk for the contribution!
- **System**: resolve a placeholder GPU name on the System page too (#1285). Thanks @chriscrosstalk for the contribution!
- **UI**: restore dark mode text contrast (#1358). Thanks @Tiancheng-Xu for the contribution!
- **ZIM**: resolve flavoured and non-English ZIMs in the update check (#1298). Thanks @chriscrosstalk for the contribution!
### Improvements
- **Brand**: replace the retired N.O.M.A.D. badge with the new logo (#1375). Thanks @chriscrosstalk for the contribution!
- **CI**: add a weekly schedule trigger to collection URL validation (#1217). Thanks @cclavin for the contribution!
- **Dashboard**: tighten the Drug Reference tile description (#1297). Thanks @chriscrosstalk for the contribution!
- **Docs**: add SECURITY.md and tighten .gitignore for credential files (#1223). Thanks @chriscrosstalk for the contribution!
- **Docs**: state how much disk NOMAD needs before any content (#1278). Thanks @chriscrosstalk for the contribution!
- **Eval**: standardized RAG quality evaluation harness with a frozen corpus and golden question set (#1233). Thanks @jakeaturner for the contribution!
- **Install**: add the Project NOMAD ASCII banner to the installer (#1310). Thanks @chriscrosstalk for the contribution!
## Version 1.34.0 - August 4, 2026
### Features

View File

@ -282,3 +282,85 @@ A browser-based client for [MeshCore](https://meshcore.io) radios. MeshCore is a
**Your data:** There's nothing to set up or store on your NOMAD for this app. Your radio's settings live on the radio itself, and the app's preferences live in your browser. There's no NOMAD folder to manage.
**Works offline:** Fully offline, which is the whole point of MeshCore. The app is served from your NOMAD and talks to your radio directly over USB or Bluetooth, never the internet.
## Translated Library {% #offline-translation %}
Reads the Information Library in another language. Open an article and a **Translate this page** bar appears at the top with a button for each installed language, plus **Original** to switch back. Your choice sticks as you click through to other articles.
**Why this instead of the AI Assistant:** the AI Assistant can translate, but this is roughly 1,600 times faster on the same machine, and it does not need a graphics card, so it works on every NOMAD. It is also more careful with names: asked to translate a page, the AI Assistant will happily translate "Project NOMAD" into another language, and this will not.
**Choosing languages:** French, Spanish and German are set up by default. To add or remove languages:
1. In the **Supply Depot**, open the Translated Library's **Manage** menu and choose **Edit**.
2. Under **Environment Variables**, find `TRANSLATE_LANGS=fr,es,de`.
3. Change the list of language codes, separated by commas. For example, `TRANSLATE_LANGS=fr,es,de,sv` adds Swedish.
4. Click **Save**. The app restarts, downloads any new languages, and adds a button for each one to the **Translate this page** bar.
Adding a language needs an internet connection, and each one takes between about 45 MB and 140 MB of disk. Languages you already have keep working offline. Removing a code from the list does not remove a language you have already downloaded; it stays in `storage/translate/models` and keeps its button.
These 50 languages are available. Chinese is not available yet.
| Language | Code |
|---|---|
| Afrikaans | `af` |
| Arabic | `ar` |
| Basque | `eu` |
| Bengali | `bn` |
| Bosnian | `bs` |
| Bulgarian | `bg` |
| Catalan | `ca` |
| Croatian | `hr` |
| Czech | `cs` |
| Danish | `da` |
| Dutch | `nl` |
| Estonian | `et` |
| Finnish | `fi` |
| French | `fr` |
| Galician | `gl` |
| German | `de` |
| Greek | `el` |
| Gujarati | `gu` |
| Hebrew | `he` |
| Hindi | `hi` |
| Hungarian | `hu` |
| Icelandic | `is` |
| Indonesian | `id` |
| Italian | `it` |
| Japanese | `ja` |
| Kannada | `kn` |
| Korean | `ko` |
| Latvian | `lv` |
| Lithuanian | `lt` |
| Malay | `ms` |
| Malayalam | `ml` |
| Marathi | `mr` |
| Norwegian | `nb` |
| Persian | `fa` |
| Polish | `pl` |
| Portuguese | `pt` |
| Romanian | `ro` |
| Russian | `ru` |
| Serbian | `sr` |
| Slovak | `sk` |
| Slovenian | `sl` |
| Spanish | `es` |
| Swedish | `sv` |
| Tamil | `ta` |
| Telugu | `te` |
| Thai | `th` |
| Turkish | `tr` |
| Ukrainian | `uk` |
| Urdu | `ur` |
| Vietnamese | `vi` |
**First start needs internet.** The language models download when the app first runs, the same as installing any other app. After that it is entirely offline. If you install this while disconnected the app still starts and the library still works, just without translation until it can fetch the models.
**What it does not translate:** tables and infoboxes, the page title in your browser tab, and Kiwix's own search results. Searching also still matches the original language, so look things up in English and translate the article you land on.
**A note on two language buttons:** the library's own toolbar has a globe that changes the *menus* around the page. The bar this app adds changes the *article*. They are different things and sit close together, which is unfortunate but not something we can move.
**Accuracy:** this is machine translation, and it is literal. It is very good for getting the sense of an article. Be careful relying on it for exact medical or safety wording, where the correct term in another language is often not the literal one.
**Your data:** language models live in `storage/translate/models`. Nothing you read is stored or sent anywhere.
**Works offline:** yes, once the models have downloaded.

View File

@ -79,6 +79,7 @@ export default function AppUrlModal({ open, service, onClose, onSaved, showError
name="customUrl"
label="Custom URL"
placeholder="http://jellyfin.myhomelab.net"
maxLength={255}
value={value}
onChange={(e) => setValue(e.target.value)}
error={isInvalid}

View File

@ -31,9 +31,9 @@ const FadingImage = ({ alt = "Fading image", className = "" }) => {
isVisible ? 'opacity-100' : 'opacity-0'
}`}>
<img
src={`/project_nomad_logo.webp`}
src="/nomad-primary.svg"
alt={alt}
className={`w-64 h-64 ${className}`}
className={`w-64 h-64 object-contain ${className}`}
/>
</div>
);

Some files were not shown because too many files have changed in this diff Show More