Files
impactflow_discovery/app/routers/discovery.py
T
Joel Salmon 33674f92f4 Complete Phase 1: goals, cookie auth, profile editing
Close the remaining Phase 1 DoD gaps and reconcile the browser flow with
the auth layer.

Goals (5 -> 7 prompts):
- Add near-term (6-12mo) and long-term (3-5yr) goal prompts; collect raw
  text on the conversation and store AI-articulated goal summaries on the
  profile. Extractor articulates the person's own stated goals (mirror,
  not compass) and never fabricates. Alembic 003 adds the four columns.

Cookie-based browser sessions (fixes frontend<->auth desync):
- OAuth callback now sets httpOnly session cookies and redirects into the
  app instead of returning JSON. get_current_user gains a cookie fallback
  (X-API-Key -> Bearer -> cookie). refresh/logout read the refresh cookie
  and set/clear cookies. New shared auth.js (authedFetch) sends cookies and
  silently refreshes on 401. Static pages drop the bogus user_id and call
  the correct /me endpoints.

Profile editing (read/edit/affirm):
- PATCH /discovery/profile/me edits the prose (Ikigai summaries, overlap
  narrative, goals); owner-scoped, partial update, 409 when locked. Edit
  mode in profile.html with Save/Cancel.

Also: bump default model to claude-sonnet-4-6, align ports to 8011
(OAuth redirect, CORS), add COOKIE_SECURE/POST_LOGIN_REDIRECT config, and
refresh the README to match the shipped behavior.

Tests: 33 passing (added cookie-auth, profile-edit, goal-extraction cases;
factored a shared app_client fixture into conftest.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:26:08 -05:00

268 lines
9.1 KiB
Python

"""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