Spaces:
Running
Running
Download app.py from BonusLockSMith/chat-with-pdf: direct link, hf CLI and curl.
- Browser
- Download file 31.9 kB
-
https://huggingface.co/spaces/BonusLockSMith/chat-with-pdf/resolve/main/app.py
- Command line
-
hf download hf://spaces/BonusLockSMith/chat-with-pdf/app.py
-
curl -L -o app.py https://huggingface.co/spaces/BonusLockSMith/chat-with-pdf/resolve/main/app.py
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 | |
| def home(): | |
| return Response(PAGE, mimetype="text/html") | |
| 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 | |
| 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 | |
| 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">▸ 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 — every answer cites the exact page it came from, and it won’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 · 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’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">›</span><input id="q" placeholder="Ask about the document…" 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 · 30-in-15 №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=>({'&':'&','<':'<','>':'>'}[c]));} | |
| const CITE=/\\(pp?\\.?\\s*\\d+(?:\\s*(?:,|;|&|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) | |