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
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""add demo materializations

Revision ID: f6a7b8c9d0e1
Revises: e5f6a7b8c9d0
Create Date: 2026-05-12 08:25:00.000000

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "f6a7b8c9d0e1"
down_revision: Union[str, Sequence[str], None] = "e5f6a7b8c9d0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"demo_materializations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("namespace", sa.String(length=255), nullable=False),
sa.Column("demo_source_id", sa.String(length=128), nullable=False),
sa.Column("document_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["document_id"],
["documents.document_id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"user_id",
"namespace",
"demo_source_id",
name="uq_demo_materializations_scope_source",
),
)
op.create_index(
"idx_demo_materializations_document",
"demo_materializations",
["document_id"],
unique=False,
)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(
"idx_demo_materializations_document",
table_name="demo_materializations",
)
op.drop_table("demo_materializations")
4 changes: 4 additions & 0 deletions apps/api/app/api/v1/api_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from app.api.v1.routes import (
api_key,
demo,
documents,
guest,
jobs,
Expand Down Expand Up @@ -36,6 +37,9 @@
# Unified Jobs routes
api_router.include_router(jobs.router, prefix="/jobs", tags=["Jobs"])

# Demo documents
api_router.include_router(demo.router, prefix="/demo", tags=["Demo Documents"])

# Retrieval
api_router.include_router(retrieval.router, prefix="/retrieval", tags=["Retrieval"])

Expand Down
158 changes: 158 additions & 0 deletions apps/api/app/api/v1/routes/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Notebook demo document catalog routes."""

from __future__ import annotations

from typing import Any

from app.services.demo_document_service import DemoDocumentService
from app.services.rate_limit.dependencies import CurrentUser, with_current_user
from fastapi import APIRouter, Depends, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession

from shared.core.database import get_db
from shared.core.exceptions.domain_exceptions import NotFoundException

router = APIRouter(tags=["Demo Documents"])

_demo_document_service = DemoDocumentService()


class DemoMaterializeRequest(BaseModel):
"""Request to copy selected canonical demo sources into a namespace."""

namespace: str | None = Field(None, description="Target retrieval namespace")
demo_source_ids: list[str] = Field(
default_factory=list,
min_length=1,
description="Canonical demo source IDs to materialize",
)


@router.get("/catalog")
async def get_demo_catalog() -> dict[str, Any]:
"""Return API-owned canonical demo source metadata and curated Q/A."""
return _demo_document_service.get_catalog()


@router.get("/sources/{demo_source_id}/chunks")
async def list_demo_source_chunks(
demo_source_id: str,
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=200, description="Items per page"),
) -> dict[str, Any]:
"""Return paginated canonical chunks for a demo source."""
response = _demo_document_service.list_chunks(
demo_source_id=demo_source_id,
page=page,
page_size=page_size,
)
if response is None:
raise _demo_source_not_found(demo_source_id)
return response


@router.get("/sources/{demo_source_id}/chunks/{demo_chunk_id}")
async def get_demo_source_chunk(
demo_source_id: str,
demo_chunk_id: str,
) -> dict[str, Any]:
"""Return one canonical demo chunk for citation focusing."""
response = _demo_document_service.get_chunk(
demo_source_id=demo_source_id,
demo_chunk_id=demo_chunk_id,
)
if response is None:
raise NotFoundException(
resource="Demo document chunk",
resource_id=demo_chunk_id,
internal_message="Demo document chunk not found",
)
return response


@router.get("/sources/{demo_source_id}/original")
async def get_demo_source_original(demo_source_id: str) -> FileResponse:
"""Return the canonical original file for preview."""
file_path = _demo_document_service.get_original_file_path(
demo_source_id=demo_source_id,
)
if file_path is None:
raise _demo_source_not_found(demo_source_id)

return FileResponse(
path=file_path,
media_type="application/pdf",
filename=file_path.name,
content_disposition_type="inline",
)


@router.get("/sources/{demo_source_id}/assets/{asset_path:path}")
async def get_demo_source_asset(
demo_source_id: str,
asset_path: str,
) -> FileResponse:
"""Return a canonical parsed media or table asset for preview."""
file_path = _demo_document_service.get_asset_file_path(
demo_source_id=demo_source_id,
asset_path=asset_path,
)
if file_path is None:
raise _demo_source_not_found(demo_source_id)

return FileResponse(
path=file_path,
filename=file_path.name,
content_disposition_type="inline",
)


@router.post("/materializations")
async def materialize_demo_sources(
payload: DemoMaterializeRequest,
current_user: CurrentUser = Depends(with_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""Copy canonical demo sources into the authenticated user's namespace."""
namespace = (payload.namespace or "default").strip() or "default"
try:
materialized_sources = await _demo_document_service.materialize_sources(
db,
user_id=current_user.user_id,
namespace=namespace,
demo_source_ids=payload.demo_source_ids,
)
except KeyError as error:
raise _demo_source_not_found(str(error.args[0])) from error

return {
"namespace": namespace,
"sources": [
{
"demo_source_id": source.demo_source_id,
"document_id": source.document_id,
"status": source.status,
"title": source.title,
"mime_type": source.mime_type,
"size_bytes": source.size_bytes,
"chunk_count": source.chunk_count,
"original_file": {
"url": f"/api/v1/demo/sources/{source.demo_source_id}/original",
"mime_type": source.mime_type,
"size_bytes": source.size_bytes,
"can_download": False,
},
}
for source in materialized_sources
],
}


def _demo_source_not_found(demo_source_id: str) -> NotFoundException:
return NotFoundException(
resource="Demo document source",
resource_id=demo_source_id,
internal_message="Demo document source not found",
)
Loading
Loading