Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/api/app/api/v1/routes/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions apps/api/app/services/demo/shared_document_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
DemoDocument,
DemoDocumentChunk,
DemoDocumentMapUnitIndex,
DemoDocumentSection,
)
from shared.models.database.job import Job
from shared.models.database.job_result import JobResult
Expand All @@ -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 = (
Expand Down
86 changes: 86 additions & 0 deletions apps/api/tests/contract/test_shared_demo_corpus_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions docs/shared-demo-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading