sajitkun125 commited on
Commit
2209774
·
verified ·
1 Parent(s): 003b97f

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,39 +33,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
- app/deploy/book1_data/book1/forster1/B1_P_012.jpg filter=lfs diff=lfs merge=lfs -text
37
- app/deploy/book1_data/book1/forster1/B1_P_014.jpg filter=lfs diff=lfs merge=lfs -text
38
- app/deploy/book1_data/book1/forster1/B1_P_015.jpg filter=lfs diff=lfs merge=lfs -text
39
- app/deploy/book1_data/book1/forster1/B1_P_016.jpg filter=lfs diff=lfs merge=lfs -text
40
- app/deploy/book1_data/book1/forster1/B1_P_017.jpg filter=lfs diff=lfs merge=lfs -text
41
- app/deploy/book1_data/book1/forster1/B1_P_020.jpg filter=lfs diff=lfs merge=lfs -text
42
- app/deploy/book1_data/book1/forster1/B1_P_021.jpg filter=lfs diff=lfs merge=lfs -text
43
- app/deploy/book1_data/book1/forster1/B1_P_024.jpg filter=lfs diff=lfs merge=lfs -text
44
- app/deploy/book1_data/book1/forster1/B1_P_025.jpg filter=lfs diff=lfs merge=lfs -text
45
- app/deploy/book1_data/book1/forster1/B1_P_028.jpg filter=lfs diff=lfs merge=lfs -text
46
- app/deploy/book1_data/book1/forster1/B1_P_029.jpg filter=lfs diff=lfs merge=lfs -text
47
- app/deploy/book1_data/book1/forster1/B1_P_030.jpg filter=lfs diff=lfs merge=lfs -text
48
- app/deploy/book1_data/book1/forster1/B1_P_031.jpg filter=lfs diff=lfs merge=lfs -text
49
- app/deploy/book1_data/book1/forster1/B1_P_034.jpg filter=lfs diff=lfs merge=lfs -text
50
- app/deploy/book1_data/book1/forster1/B1_P_035.jpg filter=lfs diff=lfs merge=lfs -text
51
- app/deploy/book1_data/book1/forster1/B1_P_038.jpg filter=lfs diff=lfs merge=lfs -text
52
- app/deploy/book1_data/book1/forster1/B1_P_039.jpg filter=lfs diff=lfs merge=lfs -text
53
- app/deploy/book1_data/book1/forster1/B1_P_042.jpg filter=lfs diff=lfs merge=lfs -text
54
- app/deploy/book1_data/book1/forster1/B1_P_043.jpg filter=lfs diff=lfs merge=lfs -text
55
- app/deploy/book1_data/book1/forster1/B1_P_046.jpg filter=lfs diff=lfs merge=lfs -text
56
- app/deploy/book1_data/book1/forster1/B1_P_047.jpg filter=lfs diff=lfs merge=lfs -text
57
- app/deploy/book1_data/book1/forster1/B1_P_050.jpg filter=lfs diff=lfs merge=lfs -text
58
- app/deploy/book1_data/book1/forster1/B1_P_051.jpg filter=lfs diff=lfs merge=lfs -text
59
- app/deploy/book1_data/book1/forster1/B1_P_052.jpg filter=lfs diff=lfs merge=lfs -text
60
- app/deploy/book1_data/book1/forster1/B1_P_053.jpg filter=lfs diff=lfs merge=lfs -text
61
- app/deploy/book1_data/book1/forster1/B1_P_056.jpg filter=lfs diff=lfs merge=lfs -text
62
- app/deploy/book1_data/book1/forster1/B1_P_057.jpg filter=lfs diff=lfs merge=lfs -text
63
- app/deploy/book1_data/book1/forster1/B1_P_060.jpg filter=lfs diff=lfs merge=lfs -text
64
- app/deploy/book1_data/book1/forster1/B1_P_061.jpg filter=lfs diff=lfs merge=lfs -text
65
- app/deploy/book1_data/book1/forster1/B1_P_064.jpg filter=lfs diff=lfs merge=lfs -text
66
- app/deploy/book1_data/book1/forster1/B1_P_065.jpg filter=lfs diff=lfs merge=lfs -text
67
- app/deploy/book1_data/book1/forster1/B1_P_068.jpg filter=lfs diff=lfs merge=lfs -text
68
- app/deploy/book1_data/book1/forster1/B1_P_069.jpg filter=lfs diff=lfs merge=lfs -text
69
- app/deploy/book1_data/book1/forster1/B1_P_072.jpg filter=lfs diff=lfs merge=lfs -text
70
- app/deploy/book1_data/book1/forster1/B1_P_073.jpg filter=lfs diff=lfs merge=lfs -text
71
- app/deploy/book1_data/book1/forster1/B1_P_074.jpg filter=lfs diff=lfs merge=lfs -text
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -36,15 +36,19 @@ ENV INKFERENCE_DATA_ROOT=/data \
36
  TROCR_MODEL_ID=microsoft/trocr-base-handwritten \
37
  HTR_MAX_LONG_EDGE=1600 \
38
  HTR_NUM_BEAMS=1 \
 
39
  # --- post-correction via Groq (GROQ_API_KEY is a Space secret) ---
40
  CORRECTION_ENABLED=true \
41
  CORRECTION_BACKEND=api \
42
  CORRECTION_API_BASE=https://api.groq.com/openai/v1 \
43
  CORRECTION_API_MODEL=qwen/qwen3-32b \
44
- # --- Ask-the-Archive via Gemini (GEMINI_API_KEY is a Space secret) ---
45
- LLM_PROVIDER=gemini \
46
- LLM_MODEL=gemini-2.5-flash
47
- # Set as Space secrets (Settings -> Variables and secrets): GEMINI_API_KEY, GROQ_API_KEY.
 
 
 
48
  # To use your fine-tuned recognizer, push it to the Hub and set TROCR_MODEL_ID=<user>/inkference-trocr.
49
 
50
  USER user
 
36
  TROCR_MODEL_ID=microsoft/trocr-base-handwritten \
37
  HTR_MAX_LONG_EDGE=1600 \
38
  HTR_NUM_BEAMS=1 \
39
+ INKFERENCE_LOG_LEVEL=INFO \
40
  # --- post-correction via Groq (GROQ_API_KEY is a Space secret) ---
41
  CORRECTION_ENABLED=true \
42
  CORRECTION_BACKEND=api \
43
  CORRECTION_API_BASE=https://api.groq.com/openai/v1 \
44
  CORRECTION_API_MODEL=qwen/qwen3-32b \
