Skip to content

Commit 8a4d20c

Browse files
committed
fix: bound PDF graph schema sampling
1 parent 893eb62 commit 8a4d20c

4 files changed

Lines changed: 224 additions & 10 deletions

File tree

docs/exec-plans/active/graphrag-cross-corpus-2026-08-31.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,14 @@ This table is the completion authority for Task 8. `Pending` may become `Proves`
330330
- Verdict: **FAIL**, with five P1 completion blockers and no implementation defect: push/deploy parity, NASA visible rebuild, Epstein visible rebuild, `ragweld_code` visible rebuild, and postdeploy GDS-community browser acceptance remain pending. The reviewer explicitly concluded: “All other integration checks ... pass. The plan's implementation is correct; these remaining operational steps are required to declare full integration successful.”
331331
- Resolution: commit/push/deploy the exact reviewed candidate, complete those five operational rows with store/browser evidence, then resubmit the completed integration packet. No code change is indicated by this verdict.
332332

333+
### Live NASA schema-proposal acceptance defect and review loop
334+
335+
- The first authenticated postdeploy NASA drive selected `NASA` on the visible RAG Indexing page, expanded the semantic-graph policy card, and clicked `Generate graph schema`. Cloudflare returned a visible 524 after the origin synchronously entered full Docling conversion for the single 359-page, 15.9 MiB Apollo 11 mission report; no proposal hash persisted. This was a production acceptance defect that the earlier nine-page extracted-Markdown fixture could not expose.
336+
- GitNexus upstream impact for `build_proposal_from_corpus` was LOW: one direct caller (`propose_graph_schema`) and one affected API process/module. The correction adds a proposal-only PDF sampler: PDFs through 12 pages contribute all pages; larger PDFs contribute the first, middle, and last three pages with explicit page markers. PDF import/open/page/text/close failures return an empty bounded sample, and PDFs never fall through to full Docling/OCR in this synchronous edge request. Non-PDF proposal sampling continues through the established extraction path. If the corpus has no embedded PDF text or other sampleable text, the API now returns a typed 422 that directs the operator to indexing-time OCR.
337+
- TDD RED reproduced the missing bounded sampler, the 12-page boundary error, and a 100-page image-only proposal request exceeding a 20-second outer kill. GREEN passed the generated 1/2/3/8/12/13-page matrix plus the image-only endpoint case: 7/7 in 0.76s. The complete non-egress schema-proposal slice collected 35 tests and passed pytest, mypy, Ruff, and the banned-pattern scan. A full real-Apollo gateway regression is present behind the existing live-gateway marker, but executing sampled mission-report text through the external model gateway remains intentionally unverified until that separate content-egress approval is explicit.
338+
- First review response `gen-1788278390-YCwMCNiS21dhGwygBXrW` resolved to `deepseek.deepseek-v4-flash`, used 2,799 prompt + 4,893 completion tokens (7,692 total), and cost `$0.0009584736`. Verdict: **FAIL**. Its P1 found that post-open PDF exceptions could bypass the fallback; its P2 found that the fallback still recreated the original timeout for image-only PDFs; its P3 requested small-PDF, boundary, malformed/textless coverage. All findings were reproduced or covered and fixed.
339+
- Re-review response `gen-1788278762-2ZOgeh0KbK8SgPcdZZuO` resolved to `deepseek.deepseek-v4-flash`, used 3,318 prompt + 2,120 completion tokens (5,438 total), and cost `$0.000846496`. Verdict: **PASS**, with no P1/P2 findings and all five correction claims verified.
340+
333341
### Final precommit GitNexus scope
334342

335343
- Task 8 uncommitted range: HIGH risk across 55 files, 99 indexed symbols, eight affected flows. The named flows are `start_index` persisted/config resolution, mechanical docs automation helpers, and `RetrievalSubtab` config/readiness loading.

