From 1e180ca8537e2ebf5bf21ccb566e40a4a10031d8 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Thu, 22 Oct 2026 02:29:01 -0700 Subject: [PATCH] feat: expose revision-pinned demo document structure --- apps/api/app/api/v1/routes/demo.py | 13 +++ .../services/demo/shared_document_service.py | 39 +++++++++ .../test_shared_demo_corpus_contract.py | 86 +++++++++++++++++++ docs/shared-demo-rollout.md | 42 +++++++++ 4 files changed, 180 insertions(+) diff --git a/apps/api/app/api/v1/routes/demo.py b/apps/api/app/api/v1/routes/demo.py index 2eec36b9c..3533f3530 100644 --- a/apps/api/app/api/v1/routes/demo.py +++ b/apps/api/app/api/v1/routes/demo.py @@ -31,6 +31,19 @@ async def list_demo_source_chunks(demo_source_id: str, job_result_id: str | None return {**(response or {}), "demo_source_id": demo_source_id, "canonical_document_id": document.document_id} +@router.get("/sources/{demo_source_id}/structure") +async def get_demo_source_structure( + demo_source_id: str, + response: Response, + job_result_id: str | None = Query(None, max_length=36), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + response.headers["Cache-Control"] = "no-store" + return await _demo_service.get_structure( + db, source_id=demo_source_id, job_result_id=job_result_id, + ) + + @router.get("/sources/{demo_source_id}/chunks/{demo_chunk_id}") async def get_demo_source_chunk(demo_source_id: str, demo_chunk_id: str, job_result_id: str | None = Query(None, max_length=36), db: AsyncSession = Depends(get_db)) -> dict[str, Any] | None: document, revision = await _demo_service.resolve_source(db, source_id=demo_source_id, job_result_id=job_result_id) diff --git a/apps/api/app/services/demo/shared_document_service.py b/apps/api/app/services/demo/shared_document_service.py index 97f6b4a04..71f318647 100644 --- a/apps/api/app/services/demo/shared_document_service.py +++ b/apps/api/app/services/demo/shared_document_service.py @@ -18,6 +18,7 @@ DemoDocument, DemoDocumentChunk, DemoDocumentMapUnitIndex, + DemoDocumentSection, ) from shared.models.database.job import Job from shared.models.database.job_result import JobResult @@ -27,6 +28,44 @@ class SharedDemoDocumentService: + async def get_structure( + self, + db: AsyncSession, + *, + source_id: str, + job_result_id: str | None = None, + ) -> dict[str, Any]: + """Read the complete published section outline without loading bodies.""" + document, revision = await self.resolve_source( + db, source_id=source_id, job_result_id=job_result_id, + ) + sections = ( + await db.execute( + select( + DemoDocumentSection.section_id, + DemoDocumentSection.parent_section_id, + DemoDocumentSection.section_path, + DemoDocumentSection.section_title, + DemoDocumentSection.section_level, + DemoDocumentSection.sort_order, + ) + .where( + DemoDocumentSection.document_id == document.document_id, + DemoDocumentSection.job_result_id == revision.id, + ) + .order_by( + DemoDocumentSection.sort_order, + DemoDocumentSection.section_id, + ) + ) + ).mappings().all() + return { + "demo_source_id": source_id, + "canonical_document_id": document.document_id, + "job_result_id": revision.id, + "sections": [dict(section) for section in sections], + } + async def get_catalog(self, db: AsyncSession) -> dict[str, Any]: sourceExpression = Job.job_metadata["demo_source_id"].as_string() latestJobs = ( diff --git a/apps/api/tests/contract/test_shared_demo_corpus_contract.py b/apps/api/tests/contract/test_shared_demo_corpus_contract.py index 024da1f82..6205ca76c 100644 --- a/apps/api/tests/contract/test_shared_demo_corpus_contract.py +++ b/apps/api/tests/contract/test_shared_demo_corpus_contract.py @@ -46,6 +46,7 @@ async def publish_demo( content: str = "Shared launch capabilities include reusable rockets.", has_page: bool = False, should_fail: bool = False, + section_paths: list[str] | None = None, ) -> str: from shared.core.database import get_db_context from shared.models.database.job import Job @@ -107,6 +108,17 @@ def publish(db: Session) -> None: "metadata": {}, } ) + if section_paths is not None: + chunks = [ + { + "chunk_id": f"body-{revisionId[:8]}-{index}", + "type": "page", + "content": f"Body for {path}", + "path": f"shared.docx/{path}", + "metadata": {"page_nums": [index + 1]}, + } + for index, path in enumerate(section_paths) + ] service = RetrievalPublicationService() outcome = service.publish_document_state( db, job_id=record.job_id, job_result_id=revisionId, chunks=chunks @@ -125,6 +137,80 @@ def publish(db: Session) -> None: return revisionId +@pytest.mark.asyncio +async def test_public_structure_preserves_order_parents_and_revision_pins( + developer_api_client_factory: ClientFactory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async with developer_api_client_factory() as client: + job = await create_demo(client) + preparing = await client.get( + "/api/v1/demo/sources/contract-demo/structure", + headers={"Authorization": ""}, + ) + assert preparing.status_code == 409 + revision = await publish_demo( + job, section_paths=["Operations/Zeta", "Operations/Alpha", "Summary"], + ) + update = await create_demo(client) + current_revision = await publish_demo(update, section_paths=["Updated"]) + + async def reject_chunk_reads(*args: Any, **kwargs: Any) -> None: + raise AssertionError("Structure reads must not fetch chunk bodies") + + def reject_artifact_reads(*args: Any, **kwargs: Any) -> None: + raise AssertionError("Structure reads must not download result artifacts") + + monkeypatch.setattr( + "app.services.documents.lifecycle_service.DocumentService.list_document_chunks", + reject_chunk_reads, + ) + monkeypatch.setattr( + "shared.services.storage.result_storage.JobResultStorage.download_raw_to_temp", + reject_artifact_reads, + ) + response = await client.get( + "/api/v1/demo/sources/contract-demo/structure", + params={"job_result_id": revision}, + headers={"Authorization": ""}, + ) + assert response.status_code == 200, response.text + assert response.headers["Cache-Control"] == "no-store" + payload = response.json() + assert payload["demo_source_id"] == "contract-demo" + assert payload["canonical_document_id"] == job["document_id"] + assert payload["job_result_id"] == revision + sections = payload["sections"] + assert [section["section_path"] for section in sections] == [ + "Operations", "Operations / Zeta", "Operations / Alpha", "Summary", + ] + assert [section["section_title"] for section in sections] == [ + "Operations", "Zeta", "Alpha", "Summary", + ] + assert [section["section_level"] for section in sections] == [1, 2, 2, 1] + assert [section["sort_order"] for section in sections] == [0, 1, 2, 3] + parent_id = sections[0]["section_id"] + assert [section["parent_section_id"] for section in sections] == [ + None, parent_id, parent_id, None, + ] + assert all(set(section) == { + "section_id", "parent_section_id", "section_path", "section_title", + "section_level", "sort_order", + } for section in sections) + current = await client.get( + "/api/v1/demo/sources/contract-demo/structure", + headers={"Authorization": ""}, + ) + assert current.status_code == 200, current.text + assert current.json()["job_result_id"] == current_revision + assert [section["section_path"] for section in current.json()["sections"]] == ["Updated"] + invalid_revision = await client.get( + "/api/v1/demo/sources/contract-demo/structure", + params={"job_result_id": str(uuid4())}, + ) + assert invalid_revision.status_code == 404 + + @pytest.mark.asyncio async def test_preparing_readiness_and_removed_materialization( developer_api_client_factory: ClientFactory, diff --git a/docs/shared-demo-rollout.md b/docs/shared-demo-rollout.md index 6985ddca9..97a6cbfa8 100644 --- a/docs/shared-demo-rollout.md +++ b/docs/shared-demo-rollout.md @@ -37,6 +37,48 @@ archive. API job deletion requests for these revisions return 409. This checkout has no general job deletion/cancellation API; ordinary jobs remain retained processing records, and DELETE remains unsupported for them. +## Public revision structure + +`GET /api/v1/demo/sources/{demo_source_id}/structure` returns the complete +published section outline without loading chunk bodies or result archives. +It requires no authentication and follows the same active-source and complete +revision checks as the other public demo reads. + +Pass `job_result_id` to pin the outline to the same revision as subsequent chunk +and asset requests. Omitting it selects the current complete revision. Responses +use `Cache-Control: no-store`. + +```json +{ + "demo_source_id": "example-source", + "canonical_document_id": "ddoc_example", + "job_result_id": "published-revision-id", + "sections": [ + { + "section_id": "sec_parent", + "parent_section_id": null, + "section_path": "Operations", + "section_title": "Operations", + "section_level": 1, + "sort_order": 0 + } + ] +} +``` + +Sections follow persisted `sort_order`, with `section_id` as a deterministic tie +breaker. Build the tree from `parent_section_id`; do not infer parentage from +title splitting or alphabetically reorder siblings. Structural parents are +included even when they own no chunk. This is the published section tree; +TOC-only entries never persisted during parsing/publication are not restored by +this read endpoint. Unknown sources or unavailable pinned revisions return 404; +known sources without a complete revision retain the existing preparing error. + +The HTTP/PostgreSQL shared-demo contract suite passes all ten tests, including +revision-pinned structure reads, structural parents, persisted sibling order, +preparing/error behavior, and guards against chunk or artifact downloads. +Ruff and Pyright pass for the changed API modules. + ## Backup gate Use the authoritative originals in `knowhere-storage-staging`, not public URLs.