Skip to content

Commit 1a807df

Browse files
committed
Extract Office document text during live export
1 parent 01ab750 commit 1a807df

5 files changed

Lines changed: 195 additions & 45 deletions

File tree

logics/backlog/item_089_worker_text_extract_artifacts_for_sharepoint_documents.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
> From version: 1.5.1
44
> Schema version: 1.0
55
> Status: Done
6-
> Understanding: 94%
7-
> Confidence: 91%
6+
> Understanding: 98%
7+
> Confidence: 94%
88
> Progress: 100%
99
> Complexity: High
1010
> Theme: Data / Worker / Corpus
@@ -57,4 +57,5 @@ flowchart LR
5757

5858
- Implemented `RuntimeStore.extract_artifact_relative_path(...)` and worker live export extract artifact writing under the configured runtime store.
5959
- Corpus documents now carry `extractionStatus`, `extractionReason`, and `extractPath` when exported from the Graph worker path.
60+
- Extended worker extraction beyond text-like files by downloading bounded binary content and extracting OOXML body text for DOCX/PPTX/XLSX, with optional PDF text extraction through `pypdf`.
6061
- `rtk python3 -m pytest worker/tests/test_live_export_service.py` passed with coverage for successful text extract, metadata-only unsupported source, and empty text download classified as unreadable.

worker/app/services/live_export_service.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import hashlib
44
import html
5+
import io
56
import json
67
import os
78
import re
89
import time
10+
import zipfile
911
from dataclasses import dataclass
1012
from datetime import datetime, timezone
1113
from pathlib import Path
@@ -23,6 +25,9 @@
2325
TEXTUAL_MIME_PREFIXES = ("text/", "application/json", "application/xml", "application/xhtml+xml")
2426
TEXTUAL_EXTENSIONS = {"txt", "md", "markdown", "csv", "tsv", "json", "xml", "html", "htm", "aspx"}
2527
MAX_TEXT_DOWNLOAD_BYTES = 256 * 1024
28+
MAX_BINARY_EXTRACT_BYTES = 15 * 1024 * 1024
29+
OOXML_EXTENSIONS = {"docx", "pptx", "xlsx"}
30+
PDF_EXTENSIONS = {"pdf"}
2631

2732

2833
@dataclass
@@ -80,6 +85,13 @@ def get_text(self, path_or_url: str) -> Tuple[str, str]:
8085
content_type = response.headers.get("content-type", "")
8186
return response.text, content_type
8287

