ragdesk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. ragdesk-0.1.0/.env.example +30 -0
  2. ragdesk-0.1.0/.github/workflows/ci.yml +32 -0
  3. ragdesk-0.1.0/.github/workflows/eval.yml +42 -0
  4. ragdesk-0.1.0/.github/workflows/release.yml +85 -0
  5. ragdesk-0.1.0/.gitignore +17 -0
  6. ragdesk-0.1.0/AGENTS.md +406 -0
  7. ragdesk-0.1.0/LICENSE +201 -0
  8. ragdesk-0.1.0/PKG-INFO +479 -0
  9. ragdesk-0.1.0/README.md +445 -0
  10. ragdesk-0.1.0/SECURITY.md +60 -0
  11. ragdesk-0.1.0/docs/auth-design.html +14849 -0
  12. ragdesk-0.1.0/docs/auth-design.json +143 -0
  13. ragdesk-0.1.0/docs/embedder-comparison.md +94 -0
  14. ragdesk-0.1.0/docs/eval.json +6 -0
  15. ragdesk-0.1.0/docs/sources.md +225 -0
  16. ragdesk-0.1.0/docs/vector-scale.md +110 -0
  17. ragdesk-0.1.0/fixtures/docs/auth.md +19 -0
  18. ragdesk-0.1.0/fixtures/docs/deploy.md +17 -0
  19. ragdesk-0.1.0/fixtures/docs/oncall.md +16 -0
  20. ragdesk-0.1.0/fixtures/golden.jsonl +7 -0
  21. ragdesk-0.1.0/fixtures/golden_corpus.jsonl +24 -0
  22. ragdesk-0.1.0/fixtures/golden_multiturn.jsonl +5 -0
  23. ragdesk-0.1.0/fixtures/golden_repo.jsonl +12 -0
  24. ragdesk-0.1.0/pyproject.toml +94 -0
  25. ragdesk-0.1.0/scripts/bench.py +166 -0
  26. ragdesk-0.1.0/scripts/build_sidecar.sh +46 -0
  27. ragdesk-0.1.0/scripts/embedder_compare.py +161 -0
  28. ragdesk-0.1.0/scripts/eval_badge.py +61 -0
  29. ragdesk-0.1.0/scripts/eval_ci.sh +31 -0
  30. ragdesk-0.1.0/scripts/make_golden.py +187 -0
  31. ragdesk-0.1.0/scripts/public_corpus.py +200 -0
  32. ragdesk-0.1.0/scripts/rerank_compare.py +106 -0
  33. ragdesk-0.1.0/scripts/rerank_pool_sweep.py +56 -0
  34. ragdesk-0.1.0/scripts/topic_map.py +186 -0
  35. ragdesk-0.1.0/scripts/vector_scale_bench.py +447 -0
  36. ragdesk-0.1.0/src/ragdesk/__init__.py +3 -0
  37. ragdesk-0.1.0/src/ragdesk/__main__.py +4 -0
  38. ragdesk-0.1.0/src/ragdesk/answer.py +178 -0
  39. ragdesk-0.1.0/src/ragdesk/archive.py +39 -0
  40. ragdesk-0.1.0/src/ragdesk/buildenv.py +94 -0
  41. ragdesk-0.1.0/src/ragdesk/chunk.py +93 -0
  42. ragdesk-0.1.0/src/ragdesk/cli.py +1012 -0
  43. ragdesk-0.1.0/src/ragdesk/complete.py +167 -0
  44. ragdesk-0.1.0/src/ragdesk/confluence.py +368 -0
  45. ragdesk-0.1.0/src/ragdesk/credentials.py +73 -0
  46. ragdesk-0.1.0/src/ragdesk/defaults.py +48 -0
  47. ragdesk-0.1.0/src/ragdesk/email_source.py +335 -0
  48. ragdesk-0.1.0/src/ragdesk/embed.py +343 -0
  49. ragdesk-0.1.0/src/ragdesk/envfile.py +63 -0
  50. ragdesk-0.1.0/src/ragdesk/evaluate.py +288 -0
  51. ragdesk-0.1.0/src/ragdesk/gdrive.py +308 -0
  52. ragdesk-0.1.0/src/ragdesk/github.py +257 -0
  53. ragdesk-0.1.0/src/ragdesk/gitlab.py +169 -0
  54. ragdesk-0.1.0/src/ragdesk/htmlutil.py +29 -0
  55. ragdesk-0.1.0/src/ragdesk/index.py +399 -0
  56. ragdesk-0.1.0/src/ragdesk/llm.py +370 -0
  57. ragdesk-0.1.0/src/ragdesk/mcp.py +248 -0
  58. ragdesk-0.1.0/src/ragdesk/msgraph.py +295 -0
  59. ragdesk-0.1.0/src/ragdesk/notes.py +117 -0
  60. ragdesk-0.1.0/src/ragdesk/notion.py +231 -0
  61. ragdesk-0.1.0/src/ragdesk/oauth.py +85 -0
  62. ragdesk-0.1.0/src/ragdesk/obsidian.py +79 -0
  63. ragdesk-0.1.0/src/ragdesk/office.py +266 -0
  64. ragdesk-0.1.0/src/ragdesk/ollama.py +60 -0
  65. ragdesk-0.1.0/src/ragdesk/presets.py +54 -0
  66. ragdesk-0.1.0/src/ragdesk/rerank.py +225 -0
  67. ragdesk-0.1.0/src/ragdesk/s3.py +329 -0
  68. ragdesk-0.1.0/src/ragdesk/screen.py +226 -0
  69. ragdesk-0.1.0/src/ragdesk/search.py +296 -0
  70. ragdesk-0.1.0/src/ragdesk/serve.py +2915 -0
  71. ragdesk-0.1.0/src/ragdesk/settings.py +59 -0
  72. ragdesk-0.1.0/src/ragdesk/store.py +1108 -0
  73. ragdesk-0.1.0/src/ragdesk/symbols.py +239 -0
  74. ragdesk-0.1.0/src/ragdesk/topics.py +124 -0
  75. ragdesk-0.1.0/src/ragdesk/tui.py +229 -0
  76. ragdesk-0.1.0/src/ragdesk/vectors.py +357 -0
  77. ragdesk-0.1.0/src/ragdesk/vision.py +182 -0
  78. ragdesk-0.1.0/src/ragdesk/web.py +171 -0
  79. ragdesk-0.1.0/tests/test_buildenv.py +41 -0
  80. ragdesk-0.1.0/tests/test_complete.py +55 -0
  81. ragdesk-0.1.0/tests/test_confluence.py +221 -0
  82. ragdesk-0.1.0/tests/test_credentials.py +91 -0
  83. ragdesk-0.1.0/tests/test_defaults.py +77 -0
  84. ragdesk-0.1.0/tests/test_email.py +224 -0
  85. ragdesk-0.1.0/tests/test_embed.py +98 -0
  86. ragdesk-0.1.0/tests/test_envfile.py +29 -0
  87. ragdesk-0.1.0/tests/test_gdrive.py +99 -0
  88. ragdesk-0.1.0/tests/test_github.py +191 -0
  89. ragdesk-0.1.0/tests/test_gitlab.py +84 -0
  90. ragdesk-0.1.0/tests/test_llm.py +269 -0
  91. ragdesk-0.1.0/tests/test_mcp.py +165 -0
  92. ragdesk-0.1.0/tests/test_msgraph.py +132 -0
  93. ragdesk-0.1.0/tests/test_notes.py +68 -0
  94. ragdesk-0.1.0/tests/test_notion.py +137 -0
  95. ragdesk-0.1.0/tests/test_obsidian.py +92 -0
  96. ragdesk-0.1.0/tests/test_office.py +238 -0
  97. ragdesk-0.1.0/tests/test_pipeline.py +605 -0
  98. ragdesk-0.1.0/tests/test_presets.py +63 -0
  99. ragdesk-0.1.0/tests/test_rerank.py +97 -0
  100. ragdesk-0.1.0/tests/test_retrieval.py +585 -0
  101. ragdesk-0.1.0/tests/test_s3.py +266 -0
  102. ragdesk-0.1.0/tests/test_screen.py +56 -0
  103. ragdesk-0.1.0/tests/test_serve.py +1696 -0
  104. ragdesk-0.1.0/tests/test_settings.py +38 -0
  105. ragdesk-0.1.0/tests/test_symbols.py +80 -0
  106. ragdesk-0.1.0/tests/test_topic_map.py +69 -0
  107. ragdesk-0.1.0/tests/test_topics.py +57 -0
  108. ragdesk-0.1.0/tests/test_tui.py +117 -0
  109. ragdesk-0.1.0/tests/test_ui.py +40 -0
  110. ragdesk-0.1.0/tests/test_vectors.py +208 -0
  111. ragdesk-0.1.0/tests/test_vision.py +164 -0
  112. ragdesk-0.1.0/tests/test_web.py +93 -0
  113. ragdesk-0.1.0/uv.lock +1619 -0
