| """RAG pipeline utilities for embeddings, summaries, and Q&A.""" |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import inspect |
| import json |
| import re |
| from concurrent.futures import ThreadPoolExecutor |
| from dataclasses import dataclass, field |
| from functools import lru_cache |
| from pathlib import Path |
| from typing import Any, Callable, Dict, Iterable, List, ForwardRef, Optional, Protocol, cast |
|
|
| try: |
| from pydantic.v1 import typing as _pydantic_typing |
| except ImportError: |
| _pydantic_typing = None |
| else: |
| _forward_sig = inspect.signature(ForwardRef._evaluate) |
| _recursive_guard_param = _forward_sig.parameters.get("recursive_guard") |
| if _recursive_guard_param is not None: |
| def _evaluate_forwardref(type_: ForwardRef, globalns: Any, localns: Any) -> Any: |
| evaluator = cast(Any, type_)._evaluate |
| if "type_params" in evaluator.__code__.co_varnames: |
| return evaluator(globalns, localns, None, recursive_guard=set()) |
| return evaluator(globalns, localns, recursive_guard=set()) |
|
|
| _pydantic_typing.evaluate_forwardref = _evaluate_forwardref |
|
|
| from langchain_community.embeddings import SentenceTransformerEmbeddings |
| from langchain_community.vectorstores import FAISS |
| from langchain_core.documents import Document |
| from langchain.text_splitter import RecursiveCharacterTextSplitter |
| from pydantic import BaseModel, Field |
| from transformers import AutoTokenizer |
|
|
| from config import ( |
| ANTHROPIC_THINKING_BUDGET, |
| DEFAULT_CHUNK_OVERLAP, |
| DEFAULT_CHUNK_SIZE, |
| DEFAULT_CHUNK_TOKENIZER_MODEL, |
| DEFAULT_FALLBACK_EMBEDDING_MODEL, |
| OPENAI_REASONING_EFFORT, |
| PROVIDER_FULL_DOCUMENT_QA_TOKEN_BUDGETS, |
| SCAN_BATCH_SIZE, |
| SCAN_CHUNK_OVERLAP, |
| SCAN_CHUNK_SIZE, |
| SCAN_MAX_WINDOWS, |
| SCAN_TOP_K, |
| TOP_K_RETRIEVAL, |
| ) |
| from services.precomputed_assets import PrecomputedExampleAsset, load_precomputed_asset_for_url |
| from services.providers import ProviderClient |
|
|
| OBJECTIVITY_INSTRUCTION = ( |
| "Be objective. Reason things out. Based on the available context, and wherever the question " |
| "lends itself, consider multiple relevant perspectives, including opposing perspectives, " |
| "before responding." |
| ) |
|
|
|
|
| class SWOTBlock(BaseModel): |
| strengths: List[str] = Field(default_factory=list) |
| weaknesses: List[str] = Field(default_factory=list) |
| opportunities: List[str] = Field(default_factory=list) |
| threats: List[str] = Field(default_factory=list) |
|
|
|
|
| class ImplementationItem(BaseModel): |
| stakeholder: str = "" |
| obligation: str = "" |
| implementation_burden: str = "" |
| risk_or_note: str = "" |
|
|
|
|
| class CritiqueItem(BaseModel): |
| issue: str = "" |
| why_it_matters: str = "" |
| recommendation: str = "" |
|
|
|
|
| class AnalysisResult(BaseModel): |
| executive_summary: str = "" |
| bill_summary: List[str] = Field(default_factory=list) |
| implementation: List[ImplementationItem] = Field(default_factory=list) |
| critique: List[CritiqueItem] = Field(default_factory=list) |
| swot: SWOTBlock = Field(default_factory=SWOTBlock) |
|
|
|
|
| class Citation(BaseModel): |
| ref_id: int |
| snippet: str |
|
|
|
|
| class AnswerResult(BaseModel): |
| answer: str |
| citations: List[Citation] |
| provenance: str = "analysis_based" |
| needs_deeper_consent: bool = False |
| deeper_answer_available: bool = False |
| consent_prompt: str = "" |
|
|
|
|
| class ScanChunk(BaseModel): |
| chunk_id: int |
| text: str |
|
|
|
|
| class ScanMatch(BaseModel): |
| chunk_id: int |
| relevance_score: int = Field(ge=0, le=3) |
| evidence_snippet: str = "" |
|
|
|
|
| class ScanResult(BaseModel): |
| matches: List[ScanMatch] = Field(default_factory=list) |
|
|
|
|
| class TokenizerLike(Protocol): |
| def encode(self, text: str, *args: Any, **kwargs: Any) -> list[int]: |
| ... |
|
|
|
|
| @dataclass |
| class AnalysisSnippet: |
| ref_id: int |
| section: str |
| text: str |
|
|
| @dataclass |
| class CachedDocumentArtifacts: |
| document_hash: str |
| document_text: str |
| chunks: list[str] |
| analysis: AnalysisResult |
| vector_store: "RetrievalIndex | None" |
| analysis_snippets: list[AnalysisSnippet] = field(default_factory=list) |
| source_url: str | None = None |
| example_bill_id: str | None = None |
| is_precomputed: bool = False |
| metadata: dict[str, Any] = field(default_factory=dict) |
|
|
|
|
| _DOCUMENT_CACHE: dict[str, CachedDocumentArtifacts] = {} |
| RetrievalIndex = FAISS |
|
|
|
|
| @lru_cache(maxsize=1) |
| def _fallback_embedding_model(model_name: str = DEFAULT_FALLBACK_EMBEDDING_MODEL) -> SentenceTransformerEmbeddings: |
| return SentenceTransformerEmbeddings(model_name=model_name) |
|
|
|
|
| @lru_cache(maxsize=1) |
| def _embedding_tokenizer(model_name: str = DEFAULT_CHUNK_TOKENIZER_MODEL) -> TokenizerLike: |
| return AutoTokenizer.from_pretrained(model_name) |
|
|
|
|
| def warm_embedding_stack(model_name: str = DEFAULT_CHUNK_TOKENIZER_MODEL) -> None: |
| _embedding_tokenizer(model_name) |
| _fallback_embedding_model() |
|
|
|
|
| def _split_with_tokenizer(text: str, chunk_size: int, chunk_overlap: int) -> List[str]: |
| tokenizer = _embedding_tokenizer() |
| splitter = RecursiveCharacterTextSplitter( |
| chunk_size=chunk_size, |
| chunk_overlap=chunk_overlap, |
| length_function=lambda value: len(tokenizer.encode(value, add_special_tokens=False)), |
| separators=["\n\n", "\n", ". ", "; ", ", ", " ", ""], |
| ) |
| return splitter.split_text(text) |
|
|
|
|
| def _token_count(text: str) -> int: |
| return len(_embedding_tokenizer().encode(text, add_special_tokens=False)) |
|
|
|
|
| def _full_document_budget(provider_client: ProviderClient) -> int: |
| return PROVIDER_FULL_DOCUMENT_QA_TOKEN_BUDGETS.get(provider_client.name, 0) |
|
|
|
|
| def _supports_openai_reasoning(model_name: str) -> bool: |
| return model_name.startswith(("gpt-5", "o1", "o3", "o4")) |
|
|
|
|
| def _supports_anthropic_extended_thinking(model_name: str) -> bool: |
| return model_name.startswith( |
| ( |
| "claude-3-7-sonnet", |
| "claude-sonnet-4", |
| "claude-opus-4", |
| ) |
| ) |
|
|
|
|
| def _openai_reasoning_kwargs(provider_client: ProviderClient) -> dict[str, Any]: |
| if provider_client.name != "openai" or not _supports_openai_reasoning(provider_client.default_model): |
| return {} |
| return {"reasoning": {"effort": OPENAI_REASONING_EFFORT}} |
|
|
|
|
| def _anthropic_thinking_kwargs(provider_client: ProviderClient) -> dict[str, Any]: |
| if provider_client.name != "anthropic" or not _supports_anthropic_extended_thinking(provider_client.default_model): |
| return {} |
| return { |
| "extra_body": { |
| "thinking": { |
| "type": "enabled", |
| "budget_tokens": ANTHROPIC_THINKING_BUDGET, |
| } |
| } |
| } |
|
|
|
|
| def split_into_chunks(text: str, chunk_size: int = DEFAULT_CHUNK_SIZE, chunk_overlap: int = DEFAULT_CHUNK_OVERLAP) -> List[str]: |
| return _split_with_tokenizer(text, chunk_size=chunk_size, chunk_overlap=chunk_overlap) |
|
|
|
|
| def split_into_scan_chunks( |
| text: str, |
| chunk_size: int = SCAN_CHUNK_SIZE, |
| chunk_overlap: int = SCAN_CHUNK_OVERLAP, |
| ) -> List[ScanChunk]: |
| chunks = _split_with_tokenizer(text, chunk_size=chunk_size, chunk_overlap=chunk_overlap) |
| return [ScanChunk(chunk_id=idx, text=chunk) for idx, chunk in enumerate(chunks, start=1)] |
|
|
|
|
| def build_vector_store(chunks: Iterable[str]) -> FAISS: |
| docs = [Document(page_content=chunk, metadata={"chunk_id": idx}) for idx, chunk in enumerate(chunks, start=1)] |
| embeddings = _fallback_embedding_model() |
| return FAISS.from_documents(docs, embedding=embeddings) |
|
|
|
|
| def build_retrieval_index(chunks: Iterable[str]) -> RetrievalIndex: |
| return build_vector_store(list(chunks)) |
|
|
|
|
| def document_hash_for_text(text: str) -> str: |
| return hashlib.sha256(text.encode("utf-8")).hexdigest() |
|
|
|
|
| def _retrieval_signature() -> str: |
| return "|".join([DEFAULT_CHUNK_TOKENIZER_MODEL, DEFAULT_FALLBACK_EMBEDDING_MODEL, "faiss"]) |
|
|
|
|
| def document_cache_key(document_hash: str) -> str: |
| return f"{document_hash}:{DEFAULT_CHUNK_SIZE}:{DEFAULT_CHUNK_OVERLAP}:{_retrieval_signature()}" |
|
|
|
|
| def get_cached_document(document_hash: str) -> CachedDocumentArtifacts | None: |
| return _DOCUMENT_CACHE.get(document_cache_key(document_hash)) |
|
|
|
|
| def cache_document(artifacts: CachedDocumentArtifacts) -> CachedDocumentArtifacts: |
| _DOCUMENT_CACHE[document_cache_key(artifacts.document_hash)] = artifacts |
| return artifacts |
|
|
|
|
| def save_vector_store(vector_store: RetrievalIndex, output_dir: Path) -> None: |
| if not isinstance(vector_store, FAISS): |
| raise TypeError("Only FAISS indexes can be saved locally.") |
| output_dir.parent.mkdir(parents=True, exist_ok=True) |
| vector_store.save_local(str(output_dir)) |
|
|
|
|
| def load_vector_store(vector_store_dir: Path) -> FAISS: |
| return FAISS.load_local( |
| str(vector_store_dir), |
| _fallback_embedding_model(), |
| allow_dangerous_deserialization=True, |
| ) |
|
|
|
|
| def hydrate_precomputed_example(asset: PrecomputedExampleAsset) -> CachedDocumentArtifacts: |
| analysis = AnalysisResult.model_validate(asset.analysis_payload) |
| vector_store = build_retrieval_index(asset.chunks) |
| artifacts = CachedDocumentArtifacts( |
| document_hash=asset.bill.document_hash or document_hash_for_text(asset.document_text), |
| document_text=asset.document_text, |
| chunks=asset.chunks, |
| analysis=analysis, |
| vector_store=vector_store, |
| analysis_snippets=build_analysis_snippets(analysis), |
| source_url=asset.bill.source_url, |
| example_bill_id=asset.bill.id, |
| is_precomputed=True, |
| metadata=asset.metadata, |
| ) |
| return cache_document(artifacts) |
|
|
|
|
| def _analysis_schema() -> Dict[str, Any]: |
| return { |
| "type": "object", |
| "properties": { |
| "executive_summary": {"type": "string"}, |
| "bill_summary": {"type": "array", "items": {"type": "string"}}, |
| "implementation": { |
| "type": "array", |
| "items": { |
| "type": "object", |
| "properties": { |
| "stakeholder": {"type": "string"}, |
| "obligation": {"type": "string"}, |
| "implementation_burden": {"type": "string"}, |
| "risk_or_note": {"type": "string"}, |
| }, |
| "required": [ |
| "stakeholder", |
| "obligation", |
| "implementation_burden", |
| "risk_or_note", |
| ], |
| "additionalProperties": False, |
| }, |
| }, |
| "critique": { |
| "type": "array", |
| "items": { |
| "type": "object", |
| "properties": { |
| "issue": {"type": "string"}, |
| "why_it_matters": {"type": "string"}, |
| "recommendation": {"type": "string"}, |
| }, |
| "required": ["issue", "why_it_matters", "recommendation"], |
| "additionalProperties": False, |
| }, |
| }, |
| "swot": { |
| "type": "object", |
| "properties": { |
| "strengths": {"type": "array", "items": {"type": "string"}}, |
| "weaknesses": {"type": "array", "items": {"type": "string"}}, |
| "opportunities": {"type": "array", "items": {"type": "string"}}, |
| "threats": {"type": "array", "items": {"type": "string"}}, |
| }, |
| "required": ["strengths", "weaknesses", "opportunities", "threats"], |
| "additionalProperties": False, |
| }, |
| }, |
| "required": [ |
| "executive_summary", |
| "bill_summary", |
| "implementation", |
| "critique", |
| "swot", |
| ], |
| "additionalProperties": False, |
| } |
|
|
|
|
| def _analysis_summary_schema() -> Dict[str, Any]: |
| return { |
| "type": "object", |
| "properties": { |
| "executive_summary": {"type": "string"}, |
| "bill_summary": {"type": "array", "items": {"type": "string"}}, |
| }, |
| "required": ["executive_summary", "bill_summary"], |
| "additionalProperties": False, |
| } |
|
|
|
|
| def _implementation_schema() -> Dict[str, Any]: |
| return { |
| "type": "object", |
| "properties": { |
| "implementation": _analysis_schema()["properties"]["implementation"], |
| }, |
| "required": ["implementation"], |
| "additionalProperties": False, |
| } |
|
|
|
|
| def _critique_schema() -> Dict[str, Any]: |
| return { |
| "type": "object", |
| "properties": { |
| "critique": _analysis_schema()["properties"]["critique"], |
| }, |
| "required": ["critique"], |
| "additionalProperties": False, |
| } |
|
|
|
|
| def _swot_schema() -> Dict[str, Any]: |
| return { |
| "type": "object", |
| "properties": { |
| "swot": _analysis_schema()["properties"]["swot"], |
| }, |
| "required": ["swot"], |
| "additionalProperties": False, |
| } |
|
|
|
|
| def _analysis_answer_schema() -> Dict[str, Any]: |
| return { |
| "type": "object", |
| "properties": { |
| "answer": {"type": "string"}, |
| "is_sufficient": {"type": "boolean"}, |
| }, |
| "required": ["answer", "is_sufficient"], |
| "additionalProperties": False, |
| } |
|
|
|
|
| def _generate_json_payload( |
| provider_client: ProviderClient, |
| *, |
| prompt: str, |
| user_text: str, |
| schema: dict[str, Any], |
| schema_name: str, |
| max_tokens: int = 1500, |
| ) -> str: |
| if provider_client.name == "openai": |
| response = provider_client.client.responses.create( |
| model=provider_client.default_model, |
| input=[ |
| {"role": "system", "content": prompt}, |
| {"role": "user", "content": user_text}, |
| ], |
| response_format={ |
| "type": "json_schema", |
| "json_schema": {"name": schema_name, "schema": schema}, |
| }, |
| **_openai_reasoning_kwargs(provider_client), |
| ) |
| return _extract_openai_json(response) |
| if provider_client.name == "qwen": |
| response = provider_client.client.chat.completions.create( |
| model=provider_client.default_model, |
| messages=[ |
| {"role": "system", "content": prompt}, |
| { |
| "role": "user", |
| "content": ( |
| f"{user_text}\n\n" |
| "Return only valid JSON matching this schema:\n" |
| f"{json.dumps(schema)}" |
| ), |
| }, |
| ], |
| temperature=0, |
| ) |
| return _strip_json_fences(_extract_chat_completion_text(response)) |
| if provider_client.name == "anthropic": |
| response = provider_client.client.messages.create( |
| model=provider_client.default_model, |
| max_tokens=max_tokens, |
| system=prompt, |
| messages=[{"role": "user", "content": [{"type": "text", "text": user_text}]}], |
| response_format={ |
| "type": "json_schema", |
| "json_schema": {"name": schema_name, "schema": schema}, |
| }, |
| **_anthropic_thinking_kwargs(provider_client), |
| ) |
| return response.content[0].text |
| if provider_client.name == "cohere": |
| response = provider_client.client.chat( |
| message=user_text, |
| model=provider_client.default_model, |
| preamble=prompt, |
| max_tokens=max_tokens, |
| temperature=0, |
| response_format={ |
| "type": "json_object", |
| "schema": schema, |
| }, |
| ) |
| return _strip_json_fences(_extract_cohere_text(response)) |
|
|
| genai = provider_client.client |
| model = genai.GenerativeModel( |
| model_name=provider_client.default_model, |
| system_instruction=prompt, |
| generation_config=genai.GenerationConfig( |
| temperature=0, |
| response_mime_type="application/json", |
| ), |
| ) |
| response = model.generate_content(user_text) |
| return _extract_gemini_text(response) |
|
|
|
|
| def generate_analysis_progress(provider_client: ProviderClient, document_text: str) -> Iterable[tuple[str, AnalysisResult]]: |
| limited_text = document_text[:30000] |
| base_prompt = ( |
| "You are a senior policy analyst helping citizens, civil society, startups, workers, consumers, " |
| "operators, and affected industries understand a government bill. Produce a clear briefing-style analysis, " |
| "not a generic summary. Be objective, text-disciplined, and grounded in the document. Separate what the bill " |
| "expressly provides from what is a reasonable inference about likely effects. Do not attribute powers, " |
| "safeguards, funding mechanisms, oversight tools, or policy instruments to the bill unless they are plainly " |
| "supported by the text. If support for a point is weak, omit it rather than embellish it. Focus on what the " |
| "bill changes, what ordinary people or practitioners in the affected field should be excited or worried " |
| "about, how businesses or industry participants may need to adapt, and what this could mean for growth, " |
| "friction, competition, cost, access, compliance, innovation, investor confidence, public-service delivery, " |
| "or digital infrastructure quality in the affected sector and, where relevant, the national economy. " |
| "Pay particular attention to ground-level effects on workers, professionals, entrepreneurs, recent " |
| "graduates, students, consumers, startups, SMEs, public institutions, and vulnerable or underserved groups " |
| "when the bill plausibly affects them, but do not force demographic groups into the analysis where the fit is " |
| "weak. Do not center implementation implications on lawmakers, legislators, or sponsoring government " |
| "agencies unless a direct downstream burden on the public or private sector depends on them. " |
| "For implementation items, identify the affected stakeholder group, the practical obligation or change in " |
| "behavior they may face, the likely implementation burden, and any practical risk or note. If a stakeholder " |
| "has no formal legal obligation, describe the real-world adjustment, exposure, opportunity, or compliance " |
| "expectation in plain language. Prefer concrete operational, lived-experience, and economic consequences over " |
| "abstract commentary. For critique items, use the structure: issue, why it matters, recommendation, and " |
| "focus on ambiguity, overlap, overreach, implementation bottlenecks, concentration of discretion, compliance " |
| "burden, and risks to competition, innovation, affordability, or access where relevant. Keep recommendations " |
| "tightly connected to the specific weakness identified. For SWOT, keep each item concise, specific, and " |
| "grounded in the bill or a clear downstream effect that follows from the bill. Avoid vague statements. " |
| "Avoid duplicating the same point across sections. " |
| + OBJECTIVITY_INSTRUCTION |
| ) |
|
|
| partial = AnalysisResult() |
| summary_payload = _generate_json_payload( |
| provider_client, |
| prompt=base_prompt + " Return JSON for executive_summary and bill_summary only.", |
| user_text=f"Document content:\n\n{limited_text}", |
| schema=_analysis_summary_schema(), |
| schema_name="BillAnalysisSummary", |
| ) |
| summary_result = json.loads(_strip_json_fences(summary_payload)) |
| partial.executive_summary = summary_result.get("executive_summary", "") |
| partial.bill_summary = summary_result.get("bill_summary", []) |
| yield "Generating executive summary...", partial.model_copy(deep=True) |
|
|
| implementation_payload = _generate_json_payload( |
| provider_client, |
| prompt=base_prompt + " Return JSON for implementation implications only.", |
| user_text=f"Document content:\n\n{limited_text}", |
| schema=_implementation_schema(), |
| schema_name="BillAnalysisImplementation", |
| ) |
| implementation_result = json.loads(_strip_json_fences(implementation_payload)) |
| partial.implementation = [ImplementationItem.model_validate(item) for item in implementation_result.get("implementation", [])] |
| yield "Generating implementation implications...", partial.model_copy(deep=True) |
|
|
| critique_payload = _generate_json_payload( |
| provider_client, |
| prompt=base_prompt + " Return JSON for critique only.", |
| user_text=f"Document content:\n\n{limited_text}", |
| schema=_critique_schema(), |
| schema_name="BillAnalysisCritique", |
| ) |
| critique_result = json.loads(_strip_json_fences(critique_payload)) |
| partial.critique = [CritiqueItem.model_validate(item) for item in critique_result.get("critique", [])] |
| yield "Generating critique...", partial.model_copy(deep=True) |
|
|
| swot_payload = _generate_json_payload( |
| provider_client, |
| prompt=base_prompt + " Return JSON for SWOT only.", |
| user_text=f"Document content:\n\n{limited_text}", |
| schema=_swot_schema(), |
| schema_name="BillAnalysisSwot", |
| ) |
| swot_result = json.loads(_strip_json_fences(swot_payload)) |
| partial.swot = SWOTBlock.model_validate(swot_result.get("swot", {})) |
| yield "Generating SWOT...", partial.model_copy(deep=True) |
|
|
|
|
| def generate_analysis_once(provider_client: ProviderClient, document_text: str) -> AnalysisResult: |
| limited_text = document_text[:30000] |
| prompt = ( |
| "You are a senior policy analyst helping citizens, civil society, startups, workers, consumers, " |
| "operators, and affected industries understand a government bill. Produce a clear briefing-style analysis, " |
| "not a generic summary. Be objective, text-disciplined, and grounded in the document. Separate what the bill " |
| "expressly provides from what is a reasonable inference about likely effects. Do not attribute powers, " |
| "safeguards, funding mechanisms, oversight tools, or policy instruments to the bill unless they are plainly " |
| "supported by the text. If support for a point is weak, omit it rather than embellish it. Focus on what the " |
| "bill changes, what ordinary people or practitioners in the affected field should be excited or worried " |
| "about, how businesses or industry participants may need to adapt, and what this could mean for growth, " |
| "friction, competition, cost, access, compliance, innovation, investor confidence, public-service delivery, " |
| "or digital infrastructure quality in the affected sector and, where relevant, the national economy. " |
| "Pay particular attention to ground-level effects on workers, professionals, entrepreneurs, recent " |
| "graduates, students, consumers, startups, SMEs, public institutions, and vulnerable or underserved groups " |
| "when the bill plausibly affects them, but do not force demographic groups into the analysis where the fit is " |
| "weak. Do not center implementation implications on lawmakers, legislators, or sponsoring government " |
| "agencies unless a direct downstream burden on the public or private sector depends on them. " |
| "For implementation items, identify the affected stakeholder group, the practical obligation or change in " |
| "behavior they may face, the likely implementation burden, and any practical risk or note. If a stakeholder " |
| "has no formal legal obligation, describe the real-world adjustment, exposure, opportunity, or compliance " |
| "expectation in plain language. Prefer concrete operational, lived-experience, and economic consequences over " |
| "abstract commentary. For critique items, use the structure: issue, why it matters, recommendation, and " |
| "focus on ambiguity, overlap, overreach, implementation bottlenecks, concentration of discretion, compliance " |
| "burden, and risks to competition, innovation, affordability, or access where relevant. Keep recommendations " |
| "tightly connected to the specific weakness identified. For SWOT, keep each item concise, specific, and " |
| "grounded in the bill or a clear downstream effect that follows from the bill. " |
| "Avoid vague statements. Avoid duplicating the same point across sections. " |
| "Respond using JSON that matches the provided schema. " |
| + OBJECTIVITY_INSTRUCTION |
| ) |
| payload = _generate_json_payload( |
| provider_client, |
| prompt=prompt, |
| user_text=f"Document content:\n\n{limited_text}", |
| schema=_analysis_schema(), |
| schema_name="BillAnalysis", |
| ) |
| return AnalysisResult.model_validate_json(_strip_json_fences(payload)) |
|
|
|
|
| def generate_analysis(provider_client: ProviderClient, document_text: str) -> AnalysisResult: |
| final = AnalysisResult() |
| for _, partial in generate_analysis_progress(provider_client, document_text): |
| final = partial |
| return final |
|
|
|
|
| def prepare_document_artifacts( |
| document_text: str, |
| *, |
| provider_factory: Callable[[], Any] | None = None, |
| ) -> tuple[str, list[str], RetrievalIndex]: |
| document_hash = document_hash_for_text(document_text) |
| cached = get_cached_document(document_hash) |
| if cached is not None and cached.vector_store is not None: |
| return cached.document_hash, cached.chunks, cached.vector_store |
|
|
| with ThreadPoolExecutor(max_workers=2) as executor: |
| chunks_future = executor.submit(split_into_chunks, document_text) |
| provider_future = executor.submit(provider_factory) if provider_factory is not None else None |
| chunks = chunks_future.result() |
| vector_store = build_retrieval_index(chunks) |
| if provider_future is not None: |
| provider_future.result() |
| return document_hash, chunks, vector_store |
|
|
|
|
| def get_precomputed_example_artifacts(url: str | None) -> CachedDocumentArtifacts | None: |
| asset = load_precomputed_asset_for_url(url) |
| if asset is None: |
| return None |
| if asset.bill.document_hash: |
| cached = get_cached_document(asset.bill.document_hash) |
| if cached is not None: |
| return cached |
| return hydrate_precomputed_example(asset) |
|
|
|
|
| def build_cached_document_artifacts( |
| *, |
| document_text: str, |
| chunks: list[str], |
| analysis: AnalysisResult, |
| vector_store: RetrievalIndex | None, |
| source_url: str | None = None, |
| ) -> CachedDocumentArtifacts: |
| return cache_document( |
| CachedDocumentArtifacts( |
| document_hash=document_hash_for_text(document_text), |
| document_text=document_text, |
| chunks=chunks, |
| analysis=analysis, |
| vector_store=vector_store, |
| analysis_snippets=build_analysis_snippets(analysis), |
| source_url=source_url, |
| ) |
| ) |
|
|
|
|
| def build_analysis_snippets(analysis: AnalysisResult) -> list[AnalysisSnippet]: |
| snippets: list[AnalysisSnippet] = [] |
| next_ref = 1 |
|
|
| def push(section: str, text: str) -> None: |
| nonlocal next_ref |
| cleaned = " ".join(text.split()).strip() |
| if not cleaned: |
| return |
| snippets.append(AnalysisSnippet(ref_id=next_ref, section=section, text=cleaned)) |
| next_ref += 1 |
|
|
| push("Executive Summary", analysis.executive_summary) |
| for item in analysis.bill_summary: |
| push("Bill Summary", item) |
| for item in analysis.implementation: |
| push( |
| "Implementation", |
| f"{item.stakeholder}: {item.obligation}. Burden: {item.implementation_burden}. Risk/Note: {item.risk_or_note}", |
| ) |
| for item in analysis.critique: |
| push( |
| "Critique", |
| f"{item.issue}. Why it matters: {item.why_it_matters}. Recommendation: {item.recommendation}", |
| ) |
| for label, values in ( |
| ("Strength", analysis.swot.strengths), |
| ("Weakness", analysis.swot.weaknesses), |
| ("Opportunity", analysis.swot.opportunities), |
| ("Threat", analysis.swot.threats), |
| ): |
| for value in values: |
| push(f"SWOT {label}", value) |
| return snippets |
|
|
|
|
| def _question_terms(text: str) -> set[str]: |
| return {term for term in re.findall(r"[a-z0-9]{3,}", text.lower())} |
|
|
|
|
| def search_analysis_snippets(snippets: list[AnalysisSnippet], question: str, top_k: int = 5) -> tuple[str, list[Citation]] | None: |
| if not snippets: |
| return None |
| terms = _question_terms(question) |
| if not terms: |
| return None |
|
|
| ranked: list[tuple[int, AnalysisSnippet]] = [] |
| for snippet in snippets: |
| snippet_terms = _question_terms(snippet.text) |
| score = len(terms & snippet_terms) |
| if score > 0: |
| ranked.append((score, snippet)) |
| if not ranked: |
| return None |
|
|
| ranked.sort(key=lambda item: (-item[0], item[1].ref_id)) |
| selected = [snippet for _, snippet in ranked[:top_k]] |
| context_blocks: list[str] = [] |
| citations: list[Citation] = [] |
| for idx, snippet in enumerate(selected, start=1): |
| citations.append(Citation(ref_id=idx, snippet=snippet.text)) |
| context_blocks.append(f"[ref{idx}] {snippet.section}\n{snippet.text}") |
| return "\n\n".join(context_blocks), citations |
|
|
|
|
| def _answer_question_from_analysis_context( |
| provider_client: ProviderClient, |
| context_text: str, |
| question: str, |
| ) -> dict[str, Any]: |
| prompt = ( |
| "You answer follow-up questions about a government bill using only the provided analysis summary snippets. " |
| "Answer directly and clearly. Set is_sufficient to true only if the analysis snippets materially answer the " |
| "question. If they do not fully answer it, say what is missing and set is_sufficient to false. " |
| "Do not pretend you have read the full bill if you only have analysis snippets. " |
| + OBJECTIVITY_INSTRUCTION |
| ) |
| payload = _generate_json_payload( |
| provider_client, |
| prompt=prompt, |
| user_text="Analysis snippets:\n" + context_text + "\n\nQuestion:\n" + question, |
| schema=_analysis_answer_schema(), |
| schema_name="AnalysisAnswer", |
| max_tokens=900, |
| ) |
| return json.loads(_strip_json_fences(payload)) |
|
|
|
|
| def answer_query( |
| provider_client: ProviderClient, |
| analysis: AnalysisResult | None, |
| vector_store: RetrievalIndex | None, |
| question: str, |
| doc_text: str | None = None, |
| allow_full_document: bool = False, |
| ) -> AnswerResult: |
| if analysis is not None: |
| snippets = build_analysis_snippets(analysis) |
| analysis_context = search_analysis_snippets(snippets, question) |
| if analysis_context is not None: |
| context_text, citations = analysis_context |
| analysis_answer = _answer_question_from_analysis_context(provider_client, context_text, question) |
| return AnswerResult( |
| answer=analysis_answer["answer"].strip(), |
| citations=citations, |
| provenance="analysis_based", |
| needs_deeper_consent=not analysis_answer.get("is_sufficient", False), |
| deeper_answer_available=bool(doc_text or vector_store is not None), |
| consent_prompt=( |
| "This answer is based on the summary and analysis. " |
| "If you want, I can run a deeper full-document answer next." |
| ), |
| ) |
|
|
| if allow_full_document: |
| return answer_query_from_full_document(provider_client, vector_store, question, doc_text=doc_text) |
|
|
| return AnswerResult( |
| answer=( |
| "I couldn't fully answer that from the summary and analysis alone. " |
| "If you want, I can run a deeper full-document answer." |
| ), |
| citations=[], |
| provenance="analysis_based", |
| needs_deeper_consent=True, |
| deeper_answer_available=bool(doc_text or vector_store is not None), |
| consent_prompt="A deeper full-document answer is available.", |
| ) |
|
|
|
|
| def answer_query_from_full_document( |
| provider_client: ProviderClient, |
| vector_store: RetrievalIndex | None, |
| question: str, |
| *, |
| doc_text: str | None = None, |
| ) -> AnswerResult: |
| context_text = "No context available." |
| citations: list[Citation] = [] |
|
|
| if doc_text: |
| full_document_answer = _answer_question_from_full_document(provider_client, doc_text, question) |
| if full_document_answer is not None: |
| return AnswerResult( |
| answer=full_document_answer.strip(), |
| citations=[], |
| provenance="full_document", |
| ) |
|
|
| scan_context = _scan_document_for_context(provider_client, doc_text, question) |
| if scan_context: |
| context_text, citations = scan_context |
|
|
| if not citations and vector_store is not None: |
| context_text, citations = _retrieve_context_from_index(vector_store, question) |
|
|
| answer = _generate_answer_from_context(provider_client, context_text, question) |
| return AnswerResult(answer=answer.strip(), citations=citations, provenance="full_document") |
|
|
|
|
| def _answer_question_from_full_document( |
| provider_client: ProviderClient, |
| doc_text: str, |
| question: str, |
| ) -> str | None: |
| if _token_count(doc_text) > _full_document_budget(provider_client): |
| return None |
|
|
| prompt = ( |
| "You answer questions about government bills. Read the full document carefully, " |
| "reason through the relevant provisions, and answer the user's question logically. " |
| "Base your answer only on the provided document. If the answer is not supported by the document, say you are unsure. " |
| + OBJECTIVITY_INSTRUCTION |
| ) |
| user_text = "Full document:\n" + doc_text + "\n\nQuestion:\n" + question |
|
|
| if provider_client.name == "openai": |
| response = provider_client.client.responses.create( |
| model=provider_client.default_model, |
| input=[ |
| {"role": "system", "content": prompt}, |
| {"role": "user", "content": user_text}, |
| ], |
| **_openai_reasoning_kwargs(provider_client), |
| ) |
| return _extract_openai_text(response) |
| if provider_client.name == "qwen": |
| response = provider_client.client.chat.completions.create( |
| model=provider_client.default_model, |
| messages=[ |
| {"role": "system", "content": prompt}, |
| {"role": "user", "content": user_text}, |
| ], |
| temperature=0, |
| ) |
| return _extract_chat_completion_text(response) |
| if provider_client.name == "anthropic": |
| response = provider_client.client.messages.create( |
| model=provider_client.default_model, |
| max_tokens=1000, |
| system=prompt, |
| messages=[{"role": "user", "content": [{"type": "text", "text": user_text}]}], |
| **_anthropic_thinking_kwargs(provider_client), |
| ) |
| return response.content[0].text |
| if provider_client.name == "cohere": |
| response = provider_client.client.chat( |
| message=user_text, |
| model=provider_client.default_model, |
| preamble=prompt, |
| max_tokens=1000, |
| temperature=0, |
| ) |
| return _extract_cohere_text(response) |
|
|
| genai = provider_client.client |
| model = genai.GenerativeModel( |
| model_name=provider_client.default_model, |
| system_instruction=prompt, |
| generation_config=genai.GenerationConfig( |
| temperature=0, |
| response_mime_type="text/plain", |
| ), |
| ) |
| response = model.generate_content(user_text) |
| return _extract_gemini_text(response) |
|
|
|
|
| def _retrieve_context_from_faiss(vector_store: FAISS, question: str) -> tuple[str, list[Citation]]: |
| context_blocks: list[str] = [] |
| citations: list[Citation] = [] |
| retrieved_docs = vector_store.similarity_search(question, k=TOP_K_RETRIEVAL) |
| for idx, doc in enumerate(retrieved_docs, start=1): |
| snippet = doc.page_content.strip() |
| if not snippet: |
| continue |
| context_blocks.append(f"[{idx}] {snippet}") |
| citations.append(Citation(ref_id=idx, snippet=snippet)) |
|
|
| context_text = "\n\n".join(context_blocks) if context_blocks else "No context available." |
| return context_text, citations |
|
|
|
|
| def _retrieve_context_from_index(vector_store: RetrievalIndex, question: str) -> tuple[str, list[Citation]]: |
| return _retrieve_context_from_faiss(vector_store, question) |
|
|
|
|
| def _generate_answer_from_context(provider_client: ProviderClient, context_text: str, question: str) -> str: |
| prompt = ( |
| "You answer questions about government bills. Use the context snippets provided. " |
| "Cite supporting snippets using [ref#] references matching the snippet number. " |
| "If unsure, say you are unsure. " |
| + OBJECTIVITY_INSTRUCTION |
| ) |
|
|
| if provider_client.name == "openai": |
| response = provider_client.client.responses.create( |
| model=provider_client.default_model, |
| input=[ |
| {"role": "system", "content": prompt}, |
| { |
| "role": "user", |
| "content": ( |
| "Context:\n" + context_text + "\n\nQuestion:\n" + question |
| ), |
| }, |
| ], |
| **_openai_reasoning_kwargs(provider_client), |
| ) |
| answer = _extract_openai_text(response) |
| elif provider_client.name == "qwen": |
| response = provider_client.client.chat.completions.create( |
| model=provider_client.default_model, |
| messages=[ |
| {"role": "system", "content": prompt}, |
| { |
| "role": "user", |
| "content": ( |
| "Context:\n" + context_text + "\n\nQuestion:\n" + question |
| ), |
| }, |
| ], |
| temperature=0, |
| ) |
| answer = _extract_chat_completion_text(response) |
| elif provider_client.name == "anthropic": |
| response = provider_client.client.messages.create( |
| model=provider_client.default_model, |
| max_tokens=800, |
| system=prompt, |
| messages=[ |
| { |
| "role": "user", |
| "content": [ |
| { |
| "type": "text", |
| "text": ( |
| "Context:\n" + context_text + "\n\nQuestion:\n" + question |
| ), |
| } |
| ], |
| } |
| ], |
| **_anthropic_thinking_kwargs(provider_client), |
| ) |
| answer = response.content[0].text |
| elif provider_client.name == "cohere": |
| response = provider_client.client.chat( |
| message=( |
| "Context:\n" + context_text + "\n\nQuestion:\n" + question |
| ), |
| model=provider_client.default_model, |
| preamble=prompt, |
| max_tokens=800, |
| temperature=0, |
| ) |
| answer = _extract_cohere_text(response) |
| else: |
| genai = provider_client.client |
| model = genai.GenerativeModel( |
| model_name=provider_client.default_model, |
| system_instruction=prompt, |
| generation_config=genai.GenerationConfig( |
| temperature=0, |
| response_mime_type="text/plain", |
| ), |
| ) |
| response = model.generate_content( |
| "Context:\n" |
| + context_text |
| + "\n\nQuestion:\n" |
| + question |
| ) |
| answer = _extract_gemini_text(response) |
|
|
| return answer |
|
|
|
|
| def _scan_document_for_context( |
| provider_client: ProviderClient, |
| doc_text: str, |
| question: str, |
| ) -> Optional[tuple[str, list[Citation]]]: |
| scan_chunks = split_into_scan_chunks(doc_text) |
| if not scan_chunks or len(scan_chunks) > SCAN_MAX_WINDOWS: |
| return None |
|
|
| ranked_matches: list[ScanMatch] = [] |
| for batch_start in range(0, len(scan_chunks), SCAN_BATCH_SIZE): |
| batch = scan_chunks[batch_start : batch_start + SCAN_BATCH_SIZE] |
| parsed_result = _run_scan_batch_with_retry(provider_client, batch, question) |
| if parsed_result is None: |
| return None |
| ranked_matches.extend(parsed_result.matches) |
|
|
| merged_matches = _merge_scan_matches(ranked_matches) |
| if not merged_matches or merged_matches[0].relevance_score <= 0: |
| return None |
|
|
| chunk_by_id = {chunk.chunk_id: chunk for chunk in scan_chunks} |
| selected_matches = merged_matches[:SCAN_TOP_K] |
| context_blocks: list[str] = [] |
| citations: list[Citation] = [] |
| for ref_id, match in enumerate(selected_matches, start=1): |
| chunk = chunk_by_id.get(match.chunk_id) |
| if chunk is None: |
| continue |
| snippet = match.evidence_snippet.strip() or _truncate_snippet(chunk.text) |
| citations.append(Citation(ref_id=ref_id, snippet=snippet)) |
| context_blocks.append( |
| f"[ref{ref_id}] Chunk {match.chunk_id} (score {match.relevance_score})\n" |
| f"Evidence: {snippet}\n" |
| f"Full context:\n{chunk.text.strip()}" |
| ) |
|
|
| if not citations: |
| return None |
| return "\n\n".join(context_blocks), citations |
|
|
|
|
| def _run_scan_batch_with_retry( |
| provider_client: ProviderClient, |
| batch: list[ScanChunk], |
| question: str, |
| ) -> ScanResult | None: |
| for _ in range(2): |
| payload = _scan_batch(provider_client, batch, question) |
| try: |
| return ScanResult.model_validate_json(_strip_json_fences(payload)) |
| except Exception: |
| continue |
| return None |
|
|
|
|
| def _scan_batch(provider_client: ProviderClient, batch: list[ScanChunk], question: str) -> str: |
| schema = { |
| "type": "object", |
| "properties": { |
| "matches": { |
| "type": "array", |
| "items": { |
| "type": "object", |
| "properties": { |
| "chunk_id": {"type": "integer"}, |
| "relevance_score": {"type": "integer", "minimum": 0, "maximum": 3}, |
| "evidence_snippet": {"type": "string"}, |
| }, |
| "required": ["chunk_id", "relevance_score", "evidence_snippet"], |
| "additionalProperties": False, |
| }, |
| } |
| }, |
| "required": ["matches"], |
| "additionalProperties": False, |
| } |
| prompt = ( |
| "You are scanning chunks from a government bill to find context relevant to a user question. " |
| "Return only JSON matching the schema. Include only chunks that are at least somewhat relevant. " |
| "Use relevance_score from 0 to 3, where 3 is highly relevant and 0 is irrelevant. " |
| "Keep evidence_snippet short and copied from the chunk. " |
| + OBJECTIVITY_INSTRUCTION |
| ) |
| chunk_text = "\n\n".join(f"Chunk {chunk.chunk_id}:\n{chunk.text}" for chunk in batch) |
| user_text = ( |
| f"Question:\n{question}\n\n" |
| f"Chunks:\n{chunk_text}\n\n" |
| f"Return JSON matching this schema:\n{json.dumps(schema)}" |
| ) |
|
|
| if provider_client.name == "openai": |
| response = provider_client.client.responses.create( |
| model=provider_client.default_model, |
| input=[ |
| {"role": "system", "content": prompt}, |
| {"role": "user", "content": user_text}, |
| ], |
| response_format={ |
| "type": "json_schema", |
| "json_schema": {"name": "ChunkScan", "schema": schema}, |
| }, |
| **_openai_reasoning_kwargs(provider_client), |
| ) |
| return _extract_openai_json(response) |
| if provider_client.name == "qwen": |
| response = provider_client.client.chat.completions.create( |
| model=provider_client.default_model, |
| messages=[ |
| {"role": "system", "content": prompt}, |
| {"role": "user", "content": user_text}, |
| ], |
| temperature=0, |
| ) |
| return _extract_chat_completion_text(response) |
| if provider_client.name == "anthropic": |
| response = provider_client.client.messages.create( |
| model=provider_client.default_model, |
| max_tokens=1200, |
| system=prompt, |
| messages=[{"role": "user", "content": [{"type": "text", "text": user_text}]}], |
| **_anthropic_thinking_kwargs(provider_client), |
| ) |
| return response.content[0].text |
| if provider_client.name == "cohere": |
| response = provider_client.client.chat( |
| message=user_text, |
| model=provider_client.default_model, |
| preamble=prompt, |
| max_tokens=1200, |
| temperature=0, |
| response_format={ |
| "type": "json_object", |
| "schema": schema, |
| }, |
| ) |
| return _extract_cohere_text(response) |
|
|
| genai = provider_client.client |
| model = genai.GenerativeModel( |
| model_name=provider_client.default_model, |
| system_instruction=prompt, |
| generation_config=genai.GenerationConfig( |
| temperature=0, |
| response_mime_type="application/json", |
| ), |
| ) |
| response = model.generate_content(user_text) |
| return _extract_gemini_text(response) |
|
|
|
|
| def _merge_scan_matches(matches: list[ScanMatch]) -> list[ScanMatch]: |
| by_chunk_id: dict[int, ScanMatch] = {} |
| for match in matches: |
| existing = by_chunk_id.get(match.chunk_id) |
| candidate_snippet = match.evidence_snippet.strip() |
| if existing is None or match.relevance_score > existing.relevance_score: |
| by_chunk_id[match.chunk_id] = match |
| elif ( |
| existing.relevance_score == match.relevance_score |
| and candidate_snippet |
| and len(candidate_snippet) > len(existing.evidence_snippet.strip()) |
| ): |
| by_chunk_id[match.chunk_id] = match |
| return sorted( |
| by_chunk_id.values(), |
| key=lambda item: (-item.relevance_score, item.chunk_id), |
| ) |
|
|
|
|
| def _truncate_snippet(text: str, limit: int = 220) -> str: |
| compact = " ".join(text.split()) |
| if len(compact) <= limit: |
| return compact |
| return compact[: limit - 3].rstrip() + "..." |
|
|
|
|
| def _extract_openai_json(response: Any) -> str: |
| buffer = [] |
| output_items = getattr(response, "output", None) |
| if output_items: |
| for item in output_items: |
| for block in item.content: |
| if getattr(block, "type", None) == "output_json_schema": |
| buffer.append(block.text) |
| elif getattr(block, "type", None) == "output_text": |
| buffer.append(block.text) |
| if buffer: |
| return "".join(buffer) |
| if hasattr(response, "output_text"): |
| return response.output_text |
| return "" |
|
|
|
|
| def _extract_openai_text(response: Any) -> str: |
| buffer = [] |
| output_items = getattr(response, "output", None) |
| if output_items: |
| for item in output_items: |
| for block in item.content: |
| if getattr(block, "type", None) in ("output_text", "output_message"): |
| buffer.append(block.text) |
| if buffer: |
| return "".join(buffer) |
| if hasattr(response, "output_text"): |
| return response.output_text |
| return "" |
|
|
|
|
| def _extract_chat_completion_text(response: Any) -> str: |
| choices = getattr(response, "choices", None) or [] |
| if not choices: |
| return "" |
| message = getattr(choices[0], "message", None) |
| if message is None: |
| return "" |
| content = getattr(message, "content", None) |
| if isinstance(content, str): |
| return _strip_thinking_block(content) |
| if isinstance(content, list): |
| parts = [] |
| for item in content: |
| text = getattr(item, "text", None) |
| if text: |
| parts.append(text) |
| return _strip_thinking_block("".join(parts)) |
| return "" |
|
|
|
|
| def _extract_gemini_text(response: Any) -> str: |
| text = getattr(response, "text", None) |
| if text: |
| return text |
| candidates = getattr(response, "candidates", None) |
| if candidates: |
| parts = [] |
| for candidate in candidates: |
| content = getattr(candidate, "content", None) |
| if not content: |
| continue |
| for part in getattr(content, "parts", []) or []: |
| segment = getattr(part, "text", None) |
| if segment: |
| parts.append(segment) |
| if parts: |
| return "".join(parts) |
| return "" |
|
|
|
|
| def _extract_cohere_text(response: Any) -> str: |
| message = getattr(response, "message", None) |
| if message: |
| content = getattr(message, "content", None) or [] |
| parts = [] |
| for item in content: |
| text = getattr(item, "text", None) |
| if text: |
| parts.append(text) |
| if parts: |
| return "".join(parts) |
| text = getattr(response, "text", None) |
| if text: |
| return text |
| return "" |
|
|
|
|
| def _strip_json_fences(value: str) -> str: |
| stripped = value.strip() |
| if stripped.startswith("```json"): |
| stripped = stripped[7:] |
| elif stripped.startswith("```"): |
| stripped = stripped[3:] |
| if stripped.endswith("```"): |
| stripped = stripped[:-3] |
| return stripped.strip() |
|
|
|
|
| def _strip_thinking_block(value: str) -> str: |
| stripped = value.strip() |
| without_think = re.sub(r"(?is)<think>.*?</think>", "", stripped).strip() |
| if without_think: |
| return without_think |
| if "</think>" in stripped: |
| _, _, tail = stripped.rpartition("</think>") |
| return tail.strip() |
| return stripped |
|
|