File size: 7,636 Bytes
d787a09 f9d60ac d787a09 f9d60ac d787a09 f9d60ac d787a09 f9d60ac d787a09 f9d60ac d787a09 f9d60ac d787a09 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | """ParaPilot FastAPI app — server-rendered (Jinja + htmx).
Two main views (SPEC §7):
* ROADMAP — visual stepper of the IL divorce process; click a step for its
summary, required forms (+ what each must contain), deadlines,
who-to-call, citations, and next/branch options.
* ASK — grounded chat; answers carry inline citations + confidence + the
disclaimer; out-of-scope/advice -> visible refusal + escalation.
Everything runs offline on the stub provider with no API keys.
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from fastapi import Depends, FastAPI, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app import __version__
from app.config import get_settings
from app.db import get_db, init_db
from app.deps import get_engine
from app.models import Matter, StepProgress
from app.rag.corpus import corpus_stats
from app.rag.generate import answer_question
from app.safety.disclaimers import (
DISCLAIMER,
DISCLAIMER_LONG,
FIND_HELP_NAME,
FIND_HELP_URL,
)
BASE_DIR = Path(__file__).resolve().parent
app = FastAPI(
title="ParaPilot",
description="Illinois divorce procedural navigator — legal information, not legal advice.",
version=__version__,
)
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _linkify_citations(answer: str, citations) -> str:
"""Turn inline [n] markers into clickable, accessible citation chips.
The answer text is HTML-escaped first (it's source-derived but we never
trust-by-default), then markers are replaced with anchors to the matching
citation's source URL. Markers without a matching citation are dropped.
"""
import html
import re
by_marker = {c.marker: c for c in citations}
safe = html.escape(answer)
def repl(m):
n = m.group(1)
c = by_marker.get(n)
if not c:
return ""
title = html.escape("{} — {}".format(c.publisher, c.title))
return (
'<a href="{url}" target="_blank" rel="noopener" class="pp-cite" '
'title="{title}">{n}</a>'
).format(url=html.escape(c.url, quote=True), title=title, n=n)
return re.sub(r"\[(\d+)\]", repl, safe)
templates.env.filters["linkify_citations"] = _linkify_citations
# --- global template context ------------------------------------------------
@app.middleware("http")
async def add_no_cache_for_htmx(request: Request, call_next):
response = await call_next(request)
if request.headers.get("HX-Request"):
response.headers["Cache-Control"] = "no-store"
return response
def _base_ctx(request: Request) -> dict:
return {
"request": request,
"version": __version__,
"disclaimer": DISCLAIMER,
"disclaimer_long": DISCLAIMER_LONG,
"find_help_name": FIND_HELP_NAME,
"find_help_url": FIND_HELP_URL,
}
@app.on_event("startup")
def _startup() -> None:
init_db()
# Warm the engine + retriever so first request is fast and config errors
# surface at boot rather than mid-request.
get_engine()
# --- pages ------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
def home(request: Request):
engine = get_engine()
ctx = _base_ctx(request)
ctx.update(
{
"flow": engine.flow,
"main_line": engine.main_line(),
"subflows": engine.subflows(),
"stats": corpus_stats(),
"active": "roadmap",
}
)
return templates.TemplateResponse(request, "roadmap.html", ctx)
@app.get("/step/{step_id}", response_class=HTMLResponse)
def step_detail(request: Request, step_id: str):
"""htmx partial: the detail panel for one step."""
engine = get_engine()
step = engine.get(step_id)
if step is None:
return HTMLResponse("<div class='p-6 text-rose-600'>Unknown step.</div>", status_code=404)
ctx = _base_ctx(request)
ctx.update(
{
"step": step,
"next_steps": engine.next_steps(step_id),
"branches": engine.branches(step_id),
}
)
return templates.TemplateResponse(request, "partials/step_detail.html", ctx)
@app.get("/ask", response_class=HTMLResponse)
def ask_page(request: Request):
ctx = _base_ctx(request)
ctx.update(
{
"active": "ask",
"examples": [
"How long must I live in Illinois before I can file?",
"What can I do if I can't find my spouse to serve them?",
"Can I appear by Zoom for my hearing?",
"Do parents have to take a parenting class?",
"What if I can't afford the filing fee?",
],
}
)
return templates.TemplateResponse(request, "ask.html", ctx)
@app.post("/ask", response_class=HTMLResponse)
def ask_submit(request: Request, question: str = Form(...)):
"""htmx partial: the grounded answer (or refusal) card."""
env = answer_question(question)
ctx = _base_ctx(request)
ctx.update({"env": env})
return templates.TemplateResponse(request, "partials/answer.html", ctx)
# --- saved progress (SQLite) ------------------------------------------------
@app.post("/matter", response_class=JSONResponse)
def create_matter(db: Session = Depends(get_db), label: Optional[str] = Form(None)):
matter = Matter(label=label or "My Illinois divorce")
db.add(matter)
db.commit()
return {"id": matter.id, "label": matter.label}
@app.post("/matter/{matter_id}/step/{step_id}", response_class=HTMLResponse)
def toggle_step(
request: Request,
matter_id: int,
step_id: str,
done: str = Form("true"),
db: Session = Depends(get_db),
):
"""htmx: mark a step done/undone for a saved matter, return the chip."""
engine = get_engine()
if engine.get(step_id) is None:
return HTMLResponse("Unknown step", status_code=404)
is_done = done.lower() in {"true", "1", "on", "yes"}
sp = (
db.query(StepProgress)
.filter(StepProgress.matter_id == matter_id, StepProgress.step_id == step_id)
.one_or_none()
)
if sp is None:
sp = StepProgress(matter_id=matter_id, step_id=step_id, done=is_done)
db.add(sp)
else:
sp.done = is_done
db.commit()
ctx = _base_ctx(request)
ctx.update({"step_id": step_id, "matter_id": matter_id, "done": is_done})
return templates.TemplateResponse(request, "partials/progress_chip.html", ctx)
# --- about / verify-checklist / health --------------------------------------
@app.get("/about", response_class=HTMLResponse)
def about(request: Request):
engine = get_engine()
ctx = _base_ctx(request)
ctx.update({"active": "about", "verify_items": engine.verify_items()})
return templates.TemplateResponse(request, "about.html", ctx)
@app.get("/healthz", response_class=JSONResponse)
def healthz():
settings = get_settings()
return {
"status": "ok",
"version": __version__,
"provider": settings.provider,
"corpus": corpus_stats(),
}
# --- JSON API (handy for the eval / integration) ----------------------------
@app.get("/api/ask", response_class=JSONResponse)
def api_ask(q: str):
env = answer_question(q)
return env.model_dump()
|