-
Notifications
You must be signed in to change notification settings - Fork 0
feat(rag): audit document ingestion, deletion, and chunk purges #577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,13 +2,49 @@ | |
|
|
||
| import asyncio | ||
|
|
||
| from sparkth.lib.audit import record_event, record_event_now, scrub_error_detail | ||
| from sparkth.lib.audit.events import ( | ||
| AuditChange, | ||
| AuditOutcome, | ||
| AuditTarget, | ||
| RAGDocumentIngestedAuditEvent, | ||
| ) | ||
| from sparkth.lib.db import session_scope | ||
| from sparkth.lib.log import get_logger | ||
| from sparkth.memory_profiler import profile_memory | ||
| from sparkth.rag.exceptions import ScannedPDFError, UnsupportedFileTypeError | ||
| from sparkth.rag.ingestion.chunking import DocumentChunker | ||
| from sparkth.rag.ingestion.extraction import check_extraction_eligibility, extract_to_markdown | ||
| from sparkth.rag.store import ChunkStoreService, store_and_link_chunks | ||
| from sparkth.rag.types import IngestionResult | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
|
|
||
| def _ingested_event( | ||
| document_id: int, | ||
| outcome: AuditOutcome, | ||
| *, | ||
| change: AuditChange | None = None, | ||
| error_detail: str | None = None, | ||
| ) -> RAGDocumentIngestedAuditEvent: | ||
| return RAGDocumentIngestedAuditEvent( | ||
| outcome=outcome, | ||
| target=AuditTarget(type="document", id=str(document_id)), | ||
| change=change, | ||
| error_detail=error_detail, | ||
| ) | ||
|
|
||
|
|
||
| def _success_change(filename: str, result: IngestionResult) -> AuditChange: | ||
| return AuditChange( | ||
| new={ | ||
| "filename": filename, | ||
| "new_chunks": result.new_chunks, | ||
| "reused_chunks": result.reused_chunks, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| async def ingest_document( | ||
| filename: str, | ||
|
|
@@ -21,6 +57,16 @@ async def ingest_document( | |
| content-hash dedup) -> link chunks to *document_id*. Opens and commits its | ||
| own database session. | ||
|
|
||
| Audited as a ``rag.document_ingested`` event. A success is recorded in the | ||
| same transaction as the chunk write (fail-closed, so unrecordable content | ||
| cannot enter the corpus). A failure is recorded, in its own transaction | ||
| before the error propagates, for the two eligibility/extraction errors this | ||
| function declares below; any other error (a parse failure inside an | ||
| extractor, a chunker error, a database error from the chunk write) | ||
| propagates without an audit record. Nothing entered the corpus in those | ||
| cases, so the trail stays accurate about the corpus itself, but it is not a | ||
| complete log of attempts. | ||
|
|
||
| Args: | ||
| document_id: Document.id recorded in the chunk-link table. | ||
| file_bytes: Raw file content. | ||
|
|
@@ -33,22 +79,38 @@ async def ingest_document( | |
| UnsupportedFileTypeError: type the extractors cannot handle. | ||
| ScannedPDFError: PDF appears scanned/image-only. | ||
| """ | ||
| check_extraction_eligibility(filename) | ||
| try: | ||
| check_extraction_eligibility(filename) | ||
|
|
||
| async with profile_memory("pipeline_total", file=filename): | ||
| async with profile_memory("extraction", file=filename, size_bytes=len(file_bytes)): | ||
| extraction_result = await asyncio.to_thread(extract_to_markdown, file_bytes, filename) | ||
| async with profile_memory("pipeline_total", file=filename): | ||
| async with profile_memory("extraction", file=filename, size_bytes=len(file_bytes)): | ||
| extraction_result = await asyncio.to_thread(extract_to_markdown, file_bytes, filename) | ||
|
|
||
| async with profile_memory("chunking", file=filename, markdown_chars=len(extraction_result.markdown)): | ||
| chunks = await asyncio.to_thread(DocumentChunker().chunk, extraction_result) | ||
| async with profile_memory("chunking", file=filename, markdown_chars=len(extraction_result.markdown)): | ||
| chunks = await asyncio.to_thread(DocumentChunker().chunk, extraction_result) | ||
|
|
||
| if not chunks: | ||
| return IngestionResult(new_chunks=0, reused_chunks=0) | ||
| if not chunks: | ||
| # Nothing was stored, so there is no store transaction to | ||
| # join; the attempt is still recorded, in its own transaction. | ||
| result = IngestionResult(new_chunks=0, reused_chunks=0) | ||
| await record_event_now( | ||
| _ingested_event(document_id, AuditOutcome.SUCCESS, change=_success_change(filename, result)) | ||
| ) | ||
| return result | ||
|
|
||
| store = ChunkStoreService() | ||
| async with session_scope() as session: | ||
| async with profile_memory("store_and_link", file=filename, chunks=len(chunks)): | ||
| new_count, reused_count = await store_and_link_chunks(session, document_id, chunks, store) | ||
| await session.commit() | ||
| store = ChunkStoreService() | ||
| async with session_scope() as session: | ||
| async with profile_memory("store_and_link", file=filename, chunks=len(chunks)): | ||
| new_count, reused_count = await store_and_link_chunks(session, document_id, chunks, store) | ||
| result = IngestionResult(new_chunks=new_count, reused_chunks=reused_count) | ||
| await record_event( | ||
| session, | ||
| _ingested_event(document_id, AuditOutcome.SUCCESS, change=_success_change(filename, result)), | ||
| ) | ||
| await session.commit() | ||
| except (UnsupportedFileTypeError, ScannedPDFError) as exc: | ||
| logger.warning("Ingestion of '%s' (document_id=%d) failed: %s", filename, document_id, exc) | ||
| await record_event_now(_ingested_event(document_id, AuditOutcome.FAILURE, error_detail=scrub_error_detail(exc))) | ||
| raise | ||
|
Comment on lines
+111
to
+114
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium — "every attempt is audited" doesn't hold for unexpected failures. The docstring (lines 60–64) states every attempt is audited, but a I understand the repo rule forbids swallowing via bare
Either is fine, but the docstring and the code should agree. Right now they don't.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reconciled by narrowing the claim (option 1) in db90834. The docstring now states that a failure event is recorded only for the two declared eligibility/extraction errors, and that anything else (extractor parse error, chunker error, database error from the chunk write) propagates without a record — noting that nothing entered the corpus in those cases, so the trail stays accurate about the corpus itself but is not a complete log of attempts. |
||
|
|
||
| return IngestionResult(new_chunks=new_count, reused_chunks=reused_count) | ||
| return result | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Low — docstring formatting. The leading spaces and the line that begins with
, so ...read as a mis-wrapped sentence:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in db90834, applied as suggested.