88+
def get_bytes(self, path_or_url: str) -> Tuple[bytes, str]:
89+
response = self._request("GET", path_or_url)
90+
if response.status_code >= 400:
91+
raise RuntimeError(f"Graph content request failed ({response.status_code})")
92+
content_type = response.headers.get("content-type", "")
93+
return response.content, content_type
94+
8395
def list_all(
8496
self,
8597
path_or_url: str,
@@ -568,11 +580,19 @@ def _crawl_drive_items(
568580
raw_text = ""
569581
extraction_reason = ""
570582
is_textual_item = self._is_textual_item(item_name, mime_type)
583+
is_binary_extractable = self._is_binary_extractable_item(item_name, mime_type)
571584
if is_textual_item and (not isinstance(item_size, int) or item_size <= MAX_TEXT_DOWNLOAD_BYTES):
572585
raw_text = self._try_download_text(client, f"/drives/{drive['id']}/items/{item['id']}")
573586
if not raw_text:
574587
extraction_reason = "text_download_empty"
575-
elif isinstance(item_size, int) and item_size > MAX_TEXT_DOWNLOAD_BYTES:
588+
elif is_binary_extractable and (not isinstance(item_size, int) or item_size <= MAX_BINARY_EXTRACT_BYTES):
589+
raw_text, extraction_reason = self._try_download_binary_extract(
590+
client,
591+
f"/drives/{drive['id']}/items/{item['id']}",
592+
item_name,
593+
mime_type,
594+
)
595+
elif isinstance(item_size, int) and item_size > (MAX_BINARY_EXTRACT_BYTES if is_binary_extractable else MAX_TEXT_DOWNLOAD_BYTES):
576596
extraction_reason = "file_too_large"
577597
report_text(f"[{site_name}] {drive_name}{current_path} over size limit ({item_size} bytes), keeping metadata only")
578598
else:
@@ -585,7 +605,7 @@ def _crawl_drive_items(
585605
extracted_text = raw_text if raw_text and not self._is_metadata_only_text(raw_text) else ""
586606
extraction_status = self._classify_extraction_status(
587607
text=extracted_text,
588-
attempted_text_download=is_textual_item and extraction_reason != "file_too_large",
608+
attempted_text_download=(is_textual_item or is_binary_extractable) and extraction_reason != "file_too_large",
589609
reason=extraction_reason,
590610
)
591611
if extracted_text:
@@ -650,6 +670,75 @@ def _try_download_text(self, client: GraphClient, item_path: str) -> str:
650670
return text.replace("\u0000", "").strip()
651671
return ""
652672

673+
def _try_download_binary_extract(self, client: GraphClient, item_path: str, name: str, mime_type: str) -> Tuple[str, str]:
674+
try:
675+
content, downloaded_mime_type = client.get_bytes(f"{item_path}/content")
676+
except Exception:
677+
return "", "binary_download_failed"
678+
679+
if not content:
680+
return "", "binary_download_empty"
681+
if len(content) > MAX_BINARY_EXTRACT_BYTES:
682+
return "", "file_too_large"
683+
684+
effective_mime_type = downloaded_mime_type or mime_type
685+
text = self._extract_plain_text_from_binary(name, effective_mime_type, content)
686+
if text:
687+
return text, ""
688+
return "", "binary_text_extraction_failed"
689+
690+
def _extract_plain_text_from_binary(self, name: str, mime_type: str, content: bytes) -> str:
691+
extension = name.rsplit(".", 1)[-1].lower() if "." in name else ""
692+
if extension in OOXML_EXTENSIONS or "officedocument" in mime_type:
693+
return self._extract_ooxml_text(content)
694+
if extension in PDF_EXTENSIONS or mime_type == "application/pdf":
695+
return self._extract_pdf_text(content)
696+
return ""
697+
698+
def _extract_ooxml_text(self, content: bytes) -> str:
699+
try:
700+
with zipfile.ZipFile(io.BytesIO(content)) as archive:
701+
text_parts: List[str] = []
702+
for name in archive.namelist():
703+
if not self._is_ooxml_text_part(name):
704+
continue
705+
try:
706+
raw_xml = archive.read(name).decode("utf-8", errors="ignore")
707+
except Exception:
708+
continue
709+
text = self._xml_to_text(raw_xml)
710+
if text:
711+
text_parts.append(text)
712+
return self._normalize_extracted_text(" ".join(text_parts))
713+
except Exception:
714+
return ""
715+
716+
def _is_ooxml_text_part(self, name: str) -> bool:
717+
return (
718+
(name.startswith("word/") and name.endswith(".xml")) or
719+
(name.startswith("ppt/slides/") and name.endswith(".xml")) or
720+
name == "xl/sharedStrings.xml"
721+
)
722+
723+
def _xml_to_text(self, value: str) -> str:
724+
text = re.sub(r"<[^>]+>", " ", value)
725+
return html.unescape(text)
726+
727+
def _extract_pdf_text(self, content: bytes) -> str:
728+
try:
729+
from pypdf import PdfReader # type: ignore[import-not-found]
730+
except Exception:
731+
return ""
732+
try:
733+
reader = PdfReader(io.BytesIO(content))
734+
text_parts = [(page.extract_text() or "") for page in reader.pages[:80]]
735+
return self._normalize_extracted_text(" ".join(text_parts))
736+
except Exception:
737+
return ""
738+
739+
def _normalize_extracted_text(self, value: str) -> str:
740+
return re.sub(r"\s+", " ", value.replace("\u0000", " ")).strip()
741+
653742
def _classify_extraction_status(self, *, text: str, attempted_text_download: bool, reason: str) -> str:
654743
if text.strip():
655744
return "full_text"
@@ -886,6 +975,10 @@ def _is_textual_item(self, name: str, mime_type: str) -> bool:
886975
return True
887976
return any(mime_type.startswith(prefix) for prefix in TEXTUAL_MIME_PREFIXES)
888977

978+
def _is_binary_extractable_item(self, name: str, mime_type: str) -> bool:
979+
extension = name.rsplit(".", 1)[-1].lower() if "." in name else ""
980+
return extension in OOXML_EXTENSIONS or extension in PDF_EXTENSIONS or "officedocument" in mime_type or mime_type == "application/pdf"
981+
889982
def _infer_file_type(self, name: str, mime_type: str) -> str:
890983
extension = name.rsplit(".", 1)[-1].lower() if "." in name else ""
891984
file_type_map = {

worker/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
fastapi==0.118.0
22
httpx==0.28.1
33
pydantic-settings==2.11.0
4+
pypdf==6.2.0
45
python-jose[cryptography]==3.5.0
56
pytest==8.4.2
67
pytest-asyncio==1.2.0

worker/tests/test_jobs.py

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
def build_jobs_service(tmp_path) -> JobsService:
1818
settings = Settings(
1919
WORKER_MODE="local",
20-
WORKER_RUNTIME_DATA_DIR=tmp_path,
20+
WORKER_RUNTIME_DATA_DIR=tmp_path / "data" / "runtime",
2121
)
2222
runtime_store = RuntimeStore(settings.runtime_data_dir)
2323
corpus_service = CorpusService(settings=settings)
@@ -53,8 +53,8 @@ def test_jobs_service_runs_evaluate_and_persists_files(tmp_path) -> None:
5353
completed = wait_for_terminal_status(service, started["jobId"])
5454
assert completed["status"] == "succeeded"
5555
assert completed["result"]["passCount"] >= 1
56-
assert (tmp_path / "jobs" / f"{started['jobId']}.json").exists()
57-
assert (tmp_path / "jobs" / f"{started['jobId']}.events.jsonl").exists()
56+
assert (service._runtime_store.jobs_dir() / f"{started['jobId']}.json").exists()
57+
assert (service._runtime_store.jobs_dir() / f"{started['jobId']}.events.jsonl").exists()
5858

5959

6060
def test_jobs_service_runs_ingest_and_writes_sync_state(tmp_path) -> None:
@@ -65,7 +65,7 @@ def test_jobs_service_runs_ingest_and_writes_sync_state(tmp_path) -> None:
6565

6666
assert completed["status"] == "succeeded"
6767
assert completed["result"]["mode"] == "mock"
68-
assert (tmp_path / "sync-state.json").exists()
68+
assert (service._runtime_store.sync_state_path()).exists()
6969

7070

7171
def test_jobs_service_runs_ingest_in_live_mode_when_corpus_path_is_provided(tmp_path) -> None:
@@ -81,7 +81,7 @@ def test_jobs_service_runs_ingest_in_live_mode_when_corpus_path_is_provided(tmp_
8181

8282
assert completed["status"] == "succeeded"
8383
assert completed["result"]["mode"] == "live"
84-
assert (tmp_path / "sync-state.live.json").exists()
84+
assert (service._runtime_store.sync_state_path("live")).exists()
8585

8686

8787
def test_jobs_service_runs_analyze_and_writes_analysis_artifacts(tmp_path) -> None:
@@ -95,16 +95,16 @@ def test_jobs_service_runs_analyze_and_writes_analysis_artifacts(tmp_path) -> No
9595

9696
assert completed["status"] == "succeeded"
9797
assert completed["result"]["analyzed"] == 5
98-
assert (tmp_path / "analyzed-corpus.json").exists()
99-
assert (tmp_path / "analyze-report.json").exists()
98+
assert (service._runtime_store.analyzed_corpus_path()).exists()
99+
assert (service._runtime_store.analyze_report_path()).exists()
100100

101101

102102
def test_jobs_service_analyze_uses_extract_backed_text(tmp_path) -> None:
103103
service = build_jobs_service(tmp_path)
104104
corpus = json.loads(service._corpus_service.load_corpus_bytes().decode("utf-8"))
105105
extract_path = "extracts/site-test/doc-extract.json"
106-
(tmp_path / extract_path).parent.mkdir(parents=True, exist_ok=True)
107-
(tmp_path / extract_path).write_text(
106+
(service._runtime_store.runtime_dir / extract_path).parent.mkdir(parents=True, exist_ok=True)
107+
(service._runtime_store.runtime_dir / extract_path).write_text(
108108
json.dumps({"text": "Real extracted body text. It contains actual policy obligations and review owners."}),
109109
encoding="utf-8",
110110
)
@@ -135,11 +135,11 @@ def test_jobs_service_analyze_uses_extract_backed_text(tmp_path) -> None:
135135
completed = wait_for_terminal_status(service, started["jobId"])
136136

137137
assert completed["status"] == "succeeded"
138-
analyzed_corpus = json.loads((tmp_path / "analyzed-corpus.json").read_text(encoding="utf-8"))
138+
analyzed_corpus = json.loads((service._runtime_store.analyzed_corpus_path()).read_text(encoding="utf-8"))
139139
section = analyzed_corpus["documents"][0]["analysis"]["sections"][0]["content"]
140140
assert "Real extracted body text" in section
141141
assert not section.startswith("Source:")
142-
report = json.loads((tmp_path / "analyze-report.json").read_text(encoding="utf-8"))
142+
report = json.loads((service._runtime_store.analyze_report_path()).read_text(encoding="utf-8"))
143143
assert report["extractionQuality"]["full_text"] == 1
144144

145145

@@ -174,10 +174,10 @@ def test_jobs_service_analyze_excludes_metadata_only_placeholder(tmp_path) -> No
174174
completed = wait_for_terminal_status(service, started["jobId"])
175175

176176
assert completed["status"] == "succeeded"
177-
analyzed_corpus = json.loads((tmp_path / "analyzed-corpus.json").read_text(encoding="utf-8"))
177+
analyzed_corpus = json.loads((service._runtime_store.analyzed_corpus_path()).read_text(encoding="utf-8"))
178178
assert analyzed_corpus["documents"][0]["analysis"]["status"] == "excluded"
179179
assert analyzed_corpus["documents"][0]["analysis"]["excludedReason"] == "metadata_only_extract"
180-
report = json.loads((tmp_path / "analyze-report.json").read_text(encoding="utf-8"))
180+
report = json.loads((service._runtime_store.analyze_report_path()).read_text(encoding="utf-8"))
181181
assert report["extractionQuality"]["metadata_only"] == 1
182182

183183

@@ -193,7 +193,7 @@ def test_jobs_service_analyze_falls_back_to_local_when_provider_requested(tmp_pa
193193

194194
assert completed["status"] == "succeeded"
195195
assert completed["result"]["provider"] == "openai"
196-
analyzed_corpus = (tmp_path / "analyzed-corpus.json").read_text(encoding="utf-8")
196+
analyzed_corpus = (service._runtime_store.analyzed_corpus_path()).read_text(encoding="utf-8")
197197
assert '"providerStatus": "fallback"' in analyzed_corpus
198198

199199

@@ -253,13 +253,13 @@ def json(self) -> dict:
253253
assert completed["result"]["providerSuccesses"] == 1
254254
assert completed["result"]["providerFallbacks"] == 0
255255

256-
analyzed_corpus = json.loads((tmp_path / "analyzed-corpus.json").read_text(encoding="utf-8"))
256+
analyzed_corpus = json.loads((service._runtime_store.analyzed_corpus_path()).read_text(encoding="utf-8"))
257257
first_analysis = analyzed_corpus["documents"][0]["analysis"]
258258
assert first_analysis["provider"] == "openai"
259259
assert first_analysis["providerStatus"] == "provider"
260260
assert first_analysis["summary"] == "Provider summary."
261261

262-
report = json.loads((tmp_path / "analyze-report.json").read_text(encoding="utf-8"))
262+
report = json.loads((service._runtime_store.analyze_report_path()).read_text(encoding="utf-8"))
263263
assert report["actualInputTokens"] == 120
264264
assert report["actualOutputTokens"] == 45
265265
assert report["tokenCountMode"] == "actual"
@@ -282,9 +282,9 @@ def test_jobs_service_runs_export_live_and_writes_live_artifacts(tmp_path) -> No
282282

283283
assert completed["status"] == "succeeded"
284284
assert completed["result"]["sourceKind"] == "mock-baseline"
285-
assert (tmp_path.parent.parent / "public" / "live-corpus.json").exists()
286-
assert (tmp_path / "live-export-checkpoint.json").exists()
287-
assert (tmp_path / "sync-state.live.json").exists()
285+
assert (service._runtime_store.live_corpus_path()).exists()
286+
assert (service._runtime_store.live_export_checkpoint_path()).exists()
287+
assert (service._runtime_store.sync_state_path("live")).exists()
288288

289289

290290
def test_jobs_service_export_live_accepts_explicit_input_override(tmp_path) -> None:
@@ -303,8 +303,8 @@ def test_jobs_service_export_live_accepts_explicit_input_override(tmp_path) -> N
303303
)
304304
completed = wait_for_terminal_status(service, started["jobId"])
305305

306-
published_corpus = (tmp_path.parent.parent / "public" / "live-corpus.json").read_text(encoding="utf-8")
307-
checkpoint = (tmp_path / "live-export-checkpoint.json").read_text(encoding="utf-8")
306+
published_corpus = (service._runtime_store.live_corpus_path()).read_text(encoding="utf-8")
307+
checkpoint = (service._runtime_store.live_export_checkpoint_path()).read_text(encoding="utf-8")
308308

309309
assert completed["status"] == "succeeded"
310310
assert completed["result"]["sourceKind"] == "explicit-input"
@@ -320,14 +320,14 @@ def test_jobs_service_runs_publish_analysis_after_analyze(tmp_path) -> None:
320320
options={"env": {"DEEPVAULT_DATA_MODE": "mock", "DEEPVAULT_ANALYZE_LIMIT": "3"}},
321321
)
322322
wait_for_terminal_status(service, analyze["jobId"])
323-
assert (tmp_path / "analyzed-corpus.json").exists()
323+
assert (service._runtime_store.analyzed_corpus_path()).exists()
324324

325325
publish = service.start_job(job_type="publish-analysis", options={"env": {"DEEPVAULT_DATA_MODE": "mock"}})
326326
completed = wait_for_terminal_status(service, publish["jobId"])
327327

328328
assert completed["status"] == "succeeded"
329329
assert completed["result"]["analyzedCount"] >= 1
330-
published_path = tmp_path.parent.parent / "public" / "live-corpus.json"
330+
published_path = service._runtime_store.live_corpus_path()
331331
assert published_path.exists()
332332
published = json.loads(published_path.read_text(encoding="utf-8"))
333333
analyzed_docs = [doc for doc in published["documents"] if isinstance(doc.get("analysis"), dict) and doc["analysis"].get("status") == "analyzed"]
@@ -352,7 +352,7 @@ def test_jobs_service_run_job_blocks_until_terminal_status(tmp_path) -> None:
352352
assert completed["status"] == "succeeded"
353353
assert completed["launchedBy"] == "worker-cli"
354354
assert completed["client"] == "worker-cli"
355-
assert (tmp_path / "jobs" / f"{completed['jobId']}.json").exists()
355+
assert (service._runtime_store.jobs_dir() / f"{completed['jobId']}.json").exists()
356356

357357

358358
def test_jobs_service_can_cancel_running_job(tmp_path) -> None:

0 commit comments

Comments
 (0)