45
+ # --- Ask-the-Archive: Groq gpt-oss-120b -> Gemini fallback -> extractive ---
46
+ LLM_PROVIDER=groq \
47
+ LLM_MODEL=openai/gpt-oss-120b \
48
+ LLM_FALLBACK=gemini:gemini-2.5-flash-lite
49
+ # Set as Space secrets (Settings -> Variables and secrets):
50
+ # GROQ_API_KEY -> post-correction AND primary RAG answers
51
+ # GEMINI_API_KEY -> RAG fallback when Groq is rate-limited/unavailable
52
  # To use your fine-tuned recognizer, push it to the Hub and set TROCR_MODEL_ID=<user>/inkference-trocr.
53
 
54
  USER user
README.md CHANGED
@@ -12,8 +12,9 @@ pinned: false
12
 
13
  Reader · Ask the Archive · Upload. One FastAPI container serves the Inkference UI +
14
  API: the 36-page **Book 1** corpus is baked in and seeded on boot, and **live upload**
15
- runs the full pipeline (Kraken → TrOCR → confidence → Groq correction). "Ask the
16
- Archive" answers over the corrected text with Gemini, citing source pages.
 
17
 
18
  ## What the Space runs
19
  - **Frontend + API**: same URL (the app serves `frontend/` at `/`).
@@ -22,34 +23,53 @@ Archive" answers over the corrected text with Gemini, citing source pages.
22
  - **Live upload**: Kraken segmentation + TrOCR recognition + per-word confidence +
23
  Qwen post-correction (Groq). Works on the free 16 GB Space, but CPU-slow
24
  (~minutes/page) and uploaded pages are lost on restart (ephemeral `/data`).
25
- - **Ask the Archive**: MiniLM + FAISS retrieval → Gemini answer + page citations,
26
- with an extractive fallback if the LLM is unavailable.
 
 
 
27
 
28
  ## Deploy steps
29
 
