BonusLockSMith commited on
Commit
383dc89
·
verified ·
1 Parent(s): ac0c619

security: log only exception type (never secrets); generic client errors

Browse files
Files changed (1) hide show
  1. app.py +542 -542
app.py CHANGED
@@ -1,542 +1,542 @@
1
- #!/usr/bin/env python
2
- """Chat with your PDF — upload a PDF and ask questions; answers are grounded in the
3
- document with page citations (real RAG: chunk -> embed -> retrieve -> answer).
4
-
5
- Embeddings: fastembed (ONNX, CPU-friendly, no torch). LLM: Anthropic Claude API if
6
- ANTHROPIC_API_KEY is set, else a local Ollama model.
7
-
8
- pip install flask anthropic pypdf fastembed numpy
9
- python app.py # http://127.0.0.1:8500
10
-
11
- Project #3 of the "30 Projects in 15 Days" challenge — GritAI.
12
- """
13
- import os, io, json, re, uuid
14
- import numpy as np
15
- from flask import Flask, request, Response, jsonify
16
-
17
- HERE = os.path.dirname(os.path.abspath(__file__))
18
- PORT = int(os.environ.get("PORT", "8500"))
19
- ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY")
20
- ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-5")
21
- OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
22
- OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b")
23
-
24
- MAX_PAGES = 120
25
- CHUNK_CHARS = 1200
26
- CHUNK_OVERLAP = 160
27
- TOP_K = 6
28
- MAX_DOCS = 24
29
-
30
- SAMPLE_DIR = os.path.join(HERE, "sample_pdfs")
31
- SAMPLES = {
32
- "memo": ("simple_memo.pdf", "📋", "Office memo", "1-page internal memo"),
33
- "manual": ("product_manual.pdf", "📘", "Product manual", "Smart thermostat guide"),
34
- "report": ("research_report.pdf", "📊", "Market report", "Data, tables, findings"),
35
- "handbook": ("employee_handbook.pdf", "📗", "Employee handbook", "13 pages, many sections"),
36
- }
37
-
38
- # ---- embedding model (lazy singleton) ----------------------------------------
39
- _EMB = None
40
- def emb_model():
41
- global _EMB
42
- if _EMB is None:
43
- from fastembed import TextEmbedding
44
- _EMB = TextEmbedding("BAAI/bge-small-en-v1.5")
45
- return _EMB
46
-
47
- def embed_passages(texts):
48
- return np.array(list(emb_model().passage_embed(texts)), dtype=np.float32)
49
-
50
- def embed_query(text):
51
- return np.array(list(emb_model().query_embed([text]))[0], dtype=np.float32)
52
-
53
- def _norm(m):
54
- n = np.linalg.norm(m, axis=-1, keepdims=True)
55
- return m / np.clip(n, 1e-8, None)
56
-
57
- # ---- pdf -> chunks -----------------------------------------------------------
58
- def extract_pages(data):
59
- from pypdf import PdfReader
60
- reader = PdfReader(io.BytesIO(data))
61
- title = (reader.metadata.title if reader.metadata and reader.metadata.title else "").strip()
62
- pages = []
63
- for i, pg in enumerate(reader.pages[:MAX_PAGES], start=1):
64
- txt = (pg.extract_text() or "").strip()
65
- if txt:
66
- pages.append((i, re.sub(r"[ \t]+", " ", txt)))
67
- return title, len(reader.pages), pages
68
-
69
- def chunk_pages(pages):
70
- chunks = []
71
- for pageno, text in pages:
72
- i = 0
73
- while i < len(text):
74
- piece = text[i:i + CHUNK_CHARS].strip()
75
- if len(piece) > 40:
76
- chunks.append({"page": pageno, "text": piece})
77
- i += CHUNK_CHARS - CHUNK_OVERLAP
78
- return chunks
79
-
80
- DOCS = {}
81
- DOC_ORDER = []
82
-
83
- def index_pdf(data, filename):
84
- title, npages, pages = extract_pages(data)
85
- if not pages:
86
- raise ValueError("No extractable text found — this PDF may be scanned images (needs OCR).")
87
- chunks = chunk_pages(pages)
88
- emb = _norm(embed_passages([c["text"] for c in chunks]))
89
- doc_id = uuid.uuid4().hex[:12]
90
- disp = re.sub(r"\.(html?|pdf)$", "", (title or ""), flags=re.I).strip()
91
- if not disp:
92
- disp = re.sub(r"\.pdf$", "", filename or "document", flags=re.I)
93
- DOCS[doc_id] = {"title": disp, "pages": npages, "chunks": chunks, "emb": emb}
94
- DOC_ORDER.append(doc_id)
95
- while len(DOC_ORDER) > MAX_DOCS:
96
- DOCS.pop(DOC_ORDER.pop(0), None)
97
- return doc_id, DOCS[doc_id]
98
-
99
- def retrieve(doc, question, k=TOP_K):
100
- q = _norm(embed_query(question).reshape(1, -1))[0]
101
- sims = doc["emb"] @ q
102
- idx = np.argsort(-sims)[:k]
103
- return [doc["chunks"][i] for i in idx]
104
-
105
- # ---- answer (streaming) ------------------------------------------------------
106
- def system_prompt():
107
- return (
108
- "You answer questions about a specific document using ONLY the excerpts provided.\n"
109
- "- Cite the page number(s) you used, in parentheses like (p. 4).\n"
110
- "- If the answer is not in the excerpts, say you couldn't find it in the document — do not guess.\n"
111
- "- Be concise and faithful. Write plain text; no markdown symbols like ** or #."
112
- )
113
-
114
- def build_context(chunks):
115
- return "\n\n".join(f"[p. {c['page']}] {c['text']}" for c in chunks)
116
-
117
- def answer_from(chunks, question):
118
- ctx = build_context(chunks)
119
- user = f"Document excerpts:\n\n{ctx}\n\n---\nQuestion: {question}"
120
- if ANTHROPIC_KEY:
121
- import anthropic
122
- client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
123
- with client.messages.stream(model=ANTHROPIC_MODEL, max_tokens=700,
124
- system=system_prompt(),
125
- messages=[{"role": "user", "content": user}]) as s:
126
- for t in s.text_stream:
127
- yield t
128
- return
129
- import requests
130
- with requests.post(f"{OLLAMA_URL}/api/chat", stream=True, timeout=120, json={
131
- "model": OLLAMA_MODEL, "stream": True,
132
- "messages": [{"role": "system", "content": system_prompt()},
133
- {"role": "user", "content": user}],
134
- "options": {"temperature": 0.2}}) as r:
135
- r.raise_for_status()
136
- for line in r.iter_lines():
137
- if not line:
138
- continue
139
- d = json.loads(line)
140
- if d.get("message", {}).get("content"):
141
- yield d["message"]["content"]
142
- if d.get("done"):
143
- break
144
-
145
- app = Flask(__name__)
146
- app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024
147
-
148
- @app.route("/")
149
- def home():
150
- return Response(PAGE, mimetype="text/html")
151
-
152
- @app.route("/api/upload", methods=["POST"])
153
- def api_upload():
154
- f = request.files.get("pdf")
155
- if not f:
156
- return jsonify(error="No file uploaded."), 400
157
- try:
158
- doc_id, doc = index_pdf(f.read(), f.filename or "document.pdf")
159
- return jsonify(doc_id=doc_id, title=doc["title"], pages=doc["pages"], chunks=len(doc["chunks"]))
160
- except Exception as e:
161
- return jsonify(error=str(e)[:200]), 400
162
-
163
- @app.route("/api/sample", methods=["POST"])
164
- def api_sample():
165
- name = request.get_json(force=True).get("name")
166
- meta = SAMPLES.get(name)
167
- if not meta:
168
- return jsonify(error="unknown sample"), 400
169
- path = os.path.join(SAMPLE_DIR, meta[0])
170
- if not os.path.exists(path):
171
- return jsonify(error="sample not available"), 404
172
- try:
173
- with open(path, "rb") as fh:
174
- doc_id, doc = index_pdf(fh.read(), meta[0])
175
- return jsonify(doc_id=doc_id, title=doc["title"], pages=doc["pages"], chunks=len(doc["chunks"]))
176
- except Exception as e:
177
- return jsonify(error=str(e)[:200]), 400
178
-
179
- @app.route("/api/ask", methods=["POST"])
180
- def api_ask():
181
- b = request.get_json(force=True)
182
- doc = DOCS.get(b.get("doc_id", ""))
183
- question = (b.get("question") or "").strip()
184
- def gen():
185
- import sys as _sys
186
- if not doc:
187
- yield "data: " + json.dumps({"t": "That document isn't loaded anymore — please re-upload it."}) + "\n\n"
188
- elif not question:
189
- yield "data: " + json.dumps({"t": "Please ask a question."}) + "\n\n"
190
- else:
191
- try:
192
- chunks = retrieve(doc, question)
193
- src = {}
194
- for c in chunks:
195
- src.setdefault(str(c["page"]), c["text"][:420])
196
- yield "data: " + json.dumps({"sources": src}) + "\n\n"
197
- for chunk in answer_from(chunks, question):
198
- yield "data: " + json.dumps({"t": chunk}) + "\n\n"
199
- except Exception as e:
200
- print("ask error:", repr(e), file=_sys.stderr, flush=True)
201
- yield "data: " + json.dumps({"t": "Sorry — I hit an error answering. Please try again."}) + "\n\n"
202
- yield "data: [DONE]\n\n"
203
- return Response(gen(), mimetype="text/event-stream",
204
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
205
-
206
- PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
207
- <title>Chat with your PDF</title>
208
- <meta name="viewport" content="width=device-width,initial-scale=1">
209
- <link rel="preconnect" href="https://fonts.googleapis.com">
210
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
211
- <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800;900&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
212
- <style>
213
- /* ============================================================================
214
- GRITAI DESIGN SYSTEM — "Engineered / Industrial"
215
- Dark · grit-orange · Archivo + IBM Plex · blueprint grid + grain.
216
- Canonical source of truth for every GritAI app (30-in-15 #1-30) + the hub.
217
- Rob-approved 2026-09-05 (#9 pilot: design-critic 5.71 -> 7.57).
218
-
219
- HOW TO USE: embed this <style> verbatim in an app's page, add the Google Fonts
220
- <link> below, then build the body from the component classes here. Keep the
221
- header (monogram + app name + tags), grit-orange as the ONLY accent, dark bg.
222
-
223
- FONTS (add to <head>):
224
- <link rel="preconnect" href="https://fonts.googleapis.com">
225
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
226
- <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800;900&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
227
- ============================================================================ */
228
- :root{
229
- --bg:#0a0b0e; --bg2:#0e1015; --panel:#111318; --panel2:#15181f;
230
- --ink:#eef0f4; --dim:#9aa1b0; --dim2:#5f6675; --line:#20242e; --line2:#2b303c;
231
- --grit:#ff5a2a; --grit2:#ff7d54; --grit-dim:#c9451f; --ok:#5fd08a; --bad:#ff6b6b;
232
- --disp:'Archivo',system-ui,sans-serif; --body:'IBM Plex Sans',system-ui,sans-serif;
233
- --mono:'IBM Plex Mono',ui-monospace,monospace;
234
- }
235
- *{box-sizing:border-box}
236
- html,body{height:100%}
237
- body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--body);font-size:15px;line-height:1.6;
238
- -webkit-font-smoothing:antialiased;position:relative;overflow-x:hidden}
239
- /* blueprint grid + vignette */
240
- body::before{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
241
- background:linear-gradient(0deg,transparent 95%,rgba(255,90,42,.04) 100%),
242
- repeating-linear-gradient(90deg,transparent 0 63px,rgba(255,255,255,.018) 63px 64px),
243
- repeating-linear-gradient(0deg,transparent 0 63px,rgba(255,255,255,.018) 63px 64px),
244
- radial-gradient(120% 80% at 50% -10%,#141821 0%,var(--bg) 55%)}
245
- /* film grain */
246
- body::after{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.05;mix-blend-mode:overlay;
247
- background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
248
- .wrap{position:relative;z-index:1;max-width:960px;margin:0 auto;padding:0 28px 70px}
249
-
250
- /* ---- header band (monogram SVG + wordmark + tags) ---- */
251
- header{display:flex;align-items:center;gap:16px;padding:22px 0 18px;border-bottom:1px solid var(--line);animation:fade .6s .02s both}
252
- .mark{width:38px;height:38px;flex:none}.mark svg{display:block}
253
- .brand{display:flex;flex-direction:column;line-height:1}
254
- .brand .co{font-family:var(--disp);font-weight:900;font-size:19px;letter-spacing:.5px}.brand .co b{color:var(--grit)}
255
- .brand .sub{font-family:var(--mono);font-size:10.5px;letter-spacing:.28em;text-transform:uppercase;color:var(--dim2);margin-top:5px}
256
- .tags{margin-left:auto;display:flex;gap:8px}
257
- .tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);border:1px solid var(--line2);border-radius:2px;padding:5px 9px}
258
- .tag.hot{color:var(--grit);border-color:var(--grit-dim)}
259
- /* canonical monogram SVG (paste in .mark):
260
- <svg width="38" height="38" viewBox="0 0 38 38" fill="none"><rect x="1" y="1" width="36" height="36" rx="2" stroke="#2b303c"/><path d="M27 12.5A9 9 0 1 0 28 22H19" stroke="#ff5a2a" stroke-width="2.4" stroke-linecap="square"/><rect x="10.5" y="10.5" width="3" height="3" fill="#ff5a2a"/></svg> */
261
-
262
- /* ---- hero ---- */
263
- .hero{padding:34px 0 26px;animation:fade .6s .12s both}
264
- .hero h1{font-family:var(--disp);font-weight:800;font-size:clamp(30px,5.4vw,52px);line-height:1.02;letter-spacing:-.02em;margin:0;max-width:16ch}
265
- .hero h1 em{font-style:normal;color:var(--grit)}
266
- .hero p{color:var(--dim);max-width:56ch;margin:16px 0 0;font-size:15.5px}
267
-
268
- /* ---- section label (01 · NAME + rule) ---- */
269
- .sec{display:flex;align-items:center;gap:14px;margin:30px 0 14px}
270
- .sec .num{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--grit);letter-spacing:.1em}
271
- .sec .name{font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.26em;text-transform:uppercase;color:var(--dim)}
272
- .sec .rule{flex:1;height:1px;background:linear-gradient(90deg,var(--line2),transparent)}
273
-
274
- /* ---- panels ---- */
275
- .panel{border:1px solid var(--line);border-radius:3px;background:linear-gradient(180deg,var(--panel),var(--bg2))}
276
- .card{border:1px solid var(--line2);border-radius:3px;background:var(--panel);padding:18px 20px}
277
- .card.accent{border-left:2px solid var(--grit)}
278
-
279
- /* ---- input field (terminal) ---- */
280
- .field{display:flex;align-items:stretch;border:1px solid var(--line2);border-radius:3px;background:var(--panel);overflow:hidden;transition:border-color .18s}
281
- .field:focus-within{border-color:var(--grit-dim)}
282
- .field .car{font-family:var(--mono);color:var(--grit);padding:16px 0 16px 16px;font-size:15px;user-select:none}
283
- .field input,.field textarea{flex:1;background:transparent;border:0;outline:none;color:var(--ink);font-family:var(--body);font-size:15.5px;padding:16px 14px;resize:vertical}
284
- .field input::placeholder,.field textarea::placeholder{color:var(--dim2)}
285
- textarea.ta{width:100%;min-height:140px;border:1px solid var(--line2);border-radius:3px;background:var(--panel);color:var(--ink);font-family:var(--body);font-size:15px;padding:14px 16px;outline:none;resize:vertical}
286
- textarea.ta:focus{border-color:var(--grit-dim)}
287
-
288
- /* ---- buttons ---- */
289
- .run{border:0;background:var(--grit);color:#140a06;font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.14em;text-transform:uppercase;padding:0 24px;cursor:pointer;transition:.16s;box-shadow:-1px 0 0 var(--grit-dim)}
290
- .run:hover{background:var(--grit2);box-shadow:0 0 26px -4px rgba(255,90,42,.55)}
291
- .run:disabled{opacity:.55;cursor:default;box-shadow:none}
292
- .btn{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:3px;background:var(--grit);color:#140a06;font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.1em;text-transform:uppercase;padding:12px 22px;cursor:pointer;transition:.16s}
293
- .btn:hover{background:var(--grit2);box-shadow:0 0 26px -4px rgba(255,90,42,.55)}
294
- .btn.ghost{background:transparent;color:var(--dim);border:1px solid var(--line2)}
295
- .btn.ghost:hover{color:var(--grit);border-color:var(--grit-dim);background:transparent;box-shadow:none}
296
-
297
- /* ---- chips / controls ---- */
298
- .chip{font-family:var(--mono);font-size:12px;color:var(--dim);background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:8px 11px;cursor:pointer;transition:.15s}
299
- .chip:hover{color:var(--grit);border-color:var(--grit-dim)}
300
- .chip .pt{color:var(--dim2);margin-right:7px}
301
- select,.select{padding:10px 12px;border:1px solid var(--line2);border-radius:3px;background:var(--panel);color:var(--ink);font-family:var(--mono);font-size:13px;outline:none;cursor:pointer}
302
- select:focus{border-color:var(--grit-dim)}
303
- label.lbl{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.24em;color:var(--dim2);display:block;margin-bottom:6px}
304
-
305
- /* ---- mono log / trace rows ---- */
306
- .log{font-family:var(--mono);font-size:13px;line-height:1.75}
307
- .row{display:grid;grid-template-columns:22px auto 1fr;gap:0 12px;padding:13px 18px;border-bottom:1px solid var(--line)}
308
- .row:last-child{border-bottom:0}
309
- .row .mk{color:var(--grit);font-weight:600}
310
- .row .tn{color:var(--grit2);text-transform:uppercase;letter-spacing:.06em;font-weight:500}
311
- .row .rd{color:var(--ink);word-break:break-word}.row .rd .arg{color:var(--dim2)}
312
-
313
- /* ---- tables ---- */
314
- table{width:100%;border-collapse:collapse;font-size:13.5px}
315
- th{text-align:left;font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim2);font-weight:500;padding:11px 14px;border-bottom:1px solid var(--line2);background:var(--bg2)}
316
- td{padding:11px 14px;border-bottom:1px solid var(--line);vertical-align:top}
317
- tr:last-child td{border-bottom:0}
318
- .tablewrap{border:1px solid var(--line);border-radius:3px;overflow:hidden;overflow-x:auto}
319
-
320
- /* ---- chat bubbles ---- */
321
- .msg{max-width:78%;padding:12px 15px;border-radius:3px;font-size:15px;line-height:1.55;margin-bottom:12px}
322
- .msg.user{margin-left:auto;background:var(--panel2);border:1px solid var(--line2)}
323
- .msg.bot{background:var(--panel);border:1px solid var(--line);border-left:2px solid var(--grit)}
324
-
325
- /* ---- badges / pills ---- */
326
- .badge{display:inline-block;font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.06em;padding:3px 9px;border-radius:2px;border:1px solid var(--line2);color:var(--dim)}
327
- .badge.on{color:var(--grit);border-color:var(--grit-dim)}
328
- .hl{color:var(--grit);font-weight:600;font-family:var(--mono)}
329
-
330
- /* ---- answer/output prose ---- */
331
- .prose{font-size:16px;line-height:1.65}.prose p{margin:0 0 12px}.prose p:last-child{margin-bottom:0}.prose b{color:var(--ink)}
332
-
333
- /* ---- spinner ---- */
334
- .spin{display:inline-flex;gap:6px;padding:16px 18px}.spin span{width:8px;height:8px;border-radius:50%;background:var(--grit);opacity:.5;animation:bd 1.2s infinite}
335
- .spin span:nth-child(2){animation-delay:.18s}.spin span:nth-child(3){animation-delay:.36s}
336
-
337
- /* ---- footer ---- */
338
- footer{margin-top:34px;padding-top:16px;border-top:1px solid var(--line);font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--dim2);display:flex;gap:16px;flex-wrap:wrap;animation:fade .6s .5s both}
339
- footer .b{color:var(--dim)}footer .sp{margin-left:auto}
340
- footer a{color:var(--dim);text-decoration:none}footer a:hover{color:var(--grit)}
341
-
342
- /* ---- reveal animations ---- */
343
- @keyframes fade{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
344
- @keyframes slidein{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
345
- @keyframes bd{0%,60%,100%{transform:translateY(0);opacity:.35}30%{transform:translateY(-6px);opacity:1}}
346
- .rvl{animation:fade .6s both}
347
-
348
-
349
- /* ---- app: Chat with your PDF — layout glue on the GritAI design system ---- */
350
- .wrap{max-width:820px}
351
- #uploadView{display:flex;flex-direction:column}
352
- #chatView{display:none;flex-direction:column}
353
-
354
- /* upload / drop zone */
355
- #drop{border:1px dashed var(--line2);text-align:center;padding:42px 24px;cursor:pointer;transition:border-color .18s,background .18s}
356
- #drop:hover,#drop.drag{border-color:var(--grit-dim);background:linear-gradient(180deg,var(--panel2),var(--bg2))}
357
- #drop .ic{font-family:var(--disp);font-weight:800;font-size:40px;line-height:1;color:var(--grit)}
358
- #drop .t{font-family:var(--disp);font-weight:700;font-size:17px;color:var(--ink);margin:12px 0 4px}
359
- #drop .h{font-family:var(--mono);font-size:12px;color:var(--dim2);margin-bottom:16px}
360
- #drop .btn{margin-top:2px}
361
- #drop .spin{justify-content:center}
362
-
363
- /* feature badges */
364
- .feats{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:16px}
365
-
366
- /* samples */
367
- .samples{margin-top:6px}
368
- .sgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
369
- .samp{display:flex;align-items:center;gap:12px;text-align:left;border:1px solid var(--line2);border-radius:3px;background:var(--panel);padding:13px 14px;cursor:pointer;transition:border-color .15s,transform .15s}
370
- .samp:hover{border-color:var(--grit-dim);transform:translateY(-1px)}
371
- .samp .e{font-family:var(--mono);font-size:12px;font-weight:600;color:var(--grit);border:1px solid var(--line2);border-radius:2px;padding:6px 9px;flex:none}
372
- .samp .tt{font-family:var(--disp);font-weight:700;font-size:14px;color:var(--ink)}
373
- .samp .hh{font-family:var(--mono);font-size:11px;color:var(--dim2);margin-top:3px}
374
-
375
- /* doc bar */
376
- .docbar{display:flex;align-items:center;gap:12px;border:1px solid var(--line);border-radius:3px;background:var(--panel);padding:12px 14px;margin-bottom:14px}
377
- .docbar .dt{font-family:var(--disp);font-weight:700;font-size:14px;color:var(--ink)}
378
- .docbar .dm{font-family:var(--mono);font-size:11px;color:var(--dim2);margin-top:2px}
379
- .docbar .btn{margin-left:auto}
380
-
381
- /* chat log */
382
- #log{display:flex;flex-direction:column;gap:14px;min-height:260px;max-height:56vh;overflow-y:auto;padding:4px 4px 4px 0}
383
- #log::-webkit-scrollbar{width:8px}
384
- #log::-webkit-scrollbar-thumb{background:var(--line2);border-radius:8px}
385
- #log .row{display:flex;gap:10px;align-items:flex-start;animation:fade .3s both}
386
- #log .row.user{flex-direction:row-reverse}
387
- #log .av{width:30px;height:30px;flex:none;display:grid;place-items:center;font-family:var(--mono);font-size:10px;font-weight:600;color:var(--grit);border:1px solid var(--line2);border-radius:2px;background:var(--panel)}
388
- #log .msg{margin:0;max-width:82%;white-space:pre-wrap}
389
- .typing{display:inline-flex;gap:5px}
390
- .typing span{width:7px;height:7px;border-radius:50%;background:var(--grit);opacity:.5;animation:bd 1.2s infinite}
391
- .typing span:nth-child(2){animation-delay:.18s}
392
- .typing span:nth-child(3){animation-delay:.36s}
393
-
394
- /* page citations (.badge) + source panel */
395
- .cite{cursor:pointer;transition:.14s;margin:0 1px;white-space:nowrap}
396
- .cite:hover{color:var(--grit);border-color:var(--grit-dim)}
397
- .cite.act{color:var(--grit);border-color:var(--grit);background:rgba(255,90,42,.12)}
398
- .srcpanel{margin-top:10px;border:1px solid var(--line);border-left:2px solid var(--grit);border-radius:3px;background:var(--bg2);padding:10px 13px}
399
- .srcpanel .ps{font-family:var(--mono);font-size:12px;line-height:1.6;color:var(--dim);margin:8px 0}
400
- .srcpanel .ps:first-child{margin-top:0}
401
- .srcpanel .ps:last-child{margin-bottom:0}
402
- .srcpanel .ps b{color:var(--grit);font-weight:600}
403
-
404
- /* starter chips */
405
- .starters{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0}
406
- .starter{font-family:var(--mono);font-size:12px;color:var(--dim);background:var(--panel);border:1px solid var(--line2);border-radius:2px;padding:8px 11px;cursor:pointer;transition:.15s}
407
- .starter:hover{color:var(--grit);border-color:var(--grit-dim)}
408
-
409
- /* ask bar */
410
- .askbar{display:flex;gap:10px;align-items:stretch}
411
- .askbar .field{flex:1}
412
- </style></head>
413
- <body>
414
- <div class="wrap">
415
- <header>
416
- <div class="mark"><svg width="38" height="38" viewBox="0 0 38 38" fill="none"><rect x="1" y="1" width="36" height="36" rx="2" stroke="#2b303c"/><path d="M27 12.5A9 9 0 1 0 28 22H19" stroke="#ff5a2a" stroke-width="2.4" stroke-linecap="square"/><rect x="10.5" y="10.5" width="3" height="3" fill="#ff5a2a"/></svg></div>
417
- <div class="brand"><span class="co">GRIT<b>AI</b></span><span class="sub">Chat with your PDF</span></div>
418
- <div class="tags"><span class="tag hot">&#9656; Live</span><span class="tag">SDVOSB</span><span class="tag">Lawton OK</span></div>
419
- </header>
420
-
421
- <section class="hero">
422
- <h1>Ask your documents <em>anything</em></h1>
423
- <p>Upload a PDF and get answers grounded in its contents &mdash; every answer cites the exact page it came from, and it won&rsquo;t guess.</p>
424
- </section>
425
-
426
- <div id="uploadView">
427
- <div class="sec"><span class="num">01</span><span class="name">Upload</span><span class="rule"></span></div>
428
- <div id="drop" class="panel">
429
- <div class="ic">+</div>
430
- <div class="t">Drop a PDF here or click to upload</div>
431
- <div class="h">Up to 20 MB &middot; text-based PDFs</div>
432
- <button class="btn" type="button">Choose PDF</button>
433
- <input type="file" id="file" accept="application/pdf" style="display:none">
434
- </div>
435
- <div class="feats">
436
- <span class="badge on">Instant answers</span>
437
- <span class="badge">Cites the page</span>
438
- <span class="badge">Won&rsquo;t guess</span>
439
- </div>
440
- <div class="samples">
441
- <div class="sec"><span class="num">02</span><span class="name">Or try a sample</span><span class="rule"></span></div>
442
- <div class="sgrid" id="sgrid"></div>
443
- </div>
444
- </div>
445
-
446
- <div id="chatView">
447
- <div class="sec"><span class="num">01</span><span class="name">Ask</span><span class="rule"></span></div>
448
- <div class="docbar">
449
- <span class="badge on">PDF</span>
450
- <div><div class="dt" id="dt">document</div><div class="dm" id="dm"></div></div>
451
- <button class="btn ghost" onclick="reset()">New PDF</button>
452
- </div>
453
- <div id="log"></div>
454
- <div class="starters" id="starters"></div>
455
- <form id="form" class="askbar" onsubmit="ask();return false;">
456
- <div class="field"><span class="car">&rsaquo;</span><input id="q" placeholder="Ask about the document&hellip;" autocomplete="off"></div>
457
- <button class="run" id="btn" aria-label="Send">Ask</button>
458
- </form>
459
- </div>
460
-
461
- <footer>
462
- <span class="b">Answers grounded in your uploaded PDF</span>
463
- <span class="sp">GRITAI SOLUTIONS &middot; 30-in-15 &#8470;3</span>
464
- </footer>
465
- </div>
466
- <script>
467
- const SAMPLES=[["memo","01","Office memo","1-page internal memo"],
468
- ["manual","02","Product manual","Smart thermostat guide"],
469
- ["report","03","Market report","Data, tables, findings"],
470
- ["handbook","04","Employee handbook","13 pages, many sections"]];
471
- const STARTERS=["Summarize this document","What are the key points?","What should I know first?"];
472
- const $=id=>document.getElementById(id);
473
- const drop=$('drop'),file=$('file'),log=$('log'),form=$('form'),q=$('q'),btn=$('btn'),
474
- uploadView=$('uploadView'),chatView=$('chatView'),dt=$('dt'),dm=$('dm');
475
- let docId=null,busy=false;
476
- // render sample buttons
477
- $('sgrid').innerHTML=SAMPLES.map(s=>`<div class="samp" onclick="loadSample('${s[0]}')"><span class="e">${s[1]}</span><div><div class="tt">${s[2]}</div><div class="hh">${s[3]}</div></div></div>`).join('');
478
- $('starters').innerHTML=STARTERS.map(s=>`<span class="starter" onclick="ask('${s.replace(/'/g,"\\\\'")}')">${s}</span>`).join('');
479
- drop.onclick=()=>file.click();
480
- file.onchange=e=>{if(e.target.files[0])upload(e.target.files[0]);};
481
- ['dragover','dragenter'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('drag');}));
482
- ['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('drag');}));
483
- drop.addEventListener('drop',e=>{const f=e.dataTransfer.files[0];if(f&&f.type==='application/pdf')upload(f);});
484
- function esc(s){return s.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
485
- const CITE=/\\(pp?\\.?\\s*\\d+(?:\\s*(?:,|;|&amp;|and)\\s*(?:pp?\\.?\\s*)?\\d+)*\\)/gi;
486
- function render(el,text,sources){el._src=sources||{};
487
- el.innerHTML=esc(text).replace(CITE,m=>{const pages=(m.match(/\\d+/g)||[]).join(',');
488
- return '<span class="cite badge" data-pages="'+pages+'">'+m+'</span>';})+'<div class="srcpanel" style="display:none"></div>';
489
- const panel=el.querySelector('.srcpanel');
490
- el.querySelectorAll('.cite').forEach(pill=>{pill.onclick=()=>{
491
- const pgs=(pill.dataset.pages||'').split(',').filter(Boolean);
492
- const html=pgs.filter(p=>el._src[p]).map(p=>'<div class="ps"><b>p. '+p+'</b> '+esc(el._src[p])+'…</div>').join('');
493
- if(!html)return;
494
- const same=panel.dataset.open===pill.dataset.pages&&panel.style.display==='block';
495
- el.querySelectorAll('.cite').forEach(c=>c.classList.remove('act'));
496
- if(same){panel.style.display='none';panel.dataset.open='';}
497
- else{panel.innerHTML=html;panel.style.display='block';panel.dataset.open=pill.dataset.pages;pill.classList.add('act');
498
- panel.scrollIntoView({block:'nearest',behavior:'smooth'});}
499
- };});}
500
- function add(t,who){const row=document.createElement('div');row.className='row '+who;
501
- if(who==='bot'){const a=document.createElement('div');a.className='av';a.textContent='AI';row.appendChild(a);}
502
- const d=document.createElement('div');d.className='msg '+who;d.textContent=t;row.appendChild(d);
503
- log.appendChild(row);log.scrollTop=log.scrollHeight;return d;}
504
- function showChat(d){docId=d.doc_id;dt.textContent=d.title;dm.textContent=d.pages+' pages · '+d.chunks+' chunks indexed';
505
- uploadView.style.display='none';chatView.style.display='flex';log.innerHTML='';
506
- add('Loaded "'+d.title+'". Ask me anything about it — I\\'ll cite the pages, and you can tap any (p. X) to see the exact source.','bot');q.focus();}
507
- function reset(){docId=null;chatView.style.display='none';uploadView.style.display='flex';file.value='';
508
- drop.innerHTML='<div class="ic">+</div><div class="t">Drop a PDF here or click to upload</div><div class="h">Up to 20 MB · text-based PDFs</div><button class="btn" type="button">Choose PDF</button>';drop.appendChild(file);}
509
- async function post(url,opts){const r=await fetch(url,opts);return r.json();}
510
- async function upload(f){
511
- drop.innerHTML='<div class="spin"><span></span><span></span><span></span></div><div class="t">Reading & indexing…</div><div class="h">'+esc(f.name)+'</div>';
512
- const fd=new FormData();fd.append('pdf',f);
513
- try{const d=await post('/api/upload',{method:'POST',body:fd});
514
- if(d.error){drop.innerHTML='<div class="ic">!</div><div class="t">Couldn\\'t read that PDF</div><div class="h">'+esc(d.error)+'</div>';drop.appendChild(file);return;}
515
- showChat(d);}catch(e){drop.innerHTML='<div class="ic">!</div><div class="t">Upload failed — click to retry</div>';drop.appendChild(file);}
516
- }
517
- async function loadSample(name){
518
- const box=[...document.querySelectorAll('.samp')].find(x=>x.getAttribute('onclick').includes("'"+name+"'"));
519
- if(box)box.style.opacity=.5;
520
- try{const d=await post('/api/sample',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
521
- if(d.error){alert(d.error);}else showChat(d);}catch(e){}
522
- if(box)box.style.opacity=1;
523
- }
524
- async function ask(preset){if(busy||!docId)return;const question=(preset||q.value).trim();if(!question)return;
525
- q.value='';busy=true;btn.disabled=true;add(question,'user');
526
- const b=add('','bot');b.innerHTML='<span class="typing"><span></span><span></span><span></span></span>';
527
- let full='',srcMap={};
528
- try{const r=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({doc_id:docId,question})});
529
- const rd=r.body.getReader(),dec=new TextDecoder();let buf='';
530
- while(true){const {value,done}=await rd.read();if(done)break;buf+=dec.decode(value,{stream:true});
531
- let i;while((i=buf.indexOf('\\n\\n'))>=0){const line=buf.slice(0,i).trim();buf=buf.slice(i+2);
532
- if(!line.startsWith('data:'))continue;const p=line.slice(5).trim();if(p==='[DONE]')continue;
533
- try{const o=JSON.parse(p);if(o.sources){srcMap=o.sources;}else if(o.t){full+=o.t;b.textContent=full;log.scrollTop=log.scrollHeight;}}catch(e){}}}
534
- if(full)render(b,full,srcMap);else b.textContent='No answer produced — try rephrasing.';
535
- }catch(e){b.textContent='Sorry — something went wrong.';}
536
- busy=false;btn.disabled=false;q.focus();}
537
- </script></body></html>"""
538
-
539
- if __name__ == "__main__":
540
- backend = "Claude API" if ANTHROPIC_KEY else f"Ollama ({OLLAMA_MODEL} @ {OLLAMA_URL})"
541
- print(f"Chat with your PDF on http://127.0.0.1:{PORT} [backend: {backend}]", flush=True)
542
- app.run(host="0.0.0.0", port=PORT, threaded=True)
 
1
+ #!/usr/bin/env python
2
+ """Chat with your PDF — upload a PDF and ask questions; answers are grounded in the
3
+ document with page citations (real RAG: chunk -> embed -> retrieve -> answer).
4
+
5
+ Embeddings: fastembed (ONNX, CPU-friendly, no torch). LLM: Anthropic Claude API if
6
+ ANTHROPIC_API_KEY is set, else a local Ollama model.
7
+
8
+ pip install flask anthropic pypdf fastembed numpy
9
+ python app.py # http://127.0.0.1:8500
10
+
11
+ Project #3 of the "30 Projects in 15 Days" challenge — GritAI.
12
+ """
13
+ import os, io, json, re, uuid
14
+ import numpy as np
15
+ from flask import Flask, request, Response, jsonify
16
+
17
+ HERE = os.path.dirname(os.path.abspath(__file__))
18
+ PORT = int(os.environ.get("PORT", "8500"))
19
+ ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY")
20
+ ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-5")
21
+ OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
22
+ OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b")
23
+
24
+ MAX_PAGES = 120
25
+ CHUNK_CHARS = 1200
26
+ CHUNK_OVERLAP = 160
27
+ TOP_K = 6
28
+ MAX_DOCS = 24
29
+
30
+ SAMPLE_DIR = os.path.join(HERE, "sample_pdfs")
31
+ SAMPLES = {
32
+ "memo": ("simple_memo.pdf", "📋", "Office memo", "1-page internal memo"),
33
+ "manual": ("product_manual.pdf", "📘", "Product manual", "Smart thermostat guide"),
34
+ "report": ("research_report.pdf", "📊", "Market report", "Data, tables, findings"),
35
+ "handbook": ("employee_handbook.pdf", "📗", "Employee handbook", "13 pages, many sections"),
36
+ }
37
+
38
+ # ---- embedding model (lazy singleton) ----------------------------------------
39
+ _EMB = None
40
+ def emb_model():
41
+ global _EMB
42
+ if _EMB is None:
43
+ from fastembed import TextEmbedding
44
+ _EMB = TextEmbedding("BAAI/bge-small-en-v1.5")
45
+ return _EMB
46
+
47
+ def embed_passages(texts):
48
+ return np.array(list(emb_model().passage_embed(texts)), dtype=np.float32)
49
+
50
+ def embed_query(text):
51
+ return np.array(list(emb_model().query_embed([text]))[0], dtype=np.float32)
52
+
53
+ def _norm(m):
54
+ n = np.linalg.norm(m, axis=-1, keepdims=True)
55
+ return m / np.clip(n, 1e-8, None)
56
+
57
+ # ---- pdf -> chunks -----------------------------------------------------------
58
+ def extract_pages(data):
59
+ from pypdf import PdfReader
60
+ reader = PdfReader(io.BytesIO(data))
61
+ title = (reader.metadata.title if reader.metadata and reader.metadata.title else "").strip()
62
+ pages = []
63
+ for i, pg in enumerate(reader.pages[:MAX_PAGES], start=1):
64
+ txt = (pg.extract_text() or "").strip()
65
+ if txt:
66
+ pages.append((i, re.sub(r"[ \t]+", " ", txt)))
67
+ return title, len(reader.pages), pages
68
+
69
+ def chunk_pages(pages):
70
+ chunks = []
71
+ for pageno, text in pages:
72
+ i = 0
73
+ while i < len(text):
74
+ piece = text[i:i + CHUNK_CHARS].strip()
75
+ if len(piece) > 40:
76
+ chunks.append({"page": pageno, "text": piece})
77
+ i += CHUNK_CHARS - CHUNK_OVERLAP
78
+ return chunks
79
+
80
+ DOCS = {}
81
+ DOC_ORDER = []
82
+
83
+ def index_pdf(data, filename):
84
+ title, npages, pages = extract_pages(data)
85
+ if not pages:
86
+ raise ValueError("No extractable text found — this PDF may be scanned images (needs OCR).")
87
+ chunks = chunk_pages(pages)
88
+ emb = _norm(embed_passages([c["text"] for c in chunks]))
89
+ doc_id = uuid.uuid4().hex[:12]
90
+ disp = re.sub(r"\.(html?|pdf)$", "", (title or ""), flags=re.I).strip()
91
+ if not disp:
92
+ disp = re.sub(r"\.pdf$", "", filename or "document", flags=re.I)
93
+ DOCS[doc_id] = {"title": disp, "pages": npages, "chunks": chunks, "emb": emb}
94
+ DOC_ORDER.append(doc_id)
95
+ while len(DOC_ORDER) > MAX_DOCS:
96
+ DOCS.pop(DOC_ORDER.pop(0), None)
97
+ return doc_id, DOCS[doc_id]
98
+
99
+ def retrieve(doc, question, k=TOP_K):
100
+ q = _norm(embed_query(question).reshape(1, -1))[0]
101
+ sims = doc["emb"] @ q
102
+ idx = np.argsort(-sims)[:k]
103
+ return [doc["chunks"][i] for i in idx]
104
+
105
+ # ---- answer (streaming) ------------------------------------------------------
106
+ def system_prompt():
107
+ return (
108
+ "You answer questions about a specific document using ONLY the excerpts provided.\n"
109
+ "- Cite the page number(s) you used, in parentheses like (p. 4).\n"
110
+ "- If the answer is not in the excerpts, say you couldn't find it in the document — do not guess.\n"
111
+ "- Be concise and faithful. Write plain text; no markdown symbols like ** or #."
112
+ )
113
+
114
+ def build_context(chunks):
115
+ return "\n\n".join(f"[p. {c['page']}] {c['text']}" for c in chunks)
116
+
117
+ def answer_from(chunks, question):
118
+ ctx = build_context(chunks)
119
+ user = f"Document excerpts:\n\n{ctx}\n\n---\nQuestion: {question}"
120
+ if ANTHROPIC_KEY:
121
+ import anthropic
122
+ client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
123
+ with client.messages.stream(model=ANTHROPIC_MODEL, max_tokens=700,
124
+ system=system_prompt(),
125
+ messages=[{"role": "user", "content": user}]) as s:
126
+ for t in s.text_stream:
127
+ yield t
128
+ return
129
+ import requests
130
+ with requests.post(f"{OLLAMA_URL}/api/chat", stream=True, timeout=120, json={
131
+ "model": OLLAMA_MODEL, "stream": True,
132
+ "messages": [{"role": "system", "content": system_prompt()},
133
+ {"role": "user", "content": user}],
134
+ "options": {"temperature": 0.2}}) as r:
135
+ r.raise_for_status()
136
+ for line in r.iter_lines():
137
+ if not line:
138
+ continue
139
+ d = json.loads(line)
140
+ if d.get("message", {}).get("content"):
141
+ yield d["message"]["content"]
142
+ if d.get("done"):
143
+ break
144
+
145
+ app = Flask(__name__)
146
+ app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024
147
+
148
+ @app.route("/")
149
+ def home():
150
+ return Response(PAGE, mimetype="text/html")
151
+
152
+ @app.route("/api/upload", methods=["POST"])
153
+ def api_upload():
154
+ f = request.files.get("pdf")
155
+ if not f:
156
+ return jsonify(error="No file uploaded."), 400
157
+ try:
158
+ doc_id, doc = index_pdf(f.read(), f.filename or "document.pdf")
159
+ return jsonify(doc_id=doc_id, title=doc["title"], pages=doc["pages"], chunks=len(doc["chunks"]))
160
+ except Exception as e:
161
+ return jsonify(error="Could not process that — please try again."), 400
162
+
163
+ @app.route("/api/sample", methods=["POST"])
164
+ def api_sample():
165
+ name = request.get_json(force=True).get("name")
166
+ meta = SAMPLES.get(name)
167
+ if not meta:
168
+ return jsonify(error="unknown sample"), 400
169
+ path = os.path.join(SAMPLE_DIR, meta[0])
170
+ if not os.path.exists(path):
171
+ return jsonify(error="sample not available"), 404
172
+ try:
173
+ with open(path, "rb") as fh:
174
+ doc_id, doc = index_pdf(fh.read(), meta[0])
175
+ return jsonify(doc_id=doc_id, title=doc["title"], pages=doc["pages"], chunks=len(doc["chunks"]))
176
+ except Exception as e:
177
+ return jsonify(error="Could not process that — please try again."), 400
178
+
179
+ @app.route("/api/ask", methods=["POST"])
180
+ def api_ask():
181
+ b = request.get_json(force=True)
182
+ doc = DOCS.get(b.get("doc_id", ""))
183
+ question = (b.get("question") or "").strip()
184
+ def gen():
185
+ import sys as _sys
186
+ if not doc:
187
+ yield "data: " + json.dumps({"t": "That document isn't loaded anymore — please re-upload it."}) + "\n\n"
188
+ elif not question:
189
+ yield "data: " + json.dumps({"t": "Please ask a question."}) + "\n\n"
190
+ else:
191
+ try:
192
+ chunks = retrieve(doc, question)
193
+ src = {}
194
+ for c in chunks:
195
+ src.setdefault(str(c["page"]), c["text"][:420])
196
+ yield "data: " + json.dumps({"sources": src}) + "\n\n"
197
+ for chunk in answer_from(chunks, question):
198
+ yield "data: " + json.dumps({"t": chunk}) + "\n\n"
199
+ except Exception as e:
200
+ print("ask error:", type(e).__name__, file=_sys.stderr, flush=True)
201
+ yield "data: " + json.dumps({"t": "Sorry — I hit an error answering. Please try again."}) + "\n\n"
202
+ yield "data: [DONE]\n\n"
203
+ return Response(gen(), mimetype="text/event-stream",
204
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
205
+
206
+ PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
207
+ <title>Chat with your PDF</title>
208
+ <meta name="viewport" content="width=device-width,initial-scale=1">
209
+ <link rel="preconnect" href="https://fonts.googleapis.com">
210
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
211
+ <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800;900&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
212
+ <style>
213
+ /* ============================================================================
214
+ GRITAI DESIGN SYSTEM — "Engineered / Industrial"
215
+ Dark · grit-orange · Archivo + IBM Plex · blueprint grid + grain.
216
+ Canonical source of truth for every GritAI app (30-in-15 #1-30) + the hub.
217
+ Rob-approved 2026-09-05 (#9 pilot: design-critic 5.71 -> 7.57).
218
+
219
+ HOW TO USE: embed this <style> verbatim in an app's page, add the Google Fonts
220
+ <link> below, then build the body from the component classes here. Keep the
221
+ header (monogram + app name + tags), grit-orange as the ONLY accent, dark bg.
222
+
223
+ FONTS (add to <head>):
224
+ <link rel="preconnect" href="https://fonts.googleapis.com">
225
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
226
+ <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800;900&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
227
+ ============================================================================ */
228
+ :root{
229
+ --bg:#0a0b0e; --bg2:#0e1015; --panel:#111318; --panel2:#15181f;
230
+ --ink:#eef0f4; --dim:#9aa1b0; --dim2:#5f6675; --line:#20242e; --line2:#2b303c;
231
+ --grit:#ff5a2a; --grit2:#ff7d54; --grit-dim:#c9451f; --ok:#5fd08a; --bad:#ff6b6b;
232
+ --disp:'Archivo',system-ui,sans-serif; --body:'IBM Plex Sans',system-ui,sans-serif;
233
+ --mono:'IBM Plex Mono',ui-monospace,monospace;
234
+ }
235
+ *{box-sizing:border-box}
236
+ html,body{height:100%}
237
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--body);font-size:15px;line-height:1.6;
238
+ -webkit-font-smoothing:antialiased;position:relative;overflow-x:hidden}
239
+ /* blueprint grid + vignette */
240
+ body::before{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
241
+ background:linear-gradient(0deg,transparent 95%,rgba(255,90,42,.04) 100%),
242
+ repeating-linear-gradient(90deg,transparent 0 63px,rgba(255,255,255,.018) 63px 64px),
243
+ repeating-linear-gradient(0deg,transparent 0 63px,rgba(255,255,255,.018) 63px 64px),
244
+ radial-gradient(120% 80% at 50% -10%,#141821 0%,var(--bg) 55%)}
245
+ /* film grain */
246
+ body::after{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.05;mix-blend-mode:overlay;
247
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
248
+ .wrap{position:relative;z-index:1;max-width:960px;margin:0 auto;padding:0 28px 70px}
249
+
250
+ /* ---- header band (monogram SVG + wordmark + tags) ---- */
251
+ header{display:flex;align-items:center;gap:16px;padding:22px 0 18px;border-bottom:1px solid var(--line);animation:fade .6s .02s both}
252
+ .mark{width:38px;height:38px;flex:none}.mark svg{display:block}
253
+ .brand{display:flex;flex-direction:column;line-height:1}
254
+ .brand .co{font-family:var(--disp);font-weight:900;font-size:19px;letter-spacing:.5px}.brand .co b{color:var(--grit)}
255
+ .brand .sub{font-family:var(--mono);font-size:10.5px;letter-spacing:.28em;text-transform:uppercase;color:var(--dim2);margin-top:5px}
256
+ .tags{margin-left:auto;display:flex;gap:8px}
257
+ .tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);border:1px solid var(--line2);border-radius:2px;padding:5px 9px}
258
+ .tag.hot{color:var(--grit);border-color:var(--grit-dim)}
259
+ /* canonical monogram SVG (paste in .mark):
260
+ <svg width="38" height="38" viewBox="0 0 38 38" fill="none"><rect x="1" y="1" width="36" height="36" rx="2" stroke="#2b303c"/><path d="M27 12.5A9 9 0 1 0 28 22H19" stroke="#ff5a2a" stroke-width="2.4" stroke-linecap="square"/><rect x="10.5" y="10.5" width="3" height="3" fill="#ff5a2a"/></svg> */
261
+
262
+ /* ---- hero ---- */
263
+ .hero{padding:34px 0 26px;animation:fade .6s .12s both}
264
+ .hero h1{font-family:var(--disp);font-weight:800;font-size:clamp(30px,5.4vw,52px);line-height:1.02;letter-spacing:-.02em;margin:0;max-width:16ch}
265
+ .hero h1 em{font-style:normal;color:var(--grit)}
266
+ .hero p{color:var(--dim);max-width:56ch;margin:16px 0 0;font-size:15.5px}
267
+
268
+ /* ---- section label (01 · NAME + rule) ---- */
269
+ .sec{display:flex;align-items:center;gap:14px;margin:30px 0 14px}
270
+ .sec .num{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--grit);letter-spacing:.1em}
271
+ .sec .name{font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.26em;text-transform:uppercase;color:var(--dim)}
272
+ .sec .rule{flex:1;height:1px;background:linear-gradient(90deg,var(--line2),transparent)}
273
+
274
+ /* ---- panels ---- */
275
+ .panel{border:1px solid var(--line);border-radius:3px;background:linear-gradient(180deg,var(--panel),var(--bg2))}
276
+ .card{border:1px solid var(--line2);border-radius:3px;background:var(--panel);padding:18px 20px}
277
+ .card.accent{border-left:2px solid var(--grit)}
278
+
279
+ /* ---- input field (terminal) ---- */
280
+ .field{display:flex;align-items:stretch;border:1px solid var(--line2);border-radius:3px;background:var(--panel);overflow:hidden;transition:border-color .18s}
281
+ .field:focus-within{border-color:var(--grit-dim)}
282
+ .field .car{font-family:var(--mono);color:var(--grit);padding:16px 0 16px 16px;font-size:15px;user-select:none}
283
+ .field input,.field textarea{flex:1;background:transparent;border:0;outline:none;color:var(--ink);font-family:var(--body);font-size:15.5px;padding:16px 14px;resize:vertical}
284
+ .field input::placeholder,.field textarea::placeholder{color:var(--dim2)}
285
+ textarea.ta{width:100%;min-height:140px;border:1px solid var(--line2);border-radius:3px;background:var(--panel);color:var(--ink);font-family:var(--body);font-size:15px;padding:14px 16px;outline:none;resize:vertical}
286
+ textarea.ta:focus{border-color:var(--grit-dim)}
287
+
288
+ /* ---- buttons ---- */
289
+ .run{border:0;background:var(--grit);color:#140a06;font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.14em;text-transform:uppercase;padding:0 24px;cursor:pointer;transition:.16s;box-shadow:-1px 0 0 var(--grit-dim)}
290
+ .run:hover{background:var(--grit2);box-shadow:0 0 26px -4px rgba(255,90,42,.55)}
291
+ .run:disabled{opacity:.55;cursor:default;box-shadow:none}
292
+ .btn{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:3px;background:var(--grit);color:#140a06;font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.1em;text-transform:uppercase;padding:12px 22px;cursor:pointer;transition:.16s}
293
+ .btn:hover{background:var(--grit2);box-shadow:0 0 26px -4px rgba(255,90,42,.55)}
294
+ .btn.ghost{background:transparent;color:var(--dim);border:1px solid var(--line2)}
295
+ .btn.ghost:hover{color:var(--grit);border-color:var(--grit-dim);background:transparent;box-shadow:none}
296
+
297
+ /* ---- chips / controls ---- */
298
+ .chip{font-family:var(--mono);font-size:12px;color:var(--dim);background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:8px 11px;cursor:pointer;transition:.15s}
299
+ .chip:hover{color:var(--grit);border-color:var(--grit-dim)}
300
+ .chip .pt{color:var(--dim2);margin-right:7px}
301
+ select,.select{padding:10px 12px;border:1px solid var(--line2);border-radius:3px;background:var(--panel);color:var(--ink);font-family:var(--mono);font-size:13px;outline:none;cursor:pointer}
302
+ select:focus{border-color:var(--grit-dim)}
303
+ label.lbl{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.24em;color:var(--dim2);display:block;margin-bottom:6px}
304
+
305
+ /* ---- mono log / trace rows ---- */
306
+ .log{font-family:var(--mono);font-size:13px;line-height:1.75}
307
+ .row{display:grid;grid-template-columns:22px auto 1fr;gap:0 12px;padding:13px 18px;border-bottom:1px solid var(--line)}
308
+ .row:last-child{border-bottom:0}
309
+ .row .mk{color:var(--grit);font-weight:600}
310
+ .row .tn{color:var(--grit2);text-transform:uppercase;letter-spacing:.06em;font-weight:500}
311
+ .row .rd{color:var(--ink);word-break:break-word}.row .rd .arg{color:var(--dim2)}
312
+
313
+ /* ---- tables ---- */
314
+ table{width:100%;border-collapse:collapse;font-size:13.5px}
315
+ th{text-align:left;font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim2);font-weight:500;padding:11px 14px;border-bottom:1px solid var(--line2);background:var(--bg2)}
316
+ td{padding:11px 14px;border-bottom:1px solid var(--line);vertical-align:top}
317
+ tr:last-child td{border-bottom:0}
318
+ .tablewrap{border:1px solid var(--line);border-radius:3px;overflow:hidden;overflow-x:auto}
319
+
320
+ /* ---- chat bubbles ---- */
321
+ .msg{max-width:78%;padding:12px 15px;border-radius:3px;font-size:15px;line-height:1.55;margin-bottom:12px}
322
+ .msg.user{margin-left:auto;background:var(--panel2);border:1px solid var(--line2)}
323
+ .msg.bot{background:var(--panel);border:1px solid var(--line);border-left:2px solid var(--grit)}
324
+
325
+ /* ---- badges / pills ---- */
326
+ .badge{display:inline-block;font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.06em;padding:3px 9px;border-radius:2px;border:1px solid var(--line2);color:var(--dim)}
327
+ .badge.on{color:var(--grit);border-color:var(--grit-dim)}
328
+ .hl{color:var(--grit);font-weight:600;font-family:var(--mono)}
329
+
330
+ /* ---- answer/output prose ---- */
331
+ .prose{font-size:16px;line-height:1.65}.prose p{margin:0 0 12px}.prose p:last-child{margin-bottom:0}.prose b{color:var(--ink)}
332
+
333
+ /* ---- spinner ---- */
334
+ .spin{display:inline-flex;gap:6px;padding:16px 18px}.spin span{width:8px;height:8px;border-radius:50%;background:var(--grit);opacity:.5;animation:bd 1.2s infinite}
335
+ .spin span:nth-child(2){animation-delay:.18s}.spin span:nth-child(3){animation-delay:.36s}
336
+
337
+ /* ---- footer ---- */
338
+ footer{margin-top:34px;padding-top:16px;border-top:1px solid var(--line);font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--dim2);display:flex;gap:16px;flex-wrap:wrap;animation:fade .6s .5s both}
339
+ footer .b{color:var(--dim)}footer .sp{margin-left:auto}
340
+ footer a{color:var(--dim);text-decoration:none}footer a:hover{color:var(--grit)}
341
+
342
+ /* ---- reveal animations ---- */
343
+ @keyframes fade{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
344
+ @keyframes slidein{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
345
+ @keyframes bd{0%,60%,100%{transform:translateY(0);opacity:.35}30%{transform:translateY(-6px);opacity:1}}
346
+ .rvl{animation:fade .6s both}
347
+
348
+
349
+ /* ---- app: Chat with your PDF — layout glue on the GritAI design system ---- */
350
+ .wrap{max-width:820px}
351
+ #uploadView{display:flex;flex-direction:column}
352
+ #chatView{display:none;flex-direction:column}
353
+
354
+ /* upload / drop zone */
355
+ #drop{border:1px dashed var(--line2);text-align:center;padding:42px 24px;cursor:pointer;transition:border-color .18s,background .18s}
356
+ #drop:hover,#drop.drag{border-color:var(--grit-dim);background:linear-gradient(180deg,var(--panel2),var(--bg2))}
357
+ #drop .ic{font-family:var(--disp);font-weight:800;font-size:40px;line-height:1;color:var(--grit)}
358
+ #drop .t{font-family:var(--disp);font-weight:700;font-size:17px;color:var(--ink);margin:12px 0 4px}
359
+ #drop .h{font-family:var(--mono);font-size:12px;color:var(--dim2);margin-bottom:16px}
360
+ #drop .btn{margin-top:2px}
361
+ #drop .spin{justify-content:center}
362
+
363
+ /* feature badges */
364
+ .feats{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:16px}
365
+
366
+ /* samples */
367
+ .samples{margin-top:6px}
368
+ .sgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
369
+ .samp{display:flex;align-items:center;gap:12px;text-align:left;border:1px solid var(--line2);border-radius:3px;background:var(--panel);padding:13px 14px;cursor:pointer;transition:border-color .15s,transform .15s}
370
+ .samp:hover{border-color:var(--grit-dim);transform:translateY(-1px)}
371
+ .samp .e{font-family:var(--mono);font-size:12px;font-weight:600;color:var(--grit);border:1px solid var(--line2);border-radius:2px;padding:6px 9px;flex:none}
372
+ .samp .tt{font-family:var(--disp);font-weight:700;font-size:14px;color:var(--ink)}
373
+ .samp .hh{font-family:var(--mono);font-size:11px;color:var(--dim2);margin-top:3px}
374
+
375
+ /* doc bar */
376
+ .docbar{display:flex;align-items:center;gap:12px;border:1px solid var(--line);border-radius:3px;background:var(--panel);padding:12px 14px;margin-bottom:14px}
377
+ .docbar .dt{font-family:var(--disp);font-weight:700;font-size:14px;color:var(--ink)}
378
+ .docbar .dm{font-family:var(--mono);font-size:11px;color:var(--dim2);margin-top:2px}
379
+ .docbar .btn{margin-left:auto}
380
+
381
+ /* chat log */
382
+ #log{display:flex;flex-direction:column;gap:14px;min-height:260px;max-height:56vh;overflow-y:auto;padding:4px 4px 4px 0}
383
+ #log::-webkit-scrollbar{width:8px}
384
+ #log::-webkit-scrollbar-thumb{background:var(--line2);border-radius:8px}
385
+ #log .row{display:flex;gap:10px;align-items:flex-start;animation:fade .3s both}
386
+ #log .row.user{flex-direction:row-reverse}
387
+ #log .av{width:30px;height:30px;flex:none;display:grid;place-items:center;font-family:var(--mono);font-size:10px;font-weight:600;color:var(--grit);border:1px solid var(--line2);border-radius:2px;background:var(--panel)}
388
+ #log .msg{margin:0;max-width:82%;white-space:pre-wrap}
389
+ .typing{display:inline-flex;gap:5px}
390
+ .typing span{width:7px;height:7px;border-radius:50%;background:var(--grit);opacity:.5;animation:bd 1.2s infinite}
391
+ .typing span:nth-child(2){animation-delay:.18s}
392
+ .typing span:nth-child(3){animation-delay:.36s}
393
+
394
+ /* page citations (.badge) + source panel */
395
+ .cite{cursor:pointer;transition:.14s;margin:0 1px;white-space:nowrap}
396
+ .cite:hover{color:var(--grit);border-color:var(--grit-dim)}
397
+ .cite.act{color:var(--grit);border-color:var(--grit);background:rgba(255,90,42,.12)}
398
+ .srcpanel{margin-top:10px;border:1px solid var(--line);border-left:2px solid var(--grit);border-radius:3px;background:var(--bg2);padding:10px 13px}
399
+ .srcpanel .ps{font-family:var(--mono);font-size:12px;line-height:1.6;color:var(--dim);margin:8px 0}
400
+ .srcpanel .ps:first-child{margin-top:0}
401
+ .srcpanel .ps:last-child{margin-bottom:0}
402
+ .srcpanel .ps b{color:var(--grit);font-weight:600}
403
+
404
+ /* starter chips */
405
+ .starters{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0}
406
+ .starter{font-family:var(--mono);font-size:12px;color:var(--dim);background:var(--panel);border:1px solid var(--line2);border-radius:2px;padding:8px 11px;cursor:pointer;transition:.15s}
407
+ .starter:hover{color:var(--grit);border-color:var(--grit-dim)}
408
+
409
+ /* ask bar */
410
+ .askbar{display:flex;gap:10px;align-items:stretch}
411
+ .askbar .field{flex:1}
412
+ </style></head>
413
+ <body>
414
+ <div class="wrap">
415
+ <header>
416
+ <div class="mark"><svg width="38" height="38" viewBox="0 0 38 38" fill="none"><rect x="1" y="1" width="36" height="36" rx="2" stroke="#2b303c"/><path d="M27 12.5A9 9 0 1 0 28 22H19" stroke="#ff5a2a" stroke-width="2.4" stroke-linecap="square"/><rect x="10.5" y="10.5" width="3" height="3" fill="#ff5a2a"/></svg></div>
417
+ <div class="brand"><span class="co">GRIT<b>AI</b></span><span class="sub">Chat with your PDF</span></div>
418
+ <div class="tags"><span class="tag hot">&#9656; Live</span><span class="tag">SDVOSB</span><span class="tag">Lawton OK</span></div>
419
+ </header>
420
+
421
+ <section class="hero">
422
+ <h1>Ask your documents <em>anything</em></h1>
423
+ <p>Upload a PDF and get answers grounded in its contents &mdash; every answer cites the exact page it came from, and it won&rsquo;t guess.</p>
424
+ </section>
425
+
426
+ <div id="uploadView">
427
+ <div class="sec"><span class="num">01</span><span class="name">Upload</span><span class="rule"></span></div>
428
+ <div id="drop" class="panel">
429
+ <div class="ic">+</div>
430
+ <div class="t">Drop a PDF here or click to upload</div>
431
+ <div class="h">Up to 20 MB &middot; text-based PDFs</div>
432
+ <button class="btn" type="button">Choose PDF</button>
433
+ <input type="file" id="file" accept="application/pdf" style="display:none">
434
+ </div>
435
+ <div class="feats">
436
+ <span class="badge on">Instant answers</span>
437
+ <span class="badge">Cites the page</span>
438
+ <span class="badge">Won&rsquo;t guess</span>
439
+ </div>
440
+ <div class="samples">
441
+ <div class="sec"><span class="num">02</span><span class="name">Or try a sample</span><span class="rule"></span></div>
442
+ <div class="sgrid" id="sgrid"></div>
443
+ </div>
444
+ </div>
445
+
446
+ <div id="chatView">
447
+ <div class="sec"><span class="num">01</span><span class="name">Ask</span><span class="rule"></span></div>
448
+ <div class="docbar">
449
+ <span class="badge on">PDF</span>
450
+ <div><div class="dt" id="dt">document</div><div class="dm" id="dm"></div></div>
451
+ <button class="btn ghost" onclick="reset()">New PDF</button>
452
+ </div>
453
+ <div id="log"></div>
454
+ <div class="starters" id="starters"></div>
455
+ <form id="form" class="askbar" onsubmit="ask();return false;">
456
+ <div class="field"><span class="car">&rsaquo;</span><input id="q" placeholder="Ask about the document&hellip;" autocomplete="off"></div>
457
+ <button class="run" id="btn" aria-label="Send">Ask</button>
458
+ </form>
459
+ </div>
460
+
461
+ <footer>
462
+ <span class="b">Answers grounded in your uploaded PDF</span>
463
+ <span class="sp">GRITAI SOLUTIONS &middot; 30-in-15 &#8470;3</span>
464
+ </footer>
465
+ </div>
466
+ <script>
467
+ const SAMPLES=[["memo","01","Office memo","1-page internal memo"],
468
+ ["manual","02","Product manual","Smart thermostat guide"],
469
+ ["report","03","Market report","Data, tables, findings"],
470
+ ["handbook","04","Employee handbook","13 pages, many sections"]];
471
+ const STARTERS=["Summarize this document","What are the key points?","What should I know first?"];
472
+ const $=id=>document.getElementById(id);
473
+ const drop=$('drop'),file=$('file'),log=$('log'),form=$('form'),q=$('q'),btn=$('btn'),
474
+ uploadView=$('uploadView'),chatView=$('chatView'),dt=$('dt'),dm=$('dm');
475
+ let docId=null,busy=false;
476
+ // render sample buttons
477
+ $('sgrid').innerHTML=SAMPLES.map(s=>`<div class="samp" onclick="loadSample('${s[0]}')"><span class="e">${s[1]}</span><div><div class="tt">${s[2]}</div><div class="hh">${s[3]}</div></div></div>`).join('');
478
+ $('starters').innerHTML=STARTERS.map(s=>`<span class="starter" onclick="ask('${s.replace(/'/g,"\\\\'")}')">${s}</span>`).join('');
479
+ drop.onclick=()=>file.click();
480
+ file.onchange=e=>{if(e.target.files[0])upload(e.target.files[0]);};
481
+ ['dragover','dragenter'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('drag');}));
482
+ ['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('drag');}));
483
+ drop.addEventListener('drop',e=>{const f=e.dataTransfer.files[0];if(f&&f.type==='application/pdf')upload(f);});
484
+ function esc(s){return s.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
485
+ const CITE=/\\(pp?\\.?\\s*\\d+(?:\\s*(?:,|;|&amp;|and)\\s*(?:pp?\\.?\\s*)?\\d+)*\\)/gi;
486
+ function render(el,text,sources){el._src=sources||{};
487
+ el.innerHTML=esc(text).replace(CITE,m=>{const pages=(m.match(/\\d+/g)||[]).join(',');
488
+ return '<span class="cite badge" data-pages="'+pages+'">'+m+'</span>';})+'<div class="srcpanel" style="display:none"></div>';
489
+ const panel=el.querySelector('.srcpanel');
490
+ el.querySelectorAll('.cite').forEach(pill=>{pill.onclick=()=>{
491
+ const pgs=(pill.dataset.pages||'').split(',').filter(Boolean);
492
+ const html=pgs.filter(p=>el._src[p]).map(p=>'<div class="ps"><b>p. '+p+'</b> '+esc(el._src[p])+'…</div>').join('');
493
+ if(!html)return;
494
+ const same=panel.dataset.open===pill.dataset.pages&&panel.style.display==='block';
495
+ el.querySelectorAll('.cite').forEach(c=>c.classList.remove('act'));
496
+ if(same){panel.style.display='none';panel.dataset.open='';}
497
+ else{panel.innerHTML=html;panel.style.display='block';panel.dataset.open=pill.dataset.pages;pill.classList.add('act');
498
+ panel.scrollIntoView({block:'nearest',behavior:'smooth'});}
499
+ };});}
500
+ function add(t,who){const row=document.createElement('div');row.className='row '+who;
501
+ if(who==='bot'){const a=document.createElement('div');a.className='av';a.textContent='AI';row.appendChild(a);}
502
+ const d=document.createElement('div');d.className='msg '+who;d.textContent=t;row.appendChild(d);
503
+ log.appendChild(row);log.scrollTop=log.scrollHeight;return d;}
504
+ function showChat(d){docId=d.doc_id;dt.textContent=d.title;dm.textContent=d.pages+' pages · '+d.chunks+' chunks indexed';
505
+ uploadView.style.display='none';chatView.style.display='flex';log.innerHTML='';
506
+ add('Loaded "'+d.title+'". Ask me anything about it — I\\'ll cite the pages, and you can tap any (p. X) to see the exact source.','bot');q.focus();}
507
+ function reset(){docId=null;chatView.style.display='none';uploadView.style.display='flex';file.value='';
508
+ drop.innerHTML='<div class="ic">+</div><div class="t">Drop a PDF here or click to upload</div><div class="h">Up to 20 MB · text-based PDFs</div><button class="btn" type="button">Choose PDF</button>';drop.appendChild(file);}
509
+ async function post(url,opts){const r=await fetch(url,opts);return r.json();}
510
+ async function upload(f){
511
+ drop.innerHTML='<div class="spin"><span></span><span></span><span></span></div><div class="t">Reading & indexing…</div><div class="h">'+esc(f.name)+'</div>';
512
+ const fd=new FormData();fd.append('pdf',f);
513
+ try{const d=await post('/api/upload',{method:'POST',body:fd});
514
+ if(d.error){drop.innerHTML='<div class="ic">!</div><div class="t">Couldn\\'t read that PDF</div><div class="h">'+esc(d.error)+'</div>';drop.appendChild(file);return;}
515
+ showChat(d);}catch(e){drop.innerHTML='<div class="ic">!</div><div class="t">Upload failed — click to retry</div>';drop.appendChild(file);}
516
+ }
517
+ async function loadSample(name){
518
+ const box=[...document.querySelectorAll('.samp')].find(x=>x.getAttribute('onclick').includes("'"+name+"'"));
519
+ if(box)box.style.opacity=.5;
520
+ try{const d=await post('/api/sample',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
521
+ if(d.error){alert(d.error);}else showChat(d);}catch(e){}
522
+ if(box)box.style.opacity=1;
523
+ }
524
+ async function ask(preset){if(busy||!docId)return;const question=(preset||q.value).trim();if(!question)return;
525
+ q.value='';busy=true;btn.disabled=true;add(question,'user');
526
+ const b=add('','bot');b.innerHTML='<span class="typing"><span></span><span></span><span></span></span>';
527
+ let full='',srcMap={};
528
+ try{const r=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({doc_id:docId,question})});
529
+ const rd=r.body.getReader(),dec=new TextDecoder();let buf='';
530
+ while(true){const {value,done}=await rd.read();if(done)break;buf+=dec.decode(value,{stream:true});
531
+ let i;while((i=buf.indexOf('\\n\\n'))>=0){const line=buf.slice(0,i).trim();buf=buf.slice(i+2);
532
+ if(!line.startsWith('data:'))continue;const p=line.slice(5).trim();if(p==='[DONE]')continue;
533
+ try{const o=JSON.parse(p);if(o.sources){srcMap=o.sources;}else if(o.t){full+=o.t;b.textContent=full;log.scrollTop=log.scrollHeight;}}catch(e){}}}
534
+ if(full)render(b,full,srcMap);else b.textContent='No answer produced — try rephrasing.';
535
+ }catch(e){b.textContent='Sorry — something went wrong.';}
536
+ busy=false;btn.disabled=false;q.focus();}
537
+ </script></body></html>"""
538
+
539
+ if __name__ == "__main__":
540
+ backend = "Claude API" if ANTHROPIC_KEY else f"Ollama ({OLLAMA_MODEL} @ {OLLAMA_URL})"
541
+ print(f"Chat with your PDF on http://127.0.0.1:{PORT} [backend: {backend}]", flush=True)
542
+ app.run(host="0.0.0.0", port=PORT, threaded=True)