From eadce36742e4b2cf5f6bc23829f9bde6801d1dba Mon Sep 17 00:00:00 2001 From: drjones Date: Sat, 5 Sep 2026 12:35:04 -0700 Subject: [PATCH] Advanced mode: chat-driven form filling (fill_form tool), 10 official WA forms auto-grabbed + auto-mapped, doc vault, web search, per-user doc RAG --- agents.py | 4 +- app.py | 309 ++++++++++++++++++++++++++++++++++++++++++- detect_fields.py | 122 +++++++++++++++++ forms.py | 88 ++++++++++++ store.py | 65 +++++++-- templates/index.html | 172 +++++++++++++++++++++++- userdocs.py | 117 ++++++++++++++++ 7 files changed, 863 insertions(+), 14 deletions(-) create mode 100644 detect_fields.py create mode 100644 forms.py create mode 100644 userdocs.py diff --git a/agents.py b/agents.py index 7c83b1d..fac6145 100644 --- a/agents.py +++ b/agents.py @@ -6,8 +6,8 @@ Each agent scopes a domain and pulls from its own set of 'books' (markdown sourc # Backend Ollama (nightmare, RTX 4080 SUPER). Renumbers after reboot — .29 as of Sept 2026. OLLAMA_URL = "http://10.30.20.29:11434" EMBED_MODEL = "nomic-embed-text-v2-moe:latest" # semantic embeddings (verified on nightmare) -RAG_MODEL = "ornith-1.5:9b" # verified best legal-advice answer lane (benchmark Sept 2026) -GENERAL_MODEL = "granite4.2:latest" # fallback (reliable, grounded) +RAG_MODEL = "qwen3.8fast:latest" # tool-calling orchestrator (web search + comms + RAG answer) +GENERAL_MODEL = "ornith-1.5:9b" # fallback (reliable, grounded, also tool-capable) BOOKS_DIR = "/opt/astraea/books" diff --git a/app.py b/app.py index c511812..a96c7b0 100644 --- a/app.py +++ b/app.py @@ -3,9 +3,13 @@ Multi-agent RAG + no-KYC profiles (About Me) + document prep + comms missions + TTS. """ import json +import os import re +import threading import time +import urllib.parse import urllib.request +import uuid from flask import Flask, jsonify, render_template, request, send_file, Response @@ -13,7 +17,9 @@ import rag import store import comms import documents +import forms import tts +import userdocs from agents import AGENTS, AGENT_BY_ID, OLLAMA_URL, RAG_MODEL, GENERAL_MODEL app = Flask(__name__) @@ -34,9 +40,76 @@ AVATARS = [ {"id": 11, "glyph": "\U0001F9ED", "c1": "#5eead4", "c2": "#0d9488", "label": "Compass"}, ] +UPLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads") +FILE_CATEGORIES = ["Court filings", "Correspondence", "Financial", "Agreements", + "Evidence", "Medical & safety", "Generated forms", "Other"] + + +def _docx_text(path): + import zipfile as _z + try: + with _z.ZipFile(path) as z: + xml = z.read("word/document.xml").decode("utf-8", "ignore") + out = [] + for p in re.split(r"", xml): + ts = re.findall(r"]*>(.*?)", p) + if ts: + out.append("".join(ts)) + return "\n".join(out) + except Exception: + return "" + + +def _reindex_user(uid): + """Rebuild a user's document vector index in a background thread.""" + try: + userdocs.build(uid, store.list_files(uid), UPLOAD_DIR) + except Exception: + pass + + +def _autofill_values(uid, form_id): + """Map the user's About Me + display name onto a form's fields via the LLM.""" + spec = forms.FORM_NAMES.get(form_id) + fm = forms.FIELDMAP.get(form_id, []) + if not spec or not fm: + return {} + profile = store.get_profile(uid) if uid else {} + about = profile.get("about_me", "") + display = profile.get("display_name", "") + field_desc = ", ".join(f"{f['key']} ({f.get('label', '')})" for f in fm) + prompt = ( + "You are filling a Washington State court form. Map the user's facts to the form fields.\n" + f"Form: {spec['name']}\n" + f"Fields: {field_desc}\n\n" + f"User's display name: {display or '(not set)'}\n" + f"User's information (About Me):\n{about or '(not provided)'}\n\n" + "Output ONLY valid JSON like {\"county\": \"...\", \"petitioner\": \"...\"}. " + "For any field you cannot determine, output an empty string \"\". " + "Petitioner is the user themself (use the display name); respondent is their spouse." + ) + try: + resp = _ollama_chat(RAG_MODEL, [{"role": "user", "content": prompt}], + temperature=0.1, num_predict=800) + content = resp.get("message", {}).get("content", "") + m = re.search(r"\{.*\}", content, re.DOTALL) + vals = json.loads(m.group(0)) if m else {} + except Exception: + vals = {} + return {f["key"]: str(vals.get(f["key"], "") or "") for f in fm} + + +def _save_pdf_to_vault(uid, path, name): + data = open(path, "rb").read() + fn = f"gen_{uid}_{uuid.uuid4().hex[:8]}.pdf" + with open(os.path.join(UPLOAD_DIR, fn), "wb") as f: + f.write(data) + return store.add_file(uid, "Generated forms", fn, name + ".pdf", + "application/pdf", len(data)) + # ── Ollama helpers ── -def _ollama_chat(model, messages, tools=None, num_ctx=16384, num_predict=1400, +def _ollama_chat(model, messages, tools=None, num_ctx=24000, num_predict=1400, temperature=0.2, timeout=180): payload = {"model": model, "messages": messages, "stream": False, "think": False, "options": {"temperature": temperature, "num_predict": num_predict, "num_ctx": num_ctx}} @@ -49,6 +122,23 @@ def _ollama_chat(model, messages, tools=None, num_ctx=16384, num_predict=1400, return json.loads(r.read().decode("utf-8")) +def _web_search(query, limit=5): + """Search the live web via local SearXNG (separate service, not the LLM).""" + try: + url = f"http://10.30.20.35:6969/search?q={urllib.parse.quote(query)}&format=json" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Astraea)"}) + op = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with op.open(req, timeout=15) as r: + data = json.loads(r.read().decode("utf-8")) + out = [] + for it in data.get("results", [])[:limit]: + out.append({"title": it.get("title", ""), "url": it.get("url", ""), + "snippet": (it.get("content") or "")[:400]}) + return out + except Exception as e: + return [{"error": f"web search failed: {e}"}] + + def _strip_sources(text): text = re.split(r"\n\s*(?:Sources|References|Citations)\s*:\s*\n", text, flags=re.I)[0] return text.strip() @@ -88,6 +178,17 @@ TOOLS = [ "recipient": {"type": "string", "description": "recipient phone number in E.164"}, "message": {"type": "string", "description": "the message to speak"}}, "required": ["recipient", "message"]}}}, + {"type": "function", "function": { + "name": "web_search", "description": "Search the live web for current facts, statutes, or answers not in your reference documents. Use when you don't know something or need up-to-date information.", + "parameters": {"type": "object", "properties": { + "query": {"type": "string", "description": "the search query"}}, + "required": ["query"]}}}, + {"type": "function", "function": { + "name": "fill_form", "description": "Fill an official Washington court form with the user's info and save the filled PDF to their case documents. Use when the user asks to fill out / prepare / complete paperwork. Form ids: fl200=Summons, fl201=Petition, fl001=Confidential Info, fl140=Parenting Plan, wscss=Child Support Worksheets, fl131=Financial Declaration, fl231=Findings & Conclusions, fl241=Final Divorce Order, fl211=Response, fl223=Motion for Temporary Order.", + "parameters": {"type": "object", "properties": { + "form_id": {"type": "string", "description": "the form id, e.g. fl201"}, + "values": {"type": "object", "description": "optional explicit field values; omitted fields auto-fill from the user's profile"}}, + "required": ["form_id"]}}}, ] @@ -108,6 +209,24 @@ def _execute_tool(name, args, settings, uid, display_name): twiml_url = request.host_url.rstrip("/") + "/twilio/voice" comms.make_call(settings, recipient, twiml_url) return f"Call initiated to {recipient}." + if name == "web_search": + res = _web_search(args.get("query", ""), limit=5) + if not res: + return "No results found." + return json.dumps(res, ensure_ascii=False) + if name == "fill_form": + form_id = args.get("form_id", "") + if not forms.FORM_NAMES.get(form_id): + return "Unknown form. Use one of: " + ", ".join(forms.FORM_NAMES.keys()) + values = args.get("values") or {} + auto = _autofill_values(uid, form_id) + merged = {k: str(values.get(k) or auto.get(k) or "") for k in auto} + path = forms.fill_form(form_id, merged) + if not path: + return "Could not generate the form." + _save_pdf_to_vault(uid, path, forms.FORM_NAMES[form_id]["name"]) + return (f"Filled '{forms.FORM_NAMES[form_id]['name']}' and saved it to the user's " + f"Case Documents. Tell the user to open their profile → Case documents to download it.") except Exception as e: return f"ERROR sending {name}: {e}" return "Unknown tool." @@ -149,6 +268,16 @@ def _build_answer(agent, message, history, about_me, settings, uid, display_name citations.append({"source": c["source"], "title": c["title"], "score": round(score, 3), "snippet": c["text"][:260]}) + # user's own uploaded documents (per-user vector index) — retrieve alongside case law + if uid: + try: + for d in userdocs.retrieve(uid, message, top_k=4): + context_blocks.append(f"[USER DOC — {d['source']}]\n{d['text']}") + citations.append({"source": d["source"], "title": "your document", + "score": d["score"], "snippet": d["text"][:260]}) + except Exception: + pass + system = ( f"{agent['system']}\n\n" "Rules:\n" @@ -158,8 +287,12 @@ def _build_answer(agent, message, history, about_me, settings, uid, display_name "- Ground every legal claim in the reference documents. Cite the RCW section AND the " "controlling case law by name.\n" "- Be plain-English, specific to Washington State.\n" + "- Items marked '[USER DOC]' are the user's own uploaded files — reference them by name " + "to ground your answer in their actual case.\n" "- You may use the available tools (send_sms / send_email / make_call) ONLY when the " "user explicitly asks you to contact someone on their behalf.\n" + "- If you don't know the answer, or need current/up-to-date facts not in the reference " + "documents, use the web_search tool and answer from those results.\n" + about_me_block(about_me) + safety_block(settings) ) @@ -283,6 +416,180 @@ def api_profile(): return jsonify(prof) +@app.route("/api/profile/pic", methods=["POST"]) +def api_profile_pic(): + uid = _uid() + if not uid: + return jsonify({"error": "auth required"}), 401 + f = request.files.get("file") + if not f or not f.filename: + return jsonify({"error": "no file"}), 400 + data = f.read() + if len(data) > 5 * 1024 * 1024: + return jsonify({"error": "image too large (max 5 MB)"}), 400 + ext = os.path.splitext(f.filename)[1].lower() + if ext not in (".png", ".jpg", ".jpeg", ".gif", ".webp"): + return jsonify({"error": "unsupported type — use PNG/JPG/GIF/WebP"}), 400 + up = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "uploads") + os.makedirs(up, exist_ok=True) + fn = f"u{uid}{ext}" + with open(os.path.join(up, fn), "wb") as out: + out.write(data) + prof = store.set_profile(uid, profile_pic=f"/static/uploads/{fn}") + return jsonify(prof) + + +# ── Document vault ── +@app.route("/api/files", methods=["GET", "POST"]) +def api_files(): + uid = _uid() + if not uid: + return jsonify({"error": "auth required"}), 401 + if request.method == "GET": + return jsonify({"categories": FILE_CATEGORIES, "files": store.list_files(uid)}) + cat = request.form.get("category", "Other") + if cat not in FILE_CATEGORIES: + cat = "Other" + f = request.files.get("file") + if not f or not f.filename: + return jsonify({"error": "no file"}), 400 + data = f.read() + if len(data) > 25 * 1024 * 1024: + return jsonify({"error": "file too large (max 25 MB)"}), 400 + os.makedirs(UPLOAD_DIR, exist_ok=True) + ext = os.path.splitext(f.filename)[1].lower()[:12] + fn = f"{uid}_{uuid.uuid4().hex[:12]}{ext}" + with open(os.path.join(UPLOAD_DIR, fn), "wb") as out: + out.write(data) + fid = store.add_file(uid, cat, fn, f.filename, f.mimetype or "", len(data)) + threading.Thread(target=_reindex_user, args=(uid,), daemon=True).start() + return jsonify({"ok": True, "id": fid, "category": cat}) + + +@app.route("/api/files/reindex", methods=["POST"]) +def api_files_reindex(): + uid = _uid() + if not uid: + return jsonify({"error": "auth required"}), 401 + n = userdocs.build(uid, store.list_files(uid), UPLOAD_DIR) + return jsonify({"ok": True, "chunks": n}) + + +@app.route("/api/files/status") +def api_files_status(): + uid = _uid() + if not uid: + return jsonify({"error": "auth required"}), 401 + return jsonify(userdocs.status(uid)) + + +@app.route("/api/files//raw") +def api_file_raw(fid): + uid = _uid() + if not uid: + return jsonify({"error": "auth required"}), 401 + rec = store.get_file(uid, fid) + if not rec: + return jsonify({"error": "not found"}), 404 + path = os.path.join(UPLOAD_DIR, rec["filename"]) + if not os.path.exists(path): + return jsonify({"error": "missing"}), 404 + return send_file(path, mimetype=rec["mime"] or "application/octet-stream", + as_attachment=False, download_name=rec["orig_name"]) + + +@app.route("/api/files//text") +def api_file_text(fid): + uid = _uid() + if not uid: + return jsonify({"error": "auth required"}), 401 + rec = store.get_file(uid, fid) + if not rec: + return jsonify({"error": "not found"}), 404 + path = os.path.join(UPLOAD_DIR, rec["filename"]) + ext = os.path.splitext(rec["orig_name"])[1].lower() + if ext == ".docx": + return jsonify({"text": _docx_text(path), "mime": "text/plain"}) + if ext in (".txt", ".md", ".csv", ".log", ".json", ".html", ".xml"): + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + return jsonify({"text": fh.read()[:200000], "mime": "text/plain"}) + except Exception: + return jsonify({"text": "", "mime": "text/plain"}) + return jsonify({"text": "", "mime": rec["mime"] or ""}) + + +@app.route("/api/files/", methods=["DELETE"]) +def api_file_delete(fid): + uid = _uid() + if not uid: + return jsonify({"error": "auth required"}), 401 + rec = store.get_file(uid, fid) + if rec: + p = os.path.join(UPLOAD_DIR, rec["filename"]) + if os.path.exists(p): + try: + os.remove(p) + except Exception: + pass + store.delete_file(uid, fid) + return jsonify({"ok": True}) + + +# ── Form filling (official WA court forms via pymupdf overlay) ── +@app.route("/api/forms") +def api_forms(): + return jsonify(forms.list_forms()) + + +@app.route("/api/forms/autofill", methods=["POST"]) +def api_forms_autofill(): + uid = _uid() + d = request.get_json(force=True, silent=True) or {} + form_id = d.get("form_id", "") + spec = forms.FORM_NAMES.get(form_id) + fm = forms.FIELDMAP.get(form_id, []) + if not spec: + return jsonify({"error": "unknown form"}), 400 + profile = store.get_profile(uid) if uid else {} + about = profile.get("about_me", "") + display = profile.get("display_name", "") + field_desc = ", ".join(f"{f['key']} ({f.get('label','')})" for f in fm) + prompt = ( + "You are filling a Washington State court form. Map the user's facts to the form fields.\n" + f"Form: {spec['name']}\n" + f"Fields: {field_desc}\n\n" + f"User's display name: {display or '(not set)'}\n" + f"User's information (About Me):\n{about or '(not provided)'}\n\n" + "Output ONLY valid JSON like {\"county\": \"...\", \"petitioner\": \"...\"}. " + "For any field you cannot determine, output an empty string. " + "Petitioner is the user themself (use the display name); respondent is their spouse." + ) + try: + resp = _ollama_chat(RAG_MODEL, [{"role": "user", "content": prompt}], + temperature=0.1, num_predict=800) + content = resp.get("message", {}).get("content", "") + m = re.search(r"\{.*\}", content, re.DOTALL) + vals = json.loads(m.group(0)) if m else {} + except Exception: + vals = {} + known = {f["key"]: str(vals.get(f["key"], "") or "") for f in fm} + return jsonify({"form_id": form_id, "values": known}) + + +@app.route("/api/forms/fill", methods=["POST"]) +def api_forms_fill(): + uid = _uid() + d = request.get_json(force=True, silent=True) or {} + form_id = d.get("form_id", "") + values = d.get("values", {}) + path = forms.fill_form(form_id, values) + if not path: + return jsonify({"error": "could not fill form"}), 400 + return send_file(path, mimetype="application/pdf", as_attachment=True, + download_name=f"{form_id}_filled.pdf") + + @app.route("/api/settings", methods=["GET", "POST"]) def api_settings(): uid = _uid() diff --git a/detect_fields.py b/detect_fields.py new file mode 100644 index 0000000..a1db0d0 --- /dev/null +++ b/detect_fields.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Detect fillable blanks across WA court forms: underscore runs + the standard +caption (county/petitioner/respondent/case_no). Emits a field-map JSON.""" +import json +import os +import re +import sys + +import pymupdf + +FORMS_DIR = "/opt/astraea/forms" + +def lines_with_pos(page): + words = page.get_text("words") + groups = {} + for w in words: + x0, y0, x1, y1, word = w[0], w[1], w[2], w[3], w[4] + key = int(y0 / 5) * 5 + groups.setdefault(key, []).append((x0, x1, word)) + out = [] + for y in sorted(groups): + ws = sorted(groups[y], key=lambda t: t[0]) + text = " ".join(w for _, _, w in ws) + out.append({"y": y, "x0": ws[0][0], "x1": ws[-1][1], "text": text, "words": ws}) + return out + + +def detect_caption(lines): + """Find the standard caption fields. Returns list of {key, label, page, x, y}.""" + fields = [] + for ln in lines: + t = ln["text"] + # "County of" -> blank right after 'of' + if re.search(r"\bCounty\b.*\bof\b\s*$", t) and "Superior" in t: + ofx = None + for x0, x1, w in ln["words"]: + if w == "of": + ofx = x1 + if ofx: + fields.append({"key": "county", "label": "County", "x": ofx + 6, "y": ln["y"] + 9}) + # Petitioner ... case): -> name after the colon + if "Petitioner" in t and ("case" in t or "case):" in t) and ":" in t: + colonx = None + for x0, x1, w in ln["words"]: + if w.endswith(":"): + colonx = x1 + if colonx and colonx < 400: + fields.append({"key": "petitioner", "label": "Petitioner (your full name)", "x": colonx + 6, "y": ln["y"] + 9}) + # Respondent ... partner): -> name after colon + if "Respondent" in t and ":" in t: + colonx = None + for x0, x1, w in ln["words"]: + if w.endswith(":"): + colonx = x1 + if colonx and colonx < 400: + fields.append({"key": "respondent", "label": "Respondent (spouse's full name)", "x": colonx + 6, "y": ln["y"] + 9}) + # Case No. + if "Case No." in t: + nox = None + for x0, x1, w in ln["words"]: + if w in ("No.", "No"): + nox = x1 + if nox: + fields.append({"key": "case_no", "label": "Case number", "x": nox + 6, "y": ln["y"] + 9}) + return fields + + +def detect_underscores(pno, page): + """Find underscore runs (blank fields) and their preceding label.""" + fields = [] + words = page.get_text("words") + groups = {} + for w in words: + x0, y0, x1, y1, word = w[0], w[1], w[2], w[3], w[4] + key = int(y0 / 5) * 5 + groups.setdefault(key, []).append((x0, x1, y0, word)) + for y in sorted(groups): + ws = sorted(groups[y], key=lambda t: t[0]) + for i, (x0, x1, y0, word) in enumerate(ws): + if word.strip("_").strip() == "" and len(word.strip()) >= 4: + label = " ".join(w for _, _, _, w in ws[:i]).strip() + fields.append({"page": pno, "x": x0, "y": y + 9, + "label": label, "type": "text"}) + return fields + + +def main(): + all_fields = {} + for fn in sorted(os.listdir(FORMS_DIR)): + if not fn.endswith(".pdf"): + continue + fid = fn[:-4] + path = os.path.join(FORMS_DIR, fn) + try: + d = pymupdf.open(path) + except Exception as e: + print(f"{fid}: OPEN FAIL {e}", file=sys.stderr) + continue + fields = [] + seen_keys = set() + for pno in range(min(len(d), 3)): # caption + first 2 pages + lines = lines_with_pos(d[pno]) + if pno == 0: + for f in detect_caption(lines): + f["page"] = 0 + if f["key"] not in seen_keys: + seen_keys.add(f["key"]) + fields.append(f) + for f in detect_underscores(pno, d[pno]): + f["key"] = "f_" + str(len(fields)) + fields.append(f) + d.close() + all_fields[fid] = fields + out = "/opt/astraea/forms/fieldmap.json" + with open(out, "w") as f: + json.dump(all_fields, f, indent=1) + print(f"saved {out}: { {k: len(v) for k, v in all_fields.items()} }") + + +if __name__ == "__main__": + main() diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..1abb33e --- /dev/null +++ b/forms.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""Astraea form filling — overlay the user's answers onto official WA court forms +with pymupdf (no PDF MCP exists; pymupdf is the editor). Field maps are +auto-detected from the flat forms and cached in forms/fieldmap.json. +""" +import json +import os +import uuid + +import pymupdf + +FORMS_DIR = "/opt/astraea/forms" +FIELDMAP_PATH = os.path.join(FORMS_DIR, "fieldmap.json") + +FORM_NAMES = { + "fl200": {"name": "Summons (FL Divorce 200)", "file": "fl200.pdf", + "description": "Notifies your spouse the divorce case has started."}, + "fl201": {"name": "Petition for Dissolution (FL Divorce 201)", "file": "fl201.pdf", + "description": "The main form that starts your divorce case."}, + "fl001": {"name": "Confidential Information (FL All Family 001)", "file": "fl001.pdf", + "description": "Private contact/safety info for the court."}, + "fl140": {"name": "Parenting Plan (FL All Family 140)", "file": "fl140.pdf", + "description": "Residential schedule and decision-making for your children."}, + "wscss": {"name": "Child Support Worksheets (WSCSS)", "file": "wscss.pdf", + "description": "Calculates child support under RCW 26.19."}, + "fl131": {"name": "Financial Declaration (FL All Family 131)", "file": "fl131.pdf", + "description": "Your income, assets, and debts."}, + "fl231": {"name": "Findings & Conclusions (FL Divorce 231)", "file": "fl231.pdf", + "description": "The court's factual findings."}, + "fl241": {"name": "Final Divorce Order (FL Divorce 241)", "file": "fl241.pdf", + "description": "The final decree ending your marriage."}, + "fl211": {"name": "Response to Petition (FL Divorce 211)", "file": "fl211.pdf", + "description": "Your reply to your spouse's petition."}, + "fl223": {"name": "Motion for Temporary Order (FL Divorce 223)", "file": "fl223.pdf", + "description": "Ask for temporary custody, support, or protection orders."}, +} + + +def _load_fieldmap(): + try: + with open(FIELDMAP_PATH) as f: + return json.load(f) + except Exception: + return {} + + +FIELDMAP = _load_fieldmap() + + +def list_forms(): + out = [] + for fid, spec in FORM_NAMES.items(): + fm = FIELDMAP.get(fid, []) + fields = [{"key": f["key"], "label": f.get("label", f["key"])} for f in fm] + out.append({"id": fid, "name": spec["name"], "description": spec["description"], + "fields": fields}) + return out + + +def fill_form(form_id, values): + """Overlay `values` (dict field_key -> text) onto the form, return the filled PDF path.""" + spec = FORM_NAMES.get(form_id) + fm = FIELDMAP.get(form_id, []) + if not spec: + return None + src = os.path.join(FORMS_DIR, spec["file"]) + if not os.path.exists(src): + return None + fmap = {f["key"]: f for f in fm} + doc = pymupdf.open(src) + for key, text in values.items(): + f = fmap.get(key) + if not f or not text: + continue + text = str(text).strip() + if not text: + continue + page = doc[f.get("page", 0)] + page.insert_text((f["x"], f["y"]), text, fontsize=f.get("size", 11), + fontname="helv", color=(0, 0, 0)) + out = os.path.join("/tmp", f"filled_{form_id}_{uuid.uuid4().hex[:8]}.pdf") + doc.save(out) + doc.close() + return out + + +def available_forms(): + return list(FORM_NAMES.keys()) diff --git a/store.py b/store.py index 1a25e63..1c760c1 100644 --- a/store.py +++ b/store.py @@ -35,6 +35,7 @@ def init_db(): display_name TEXT DEFAULT '', avatar INTEGER DEFAULT 0, about_me TEXT DEFAULT '', + profile_pic TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS settings ( @@ -63,6 +64,16 @@ def init_db(): citations TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + category TEXT DEFAULT 'Other', + filename TEXT NOT NULL, + orig_name TEXT NOT NULL, + mime TEXT DEFAULT '', + size INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); """) c.commit() # migrations for pre-existing DBs @@ -74,6 +85,11 @@ def init_db(): c.commit() except Exception: pass + try: + c.execute("ALTER TABLE profiles ADD COLUMN profile_pic TEXT DEFAULT ''") + c.commit() + except Exception: + pass c.close() @@ -127,26 +143,27 @@ def get_profile(uid): row = c.execute("SELECT * FROM profiles WHERE user_id=?", (uid,)).fetchone() c.close() if not row: - return {"display_name": "", "avatar": 0, "about_me": ""} + return {"display_name": "", "avatar": 0, "about_me": "", "profile_pic": ""} return {"display_name": row["display_name"], "avatar": row["avatar"], - "about_me": row["about_me"]} + "about_me": row["about_me"], "profile_pic": row["profile_pic"] or ""} -def set_profile(uid, display_name=None, avatar=None, about_me=None): +def set_profile(uid, display_name=None, avatar=None, about_me=None, profile_pic=None): cur = get_profile(uid) dn = cur["display_name"] if display_name is None else display_name av = cur["avatar"] if avatar is None else avatar am = cur["about_me"] if about_me is None else about_me + pp = cur.get("profile_pic", "") if profile_pic is None else profile_pic c = _conn() - c.execute("""INSERT INTO profiles (user_id, display_name, avatar, about_me, updated_at) - VALUES (?,?,?,?, CURRENT_TIMESTAMP) + c.execute("""INSERT INTO profiles (user_id, display_name, avatar, about_me, profile_pic, updated_at) + VALUES (?,?,?,?,?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name, avatar=excluded.avatar, - about_me=excluded.about_me, updated_at=CURRENT_TIMESTAMP""", - (uid, dn, av, am)) + about_me=excluded.about_me, profile_pic=excluded.profile_pic, updated_at=CURRENT_TIMESTAMP""", + (uid, dn, av, am, pp)) c.commit() c.close() - return {"display_name": dn, "avatar": av, "about_me": am} + return {"display_name": dn, "avatar": av, "about_me": am, "profile_pic": pp} def get_settings(uid): @@ -203,3 +220,35 @@ def set_settings(uid, **fields): c.commit() c.close() return get_settings(uid) + + +# ── Document vault ── +def add_file(uid, category, filename, orig_name, mime, size): + c = _conn() + c.execute("INSERT INTO files (user_id, category, filename, orig_name, mime, size) VALUES (?,?,?,?,?,?)", + (uid, category, filename, orig_name, mime, size)) + c.commit() + fid = c.execute("SELECT last_insert_rowid()").fetchone()[0] + c.close() + return fid + + +def list_files(uid): + c = _conn() + rows = c.execute("SELECT * FROM files WHERE user_id=? ORDER BY created_at DESC, id DESC", (uid,)).fetchall() + c.close() + return [dict(r) for r in rows] + + +def get_file(uid, fid): + c = _conn() + row = c.execute("SELECT * FROM files WHERE id=? AND user_id=?", (fid, uid)).fetchone() + c.close() + return dict(row) if row else None + + +def delete_file(uid, fid): + c = _conn() + c.execute("DELETE FROM files WHERE id=? AND user_id=?", (fid, uid)) + c.commit() + c.close() diff --git a/templates/index.html b/templates/index.html index ddf0fe7..f8005e9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -83,6 +83,18 @@ .avatar-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-bottom:18px; } .avatar-opt { aspect-ratio:1; border-radius:50%; display:grid; place-items:center; font-size:22px; color:#fff; cursor:pointer; border:2px solid transparent; } .avatar-opt.sel { border-color:#fff; box-shadow:0 0 0 3px rgba(255,255,255,.2); } + .progbar { height:6px; background:var(--panel2); border-radius:3px; overflow:hidden; margin:14px 0 18px; } + .progbar > div { height:100%; background:linear-gradient(90deg,#6ea8ff,#a78bfa); border-radius:3px; transition:width .2s; } + .intake-q { font-size:16px; font-weight:600; margin-bottom:12px; line-height:1.4; } + .intake-nav { display:flex; justify-content:space-between; margin-top:14px; gap:10px; } + .intake-nav .btn { flex:1; } + .modal.wide { width:min(880px,94vw); max-height:90vh; overflow:auto; } + .file-list { max-height:240px; overflow:auto; display:flex; flex-direction:column; gap:6px; } + .file-item { display:flex; align-items:center; gap:8px; padding:8px 10px; background:var(--panel2); border-radius:8px; font-size:13px; } + .file-item .nm { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .file-item .cat { color:var(--muted); font-size:11px; } + .file-item button { background:none; border:1px solid var(--line); color:var(--text); border-radius:6px; padding:4px 9px; cursor:pointer; font-size:12px; } + .file-item button:hover { background:var(--line); } .switch { display:flex; align-items:center; gap:10px; } .switch input { width:auto; } .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:13px; } @@ -121,6 +133,7 @@
+
@@ -141,10 +154,45 @@
-
+
+
+
+
+
No documents yet — upload your petition, orders, agreements, and evidence.
+
+ + + + + + + + +