server/api/index.py

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4139,24 +4139,33 @@ async def build_proposal_from_corpus(
41394139
entries[round(index * (len(entries) - 1) / 11)] for index in range(12)
41404140
]
41414141
chunker = Chunker(cfg.chunking, cfg.tokenization)
4142-
await asyncio.to_thread(warm_sampler, chunker)
41434142
candidates: list[Chunk] = []
4143+
sampler_warmed = False
41444144
for relative, absolute in entries:
4145-
extracted = await asyncio.to_thread(
4146-
extract_text_for_path,
4145+
text = await asyncio.to_thread(
4146+
_extract_schema_sample_text_for_path,
41474147
absolute,
4148-
parquet_max_rows=int(cfg.indexing.parquet_extract_max_rows),
4149-
parquet_max_chars=int(cfg.indexing.parquet_extract_max_chars),
4150-
parquet_max_cell_chars=int(cfg.indexing.parquet_extract_max_cell_chars),
4151-
parquet_text_columns_only=bool(cfg.indexing.parquet_extract_text_columns_only),
4152-
parquet_include_column_names=bool(cfg.indexing.parquet_extract_include_column_names),
4148+
cfg,
41534149
)
4154-
if extracted is None or not str(extracted.text or "").strip():
4150+
if not text.strip():
41554151
continue
4152+
if not sampler_warmed:
4153+
await asyncio.to_thread(warm_sampler, chunker)
4154+
sampler_warmed = True
41564155
candidates.extend(
4157-
await asyncio.to_thread(chunker.chunk_file, str(relative), extracted.text)
4156+
await asyncio.to_thread(chunker.chunk_file, str(relative), text)
41584157
)
41594158
sampled = select_schema_chunks(candidates, corpus_id=corpus_id)
4159+
if not sampled:
4160+
raise HTTPException(
4161+
status_code=422,
4162+
detail=(
4163+
"Graph schema proposal sampling found no embedded PDF text or other "
4164+
"indexable text. Provide a text-bearing source before schema review; "
4165+
"whole-document OCR remains an indexing operation, not a synchronous "
4166+
"proposal request."
4167+
),
4168+
)
41604169
route = _resolve_semantic_kg_route(cfg)
41614170
return await derive_graph_schema_proposal(
41624171
corpus_id=corpus_id,
@@ -4169,6 +4178,73 @@ async def build_proposal_from_corpus(
41694178
)
41704179

41714180

4181+
def _extract_schema_sample_text_for_path(path: Path, cfg: TriBridConfig) -> str:
4182+
"""Bound PDF proposal work to nine positionally representative pages.
4183+
4184+
A schema proposal needs representative domain text, not a complete document
4185+
conversion. Running whole-document Docling conversion behind this synchronous
4186+
HTTP boundary exceeded the public proxy window on the 359-page Apollo report.
4187+
Text-bearing PDFs therefore use the same fast pdfium substrate as the estimator,
4188+
but keep explicit page markers so sampled positions remain reviewable. Image-only
4189+
or unreadable PDFs return no sample so the caller can refuse synchronously instead
4190+
of starting unbounded whole-document OCR behind the public request.
4191+
"""
4192+
if path.suffix.lower() == ".pdf":
4193+
try:
4194+
import pypdfium2 as pdfium
4195+
from docling.utils.locks import pypdfium2_lock
4196+
4197+
with pypdfium2_lock:
4198+
document = pdfium.PdfDocument(str(path))
4199+
try:
4200+
page_count = len(document)
4201+
if page_count <= 12:
4202+
indexes = list(range(page_count))
4203+
else:
4204+
last = page_count - 1
4205+
indexes = sorted(
4206+
{
4207+
0,
4208+
1,
4209+
2,
4210+
max(0, last // 2 - 1),
4211+
last // 2,
4212+
min(last, last // 2 + 1),
4213+
last - 2,
4214+
last - 1,
4215+
last,
4216+
}
4217+
)
4218+
parts: list[str] = []
4219+
for index in indexes:
4220+
page = document[index]
4221+
try:
4222+
text_page = page.get_textpage()
4223+
try:
4224+
text = str(text_page.get_text_range() or "").strip()
4225+
finally:
4226+
text_page.close()
4227+
finally:
4228+
page.close()
4229+
if text:
4230+
parts.append(f"# {path.name} page {index + 1}\n\n{text}")
4231+
return "\n\n".join(parts)
4232+
finally:
4233+
document.close()
4234+
except Exception:
4235+
return ""
4236+
4237+
extracted = extract_text_for_path(
4238+
path,
4239+
parquet_max_rows=int(cfg.indexing.parquet_extract_max_rows),
4240+
parquet_max_chars=int(cfg.indexing.parquet_extract_max_chars),
4241+
parquet_max_cell_chars=int(cfg.indexing.parquet_extract_max_cell_chars),
4242+
parquet_text_columns_only=bool(cfg.indexing.parquet_extract_text_columns_only),
4243+
parquet_include_column_names=bool(cfg.indexing.parquet_extract_include_column_names),
4244+
)
4245+
return str(extracted.text or "") if extracted is not None else ""
4246+
4247+
41724248
@router.post(
41734249
"/index/{corpus_id}/graph-schema/proposal",
41744250
response_model=GraphSchemaProposal,

tests/api/test_graph_schema_endpoints.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import inspect
23
from pathlib import Path
34
from uuid import uuid4
@@ -6,6 +7,8 @@
67
from httpx import AsyncClient
78

89
import server.api.index as index_api
10+
from server.models.tribrid_config_model import TriBridConfig
11+
from tests.fixtures.pdf_builder import build_pdf
912

1013

1114
def test_proposal_builder_keeps_inventory_and_chunking_off_the_api_loop() -> None:
@@ -14,6 +17,93 @@ def test_proposal_builder_keeps_inventory_and_chunking_off_the_api_loop() -> Non
1417
assert "await asyncio.to_thread(chunker.chunk_file" in source
1518

1619

20+
def test_schema_pdf_sampler_reads_only_stratified_pages(tmp_path: Path) -> None:
21+
"""A large PDF proposal must not run whole-document Docling conversion behind HTTP."""
22+
pdf = tmp_path / "thirteen-pages.pdf"
23+
pdf.write_bytes(
24+
build_pdf(
25+
[
26+
[f"Unique schema evidence from page {page_number}."]
27+
for page_number in range(1, 14)
28+
]
29+
)
30+
)
31+
sampler = getattr(index_api, "_extract_schema_sample_text_for_path", None)
32+
assert callable(sampler), "schema proposals need a bounded PDF page sampler"
33+
34+
text = sampler(pdf, TriBridConfig())
35+
36+
assert [line for line in text.splitlines() if line.startswith("# ")] == [
37+
"# thirteen-pages.pdf page 1",
38+
"# thirteen-pages.pdf page 2",
39+
"# thirteen-pages.pdf page 3",
40+
"# thirteen-pages.pdf page 6",
41+
"# thirteen-pages.pdf page 7",
42+
"# thirteen-pages.pdf page 8",
43+
"# thirteen-pages.pdf page 11",
44+
"# thirteen-pages.pdf page 12",
45+
"# thirteen-pages.pdf page 13",
46+
]
47+
assert "Unique schema evidence from page 4." not in text
48+
assert "Unique schema evidence from page 10." not in text
49+
50+
51+
@pytest.mark.parametrize("page_count", [1, 2, 3, 8, 12])
52+
def test_schema_pdf_sampler_reads_every_page_of_small_pdfs(
53+
tmp_path: Path, page_count: int
54+
) -> None:
55+
pdf = tmp_path / f"{page_count}-pages.pdf"
56+
pdf.write_bytes(
57+
build_pdf(
58+
[
59+
[f"Unique schema evidence from page {page_number}."]
60+
for page_number in range(1, page_count + 1)
61+
]
62+
)
63+
)
64+
65+
text = index_api._extract_schema_sample_text_for_path(pdf, TriBridConfig())
66+
67+
assert [line for line in text.splitlines() if line.startswith("# ")] == [
68+
f"# {page_count}-pages.pdf page {page_number}"
69+
for page_number in range(1, page_count + 1)
70+
]
71+
72+
73+
@pytest.mark.asyncio
74+
async def test_schema_proposal_refuses_a_large_textless_pdf_inside_the_edge_window(
75+
client: AsyncClient, tmp_path: Path
76+
) -> None:
77+
corpus_id = f"schema-textless-{uuid4().hex[:8]}"
78+
corpus_path = tmp_path / "textless"
79+
corpus_path.mkdir()
80+
(corpus_path / "one-hundred-empty-pages.pdf").write_bytes(
81+
build_pdf([[] for _ in range(100)])
82+
)
83+
created = await client.post(
84+
"/api/corpora",
85+
json={"corpus_id": corpus_id, "name": corpus_id, "path": str(corpus_path)},
86+
)
87+
assert created.status_code in (200, 201), created.text
88+
try:
89+
configured = await client.patch(
90+
f"/api/config/graph_indexing?corpus_id={corpus_id}",
91+
json={"enabled": True, "build_code_graph": False},
92+
)
93+
assert configured.status_code == 200, configured.text
94+
95+
async with asyncio.timeout(5):
96+
response = await client.post(
97+
f"/api/index/{corpus_id}/graph-schema/proposal",
98+
json={"force_refresh": False},
99+
)
100+
101+
assert response.status_code == 422, response.text
102+
assert "no embedded PDF text" in str(response.json()["detail"])
103+
finally:
104+
await client.delete(f"/api/corpora/{corpus_id}")
105+
106+
17107
@pytest.mark.asyncio
18108
async def test_graph_schema_proposal_refuses_graph_off_policy_with_typed_conflict(
19109
client: AsyncClient,

tests/integration/test_graph_schema_proposal_live.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,46 @@
1818
_MODEL = os.environ.get("GRAPH_E2E_KG_MODEL", "deepseek.deepseek-v4-flash")
1919

2020

21+
async def test_real_full_apollo_pdf_schema_proposal_fits_the_public_edge_window(
22+
client: AsyncClient,
23+
) -> None:
24+
"""The production PDF path must return before the public proxy closes the request."""
25+
if not _APOLLO_SOURCE.is_file():
26+
pytest.skip(f"Apollo source is unavailable on this runtime: {_APOLLO_SOURCE}")
27+
28+
corpus_id = f"apollo-full-schema-{uuid.uuid4().hex[:8]}"
29+
created = await client.post(
30+
"/api/corpora",
31+
json={"corpus_id": corpus_id, "name": corpus_id, "path": str(_APOLLO_SOURCE.parent)},
32+
)
33+
assert created.status_code in (200, 201), created.text
34+
try:
35+
configured = await client.patch(
36+
f"/api/config/graph_indexing?corpus_id={corpus_id}",
37+
json={
38+
"enabled": True,
39+
"build_code_graph": False,
40+
"semantic_kg_llm_model": _MODEL,
41+
},
42+
)
43+
assert configured.status_code == 200, configured.text
44+
45+
loop = asyncio.get_running_loop()
46+
started = loop.time()
47+
async with asyncio.timeout(90):
48+
response = await client.post(
49+
f"/api/index/{corpus_id}/graph-schema/proposal",
50+
json={"force_refresh": False},
51+
)
52+
elapsed = loop.time() - started
53+
54+
assert response.status_code == 200, response.text
55+
assert elapsed < 90
56+
assert response.json()["sample"]["chunk_ids"]
57+
finally:
58+
await client.delete(f"/api/corpora/{corpus_id}")
59+
60+
2161
async def test_real_apollo_schema_proposal_persists_reuses_and_invalidates_approval(
2262
client: AsyncClient, tmp_path: Path
2363
) -> None:

0 commit comments

Comments
 (0)