Rifqi Hafizuddin
Claude Opus 5
[NOTICKET] fix: gate spend in cluster units and surface the estimate (Step 6)
cdb5fe8 Download src/knowledge_ingest/pipeline.py from DataEyond/Agentic-Service-Data-Eyond-Catalog: direct link, hf CLI and curl.
- Browser
- Download file 19.3 kB
-
https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/refs%2Fpr%2F21/src/knowledge_ingest/pipeline.py
- Command line
-
hf download hf://spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog@refs/pr/21/src/knowledge_ingest/pipeline.py
-
curl -L -o pipeline.py https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/refs%2Fpr%2F21/src/knowledge_ingest/pipeline.py
19.3 kB
| """The real `PipelinePort` - one document, blob to persisted rows (E1-E4). | |
| Stages, ordered by what they cost: | |
| download blob -> a temp file free | |
| parse Mistral OCR, per page PAID (cheap) | |
| filter GLiNER + regex free | |
| cluster grouping mentions into term clusters free | |
| estimate builds the exact prompts, sends none free | |
| ---------------------------------------- the spend gate ---------------- | |
| extract ~90 LLM calls for nine pages PAID (the real cost) | |
| persist artifact + run + entries free | |
| The gate sits where it does because `estimate_cost()` needs a parsed, clustered | |
| document to be exact - it builds the real prompts and sends nothing. So a job | |
| can refuse *before* the expensive stage while still quoting a real number, which | |
| is the difference between a spend ceiling and a guess. | |
| This module owns no extraction logic. Parsing is `src/knowledge_parsing/` | |
| (Sofhia) and extraction is `src/knowledge_extraction/` (Rifqi); both are called | |
| through their public entry points and neither is reimplemented here. | |
| **`doc_id` is `documents.id`, deliberately.** Every `entity_id` downstream is | |
| derived from it (`ids.py`), so a filename-derived key means renaming a file | |
| mints all-new ids and orphans every expert approval recorded against the old | |
| ones. `parse_one` accepts the key but cannot enforce it - passing the stable one | |
| is this caller's job, and is the whole point of the parsing-side change. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import tempfile | |
| from concurrent.futures import ThreadPoolExecutor | |
| from functools import partial | |
| from pathlib import Path | |
| from src.db.postgres.models import Document | |
| from src.knowledge_extraction.genre import classify as classify_genre | |
| from src.knowledge_extraction.service import ( | |
| build_clusters, | |
| estimate_cost, | |
| extract_all, | |
| run_filters, | |
| ) | |
| from src.knowledge_ingest.knowledge_store import KnowledgeStore | |
| from src.knowledge_ingest.models import DocumentResult | |
| from src.middlewares.logging import get_logger | |
| logger = get_logger("knowledge_pipeline") | |
| # Refuse a document whose estimate exceeds this. Deliberately generous - it is a | |
| # runaway guard, not a budget: the reference nine-page standard estimates around | |
| # 150k prompt tokens, so this stops a 2,000-page handbook being ingested by | |
| # accident without getting in the way of anything real. | |
| MAX_PROMPT_TOKENS = 2_000_000 | |
| # The SECOND ceiling, and the one that tracks what actually scales. | |
| # | |
| # Spend is driven by CLUSTER COUNT, not pages: the pipeline makes one call per | |
| # term cluster, so a dense chapter costs more than a long simple one. Two things | |
| # the token ceiling alone cannot see: | |
| # | |
| # 1. **Per-call cost is not linear in prompt tokens.** Each call carries its | |
| # own completion tokens and its own latency, and the fixed cached prefix | |
| # becomes a smaller share of each prompt as evidence grows - so the CACHE | |
| # HIT RATE degrades as a run gets bigger, and the real bill rises faster | |
| # than the token estimate does. | |
| # 2. **The estimate is partly an approximation** (see `estimate_cost`), while | |
| # cluster count is exact and known for free before anything is spent. | |
| # | |
| # This became urgent rather than theoretical on 2026-09-10: retiring the fuzzy | |
| # clustering pass was correct, and it produces MORE, SMALLER clusters - the Open | |
| # Pit textbook's single 46-page chapter went from 56% to 71% of the token | |
| # ceiling on that change alone. A ceiling that only counts tokens would have let | |
| # the next such improvement through unnoticed. | |
| # | |
| # Measured free on the three v2 artifacts, 2026-09-11: | |
| # | |
| # clusters calls est. prompt tokens % of the token ceiling | |
| # BUMA 83 106 201,751 10.1% | |
| # Komatsu 93 95 227,329 11.4% | |
| # Open Pit 427 494 1,560,548 78.0% | |
| # | |
| # Read those honestly: at this corpus's density (~3,200 prompt tokens per | |
| # cluster) the TOKEN ceiling binds first, at roughly 630 clusters. So this | |
| # ceiling is not the tighter of the two for documents that look like these - it | |
| # is the guard for the other shape, a document with many cheap clusters, where | |
| # the cost is call COUNT rather than prompt size: 494 calls already runs about a | |
| # quarter of an hour, and a batch is twenty documents. | |
| # | |
| # 750 is ~1.75x the largest real document measured. Generous enough not to | |
| # obstruct anything observed, tight enough that one document cannot occupy the | |
| # single-flight worker for an hour. Raise it with a measurement, not a hunch. | |
| MAX_CLUSTERS = 750 | |
| # Extraction is synchronous and blocks for ~170 s on nine pages, so it cannot run | |
| # on the event loop. Its own pool, following the #49 `dbexec` precedent: sharing | |
| # the default `to_thread` pool means a long parse competes with every other | |
| # `to_thread` caller in the process, notably the tabular Parquet loader. | |
| # | |
| # One worker, not several: the point is to get the work OFF the loop, not to run | |
| # batches concurrently. Two concurrent extractions would hold two ~1.2 GB GLiNER | |
| # models resident, which is how this falls over on a Space. | |
| _EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="knowledge") | |
| # Which branches a run covers, in `extract_all`'s own vocabulary. Recorded on | |
| # the run row so a later comparison knows what was actually ATTEMPTED rather | |
| # than inferring it from what happened to appear. | |
| # | |
| # Note "summary" is the branch that produces the DocumentBrief - the branch kept | |
| # its original name through the v3 rename, while the entity and the entry `kind` | |
| # are both "document". Passing "document" here silently extracts nothing. | |
| BRANCHES = ("glossary", "rule", "formula", "summary") | |
| class SpendCeilingExceededError(RuntimeError): | |
| """The pre-flight estimate exceeded a ceiling; nothing was spent. | |
| Names WHICH ceiling bound and by how much. An operator's next move differs | |
| completely between the two - a cluster refusal means the document is dense | |
| and wants splitting, a token refusal means it is simply long - and "over | |
| budget" tells them neither. | |
| """ | |
| def __init__(self, estimate: dict, ceiling: str) -> None: | |
| self.estimate = estimate | |
| self.ceiling = ceiling | |
| if ceiling == "clusters": | |
| measured, limit, unit = estimate.get("clusters", 0), MAX_CLUSTERS, "term clusters" | |
| else: | |
| measured, limit, unit = ( | |
| estimate.get("estimated_prompt_tokens", 0), | |
| MAX_PROMPT_TOKENS, | |
| "prompt tokens", | |
| ) | |
| self.measured, self.limit = measured, limit | |
| super().__init__( | |
| f"estimated {measured:,} {unit} exceeds the ceiling of {limit:,} " | |
| f"({measured / limit:.0%} of it) — nothing was spent; " | |
| f"{estimate.get('total_calls', 0):,} LLM calls were declined" | |
| ) | |
| def check_ceilings(estimate: dict) -> None: | |
| """Raise if either ceiling binds. Clusters are checked FIRST because they | |
| are exact where the token figure is partly approximate.""" | |
| if (estimate.get("clusters") or 0) > MAX_CLUSTERS: | |
| raise SpendCeilingExceededError(estimate, "clusters") | |
| if (estimate.get("estimated_prompt_tokens") or 0) > MAX_PROMPT_TOKENS: | |
| raise SpendCeilingExceededError(estimate, "prompt_tokens") | |
| def _extractor(): | |
| """The Azure client the extraction branches call. | |
| Built here rather than injected so the pipeline has one obvious wiring | |
| point; `AzureExtractor` raises if its deployment quad is unset, which is a | |
| better failure than a run that starts and then fails on its first call. | |
| """ | |
| from src.knowledge_extraction.extract import AzureExtractor | |
| return AzureExtractor() | |
| def _storage(): | |
| """The configured blob backend, chosen the same way `TabularExecutor` does.""" | |
| from src.config.settings import settings | |
| provider = (settings.storage_provider or "").strip().lower() | |
| if provider == "supabase_s3": | |
| from src.storage.object_storage.supabase_s3 import SupabaseS3Storage | |
| return SupabaseS3Storage() | |
| from src.storage.az_blob.az_blob import AzureBlobStorage | |
| return AzureBlobStorage() | |
| class KnowledgePipeline: | |
| """Run one document end to end and report its outcome. | |
| Returns a failed `DocumentResult` rather than raising for expected failures | |
| (an unreadable blob, a spend refusal); the runner records either shape | |
| identically, so neither can take a batch down. | |
| """ | |
| def __init__(self, store: KnowledgeStore | None = None) -> None: | |
| self._store = store or KnowledgeStore() | |
| # The most recent pre-flight estimate, so `run_document` can report it | |
| # on the SUCCESS path too (a refusal carries its own on the exception). | |
| # Safe as instance state only because documents are run one at a time — | |
| # `IngestService` holds a single-flight lock and `_EXECUTOR` has one | |
| # worker. If either ever changes, pass it back instead of stashing it. | |
| self.last_estimate: dict | None = None | |
| async def run_document( | |
| self, document: Document, scope_id: str, user_id: str | |
| ) -> DocumentResult: | |
| result = DocumentResult( | |
| document_id=document.id, filename=document.filename, status="pending" | |
| ) | |
| try: | |
| artifact = await self._parse(document) | |
| except Exception as e: | |
| logger.error( | |
| "knowledge_parse_failed", | |
| extra={"document_id": document.id, "error": repr(e)}, | |
| ) | |
| result.status = "failed" | |
| result.error = f"parse failed: {e!r}" | |
| return result | |
| doc_row = await self._store.save_document( | |
| artifact, scope_id=scope_id, user_id=user_id, document_id=document.id | |
| ) | |
| result.knowledge_document_id = doc_row.id | |
| try: | |
| n_entries, run_id = await self.extract_and_persist( | |
| artifact, doc_row.id, scope_id=scope_id, user_id=user_id | |
| ) | |
| except SpendCeilingExceededError as e: | |
| logger.warning( | |
| "knowledge_spend_ceiling", | |
| extra={ | |
| "document_id": document.id, | |
| "ceiling": e.ceiling, | |
| "measured": e.measured, | |
| "limit": e.limit, | |
| "estimate": e.estimate, | |
| }, | |
| ) | |
| result.status = "failed" | |
| result.error = str(e) | |
| result.estimate = e.estimate | |
| result.blocked_by = e.ceiling | |
| return result | |
| except Exception as e: | |
| logger.error( | |
| "knowledge_extract_failed", | |
| extra={"document_id": document.id, "error": repr(e)}, | |
| ) | |
| result.status = "failed" | |
| result.error = f"extraction failed: {e!r}" | |
| return result | |
| result.run_id = run_id | |
| result.n_entries = n_entries | |
| result.estimate = self.last_estimate | |
| result.status = "succeeded" | |
| return result | |
| async def _parse(self, document: Document): | |
| """Blob -> `ParsedDocument`, with `documents.id` as the stable key.""" | |
| from src.knowledge_parsing.config import PipelineConfig | |
| from src.knowledge_parsing.run import parse_one | |
| storage = _storage() | |
| content = await storage.download_file(document.blob_name) | |
| suffix = Path(document.filename or "").suffix or ".pdf" | |
| with tempfile.TemporaryDirectory(prefix="knowledge-ingest-") as tmp: | |
| # Named for the original file so anything reading `source_path` or | |
| # `source_title` sees something a human recognises; identity comes | |
| # from `doc_id`, which is passed explicitly. | |
| source = Path(tmp) / f"{Path(document.filename or 'document').stem}{suffix}" | |
| source.write_bytes(content) | |
| cfg = PipelineConfig(input_dir=Path(tmp)) | |
| loop = asyncio.get_running_loop() | |
| return await loop.run_in_executor( | |
| _EXECUTOR, parse_one, source, cfg, document.id | |
| ) | |
| async def extract_and_persist( | |
| self, | |
| artifact, | |
| knowledge_document_id: str, | |
| *, | |
| scope_id: str, | |
| user_id: str, | |
| active_glossary: list[dict] | None = None, | |
| ) -> tuple[int, str]: | |
| """Filter, cluster, gate on cost, extract, persist. Returns (rows, run_id). | |
| Also the entry point for re-extracting a stored artifact, which needs no | |
| parsing at all - prompt retuning is the common case and must not re-pay | |
| the parser for a byte-identical result. | |
| `active_glossary` defaults to **the tenant's approved entries, | |
| fetched here**. Passing it was previously the caller's job and the | |
| ingest path forgot, so every entry came back `diff_status="new"`: the | |
| diff ran against an empty list, and a second document never showed as | |
| duplicating or contradicting what an expert had already approved - which | |
| is exactly the cross-document dedup that makes the queue finishable. | |
| Fetching it at the single point that needs it means no caller can omit | |
| it again. Pass an explicit `[]` to diff against nothing. | |
| """ | |
| from src.knowledge_extraction.adapter import parsed_doc_from_artifact | |
| if active_glossary is None: | |
| active_glossary = await self._store.approved_glossary(scope_id) | |
| doc = parsed_doc_from_artifact(artifact) | |
| loop = asyncio.get_running_loop() | |
| # Free stages, but CPU-bound (GLiNER), so still off the loop. | |
| filtered = await loop.run_in_executor(_EXECUTOR, run_filters, doc) | |
| clustered = await loop.run_in_executor(_EXECUTOR, build_clusters, doc, filtered) | |
| estimate = estimate_cost(doc, clustered, filtered) | |
| # What KIND of document this is, decided before anything is spent. | |
| # Advisory today — see `genre.ROUTE_LABELS` / `ROUTE_BRANCHES`, both | |
| # off — but recorded, because "104 calls, 93 abstentions" is a fact an | |
| # operator should be able to see the reason for. | |
| # | |
| # A never-throw seam, like every other observability path here: this | |
| # describes the run, it does not decide it, and a classifier defect must | |
| # not take down an extraction that was about to work. `repr(e)` because | |
| # an empty `str(e)` is how the Fernet mismatch hid for a day. | |
| try: | |
| verdict = classify_genre(doc, filtered) | |
| estimate["genre"] = verdict.as_dict() | |
| for warning in verdict.warnings: | |
| logger.warning( | |
| "knowledge_genre_warning", | |
| extra={ | |
| "doc_id": doc.doc_id, | |
| "genre": verdict.genre, | |
| "warning": warning, | |
| }, | |
| ) | |
| except Exception as e: # noqa: BLE001 - described above | |
| logger.warning( | |
| "knowledge_genre_failed", | |
| extra={"doc_id": doc.doc_id, "error": repr(e)}, | |
| ) | |
| self.last_estimate = estimate | |
| # Logged on EVERY run, not only on a refusal: an operator who can only | |
| # see the number when it is already too late cannot plan a batch, and | |
| # "why was this refused" is answerable only against what a run that | |
| # succeeded looked like. | |
| logger.info( | |
| "knowledge_pre_flight_estimate", | |
| extra={ | |
| "doc_id": doc.doc_id, | |
| "clusters": estimate.get("clusters"), | |
| "total_calls": estimate.get("total_calls"), | |
| "estimated_prompt_tokens": estimate.get("estimated_prompt_tokens"), | |
| "cluster_ceiling_pct": round( | |
| 100 * (estimate.get("clusters") or 0) / MAX_CLUSTERS, 1 | |
| ), | |
| "token_ceiling_pct": round( | |
| 100 * (estimate.get("estimated_prompt_tokens") or 0) / MAX_PROMPT_TOKENS, | |
| 1, | |
| ), | |
| }, | |
| ) | |
| check_ceilings(estimate) | |
| # Built before the run row so a missing Azure deployment fails without | |
| # leaving an orphan `running` row behind. | |
| extractor = _extractor() | |
| run = await self._store.start_run( | |
| knowledge_document_id=knowledge_document_id, | |
| doc_id=doc.doc_id, | |
| scope_id=scope_id, | |
| user_id=user_id, | |
| branches=list(BRANCHES), | |
| model_deployment=getattr(extractor, "deployment", None), | |
| ) | |
| # EVERYTHING after `start_run` is inside this block. It used to cover only | |
| # `extract_all`, so a failure in persistence left the run row `running` | |
| # with n_calls=0 - a row claiming nothing happened for a run that had | |
| # already spent ~90 LLM calls. An expensive failure must leave an honest | |
| # trace, which is the whole reason the row is opened before the spend. | |
| try: | |
| out = await loop.run_in_executor( | |
| _EXECUTOR, | |
| partial( | |
| extract_all, | |
| doc, | |
| clustered, | |
| filtered, | |
| extractor, | |
| active_glossary=active_glossary or None, | |
| branches=BRANCHES, | |
| ), | |
| ) | |
| entries = { | |
| "glossary": out.glossary, | |
| "rule": out.rules, | |
| "formula": out.formulas, | |
| "document": [out.brief] if out.brief else [], | |
| } | |
| n = await self._store.save_entries( | |
| run_id=run.id, | |
| knowledge_document_id=knowledge_document_id, | |
| doc_id=doc.doc_id, | |
| scope_id=scope_id, | |
| user_id=user_id, | |
| entries_by_kind=entries, | |
| ) | |
| except Exception as e: | |
| # The run row is closed even on failure: an expensive extraction | |
| # that crashed must leave a trace, not just an absence. Persistence | |
| # is inside the block for the same reason - a UNIQUE violation on | |
| # entry ids used to leave the row `running` with n_calls=0, claiming | |
| # nothing had happened for a run that had already been paid for. | |
| await self._store.finish_run( | |
| run.id, status="failed", error_message=repr(e) | |
| ) | |
| raise | |
| await self._store.finish_run( | |
| run.id, | |
| status="succeeded", | |
| usages=[u.model_dump() if hasattr(u, "model_dump") else dict(u) for u in out.usages], | |
| rejected=[ | |
| r.model_dump() if hasattr(r, "model_dump") else dict(r) | |
| for r in out.rejected | |
| ], | |
| counts={ | |
| "entries": n, | |
| "glossary": len(out.glossary), | |
| "rules": len(out.rules), | |
| "formulas": len(out.formulas), | |
| "queue": len(out.review_queue), | |
| **{f"link_{k}": v for k, v in (out.links or {}).items()}, | |
| }, | |
| ) | |
| return n, run.id | |