Spaces:
Running on Zero
Running on Zero
| """Answer a question about ONE agenda item in a single LLM call. | |
| Where :mod:`chroma.report` runs a map-reduce over *every* chunk (the right tool for | |
| summarizing a whole meeting), this module is built for the much narrower "ask the | |
| packet" case: the packet has already been sliced down to one item's section, so the | |
| relevant text is small. We assemble the most relevant context once and answer it in a | |
| **single** completion -- far faster than N chunk calls, and more coherent because the | |
| model sees the context whole instead of pre-digested bullet notes. | |
| Scope is still chosen *structurally* upstream (the item->pages slice); the optional | |
| relevance retrieval here only ranks chunks *within* that slice when it is too big to | |
| feed wholesale -- it never decides which item the user is asking about. | |
| The LLM call is injectable via ``complete`` (defaults to | |
| :func:`chroma.llm.chat_complete`) so callers can swap in a fake (or a llama.cpp | |
| streaming completer) for testing. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from typing import Callable | |
| from .store import AgendaStore | |
| Completer = Callable[..., str] | |
| # How much packet text (characters) to feed the model in one shot. Sized to leave | |
| # room under the local GGUF's context window (LLAMA_N_CTX=8192 tokens) for the | |
| # system/user prompt wrappers and the answer itself. | |
| DEFAULT_CHAR_BUDGET = int(os.getenv("ANSWER_CHAR_BUDGET", "16000")) | |
| # How many chunks to pull from the relevance query before packing to the budget. | |
| DEFAULT_N_RESULTS = int(os.getenv("ANSWER_N_RESULTS", "16")) | |
| # Ceiling on a single-pass item answer's length (the thoroughness slider scales up to | |
| # this). Raise ANSWER_MAX_TOKENS for longer answers — pair it with a bigger LLAMA_N_CTX | |
| # and GPU_DURATION_ANSWER so the generation fits the context + ZeroGPU window. | |
| ANSWER_MAX_TOKENS = int(os.getenv("ANSWER_MAX_TOKENS", "1536")) | |
| ANSWER_SYSTEM = ( | |
| "You are a civic-records analyst answering a resident's question about ONE item on " | |
| "a public meeting's agenda, using ONLY the packet text you are given. " | |
| "Write the answer in clean GitHub-flavored markdown:\n" | |
| "- Open with a one- or two-sentence direct answer in **bold**.\n" | |
| "- Then use short `##` sub-headings with `-` bullet lists for the specifics that matter.\n" | |
| "- Quote concrete dollar amounts, dates, deadlines, vote/motion language, and agenda " | |
| "item numbers verbatim when they appear.\n" | |
| "- Never invent facts or use outside knowledge. If the packet does not address the " | |
| "question, say so plainly in a single sentence.\n" | |
| "No preamble, no \"Based on the packet…\", no restating the question, no closing summary." | |
| ) | |
| ANSWER_PROMPT = ( | |
| "Agenda item: {title}\n\n" | |
| "Resident's question:\n{query}\n\n" | |
| "Packet text for this item (its supporting documents):\n" | |
| '"""\n{context}\n"""\n\n' | |
| "Write the markdown answer now." | |
| ) | |
| def _default_completer() -> Completer: | |
| from .llm import chat_complete | |
| return chat_complete | |
| def _invoke(complete: Completer, prompt: str, system: str, max_tokens: int) -> str: | |
| """Call ``complete``, degrading gracefully if it ignores system/max_tokens.""" | |
| try: | |
| return complete(prompt, system=system, max_tokens=max_tokens) | |
| except TypeError: | |
| pass | |
| try: | |
| return complete(prompt, system=system) | |
| except TypeError: | |
| return complete(prompt) | |
| def budget_for(max_sections: int) -> tuple[int, int]: | |
| """Map the UI "thoroughness" knob onto ``(char_budget, answer_max_tokens)``. | |
| Higher thoroughness feeds more packet context and allows a longer answer. The | |
| char budget is capped so the prompt still fits the local model's context window. | |
| """ | |
| n = int(max_sections or 0) | |
| char_budget = min(DEFAULT_CHAR_BUDGET, max(6000, n * 320)) | |
| # Scale so the UI's max thoroughness (~120) reaches ANSWER_MAX_TOKENS. | |
| max_tokens = min(ANSWER_MAX_TOKENS, max(640, round(n * ANSWER_MAX_TOKENS / 120))) | |
| return char_budget, max_tokens | |
| def _split_docs(documents: list[dict]) -> tuple[str, list[dict]]: | |
| """Separate the focusing item-title doc from the packet body docs. | |
| The item-report loader (:func:`webapp.backend._item_documents`) prepends a tiny | |
| ``…:item`` doc holding the selected item's title; the real content is the | |
| ``…:packet-slice`` / ``…:packet`` / ``…:agenda`` doc(s). | |
| """ | |
| title = "" | |
| body: list[dict] = [] | |
| for d in documents: | |
| doc_id = str(d.get("doc_id", "")) | |
| text = (d.get("text") or "").strip() | |
| if doc_id.endswith(":item"): | |
| # text looks like "Selected agenda item: <title>" | |
| title = text.split(":", 1)[1].strip() if ":" in text else text | |
| elif text: | |
| body.append(d) | |
| return title, body | |
| def pick_engine(documents: list[dict], *, char_budget: int = DEFAULT_CHAR_BUDGET) -> str: | |
| """Choose ``"single"`` or ``"mapreduce"`` for these item documents. | |
| Single-pass when the body already fits one context window, or when the item was | |
| structurally located (a ``…:packet-slice`` doc) and is only modestly over budget — | |
| there, relevance retrieval still surfaces the chunks that matter. Map-reduce only | |
| when the text is *many* times the window (a located slice that big, or the | |
| un-located full-packet fallback), where one-shot retrieval would silently drop | |
| most of the content. | |
| """ | |
| located = any(str(d.get("doc_id", "")).endswith(":packet-slice") for d in documents) | |
| _, body = _split_docs(documents) | |
| body_chars = sum(len(d.get("text") or "") for d in body) | |
| if body_chars <= char_budget: | |
| return "single" | |
| if located and body_chars <= 2 * char_budget: | |
| return "single" | |
| return "mapreduce" | |
| def resolve_engine(documents: list[dict], engine: str = "auto", *, | |
| char_budget: int = DEFAULT_CHAR_BUDGET) -> str: | |
| """Honor an explicit ``engine`` choice, else fall back to :func:`pick_engine`. | |
| ``engine`` is the user's selection from the UI: ``"single"`` (semantic search / | |
| one-shot answer) or ``"mapreduce"`` (read every section). Anything else | |
| (``"auto"``, ``""``) defers to the size-based heuristic. | |
| """ | |
| if engine in ("single", "mapreduce"): | |
| return engine | |
| return pick_engine(documents, char_budget=char_budget) | |
| def assemble_context( | |
| documents: list[dict], | |
| query: str, | |
| *, | |
| store: AgendaStore | None = None, | |
| char_budget: int = DEFAULT_CHAR_BUDGET, | |
| n_results: int = DEFAULT_N_RESULTS, | |
| ) -> tuple[str, dict]: | |
| """Build the single-pass context string from the item's documents. | |
| If the packet body fits ``char_budget`` it is used whole (no embedding needed -- | |
| the fastest path). Otherwise the body is chunked + embedded into an ephemeral | |
| Chroma collection, the chunks most relevant to ``query`` are retrieved, packed up | |
| to the budget, and re-sorted into reading order for coherence. | |
| Returns ``(context, info)`` where ``info`` carries ``title`` plus | |
| ``{retrieved, used_chunks, total_chunks, context_chars}`` for UI notes / eval. | |
| """ | |
| title, body = _split_docs(documents) | |
| info: dict = {"title": title, "retrieved": False, "used_chunks": 0, | |
| "total_chunks": 0, "context_chars": 0} | |
| if not body: | |
| return "", info | |
| body_text = "\n\n".join((d.get("text") or "").strip() for d in body).strip() | |
| if len(body_text) <= char_budget: | |
| info["context_chars"] = len(body_text) | |
| return body_text, info | |
| # Too big for one shot: rank chunks by relevance, pack to budget, restore order. | |
| store = store if store is not None else AgendaStore(path=None) | |
| store.add_documents(body) | |
| all_chunks = store.get_chunks() | |
| info["total_chunks"] = len(all_chunks) | |
| hits = store.query(query, n_results=min(n_results, max(1, len(all_chunks)))) | |
| selected: list[dict] = [] | |
| used = 0 | |
| for h in hits: | |
| text = h.get("text") or "" | |
| if not text: | |
| continue | |
| if used and used + len(text) > char_budget: | |
| continue | |
| selected.append(h) | |
| used += len(text) | |
| if used >= char_budget: | |
| break | |
| def _order(c: dict) -> tuple: | |
| m = c.get("metadata") or {} | |
| return (str(m.get("doc_id", "")), m.get("chunk_index", 0)) | |
| selected.sort(key=_order) | |
| context = "\n\n".join((c.get("text") or "").strip() for c in selected).strip() | |
| info.update(retrieved=True, used_chunks=len(selected), context_chars=len(context)) | |
| return context, info | |
| def answer_question( | |
| documents: list[dict], | |
| query: str, | |
| *, | |
| complete: Completer | None = None, | |
| char_budget: int = DEFAULT_CHAR_BUDGET, | |
| n_results: int = DEFAULT_N_RESULTS, | |
| max_tokens: int = 1024, | |
| progress: Callable[[float, str], None] | None = None, | |
| ) -> str: | |
| """Answer ``query`` about one agenda item in a single LLM call (non-streaming). | |
| Used by the remote backend and the eval harness; the ZeroGPU path streams the | |
| same prompt via :func:`webapp.local_llm.gpu_answer`. | |
| """ | |
| query = (query or "").strip() | |
| if not query: | |
| return "_Enter a question to ask about this item._" | |
| if not documents: | |
| return "_No agenda text was available for this item (the packet may be empty or failed to download)._" | |
| complete = complete or _default_completer() | |
| if progress: | |
| progress(0.1, "Finding the most relevant pages…") | |
| context, info = assemble_context( | |
| documents, query, char_budget=char_budget, n_results=n_results) | |
| if not context.strip(): | |
| return "_No extractable text found for this item._" | |
| if progress: | |
| progress(0.45, "Writing the answer…") | |
| out = _invoke( | |
| complete, | |
| ANSWER_PROMPT.format(title=info["title"], query=query, context=context), | |
| ANSWER_SYSTEM, | |
| max_tokens, | |
| ) | |
| if progress: | |
| progress(1.0, "Done") | |
| return (out or "").strip() | |