diff --git a/backend/routers/creator_dashboard.py b/backend/routers/creator_dashboard.py index 2b89b27..2673f0f 100644 --- a/backend/routers/creator_dashboard.py +++ b/backend/routers/creator_dashboard.py @@ -10,12 +10,14 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from auth import get_current_user from database import get_session from models import ( Creator, KeyMoment, + RelatedTechniqueLink, SearchLog, SourceVideo, TechniquePage, @@ -25,6 +27,11 @@ from schemas import ( CreatorDashboardResponse, CreatorDashboardTechnique, CreatorDashboardVideo, + CreatorTransparencyResponse, + TransparencyKeyMoment, + TransparencyRelationship, + TransparencySourceVideo, + TransparencyTechnique, ) logger = logging.getLogger("chrysopedia.creator_dashboard") @@ -160,3 +167,154 @@ async def get_creator_dashboard( techniques=techniques, videos=videos, ) + + +@router.get("/transparency", response_model=CreatorTransparencyResponse) +async def get_creator_transparency( + current_user: Annotated[User, Depends(get_current_user)], + db: AsyncSession = Depends(get_session), +) -> CreatorTransparencyResponse: + """Return all entities derived from the authenticated creator's content. + + Shows technique pages, key moments, cross-reference relationships, + source videos, and aggregated topic tags — everything the AI pipeline + produced from this creator's uploads. + """ + if current_user.creator_id is None: + raise HTTPException( + status_code=404, + detail="No creator profile linked to this account", + ) + + creator_id = current_user.creator_id + + # Verify creator exists + creator = (await db.execute( + select(Creator).where(Creator.id == creator_id) + )).scalar_one_or_none() + if creator is None: + logger.error("User %s has creator_id %s but creator row missing", current_user.id, creator_id) + raise HTTPException( + status_code=404, + detail="Linked creator profile not found", + ) + + # ── Technique pages with key moment counts ─────────────────────────── + + technique_pages = (await db.execute( + select(TechniquePage) + .where(TechniquePage.creator_id == creator_id) + .options( + selectinload(TechniquePage.key_moments).selectinload(KeyMoment.source_video), + selectinload(TechniquePage.outgoing_links).selectinload(RelatedTechniqueLink.target_page), + selectinload(TechniquePage.incoming_links).selectinload(RelatedTechniqueLink.source_page), + ) + .order_by(TechniquePage.created_at.desc()) + )).scalars().all() + + techniques = [] + all_key_moments: list[TransparencyKeyMoment] = [] + all_relationships: list[TransparencyRelationship] = [] + all_tags: set[str] = set() + + for tp in technique_pages: + techniques.append(TransparencyTechnique( + title=tp.title, + slug=tp.slug, + topic_category=tp.topic_category, + topic_tags=tp.topic_tags or [], + summary=(tp.summary or "")[:200], + created_at=tp.created_at, + key_moment_count=len(tp.key_moments), + )) + + # Collect tags + if tp.topic_tags: + all_tags.update(tp.topic_tags) + + # Key moments from this technique page + for km in tp.key_moments: + all_key_moments.append(TransparencyKeyMoment( + title=km.title, + summary=km.summary, + content_type=km.content_type.value if hasattr(km.content_type, 'value') else str(km.content_type), + start_time=km.start_time, + end_time=km.end_time, + source_video_filename=km.source_video.filename if km.source_video else "", + technique_page_title=tp.title, + )) + + # Outgoing relationships + for link in tp.outgoing_links: + all_relationships.append(TransparencyRelationship( + relationship_type=link.relationship.value if hasattr(link.relationship, 'value') else str(link.relationship), + source_page_title=tp.title, + source_page_slug=tp.slug, + target_page_title=link.target_page.title if link.target_page else "", + target_page_slug=link.target_page.slug if link.target_page else "", + )) + + # Incoming relationships + for link in tp.incoming_links: + all_relationships.append(TransparencyRelationship( + relationship_type=link.relationship.value if hasattr(link.relationship, 'value') else str(link.relationship), + source_page_title=link.source_page.title if link.source_page else "", + source_page_slug=link.source_page.slug if link.source_page else "", + target_page_title=tp.title, + target_page_slug=tp.slug, + )) + + # ── Key moments not linked to a technique page ─────────────────────── + # (moments from creator's videos that haven't been assigned to a page) + + unlinked_moments = (await db.execute( + select(KeyMoment) + .join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id) + .where( + SourceVideo.creator_id == creator_id, + KeyMoment.technique_page_id.is_(None), + ) + .options(selectinload(KeyMoment.source_video)) + )).scalars().all() + + for km in unlinked_moments: + all_key_moments.append(TransparencyKeyMoment( + title=km.title, + summary=km.summary, + content_type=km.content_type.value if hasattr(km.content_type, 'value') else str(km.content_type), + start_time=km.start_time, + end_time=km.end_time, + source_video_filename=km.source_video.filename if km.source_video else "", + technique_page_title=None, + )) + + # ── Source videos ──────────────────────────────────────────────────── + + video_rows = (await db.execute( + select(SourceVideo) + .where(SourceVideo.creator_id == creator_id) + .order_by(SourceVideo.created_at.desc()) + )).scalars().all() + + source_videos = [ + TransparencySourceVideo( + filename=v.filename, + processing_status=v.processing_status.value if hasattr(v.processing_status, 'value') else str(v.processing_status), + created_at=v.created_at, + ) + for v in video_rows + ] + + logger.info( + "Transparency loaded for creator %s: %d techniques, %d moments, %d relationships, %d videos, %d tags", + creator_id, len(techniques), len(all_key_moments), + len(all_relationships), len(source_videos), len(all_tags), + ) + + return CreatorTransparencyResponse( + techniques=techniques, + key_moments=all_key_moments, + relationships=all_relationships, + source_videos=source_videos, + tags=sorted(all_tags), + ) diff --git a/backend/schemas.py b/backend/schemas.py index 96fd908..4be6928 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -862,3 +862,52 @@ class NotificationPreferencesUpdate(BaseModel): """Partial update for notification preferences.""" email_digests: bool | None = None digest_frequency: str | None = None + + +# ── Creator Transparency ───────────────────────────────────────────────────── + +class TransparencyTechnique(BaseModel): + """Technique page derived from creator's content.""" + title: str + slug: str + topic_category: str + topic_tags: list[str] = Field(default_factory=list) + summary: str = "" + created_at: datetime + key_moment_count: int = 0 + + +class TransparencyKeyMoment(BaseModel): + """Key moment extracted from creator's videos.""" + title: str + summary: str + content_type: str + start_time: float + end_time: float + source_video_filename: str = "" + technique_page_title: str | None = None + + +class TransparencyRelationship(BaseModel): + """Cross-reference link involving creator's technique pages.""" + relationship_type: str + source_page_title: str + source_page_slug: str + target_page_title: str + target_page_slug: str + + +class TransparencySourceVideo(BaseModel): + """Source video uploaded by creator.""" + filename: str + processing_status: str + created_at: datetime + + +class CreatorTransparencyResponse(BaseModel): + """Full transparency payload — all entities derived from a creator's content.""" + techniques: list[TransparencyTechnique] = Field(default_factory=list) + key_moments: list[TransparencyKeyMoment] = Field(default_factory=list) + relationships: list[TransparencyRelationship] = Field(default_factory=list) + source_videos: list[TransparencySourceVideo] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list)