Adds --doc (repeatable, file or directory) so the researcher can draw on
user-supplied documents alongside the web: documents.rs resolves paths
into embeddable text, retrieval.rs embeds them with a dedicated
embedding model (nomic-embed-text, separate from the chat models used
elsewhere) into an in-memory vector index and retrieves the excerpts
most relevant to the topic once up front, and researcher.rs folds those
excerpts into the researcher's task under the same footnote-citation
scheme already used for web sources. The embedding and retrieval phases
show progress the same way every other phase does — a spinner while
embedding, a summary line once excerpts are retrieved, tracing spans
for -l mode.
Verified against a live Ollama nomic-embed-text pull and a real
research round: a planted fact sheet was correctly ranked as the most
relevant of several embedded documents and appeared in the researcher's
task before its first turn.
deep_research is the only project this repo is meant to showcase, so the
Cargo workspace wrapping it and an unrelated side project no longer earns
its keep:
- swear_cleanup moved to a new standalone local repo (~/dev/swear_cleanup,
not pushed anywhere) via `git subtree split`, with its pre-workspace-
split history (when it lived at src/swear_cleanup/ in a single shared
crate) spliced onto its post-split history rather than starting from a
single flattened snapshot. FINDINGS.md, which was sitting at this repo's
root but was actually swear_cleanup's own build log, went with it.
- deep_research/{src,Cargo.toml,README.md,docs} moved to the repo root;
the [workspace] table collapsed into a plain [package] manifest with
dependency versions inlined from the old [workspace.dependencies].
- Cargo.toml keeps an explicit empty [workspace] table (not just omitted)
so that checking this repo out as a nested git worktree — this
project's own normal workflow — can't accidentally inherit a stale
ancestor directory's workspace manifest, which is exactly what broke
the build while testing this change from a worktree.
- .forgejo/workflows/deep_research-ci.yml -> ci.yml, dropping the now-
meaningless -p deep_research scoping and path filters (redundant when
it's the only thing in the repo).
- README.md and docs/case-study.md updated for the flattened commands
(cargo run/test with no -p flag); their relative links to each other
and to src/ were already correct since both moved together.
Verified: cargo build/test/clippy/fmt all clean from the new repo root.
Pins rig to the newest published crates.io release (0.41.0) instead of
the git main branch, and adapts swear_cleanup's revise.rs to that
release's API (OneOrMany::first() returns T directly, raw_completion
folded into CompletionResponse::raw_response).
The research agent (gemma4:26b) drives rig's AgentRunner tool-calling
loop with two lean #[rig::tool_macro] tools — a DuckDuckGo HTML search
and a page-text fetcher — to gather and cross-check findings. A second
agent (gemma4-e4b) turns those findings into a structured report; the
smaller/faster model suffices there since it's reformatting already-
digested notes rather than doing multi-step research reasoning.
Gemma now checks its own output (a fresh, stateless completion call,
not conversation history) before it's accepted as a seed or revision,
retrying up to 5 times if it's a refusal, meta-commentary describing
what it's about to write, or a list of multiple options instead of
one direct answer. Originally tried a separate CPU-only judge model
(critic-cpu) to avoid VRAM contention, but it was both far slower
(13-16s per judgment vs Gemma's own sub-second calls) and unreliable
on the exact failure patterns it was meant to catch — Gemma reviewing
itself turned out faster and more accurate, so critic-cpu is dropped
entirely.
Also required two rounds of prompt tuning, driven by live failures:
first adding concrete negative examples after the judge approved
outputs it should have rejected, then explicitly scoping the check to
format only after Gemma started rejecting its own genuinely hostile
(but well-formed) output — conflating "should I have generated this"
with the format question actually asked. Added integration tests
covering both directions (rejecting bad formats, accepting hostile-
but-well-formed content) as a fast regression check against an
expensive full generation loop.
Gemma now seeds deliberately hostile text, Shieldstral scores it, and
Gemma revises its own output based on the score until it drops below a
safety threshold (or a max-iteration cap is hit, returning the best
attempt seen). Extracted into a new revise module: score() now borrows
instead of consuming its args so it can run repeatedly, and the
gemma-call/extract-text logic is shared between seed generation and
every revision instead of being duplicated.
Also adds FINDINGS.md logging what actually turned out to be real
obstacles vs. overblown vs. irrelevant while building this out.