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)