chat-with-pdf / app.py
BonusLockSMith's picture
security: log only exception type (never secrets); generic client errors
383dc89 verified
Raw History Blame Contribute Delete
31.9 kB
#!/usr/bin/env python
"""Chat with your PDF — upload a PDF and ask questions; answers are grounded in the
document with page citations (real RAG: chunk -> embed -> retrieve -> answer).
Embeddings: fastembed (ONNX, CPU-friendly, no torch). LLM: Anthropic Claude API if
ANTHROPIC_API_KEY is set, else a local Ollama model.
pip install flask anthropic pypdf fastembed numpy
python app.py # http://127.0.0.1:8500
Project #3 of the "30 Projects in 15 Days" challenge — GritAI.
"""
import os, io, json, re, uuid
import numpy as np
from flask import Flask, request, Response, jsonify
HERE = os.path.dirname(os.path.abspath(__file__))
PORT = int(os.environ.get("PORT", "8500"))
ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY")
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-5")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b")
MAX_PAGES = 120
CHUNK_CHARS = 1200
CHUNK_OVERLAP = 160
TOP_K = 6
MAX_DOCS = 24
SAMPLE_DIR = os.path.join(HERE, "sample_pdfs")
SAMPLES = {
"memo": ("simple_memo.pdf", "📋", "Office memo", "1-page internal memo"),
"manual": ("product_manual.pdf", "📘", "Product manual", "Smart thermostat guide"),
"report": ("research_report.pdf", "📊", "Market report", "Data, tables, findings"),
"handbook": ("employee_handbook.pdf", "📗", "Employee handbook", "13 pages, many sections"),
}
# ---- embedding model (lazy singleton) ----------------------------------------
_EMB = None
def emb_model():
global _EMB
if _EMB is None:
from fastembed import TextEmbedding
_EMB = TextEmbedding("BAAI/bge-small-en-v1.5")
return _EMB
def embed_passages(texts):
return np.array(list(emb_model().passage_embed(texts)), dtype=np.float32)
def embed_query(text):
return np.array(list(emb_model().query_embed([text]))[0], dtype=np.float32)
def _norm(m):
n = np.linalg.norm(m, axis=-1, keepdims=True)
return m / np.clip(n, 1e-8, None)
# ---- pdf -> chunks -----------------------------------------------------------
def extract_pages(data):
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(data))
title = (reader.metadata.title if reader.metadata and reader.metadata.title else "").strip()
pages = []
for i, pg in enumerate(reader.pages[:MAX_PAGES], start=1):
txt = (pg.extract_text() or "").strip()
if txt:
pages.append((i, re.sub(r"[ \t]+", " ", txt)))
return title, len(reader.pages), pages
def chunk_pages(pages):
chunks = []
for pageno, text in pages:
i = 0
while i < len(text):
piece = text[i:i + CHUNK_CHARS].strip()
if len(piece) > 40:
chunks.append({"page": pageno, "text": piece})
i += CHUNK_CHARS - CHUNK_OVERLAP
return chunks
DOCS = {}
DOC_ORDER = []
def index_pdf(data, filename):
title, npages, pages = extract_pages(data)
if not pages:
raise ValueError("No extractable text found — this PDF may be scanned images (needs OCR).")
chunks = chunk_pages(pages)
emb = _norm(embed_passages([c["text"] for c in chunks]))
doc_id = uuid.uuid4().hex[:12]
disp = re.sub(r"\.(html?|pdf)$", "", (title or ""), flags=re.I).strip()
if not disp:
disp = re.sub(r"\.pdf$", "", filename or "document", flags=re.I)
DOCS[doc_id] = {"title": disp, "pages": npages, "chunks": chunks, "emb": emb}
DOC_ORDER.append(doc_id)
while len(DOC_ORDER) > MAX_DOCS:
DOCS.pop(DOC_ORDER.pop(0), None)
return doc_id, DOCS[doc_id]
def retrieve(doc, question, k=TOP_K):
q = _norm(embed_query(question).reshape(1, -1))[0]
sims = doc["emb"] @ q
idx = np.argsort(-sims)[:k]
return [doc["chunks"][i] for i in idx]
# ---- answer (streaming) ------------------------------------------------------
def system_prompt():
return (
"You answer questions about a specific document using ONLY the excerpts provided.\n"
"- Cite the page number(s) you used, in parentheses like (p. 4).\n"
"- If the answer is not in the excerpts, say you couldn't find it in the document — do not guess.\n"
"- Be concise and faithful. Write plain text; no markdown symbols like ** or #."
)
def build_context(chunks):
return "\n\n".join(f"[p. {c['page']}] {c['text']}" for c in chunks)
def answer_from(chunks, question):
ctx = build_context(chunks)
user = f"Document excerpts:\n\n{ctx}\n\n---\nQuestion: {question}"
if ANTHROPIC_KEY:
import anthropic
client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
with client.messages.stream(model=ANTHROPIC_MODEL, max_tokens=700,
system=system_prompt(),
messages=[{"role": "user", "content": user}]) as s:
for t in s.text_stream:
yield t
return
import requests
with requests.post(f"{OLLAMA_URL}/api/chat", stream=True, timeout=120, json={
"model": OLLAMA_MODEL, "stream": True,
"messages": [{"role": "system", "content": system_prompt()},
{"role": "user", "content": user}],
"options": {"temperature": 0.2}}) as r:
r.raise_for_status()
for line in r.iter_lines():
if not line:
continue
d = json.loads(line)
if d.get("message", {}).get("content"):
yield d["message"]["content"]
if d.get("done"):
break
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024
@app.route("/")
def home():
return Response(PAGE, mimetype="text/html")
@app.route("/api/upload", methods=["POST"])
def api_upload():
f = request.files.get("pdf")
if not f:
return jsonify(error="No file uploaded."), 400
try:
doc_id, doc = index_pdf(f.read(), f.filename or "document.pdf")
return jsonify(doc_id=doc_id, title=doc["title"], pages=doc["pages"], chunks=len(doc["chunks"]))
except Exception as e:
return jsonify(error="Could not process that — please try again."), 400
@app.route("/api/sample", methods=["POST"])
def api_sample():
name = request.get_json(force=True).get("name")
meta = SAMPLES.get(name)
if not meta:
return jsonify(error="unknown sample"), 400
path = os.path.join(SAMPLE_DIR, meta[0])
if not os.path.exists(path):
return jsonify(error="sample not available"), 404
try:
with open(path, "rb") as fh:
doc_id, doc = index_pdf(fh.read(), meta[0])
return jsonify(doc_id=doc_id, title=doc["title"], pages=doc["pages"], chunks=len(doc["chunks"]))
except Exception as e:
return jsonify(error="Could not process that — please try again."), 400
@app.route("/api/ask", methods=["POST"])
def api_ask():
b = request.get_json(force=True)
doc = DOCS.get(b.get("doc_id", ""))
question = (b.get("question") or "").strip()
def gen():
import sys as _sys
if not doc:
yield "data: " + json.dumps({"t": "That document isn't loaded anymore — please re-upload it."}) + "\n\n"
elif not question:
yield "data: " + json.dumps({"t": "Please ask a question."}) + "\n\n"
else:
try:
chunks = retrieve(doc, question)
src = {}
for c in chunks:
src.setdefault(str(c["page"]), c["text"][:420])
yield "data: " + json.dumps({"sources": src}) + "\n\n"
for chunk in answer_from(chunks, question):
yield "data: " + json.dumps({"t": chunk}) + "\n\n"
except Exception as e:
print("ask error:", type(e).__name__, file=_sys.stderr, flush=True)
yield "data: " + json.dumps({"t": "Sorry — I hit an error answering. Please try again."}) + "\n\n"
yield "data: [DONE]\n\n"
return Response(gen(), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>Chat with your PDF</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<style>
/* ============================================================================
GRITAI DESIGN SYSTEM — "Engineered / Industrial"
Dark · grit-orange · Archivo + IBM Plex · blueprint grid + grain.
Canonical source of truth for every GritAI app (30-in-15 #1-30) + the hub.
Rob-approved 2026-09-05 (#9 pilot: design-critic 5.71 -> 7.57).
HOW TO USE: embed this <style> verbatim in an app's page, add the Google Fonts
<link> below, then build the body from the component classes here. Keep the
header (monogram + app name + tags), grit-orange as the ONLY accent, dark bg.
FONTS (add to <head>):
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
============================================================================ */
:root{
--bg:#0a0b0e; --bg2:#0e1015; --panel:#111318; --panel2:#15181f;
--ink:#eef0f4; --dim:#9aa1b0; --dim2:#5f6675; --line:#20242e; --line2:#2b303c;
--grit:#ff5a2a; --grit2:#ff7d54; --grit-dim:#c9451f; --ok:#5fd08a; --bad:#ff6b6b;
--disp:'Archivo',system-ui,sans-serif; --body:'IBM Plex Sans',system-ui,sans-serif;
--mono:'IBM Plex Mono',ui-monospace,monospace;
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--body);font-size:15px;line-height:1.6;
-webkit-font-smoothing:antialiased;position:relative;overflow-x:hidden}
/* blueprint grid + vignette */
body::before{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
background:linear-gradient(0deg,transparent 95%,rgba(255,90,42,.04) 100%),
repeating-linear-gradient(90deg,transparent 0 63px,rgba(255,255,255,.018) 63px 64px),
repeating-linear-gradient(0deg,transparent 0 63px,rgba(255,255,255,.018) 63px 64px),
radial-gradient(120% 80% at 50% -10%,#141821 0%,var(--bg) 55%)}
/* film grain */
body::after{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.05;mix-blend-mode:overlay;
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")}
.wrap{position:relative;z-index:1;max-width:960px;margin:0 auto;padding:0 28px 70px}
/* ---- header band (monogram SVG + wordmark + tags) ---- */
header{display:flex;align-items:center;gap:16px;padding:22px 0 18px;border-bottom:1px solid var(--line);animation:fade .6s .02s both}
.mark{width:38px;height:38px;flex:none}.mark svg{display:block}
.brand{display:flex;flex-direction:column;line-height:1}
.brand .co{font-family:var(--disp);font-weight:900;font-size:19px;letter-spacing:.5px}.brand .co b{color:var(--grit)}
.brand .sub{font-family:var(--mono);font-size:10.5px;letter-spacing:.28em;text-transform:uppercase;color:var(--dim2);margin-top:5px}
.tags{margin-left:auto;display:flex;gap:8px}
.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}
.tag.hot{color:var(--grit);border-color:var(--grit-dim)}
/* canonical monogram SVG (paste in .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> */
/* ---- hero ---- */
.hero{padding:34px 0 26px;animation:fade .6s .12s both}
.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}
.hero h1 em{font-style:normal;color:var(--grit)}
.hero p{color:var(--dim);max-width:56ch;margin:16px 0 0;font-size:15.5px}
/* ---- section label (01 · NAME + rule) ---- */
.sec{display:flex;align-items:center;gap:14px;margin:30px 0 14px}
.sec .num{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--grit);letter-spacing:.1em}
.sec .name{font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.26em;text-transform:uppercase;color:var(--dim)}
.sec .rule{flex:1;height:1px;background:linear-gradient(90deg,var(--line2),transparent)}
/* ---- panels ---- */
.panel{border:1px solid var(--line);border-radius:3px;background:linear-gradient(180deg,var(--panel),var(--bg2))}
.card{border:1px solid var(--line2);border-radius:3px;background:var(--panel);padding:18px 20px}
.card.accent{border-left:2px solid var(--grit)}
/* ---- input field (terminal) ---- */
.field{display:flex;align-items:stretch;border:1px solid var(--line2);border-radius:3px;background:var(--panel);overflow:hidden;transition:border-color .18s}
.field:focus-within{border-color:var(--grit-dim)}
.field .car{font-family:var(--mono);color:var(--grit);padding:16px 0 16px 16px;font-size:15px;user-select:none}
.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}
.field input::placeholder,.field textarea::placeholder{color:var(--dim2)}
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}
textarea.ta:focus{border-color:var(--grit-dim)}
/* ---- buttons ---- */
.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)}
.run:hover{background:var(--grit2);box-shadow:0 0 26px -4px rgba(255,90,42,.55)}
.run:disabled{opacity:.55;cursor:default;box-shadow:none}
.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}
.btn:hover{background:var(--grit2);box-shadow:0 0 26px -4px rgba(255,90,42,.55)}
.btn.ghost{background:transparent;color:var(--dim);border:1px solid var(--line2)}
.btn.ghost:hover{color:var(--grit);border-color:var(--grit-dim);background:transparent;box-shadow:none}
/* ---- chips / controls ---- */
.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}
.chip:hover{color:var(--grit);border-color:var(--grit-dim)}
.chip .pt{color:var(--dim2);margin-right:7px}
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}
select:focus{border-color:var(--grit-dim)}
label.lbl{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.24em;color:var(--dim2);display:block;margin-bottom:6px}
/* ---- mono log / trace rows ---- */
.log{font-family:var(--mono);font-size:13px;line-height:1.75}
.row{display:grid;grid-template-columns:22px auto 1fr;gap:0 12px;padding:13px 18px;border-bottom:1px solid var(--line)}
.row:last-child{border-bottom:0}
.row .mk{color:var(--grit);font-weight:600}
.row .tn{color:var(--grit2);text-transform:uppercase;letter-spacing:.06em;font-weight:500}
.row .rd{color:var(--ink);word-break:break-word}.row .rd .arg{color:var(--dim2)}
/* ---- tables ---- */
table{width:100%;border-collapse:collapse;font-size:13.5px}
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)}
td{padding:11px 14px;border-bottom:1px solid var(--line);vertical-align:top}
tr:last-child td{border-bottom:0}
.tablewrap{border:1px solid var(--line);border-radius:3px;overflow:hidden;overflow-x:auto}
/* ---- chat bubbles ---- */
.msg{max-width:78%;padding:12px 15px;border-radius:3px;font-size:15px;line-height:1.55;margin-bottom:12px}
.msg.user{margin-left:auto;background:var(--panel2);border:1px solid var(--line2)}
.msg.bot{background:var(--panel);border:1px solid var(--line);border-left:2px solid var(--grit)}
/* ---- badges / pills ---- */
.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)}
.badge.on{color:var(--grit);border-color:var(--grit-dim)}
.hl{color:var(--grit);font-weight:600;font-family:var(--mono)}
/* ---- answer/output prose ---- */
.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)}
/* ---- spinner ---- */
.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}
.spin span:nth-child(2){animation-delay:.18s}.spin span:nth-child(3){animation-delay:.36s}
/* ---- footer ---- */
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}
footer .b{color:var(--dim)}footer .sp{margin-left:auto}
footer a{color:var(--dim);text-decoration:none}footer a:hover{color:var(--grit)}
/* ---- reveal animations ---- */
@keyframes fade{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
@keyframes slidein{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
@keyframes bd{0%,60%,100%{transform:translateY(0);opacity:.35}30%{transform:translateY(-6px);opacity:1}}
.rvl{animation:fade .6s both}
/* ---- app: Chat with your PDF — layout glue on the GritAI design system ---- */
.wrap{max-width:820px}
#uploadView{display:flex;flex-direction:column}
#chatView{display:none;flex-direction:column}
/* upload / drop zone */
#drop{border:1px dashed var(--line2);text-align:center;padding:42px 24px;cursor:pointer;transition:border-color .18s,background .18s}
#drop:hover,#drop.drag{border-color:var(--grit-dim);background:linear-gradient(180deg,var(--panel2),var(--bg2))}
#drop .ic{font-family:var(--disp);font-weight:800;font-size:40px;line-height:1;color:var(--grit)}
#drop .t{font-family:var(--disp);font-weight:700;font-size:17px;color:var(--ink);margin:12px 0 4px}
#drop .h{font-family:var(--mono);font-size:12px;color:var(--dim2);margin-bottom:16px}
#drop .btn{margin-top:2px}
#drop .spin{justify-content:center}
/* feature badges */
.feats{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:16px}
/* samples */
.samples{margin-top:6px}
.sgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.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}
.samp:hover{border-color:var(--grit-dim);transform:translateY(-1px)}
.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}
.samp .tt{font-family:var(--disp);font-weight:700;font-size:14px;color:var(--ink)}
.samp .hh{font-family:var(--mono);font-size:11px;color:var(--dim2);margin-top:3px}
/* doc bar */
.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}
.docbar .dt{font-family:var(--disp);font-weight:700;font-size:14px;color:var(--ink)}
.docbar .dm{font-family:var(--mono);font-size:11px;color:var(--dim2);margin-top:2px}
.docbar .btn{margin-left:auto}
/* chat log */
#log{display:flex;flex-direction:column;gap:14px;min-height:260px;max-height:56vh;overflow-y:auto;padding:4px 4px 4px 0}
#log::-webkit-scrollbar{width:8px}
#log::-webkit-scrollbar-thumb{background:var(--line2);border-radius:8px}
#log .row{display:flex;gap:10px;align-items:flex-start;animation:fade .3s both}
#log .row.user{flex-direction:row-reverse}
#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)}
#log .msg{margin:0;max-width:82%;white-space:pre-wrap}
.typing{display:inline-flex;gap:5px}
.typing span{width:7px;height:7px;border-radius:50%;background:var(--grit);opacity:.5;animation:bd 1.2s infinite}
.typing span:nth-child(2){animation-delay:.18s}
.typing span:nth-child(3){animation-delay:.36s}
/* page citations (.badge) + source panel */
.cite{cursor:pointer;transition:.14s;margin:0 1px;white-space:nowrap}
.cite:hover{color:var(--grit);border-color:var(--grit-dim)}
.cite.act{color:var(--grit);border-color:var(--grit);background:rgba(255,90,42,.12)}
.srcpanel{margin-top:10px;border:1px solid var(--line);border-left:2px solid var(--grit);border-radius:3px;background:var(--bg2);padding:10px 13px}
.srcpanel .ps{font-family:var(--mono);font-size:12px;line-height:1.6;color:var(--dim);margin:8px 0}
.srcpanel .ps:first-child{margin-top:0}
.srcpanel .ps:last-child{margin-bottom:0}
.srcpanel .ps b{color:var(--grit);font-weight:600}
/* starter chips */
.starters{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0}
.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}
.starter:hover{color:var(--grit);border-color:var(--grit-dim)}
/* ask bar */
.askbar{display:flex;gap:10px;align-items:stretch}
.askbar .field{flex:1}
</style></head>
<body>
<div class="wrap">
<header>
<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>
<div class="brand"><span class="co">GRIT<b>AI</b></span><span class="sub">Chat with your PDF</span></div>
<div class="tags"><span class="tag hot">&#9656; Live</span><span class="tag">SDVOSB</span><span class="tag">Lawton OK</span></div>
</header>
<section class="hero">
<h1>Ask your documents <em>anything</em></h1>
<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>
</section>
<div id="uploadView">
<div class="sec"><span class="num">01</span><span class="name">Upload</span><span class="rule"></span></div>
<div id="drop" class="panel">
<div class="ic">+</div>
<div class="t">Drop a PDF here or click to upload</div>
<div class="h">Up to 20 MB &middot; text-based PDFs</div>
<button class="btn" type="button">Choose PDF</button>
<input type="file" id="file" accept="application/pdf" style="display:none">
</div>
<div class="feats">
<span class="badge on">Instant answers</span>
<span class="badge">Cites the page</span>
<span class="badge">Won&rsquo;t guess</span>
</div>
<div class="samples">
<div class="sec"><span class="num">02</span><span class="name">Or try a sample</span><span class="rule"></span></div>
<div class="sgrid" id="sgrid"></div>
</div>
</div>
<div id="chatView">
<div class="sec"><span class="num">01</span><span class="name">Ask</span><span class="rule"></span></div>
<div class="docbar">
<span class="badge on">PDF</span>
<div><div class="dt" id="dt">document</div><div class="dm" id="dm"></div></div>
<button class="btn ghost" onclick="reset()">New PDF</button>
</div>
<div id="log"></div>
<div class="starters" id="starters"></div>
<form id="form" class="askbar" onsubmit="ask();return false;">
<div class="field"><span class="car">&rsaquo;</span><input id="q" placeholder="Ask about the document&hellip;" autocomplete="off"></div>
<button class="run" id="btn" aria-label="Send">Ask</button>
</form>
</div>
<footer>
<span class="b">Answers grounded in your uploaded PDF</span>
<span class="sp">GRITAI SOLUTIONS &middot; 30-in-15 &#8470;3</span>
</footer>
</div>
<script>
const SAMPLES=[["memo","01","Office memo","1-page internal memo"],
["manual","02","Product manual","Smart thermostat guide"],
["report","03","Market report","Data, tables, findings"],
["handbook","04","Employee handbook","13 pages, many sections"]];
const STARTERS=["Summarize this document","What are the key points?","What should I know first?"];
const $=id=>document.getElementById(id);
const drop=$('drop'),file=$('file'),log=$('log'),form=$('form'),q=$('q'),btn=$('btn'),
uploadView=$('uploadView'),chatView=$('chatView'),dt=$('dt'),dm=$('dm');
let docId=null,busy=false;
// render sample buttons
$('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('');
$('starters').innerHTML=STARTERS.map(s=>`<span class="starter" onclick="ask('${s.replace(/'/g,"\\\\'")}')">${s}</span>`).join('');
drop.onclick=()=>file.click();
file.onchange=e=>{if(e.target.files[0])upload(e.target.files[0]);};
['dragover','dragenter'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('drag');}));
['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('drag');}));
drop.addEventListener('drop',e=>{const f=e.dataTransfer.files[0];if(f&&f.type==='application/pdf')upload(f);});
function esc(s){return s.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
const CITE=/\\(pp?\\.?\\s*\\d+(?:\\s*(?:,|;|&amp;|and)\\s*(?:pp?\\.?\\s*)?\\d+)*\\)/gi;
function render(el,text,sources){el._src=sources||{};
el.innerHTML=esc(text).replace(CITE,m=>{const pages=(m.match(/\\d+/g)||[]).join(',');
return '<span class="cite badge" data-pages="'+pages+'">'+m+'</span>';})+'<div class="srcpanel" style="display:none"></div>';
const panel=el.querySelector('.srcpanel');
el.querySelectorAll('.cite').forEach(pill=>{pill.onclick=()=>{
const pgs=(pill.dataset.pages||'').split(',').filter(Boolean);
const html=pgs.filter(p=>el._src[p]).map(p=>'<div class="ps"><b>p. '+p+'</b> '+esc(el._src[p])+'…</div>').join('');
if(!html)return;
const same=panel.dataset.open===pill.dataset.pages&&panel.style.display==='block';
el.querySelectorAll('.cite').forEach(c=>c.classList.remove('act'));
if(same){panel.style.display='none';panel.dataset.open='';}
else{panel.innerHTML=html;panel.style.display='block';panel.dataset.open=pill.dataset.pages;pill.classList.add('act');
panel.scrollIntoView({block:'nearest',behavior:'smooth'});}
};});}
function add(t,who){const row=document.createElement('div');row.className='row '+who;
if(who==='bot'){const a=document.createElement('div');a.className='av';a.textContent='AI';row.appendChild(a);}
const d=document.createElement('div');d.className='msg '+who;d.textContent=t;row.appendChild(d);
log.appendChild(row);log.scrollTop=log.scrollHeight;return d;}
function showChat(d){docId=d.doc_id;dt.textContent=d.title;dm.textContent=d.pages+' pages · '+d.chunks+' chunks indexed';
uploadView.style.display='none';chatView.style.display='flex';log.innerHTML='';
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();}
function reset(){docId=null;chatView.style.display='none';uploadView.style.display='flex';file.value='';
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);}
async function post(url,opts){const r=await fetch(url,opts);return r.json();}
async function upload(f){
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>';
const fd=new FormData();fd.append('pdf',f);
try{const d=await post('/api/upload',{method:'POST',body:fd});
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;}
showChat(d);}catch(e){drop.innerHTML='<div class="ic">!</div><div class="t">Upload failed — click to retry</div>';drop.appendChild(file);}
}
async function loadSample(name){
const box=[...document.querySelectorAll('.samp')].find(x=>x.getAttribute('onclick').includes("'"+name+"'"));
if(box)box.style.opacity=.5;
try{const d=await post('/api/sample',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
if(d.error){alert(d.error);}else showChat(d);}catch(e){}
if(box)box.style.opacity=1;
}
async function ask(preset){if(busy||!docId)return;const question=(preset||q.value).trim();if(!question)return;
q.value='';busy=true;btn.disabled=true;add(question,'user');
const b=add('','bot');b.innerHTML='<span class="typing"><span></span><span></span><span></span></span>';
let full='',srcMap={};
try{const r=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({doc_id:docId,question})});
const rd=r.body.getReader(),dec=new TextDecoder();let buf='';
while(true){const {value,done}=await rd.read();if(done)break;buf+=dec.decode(value,{stream:true});
let i;while((i=buf.indexOf('\\n\\n'))>=0){const line=buf.slice(0,i).trim();buf=buf.slice(i+2);
if(!line.startsWith('data:'))continue;const p=line.slice(5).trim();if(p==='[DONE]')continue;
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){}}}
if(full)render(b,full,srcMap);else b.textContent='No answer produced — try rephrasing.';
}catch(e){b.textContent='Sorry — something went wrong.';}
busy=false;btn.disabled=false;q.focus();}
</script></body></html>"""
if __name__ == "__main__":
backend = "Claude API" if ANTHROPIC_KEY else f"Ollama ({OLLAMA_MODEL} @ {OLLAMA_URL})"
print(f"Chat with your PDF on http://127.0.0.1:{PORT} [backend: {backend}]", flush=True)
app.run(host="0.0.0.0", port=PORT, threaded=True)