Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitattributes +0 -36
- Dockerfile +8 -4
- README.md +55 -22
- app/frontend/app.js +11 -6
- app/frontend/index.html +5 -1
- app/frontend/styles.css +14 -2
- app/src/inkference/api/main.py +34 -1
- app/src/inkference/api/services.py +13 -2
- app/src/inkference/config.py +28 -5
- app/src/inkference/rag/answer.py +8 -2
- app/src/inkference/rag/llm.py +85 -23
.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
|
| 45 |
-
LLM_PROVIDER=
|
| 46 |
-
LLM_MODEL=
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
| 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 →
|
| 16 |
-
Archive" answers over the corrected text with
|
|
|
|
| 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 →
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
## Deploy steps
|
| 29 |
|
| 30 |
1. **Log in to Hugging Face**: `hf auth login` (token from
|
| 31 |
-
https://huggingface.co/settings/tokens)
|
| 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**
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
4. **Populate the Space repo** with ONLY what the image needs (
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
```bash
|
| 45 |
-
|
| 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 |
-
- `
|
| 52 |
-
- `
|
| 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 |
-
| `
|
| 69 |
-
| `
|
| 70 |
-
| `
|
|
|
|
|
|
|
| 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
|
| 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">
|
| 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 |
-
|
| 181 |
-
$("#ask-
|
|
|
|
|
|
|
| 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-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 61 |
-
f"
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 74 |
prompt = _build_prompt(question, contexts)
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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":
|
| 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":
|
| 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,
|