agenda-parser / webapp /backend.py
rdubwiley's picture
Deploy Agenda Parser
ac6caaa verified
Raw
History Blame Contribute Delete
81.2 kB
"""Pure, typed backend for the Agenda Parser.
All the PDF parsing, packet slicing, and the chunk -> ChromaDB -> Gemma-4
summarizer live here as plain functions with full type hints. The type hints
matter: :func:`gradio.api` derives each endpoint's JSON schema from them (see
``server.py``), and the React frontend consumes that schema.
The app takes an **uploaded agenda-packet PDF** (no external API): the user uploads
the packet and marks the agenda (table-of-contents) page range; we parse it into
agenda items mapped to their backup-packet page ranges (bookmarks first, text
fallback), then summarize / report / answer / run the agent against that packet.
Nothing in this module touches Gradio UI components -- it is the API surface,
not the presentation.
"""
from __future__ import annotations
import os
import re
import secrets
import tempfile
import threading
from collections import OrderedDict
from pathlib import Path
from typing import Iterator, TypedDict
from chroma import (
AgendaStore,
chunk_text,
generate_report,
summarize_text,
)
from chroma.llm import chat_complete, load_llm_config
from pdf_utils import extract_pdf_outline
REPO = Path(__file__).resolve().parent.parent
# Let the LLM client pick up the deployed key even if pi_models.json isn't found.
_key_file = REPO / "model" / ".gemma4_api_key"
if _key_file.exists() and not os.getenv("GEMMA_API_KEY"):
os.environ["GEMMA_API_KEY"] = _key_file.read_text().strip()
# One persistent Chroma store + one resolved LLM config, reused across requests.
STORE = AgendaStore(str(REPO / ".chroma"))
LLM_CFG = load_llm_config()
# Which LLM path to use:
# "remote" (default) -> the OpenAI-compatible endpoint in LLM_CFG (Modal vLLM).
# "local" -> an in-process llama.cpp GGUF run inside @spaces.GPU
# (webapp/local_llm.py); set this on the ZeroGPU Space.
LLM_BACKEND = os.getenv("LLM_BACKEND", "remote").strip().lower()
def _model_label(model: str = "") -> str:
"""Human label for the model that produced an output, for the 'done' frame."""
if LLM_BACKEND == "local":
from webapp import local_llm
return local_llm.model_label(model)
return str(LLM_CFG["model"])
def available_models() -> list[str]:
"""Which model-picker keys are usable here. On the Space (local backend) these are
the in-process GGUFs whose file can be downloaded (the private 26B/full need
HF_TOKEN). All three run locally β€” no remote endpoint."""
if LLM_BACKEND == "local":
from webapp import local_llm
keys = local_llm.available_models()
return keys or ["e4b"]
return ["e4b", "26b", "full"]
def _complete(prompt: str, system: str | None = None) -> str:
"""LLM completer with a generous timeout (the Modal GPU can cold-start)."""
from openai import OpenAI
client = OpenAI(
base_url=LLM_CFG["base_url"], api_key=LLM_CFG["api_key"],
timeout=900.0, max_retries=1,
)
return chat_complete(prompt, system=system, client=client, model=LLM_CFG["model"])
# --------------------------------------------------------------------------- #
# Bundled sample agenda packets β€” a one-click "try it" alternative to uploading.
# The PDFs ship in samples/ (served from disk, parsed exactly like an upload), so
# there's no client-side CORS / external fetch at runtime.
# --------------------------------------------------------------------------- #
_SAMPLES_DIR = REPO / "samples"
SAMPLES: list[dict] = [
{
"id": "oakland-1570",
"label": "Oakland County β€” Full Board",
"description": "773-page board packet Β· 50 items, bookmark-mapped",
"file": "oakland-1570.pdf",
"agenda_pages": "1-3",
"source_url": "https://oaklandcomi.portal.civicclerk.com/event/1570/files/agenda/13735",
},
{
"id": "baycity-928",
"label": "Bay City β€” City Commission",
"description": "228-page packet Β· 20 items incl. a budget presentation",
"file": "baycity-928.pdf",
"agenda_pages": "1-3",
"source_url": "https://baycitymi.portal.civicclerk.com/event/928/files/agenda/3731",
},
{
"id": "ionia-2026-02",
"label": "Ionia β€” City Council",
"description": "144-page council packet Β· 30 items, bookmark-mapped",
"file": "ionia-2026-02.pdf",
"agenda_pages": "1-3",
"source_url": "",
},
]
_SAMPLE_BY_ID = {s["id"]: s for s in SAMPLES}
_SAMPLE_UID_PREFIX = "sample-"
def _sample_uid(sample_id: str) -> str:
"""Stable upload_id for a bundled sample (so its PDF is always re-derivable)."""
return f"{_SAMPLE_UID_PREFIX}{sample_id}"
def list_samples() -> list[dict]:
"""The bundled sample agendas available to try (only those present on disk)."""
return [
{"id": s["id"], "label": s["label"], "description": s["description"],
"agenda_pages": s["agenda_pages"], "source_url": s["source_url"]}
for s in SAMPLES
if (_SAMPLES_DIR / s["file"]).exists()
]
def ingest_sample(sample_id: str) -> dict:
"""Parse a bundled sample agenda packet (same return shape as upload_packet).
Truncated bookmark titles are restored from the agenda text at parse time (see
:func:`_untruncate_item_names`), so samples show full titles like any upload.
"""
sid = (sample_id or "").strip()
s = _SAMPLE_BY_ID.get(sid)
if s is None:
return {"error": f"Unknown sample '{sample_id}'."}
path = _SAMPLES_DIR / s["file"]
if not path.exists():
return {"error": f"Sample '{sample_id}' is not bundled on this server."}
# Deterministic upload_id (vs a random one) so the cached PDF is re-derivable from
# the bundle: cached_packet rehydrates a sample on a miss (Space restart / LRU
# eviction / a saved sample from a prior session), so sample downloads & page views
# never 404 the way an expired user upload would.
return upload_packet(path.read_bytes(), s["file"], s["agenda_pages"],
upload_id=_sample_uid(sid))
# --------------------------------------------------------------------------- #
# Typed shapes (gradio.api derives each endpoint's JSON schema from these)
# --------------------------------------------------------------------------- #
class AgendaItem(TypedDict):
id: str
number: str # leading outline enumerator, e.g. "11." / "a." / ""
name: str # item title with the enumerator stripped
summary: str
is_section: bool # a grouping header (no backup pages of its own)
level: int # nesting depth (0 = top-level item, 1 = sub-item)
status: str
attachments: int
has_pages: bool # whether this item has backup pages in the packet
pages: str # "24-35" 1-indexed human range, "" when none
start: int # 0-indexed start page (inclusive), 0 when none
end: int # 0-indexed end page (exclusive), 0 when none
class AgendaOutline(TypedDict):
items: list[AgendaItem]
source: str # "outline" | "text" | "none"
confidence: str # "good" | "poor" | "empty"
message: str
model: str
title: str # suggested agenda name (from the agenda header), "" if none
class ItemPage(TypedDict):
page: int # 1-indexed page number in the packet
image: str # "data:image/jpeg;base64,…" β€” renderable directly in an <img>
class ItemPages(TypedDict):
stage: str # "working" | "done" | "error" β€” streamed so the browser keeps the
# connection alive while the packet renders
sliced: bool # whether the item's pages were confidently located
pages: str # "26-48" page range, or "" when not sliced
note: str # human explanation (range, or why it couldn't be isolated)
code: str # "" normally; "no_packet" when the upload is gone (re-upload)
images: list[ItemPage]
class AgentMessage(TypedDict):
role: str # "user" | "assistant"
content: str
class AgentFrame(TypedDict):
# One streamed step of an Agent Mode turn. Stable shape (every key always present)
# so gradio.api derives a clean schema and the React client can switch on `stage`.
stage: str # "thinking" | "tool_call" | "tool_result" | "notice" | "answer" | "error"
text: str # thinking / notice / answer / error message (empty for tool_* frames)
tool: str # tool name (tool_call / tool_result)
args: dict # tool_call arguments
result: str # tool_result compact JSON (empty otherwise)
summary: str # tool_result one-line human gist (empty otherwise)
step: int # 1-based step index (0 for answer/error)
model: str # model label, set on the final answer frame
# --------------------------------------------------------------------------- #
# Agenda outline from the PDF text (fallback when the packet has no bookmarks).
# Numbered lines ("10. ...") become top-level items; lettered lines ("a. ...")
# nest one level under the preceding numbered item β€” that is where the substance
# lives. Recognized headers ("Consent Agenda", ...) become sections.
# --------------------------------------------------------------------------- #
_TAG_RE = re.compile(r"<[^>]+>")
_OUTLINE_START_RE = re.compile(r"(?i)^a\s*g\s*e\s*n\s*d\s*a\s*:?\s*$")
_OUTLINE_FOOTER_RE = re.compile(
r"(?i)^(public access information|if you require special accommodation|"
r"view meeting|updated agenda|watch |how to (view|participate|join)|"
r"persons with disabilities|americans with disabilities act|"
r"closed captioning|to (view|watch|join|participate)|"
r"in accordance with|note:|the public may)\b"
)
_OUTLINE_NUM_RE = re.compile(r"^\s*(\d{1,3})[\.\)]\s+(.+)$")
_OUTLINE_ALPHA_RE = re.compile(r"^\s*([a-zA-Z]|[ivxIVX]{1,4})[\.\)]\s+(.+)$")
_OUTLINE_BULLET_RE = re.compile(r"^\s*[β€’\-\*]\s+")
# Per-item metadata lines (e.g. "Item Category: Appointment", "Presenter: ...").
_OUTLINE_META_RE = re.compile(
r"(?i)^(item category|presenter|action required|action|fiscal impact|"
r"department|sponsor|recommended action)\s*:\s*(.+)$"
)
_OUTLINE_SECTION_WORDS = {
"consent agenda", "consent calendar", "regular agenda",
"reports of standing committees", "reports of special committees",
"presentations", "communications", "public comment", "old business",
"new business", "unfinished business", "special order of business",
"items for discussion", "action items", "information items",
}
def _looks_like_section(line: str) -> bool:
"""True if a non-numbered agenda line reads like a grouping header.
Deliberately conservative: a known header phrase, or a short ALL-CAPS line.
We do *not* treat arbitrary Title-Case lines as headers β€” in practice those
are wrapped continuations of an item's title, and misreading them as sections
shreds the outline (and orphans the sub-items that follow).
"""
s = line.strip()
if s.rstrip(":").lower() in _OUTLINE_SECTION_WORDS:
return True
if len(s) > 60 or ":" in s or s.endswith((".", "?", ",")):
return False
words = [w for w in re.split(r"\s+", s) if w]
if not (1 <= len(words) <= 6) or not any(c.isalpha() for c in s):
return False
return s == s.upper() # ALL-CAPS header only
def _parse_agenda_outline(text: str) -> list[AgendaItem]:
"""Heuristically turn an agenda PDF's text into an ordered item outline.
Numbered lines (``10. ...``) become top-level items; lettered lines
(``a. ...``) nest one level under the preceding numbered item β€” that is where
the substance lives (a committee's individual resolutions, appointments,
etc.). Recognized headers (``Consent Agenda``, ``Regular Agenda``, ...) become
sections, and per-item metadata (``Item Category:`` -> status; ``Presenter:``,
``Action Required:`` -> summary) folds into the item it follows. Best-effort:
a readable rendering of the published agenda, not a parser of record.
"""
lines = text.splitlines()
start = 0
for i, ln in enumerate(lines):
if _OUTLINE_START_RE.match(ln.strip()):
start = i + 1
break
out: list[AgendaItem] = []
last: AgendaItem | None = None
n = 0
def add(number: str, name: str, is_section: bool, level: int) -> AgendaItem:
nonlocal n
n += 1
it = AgendaItem(
id=f"pdf:{n}", number=number, name=name.strip(), summary="",
is_section=is_section, level=level, status="", attachments=0,
has_pages=False, pages="", start=0, end=0,
)
out.append(it)
return it
def add_summary(it: AgendaItem, piece: str) -> None:
it["summary"] = f"{it['summary']} Β· {piece}" if it["summary"] else piece
for raw in lines[start:]:
s = raw.strip()
if not s:
continue
if _OUTLINE_FOOTER_RE.match(s):
break
m = _OUTLINE_NUM_RE.match(raw)
if m:
last = add(m.group(1) + ".", m.group(2), False, 0)
continue
m = _OUTLINE_ALPHA_RE.match(raw)
if m and last is not None and not last["is_section"]:
base = last["level"] if last["number"].rstrip(".").isalpha() else last["level"] + 1
last = add(m.group(1) + ".", m.group(2), False, base)
continue
# Per-item metadata β€” must precede section detection ("Item Category:
# Appointment" would otherwise read as a Title-Case header).
m = _OUTLINE_META_RE.match(s)
if m and last is not None:
key, val = m.group(1).strip().lower(), m.group(2).strip()
if key == "item category" and not last["status"]:
last["status"] = val
else:
add_summary(last, f"{m.group(1).strip().title()}: {val}")
continue
if _OUTLINE_BULLET_RE.match(s) and last is not None:
add_summary(last, re.sub(r"^[β€’\-\*]\s+", "", s))
continue
if _looks_like_section(s):
last = None
add("", s.rstrip(":"), True, 0)
continue
# Otherwise: a wrapped continuation of the previous item's title.
if last is not None and not last["is_section"]:
last["name"] = (last["name"] + " " + s).strip()
return out
# --------------------------------------------------------------------------- #
# Item -> packet page anchoring (shared by the report slicer and the page viewer).
#
# A title is only distinctive enough to anchor in the packet if it's reasonably
# long; short generic lines ("Roll Call", "Invocation") never carry their own
# packet section. When an item's slice has no downstream item to bound it, cap how
# far it can run so we don't sweep in the rest of a long packet.
# --------------------------------------------------------------------------- #
_ITEM_ANCHOR_MIN_LEN = 18
_ITEM_ANCHOR_KEY_LEN = 50
_ITEM_SLICE_MAX_PAGES = 25
# A page carrying this many *other* item titles (besides the one being anchored) is
# an agenda outline / table-of-contents page, not an item's content section, so it
# never anchors an item. A content page carries its item's title and few others.
_ITEM_OUTLINE_OTHER_TITLES = 2
# Leading outline enumerator on a title ("1.", "a.", "iv.", "V.") β€” the packet body
# repeats the item's wording but not its outline number, so we strip it for matching.
_ENUM_RE = re.compile(r"^\s*(?:\d{1,3}|[a-z]|[ivxlcdm]{1,6})[.)]\s+", re.IGNORECASE)
def _norm_ws(s: str) -> str:
return re.sub(r"\s+", " ", (s or "")).strip().lower()
def _title_key(title: str) -> str:
"""Whitespace-normalized title with any leading outline enumerator stripped β€”
the form used to find the item's wording in the packet body."""
return _norm_ws(_ENUM_RE.sub("", (title or "").strip()))
def _split_enumerator(title: str) -> tuple[str, str]:
"""Split a bookmark/agenda title into ``(number, name)``.
``"11. Economic Development…"`` -> ``("11.", "Economic Development…")``;
a title with no leading enumerator (a section header) -> ``("", title)``.
"""
t = re.sub(r"\s+", " ", (title or "").strip())
m = _ENUM_RE.match(t)
if m:
return t[: m.end()].strip(), t[m.end():].strip()
return "", t
# --------------------------------------------------------------------------- #
# Outline-based anchoring: match each agenda item to the packet's PDF bookmark.
#
# Government agenda packets are compiled with a bookmark outline that mirrors the
# agenda β€” a bookmark per item (titled "<number> <name>") pointing at the page where
# that item's content begins, with the item's attachments/reports nested one level
# deeper. That is the packet author's own item→page map, so we prefer it over
# re-deriving page ranges from body text. We match an item to its bookmark on an
# alphanumeric-squeezed title prefix (robust to HTML tags, "<br>"-joined words, and
# outline-number/punctuation differences), scanning bookmarks in document order so
# repeated titles stay in sync. An item only anchors when its bookmark has a deeper
# child bookmark β€” i.e. the item actually carries supporting documents in the packet
# β€” which keeps procedural items (whose bookmark merely points at the agenda front
# matter) from claiming a section.
# --------------------------------------------------------------------------- #
_OUTLINE_MATCH_MIN_LEN = 6 # squeezed item title shorter than this is too generic
_OUTLINE_MATCH_PREFIX = 28 # compare this many squeezed leading chars
def _squeeze_title(title: str) -> str:
"""Lowercased alphanumeric-only form of a title (tags + enumerator stripped).
Collapsing to ``[a-z0-9]`` makes the item title and the packet bookmark title
line up despite HTML markup, ``<br>``-joined words, non-breaking/zero-width
spaces, and outline-number/punctuation differences."""
t = _TAG_RE.sub(" ", title or "")
t = _ENUM_RE.sub("", t.strip())
return re.sub(r"[^a-z0-9]+", "", t.lower())
def _outline_anchors(outline: list[dict], titles: list[str]) -> list[int | None]:
"""For each agenda item, the 0-indexed packet page from its bookmark (or None).
``outline`` is :func:`pdf_utils.extract_pdf_outline`'s flat bookmark list.
Anchors only content-bearing items (whose bookmark has a deeper child) and never
the front-matter page; everything else is ``None`` so the caller can fall back to
text anchoring.
"""
anchors: list[int | None] = [None] * len(titles)
if not outline:
return anchors
# A bookmark is content-bearing iff the next bookmark nests under it (its own
# attachment/report), so its page is where the item's documents actually start.
has_child = [
i + 1 < len(outline) and outline[i + 1]["level"] > outline[i]["level"]
for i in range(len(outline))
]
cand = [
(outline[i]["page"], _squeeze_title(outline[i]["title"]), has_child[i])
for i in range(len(outline))
if outline[i]["page"] is not None
]
pos = 0 # bookmarks are consumed in document order to disambiguate repeats
for i, title in enumerate(titles):
key = _squeeze_title(title)
if len(key) < _OUTLINE_MATCH_MIN_LEN:
continue
for j in range(pos, len(cand)):
page, bkey, child = cand[j]
if not bkey:
continue
if bkey.startswith(key[:_OUTLINE_MATCH_PREFIX]) or key.startswith(
bkey[:_OUTLINE_MATCH_PREFIX]
):
pos = j + 1
if child and page > 0:
anchors[i] = page
break
return anchors
def _slice_packet_for_item(
pages: list[str], titles: list[str], index: int,
outline: list[dict] | None = None,
) -> tuple[str, dict]:
"""Slice an agenda packet down to the selected item's section.
The packet is one compiled PDF. We locate each item's section by **two anchoring
strategies, outline first**:
1. *Outline* β€” match the item to its bookmark in the packet's PDF outline (the
packet author's own item→page map). Deterministic and exact when present.
2. *Text* β€” fallback when an item has no bookmark: anchor the item's title to a
packet *content* page by whitespace-normalized substring match, skipping
outline/TOC pages (which list several item titles at once).
The selected item's slice runs from its anchor page to the next later-anchored
item's page (text anchors are capped; outline anchors aren't, since they're
exact). When the item can't be confidently located by either strategy, we don't
guess β€” the caller falls back to the whole packet.
Returns ``(sliced_text, info)`` where ``info`` is ``{"sliced", "pages", "start",
"end", "method"}`` (``start``/``end`` are 0-indexed, end-exclusive page bounds, or
``None`` when not sliced; ``method`` is ``"outline"``/``"text"``/``""``).
``sliced_text`` is empty when no confident anchor was found.
"""
norm_pages = [_norm_ws(p) for p in pages]
keys = [_title_key(t) for t in titles]
probes = [k[:_ITEM_ANCHOR_KEY_LEN] for k in keys]
out_anchors = _outline_anchors(outline or [], titles)
def distinctive(k: str) -> bool:
return len(k) >= _ITEM_ANCHOR_MIN_LEN
def text_anchor(idx: int) -> int | None:
"""First content page whose text contains item ``idx``'s title and at most
one *other* item title (i.e. not an outline/TOC page)."""
if not (0 <= idx < len(keys)) or not distinctive(keys[idx]):
return None
probe = probes[idx]
for pi, npg in enumerate(norm_pages):
if probe not in npg:
continue
others = sum(1 for j, p in enumerate(probes)
if j != idx and distinctive(keys[j]) and p and p in npg)
if others < _ITEM_OUTLINE_OTHER_TITLES:
return pi
return None
def anchor(idx: int) -> int | None:
"""Item ``idx``'s packet start page β€” its bookmark if any, else its title."""
if 0 <= idx < len(out_anchors) and out_anchors[idx] is not None:
return out_anchors[idx]
return text_anchor(idx)
not_sliced = {"sliced": False, "pages": "", "start": None, "end": None,
"method": ""}
start = anchor(index)
if start is None:
return "", dict(not_sliced)
method = "outline" if (0 <= index < len(out_anchors)
and out_anchors[index] is not None) else "text"
later = [a for a in (anchor(j) for j in range(index + 1, len(keys)))
if a is not None and a > start]
if later:
end = min(later)
elif method == "outline":
end = len(pages) # exact map β€” run to the packet end
else:
end = min(len(pages), start + _ITEM_SLICE_MAX_PAGES)
text = "\n\n".join(p for p in pages[start:end] if p).strip()
if not text:
return "", dict(not_sliced)
return text, {"sliced": True, "pages": f"{start + 1}-{end}",
"start": start, "end": end, "method": method}
def _outline_slice_for_item(
outline: list[dict], titles: list[str], index: int, page_count: int,
) -> dict:
"""Outline-anchored slice info for one item β€” **without** per-page text.
The page viewer renders by page *index*, so when the packet's PDF outline anchors
the item we never need to extract the whole packet's text. We match the item to its
bookmark exactly as :func:`_outline_anchors` does (squeezed-title prefix, in
document order, only content-bearing bookmarks) so the viewer's start page agrees
with the report slicer β€” then bound the section at the **next bookmark of the same
or a higher level** (the item's next sibling or the next section), i.e. the
bookmark's natural extent. That is the item's true page span; we fall back to the
packet end only when no such bookmark follows. Returns the same ``info`` dict shape
(``method`` always ``"outline"``), or ``{"sliced": False, ...}`` when the item has
no content-bearing bookmark β€” the caller then falls back to the text-based slice.
"""
not_sliced = {"sliced": False, "pages": "", "start": None, "end": None,
"method": ""}
if page_count <= 0 or not outline or not (0 <= index < len(titles)):
return dict(not_sliced)
# Candidate bookmarks in document order, each carrying its level and whether it is
# content-bearing (has a deeper child = the item's own attachments/reports).
has_child = [
i + 1 < len(outline) and outline[i + 1]["level"] > outline[i]["level"]
for i in range(len(outline))
]
cand = [
(outline[i]["page"], outline[i]["level"], _squeeze_title(outline[i]["title"]),
has_child[i])
for i in range(len(outline))
if outline[i]["page"] is not None
]
pos = 0 # bookmarks consumed in document order to disambiguate repeated titles
for ti, title in enumerate(titles):
key = _squeeze_title(title)
if len(key) < _OUTLINE_MATCH_MIN_LEN:
if ti == index:
return dict(not_sliced)
continue
match_j = None
for j in range(pos, len(cand)):
page, level, bkey, child = cand[j]
if bkey and (bkey.startswith(key[:_OUTLINE_MATCH_PREFIX])
or key.startswith(bkey[:_OUTLINE_MATCH_PREFIX])):
match_j, pos = j, j + 1
break
if ti != index:
continue
if match_j is None:
return dict(not_sliced)
page, level, _, child = cand[match_j]
if not (child and page > 0):
return dict(not_sliced)
end = page_count
for k in range(match_j + 1, len(cand)):
npage, nlevel = cand[k][0], cand[k][1]
if nlevel <= level and npage > page:
end = npage
break
if end <= page:
return dict(not_sliced)
return {"sliced": True, "pages": f"{page + 1}-{end}",
"start": page, "end": end, "method": "outline"}
return dict(not_sliced)
# --------------------------------------------------------------------------- #
# Build the agenda item tree from a parsed packet β€” bookmarks first, text fallback.
# --------------------------------------------------------------------------- #
def _items_from_outline(outline: list[dict], page_count: int) -> list[AgendaItem]:
"""Build the agenda item tree from the packet's PDF bookmarks.
Surfaces level-0 (top-level items + section headers) and level-1 (sub-items)
bookmarks as agenda items; deeper bookmarks are an item's own attachments and are
not surfaced. An item is content-bearing (``has_pages``) iff its bookmark has a
deeper child AND points beyond the agenda front matter (page index > 0); its page
span runs to the next bookmark of the same-or-higher level (its next sibling /
section). 0-indexed ``start``/``end`` (end-exclusive) plus a 1-indexed ``pages``
string, matching the slicer.
"""
if not outline:
return []
has_child = [
i + 1 < len(outline) and outline[i + 1]["level"] > outline[i]["level"]
for i in range(len(outline))
]
items: list[AgendaItem] = []
n = 0
for i, bm in enumerate(outline):
level = int(bm.get("level", 0) or 0)
if level > 1: # attachments / reports β€” not surfaced as agenda items
continue
page = bm.get("page")
number, name = _split_enumerator(bm.get("title", ""))
if not name:
continue
content = bool(has_child[i] and page is not None and page > 0)
start = end = 0
pages_str = ""
if content:
sec_end = page_count
for k in range(i + 1, len(outline)):
npage, nlevel = outline[k].get("page"), int(outline[k].get("level", 0) or 0)
if npage is not None and nlevel <= level and npage > page:
sec_end = npage
break
if sec_end > page:
start, end, pages_str = page, sec_end, f"{page + 1}-{sec_end}"
else:
content = False
n += 1
items.append(AgendaItem(
id=f"outline:{n}",
number=number,
name=name,
summary="",
is_section=(level == 0 and not number),
level=level,
status="",
attachments=0,
has_pages=content,
pages=pages_str,
start=start,
end=end,
))
return items
def _items_from_text(pages: list[str], agenda_pages: str) -> list[AgendaItem]:
"""Text fallback when the packet has no bookmarks.
Parses the user-marked agenda page range into an item list, then text-anchors each
item into the later packet pages to fill ``start``/``end``/``has_pages``/``pages``.
"""
if not pages:
return []
a_start, a_end = _parse_page_range(agenda_pages, len(pages))
agenda_text = "\n\n".join(p for p in pages[a_start:a_end] if p).strip()
items = _parse_agenda_outline(agenda_text)
if not items:
return []
titles = [f"{it['number']} {it['name']}".strip() for it in items]
for idx, it in enumerate(items):
_, info = _slice_packet_for_item(pages, titles, idx, outline=[])
if info.get("sliced"):
it["has_pages"] = True
it["pages"] = info["pages"]
it["start"] = int(info["start"])
it["end"] = int(info["end"])
return items
def _parse_page_range(agenda_pages: str, page_count: int) -> tuple[int, int]:
"""Parse a 1-indexed inclusive page range (``"1-3"`` / ``"5"``) to a 0-indexed,
end-exclusive ``(start, end)`` slice, clamped to ``[0, page_count]``. Empty input
defaults to the first few pages (a typical agenda front section)."""
s = (agenda_pages or "").strip()
if not s:
return 0, min(page_count, 3) if page_count else 0
m = re.match(r"^\s*(\d+)\s*(?:[-–to]+\s*(\d+))?\s*$", s)
if not m:
return 0, min(page_count, 3) if page_count else 0
lo = max(1, int(m.group(1)))
hi = int(m.group(2)) if m.group(2) else lo
if hi < lo:
lo, hi = hi, lo
start = max(0, lo - 1)
end = hi if page_count <= 0 else min(page_count, hi)
if end <= start:
end = min(page_count or hi, start + 1)
return start, end
def parse_agenda_outline_from_packet(
upload_id: str, agenda_pages: str = ""
) -> AgendaOutline:
"""Parse an uploaded packet into agenda items β€” bookmarks first, text fallback.
Returns the item tree (each item carrying ``has_pages`` + 0-indexed
``start``/``end`` + a 1-indexed ``pages`` string) plus ``source``
(``outline`` | ``text`` | ``none``), a ``confidence`` hint, a message, and a
suggested agenda ``title``. Item names truncated by the PDF bookmarks are restored
deterministically from the agenda (table-of-contents) page text β€” no LLM.
"""
def result(items, source, confidence, message, title="") -> AgendaOutline:
return {"items": items, "source": source, "confidence": confidence,
"message": message, "model": "", "title": title}
if not upload_id:
return result([], "none", "empty", "Upload an agenda packet first.")
if not agenda_pages:
with _PKT_LOCK:
meta = _PKT_META.get(upload_id)
agenda_pages = (meta or {}).get("agenda_pages", "")
outline = cached_packet_outline(upload_id)
page_count = cached_packet_page_count(upload_id)
if page_count <= 0:
return result([], "none", "empty",
"The packet is no longer on the server β€” re-upload it.")
if outline:
items = _items_from_outline(outline, page_count)
if items:
# Restore truncated bookmark titles + a default agenda name from the TOC text.
agenda_text = _agenda_text_for(upload_id, agenda_pages, page_count)
if agenda_text:
_untruncate_item_names(items, agenda_text)
return result(items, "outline", "good",
"Parsed from the packet's PDF bookmarks.",
title=_agenda_title_from_text(agenda_text))
pages = cached_packet_pages(upload_id)
items = _items_from_text(pages, agenda_pages)
if not items:
return result([], "none", "empty",
"No agenda items could be parsed β€” set the agenda page range, "
"or add items/ranges manually.")
a_start, a_end = _parse_page_range(agenda_pages, len(pages))
agenda_text = "\n\n".join(p for p in pages[a_start:a_end] if p).strip()
n_numbered = sum(1 for it in items if it["number"] and not it["is_section"])
confidence = "good" if n_numbered >= 3 else "poor"
return result(items, "text", confidence,
"Parsed from the agenda text (no PDF bookmarks) β€” verify the page "
"ranges.", title=_agenda_title_from_text(agenda_text))
# --------------------------------------------------------------------------- #
# Local packet cache (keyed by upload_id).
#
# The uploaded Agenda Packet is one multi-MB PDF used by every feature: the page
# viewer renders pages from it, the item report reads its text, the agent searches
# it. We save it once to a local file at upload time and reuse it everywhere. Per-page
# text and the bookmark outline (the next-biggest costs) are extracted from the saved
# PDF once and memoized alongside. The cache is keyed by the server-minted upload_id.
# --------------------------------------------------------------------------- #
_PKT_DIR = Path(
os.getenv("PACKET_CACHE_DIR") or (Path(tempfile.gettempdir()) / "agenda_packets")
)
_PKT_META: "OrderedDict[str, dict]" = OrderedDict() # uid -> {path, name, agenda_pages}
_PKT_PAGES: "OrderedDict[str, list]" = OrderedDict() # uid -> [page text]
_PKT_OUTLINE: "OrderedDict[str, list]" = OrderedDict() # uid -> [bookmark]
_PKT_PAGECOUNT: "OrderedDict[str, int]" = OrderedDict() # uid -> page count
_PKT_CACHE_MAX = 24
# Gradio runs API handlers in a threadpool, so the caches above and the in-flight
# map below are shared across threads. One lock guards every mutation/read of them.
# The expensive work (text extraction) runs OUTSIDE the lock via the single-flight
# helper, so the lock is only ever held briefly.
_PKT_LOCK = threading.Lock()
# pypdfium2 (PDFium) is NOT thread-safe β€” it keeps global state, so two threads doing
# any PDFium work at once (rendering pages, counting pages, extracting text/outline)
# corrupt the native heap and abort the process ("corrupted double-linked list").
# Gradio runs API handlers in a threadpool, so EVERY PDFium call in this module must be
# serialized behind this one global lock.
_PDFIUM_LOCK = threading.Lock()
class _Flight:
"""One in-flight expensive op for a cache key β€” followers share its result."""
__slots__ = ("event", "result", "error")
def __init__(self) -> None:
self.event = threading.Event()
self.result = None
self.error: BaseException | None = None
_PKT_PAGES_FLIGHTS: "dict[str, _Flight]" = {} # text extractions
def _compute_once(flights: "dict[str, _Flight]", key: str, producer):
"""Run ``producer()`` once across threads for ``key``; share its result/error.
The first caller becomes the leader and runs ``producer()`` **without holding**
``_PKT_LOCK`` (it may parse for many seconds); concurrent callers become followers
that wait on the leader's event and return the same result (or re-raise the same
exception). This collapses a burst of rapid item clicks into a single extraction
instead of N duplicate, mutually-starving ones.
"""
with _PKT_LOCK:
flight = flights.get(key)
leader = flight is None
if leader:
flight = _Flight()
flights[key] = flight
if not leader:
flight.event.wait()
if flight.error is not None:
raise flight.error
return flight.result
try:
flight.result = producer()
except BaseException as e: # noqa: BLE001 - shared with followers, then re-raised
flight.error = e
finally:
with _PKT_LOCK:
flights.pop(key, None)
flight.event.set()
if flight.error is not None:
raise flight.error
return flight.result
def _evict_packet(uid: str) -> None:
"""Drop a packet's file + every derived cache entry. Caller holds ``_PKT_LOCK``."""
meta = _PKT_META.pop(uid, None)
_PKT_PAGES.pop(uid, None)
_PKT_OUTLINE.pop(uid, None)
_PKT_PAGECOUNT.pop(uid, None)
if meta:
try:
Path(meta["path"]).unlink()
except OSError:
pass
def upload_packet(
file_bytes: bytes, filename: str, agenda_pages: str = "", upload_id: str = ""
) -> dict:
"""Persist an uploaded agenda packet and parse its agenda outline.
Writes the PDF under ``_PKT_DIR`` keyed by a server-minted ``upload_id`` (or the
one passed in, for a re-upload β€” which overwrites in place and refreshes the parse),
registers the cache meta, evicting the least-recently-used packet past the cap.
Returns ``{upload_id, name, size, page_count, agenda_pages, items, source,
confidence, message, model}``.
"""
uid = upload_id or secrets.token_urlsafe(12)
_PKT_DIR.mkdir(parents=True, exist_ok=True)
safe = re.sub(r"[^A-Za-z0-9_-]", "_", uid)
path = _PKT_DIR / f"{safe}.pdf"
path.write_bytes(file_bytes)
name = (filename or "agenda-packet.pdf").strip()
if not name.lower().endswith(".pdf"):
name += ".pdf"
with _PKT_LOCK:
# A re-upload reuses the uid β€” clear any stale derived caches for it.
_PKT_PAGES.pop(uid, None)
_PKT_OUTLINE.pop(uid, None)
_PKT_PAGECOUNT.pop(uid, None)
_PKT_META[uid] = {"path": str(path), "name": name, "agenda_pages": agenda_pages}
_PKT_META.move_to_end(uid)
while len(_PKT_META) > _PKT_CACHE_MAX:
old_uid = next(iter(_PKT_META))
if old_uid == uid:
break
_evict_packet(old_uid)
page_count = cached_packet_page_count(uid)
outline_result = parse_agenda_outline_from_packet(uid, agenda_pages)
return {
"upload_id": uid,
"name": name,
"size": len(file_bytes),
"page_count": page_count,
"agenda_pages": agenda_pages,
**outline_result,
}
def cached_packet(upload_id: str) -> tuple[Path, str] | None:
"""Local path + filename of an uploaded packet, or ``None`` if unknown/evicted.
The packet is written at upload time, so this is a pure cache lookup. ``None``
means the upload is gone (tmp wiped on a Space restart, or LRU-evicted) β€” the
caller surfaces a "re-upload" prompt.
"""
if not upload_id:
return None
with _PKT_LOCK:
meta = _PKT_META.get(upload_id)
if meta is not None and Path(meta["path"]).exists():
_PKT_META.move_to_end(upload_id)
return Path(meta["path"]), meta["name"]
# Bundled samples are always re-derivable from disk: on a cache miss (Space restart,
# LRU eviction, or a saved sample from a prior session), rehydrate from samples/
# rather than 404. User uploads have no such source, so they still surface no_packet.
if upload_id.startswith(_SAMPLE_UID_PREFIX):
s = _SAMPLE_BY_ID.get(upload_id[len(_SAMPLE_UID_PREFIX):])
if s is not None and (_SAMPLES_DIR / s["file"]).exists():
return _rehydrate_sample(upload_id, s)
return None
def _rehydrate_sample(upload_id: str, s: dict) -> tuple[Path, str] | None:
"""Re-cache a bundled sample's PDF on disk + register its meta (no re-parse), so the
/packet route can serve it after the in-memory cache lost it."""
_PKT_DIR.mkdir(parents=True, exist_ok=True)
safe = re.sub(r"[^A-Za-z0-9_-]", "_", upload_id)
path = _PKT_DIR / f"{safe}.pdf"
try:
if not path.exists():
path.write_bytes((_SAMPLES_DIR / s["file"]).read_bytes())
except OSError:
return None
with _PKT_LOCK:
_PKT_META[upload_id] = {"path": str(path), "name": s["file"],
"agenda_pages": s["agenda_pages"]}
_PKT_META.move_to_end(upload_id)
return path, s["file"]
def _extract_pages_text(src: bytes | str) -> list[str]:
"""Per-page text via pypdfium2 (PDFium native).
~20-25x faster than pdfplumber's pdfminer backend on a 150-page packet (β‰ˆ0.4s
vs β‰ˆ9s), and the slicer's whitespace-normalized title anchoring lands on exactly
the same pages β€” we only need the page *text*, not pdfplumber's layout analysis.
Accepts PDF bytes or a path (renders from the locally-cached file).
"""
import pypdfium2 as pdfium
out: list[str] = []
with _PDFIUM_LOCK: # PDFium is not thread-safe β€” serialize all access
pdf = pdfium.PdfDocument(src)
try:
for i in range(len(pdf)):
textpage = pdf[i].get_textpage()
try:
out.append(textpage.get_text_range())
finally:
textpage.close()
finally:
pdf.close()
return out
def cached_packet_pages(upload_id: str) -> list[str]:
"""Per-page text of the cached packet PDF β€” extracted once and memoized.
Drives the slicer's text fallback (and the item report). Reads the locally-saved
PDF. Returns ``[]`` when the upload is unknown/evicted. The page viewer avoids this
whole-packet text pass when the PDF outline anchors the item (see
:func:`agenda_item_pages`). Concurrent callers share one extraction.
"""
if not upload_id:
return []
with _PKT_LOCK:
cached = _PKT_PAGES.get(upload_id)
if cached is not None:
_PKT_PAGES.move_to_end(upload_id)
return cached
cp = cached_packet(upload_id)
if cp is None:
return []
def _extract() -> list[str]:
with _PKT_LOCK:
cached = _PKT_PAGES.get(upload_id)
if cached is not None:
_PKT_PAGES.move_to_end(upload_id)
return cached
pages = _extract_pages_text(str(cp[0])) # CPU β€” no lock held
with _PKT_LOCK:
_PKT_PAGES[upload_id] = pages
_PKT_PAGES.move_to_end(upload_id)
while len(_PKT_PAGES) > _PKT_CACHE_MAX:
_PKT_PAGES.popitem(last=False)
return pages
return _compute_once(_PKT_PAGES_FLIGHTS, upload_id, _extract)
def cached_packet_page_count(upload_id: str) -> int:
"""Number of pages in the cached packet PDF β€” read once and memoized.
The outline-only slice (page viewer) needs just the page count for its end bound,
not the per-page text. Opens the locally-saved PDF (no text extraction). Returns 0
when the upload is unknown/evicted.
"""
if not upload_id:
return 0
with _PKT_LOCK:
cached = _PKT_PAGECOUNT.get(upload_id)
if cached is not None:
_PKT_PAGECOUNT.move_to_end(upload_id)
return cached
cp = cached_packet(upload_id)
if cp is None:
return 0
import pypdfium2 as pdfium
with _PDFIUM_LOCK: # PDFium is not thread-safe β€” serialize all access
pdf = pdfium.PdfDocument(str(cp[0]))
try:
count = len(pdf)
finally:
pdf.close()
with _PKT_LOCK:
_PKT_PAGECOUNT[upload_id] = count
_PKT_PAGECOUNT.move_to_end(upload_id)
while len(_PKT_PAGECOUNT) > _PKT_CACHE_MAX:
_PKT_PAGECOUNT.popitem(last=False)
return count
def cached_packet_outline(upload_id: str) -> list[dict]:
"""The cached packet PDF's bookmark outline β€” read once and memoized.
Feeds the bookmarks-first parse and the slicer's outline anchoring (see
:func:`_outline_anchors`). Reads the locally-saved PDF; returns ``[]`` when the
upload is unknown/evicted or the packet carries no outline.
"""
if not upload_id:
return []
with _PKT_LOCK:
cached = _PKT_OUTLINE.get(upload_id)
if cached is not None:
_PKT_OUTLINE.move_to_end(upload_id)
return cached
cp = cached_packet(upload_id)
if cp is None:
return []
try:
with _PDFIUM_LOCK: # extract_pdf_outline uses PDFium β€” serialize all access
outline = extract_pdf_outline(Path(cp[0]).read_bytes())
except Exception: # noqa: BLE001 - a packet with no/garbled outline just falls back
outline = []
with _PKT_LOCK:
_PKT_OUTLINE[upload_id] = outline
_PKT_OUTLINE.move_to_end(upload_id)
while len(_PKT_OUTLINE) > _PKT_CACHE_MAX:
_PKT_OUTLINE.popitem(last=False)
return outline
def _agenda_pages_for(upload_id: str) -> str:
with _PKT_LOCK:
meta = _PKT_META.get(upload_id)
return (meta or {}).get("agenda_pages", "")
# --------------------------------------------------------------------------- #
# Deterministic title untruncation β€” recover full item titles (and a default agenda
# name) by searching the agenda (table-of-contents) page text. PDF bookmark titles are
# commonly truncated (~50 chars) and may drop a department prefix; the agenda text holds
# each item's full title, so we find the truncated substring there and lift the whole
# enclosing title block. No LLM.
# --------------------------------------------------------------------------- #
_UNTRUNC_MIN_KEY = 24 # squeezed bookmark shorter than this isn't worth searching
_UNTRUNC_MAX_LEN = 320 # never adopt a runaway "title" longer than this
# A line that ends an item's title block (its metadata / a page footer / the AGENDA
# banner). Continuation lines (wrapped title text) match none of these.
_TITLE_META_RE = re.compile(
r"(?i)^\s*(item category|presenter|action required|action|fiscal impact|department|"
r"sponsor|recommended action|recommendation|background|summary|whereas|now therefore)\b"
)
_PAGE_FOOTER_RE = re.compile(r"(?i)(page \d+ of \d+|agenda page \d+)")
# Lenient enumerator at the START of a line ("11.", "a)", "iv.", or even "1 " / "2 ").
_ENUM_LINE_RE = re.compile(r"^\s*(?:\d{1,3}|[a-zA-Z]|[ivxlcdmIVXLCDM]{1,6})[.):]?\s+\S")
# Lenient enumerator to strip from a recovered full title (allows the bare "1 " form).
_ENUM_LEAD_RE = re.compile(r"^\s*(?:\d{1,3}|[a-z]|[ivxlcdm]{1,6})[.):]?\s+", re.IGNORECASE)
def _is_title_boundary(line: str) -> bool:
s = line.strip()
if not s:
return True
return bool(_TITLE_META_RE.match(s) or _PAGE_FOOTER_RE.search(s)
or _OUTLINE_START_RE.match(s))
def _title_block(lines: list[str], hit: int) -> str:
"""The full title block of agenda ``lines`` that the matched line ``hit`` belongs to.
Walks up to the block's enumerator line (or a boundary above) and down across wrapped
continuation lines, stopping at the next enumerator / a metadata line / a page footer.
"""
top = hit
while top > 0:
if _ENUM_LINE_RE.match(lines[top]):
break
if _is_title_boundary(lines[top - 1]):
break
top -= 1
bot = hit
while bot + 1 < len(lines):
nxt = lines[bot + 1]
if _is_title_boundary(nxt) or _ENUM_LINE_RE.match(nxt):
break
bot += 1
return " ".join(ln.strip() for ln in lines[top:bot + 1] if ln.strip())
def _untruncate_item_names(items: list[dict], agenda_text: str) -> int:
"""Replace truncated bookmark item names with their full title from the agenda text.
For each item, find its (enumerator-stripped, alphanumeric-squeezed) bookmark title as
a substring of the squeezed agenda text β€” in document order to disambiguate repeats β€”
then lift the enclosing title block. Adopts it only when it's genuinely longer. Returns
the number of names improved.
"""
lines = agenda_text.splitlines()
squeezed_chars: list[str] = []
line_of: list[int] = [] # squeezed char index -> agenda line index
for li, ln in enumerate(lines):
for ch in ln:
if ch.isalnum():
squeezed_chars.append(ch.lower())
line_of.append(li)
squeezed = "".join(squeezed_chars)
if not squeezed:
return 0
cursor = 0
changed = 0
for it in items:
name = it.get("name", "")
bkey = _squeeze_title(name)
if len(bkey) < _UNTRUNC_MIN_KEY:
continue
q = squeezed.find(bkey, cursor)
if q == -1: # fall back to a global search (order drift)
q = squeezed.find(bkey)
if q == -1:
continue
cursor = q + len(bkey)
full = _title_block(lines, line_of[q])
full = _ENUM_LEAD_RE.sub("", full).strip()
if full and len(name) < len(full) <= _UNTRUNC_MAX_LEN:
it["name"] = full
changed += 1
return changed
# Header lines (before the AGENDA banner) that name the meeting + its date.
_AGENDA_DATE_RE = re.compile(
r"(?i)(?:(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*,?\s+)?"
r"(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}"
)
_AGENDA_BODY_RE = re.compile(
r"(?i)\b(board|council|commission|committee|authority|district|trustees|supervisors|"
r"township|village|city|county|district|department|agency)\b"
)
def _agenda_title_from_text(agenda_text: str) -> str:
"""A concise default agenda name (governing body + date) from the header lines.
Deterministic, best-effort: scans the lines before the AGENDA banner for a governing-
body line and a date. Returns ``""`` when nothing usable is found (caller falls back
to the file name)."""
lines = [re.sub(r"\s+", " ", ln).strip() for ln in agenda_text.splitlines()]
lines = [ln for ln in lines if ln]
end = len(lines)
for i, ln in enumerate(lines):
if _OUTLINE_START_RE.match(ln):
end = i
break
header = lines[:end] or lines[:15]
date = ""
for ln in header:
m = _AGENDA_DATE_RE.search(ln)
if m:
date = m.group(0).strip(" ,")
break
body = ""
for ln in header:
if _AGENDA_DATE_RE.search(ln) or any(c.isdigit() for c in ln):
continue
if 4 <= len(ln) <= 70 and _AGENDA_BODY_RE.search(ln):
body = ln.title() if ln.isupper() else ln
break
return " β€” ".join(p for p in (body, date) if p)[:90]
def _agenda_text_for(upload_id: str, agenda_pages: str, page_count: int) -> str:
"""Text of just the marked agenda (table-of-contents) pages β€” a targeted PDFium read
so big packets don't pay a full text extraction at parse time."""
cp = cached_packet(upload_id)
if cp is None or page_count <= 0:
return ""
a_start, a_end = _parse_page_range(agenda_pages, page_count)
import pypdfium2 as pdfium
out: list[str] = []
with _PDFIUM_LOCK: # PDFium is not thread-safe β€” serialize all access
pdf = pdfium.PdfDocument(str(cp[0]))
try:
for i in range(max(0, a_start), min(len(pdf), a_end)):
textpage = pdf[i].get_textpage()
try:
out.append(textpage.get_text_range())
finally:
textpage.close()
finally:
pdf.close()
return "\n\n".join(p for p in out if p).strip()
def _packet_documents(upload_id: str) -> list[dict]:
"""Chroma documents for the whole uploaded packet (one doc; chunking splits it).
Replaces the old per-event document loader. Used by the report, the agent's
semantic search, and the summarize/report tools.
"""
cp = cached_packet(upload_id)
if cp is None:
return []
name = cp[1]
pages = cached_packet_pages(upload_id)
text = "\n\n".join(p for p in pages if p).strip()
if not text:
return []
return [{
"doc_id": f"{upload_id}:packet",
"text": text,
"metadata": {"source": "packet", "upload_id": upload_id, "file_name": name},
}]
# --------------------------------------------------------------------------- #
# Summarize (chunk the agenda portion -> Gemma-4)
# --------------------------------------------------------------------------- #
def summarize_agenda(upload_id: str, model: str = "") -> Iterator[dict]:
"""Stream a summary of the uploaded agenda's table-of-contents portion.
Summarizes the text of the page range the user marked as the agenda (not the whole
multi-hundred-page packet). Yields progress dicts ``{"stage", "message", "summary",
"chunks", "model"}`` where ``stage`` is ``working`` | ``done`` | ``error``.
Args:
upload_id: the uploaded packet handle.
model: which GGUF to run (local backend) β€” ``"e4b"`` (default) or ``"26b"``.
"""
def msg(stage: str, message: str = "", summary: str = "",
chunks: int = 0, model: str = "") -> dict:
return {"stage": stage, "message": message, "summary": summary,
"chunks": chunks, "model": model}
if not upload_id:
yield msg("error", "Upload an agenda packet first.")
return
yield msg(
"working",
"Reading the agenda and summarizing. The model may cold-start on the first "
"call β€” this can take a minute.",
)
pages = cached_packet_pages(upload_id)
if not pages:
yield msg("error", "The packet is no longer on the server β€” re-upload it.")
return
a_start, a_end = _parse_page_range(_agenda_pages_for(upload_id), len(pages))
text = "\n\n".join(p for p in pages[a_start:a_end] if p).strip()
if not text:
yield msg(
"error",
"The agenda pages have no extractable text β€” they may be scanned / "
"image-only. Try the Report tab, which reads the whole packet.",
)
return
n_chunks = len(chunk_text(text))
try:
if LLM_BACKEND == "local":
from webapp import local_llm
summary = local_llm.gpu_summarize(text, model)
else:
summary = summarize_text(text, complete=_complete)
except Exception as e: # noqa: BLE001
yield msg("error", f"Summarization failed: {type(e).__name__}: {e}")
return
yield msg(
"done",
f"Summarized the agenda pages {a_start + 1}-{a_end} ({n_chunks} block(s)).",
summary=summary or "_(empty summary)_",
chunks=n_chunks,
model=_model_label(model),
)
# --------------------------------------------------------------------------- #
# Report streaming (shared by the whole-packet and per-item reports)
# --------------------------------------------------------------------------- #
def _report_msg(stage: str, message: str = "", report: str = "",
frac: float = 0.0, docs: int = 0, model: str = "",
code: str = "") -> dict:
return {"stage": stage, "message": message, "report": report,
"frac": frac, "docs": docs, "model": model, "code": code}
def _stream_report(docs: list[dict], question: str, max_sections: int,
built_from: str, model: str = "") -> Iterator[dict]:
"""Run a query-framed report over pre-fetched ``docs`` and stream progress.
Bridges :func:`chroma.report.generate_report` (remote) or
:func:`webapp.local_llm.gpu_report` (in-process ZeroGPU) to ``working`` /
``done`` / ``error`` frames. ``built_from`` is surfaced in the done frame;
``model`` selects the GGUF (local backend).
"""
import queue
import threading
def done_frame(report: str) -> dict:
return _report_msg(
"done", built_from, report=report or "_(empty report)_",
frac=1.0, docs=len(docs), model=_model_label(model),
)
# Local backend: stream straight from the in-process GPU generator.
if LLM_BACKEND == "local":
from webapp import local_llm
try:
for frame in local_llm.gpu_report(docs, question, max_sections, model):
if "error" in frame:
yield _report_msg("error", f"Report generation failed: {frame['error']}")
return
if frame.get("done"):
yield done_frame(frame.get("report", ""))
return
yield _report_msg("working", frame.get("message", "Working…"),
frac=float(frame.get("frac", 0.0)))
except Exception as e: # noqa: BLE001
yield _report_msg("error", f"Report generation failed: {type(e).__name__}: {e}")
return
# Remote backend: bridge generate_report's progress callback to this generator.
q: "queue.Queue[tuple]" = queue.Queue()
def cb(frac: float, message: str) -> None:
q.put(("progress", frac, message))
def worker() -> None:
try:
report = generate_report(
docs, question, complete=_complete,
max_chunks=int(max_sections), progress=cb,
)
q.put(("done", report))
except Exception as e: # noqa: BLE001
q.put(("error", f"{type(e).__name__}: {e}"))
threading.Thread(target=worker, daemon=True).start()
while True:
kind, *rest = q.get()
if kind == "progress":
frac, message = rest
yield _report_msg("working", message, frac=float(frac))
elif kind == "done":
yield done_frame(rest[0])
return
else: # error
yield _report_msg("error", f"Report generation failed: {rest[0]}")
return
def _stream_item_answer(docs: list[dict], question: str, max_sections: int,
built_from: str, engine: str = "auto",
model: str = "") -> Iterator[dict]:
"""Answer one agenda item in a single pass and stream progress.
Bridges :func:`webapp.local_llm.gpu_answer` (in-process ZeroGPU, token-streamed)
or :func:`chroma.answer.answer_question` (remote) to ``working`` / ``done`` /
``error`` frames. ``engine`` is the user's choice β€” ``"single"`` (semantic search /
one-shot answer) or ``"mapreduce"`` (read every section) β€” defaulting to ``"auto"``
(size-based heuristic). ``model`` selects the GGUF (local backend only). ``working``
frames carry the partial ``report`` so the UI renders the answer as it streams.
"""
import queue
import threading
def done_frame(report: str) -> dict:
return _report_msg(
"done", built_from, report=report or "_(empty answer)_",
frac=1.0, docs=len(docs), model=_model_label(model),
)
# Local backend: stream tokens straight from the in-process GPU generator.
if LLM_BACKEND == "local":
from webapp import local_llm
try:
for frame in local_llm.gpu_answer(docs, question, max_sections, engine, model):
if "error" in frame:
yield _report_msg("error", f"Answer generation failed: {frame['error']}")
return
if frame.get("done"):
yield done_frame(frame.get("report", ""))
return
yield _report_msg("working", frame.get("message", "Working…"),
report=frame.get("report", ""),
frac=float(frame.get("frac", 0.0)))
except Exception as e: # noqa: BLE001
yield _report_msg("error", f"Answer generation failed: {type(e).__name__}: {e}")
return
# Remote backend: single-pass answer (or map-reduce fallback) in a worker thread.
from chroma.answer import answer_question, budget_for, resolve_engine
char_budget, max_tokens = budget_for(max_sections)
q: "queue.Queue[tuple]" = queue.Queue()
def cb(frac: float, message: str) -> None:
q.put(("progress", frac, message))
def worker() -> None:
try:
if resolve_engine(docs, engine, char_budget=char_budget) == "mapreduce":
report = generate_report(docs, question, complete=_complete,
max_chunks=int(max_sections), progress=cb)
else:
report = answer_question(docs, question, complete=_complete,
char_budget=char_budget, max_tokens=max_tokens,
progress=cb)
q.put(("done", report))
except Exception as e: # noqa: BLE001
q.put(("error", f"{type(e).__name__}: {e}"))
threading.Thread(target=worker, daemon=True).start()
while True:
kind, *rest = q.get()
if kind == "progress":
frac, message = rest
yield _report_msg("working", message, frac=float(frac))
elif kind == "done":
yield done_frame(rest[0])
return
else: # error
yield _report_msg("error", f"Answer generation failed: {rest[0]}")
return
# --------------------------------------------------------------------------- #
# Agenda Report (query-framed over the whole uploaded packet)
# --------------------------------------------------------------------------- #
def generate_agenda_report(
upload_id: str,
question: str,
max_sections: int = 60,
model: str = "",
) -> Iterator[dict]:
"""Stream a query-framed markdown report over the whole uploaded packet.
Chunks the packet into an ephemeral in-memory ChromaDB collection, mines every
chunk for facts relevant to ``question`` (map) and writes the findings up as
markdown (reduce) β€” see :func:`chroma.report.generate_report`.
Yields progress dicts ``{"stage", "frac", "message", "report", "docs", "model",
"code"}`` where ``stage`` is ``working`` | ``done`` | ``error``.
Args:
upload_id: the uploaded packet handle.
question: the free-form question framing the report.
max_sections: cap on how many chunks are mined (higher = more thorough, slower).
model: which GGUF to run (local backend) β€” ``"e4b"`` (default) or ``"26b"``.
"""
question = (question or "").strip()
if not upload_id:
yield _report_msg("error", "Upload an agenda packet, then ask a question.")
return
if not question:
yield _report_msg("error", "Enter a question to frame the report.")
return
yield _report_msg("working", "Reading the agenda packet…", frac=0.02)
docs = _packet_documents(upload_id)
if not docs:
yield _report_msg(
"error", "The packet is no longer on the server β€” re-upload it.",
code="no_packet",
)
return
built_from = (f"Built from the agenda packet, chunked into an ephemeral ChromaDB "
"collection.")
yield from _stream_report(docs, question, max_sections, built_from, model)
# --------------------------------------------------------------------------- #
# Agenda Item Report (slice the packet to one item's section, report over it)
# --------------------------------------------------------------------------- #
def _item_documents(
upload_id: str, item_titles: list[str], item_index: int,
start: int = 0, end: int = 0,
) -> tuple[list[dict], dict]:
"""Build report documents scoped to one agenda item.
Reads the packet from the local cache and scopes it to the item's pages. When an
explicit page range is given (``end > start``, the user-adjusted range from the
client), that range is used directly (clamped); otherwise the item is located by
anchoring (outline first, text fallback β€” see :func:`_slice_packet_for_item`). The
item's own agenda line is always included as a focusing document. Returns
``(documents, info)`` with ``info`` = ``{"sliced", "pages", "note"}``.
"""
cp = cached_packet(upload_id)
packet_name = cp[1] if cp is not None else "Agenda Packet"
pages = cached_packet_pages(upload_id) if cp is not None else []
outline = cached_packet_outline(upload_id) if cp is not None else []
title = item_titles[item_index] if 0 <= item_index < len(item_titles) else ""
base_meta = {"source": "packet", "upload_id": str(upload_id)}
docs: list[dict] = []
if title:
docs.append({
"doc_id": f"{upload_id}:item",
"text": f"Selected agenda item: {title}",
"metadata": {**base_meta, "file_name": "Selected agenda item"},
})
if not pages:
return docs, {"sliced": False, "pages": "", "note": "",
"code": "no_packet"}
# Explicit (possibly user-adjusted) range wins β€” clamp to the packet.
s = max(0, int(start or 0))
e = min(len(pages), int(end or 0))
if e > s:
sliced_text = "\n\n".join(p for p in pages[s:e] if p).strip()
if sliced_text:
info = {"sliced": True, "pages": f"{s + 1}-{e}", "start": s, "end": e,
"method": "explicit"}
docs.append({
"doc_id": f"{upload_id}:packet-slice",
"text": sliced_text,
"metadata": {**base_meta, "file_name": packet_name, "pages": info["pages"]},
})
info["note"] = (f"Reporting on pages {info['pages']} of {len(pages)} "
"(the item's backup pages).")
return docs, info
sliced_text, info = _slice_packet_for_item(
pages, item_titles, item_index, outline=outline)
if info["sliced"]:
docs.append({
"doc_id": f"{upload_id}:packet-slice",
"text": sliced_text,
"metadata": {**base_meta, "file_name": packet_name, "pages": info["pages"]},
})
how = ("from the packet's bookmarks" if info.get("method") == "outline"
else "by matching the item's title in the packet")
info["note"] = (f"Sliced the agenda packet to this item's section "
f"(pages {info['pages']} of {len(pages)}, {how}).")
else:
docs.append({
"doc_id": f"{upload_id}:packet",
"text": "\n\n".join(p for p in pages if p),
"metadata": {**base_meta, "file_name": packet_name},
})
info["note"] = ("Couldn't locate this item's pages in the packet, so this "
"report covers the full packet, framed by the item. Set the "
"item's page range to scope it.")
return docs, info
def generate_agenda_item_report(
upload_id: str,
item_titles: list[str],
item_index: int,
question: str,
start: int = 0,
end: int = 0,
max_sections: int = 60,
engine: str = "auto",
model: str = "",
) -> Iterator[dict]:
"""Stream a query-framed report scoped to a single agenda item.
Scopes the packet to the item's backup pages β€” using the explicit (user-adjusted)
``start``/``end`` range when given, else anchoring by bookmark/title (see
:func:`_item_documents`) β€” and answers the question over that slice in a single
streamed pass. When the pages can't be located the answer falls back to the full
packet, with a note saying so.
Args:
upload_id: the uploaded packet handle.
item_titles: every agenda item's ``"<number> <name>"`` line, in agenda order β€”
used to anchor the item when no explicit range is given.
item_index: index into ``item_titles`` of the item to report on.
question: the free-form question framing the answer.
start: 0-indexed start page of the item's slice (inclusive), 0 to auto-locate.
end: 0-indexed end page of the item's slice (exclusive), 0 to auto-locate.
max_sections: thoroughness knob β€” packet context fed + answer length.
engine: ``"single"`` (semantic search) | ``"mapreduce"`` (read every section) |
``"auto"`` (by slice size).
model: which GGUF to run (local backend) β€” ``"e4b"`` (default) or ``"26b"``.
"""
question = (question or "").strip()
if not upload_id:
yield _report_msg("error", "Upload an agenda packet, then pick an item.")
return
if not item_titles or not (0 <= item_index < len(item_titles)):
yield _report_msg("error", "Pick an agenda item to report on.")
return
if not question:
yield _report_msg("error", "Enter a question to frame the report.")
return
yield _report_msg("working", "Reading the agenda packet and locating this item's "
"section…", frac=0.02)
docs, info = _item_documents(upload_id, item_titles, item_index, start, end)
if info.get("code") == "no_packet":
yield _report_msg(
"error", "The packet is no longer on the server β€” re-upload it.",
code="no_packet",
)
return
if not any((d.get("text") or "").strip() for d in docs):
yield _report_msg("error", "No agenda text was available for this item β€” the "
"packet may be empty or image-only.")
return
note = info.get("note", "")
built_from = f"{note} Answered by the model below."
for frame in _stream_item_answer(docs, question, max_sections, built_from, engine, model):
if note and frame["stage"] in ("working", "done") and frame.get("report"):
frame["report"] = f"> _{note}_\n\n{frame['report']}"
yield frame
# --------------------------------------------------------------------------- #
# Agenda item packet pages (render the item's PDF pages to images).
# --------------------------------------------------------------------------- #
_ITEM_PAGE_RENDER_SCALE = 1.6 # ~115 dpi β€” legible, keeps the JPEG payload small
def _render_pages_b64_iter(src: bytes | str, start: int, end: int) -> Iterator[ItemPage]:
"""Rasterize packet pages ``[start, end)`` to base64 JPEG data URLs, one at a time.
Yields each page's :class:`ItemPage` as soon as it rasterizes (the PDF is opened
once). ``src`` is the PDF bytes or a path to the locally-cached packet (pypdfium2
accepts either).
"""
import base64
import io
import pypdfium2 as pdfium
pdf = pdfium.PdfDocument(src)
try:
for i in range(start, end):
pil = pdf[i].render(scale=_ITEM_PAGE_RENDER_SCALE).to_pil().convert("RGB")
buf = io.BytesIO()
pil.save(buf, format="JPEG", quality=80)
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
yield ItemPage(page=i + 1, image=f"data:image/jpeg;base64,{b64}")
finally:
pdf.close()
def agenda_item_pages(
upload_id: str,
item_titles: list[str],
item_index: int,
start: int = 0,
end: int = 0,
max_pages: int = 0,
) -> Iterator[ItemPages]:
"""Stream the packet PDF pages that back a single agenda item.
Uses the explicit (user-adjusted) ``start``/``end`` range when given; otherwise
locates the item with the same confidence-gated slicer as
:func:`generate_agenda_item_report` (outline first, text fallback). Rasterizes the
page range to base64 JPEG data URLs the frontend drops straight into ``<img>``
tags. When the item's pages can't be located, ``sliced`` is ``False`` and
``images`` is empty (with ``note`` explaining why).
Yields ``ItemPages`` frames with ``stage`` ``working`` β†’ ``done`` | ``error``. It
streams so the browser keeps the connection alive while pages render.
Args:
upload_id: the uploaded packet handle.
item_titles: every agenda item's ``"<number> <name>"`` line, in agenda order.
item_index: index into ``item_titles`` of the item to show pages for.
start: 0-indexed start page (inclusive), 0 to auto-locate.
end: 0-indexed end page (exclusive), 0 to auto-locate.
max_pages: optional cap on how many pages to rasterize; ``0`` = the whole slice.
"""
def frame(stage: str, note: str = "", sliced: bool = False,
pages: str = "", images: list[ItemPage] | None = None,
code: str = "") -> ItemPages:
return {"stage": stage, "sliced": sliced, "pages": pages,
"note": note, "code": code, "images": images or []}
if not upload_id or not (0 <= item_index < len(item_titles or [])):
yield frame("error", "Pick an agenda item.")
return
limit = int(max_pages) if max_pages and int(max_pages) > 0 else 0
cp = cached_packet(upload_id)
if cp is None:
yield frame("error", "The packet is no longer on the server β€” re-upload it.",
code="no_packet")
return
yield frame("working", "Locating this item's pages in the packet…")
try:
page_count = cached_packet_page_count(upload_id)
n_pages = page_count
# Explicit (user-adjusted) range wins.
s = max(0, int(start or 0))
e = min(page_count, int(end or 0))
if e > s:
info = {"sliced": True, "pages": f"{s + 1}-{e}", "start": s, "end": e,
"method": "explicit"}
else:
# Fast path: outline anchors the item from bookmarks + page count alone.
outline = cached_packet_outline(upload_id)
info = _outline_slice_for_item(outline, item_titles, item_index, page_count)
if not info["sliced"]:
pages = cached_packet_pages(upload_id)
if not pages:
yield frame("error", "The packet is no longer on the server β€” "
"re-upload it.", code="no_packet")
return
n_pages = len(pages)
_, info = _slice_packet_for_item(
pages, item_titles, item_index, outline=outline
)
except Exception as e: # noqa: BLE001
yield frame("error", f"Couldn't load the agenda packet: {type(e).__name__}: {e}")
return
if not info["sliced"]:
yield frame(
"done", sliced=False,
note=(f"No matching pages were found for this item in the {n_pages}-page "
"agenda packet. Set the item's page range, or open the full packet "
"PDF below. (The ask box still searches the whole packet.)"),
)
return
packet_path = cp[0]
pstart, pend = info["start"], info["end"]
total = pend - pstart
n = total if limit <= 0 else min(total, limit)
render_end = pstart + n
located = {"outline": "via the packet's PDF bookmarks",
"explicit": "from the item's page range",
"text": "by matching the item title in the packet text"}.get(
info.get("method", ""), "in the packet")
note = f"Packet pages {info['pages']} β€” {total} page(s) for this item, located {located}"
note += "." if n >= total else f"; showing the first {n}."
yield frame("working", note=f"Rendering {n} packet page(s) {info['pages']}…",
sliced=True, pages=info["pages"])
# Rasterize the whole slice under the PDFium lock (PDFium is not thread-safe; the
# lock can't be held across a yield, so render to memory, release, then return).
try:
with _PDFIUM_LOCK:
rendered = list(_render_pages_b64_iter(str(packet_path), pstart, render_end))
except Exception as e: # noqa: BLE001
yield frame("error", f"Couldn't render the packet pages: {type(e).__name__}: {e}")
return
yield frame("done", sliced=True, pages=info["pages"], note=note, images=rendered)
# --------------------------------------------------------------------------- #
# Agent Mode (a ReAct loop drives the packet tools β€” see webapp/agent_loop.py
# and webapp/agent_tools.py). The model picks tools, reads results, and answers;
# we stream its thinking / tool calls / results / final answer to the React panel.
# --------------------------------------------------------------------------- #
def _agent_frame(fr: dict, *, model: str = "") -> AgentFrame:
"""Normalize a loop frame to the stable :class:`AgentFrame` shape."""
stage = fr.get("stage", "")
return {
"stage": stage,
"text": str(fr.get("text", "")),
"tool": str(fr.get("tool", "")),
"args": fr.get("args") or {},
"result": str(fr.get("result", "")),
"summary": str(fr.get("summary", "")),
"step": int(fr.get("step", 0) or 0),
"model": model if stage in ("answer", "error") else "",
}
def agent_chat(
upload_id: str,
messages: list[AgentMessage],
model: str = "",
) -> Iterator[AgentFrame]:
"""Stream one turn of Agent Mode over the uploaded agenda packet.
A ReAct loop lets the model call the packet tools (:mod:`webapp.agent_tools`) β€”
listing agenda items, reading an item's pages, semantic-searching the packet, and
(heavier) summarizing or reporting β€” then answer the user's latest message.
On the ZeroGPU Space (``LLM_BACKEND=local``) the **entire turn** runs inside one
``@spaces.GPU`` window (the GGUF is loaded once and reused for every reasoning step
and any LLM-backed tool); otherwise it runs against the remote vLLM endpoint.
Args:
upload_id: the uploaded packet handle the tools operate on.
messages: the conversation so far, each ``{"role", "content"}``, ending with
the user's current message.
model: which in-process GGUF to use on the local backend (``"e4b"`` / ``"26b"``);
ignored by the remote backend, which uses its configured model.
Yields :class:`AgentFrame` steps: ``thinking`` β†’ ``tool_call`` β†’ ``tool_result``
(repeated) β†’ ``answer``, or ``error``.
"""
if not upload_id:
yield _agent_frame({"stage": "error", "text": "Upload an agenda packet first."})
return
if not messages or not (messages[-1].get("content") or "").strip():
yield _agent_frame({"stage": "error", "text": "Ask a question to begin."})
return
model_lbl = _model_label(model)
try:
if LLM_BACKEND == "local":
# Whole loop runs in one GPU window (model loaded there, reused per step).
from webapp import local_llm
frames = local_llm.gpu_agent_turn(upload_id, list(messages), model)
else:
from webapp.agent_loop import agent_turn
from webapp.agent_tools import ToolContext
ctx = ToolContext(upload_id=upload_id, complete=_complete)
frames = agent_turn(list(messages), ctx, _complete)
for fr in frames:
yield _agent_frame(fr, model=model_lbl)
except Exception as e: # noqa: BLE001
yield _agent_frame(
{"stage": "error", "text": f"Agent failed: {type(e).__name__}: {e}"},
model=model_lbl,
)
def lii_agent_chat(
messages: list[AgentMessage],
model: str = "",
) -> Iterator[AgentFrame]:
"""Stream one turn of the Cornell LII legal-research agent.
A ReAct loop over the legal tools (:mod:`webapp.lii_tools`) β€” searching the federal
eCFR, resolving and verifying CFR/USC citations, and linking state materials on
Cornell LII β€” then answering the user's latest message. Unlike :func:`agent_chat`
this is **packet-independent** (no ``upload_id``); it researches public US law and
its tools make live calls to the eCFR API / ``law.cornell.edu``.
On the ZeroGPU Space (``LLM_BACKEND=local``) the whole turn runs in one
``@spaces.GPU`` window; otherwise it runs against the remote vLLM endpoint.
Args:
messages: the conversation so far, each ``{"role", "content"}``, ending with the
user's current message.
model: which in-process GGUF to use on the local backend; ignored remotely.
Yields :class:`AgentFrame` steps: ``thinking`` β†’ ``tool_call`` β†’ ``tool_result``
(repeated) β†’ ``answer``, or ``error``.
"""
if not messages or not (messages[-1].get("content") or "").strip():
yield _agent_frame({"stage": "error", "text": "Ask a question to begin."})
return
model_lbl = _model_label(model)
try:
if LLM_BACKEND == "local":
from webapp import local_llm
frames = local_llm.gpu_lii_agent_turn(list(messages), model)
else:
from webapp.agent_loop import agent_turn
from webapp.lii_tools import TOOLKIT, LiiContext
ctx = LiiContext()
frames = agent_turn(list(messages), ctx, _complete, toolkit=TOOLKIT)
for fr in frames:
yield _agent_frame(fr, model=model_lbl)
except Exception as e: # noqa: BLE001
yield _agent_frame(
{"stage": "error", "text": f"Agent failed: {type(e).__name__}: {e}"},
model=model_lbl,
)