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
102 changes: 98 additions & 4 deletions ml_service/core/synth_scholar/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1247,18 +1247,29 @@ async def cancel_review(
)


_EXPORT_FORMAT_PATTERN = (
r"^(markdown|json|bibtex|ttl|jsonld|rubric_markdown|rubric_json|charting_markdown"
r"|charting_json|appraisal_markdown|appraisal_json|narrative_summary_markdown"
r"|narrative_summary_json)$"
)


@router.get("/synth-scholar/reviews/{review_id}/export", tags=["SynthScholar — export"])
async def export_review(
review_id: str,
user: Annotated[dict, Depends(get_current_user)],
format: str = Query(
default="markdown",
pattern=r"^(markdown|json|bibtex|ttl|jsonld|rubric_markdown|rubric_json|charting_markdown|charting_json|appraisal_markdown|appraisal_json|narrative_summary_markdown|narrative_summary_json)$",
),
format: str = Query(default="markdown", pattern=_EXPORT_FORMAT_PATTERN),
model: Optional[str] = Query(default=None, description="Compare-mode only: export a single model's result by model_name"),
):
"""Export a completed review in the requested format."""
session = await _session_or_404(review_id, user)
return await _export_session(session, format, model)


# Everything past the access check is identical for the authenticated route above
# and the public one further down, so it lives here once — otherwise the two
# drift, and a format added for signed-in users silently 400s on public reviews.
async def _export_session(session: ReviewSession, format: str, model: Optional[str]):
if session.status != ReviewStatus.COMPLETED or not session.result:
raise HTTPException(
status_code=400,
Expand Down Expand Up @@ -1440,6 +1451,89 @@ async def get_pipeline_log(
}


# ---------------------------------------------------------------------------
# Public (unauthenticated) read surface
#
# Backs /knowledge-base/synth-scholar in the web UI, which is reachable without
# signing in. Those pages previously called the authenticated routes above, which
# cannot work for an anonymous visitor: the UI has no credential to present, so
# the token exchange fails before any request is sent ("ML service requires a
# signed-in session"). Nor could they work for a signed-in visitor, because
# GET /reviews is owner-scoped — the "public listing" showed the viewer their own
# reviews, and a public review's detail page 404'd for everyone but its author.
#
# So `is_public` had no reader. These routes are it. Three rules hold throughout:
# * no `Depends(get_current_user)` — that is the point;
# * every lookup goes through review_store.get_public / list_public, which
# require is_public AND completed, so an unpublished review is invisible;
# * a review that is not published answers 404, never 403 — a 403 would confirm
# the id exists.
# Nothing here is owner-scoped, because published means published to everyone.
# ---------------------------------------------------------------------------


async def _public_session_or_404(review_id: str) -> ReviewSession:
session = await review_store.get_public(review_id)
if not session:
raise HTTPException(status_code=404, detail=f"Review '{review_id}' not found")
return session


@router.get(
"/synth-scholar/public/reviews",
response_model=list[ReviewSummaryResponse],
tags=["SynthScholar — public"],
)
async def list_public_reviews():
"""List every completed review its author marked Public. No auth."""
sessions = await review_store.list_public()
return [_to_summary_response(s) for s in sessions]


@router.get(
"/synth-scholar/public/reviews/{review_id}",
response_model=ReviewDetailResponse,
tags=["SynthScholar — public"],
)
async def get_public_review(review_id: str):
"""Full detail for one published review. 404 if it is not published."""
return _to_detail_response(await _public_session_or_404(review_id))


@router.get("/synth-scholar/public/reviews/{review_id}/log", tags=["SynthScholar — public"])
async def get_public_review_log(review_id: str):
"""Pipeline log for a published review — the provenance timeline reads this.

Same content the author sees: the log records which pipeline step ran when,
which is exactly the provenance a published review is meant to carry.
"""
session = await _public_session_or_404(review_id)
log_entries = list(session.pipeline_log)
return {
"review_id": review_id,
"status": session.status.value,
"step_count": session.progress_step,
"log": log_entries,
"log_events": [
{"step": i + 1, "message": msg, "timestamp": ts}
for i, (ts, msg) in enumerate(_parse_log_entry(e) for e in log_entries)
],
}


@router.get("/synth-scholar/public/reviews/{review_id}/export", tags=["SynthScholar — public"])
async def export_public_review(
review_id: str,
format: str = Query(default="markdown", pattern=_EXPORT_FORMAT_PATTERN),
model: Optional[str] = Query(
default=None, description="Compare-mode only: export a single model's result by model_name"
),
):
"""Export a published review. Same formats as the authenticated route."""
session = await _public_session_or_404(review_id)
return await _export_session(session, format, model)


@router.patch(
"/synth-scholar/reviews/{review_id}/visibility",
response_model=ReviewSummaryResponse,
Expand Down
31 changes: 31 additions & 0 deletions ml_service/core/synth_scholar/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,37 @@ async def list_for_owner(self, owner_email: Optional[str]) -> list[ReviewSession
sessions.append(s)
return sessions

async def list_public(self) -> list[ReviewSession]:
"""List every review its author published — `is_public` and completed.

Read by the unauthenticated public routes, so the filter lives in SQL
rather than in the caller: a client-side filter over a full listing would
mean shipping other people's unpublished reviews to the browser first.
Runtime (in-flight) state is deliberately not merged in — a completed
review has none, and consulting it would only leak progress for rows that
are being re-run.
"""
async with async_session() as db:
result = await db.execute(
select(ReviewRow)
.where(ReviewRow.is_public.is_(True))
.where(ReviewRow.status == ReviewStatus.COMPLETED.value)
.order_by(ReviewRow.created_at.desc())
)
rows = result.scalars().all()
return [_row_to_session(row) for row in rows]

async def get_public(self, review_id: str) -> Optional[ReviewSession]:
"""Fetch a review only if its author published it. Returns None otherwise
— callers turn that into a 404 so an unpublished review's existence is not
disclosed by the status code."""
session = await self.get(review_id)
if not session:
return None
if not session.is_public or session.status != ReviewStatus.COMPLETED:
return None
return session

async def delete(self, review_id: str) -> bool:
self._runtime.pop(review_id, None)
async with async_session() as db:
Expand Down