feat(artifacts): surface artifact_id on podcast and video detail

Reverse-lookup dual-written Artifacts after READY so chat cards and clients
can stream via Artifact without waiting on tool-create payloads.
This commit is contained in:
CREDO23 2026-08-13 08:16:23 +02:00
parent 341844f0bd
commit 238abb5889
7 changed files with 71 additions and 16 deletions

View file

@ -193,7 +193,7 @@ async def get_podcast(
auth: AuthContext = Depends(get_auth_context),
):
podcast = await _load(session, auth, podcast_id, Permission.PODCASTS_READ)
return PodcastDetail.of(podcast)
return await PodcastDetail.resolve(session, podcast)
@router.patch("/podcasts/{podcast_id}/spec", response_model=PodcastDetail)

View file

@ -102,9 +102,11 @@ class PodcastDetail(BaseModel):
created_at: datetime
workspace_id: int
thread_id: int | None
# Cutover: dual-written Artifact id when present (Phase 3 single-read).
artifact_id: int | None = None
@classmethod
def of(cls, podcast: Podcast) -> PodcastDetail:
def of(cls, podcast: Podcast, *, artifact_id: int | None = None) -> PodcastDetail:
return cls(
id=podcast.id,
title=podcast.title,
@ -118,4 +120,17 @@ class PodcastDetail(BaseModel):
created_at=podcast.created_at,
workspace_id=podcast.workspace_id,
thread_id=podcast.thread_id,
artifact_id=artifact_id,
)
@classmethod
async def resolve(cls, session, podcast: Podcast) -> PodcastDetail:
from app.artifacts.media.legacy import existing_legacy_artifact
art = await existing_legacy_artifact(
session,
workspace_id=podcast.workspace_id,
kind="podcast",
legacy_id=podcast.id,
)
return cls.of(podcast, artifact_id=art.id if art else None)

View file

@ -119,7 +119,17 @@ async def read_video_presentation(
"You don't have permission to read video presentations in this workspace",
)
return VideoPresentationRead.from_orm_with_slides(video_pres)
from app.artifacts.media.legacy import existing_legacy_artifact
art = await existing_legacy_artifact(
session,
workspace_id=video_pres.workspace_id,
kind="video",
legacy_id=video_pres.id,
)
return VideoPresentationRead.from_orm_with_slides(
video_pres, artifact_id=art.id if art else None
)
except HTTPException as he:
raise he
except SQLAlchemyError:

View file

@ -45,12 +45,14 @@ class VideoPresentationRead(VideoPresentationBase):
created_at: datetime
slide_count: int | None = None
thread_id: int | None = None
# Cutover: dual-written Artifact id when present (Phase 3).
artifact_id: int | None = None
class Config:
from_attributes = True
@classmethod
def from_orm_with_slides(cls, obj):
def from_orm_with_slides(cls, obj, *, artifact_id: int | None = None):
"""Create VideoPresentationRead with slide_count computed.
Replaces raw server file paths in `audio_file` with API streaming
@ -70,6 +72,7 @@ class VideoPresentationRead(VideoPresentationBase):
"created_at": obj.created_at,
"slide_count": len(obj.slides) if obj.slides else None,
"thread_id": obj.thread_id,
"artifact_id": artifact_id,
}
return cls(**data)

View file

@ -9,6 +9,8 @@ test builds a row in one lifecycle shape and asserts the mapping reflects it.
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
@ -85,10 +87,25 @@ def test_a_ready_podcast_reports_available_audio(make_spec, make_transcript):
duration_seconds=120,
)
detail = PodcastDetail.of(podcast)
detail = PodcastDetail.of(podcast, artifact_id=77)
assert detail.status == PodcastStatus.READY
assert detail.has_audio is True
assert detail.duration_seconds == 120
assert detail.transcript is not None
assert detail.error is None
assert detail.artifact_id == 77
@pytest.mark.asyncio
async def test_resolve_attaches_dual_written_artifact_id(monkeypatch):
import app.artifacts.media.legacy as legacy
podcast = _podcast(status=PodcastStatus.READY)
monkeypatch.setattr(
legacy,
"existing_legacy_artifact",
AsyncMock(return_value=SimpleNamespace(id=55)),
)
detail = await PodcastDetail.resolve(AsyncMock(), podcast)
assert detail.artifact_id == 55

View file

@ -213,18 +213,25 @@ function LivePodcastCard({
// a regeneration starts with one).
const status = podcast?.status;
const [hasEpisode, setHasEpisode] = useState(false);
const [artifactId, setArtifactId] = useState<number | undefined>();
useEffect(() => {
if (!status || !BACK_OUT_STATUSES.has(status)) return;
let stale = false;
podcastsApiService
.getDetail(podcastId)
.then((detail) => {
if (!stale) setHasEpisode(detail.has_audio);
})
.catch(() => {});
return () => {
stale = true;
};
if (!status) return;
if (status === "ready" || BACK_OUT_STATUSES.has(status)) {
let stale = false;
podcastsApiService
.getDetail(podcastId)
.then((detail) => {
if (stale) return;
if (BACK_OUT_STATUSES.has(status)) setHasEpisode(detail.has_audio);
if (status === "ready") {
setArtifactId(detail.artifact_id ?? undefined);
}
})
.catch(() => {});
return () => {
stale = true;
};
}
}, [podcastId, status]);
if (!podcast) {
@ -296,6 +303,8 @@ function LivePodcastCard({
<div>
<PodcastPlayer
podcastId={podcast.id}
artifactId={artifactId}
workspaceId={podcast.workspaceId}
title={title}
durationMs={podcast.durationSeconds ? podcast.durationSeconds * 1000 : undefined}
/>

View file

@ -153,6 +153,7 @@ export const podcastDetail = z.object({
created_at: z.string(),
workspace_id: z.number(),
thread_id: z.number().nullable(),
artifact_id: z.number().int().positive().nullish(),
});
export type PodcastDetail = z.infer<typeof podcastDetail>;