FlyingNunchucks commited on
Commit
f9f8af8
·
1 Parent(s): cdd2f89

Add traceable findings and observable document pipeline

Browse files
Files changed (1) hide show
  1. src/document_parser.py +134 -7
src/document_parser.py CHANGED
@@ -1,4 +1,5 @@
1
  import re
 
2
  from pathlib import Path
3
  from typing import List, Tuple
4
 
@@ -6,8 +7,10 @@ import fitz # PyMuPDF
6
 
7
  from src.document_schema import (
8
  DocumentChunk,
 
9
  DocumentIntelligenceReport,
10
  DocumentMetadata,
 
11
  DocumentSummary,
12
  )
13
 
@@ -137,7 +140,8 @@ def build_summary(full_text: str) -> DocumentSummary:
137
  key_facts = sentences[:5]
138
 
139
  risks = [
140
- s for s in sentences
 
141
  if sentence_matches_keywords(
142
  s,
143
  ["risk", "issue", "concern", "delay", "constraint", "challenge", "exposure", "dependency"],
@@ -145,7 +149,8 @@ def build_summary(full_text: str) -> DocumentSummary:
145
  ][:5]
146
 
147
  action_items = [
148
- s for s in sentences
 
149
  if sentence_matches_keywords(
150
  s,
151
  ["should", "must", "recommend", "next step", "action", "approve", "review", "complete"],
@@ -162,11 +167,89 @@ def build_summary(full_text: str) -> DocumentSummary:
162
  )
163
 
164
 
165
- def parse_document(file_path: str) -> DocumentIntelligenceReport:
166
- """Parse a PDF or TXT file into a structured intelligence report."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  path = Path(file_path)
168
  suffix = path.suffix.lower()
169
 
 
 
 
 
 
170
  if suffix == ".pdf":
171
  page_texts, notes = extract_text_from_pdf(str(path))
172
  extraction_method = "PyMuPDF PDF text extraction"
@@ -177,7 +260,31 @@ def parse_document(file_path: str) -> DocumentIntelligenceReport:
177
  raise ValueError("Unsupported file type. Please upload a PDF or TXT file.")
178
 
179
  full_text = "\n\n".join(page_texts).strip()
 
 
 
 
 
 
 
180
  chunks = chunk_pages(page_texts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
  metadata = DocumentMetadata(
183
  file_name=path.name,
@@ -187,11 +294,31 @@ def parse_document(file_path: str) -> DocumentIntelligenceReport:
187
  parsing_notes=notes,
188
  )
189
 
190
- summary = build_summary(full_text)
191
-
192
- return DocumentIntelligenceReport(
193
  metadata=metadata,
194
  summary=summary,
195
  chunks=chunks,
196
  tables=[],
197
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import re
2
+ from collections.abc import Iterator
3
  from pathlib import Path
4
  from typing import List, Tuple
5
 
 
7
 
8
  from src.document_schema import (
9
  DocumentChunk,
10
+ DocumentFinding,
11
  DocumentIntelligenceReport,
12
  DocumentMetadata,
13
+ DocumentProcessingEvent,
14
  DocumentSummary,
15
  )
16
 
 
140
  key_facts = sentences[:5]
141
 
142
  risks = [
143
+ s
144
+ for s in sentences
145
  if sentence_matches_keywords(
146
  s,
147
  ["risk", "issue", "concern", "delay", "constraint", "challenge", "exposure", "dependency"],
 
149
  ][:5]
150
 
151
  action_items = [
152
+ s
153
+ for s in sentences
154
  if sentence_matches_keywords(
155
  s,
156
  ["should", "must", "recommend", "next step", "action", "approve", "review", "complete"],
 
167
  )
168
 
169
 
170
+ def _normalize(text: str) -> str:
171
+ return re.sub(r"\s+", " ", text).strip().lower()
172
+
173
+
174
+ def _source_location(text: str, page_texts: List[str], chunks: List[DocumentChunk]) -> tuple[int | None, int | None]:
175
+ """Find the source page and best matching chunk for an extracted item without inventing provenance."""
176
+ normalized = _normalize(text)
177
+ if not normalized:
178
+ return None, None
179
+
180
+ page_number: int | None = None
181
+ for index, page_text in enumerate(page_texts, start=1):
182
+ if normalized in _normalize(page_text):
183
+ page_number = index
184
+ break
185
+
186
+ if page_number is None:
187
+ return None, None
188
+
189
+ same_page_chunks = [chunk for chunk in chunks if chunk.page_number == page_number]
190
+ for chunk in same_page_chunks:
191
+ if normalized in _normalize(chunk.text):
192
+ return page_number, chunk.chunk_id
193
+
194
+ item_terms = set(re.findall(r"[a-zA-Z0-9]+", normalized))
195
+ best_chunk_id: int | None = None
196
+ best_overlap = 0
197
+ for chunk in same_page_chunks:
198
+ chunk_terms = set(re.findall(r"[a-zA-Z0-9]+", _normalize(chunk.text)))
199
+ overlap = len(item_terms & chunk_terms)
200
+ if overlap > best_overlap:
201
+ best_overlap = overlap
202
+ best_chunk_id = chunk.chunk_id
203
+
204
+ return page_number, best_chunk_id if best_overlap > 0 else None
205
+
206
+
207
+ def build_findings(
208
+ summary: DocumentSummary,
209
+ page_texts: List[str],
210
+ chunks: List[DocumentChunk],
211
+ ) -> List[DocumentFinding]:
212
+ """Attach deterministic source page/chunk lineage to structured summary items."""
213
+ groups = [
214
+ ("key_fact", "FACT", summary.key_facts),
215
+ ("entity", "ENTITY", summary.key_entities),
216
+ ("date", "DATE", summary.important_dates),
217
+ ("risk", "RISK", summary.risks_or_issues),
218
+ ("action_item", "ACTION", summary.action_items),
219
+ ]
220
+
221
+ findings: List[DocumentFinding] = []
222
+ for category, prefix, items in groups:
223
+ category_index = 1
224
+ for item in items:
225
+ page_number, chunk_id = _source_location(item, page_texts, chunks)
226
+ if page_number is None:
227
+ continue
228
+
229
+ findings.append(
230
+ DocumentFinding(
231
+ finding_id=f"{prefix}-{category_index:02d}",
232
+ category=category,
233
+ text=item,
234
+ page_number=page_number,
235
+ chunk_id=chunk_id,
236
+ )
237
+ )
238
+ category_index += 1
239
+
240
+ return findings
241
+
242
+
243
+ def parse_document_iter(file_path: str) -> Iterator[DocumentProcessingEvent]:
244
+ """Yield real stages from the deterministic document-intelligence pipeline."""
245
  path = Path(file_path)
246
  suffix = path.suffix.lower()
247
 
248
+ yield DocumentProcessingEvent(
249
+ event="document_received",
250
+ message=f"Document accepted for processing: {path.name}.",
251
+ )
252
+
253
  if suffix == ".pdf":
254
  page_texts, notes = extract_text_from_pdf(str(path))
255
  extraction_method = "PyMuPDF PDF text extraction"
 
260
  raise ValueError("Unsupported file type. Please upload a PDF or TXT file.")
261
 
262
  full_text = "\n\n".join(page_texts).strip()
263
+
264
+ yield DocumentProcessingEvent(
265
+ event="text_extracted",
266
+ message=f"Text extraction complete across {len(page_texts)} page(s). {notes}",
267
+ page_count=len(page_texts),
268
+ )
269
+
270
  chunks = chunk_pages(page_texts)
271
+ yield DocumentProcessingEvent(
272
+ event="chunks_created",
273
+ message=f"Created {len(chunks)} page-aware searchable chunk(s).",
274
+ page_count=len(page_texts),
275
+ chunk_count=len(chunks),
276
+ )
277
+
278
+ summary = build_summary(full_text)
279
+ summary.findings = build_findings(summary, page_texts, chunks)
280
+
281
+ yield DocumentProcessingEvent(
282
+ event="structure_extracted",
283
+ message=f"Extracted {len(summary.findings)} structured finding(s) with source lineage.",
284
+ page_count=len(page_texts),
285
+ chunk_count=len(chunks),
286
+ finding_count=len(summary.findings),
287
+ )
288
 
289
  metadata = DocumentMetadata(
290
  file_name=path.name,
 
294
  parsing_notes=notes,
295
  )
296
 
297
+ report = DocumentIntelligenceReport(
 
 
298
  metadata=metadata,
299
  summary=summary,
300
  chunks=chunks,
301
  tables=[],
302
  )
303
+
304
+ yield DocumentProcessingEvent(
305
+ event="report_completed",
306
+ message="Structured document intelligence report is ready for review, search, and export.",
307
+ page_count=len(page_texts),
308
+ chunk_count=len(chunks),
309
+ finding_count=len(summary.findings),
310
+ report=report,
311
+ )
312
+
313
+
314
+ def parse_document(file_path: str) -> DocumentIntelligenceReport:
315
+ """Parse a PDF or TXT file and return the final report from the observable pipeline."""
316
+ final_report: DocumentIntelligenceReport | None = None
317
+ for event in parse_document_iter(file_path):
318
+ if event.report is not None:
319
+ final_report = event.report
320
+
321
+ if final_report is None:
322
+ raise RuntimeError("Document processing completed without producing a report.")
323
+
324
+ return final_report