"""Discovery API routes: start a conversation, save responses, generate and confirm a profile. All routes are user-scoped via the dual-auth dependency. The MCP server uses the X-API-Key header and operates under the synthetic admin user; the browser app uses a Google-issued JWT. """ import json import os import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app import schemas from app.auth import get_current_user from app.database import get_db from app.models import DiscoveryConversation, DiscoveryProfile, User from app.services.extractor import DiscoveryExtractionError, DiscoveryExtractor router = APIRouter(prefix="/discovery", tags=["discovery"]) def _now() -> datetime: return datetime.now(timezone.utc) def _to_profile_response( profile: DiscoveryProfile, extraction_notes: str | None = None ) -> schemas.ProfileResponse: """Build a ProfileResponse from a stored profile row.""" confidence = None if profile.confidence_json: try: confidence = schemas.Confidence(**json.loads(profile.confidence_json)) except (json.JSONDecodeError, TypeError, ValueError): confidence = None return schemas.ProfileResponse( id=profile.id, user_id=profile.user_id, conversation_id=profile.conversation_id, generated_at=profile.generated_at, triad=profile.triad, probable_type=profile.probable_type, wing=profile.wing, instinctual_variant=profile.instinctual_variant, instinctual_stack=profile.instinctual_stack, love_summary=profile.love_summary, strength_summary=profile.strength_summary, mission_summary=profile.mission_summary, vocation_summary=profile.vocation_summary, overlap_narrative=profile.overlap_narrative, short_term_goals=profile.short_term_goals, long_term_goals=profile.long_term_goals, confidence=confidence, locked=profile.locked, extraction_notes=extraction_notes, ) async def _latest_profile( db: AsyncSession, user_id: str ) -> DiscoveryProfile | None: stmt = ( select(DiscoveryProfile) .where(DiscoveryProfile.user_id == user_id) .order_by(DiscoveryProfile.generated_at.desc()) ) result = await db.execute(stmt) return result.scalars().first() async def _owned_conversation( db: AsyncSession, conversation_id: str, user: User ) -> DiscoveryConversation: """Fetch a conversation and assert the caller owns it (admins bypass). Returns 404 for both 'not found' and 'not yours' so the existence of other users' conversations isn't leaked.""" conversation = await db.get(DiscoveryConversation, conversation_id) if conversation is None: raise HTTPException(status_code=404, detail="Conversation not found") if conversation.user_id != user.id and user.role != "admin": raise HTTPException(status_code=404, detail="Conversation not found") return conversation @router.post("/start", response_model=schemas.StartResponse) async def start_conversation( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): conversation = DiscoveryConversation( id=str(uuid.uuid4()), user_id=user.id, started_at=_now(), ) db.add(conversation) await db.commit() return schemas.StartResponse(conversation_id=conversation.id) @router.put( "/{conversation_id}/respond", response_model=schemas.RespondResponse ) async def save_responses( conversation_id: str, payload: schemas.RespondRequest, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): conversation = await _owned_conversation(db, conversation_id, user) conversation.prompt_alive = payload.prompt_alive conversation.prompt_friction = payload.prompt_friction conversation.prompt_pull = payload.prompt_pull conversation.prompt_recognition = payload.prompt_recognition conversation.prompt_future = payload.prompt_future conversation.prompt_goals_short = payload.prompt_goals_short conversation.prompt_goals_long = payload.prompt_goals_long await db.commit() return schemas.RespondResponse( conversation_id=conversation_id, status="responses_saved" ) @router.post( "/{conversation_id}/complete", response_model=schemas.ProfileResponse ) async def complete_conversation( conversation_id: str, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): conversation = await _owned_conversation(db, conversation_id, user) responses = { "alive": conversation.prompt_alive or "", "friction": conversation.prompt_friction or "", "pull": conversation.prompt_pull or "", "recognition": conversation.prompt_recognition or "", "future": conversation.prompt_future or "", "goals_short": conversation.prompt_goals_short or "", "goals_long": conversation.prompt_goals_long or "", } if not any(text.strip() for text in responses.values()): raise HTTPException( status_code=400, detail="No responses available to analyze" ) api_key = os.getenv("ANTHROPIC_API_KEY") model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6") try: extractor = DiscoveryExtractor(api_key=api_key, model=model) data = await extractor.extract(responses) except DiscoveryExtractionError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc profile = DiscoveryProfile( id=str(uuid.uuid4()), user_id=conversation.user_id, conversation_id=conversation.id, generated_at=_now(), triad=data.get("triad"), probable_type=_as_int(data.get("probable_type")), wing=_as_int(data.get("wing")), instinctual_variant=data.get("instinctual_variant"), instinctual_stack=data.get("instinctual_stack"), love_summary=data.get("love_summary"), strength_summary=data.get("strength_summary"), mission_summary=data.get("mission_summary"), vocation_summary=data.get("vocation_summary"), overlap_narrative=data.get("overlap_narrative"), short_term_goals=data.get("short_term_goals"), long_term_goals=data.get("long_term_goals"), confidence_json=json.dumps(data.get("confidence", {})), locked=False, ) conversation.completed_at = _now() db.add(profile) await db.commit() return _to_profile_response( profile, extraction_notes=data.get("extraction_notes") ) @router.get("/profile/me", response_model=schemas.ProfileResponse) async def get_my_profile( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): profile = await _latest_profile(db, user.id) if profile is None: raise HTTPException(status_code=404, detail="No profile for this user") return _to_profile_response(profile) @router.patch("/profile/me", response_model=schemas.ProfileResponse) async def update_my_profile( payload: schemas.ProfileUpdate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """Edit the prose of the latest profile. The person owns their words, so they can revise any summary, the narrative, or their goals — but only while the profile is unlocked. Affirming (locking) makes it final.""" profile = await _latest_profile(db, user.id) if profile is None: raise HTTPException(status_code=404, detail="No profile for this user") if profile.locked: raise HTTPException( status_code=409, detail="Profile is locked; it can no longer be edited.", ) updates = payload.model_dump(exclude_unset=True) if not updates: raise HTTPException(status_code=400, detail="No fields to update") for field, value in updates.items(): setattr(profile, field, value) await db.commit() await db.refresh(profile) return _to_profile_response(profile) @router.put( "/profile/me/confirm", response_model=schemas.ConfirmResponse ) async def confirm_my_profile( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): profile = await _latest_profile(db, user.id) if profile is None: raise HTTPException(status_code=404, detail="No profile for this user") profile.locked = True await db.commit() return schemas.ConfirmResponse(status="locked") @router.get( "/conversation/{conversation_id}", response_model=schemas.ConversationResponse, ) async def get_conversation( conversation_id: str, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): conversation = await _owned_conversation(db, conversation_id, user) return schemas.ConversationResponse.model_validate(conversation) def _as_int(value) -> int | None: """Coerce the model's numeric fields to int, tolerating strings/None.""" if value is None: return None try: return int(value) except (TypeError, ValueError): return None