Commit Graph

782 Commits

Author SHA1 Message Date
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
cosmistack-bot
f8e40fbd01 chore(release): 1.34.1 [skip ci] 2026-09-02 18:59:42 +00:00
jakeaturner
fbeb3cf7ae fix(zim): extract Kiwix catalog URL into system-wide constant 2026-09-02 09:40:38 -07:00
Chris Sherwood
0a32344942 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-02 09:40:38 -07:00
chriscrosstalk
0bd1c6f4f9
chore(collections): surface FDA Drug Reference in Medicine > Standard (#1167)
HOLD UNTIL 1.34.0 GA. Merging early breaks v1.33.0 installs.

The manifest is fetched live from main
(collection_manifest_service.ts: refs/heads/main/collections/kiwix-categories.json),
so every install on every version picks this up the moment it merges — not on
their next upgrade.

`type: "dataset"` does not exist anywhere in v1.33.0: zero hits across
admin/app, admin/inertia and admin/types. It is new in 1.34.0. On a v1.33.0 box
this entry would render as an installable resource with no code that
understands its type and a URL that is not a ZIM, so the install path breaks
for users who are not running the version that introduced the feature.

Held back until GA for that reason, which also means 1.34.0 would otherwise
ship a working Drug Reference that cannot be discovered from Content Explorer.
Entry is byte-identical to the one already on rc.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:26:48 -07:00
cosmistack-bot
b6c6ff4f30 docs(release): finalize v1.34.0 release notes [skip ci] 2026-08-04 03:46:26 +00:00
cosmistack-bot
60b2e4568b chore(release): 1.34.0 [skip ci] 2026-08-04 03:45:48 +00:00
jakeaturner
195a0a99fa
docs: update release notes 2026-08-04 03:40:02 +00:00
chriscrosstalk
65bfd4f45a
fix(downloads): don't retry a rejected entitlement for four hours (#1205)
The 401/403 branch added in #1172 threw a plain Error, and
RunDownloadJob is registered attempts: 10 with exponential backoff from
30s. A permanent "this build has no entitlement key" rejection therefore
retried nine more times over roughly 4h15m, during which the job reads
`delayed` rather than `failed` - so the user sees a stuck download
instead of the clear message #1172 was written to give them. Each retry
also re-hits our own rate-limited Worker.

Throw a named GatedContentAuthError from the download util and translate
it to UnrecoverableError at the queue boundary in RunDownloadJob,
alongside the existing cancellation case. Declaring the class in
downloads.ts rather than throwing UnrecoverableError directly keeps
BullMQ out of a module that docker_service and map_service also use.

Harmless today because no catalog entry uses `auth`, but that ends with
the first gated card - see #1204.

Fixes #1195
2026-08-04 03:40:02 +00:00
chriscrosstalk
2159c9dec6
fix(downloads): let interrupted content downloads resume (#1202)
doResumableDownload already implements resume - stats the .tmp, sends a
Range header, handles a server that ignores it. The code was unreachable
for ZIMs because every dispatch site passed forceNew: true, which skips
the partial-file check and opens the stream with 'w' instead of 'a', so
an interrupted 12.5 GB Wikipedia download truncated and restarted at
byte 0. Maps already do this correctly (map_service.ts:681, "so retries
resume partial downloads"); ZIMs never got the same treatment.

Worse in combination with attempts: 10 - every retry also restarted from
zero, so a flaky connection re-downloaded the whole file up to ten times.

Drop forceNew from the content-download dispatch sites (it already
defaults to false) and add the guard that enabling resume requires: a
.tmp larger than the file now on the server cannot be a prefix of it,
because openZIM re-publishes builds under the same name. Resuming would
request a range past the end, 416 on every attempt, and never delete the
.tmp - so the download could never recover on its own.

Verified on a test appliance: a 470 MB partial survived a container
restart and continued rather than truncating, and a planted oversized
.tmp was discarded with the file then downloading to its exact size.

Refs #1201
2026-08-04 03:40:01 +00:00
chriscrosstalk
7472442ae8
fix(kb): keep the collection when a file is indexed after assignment (#1200)
Assigning a collection before indexing lost it silently. The per-row
value still showed, but Manage Collections and the Search in dropdown
stayed empty, because getKnowledgeCollections() facets on the Qdrant
payload while only MySQL had been written.

Five gaps on one path:

updateFileCollection() sets the payload filtered on `source`, which
matches nothing before the file is indexed. It also only persisted to
kb_ingest_state `if (row)`, so a file with no row stored the value
nowhere at all and still returned "Moved to ...".

Six of the seven EmbedFileJob.dispatch sites never pass `collection`,
and none read the existing row, so Index dispatched a job with no
knowledge of the assignment. The ZIM branch of processAndEmbedFile then
dropped `collection` even when the job had one, so ZIM content could
never be tagged at embed time by any path. Batch continuations dropped
it too, which would have tagged only batch 1.

Resolve the effective collection once inside EmbedFileJob.handle rather
than at seven call sites, thread it through the ZIM path into the point
payload, carry it across batch continuations, and make the pre-index
assignment durable with getOrCreate.

Verified end to end on a test appliance: assigned a collection to an
unindexed ZIM, indexed it across multiple batch continuations, and all
6106 chunks carry the tag.
2026-08-04 03:40:01 +00:00
chriscrosstalk
7325457242
fix(kb): stop the collection dropdown being clipped, widen the modal (#1198)
Two layout defects in the Knowledge Base modal, both found during
v1.34.0-rc.4 QA.

The collection combobox list was clipped to a single row's height on
every row of the table. The cause is not stacking order - StyledTable
gives each cell `truncate` (overflow:hidden) plus `relative`, so an
absolutely-positioned child cannot escape the cell box. No z-index can
win against a clip. The modal body is also an `overflow-y-auto`
scroller, so simply opting the cell out of `truncate` still left rows
lower down clipped by the scroller instead.

Render the list in a portal with fixed positioning instead, measured
from the input's rect and flipped above when it would run off the
bottom of the viewport. Reposition on scroll and resize (capture
listener, so any ancestor scroller counts). The click-outside handler
now also checks the portaled list, otherwise mousedown on an option
would close it before the click landed.

Separately, the modal was capped at max-w-4xl (896px) while its table
needs 906px, so the Delete button lost its right edge for everyone
regardless of monitor size. max-w-5xl gives the table room with none to
spare wasted.

Verified against a real build on a test appliance.
2026-08-04 03:40:00 +00:00
chriscrosstalk
af3fb8de13
feat(dashboard): round out the v1.34 What's new highlights (#1197)
The banner shipped with two highlights while the 1.34 line delivered 21
features, so it undersold the release. Adds the three with the widest
user-facing surface: Score v2, NOMAD.md and Knowledge Base collections.

Also converts the two existing bullets from em dashes to spaced hyphens
to match project copy convention.

Kept to five bullets - this is a dashboard alert, not release notes.
2026-08-04 03:40:00 +00:00
cosmistack-bot
926c7dda84
chore(release): 1.34.0-rc.4 [skip ci] 2026-08-04 03:39:59 +00:00
jakeaturner
9e173ce3d9
docs: update release notes 2026-08-04 03:39:59 +00:00
chriscrosstalk
2ec6ef3d5c
docs: document Ubuntu 26.04 LTS as the recommended OS in the in-app docs (#1159) 2026-08-04 03:39:59 +00:00
chriscrosstalk
bab2c426b0
docs: add an in-app Drug Reference guide (#1161) 2026-08-04 03:39:58 +00:00
chriscrosstalk
7afef079b6
fix(benchmark): don't submit unresolved PCI ids as the GPU model (#1165) 2026-08-04 03:39:58 +00:00
chriscrosstalk
a234658469
fix(benchmark): name the fix in the remote-host block, log warm-up failures (#1164) 2026-08-04 03:39:57 +00:00
chriscrosstalk
8f6b17b565
fix(drug-reference): responsive compare columns and dark-mode readability (#1163) 2026-08-04 03:39:57 +00:00
chriscrosstalk
131cb59fc1
fix(downloads): don't 500 the jobs endpoint on an orphaned BullMQ job (#1191)
A job id can outlive its payload hash. BullMQ still returns an entry for
it with empty `data`, so `normalize(job.data.filepath)` threw and took the
whole response with it.

`fetchJobsWithStates()` includes failed jobs, and failed jobs are retained
deliberately, so one orphan made GET /api/downloads/jobs throw on every
call, permanently. Content Explorer polls that endpoint every ~3s, so the
symptom was a 500 loop and a page that never loaded. It survived restarts
and only cleared by editing Redis by hand.

Drop entries with no usable payload at the source, and guard both
normalize() calls (the pmtiles-extract map had the same pattern). With no
payload there is nothing to render anyway.

Seen on NOMAD3 running rc.3: 93 occurrences in 15 minutes.

Closes #1190

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:56 +00:00