Spaces:
Running on Zero
Running on Zero
| """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, | |
| ) | |