30
  1. **Log in to Hugging Face**: `hf auth login` (token from
31
- https://huggingface.co/settings/tokens), or use the web UI.
32
 
33
  2. *(Recommended)* **Push the fine-tuned recognizer to the Hub** so uploads get good OCR
34
  (otherwise the base model is used):
35
  ```bash
36
- hf upload <user>/inkference-trocr models/trocr_best_from_bentham
37
  ```
38
  Then set the Space variable `TROCR_MODEL_ID=<user>/inkference-trocr`.
39
 
40
- 3. **Create a Space** SDK **Docker** (free CPU, 16 GB).
 
 
 
41
 
42
- 4. **Populate the Space repo** with ONLY what the image needs (do NOT push `data/`,
43
- `models/`, or `notebooks/` they're huge). From a clean checkout:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  ```bash
45
- cp app/deploy/Dockerfile Dockerfile # HF builds the ROOT Dockerfile
46
- # keep: Dockerfile, app/ (src, frontend, pyproject.toml, deploy/), app/deploy/book1_data/
47
- git add Dockerfile app/ && git commit -m "Inkference Space" && git push <space-remote> main
48
  ```
49
 
50
  5. **Secrets** (Space → Settings → Variables and secrets):
51
- - `GEMINI_API_KEY` — Ask-the-Archive answers
52
- - `GROQ_API_KEY` — post-correction
53
  - *(optional)* `TROCR_MODEL_ID` — your Hub recognizer
54
 
55
  6. HF builds the image (~4–5 GB; a few minutes) and boots: it seeds Book 1, then serves.
@@ -64,27 +84,40 @@ Without the keys the app still runs — correction and answers degrade to their
64
  | `TROCR_MODEL_ID` | `microsoft/trocr-base-handwritten` | recognizer (set to your Hub model) |
65
  | `HTR_MAX_LONG_EDGE` | `1600` | downscale cap (speed vs accuracy) |
66
  | `CORRECTION_ENABLED` / `CORRECTION_BACKEND` | `true` / `api` | Qwen correction via Groq |
67
- | `CORRECTION_API_MODEL` | `qwen/qwen3-32b` | Groq model |
68
- | `GROQ_API_KEY` | (secret) | correction key |
69
- | `LLM_PROVIDER` / `LLM_MODEL` | `gemini` / `gemini-2.5-flash` | Ask-the-Archive |
70
- | `GEMINI_API_KEY` | – (secret) | RAG answer key |
 
 
71
  | `INKFERENCE_DATA_ROOT` | `/data` | ephemeral corpus store |
 
 
 
 
 
 
 
 
 
 
72
 
73
  ## Caveats (free tier)
74
  - **Ephemeral storage**: `/data` resets on restart → Book 1 re-seeds automatically, but
75
- uploaded pages are lost. For persistence, attach paid persistent storage or move the
76
- store to a managed DB.
77
  - **CPU speed**: live upload is minutes/page (design assumed a GPU). For production, run
78
  HTR on a serverless GPU (Modal/Replicate) via a `remote` executor.
79
  - **Sleep**: free Spaces sleep on inactivity (cold start ~30–60 s).
 
 
80
 
81
  ## Local run
82
 
83
  ```bash
84
  pip install -r requirements.txt && pip install -e ./app
85
  python -m inkference.store.seed_book1 --alex ~/Downloads/AlexFiles # or store.seed for the demo
86
- uvicorn inkference.api.main:app --port 8000
87
  ```
88
 
89
  See [../projectNotes/running_and_seeds.md](../projectNotes/running_and_seeds.md) for seeds/data-roots
90
- and [../projectNotes/inkference_platform_plan.md](../projectNotes/inkference_platform_plan.md) for the plan.
 
12
 
13
  Reader · Ask the Archive · Upload. One FastAPI container serves the Inkference UI +
14
  API: the 36-page **Book 1** corpus is baked in and seeded on boot, and **live upload**
15
+ runs the full pipeline (Kraken → TrOCR → confidence → Qwen correction). "Ask the
16
+ Archive" answers over the corrected text (with page citations), and can also answer
17
+ **in character as the author** (Forster).
18
 
19
  ## What the Space runs
20
  - **Frontend + API**: same URL (the app serves `frontend/` at `/`).
 
23
  - **Live upload**: Kraken segmentation + TrOCR recognition + per-word confidence +
24
  Qwen post-correction (Groq). Works on the free 16 GB Space, but CPU-slow
25
  (~minutes/page) and uploaded pages are lost on restart (ephemeral `/data`).
26
+ - **Ask the Archive**: MiniLM + FAISS retrieval → LLM answer + page citations.
27
+ Answer generation uses a **fallback chain**: primary **Groq `openai/gpt-oss-120b`**
28
+ → **Gemini `gemini-2.5-flash-lite`** (when Groq is rate-limited/unavailable) →
29
+ **extractive** passage (always works, cited, $0). The **"Answer as Author"** button
30
+ answers in first person as Forster with an *IN CHARACTER* tag.
31
 
32
  ## Deploy steps
33
 
34
  1. **Log in to Hugging Face**: `hf auth login` (token from
35
+ https://huggingface.co/settings/tokens); confirm with `hf auth whoami`.
36
 
37
  2. *(Recommended)* **Push the fine-tuned recognizer to the Hub** so uploads get good OCR
38
  (otherwise the base model is used):
39
  ```bash
40
+ hf upload <user>/inkference-trocr models/trocr_best_from_bentham --repo-type model
41
  ```
42
  Then set the Space variable `TROCR_MODEL_ID=<user>/inkference-trocr`.
43
 
44
+ 3. **Create a Space** (Docker SDK, free CPU):
45
+ ```bash
46
+ hf repo create inkference --repo-type space --space-sdk docker
47
+ ```
48
 
49
+ 4. **Populate the Space repo** with ONLY what the image needs (never `data/`, `models/`,
50
+ or `notebooks/`). Clone the Space and copy the required files in:
51
+ ```bash
52
+ REPO=$(pwd) # this project's root
53
+ git clone https://huggingface.co/spaces/<user>/inkference ~/hf-inkference
54
+ cd ~/hf-inkference
55
+ cp "$REPO/app/deploy/Dockerfile" Dockerfile # HF builds the ROOT Dockerfile
56
+ cp "$REPO/app/deploy/README.md" README.md # HF frontmatter (sdk: docker, app_port)
57
+ mkdir -p app/deploy
58
+ cp "$REPO/app/pyproject.toml" app/
59
+ cp -r "$REPO/app/src" app/
60
+ cp -r "$REPO/app/frontend" app/
61
+ cp "$REPO/app/deploy/requirements-space.txt" app/deploy/
62
+ cp -r "$REPO/app/deploy/book1_data" app/deploy/
63
+ ```
64
+ Push with **`hf upload`** (uses your login token — avoids the git-credential prompt
65
+ that makes `git push` hang):
66
  ```bash
67
+ hf upload <user>/inkference . --repo-type space --exclude ".git/*"
 
 
68
  ```
69
 
70
  5. **Secrets** (Space → Settings → Variables and secrets):
71
+ - `GROQ_API_KEY` — post-correction **and** primary Ask-the-Archive answers
72
+ - `GEMINI_API_KEY` — Ask-the-Archive fallback (used when Groq is rate-limited)
73
  - *(optional)* `TROCR_MODEL_ID` — your Hub recognizer
74
 
75
  6. HF builds the image (~4–5 GB; a few minutes) and boots: it seeds Book 1, then serves.
 
84
  | `TROCR_MODEL_ID` | `microsoft/trocr-base-handwritten` | recognizer (set to your Hub model) |
85
  | `HTR_MAX_LONG_EDGE` | `1600` | downscale cap (speed vs accuracy) |
86
  | `CORRECTION_ENABLED` / `CORRECTION_BACKEND` | `true` / `api` | Qwen correction via Groq |
87
+ | `CORRECTION_API_MODEL` | `qwen/qwen3-32b` | Groq correction model |
88
+ | `LLM_PROVIDER` / `LLM_MODEL` | `groq` / `openai/gpt-oss-120b` | primary Ask-the-Archive model |
89
+ | `LLM_FALLBACK` | `gemini:gemini-2.5-flash-lite` | ordered `provider:model` fallback chain |
90
+ | `GROQ_API_KEY` | – (secret) | correction + primary RAG |
91
+ | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | – (secret) | RAG fallback |
92
+ | `INKFERENCE_LOG_LEVEL` | `INFO` | `DEBUG` for per-page/stage + provider logs |
93
  | `INKFERENCE_DATA_ROOT` | `/data` | ephemeral corpus store |
94
+ | `CORS_ORIGINS` | `*` | allowed frontend origins |
95
+
96
+ Key resolution is provider-aware: `LLM_PROVIDER=groq` uses `GROQ_API_KEY`, `=gemini`
97
+ uses `GEMINI_API_KEY`/`GOOGLE_API_KEY` — so switching the provider "just works".
98
+
99
+ ## Logs
100
+ All app logs use the `inkference.*` loggers (`inkference.api`, `.rag`, `.ingest`,
101
+ `.correction`) and print to the container console (visible in the Space **Logs** tab).
102
+ API keys are **redacted** from logs, and provider errors are logged server-side only —
103
+ never returned to the client. Set `INKFERENCE_LOG_LEVEL=DEBUG` for verbose detail.
104
 
105
  ## Caveats (free tier)
106
  - **Ephemeral storage**: `/data` resets on restart → Book 1 re-seeds automatically, but
107
+ uploaded pages are lost. For persistence, attach paid persistent storage or a managed DB.
 
108
  - **CPU speed**: live upload is minutes/page (design assumed a GPU). For production, run
109
  HTR on a serverless GPU (Modal/Replicate) via a `remote` executor.
110
  - **Sleep**: free Spaces sleep on inactivity (cold start ~30–60 s).
111
+ - **LLM free-tier limits**: Groq gpt-oss-120b ≈ 8k tokens/min (plenty for a demo);
112
+ Gemini free tier is stingy — the fallback chain + extractive default keep answers flowing.
113
 
114
  ## Local run
115
 
116
  ```bash
117
  pip install -r requirements.txt && pip install -e ./app
118
  python -m inkference.store.seed_book1 --alex ~/Downloads/AlexFiles # or store.seed for the demo
119
+ uvicorn inkference.api.main:app --port 8000 --log-level info
120
  ```
121
 
122
  See [../projectNotes/running_and_seeds.md](../projectNotes/running_and_seeds.md) for seeds/data-roots
123
+ and [../projectNotes/inkference_platform_plan.md](../projectNotes/inkference_platform_plan.md) for the plan.
app/frontend/app.js CHANGED
@@ -144,22 +144,25 @@ $("#view-toggle").addEventListener("click", (e) => {
144
  });
145
 
146
  /* ---------- Ask the Archive ---------- */
147
- async function ask(question) {
148
  if (!question.trim() || !state.doc) return;
 
149
  const thread = $("#thread");
150
  const q = el("div", "bubble-q"); q.textContent = question; thread.appendChild(q);
151
 
152
  const ans = el("div", "answer");
 
 
153
  ans.innerHTML = `<div class="answer-head"><div class="answer-mark">I</div>
154
- <span class="answer-who">Inkference</span></div>
155
- <div class="answer-body">…thinking…</div>`;
156
  thread.appendChild(ans);
157
  thread.scrollTop = thread.scrollHeight;
158
 
159
  try {
160
  const res = await api(`/documents/${state.doc.id}/ask`, {
161
  method: "POST", headers: { "content-type": "application/json" },
162
- body: JSON.stringify({ question }),
163
  });
164
  ans.querySelector(".answer-body").textContent = res.answer;
165
  if (res.source_pages && res.source_pages.length) {
@@ -177,8 +180,10 @@ async function ask(question) {
177
  }
178
  thread.scrollTop = thread.scrollHeight;
179
  }
180
- $("#ask-send").addEventListener("click", () => { const i = $("#ask-input"); ask(i.value); i.value = ""; });
181
- $("#ask-input").addEventListener("keydown", (e) => { if (e.key === "Enter") { ask(e.target.value); e.target.value = ""; } });
 
 
182
  $("#suggestions").addEventListener("click", (e) => { if (e.target.dataset.q) ask(e.target.dataset.q); });
183
 
184
  /* ---------- Upload & Process ---------- */
 
144
  });
145
 
146
  /* ---------- Ask the Archive ---------- */
147
+ async function ask(question, persona) {
148
  if (!question.trim() || !state.doc) return;
149
+ const cook = persona === "cook";
150
  const thread = $("#thread");
151
  const q = el("div", "bubble-q"); q.textContent = question; thread.appendChild(q);
152
 
153
  const ans = el("div", "answer");
154
+ const tag = cook ? '<span class="in-character">in character</span>' : "";
155
+ const loading = cook ? "Consulting the journal…" : "…thinking…";
156
  ans.innerHTML = `<div class="answer-head"><div class="answer-mark">I</div>
157
+ <span class="answer-who">${cook ? "Author" : "Inkference"}</span>${tag}</div>
158
+ <div class="answer-body">${loading}</div>`;
159
  thread.appendChild(ans);
160
  thread.scrollTop = thread.scrollHeight;
161
 
162
  try {
163
  const res = await api(`/documents/${state.doc.id}/ask`, {
164
  method: "POST", headers: { "content-type": "application/json" },
165
+ body: JSON.stringify({ question, persona: persona || null }),
166
  });
167
  ans.querySelector(".answer-body").textContent = res.answer;
168
  if (res.source_pages && res.source_pages.length) {
 
180
  }
181
  thread.scrollTop = thread.scrollHeight;
182
  }
183
+ function submitAsk(persona) { const i = $("#ask-input"); ask(i.value, persona); i.value = ""; }
184
+ $("#ask-send").addEventListener("click", () => submitAsk());
185
+ $("#ask-cook").addEventListener("click", () => submitAsk("cook"));
186
+ $("#ask-input").addEventListener("keydown", (e) => { if (e.key === "Enter") submitAsk(); });
187
  $("#suggestions").addEventListener("click", (e) => { if (e.target.dataset.q) ask(e.target.dataset.q); });
188
 
189
  /* ---------- Upload & Process ---------- */
app/frontend/index.html CHANGED
@@ -91,7 +91,11 @@
91
  </div>
92
  <div class="dock">
93
  <input id="ask-input" placeholder="Ask anything about this document…" />
94
- <button id="ask-send" class="send-btn">↑</button>
 
 
 
 
95
  </div>
96
  <div class="disclaimer muted">Inkference can make mistakes. Verify answers against the cited pages.</div>
97
  </div>
 
91
  </div>
92
  <div class="dock">
93
  <input id="ask-input" placeholder="Ask anything about this document…" />
94
+ <button id="ask-cook" class="cook-btn" title="Answer in the voice of the author">
95
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"></circle><path d="M15.5 8.5l-2 5-5 2 2-5z" fill="currentColor"></path></svg>
96
+ Answer as Author
97
+ </button>
98
+ <button id="ask-send" class="send-btn" title="Ask">↑</button>
99
  </div>
100
  <div class="disclaimer muted">Inkference can make mistakes. Verify answers against the cited pages.</div>
101
  </div>
app/frontend/styles.css CHANGED
@@ -180,12 +180,24 @@ body {
180
  border: 1px solid #ddd0b8; border-radius: 14px; padding: 8px 8px 8px 18px;
181
  box-shadow: 0 6px 20px rgba(60,42,18,.07);
182
  }
183
- .dock input { flex: 1; border: none; outline: none; font-size: 16px; font-family: var(--sans); background: none; color: var(--text); }
 
 
 
 
 
 
184
  .send-btn {
185
  width: 40px; height: 40px; border: none; border-radius: 11px; background: var(--accent);
186
- color: #fff; font-size: 18px; cursor: pointer;
187
  }
188
  .disclaimer { text-align: center; font-size: 11.5px; color: #a99a82; margin-top: 10px; }
 
 
 
 
 
 
189
 
190
  /* ===== Upload & Process ===== */
191
  .dropzone {
 
180
  border: 1px solid #ddd0b8; border-radius: 14px; padding: 8px 8px 8px 18px;
181
  box-shadow: 0 6px 20px rgba(60,42,18,.07);
182
  }
183
+ .dock input { flex: 1; min-width: 0; border: none; outline: none; font-size: 16px; font-family: var(--sans); background: none; color: var(--text); }
184
+ .cook-btn {
185
+ display: flex; align-items: center; gap: 7px; cursor: pointer; white-space: nowrap;
186
+ font-family: var(--sans); font-size: 13px; font-weight: 500; color: var(--accent);
187
+ background: #f6ece0; border: 1px solid rgba(138,58,47,.28); border-radius: 10px; padding: 9px 13px;
188
+ }
189
+ .cook-btn:hover { background: #f0e0d0; }
190
  .send-btn {
191
  width: 40px; height: 40px; border: none; border-radius: 11px; background: var(--accent);
192
+ color: #fff; font-size: 18px; cursor: pointer; flex: 0 0 auto;
193
  }
194
  .disclaimer { text-align: center; font-size: 11.5px; color: #a99a82; margin-top: 10px; }
195
+ /* IN CHARACTER pill on Captain Cook answers */
196
+ .in-character {
197
+ font-size: 10.5px; letter-spacing: .04em; text-transform: uppercase; color: var(--accent);
198
+ background: rgba(138,58,47,.09); border: 1px solid rgba(138,58,47,.22);
199
+ border-radius: 999px; padding: 2px 9px;
200
+ }
201
 
202
  /* ===== Upload & Process ===== */
203
  .dropzone {
app/src/inkference/api/main.py CHANGED
@@ -13,6 +13,7 @@ Endpoints (see projectNotes/inkference_platform_plan.md):
13
  """
14
  from __future__ import annotations
15
 
 
16
  import os
17
  import re
18
  import unicodedata
@@ -33,6 +34,24 @@ from ..config import (
33
  from ..rag.answer import answer_question
34
  from . import services
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  app = FastAPI(title="Inkference", version="0.1.0")
37
 
38
  app.add_middleware(
@@ -43,6 +62,16 @@ app.add_middleware(
43
  )
44
 
45
 
 
 
 
 
 
 
 
 
 
 
46
  # --------------------------------------------------------------------------- #
47
  # request models
48
  # --------------------------------------------------------------------------- #
@@ -55,6 +84,7 @@ class CreateDocument(BaseModel):
55
  class AskRequest(BaseModel):
56
  question: str
57
  top_k: int | None = None
 
58
 
59
 
60
  def _slugify(text: str) -> str:
@@ -165,8 +195,11 @@ def ask(doc_id: int, body: AskRequest) -> dict:
165
  raise HTTPException(404, "document not found")
166
  index = services.get_index()
167
  if not index.exists(doc_id):
 
168
  index.build_from_store(doc_id, store)
169
- ans = answer_question(doc_id, body.question, index, top_k=body.top_k)
 
 
170
  return ans.to_dict()
171
 
172
 
 
13
  """
14
  from __future__ import annotations
15
 
16
+ import logging
17
  import os
18
  import re
19
  import unicodedata
 
34
  from ..rag.answer import answer_question
35
  from . import services
36
 
37
+
38
+ def _setup_logging() -> None:
39
+ """Route all `inkference.*` loggers to the console at INKFERENCE_LOG_LEVEL
40
+ (default INFO; set DEBUG for verbose). Independent of uvicorn's own loggers."""
41
+ level = os.getenv("INKFERENCE_LOG_LEVEL", "INFO").upper()
42
+ lg = logging.getLogger("inkference")
43
+ lg.setLevel(level)
44
+ if not lg.handlers:
45
+ h = logging.StreamHandler()
46
+ h.setFormatter(logging.Formatter(
47
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%H:%M:%S"))
48
+ lg.addHandler(h)
49
+ lg.propagate = False
50
+
51
+
52
+ _setup_logging()
53
+ logger = logging.getLogger("inkference.api")
54
+
55
  app = FastAPI(title="Inkference", version="0.1.0")
56
 
57
  app.add_middleware(
 
62
  )
63
 
64
 
65
+ @app.middleware("http")
66
+ async def _no_cache_frontend(request, call_next):
67
+ """Tell browsers to revalidate the static frontend so edits (js/css/html)
68
+ always load fresh instead of serving a stale cached bundle."""
69
+ response = await call_next(request)
70
+ if not request.url.path.startswith("/api/"):
71
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
72
+ return response
73
+
74
+
75
  # --------------------------------------------------------------------------- #
76
  # request models
77
  # --------------------------------------------------------------------------- #
 
84
  class AskRequest(BaseModel):
85
  question: str
86
  top_k: int | None = None
87
+ persona: str | None = None # "cook" -> answer in character as Captain Cook
88
 
89
 
90
  def _slugify(text: str) -> str:
 
195
  raise HTTPException(404, "document not found")
196
  index = services.get_index()
197
  if not index.exists(doc_id):
198
+ logger.info("building RAG index for doc %s", doc_id)
199
  index.build_from_store(doc_id, store)
200
+ logger.info("ask doc=%s persona=%s q=%r", doc_id, body.persona, body.question[:100])
201
+ ans = answer_question(doc_id, body.question, index, top_k=body.top_k, persona=body.persona)
202
+ logger.info("ask doc=%s -> sources=%s", doc_id, ans.source_pages)
203
  return ans.to_dict()
204
 
205
 
app/src/inkference/api/services.py CHANGED
@@ -6,6 +6,7 @@ free-CPU pages process serially; the frontend polls GET /jobs/{id} for progress.
6
  """
7
  from __future__ import annotations
8
 
 
9
  import threading
10
  import traceback
11
  from concurrent.futures import ThreadPoolExecutor
@@ -16,6 +17,8 @@ from ..rag.index import RagIndex
16
  from ..schemas import JobStatus, Stage
17
  from ..store import DocumentStore
18
 
 
 
19
  # Map a pipeline Stage -> the job status shown in the queue UI.
20
  _STAGE_STATUS = {
21
  Stage.SEGMENTATION: JobStatus.SEGMENTING,
@@ -62,12 +65,15 @@ def submit_ingest(doc_id: int, page_specs: list[tuple[int, int, str]], job_id: i
62
  def _run_ingest(doc_id: int, page_specs: list[tuple[int, int, str]], job_id: int) -> None:
63
  store = get_store()
64
  total = len(page_specs)
 
65
  store.update_job(job_id, status=JobStatus.QUEUED, total_pages=total, done_pages=0)
66
  try:
67
  pipeline = get_pipeline()
68
  for done, (page_id, page_number, image_path) in enumerate(page_specs):
69
- def progress(stage: Stage, frac: float, msg: str, _done=done) -> None:
70
  overall = (_done + frac) / total
 
 
71
  store.update_job(
72
  job_id,
73
  status=_STAGE_STATUS.get(stage, JobStatus.RECOGNIZING),
@@ -76,17 +82,22 @@ def _run_ingest(doc_id: int, page_specs: list[tuple[int, int, str]], job_id: int
76
  message=f"Page {page_number} — {msg}",
77
  )
78
 
 
79
  store.set_page_status(page_id, "processing")
80
  result = pipeline.process_path(image_path, page_number, progress)
81
  store.save_page_result(page_id, result)
82
  store.update_job(job_id, done_pages=done + 1)
 
 
83
 
84
  # Rebuild the retrieval index now that new pages exist.
85
- get_index().build_from_store(doc_id, store)
86
  store.update_job(
87
  job_id, status=JobStatus.COMPLETE, progress=1.0, message="Complete"
88
  )
 
89
  except Exception as exc: # surface failure to the job poller
 
90
  store.update_job(
91
  job_id, status=JobStatus.FAILED,
92
  error=f"{exc}\n{traceback.format_exc()}", message=str(exc),
 
6
  """
7
  from __future__ import annotations
8
 
9
+ import logging
10
  import threading
11
  import traceback
12
  from concurrent.futures import ThreadPoolExecutor
 
17
  from ..schemas import JobStatus, Stage
18
  from ..store import DocumentStore
19
 
20
+ logger = logging.getLogger("inkference.ingest")
21
+
22
  # Map a pipeline Stage -> the job status shown in the queue UI.
23
  _STAGE_STATUS = {
24
  Stage.SEGMENTATION: JobStatus.SEGMENTING,
 
65
  def _run_ingest(doc_id: int, page_specs: list[tuple[int, int, str]], job_id: int) -> None:
66
  store = get_store()
67
  total = len(page_specs)
68
+ logger.info("ingest job %s: doc=%s, %d page(s) queued", job_id, doc_id, total)
69
  store.update_job(job_id, status=JobStatus.QUEUED, total_pages=total, done_pages=0)
70
  try:
71
  pipeline = get_pipeline()
72
  for done, (page_id, page_number, image_path) in enumerate(page_specs):
73
+ def progress(stage: Stage, frac: float, msg: str, _done=done, _pn=page_number) -> None:
74
  overall = (_done + frac) / total
75
+ logger.debug("job %s page %s: %s %.0f%% — %s",
76
+ job_id, _pn, stage.value, frac * 100, msg)
77
  store.update_job(
78
  job_id,
79
  status=_STAGE_STATUS.get(stage, JobStatus.RECOGNIZING),
 
82
  message=f"Page {page_number} — {msg}",
83
  )
84
 
85
+ logger.info("job %s: processing page %s (%d/%d)", job_id, page_number, done + 1, total)
86
  store.set_page_status(page_id, "processing")
87
  result = pipeline.process_path(image_path, page_number, progress)
88
  store.save_page_result(page_id, result)
89
  store.update_job(job_id, done_pages=done + 1)
90
+ logger.info("job %s: page %s done — %d lines, avg conf %.2f",
91
+ job_id, page_number, len(result.lines), result.avg_confidence)
92
 
93
  # Rebuild the retrieval index now that new pages exist.
94
+ n_chunks = get_index().build_from_store(doc_id, store)
95
  store.update_job(
96
  job_id, status=JobStatus.COMPLETE, progress=1.0, message="Complete"
97
  )
98
+ logger.info("ingest job %s complete; RAG index rebuilt (%s chunks)", job_id, n_chunks)
99
  except Exception as exc: # surface failure to the job poller
100
+ logger.exception("ingest job %s FAILED: %s", job_id, exc)
101
  store.update_job(
102
  job_id, status=JobStatus.FAILED,
103
  error=f"{exc}\n{traceback.format_exc()}", message=str(exc),
app/src/inkference/config.py CHANGED
@@ -108,10 +108,15 @@ class RAGConfig:
108
  )
109
  )
110
  top_k: int = field(default_factory=lambda: _env_int("RAG_TOP_K", 5))
111
- # Provider for the written answer: gemini | groq | claude | openai
112
  llm_provider: str = field(default_factory=lambda: os.getenv("LLM_PROVIDER", "gemini"))
113
  llm_model: str = field(default_factory=lambda: os.getenv("LLM_MODEL", ""))
114
  llm_api_key: str = field(default_factory=lambda: os.getenv("LLM_API_KEY", ""))
 
 
 
 
 
115
 
116
  _PROVIDER_KEYS = {
117
  "gemini": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
@@ -124,10 +129,28 @@ class RAGConfig:
124
  # If no explicit LLM_API_KEY, pull the key for the SELECTED provider so a
125
  # provider switch (e.g. gemini -> groq) uses the right key automatically.
126
  if not self.llm_api_key:
127
- for env in self._PROVIDER_KEYS.get((self.llm_provider or "").lower(), ()):
128
- if os.getenv(env):
129
- self.llm_api_key = os.getenv(env)
130
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
 
133
  @dataclass
 
108
  )
109
  )
110
  top_k: int = field(default_factory=lambda: _env_int("RAG_TOP_K", 5))
111
+ # Primary provider for the written answer: gemini | groq | claude | openai
112
  llm_provider: str = field(default_factory=lambda: os.getenv("LLM_PROVIDER", "gemini"))
113
  llm_model: str = field(default_factory=lambda: os.getenv("LLM_MODEL", ""))
114
  llm_api_key: str = field(default_factory=lambda: os.getenv("LLM_API_KEY", ""))
115
+ # Ordered fallback chain tried when the primary errors/rate-limits, as a
116
+ # comma-separated "provider:model" list. After all fail -> extractive fallback.
117
+ llm_fallback: str = field(
118
+ default_factory=lambda: os.getenv("LLM_FALLBACK", "gemini:gemini-2.5-flash-lite")
119
+ )
120
 
121
  _PROVIDER_KEYS = {
122
  "gemini": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
 
129
  # If no explicit LLM_API_KEY, pull the key for the SELECTED provider so a
130
  # provider switch (e.g. gemini -> groq) uses the right key automatically.
131
  if not self.llm_api_key:
132
+ self.llm_api_key = self.key_for(self.llm_provider)
133
+
134
+ def key_for(self, provider: str) -> str:
135
+ """Resolve the API key for a provider (used per-attempt in the chain)."""
136
+ provider = (provider or "").lower()
137
+ if self.llm_api_key and provider == (self.llm_provider or "").lower():
138
+ return self.llm_api_key
139
+ for env in self._PROVIDER_KEYS.get(provider, ()):
140
+ if os.getenv(env):
141
+ return os.getenv(env)
142
+ return ""
143
+
144
+ def attempts(self) -> list[tuple[str, str]]:
145
+ """Ordered (provider, model) attempts: primary first, then the fallback chain."""
146
+ out: list[tuple[str, str]] = [((self.llm_provider or "").lower(), self.llm_model)]
147
+ for part in self.llm_fallback.split(","):
148
+ part = part.strip()
149
+ if not part:
150
+ continue
151
+ provider, _, model = part.partition(":")
152
+ out.append((provider.strip().lower(), model.strip()))
153
+ return out
154
 
155
 
156
  @dataclass
app/src/inkference/rag/answer.py CHANGED
@@ -14,6 +14,7 @@ class Answer:
14
  question: str
15
  answer: str
16
  source_pages: list[int] = field(default_factory=list)
 
17
  # retrieved evidence, for transparency / debugging
18
  contexts: list[dict] = field(default_factory=list)
19
 
@@ -22,6 +23,8 @@ class Answer:
22
  "question": self.question,
23
  "answer": self.answer,
24
  "source_pages": self.source_pages,
 
 
25
  "contexts": self.contexts,
26
  }
27
 
@@ -32,13 +35,15 @@ def answer_question(
32
  index: RagIndex,
33
  cfg: RAGConfig = default_rag,
34
  top_k: int | None = None,
 
35
  ) -> Answer:
36
  retrieved = index.query(doc_id, question, top_k=top_k)
37
  if not retrieved:
38
- return Answer(question, "No transcribed text is available for this document yet.")
 
39
 
40
  contexts = [(r.page_number, r.text) for r in retrieved]
41
- text = generate_answer(question, contexts, cfg)
42
 
43
  # Distinct source pages in retrieval order -> the design's "Sources" chips.
44
  seen: set[int] = set()
@@ -52,6 +57,7 @@ def answer_question(
52
  question=question,
53
  answer=text,
54
  source_pages=source_pages,
 
55
  contexts=[{"page_number": r.page_number, "score": round(r.score, 4),
56
  "text": r.text} for r in retrieved],
57
  )
 
14
  question: str
15
  answer: str
16
  source_pages: list[int] = field(default_factory=list)
17
+ persona: str | None = None # e.g. "cook" -> shown with an IN CHARACTER tag
18
  # retrieved evidence, for transparency / debugging
19
  contexts: list[dict] = field(default_factory=list)
20
 
 
23
  "question": self.question,
24
  "answer": self.answer,
25
  "source_pages": self.source_pages,
26
+ "persona": self.persona,
27
+ "in_character": bool(self.persona),
28
  "contexts": self.contexts,
29
  }
30
 
 
35
  index: RagIndex,
36
  cfg: RAGConfig = default_rag,
37
  top_k: int | None = None,
38
+ persona: str | None = None,
39
  ) -> Answer:
40
  retrieved = index.query(doc_id, question, top_k=top_k)
41
  if not retrieved:
42
+ return Answer(question, "No transcribed text is available for this document yet.",
43
+ persona=persona)
44
 
45
  contexts = [(r.page_number, r.text) for r in retrieved]
46
+ text = generate_answer(question, contexts, cfg, persona=persona)
47
 
48
  # Distinct source pages in retrieval order -> the design's "Sources" chips.
49
  seen: set[int] = set()
 
57
  question=question,
58
  answer=text,
59
  source_pages=source_pages,
60
+ persona=persona,
61
  contexts=[{"page_number": r.page_number, "score": round(r.score, 4),
62
  "text": r.text} for r in retrieved],
63
  )
app/src/inkference/rag/llm.py CHANGED
@@ -7,11 +7,22 @@ the app still works at $0 and offline.
7
  """
8
  from __future__ import annotations
9
 
 
 
10
  import time
11
 
12
  from ..config import RAGConfig
13
  from ..config import rag as default_rag
14
 
 
 
 
 
 
 
 
 
 
15
 
16
  def _post_retry(url: str, retries: int = 3, **kwargs):
17
  """POST that retries transient 429/503 (rate limit / overload) with backoff."""
@@ -41,6 +52,23 @@ _SYSTEM = (
41
  "Do not invent facts. Write 2–4 sentences in a clear, scholarly tone."
42
  )
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  def _build_prompt(question: str, contexts: list[tuple[int, str]]) -> str:
46
  blocks = "\n\n".join(f"[Page {pn}]\n{txt}" for pn, txt in contexts)
@@ -57,43 +85,77 @@ def _extractive_fallback(question: str, contexts: list[tuple[int, str]]) -> str:
57
  top = contexts[0][1].replace("\n", " ").strip()
58
  pages = ", ".join(str(pn) for pn, _ in contexts)
59
  return (
60
- f"(No language model configured — showing the most relevant transcribed "
61
- f"passage from page{'s' if ',' in pages else ''} {pages}.)\n\n“{top}”"
62
  )
63
 
64
 
 
 
 
 
 
 
 
 
 
 
65
  def generate_answer(
66
- question: str, contexts: list[tuple[int, str]], cfg: RAGConfig = default_rag
 
67
  ) -> str:
68
- """contexts = [(page_number, text), ...] in relevance order."""
69
- provider = (cfg.llm_provider or "").lower()
70
- if not cfg.llm_api_key or provider not in _DEFAULT_MODELS:
71
- return _extractive_fallback(question, contexts)
72
 
73
- model = cfg.llm_model or _DEFAULT_MODELS[provider]
 
 
 
74
  prompt = _build_prompt(question, contexts)
75
- try:
76
- if provider == "gemini":
77
- return _call_gemini(model, prompt, cfg.llm_api_key)
78
- if provider in ("groq", "openai"):
79
- return _call_openai_compatible(provider, model, prompt, cfg.llm_api_key)
80
- if provider == "claude":
81
- return _call_claude(model, prompt, cfg.llm_api_key)
82
- except Exception as exc: # network/quota/etc — degrade gracefully
83
- return _extractive_fallback(question, contexts) + f"\n\n[generation error: {exc}]"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  return _extractive_fallback(question, contexts)
85
 
86
 
87
  # --------------------------------------------------------------------------- #
88
  # provider calls
89
  # --------------------------------------------------------------------------- #
90
- def _call_gemini(model: str, prompt: str, api_key: str) -> str:
91
  url = (
92
  f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
93
  f"?key={api_key}"
94
  )
95
  body = {
96
- "system_instruction": {"parts": [{"text": _SYSTEM}]},
97
  "contents": [{"role": "user", "parts": [{"text": prompt}]}],
98
  }
99
  r = _post_retry(url, json=body, timeout=60)
@@ -101,7 +163,7 @@ def _call_gemini(model: str, prompt: str, api_key: str) -> str:
101
  return r.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
102
 
103
 
104
- def _call_openai_compatible(provider: str, model: str, prompt: str, api_key: str) -> str:
105
  base = "https://api.groq.com/openai/v1" if provider == "groq" else "https://api.openai.com/v1"
106
  r = _post_retry(
107
  f"{base}/chat/completions",
@@ -109,7 +171,7 @@ def _call_openai_compatible(provider: str, model: str, prompt: str, api_key: str
109
  json={
110
  "model": model,
111
  "messages": [
112
- {"role": "system", "content": _SYSTEM},
113
  {"role": "user", "content": prompt},
114
  ],
115
  "temperature": 0.2,
@@ -120,7 +182,7 @@ def _call_openai_compatible(provider: str, model: str, prompt: str, api_key: str
120
  return r.json()["choices"][0]["message"]["content"].strip()
121
 
122
 
123
- def _call_claude(model: str, prompt: str, api_key: str) -> str:
124
  import requests
125
 
126
  r = requests.post(
@@ -133,7 +195,7 @@ def _call_claude(model: str, prompt: str, api_key: str) -> str:
133
  json={
134
  "model": model,
135
  "max_tokens": 600,
136
- "system": _SYSTEM,
137
  "messages": [{"role": "user", "content": prompt}],
138
  },
139
  timeout=60,
 
7
  """
8
  from __future__ import annotations
9
 
10
+ import logging
11
+ import re
12
  import time
13
 
14
  from ..config import RAGConfig
15
  from ..config import rag as default_rag
16
 
17
+ logger = logging.getLogger("inkference.rag")
18
+
19
+ # Strip secrets before anything is logged (Gemini puts ?key= in the URL; bearer tokens too).
20
+ _SECRET_RE = re.compile(r"(key=)[\w.\-]+|(AIza[\w\-]{20,})|(gsk_[A-Za-z0-9]{20,})|(Bearer\s+\S+)")
21
+
22
+
23
+ def _redact(text: str) -> str:
24
+ return _SECRET_RE.sub("\\1***", str(text))
25
+
26
 
27
  def _post_retry(url: str, retries: int = 3, **kwargs):
28
  """POST that retries transient 429/503 (rate limit / overload) with backoff."""
 
52
  "Do not invent facts. Write 2–4 sentences in a clear, scholarly tone."
53
  )
54
 
55
+ # In-character persona: answer AS Captain Cook, still grounded in the excerpts.
56
+ _SYSTEM_COOK = (
57
+ "You are Johann Reinhold Forster, the naturalist aboard HMS Resolution during "
58
+ "Captain Cook's second voyage and the author of this journal (Books 1-6, "
59
+ "transcribed from your own handwriting). Answer the reader's question in the "
60
+ "first person, as yourself, drawing ONLY on the provided excerpts from your own "
61
+ "journal as your memory of the voyage — do not break character and do not refer "
62
+ "to yourself as an AI or assistant. Write in a reflective, learned 18th-century "
63
+ "voice, but keep the language clear for a modern reader. If your journal "
64
+ "excerpts do not cover the question, say so honestly as yourself rather than "
65
+ "inventing facts. Write 2-4 sentences."
66
+ )
67
+
68
+
69
+ def _system_for(persona: str | None) -> str:
70
+ return _SYSTEM_COOK if (persona or "").lower() == "cook" else _SYSTEM
71
+
72
 
73
  def _build_prompt(question: str, contexts: list[tuple[int, str]]) -> str:
74
  blocks = "\n\n".join(f"[Page {pn}]\n{txt}" for pn, txt in contexts)
 
85
  top = contexts[0][1].replace("\n", " ").strip()
86
  pages = ", ".join(str(pn) for pn, _ in contexts)
87
  return (
88
+ f"Showing the most relevant transcribed passage from "
89
+ f"page{'s' if ',' in pages else ''} {pages}:\n\n“{top}”"
90
  )
91
 
92
 
93
+ def _dispatch(provider: str, model: str, system: str, prompt: str, key: str) -> str:
94
+ if provider == "gemini":
95
+ return _call_gemini(model, system, prompt, key)
96
+ if provider in ("groq", "openai"):
97
+ return _call_openai_compatible(provider, model, system, prompt, key)
98
+ if provider == "claude":
99
+ return _call_claude(model, system, prompt, key)
100
+ raise ValueError(f"unknown provider: {provider!r}")
101
+
102
+
103
  def generate_answer(
104
+ question: str, contexts: list[tuple[int, str]], cfg: RAGConfig = default_rag,
105
+ persona: str | None = None,
106
  ) -> str:
107
+ """contexts = [(page_number, text), ...] in relevance order.
 
 
 
108
 
109
+ Tries the provider chain (primary -> fallbacks from cfg.attempts()); on a
110
+ provider error/rate-limit it moves to the next, and if all fail returns the
111
+ extractive fallback. persona="cook" answers in Captain Cook's voice."""
112
+ system = _system_for(persona)
113
  prompt = _build_prompt(question, contexts)
114
+
115
+ seen: set[tuple[str, str]] = set()
116
+ tried_any = False
117
+ for provider, model in cfg.attempts():
118
+ if provider not in _DEFAULT_MODELS:
119
+ logger.debug("skip unknown provider %r", provider)
120
+ continue
121
+ model = model or _DEFAULT_MODELS[provider]
122
+ if (provider, model) in seen:
123
+ continue
124
+ seen.add((provider, model))
125
+ key = cfg.key_for(provider)
126
+ if not key:
127
+ logger.debug("skip %s:%s (no API key configured)", provider, model)
128
+ continue
129
+ tried_any = True
130
+ logger.info("RAG answering via %s:%s%s", provider, model,
131
+ f" (persona={persona})" if persona else "")
132
+ try:
133
+ answer = _dispatch(provider, model, system, prompt, key)
134
+ logger.info("RAG answer OK via %s:%s (%d chars)", provider, model, len(answer))
135
+ return answer
136
+ except Exception as exc: # rate limit / network / quota -> try next provider
137
+ # Redacted: never let the API key (in ?key= / bearer) reach the logs.
138
+ logger.warning("RAG provider %s:%s failed, falling back: %s",
139
+ provider, model, _redact(exc))
140
+
141
+ # Client never sees provider errors/keys — only the clean extractive passage.
142
+ if tried_any:
143
+ logger.warning("RAG all providers failed; using extractive fallback")
144
+ else:
145
+ logger.info("RAG no LLM configured; using extractive fallback")
146
  return _extractive_fallback(question, contexts)
147
 
148
 
149
  # --------------------------------------------------------------------------- #
150
  # provider calls
151
  # --------------------------------------------------------------------------- #
152
+ def _call_gemini(model: str, system: str, prompt: str, api_key: str) -> str:
153
  url = (
154
  f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
155
  f"?key={api_key}"
156
  )
157
  body = {
158
+ "system_instruction": {"parts": [{"text": system}]},
159
  "contents": [{"role": "user", "parts": [{"text": prompt}]}],
160
  }
161
  r = _post_retry(url, json=body, timeout=60)
 
163
  return r.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
164
 
165
 
166
+ def _call_openai_compatible(provider: str, model: str, system: str, prompt: str, api_key: str) -> str:
167
  base = "https://api.groq.com/openai/v1" if provider == "groq" else "https://api.openai.com/v1"
168
  r = _post_retry(
169
  f"{base}/chat/completions",
 
171
  json={
172
  "model": model,
173
  "messages": [
174
+ {"role": "system", "content": system},
175
  {"role": "user", "content": prompt},
176
  ],
177
  "temperature": 0.2,
 
182
  return r.json()["choices"][0]["message"]["content"].strip()
183
 
184
 
185
+ def _call_claude(model: str, system: str, prompt: str, api_key: str) -> str:
186
  import requests
187
 
188
  r = requests.post(
 
195
  json={
196
  "model": model,
197
  "max_tokens": 600,
198
+ "system": system,
199
  "messages": [{"role": "user", "content": prompt}],
200
  },
201
  timeout=60,