@@ -0,0 +1,30 @@
1
+ # ragdesk build credentials — copy to `.env` (gitignored) and fill in.
2
+ #
3
+ # Everything in this file is baked into release builds at build time:
4
+ # python -m ragdesk.buildenv # .env -> src/ragdesk/_build_env.py (gitignored)
5
+ # uv build # wheel/DMG now carries the values
6
+ #
7
+ # Policy (see SECURITY.md): only non-confidential values belong here.
8
+ # - GitHub device flow: client ID only (no secret exists).
9
+ # - Google Drive (Desktop app type): client ID + secret are not confidential —
10
+ # Google documents that installed apps cannot keep secrets; PKCE protects
11
+ # the flow.
12
+ # - Atlassian 3LO: the secret IS a real credential. Never bake it into a
13
+ # public artifact; the baker refuses it. Users connect with an API token or
14
+ # bring their own Atlassian app (stored locally, 0600).
15
+
16
+ # --- GitHub (OAuth App with "Enable Device Flow" ticked) ----------------------
17
+ RAGDESK_GITHUB_CLIENT_ID=
18
+
19
+ # --- Google Drive (GCP project -> Drive API -> OAuth client, type Desktop) ---
20
+ GDRIVE_CLIENT_ID=
21
+ GDRIVE_CLIENT_SECRET=
22
+
23
+ # --- Atlassian (optional, local-only; never commit the secret) ---------------
24
+ # RAGDESK_ATLASSIAN_CLIENT_ID=
25
+ # RAGDESK_ATLASSIAN_CLIENT_SECRET=
26
+
27
+ # --- Microsoft Graph / OneDrive / SharePoint (public client app, no secret) ---
28
+ # Azure app registration: "Allow public client flows" = Yes,
29
+ # delegated permissions: Files.Read.All, Sites.Read.All, offline_access.
30
+ # RAGDESK_MS_CLIENT_ID=
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v5
13
+ - uses: astral-sh/setup-uv@v7
14
+ with:
15
+ python-version: "3.12"
16
+ enable-cache: true
17
+ - name: Cache the embedding model
18
+ uses: actions/cache@v6
19
+ with:
20
+ path: ~/.cache/huggingface
21
+ key: hf-embeddinggemma-${{ runner.os }}-v1
22
+ - run: uv sync --all-groups --extra onnx
23
+ - run: uv run ruff check .
24
+ - run: uv run ruff format --check . # style is enforced, not just linted
25
+ - run: uv run pytest
26
+ - name: Retrieval eval (fixtures, offline embedder — fast smoke test)
27
+ run: |
28
+ uv run ragdesk --embedder hash --db .ragdesk/eval.db index fixtures/docs
29
+ uv run ragdesk --embedder hash --db .ragdesk/eval.db eval \
30
+ --golden fixtures/golden.jsonl --min-recall 0.8
31
+ - name: Retrieval eval (repo golden, real embedder, gated at 0.8)
32
+ run: scripts/eval_ci.sh --gate
@@ -0,0 +1,42 @@
1
+ name: Eval
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ tags: ["v*"]
7
+ schedule:
8
+ - cron: "0 6 1 * *" # monthly, so the badge cannot quietly rot
9
+
10
+ permissions:
11
+ contents: write
12
+
13
+ jobs:
14
+ badge:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v5
18
+ - uses: astral-sh/setup-uv@v7
19
+ with:
20
+ python-version: "3.12"
21
+ enable-cache: true
22
+ - name: Cache the embedding model
23
+ uses: actions/cache@v6
24
+ with:
25
+ path: ~/.cache/huggingface
26
+ key: hf-embeddinggemma-${{ runner.os }}-v1
27
+ - run: uv sync --all-groups --extra onnx
28
+ - name: Score the repo golden set
29
+ run: scripts/eval_ci.sh --db .ragdesk/eval-badge.db > /tmp/eval.json
30
+ - name: Refresh docs/eval.json and the README row
31
+ run: uv run python scripts/eval_badge.py /tmp/eval.json
32
+ - name: Commit only when a number moved
33
+ run: |
34
+ if git diff --quiet; then
35
+ echo "numbers unchanged — nothing to publish"
36
+ exit 0
37
+ fi
38
+ git config user.name "ragdesk eval"
39
+ git config user.email "github-actions[bot]@users.noreply.github.com"
40
+ git add docs/eval.json README.md
41
+ git commit -m "eval: refresh the published retrieval numbers [skip ci]"
42
+ git push
@@ -0,0 +1,85 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ # Tag pushes do not run ci.yml, and publishing is irreversible: gate here.
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v5
16
+ - uses: astral-sh/setup-uv@v7
17
+ with:
18
+ python-version: "3.12"
19
+ enable-cache: true
20
+ - run: uv sync --all-groups --extra onnx
21
+ - run: uv run ruff check .
22
+ - run: uv run ruff format --check .
23
+ - run: uv run pytest
24
+
25
+ build:
26
+ needs: test
27
+ runs-on: ubuntu-latest
28
+ steps:
29
+ - uses: actions/checkout@v5
30
+ - uses: astral-sh/setup-uv@v7
31
+ with:
32
+ python-version: "3.12"
33
+ - run: uv build
34
+ - uses: actions/upload-artifact@v4
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+
39
+ publish:
40
+ needs: build
41
+ runs-on: ubuntu-latest
42
+ environment: pypi
43
+ permissions:
44
+ id-token: write
45
+ steps:
46
+ - uses: actions/download-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist/
50
+ - uses: pypa/gh-action-pypi-publish@release/v1
51
+
52
+ desktop:
53
+ # The DMG is attached to the GitHub Release for people who want the app
54
+ # without a toolchain. It is NOT notarized (that needs a paid Apple
55
+ # Developer account), so Gatekeeper will warn on first open — the release
56
+ # notes say so, and building from source stays the supported path.
57
+ needs: test
58
+ runs-on: macos-14
59
+ permissions:
60
+ contents: write
61
+ env:
62
+ # Ad-hoc signing in CI: no developer certificate lives in the repo.
63
+ APPLE_SIGNING_IDENTITY: "-"
64
+ steps:
65
+ - uses: actions/checkout@v5
66
+ - uses: actions/setup-node@v4
67
+ with:
68
+ node-version: "20"
69
+ - uses: dtolnay/rust-toolchain@stable
70
+ - uses: astral-sh/setup-uv@v7
71
+ with:
72
+ python-version: "3.12"
73
+ - run: npm ci
74
+ working-directory: desktop
75
+ - run: npm run tauri build
76
+ working-directory: desktop
77
+ - name: Attach the DMG to the release
78
+ run: |
79
+ tag="${GITHUB_REF_NAME}"
80
+ gh release view "$tag" >/dev/null 2>&1 || \
81
+ gh release create "$tag" --title "$tag" --generate-notes
82
+ gh release upload "$tag" \
83
+ desktop/src-tauri/target/release/bundle/dmg/*.dmg --clobber
84
+ env:
85
+ GH_TOKEN: ${{ github.token }}
@@ -0,0 +1,17 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .ragdesk/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .DS_Store
11
+
12
+ # local build credentials (never commit)
13
+ .env
14
+ src/ragdesk/_build_env.py
15
+
16
+ # bundled runtime (scripts/build_sidecar.sh regenerates it)
17
+ desktop/src-tauri/sidecar/
@@ -0,0 +1,406 @@
1
+ # ragdesk — agent notes
2
+
3
+ Personal, local-first RAG over your own sources. Core is **plain Python** with
4
+ two runtime dependencies (numpy, pypdf); models are reached through a local
5
+ Ollama server.
6
+
7
+ ## Layout
8
+
9
+ - `src/ragdesk/` — package. `chunk` (paragraph-aware + overlap), `embed`
10
+ (HashingEmbedder for CI, OllamaEmbedder, OnnxEmbedder = EmbeddingGemma int8
11
+ with query/doc prompts; shared `tokenize`), `store` (SQLite: FTS5 + float32
12
+ vectors + fail-closed embedder guard; keeps the chosen local roots in `meta`
13
+ for the per-path Indexed breakdown via `local_paths()`; `duplicate_clusters`
14
+ groups docs by exact chunk-hash containment — a copied or sliced file shares
15
+ chunks, a topical neighbour does not, which document-level cosine cannot
16
+ separate; `web_pages()` is the bookmark list), `search` (BM25 +
17
+ dense + RRF; `retrieve` adds the optional rerank stage), `rerank`
18
+ (LexicalReranker baseline; FastEmbedReranker + OnnxReranker = multilingual
19
+ gte behind the `onnx` extra), `index` (incremental local files; `iter_files`
20
+ prunes `SKIP_DIRS` during the walk so `target/`/`node_modules/` are never
21
+ traversed; `index_document` takes optional `metadata` merged over any
22
+ front-matter; one bad file becomes a skip with the reason, never a dead
23
+ watcher), `office` (PDF/DOCX/PPTX/XLSX text extraction: docx/pptx/xlsx via
24
+ zip+XML with zero deps — sheets keep `r<row>` refs, shared + inline strings;
25
+ PDFs via pypdf (one of the two runtime dependencies); XML with a DTD is refused;
26
+ scanned PDFs return empty and are skipped, no OCR), `vision` (image OCR through Apple's
27
+ on-device Vision framework via the `vision` extra: `en-US` + `vi-VT`, images
28
+ upscaled 2× before recognition, header carries file name + Spotlight capture
29
+ date; non-macOS or no extra → images skip as before), `github` / `gitlab` / `confluence` / `gdrive` / `notion` /
30
+ `msgraph` (OneDrive + SharePoint, device flow) connectors, `email_source`
31
+ (read-only email: `index_mbox` iterates one document per message,
32
+ `sync_imap` uses `select(readonly=True)` + `BODY.PEEK` so nothing is ever
33
+ marked read; headers + text body only, attachment names are listed not
34
+ parsed; credentials under the `email` provider), `web` (same-host
35
+ HTML crawl, capped pages/depth; `save_page` = one page, failures raise),
36
+ `archive`
37
+ (shared repo-tarball extraction), `htmlutil` (shared HTML→text),
38
+ `evaluate` (recall@5 / nDCG@10 / MRR), `answer` (Ollama LLM: non-stream +
39
+ stream, grounding gate), `credentials` (0600 store under `~/.config/ragdesk/`),
40
+ `envfile` (.env loader for dev), `buildenv` (bakes .env into a gitignored
41
+ `_build_env.py` for release builds), `mcp` (stdio MCP server exposing
42
+ search/document/sources to Claude Code & co), `serve` (loopback JSON API:
43
+ status/search/ask/index/sync + connections connect/disconnect + optional
44
+ static UI; hosts the auto-index timer), `settings` (app config in
45
+ `~/.config/ragdesk/settings.json`, 0600: `auto_index_hours`, default 1,
46
+ 0 = off), `llm` (answer backends + the no-double-download ladder: `auto`
47
+ reuses a running Ollama that already has the preset model, else MLX
48
+ in-process from the shared HF cache; `--llm`/`RAGDESK_LLM` override),
49
+ `presets` (RAM tiers; each carries an Ollama tag and an `llm_mlx` repo;
50
+ `POST /api/settings {preset}` applies a tier live — rerank and LLM swap, the
51
+ shared embedder means no re-index; the choice persists in settings.json and
52
+ beats the built-in default, with `--preset` still winning at launch),
53
+ `cli`.
54
+ - UI identity (`desktop/src/styles.css`, tokens at the top): the "catalog
55
+ drawer" — grey-green card stock (`--paper` desk, `--sheet` working sheet,
56
+ `--card` insets), **violet library ink** (`--stamp`) for actions and stamps,
57
+ **amber** (`--lamp`) for anything live or cited, Iowan/Charter display + SF
58
+ interface + SF Mono for paths and figures. Structural devices carry meaning:
59
+ the transcript is a ruled ledger with entry numbers in the gutter, the active
60
+ rail tab is the pulled drawer, the Indexed stats are a ledger with
61
+ proportional bars, and the composer is an index card with a violet top edge.
62
+ Keep it restrained — hairlines and one shadow, no per-element shadows, no
63
+ gradient decoration, and `prefers-reduced-motion` disables all motion.
64
+ `main.ts` also owns the command palette (⌘K/⌘N/⌘1-4/?; documents come from
65
+ `/api/search`), which must keep every `$("id")` it touches present in
66
+ `index.html` (`tests/test_ui.py` enforces that). One more rule with a
67
+ precedent: citations have ONE stored shape (`search.hit_to_dict`) — the
68
+ terminal chats once wrote a thinner one and the GUI crashed on a missing
69
+ `cosine` when it replayed them, so any writer must use the shared helper.
70
+ - `desktop/` — Tauri 2 shell (card-catalog UI; tabs: Chat, Sources, Indexed, Settings). Retrieval-only search lives in `ragdesk search` and `/api/search`;
71
+ the Chat tab keeps its source list under every answer. Rust spawns `ragdesk serve`
72
+ with `--db $HOME/.ragdesk/index.db`; a watchdog thread respawns it if it
73
+ dies (skipping the respawn when another instance owns the port) and the
74
+ child's output goes to `~/.ragdesk/serve.log`; env overrides: `RAGDESK_BIN`,
75
+ `RAGDESK_DB`, `RAGDESK_LLM_MODEL`, `RAGDESK_PROJECT`. Browser mode:
76
+ `ragdesk serve --ui desktop/dist`.
77
+ - Terminal surfaces (`tui.py`, `screen.py`): `ragdesk chat` is the line REPL
78
+ (streaming + citations, history shared with the GUI); `ragdesk chat --server
79
+ URL` attaches to a running server over its local API — no db, no embedder,
80
+ no model load, the server owns all three; `ragdesk tui` is the full-screen
81
+ Textual app behind the optional `tui` extra (`uv run --extra tui pytest
82
+ tests/test_screen.py`; the test skips itself without the extra).
83
+ - Watcher + auto-index: `watch_loop` re-indexes the recorded local roots every
84
+ `settings.watch_seconds` (default 60, 0 = off, UI card "Watch for changes")
85
+ and `auto_index_loop` re-checks everything every `auto_index_hours` as the
86
+ safety net; both take the mtime fast path, so a quiet pass is one stat per
87
+ file. `POST /api/settings {watch_seconds}` clamps to 0-3600 and `/api/status`
88
+ exposes it.
89
+ - Auto-index: `serve` runs a 60s timer; when `auto_index_hours` (Settings tab,
90
+ default 1, 0 = off) has elapsed since `auto_index_last`, it re-indexes the
91
+ recorded local roots via `run_auto_index` (connectors stay manual until
92
+ their sync params are persisted).
93
+ - LLM wizard: `/api/llm/setup` {kind: mlx|ollama} starts a background download
94
+ (`run_llm_setup`); progress + options ride on `/api/status.llm_setup`
95
+ (job: running/progress/detail/error), the Settings tab renders them, and a
96
+ finished job clears `state.llm` so the next ask re-resolves the ladder.
97
+ `llm_setup_options` respects an explicit `--llm mlx:<repo>` override.
98
+ - Ask pipeline: `ask`/`ask_stream` parse `folder:`/`source:` filters, build a
99
+ fingerprint (embedder + model + corpus revision) and try the exact cache key,
100
+ then the semantic cache (`store.cache_nearest`, cosine ≥ 0.88 — calibrated:
101
+ paraphrases score 0.91+, different intents ≤ 0.35), then retrieve → inject up
102
+ to 3 similar memories (`store.memories`, cosine ≥ 0.35) and the last 3 turns
103
+ → `answer*()`; every exchange is recorded in `chats`/`messages`, refusals and
104
+ cache replays included. Chat/cache/memory tables live in the same SQLite
105
+ file; `/api/chats` and `/api/memories` (+ `/api/memories/extract`) back the
106
+ UI. A semantic hit reports `cached_question` so the UI can say what it matched.
107
+ - Answer engines: `llm.llm_preference` (Settings/wizard) picks Ollama, MLX, or an
108
+ OpenAI-compatible endpoint; host/model live in settings.json and the API key
109
+ in credentials (`openai`). `auto` ladder: ollama-with-model → configured
110
+ endpoint → MLX.
111
+ - Diagrams: `answer.wants_diagram` detects intent (EN + VI words) and adds
112
+ `DIAGRAM_NOTE` to the prompt, which pins the allowed Mermaid vocabulary;
113
+ the UI strips the fence from the prose and renders the figure with mermaid
114
+ (strict security, themed from the CSS tokens) plus SVG download. Small-model
115
+ keyword slips (`subregion`) are repaired before rendering.
116
+ - Wizard: `/api/status.onboarded` gates the first-run overlay; `system_info()`
117
+ reports RAM + suggested preset; `/api/settings {onboarded}` marks it done.
118
+ - Chunking: `chunk_text` is paragraph-aware and records `line_start` per chunk
119
+ (citations show `file:line`). **Symbol-aware code chunking was tried three
120
+ ways and rejected — do not rebuild it without new evidence.** Fresh-db A/B on
121
+ the 12-query scoped golden (plain = recall 1.000 / nDCG 0.819 / MRR 0.757):
122
+ 1. per-symbol segmentation → 0.743 nDCG / 0.656 MRR (fragmentation raises BM25
123
+ term density, small keyword-rich chunks crowd out the right document);
124
+ 2. symbol header inside each chunk → 0.788 / 0.715 (test files carry the
125
+ symbol in their own names and out-rank the implementation);
126
+ 3. a symbol lane over definition sites (LIKE on names) → 0.523 / 0.419 (loose
127
+ substring matching adds a weak lane and dilutes RRF).
128
+ The path lane already answers "where is X defined". Real symbol intelligence
129
+ is a call-graph index (defs + references + callers), a separate feature —
130
+ not a chunking trick.
131
+ - Tuning tool: `scripts/bench.py` indexes once per chunking config and sweeps
132
+ rerankers/weights over the same db (`--db`). Measured 2026-09-15 on the
133
+ scoped golden: 600-char chunks rank better but lose recall; the multilingual
134
+ `onnx` reranker lifts recall 0.833 → 0.917 while the English fastembed one
135
+ drops it to 0.750; lane weights change nothing. Chunking stays 1000 chars
136
+ (settings `chunk_chars`/`chunk_overlap`), weights stay equal, and the quality
137
+ preset keeps `rerank: onnx`.
138
+ - Metadata: `index.parse_front_matter` reads a flat `--- key: value ---` header
139
+ (md/txt), `documents.metadata` stores it as JSON, `parse_filters` turns any
140
+ unrecognised `key:value` (minus URL schemes) into `Filters.meta`, and
141
+ `_filter_sql` matches it through `json_extract` with a sanitised key. Hits
142
+ carry the metadata for citation chips. `search.metadata_factor` is the soft
143
+ boost (measure first — see the symbol-lane lesson): `authority: canonical`
144
+ (or authoritative/official/high/true/yes/1) multiplies the fused score by
145
+ `META_BOOST` 1.05, `status: draft` (or deprecated/archived/superseded/
146
+ obsolete/wip) by `META_PENALTY` 0.90; no tag = 1.0, so the boost is opt-in
147
+ and cannot re-rank an untagged corpus. `hybrid_search` decodes every lane's
148
+ metadata JSON into the same dict shape (bm25/path lanes used to leak the raw
149
+ string into `Hit.metadata`). Measured 2026-09-15: whole-repo index (93 docs /
150
+ 2408 chunks, absolute paths) bench none+onnx and the 24-query corpus copy
151
+ both identical before/after (0.917/0.752/0.694 and 0.542/0.557/0.549);
152
+ `tests/test_retrieval.py` proves the reorder canonical > plain > draft.
153
+ - Quiet indexing: `embed.session_options(threads)` builds every ONNX session
154
+ (embedder *and* reranker) with `intra_op_num_threads`/`inter_op_num_threads=1`
155
+ when capped; `settings.embed_threads` (0 = all cores) is read at session build
156
+ and `embed.set_thread_override` lets `--embed-threads N` win for one process.
157
+ `POST /api/settings {embed_threads}` unloads the embedder so the toggle takes
158
+ effect immediately. Measured on the 651-chunk repo subset, cold index:
159
+ all cores 97s @ ~580% CPU, 4 threads 136s @ ~390%, 2 threads 256s @ ~200% —
160
+ `OMP_NUM_THREADS` does **not** work with the Python wheel, the code change is
161
+ the only way to cap it.
162
+ - Embedding cache (`store.embed_cache`, key = sha1 of embedder name + dim + exact
163
+ chunk text): `index._embed_with_cache` embeds only what is new, so a one-line
164
+ edit costs one embedding instead of a whole document's worth and a duplicated
165
+ file costs zero model calls. Measured on the 48-doc / 648-chunk repo subset:
166
+ cold index 96.8s, wipe-the-documents-and-re-index 0.2s, and the golden
167
+ numbers unchanged (0.917 / 0.783) — the cache must never alter a result, only
168
+ the work it takes to produce one. It lives in the same SQLite file (one vector
169
+ per chunk, ~3KB, pruned to 50k rows FIFO) and is disposable: delete the rows
170
+ and the next index run re-embeds.
171
+ - Rejected after measuring: a **`tiny` LLM preset** (Qwen3.5-2B-4bit, ~1.2GB RAM
172
+ vs the 4B's 2.5GB). The deterministic proxy looked *better* (grounded 1.000 vs
173
+ 0.953) but reading the answers showed the drop: the 2B opened the second
174
+ question by re-answering the first (context bleed), missed the named flags and
175
+ was visibly less structured. Lesson: `ground_answer` measures sentence overlap
176
+ with the cited chunks, so a short extractive answer scores high — **always read
177
+ the answers before shipping a model swap**, and keep the 4B.
178
+ - Ranking extras: `diversify` caps chunks per document (2 by default, backfills
179
+ when a query is dominated by one file) and `recency_factor` adds a mild
180
+ freshness nudge to the RRF score (RECENCY_WEIGHT).
181
+ - Smart retrieval (`serve._smart_retrieval` + `parse_smart_retrieval`): ONE
182
+ local-model call that rewrites follow-ups, drafts the HyDE text and proposes
183
+ up to two sub-queries (extra dense lanes). Runs only when it can pay off
184
+ (history present, multi-part question, or hyde enabled) and is a no-op
185
+ whenever no model resolves.
186
+ - Trust surface: `POST /api/feedback {message_id, value}` (▲/▼ stored on the
187
+ message), `GET /api/feedback/golden` (rated answers exported as a golden
188
+ JSONL), `POST /api/verify {message_id}` (`ground_answer_detail`: per-sentence
189
+ grounded/loose verdicts against the stored citations, stopword-free overlap).
190
+ - Library surfaces: `GET /api/duplicates` (chunk-hash clusters, display-only —
191
+ ranking is untouched by design), `POST /api/save {url}` + `GET /api/bookmarks`
192
+ (one page saved with `web.save_page`, `metadata.url` keeps the real address),
193
+ and the CLI mirrors: `ragdesk save <url>`, `ragdesk completions
194
+ bash|zsh|fish`, `ragdesk man` (all in `complete.py`, generated from the
195
+ argparse tree so they cannot drift; `complete._subparsers` reads argparse
196
+ internals on purpose).
197
+ - Email surfaces: `POST /api/connections/email {host, port, user, password}`
198
+ (validated by `email_source.whoami` before saving), `POST /api/sync/email
199
+ {folder, limit}` (saved credentials; `limit` = newest N messages) and
200
+ `POST /api/sync/email-mbox {path}`; CLI mirrors as `ragdesk email --mbox
201
+ FILE` / `--imap HOST --user U` (password prompted, never an argv default).
202
+ The Sources tab has the card; `credentials.email` holds the app password.
203
+ - Call graph (`symbols.py`): `parse_symbol_question` recognises "who calls X",
204
+ "callers of X", "where is X defined" and the VI equivalents (returns
205
+ `(name, certain)`; a plain lowercase word is only trusted when a definition
206
+ exists). `find_symbol` scans the *stored chunk text* of code extensions for
207
+ definitions (`chunk.SYMBOL_RE`), call sites (`X(`) and mentioning files — no
208
+ AST, no index-time table, no re-index needed on old corpora; line numbers are
209
+ chunk-relative (a file with runs of blank lines can be off by a line or two).
210
+ `serve._symbol_lookup` runs before retrieval and returns a deterministic
211
+ answer + `lanes="symbol"` hits, so symbol questions never reach the LLM and
212
+ never touch the RRF lanes (graph stays at the navigation layer). The ask
213
+ response carries `symbol` and the UI shows a "call graph" badge.
214
+ - Judge (`evaluate.judge_answer` + `JUDGE_PROMPT`): an opt-in LLM grader next
215
+ to the overlap proxy — one call per answer asking for
216
+ `{"supported", "total", "unsupported"}`; `parse_judge_reply` tolerates prose
217
+ around the JSON and any unparseable reply stays `judged: False` (out of the
218
+ aggregate, reported as coverage). `eval --judge` implies `--answers` and
219
+ prints `judge faithfulness` + how many answers were judged.
220
+ - Portable bundle (`serve.run_export` / `run_import`): a zip with
221
+ `manifest.json` + a SQLite-consistent `index.db` snapshot under
222
+ `<db dir>/bundles/`; import validates the manifest, refuses a major-version
223
+ gap or a different embedder (fail-closed: a silent mismatch would poison
224
+ every future query), takes a safety snapshot, then uses the SQLite backup API
225
+ to overwrite the live database in place. CLI `ragdesk export` / `import`,
226
+ API `/api/export`, `/api/bundles`, `/api/import`, UI card in Settings.
227
+ Credentials are never included in a bundle.
228
+ - Scope chip (UI): "Only this folder" on a citation sets a pending scope; the
229
+ next questions are sent as `folder:"<dir>"` (quoted filter values — see
230
+ `_FILTER_RE` in search.py), which is why paths with spaces work.
231
+ - Topic map picture (`scripts/topic_map.py`): numpy PCA over `_doc_vectors()`
232
+ (script, not the stdlib core), colours from `topics.cluster_documents`,
233
+ capped at `PER_CLUSTER` dots so a 200-copy pile cannot squash the map;
234
+ writes a self-contained HTML with hover tooltips.
235
+ - Topic map (`topics.py`): `cluster_documents` is greedy leader clustering over
236
+ `store._doc_vectors()` (the same average chunk vectors related-documents uses;
237
+ pure Python, `MAX_DOCS` 2000 before it needs numpy) at cosine ≥ 0.75;
238
+ `cluster_labels` attaches the most distinctive terms (cluster rate minus the
239
+ corpus rate, sampled to 20k chunks) so the label is not just the commonest
240
+ words. `GET /api/topics` powers the Indexed tab's Topics card, which shows
241
+ multi-document clusters open, collapses single-document topics, and caps
242
+ paths per cluster. Display only — never a ranking lane.
243
+ - Connector auto-sync: `settings.sync_jobs` holds `{id, provider, params}` entries
244
+ (id = sha1 of provider+params, so the same sync is one job); the UI's
245
+ "Keep in sync (hourly)" checkbox on a sync form posts to `/api/sync-jobs`
246
+ after a successful sync. `SYNC_HANDLERS` in serve.py dispatches all nine
247
+ connectors — github, gitlab, confluence, gdrive, msgraph, notion, email,
248
+ web, s3 — pulling credentials from the same places the manual endpoints do
249
+ (email's password from `credentials`, tokens resolved by each connector).
250
+ `run_auto_index` runs local roots first, then every job, and returns a
251
+ `connectors` list; a failing job is reported in it, never fatal.
252
+ `GET /api/sync-jobs`, `POST /api/sync-jobs`, `/api/sync-jobs/delete`;
253
+ `/api/status.sync_jobs` feeds the Settings card.
254
+ - S3 (`s3.py`): hand-rolled **SigV4 over stdlib** (`hashlib`/`hmac`/`urllib` +
255
+ `xml.etree`) instead of boto3 or the aws CLI — a non-technical user should
256
+ paste one key into the app (or read a public bucket with no key at all), not
257
+ install a second tool. Key details: the canonical URI is the path **as sent**
258
+ (S3 does not normalize it — matching botocore's `S3SigV4Auth`, cross-checked
259
+ 2026-09-16 on query strings, encoded keys with spaces, Unicode, `+` and
260
+ custom endpoints), `x-amz-content-sha256` is the empty-payload hash, and
261
+ **S3 XML carries a default namespace** so tags are stripped before parsing
262
+ (finding `Contents` without that step silently returns zero objects — the
263
+ first live run against a public bucket caught it). Credentials resolve
264
+ explicit → `AWS_*` env → saved connection; custom endpoints are path-style.
265
+ `/api/connections/s3` validates by listing before saving, `/api/sync/s3`
266
+ indexes, `/api/status.s3` drives the card, `ragdesk s3 BUCKET` mirrors it.
267
+ Verified live against the public `noaa-ghcn-pds` bucket: anonymous list,
268
+ fetch, extract and search with no key stored.
269
+ - Email attachments (`email_source.attachments`): up to 10 per message, each
270
+ decoded and pushed through `extract_bytes` (so OCR and sheet row-refs work);
271
+ indexed as their own documents at `<base>::<message-key>::NN-<name>` with
272
+ `kind: attachment`, while the message body keeps `[attachment: name]` lines.
273
+ `IndexStats.attachments` counts them and flows into every sync summary.
274
+ - Reliability surfaces: `GET /api/health` (embedder match, last local run + `skipped_samples` from `store.last_index_report()`, oldest documents, db
275
+ size, never-index patterns + docs still matching them), `POST
276
+ /api/never-index {patterns}` (saves `settings.never_index` AND prunes matching
277
+ documents — redaction, not just prevention; `index_paths` skips matching files
278
+ as `never-index pattern`), and `GET /api/backups` + `POST /api/backup` +
279
+ `POST /api/restore {path}` (`run_backup`/`restore_backup` use the SQLite
280
+ backup API so they are safe while the app runs; a restore takes a safety
281
+ snapshot first, and same-second snapshots get a `-N` suffix — without it the
282
+ safety copy silently overwrote the backup being restored).
283
+ - **FTS wedge (fixed 2026-09-15, live incident):** a `chunks_fts` row whose
284
+ chunk was gone collided with the next chunk id once ids were reused
285
+ ("constraint failed" on insert), every re-index of that file failed, and the
286
+ exception killed the watch + auto-index threads for the session. `Store._prune_orphan_fts_rows` repairs on every open (cheap count check),
287
+ `index_paths` turns a per-file failure into a skip with the reason, and the
288
+ serve loops log a failed pass instead of dying. If indexing "stops" again,
289
+ check `~/.ragdesk/serve.log` first.
290
+ - Corrections: `corrections` table (`question`, `answer`, embedding of the
291
+ question; `corrections_revision` for invalidation). The UI's Fix button under
292
+ an answer opens an editor and `POST /api/corrections {question, answer}`;
293
+ `GET /api/corrections` + `POST /api/corrections/delete` back the Settings
294
+ card. On ask, `serve._corrections_for` tries every rewrite variant
295
+ (`search_query` + `sub_queries`) and injects the nearest correction at
296
+ cosine ≥ `CORRECTION_MIN_COSINE` 0.88 (same calibration as the semantic
297
+ cache) — measured: a follow-up's raw text scored 0.416, its sub-query 0.896,
298
+ so a single raw lookup is not enough. The block rides right before the
299
+ question (`answer.CORRECTION_HEADER`, `ANSWER_PROMPT_VERSION` v4) because a
300
+ 4B model ignored it mid-prompt but followed it near the question — verify any
301
+ wording change against the live model, not just a unit test. The API echoes
302
+ `correction` (the matched question) on ask/ask_stream so the UI shows a "your
303
+ fix" badge; the fingerprint carries `corrections_revision`, so adding a
304
+ correction invalidates cached answers instead of replaying an uncorrected one.
305
+ - Retrieval lanes (`search.hybrid_search`): BM25 (FTS5), dense cosine, path
306
+ tokens (file names), plus an optional HyDE dense lane — RRF-fused. HyDE text
307
+ comes from `settings.hyde` (Settings toggle, off by default; measured:
308
+ recall@5 0.889 → 1.000 on the repo golden set at +2-4s per question) and is
309
+ skipped silently whenever no LLM resolves.
310
+ - Parent-child: `index.group_parents` groups child chunks (~4k chars) and
311
+ `Hit.context` returns the parent for prompts; old rows are backfilled on
312
+ Store open (`_backfill_parents`, no re-embedding) and fall back to the child.
313
+ - Eval: `evaluate` groups per-category metrics, `category_metrics` powers
314
+ `--min-recall-category name=value` gates, and `ground_answer` is the
315
+ deterministic faithfulness proxy (sentence overlap against cited chunks +
316
+ citation range checks) used by `eval --answers`. Golden rows may carry
317
+ `"history": [["user", "…"], ["assistant", "…"]]` for follow-ups; passing
318
+ `rewrite_for(question, history)` makes the harness search the standalone
319
+ rewrite too, and `eval --rewrite` prints the raw baseline next to it (the
320
+ rewriter is `cli._standalone_rewrite`, the same prompt `serve` uses; it needs
321
+ a resolvable local model or exits 2). `fixtures/golden_multiturn.jsonl`
322
+ scores 0.926 → 1.000 nDCG / 0.900 → 1.000 MRR (raw → rewrite, Qwen3.5-4B MLX,
323
+ 2026-09-15). **Lesson: `SMART_RETRIEVAL_PROMPT` holds a JSON example, so its
324
+ braces must be doubled for `.format()` — the un-doubled prompt raised
325
+ `KeyError: '"standalone"'`, the bare `except` in `_smart_retrieval` swallowed
326
+ it, and follow-up rewriting was silently off; those handlers now print the
327
+ reason to stderr.**
328
+ - Connectors ingest payloads through `index.extract_bytes(data, name)`: one
329
+ dispatcher for PDFs, office files, images (OCR) and plain text. Never decode
330
+ raw bytes to text at a call site — `is_indexable` admits those types now, so
331
+ a local decode would index image bytes as mojibake.
332
+ - Long jobs report progress through `state.activity` (owner-token guarded so
333
+ concurrent runs cannot clobber each other): index/sync endpoints pass a
334
+ progress callback into `index_paths`/`sync_github`/`sync_gitlab`, auto-index
335
+ claims the slot too, and `/api/status.activity` feeds the rail + sync cards.
336
+ - `ask_stream` commits headers first and then emits `{"status": …}` lines
337
+ (searching → HyDE → loading the model → thinking) before the deltas, so the
338
+ UI shows phases + elapsed seconds and can abort with the Stop button.
339
+ - Idle unload: the serve timer releases the embedder session and the LLM after
340
+ `idle_unload_minutes` (Settings; 0 = never) of no POSTs; ONNX workspace
341
+ memory does not respond to arena/batch tuning, so dropping the session is
342
+ the honest way to give RAM back.
343
+ - Connections: GitHub has three paths (device code with `RAGDESK_GITHUB_CLIENT_ID`
344
+ or a saved client ID, `gh` login, or a pasted token); Confluence and GDrive
345
+ connect flows validate before saving to `~/.config/ragdesk/credentials.json`
346
+ (`0600`; override the dir with `RAGDESK_CONFIG_DIR` for tests).
347
+ - Credentials policy: `.env` (gitignored) → `python -m ragdesk.buildenv` bakes
348
+ non-confidential values into `src/ragdesk/_build_env.py` (gitignored) for
349
+ release artifacts. The Atlassian 3LO secret is refused by the baker — it stays
350
+ per-user. User-facing setup guides: `docs/sources.md`.
351
+ - `fixtures/` — tiny corpus + two golden sets (fixtures, repo) for offline CI.
352
+ - `tests/` — pytest; always uses `HashingEmbedder` (never requires Ollama or
353
+ network). Connector tests monkeypatch HTTP.
354
+
355
+ ## Commands
356
+
357
+ ```bash
358
+ uv sync --all-groups
359
+ uv run pytest
360
+ uv run ruff check .
361
+ uv run ragdesk --embedder hash --db /tmp/eval.db index fixtures/docs
362
+ uv run ragdesk --embedder hash --db /tmp/eval.db eval --golden fixtures/golden.jsonl
363
+ # repo golden (real numbers; --embedder onnx downloads ~0.3GB on first run)
364
+ # NB: the golden scopes with folder:dtduc-git/ragdesk, so index an absolute path
365
+ uv run ragdesk --embedder hash --db /tmp/eval-repo.db index "$PWD"
366
+ uv run ragdesk --embedder hash --db /tmp/eval-repo.db eval --golden fixtures/golden_repo.jsonl
367
+ # desktop shell (bundles a self-contained Python runtime — scripts/build_sidecar.sh
368
+ # runs automatically as beforeBuildCommand: standalone CPython + ragdesk[onnx,mlx,vision]
369
+ # into desktop/src-tauri/sidecar/, ~470MB, gitignored. The shell prefers
370
+ # <Resources>/python/bin/python3 -m ragdesk over PATH; module invocation on
371
+ # purpose — a console-script shebang bakes in build-machine paths. Verify a
372
+ # build with: env -i HOME=$HOME PATH=/usr/bin:/bin <app>/Contents/Resources/python/bin/python3 -m ragdesk --db /tmp/t.db serve --port 8770)
373
+ cd desktop && npm install && npm run tauri build
374
+ # macOS: sign with a stable identity so the Documents/Desktop/Downloads TCC
375
+ # grant survives rebuilds — an ad-hoc build gets a new identity every time and
376
+ # macOS re-asks for folder access on the next run
377
+ APPLE_SIGNING_IDENTITY="Apple Development: <you> (<TEAMID>)" npm run tauri build
378
+ # release build with baked credentials
379
+ cp .env.example .env # fill non-confidential values (see SECURITY.md)
380
+ uv run python -m ragdesk.buildenv && uv build
381
+ ```
382
+
383
+ ## Conventions
384
+
385
+ - Core stays dependency-free (argparse/sqlite3/urllib). New runtime deps need a
386
+ good reason; ONNX/reranker deps belong in an optional extra.
387
+ - `HashingEmbedder` is for tests/CI only — never quote its numbers as quality.
388
+ - Never default the reranker to a CC-BY-NC model (jina-reranker-v2 is
389
+ non-commercial). Defaults: `BAAI/bge-reranker-base` (MIT, fastembed) and
390
+ `onnx-community/gte-multilingual-reranker-base` (Apache-2.0, `--rerank onnx`).
391
+ - Embedder/dimension mismatch must stay fail-closed (`Store.ensure_embedder`).
392
+ - Sources are read-only. No telemetry, ever.
393
+ - Eval numbers published in README must be reproducible from the repo.
394
+
395
+ ## Roadmap order
396
+
397
+ sources (Notion → GitLab → OneDrive/SharePoint → S3) → multilingual reranker →
398
+ MCP server → release v0.1.0 (DMG notarization + release workflow + PyPI
399
+ trusted publisher) → eval badge automation → Windows/Linux builds.
400
+
401
+ ## Notes
402
+
403
+ - If code is ever reused from OpsRAG (Apache-2.0), add the required NOTICE
404
+ attribution before release.
405
+ - PyPI name `ragdesk` verified free (2026-09-14); re-check the similarity
406
+ checker at publish time.