File size: 14,833 Bytes
b68816f cdb5fe8 b68816f 6ef7990 b68816f 6ef7990 b68816f 6ef7990 b68816f cdb5fe8 b68816f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 | """IngestService — the job lifecycle: select, admit, run, settle.
Owns *orchestration only*. Parsing lives in `src/knowledge_parsing/` (Sofhia)
and extraction in `src/knowledge_extraction/` (Rifqi); this module knows neither,
and reaches both through `PipelinePort`. That indirection is not decoration —
the single-document parsing entry point does not exist yet (N9 in
KNOWLEDGE_SURFACE_PLAN.md: `run.py` is folder-oriented and its per-document glue
is exactly what N9 extracts), so the port is what lets the job lifecycle be
built and verified before it lands.
Two rules this module exists to enforce:
- **Single-flight per scope.** Two concurrent runs mean two 1.2 GB GLiNER models
resident at once. A second click while a job is in flight returns the job
already running, rather than queueing a second one.
- **One bad document does not fail the batch.** Each document is attempted
independently and its outcome recorded; the job settles `partial` when some
succeeded and some did not.
"""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from typing import Protocol, runtime_checkable
from src.db.postgres.models import Document
from src.knowledge_ingest.knowledge_store import KnowledgeStore
from src.knowledge_ingest.models import DocumentResult, IngestJob, SkippedDocument
from src.knowledge_ingest.pipeline import KnowledgePipeline
from src.knowledge_ingest.selector import (
resolve_scope_id,
select_documents,
)
from src.knowledge_ingest.store import JobStore, PostgresJobStore
from src.middlewares.logging import get_logger
logger = get_logger("knowledge_ingest")
def _roll_up_estimates(results: list[DocumentResult]) -> dict | None:
"""The batch's pre-flight shape, per document and in total.
`IngestJob.estimated_cost` has existed since the job model was written and
was never populated, so the field a caller would poll to answer "what is
this going to cost, and why was that document refused" always came back
null. Spend is driven by CLUSTER COUNT — one LLM call per term cluster — so
the per-document breakdown is what an operator needs to see; a total alone
cannot tell them which document to split.
"""
estimates = [(r.document_id, r.estimate) for r in results if r.estimate]
if not estimates:
return None
return {
"clusters": sum(e.get("clusters") or 0 for _, e in estimates),
"total_calls": sum(e.get("total_calls") or 0 for _, e in estimates),
"estimated_prompt_tokens": sum(
e.get("estimated_prompt_tokens") or 0 for _, e in estimates
),
"per_document": {
doc_id: {
"clusters": e.get("clusters"),
"total_calls": e.get("total_calls"),
"estimated_prompt_tokens": e.get("estimated_prompt_tokens"),
}
for doc_id, e in estimates
},
"blocked": {
r.document_id: r.blocked_by for r in results if r.blocked_by
},
}
@runtime_checkable
class PipelinePort(Protocol):
"""Run the whole pipeline over ONE document and report its outcome.
Implementations own everything between the blob and the persisted rows:
download, parse, filter, cluster, estimate, extract, persist. They report
failure by returning a `DocumentResult` with `status="failed"` — a raised
exception is caught by the runner and recorded the same way, so neither
style can take the batch down.
"""
async def run_document(
self, document: Document, scope_id: str, user_id: str
) -> DocumentResult: ...
class UnavailablePipeline:
"""Placeholder until the parsing entry point (N9) and the tables land.
Fails every document with a legible reason instead of pretending to work.
Deliberately not a silent no-op: a job that reports success having done
nothing is the single most expensive way to be wrong here.
"""
REASON = (
"pipeline unavailable: single-document parsing entry point not built (N9) "
"and knowledge_* tables not migrated (contract pending Harry)"
)
async def run_document(
self, document: Document, scope_id: str, user_id: str
) -> DocumentResult:
return DocumentResult(
document_id=document.id,
filename=document.filename,
status="failed",
error=self.REASON,
)
class IngestService:
"""Create and drive ingestion jobs."""
def __init__(
self, store: JobStore | None = None, pipeline: PipelinePort | None = None
) -> None:
self._store = store or PostgresJobStore()
self._pipeline = pipeline or KnowledgePipeline()
self._admit = asyncio.Lock()
# Admission is per scope; EXECUTION is serialised process-wide. Two
# scopes may each have a job admitted, but they must not extract at the
# same time — the GLiNER span model is ~1.2 GB resident and two copies
# is how this falls over on a Space.
self._knowledge = KnowledgeStore()
self._run_lock = asyncio.Lock()
self._tasks: set[asyncio.Task] = set()
async def get(self, job_id: str) -> IngestJob | None:
return await self._store.get(job_id)
async def list_for_user(self, user_id: str, limit: int = 20) -> list[IngestJob]:
return await self._store.list_for_user(user_id, limit)
async def start(
self,
user_id: str,
document_ids: list[str] | None = None,
review_mode: str = "expert",
) -> tuple[IngestJob, bool]:
"""Admit a batch. Returns (job, reused).
`reused=True` means a job for this scope was already in flight and is
being returned instead of a second one being started. The button asks
for a state, not a command, so a double click is not an error.
"""
scope_id = await resolve_scope_id(user_id)
# Admission is serialised so two simultaneous clicks cannot both find
# the scope idle and each start a run.
async with self._admit:
active = await self._store.find_active(scope_id)
if active is not None:
logger.info(
"ingest_job_reused",
extra={"job_id": active.id, "scope_id": scope_id},
)
return active, True
selected, skipped = await select_documents(user_id, document_ids)
job = IngestJob(
scope_id=scope_id,
user_id=user_id,
review_mode=review_mode,
document_ids=[d.id for d in selected],
n_requested=len(selected),
results=[
DocumentResult(document_id=d.id, filename=d.filename)
for d in selected
],
skipped=[
SkippedDocument(
document_id=d.id, filename=d.filename, reason=reason
)
for d, reason in skipped
],
)
# Nothing to do is a terminal state, not a run. Settling here keeps
# the caller from polling a job that will never move.
if not selected:
job.status = "succeeded" if not skipped else "failed"
job.error_message = (
None if not skipped else "No parseable documents in this batch."
)
job.completed_at = datetime.now(UTC)
await self._store.create(job)
return job, False
await self._store.create(job)
self._spawn(job, selected)
return job, False
def _spawn(self, job: IngestJob, documents: list[Document]) -> None:
"""Run the batch in the background; the request does not wait on it."""
task = asyncio.create_task(self._run(job, documents))
# asyncio holds only a weak reference to a running task, so without this
# a batch can be garbage-collected mid-flight. Discarded on completion.
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _run(self, job: IngestJob, documents: list[Document]) -> None:
async with self._run_lock:
await self._run_serialised(job, documents)
async def _run_serialised(self, job: IngestJob, documents: list[Document]) -> None:
job.status = "running"
job.started_at = datetime.now(UTC)
await self._store.save(job)
by_id = {r.document_id: r for r in job.results}
for document in documents:
try:
result = await self._pipeline.run_document(
document, job.scope_id, job.user_id
)
except Exception as e: # one document must not take the batch down
logger.error(
"ingest_document_failed",
extra={
"job_id": job.id,
"document_id": document.id,
"error": repr(e),
},
)
result = DocumentResult(
document_id=document.id,
filename=document.filename,
status="failed",
error=repr(e),
)
# r1: in `llm` mode there is no reviewer step - the run's
# entries are approved as soon as they land, recorded against
# the person who chose the mode (r2/r3). Never-throw: an
# auto-approval that fails leaves the entries REVIEWABLE, which
# is the safe direction, and must not fail the document.
if job.review_mode == "llm" and result.status == "succeeded" and result.run_id:
try:
await self._knowledge.auto_approve_run(
run_id=result.run_id,
scope_id=job.scope_id,
authorised_by=job.user_id,
)
except Exception as e: # noqa: BLE001
logger.error(
"ingest_auto_approve_failed",
extra={"job_id": job.id, "run_id": result.run_id, "error": repr(e)},
)
by_id[result.document_id] = result
job.results = list(by_id.values())
job.estimated_cost = _roll_up_estimates(job.results)
await self._store.save(job)
job.settle()
await self._store.save(job)
logger.info(
"ingest_job_settled",
extra={
"job_id": job.id,
"status": job.status,
"succeeded": job.n_succeeded,
"failed": job.n_failed,
},
)
async def start_reextract(
self, user_id: str, knowledge_document_id: str
) -> tuple[IngestJob, bool]:
"""Admit a re-extraction of one stored artifact. Returns (job, reused).
Shares the job machinery with `/ingest` — same single-flight rule, same
polling route — but skips parsing entirely, which is the point: prompt
retuning is the common case and re-parsing a byte-identical artifact
costs money for an identical result.
"""
scope_id = await resolve_scope_id(user_id)
async with self._admit:
active = await self._store.find_active(scope_id)
if active is not None:
logger.info(
"ingest_job_reused",
extra={"job_id": active.id, "scope_id": scope_id},
)
return active, True
document = await self._knowledge.get_document(
knowledge_document_id, scope_id
)
job = IngestJob(scope_id=scope_id, user_id=user_id)
# Tenant-checked in SQL, so a wrong scope is indistinguishable from a
# wrong id — deliberately: neither should confirm the row exists.
if document is None:
job.status = "failed"
job.error_message = (
f"No stored artifact {knowledge_document_id} in this scope."
)
job.completed_at = datetime.now(UTC)
await self._store.create(job)
return job, False
# `document_id` is the source `documents` row; it is null for an
# artifact parsed by the CLI rather than ingested, and the result
# falls back to the artifact's own key so the row is still legible.
job.document_ids = [knowledge_document_id]
job.n_requested = 1
job.results = [
DocumentResult(
document_id=document.document_id or document.doc_id,
filename=None,
knowledge_document_id=document.id,
)
]
await self._store.create(job)
self._spawn_reextract(job, document)
return job, False
def _spawn_reextract(self, job: IngestJob, document) -> None:
task = asyncio.create_task(self._run_reextract(job, document))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _run_reextract(self, job: IngestJob, document) -> None:
async with self._run_lock:
job.status = "running"
job.stage = "extracting"
job.started_at = datetime.now(UTC)
await self._store.save(job)
result = job.results[0]
try:
# The approved set is fetched inside `extract_and_persist` for
# The approved set is fetched inside `extract_and_persist` for
# BOTH paths - it used to be passed here and forgotten on the
# ingest path, which silently disabled cross-document diffing.
# This is the edge that closes the loop: an expert's approvals
# become the baseline the next run classifies against.
n, run_id = await self._pipeline.extract_and_persist(
document.artifact,
document.id,
scope_id=job.scope_id,
user_id=job.user_id,
)
result.run_id = run_id
result.n_entries = n
result.status = "succeeded"
except Exception as e:
logger.error(
"reextract_failed",
extra={"job_id": job.id, "error": repr(e)},
)
result.status = "failed"
result.error = repr(e)
job.results = [result]
job.settle()
await self._store.save(job)
|