mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 07:10:37 +00:00
Initial commit: ImpactFlow Discovery + Google OAuth auth layer
Discovery service (pre-existing): FastAPI + async SQLAlchemy + Alembic +
SQLite + Anthropic, with a five-prompt static UI that produces an Enneagram
+ Ikigai profile.
Auth implementation (this change set) follows
Impact_Flow_Auth_Plan_OAuth.html, adapted to the discovery_conversation /
discovery_profile schema:
- app/auth.py: Google OAuth registration, JWT issue/decode, dual-auth
dependency (Bearer JWT or X-API-Key), refresh-token hashing, domain
allow-list, synthetic api-key-admin user
- app/tracking.py: ActivityTrackingMiddleware + log_activity helper;
tags machine-to-machine calls source=mcp
- app/routers/auth.py: /api/auth/{login,callback,refresh,logout},
/api/me, /api/me/{stats,sessions,sessions/{id}}
- app/routers/activity.py: /api/activity, /api/activity/summary,
/api/admin/activity, plus prune_old_activity (90-day retention)
- app/routers/discovery.py: every route now user-scoped via the auth
dependency; /discovery/profile/{user_id} -> /discovery/profile/me
- alembic/versions/002_add_auth.py: users, refresh_tokens, activity_log
- tests/test_auth.py: 8 tests covering 401 paths, X-API-Key admin
resolution, JWT round-trip, admin gating, domain allow-list
- README.md: Authentication section, expanded env-var table, updated
data-model and API-reference tables
- .env.example: new GOOGLE_*, JWT_*, IMPACTFLOW_API_KEY, CORS_*,
ALLOWED_EMAIL_DOMAINS placeholders
- .gitignore: also exclude data/*.log
Tests: 19/19 pass (11 pre-existing + 8 new). smoke_test.py exercises the
full discovery flow under X-API-Key plus 401 paths, OAuth login redirect,
activity logging, and /api/me/stats.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""Activity feed + admin endpoints."""
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import get_current_user, require_admin
|
||||
from app.database import get_db
|
||||
from app.models import ActivityLog, User
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["activity"])
|
||||
|
||||
|
||||
class ActivityOut(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
action: str
|
||||
resource: str
|
||||
resource_id: str | None
|
||||
source: str
|
||||
created_at: datetime
|
||||
ip_address: str | None
|
||||
user_agent: str | None
|
||||
|
||||
|
||||
class ActivitySummary(BaseModel):
|
||||
days: int
|
||||
total: int
|
||||
actions_per_day: dict[str, int]
|
||||
top_resources: list[tuple[str, int]]
|
||||
web_count: int
|
||||
mcp_count: int
|
||||
|
||||
|
||||
def _to_out(row: ActivityLog) -> ActivityOut:
|
||||
return ActivityOut(
|
||||
id=row.id,
|
||||
user_id=row.user_id,
|
||||
action=row.action,
|
||||
resource=row.resource,
|
||||
resource_id=row.resource_id,
|
||||
source=row.source,
|
||||
created_at=row.created_at,
|
||||
ip_address=row.ip_address,
|
||||
user_agent=row.user_agent,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/activity", response_model=list[ActivityOut])
|
||||
async def list_my_activity(
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=200),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = (
|
||||
select(ActivityLog)
|
||||
.where(ActivityLog.user_id == user.id)
|
||||
.order_by(ActivityLog.created_at.desc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [_to_out(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/activity/summary", response_model=ActivitySummary)
|
||||
async def my_activity_summary(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
stmt = (
|
||||
select(ActivityLog)
|
||||
.where(ActivityLog.user_id == user.id)
|
||||
.where(ActivityLog.created_at >= since)
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
per_day: Counter[str] = Counter()
|
||||
resource_counts: Counter[str] = Counter()
|
||||
web = mcp = 0
|
||||
for r in rows:
|
||||
per_day[r.created_at.date().isoformat()] += 1
|
||||
resource_counts[r.resource] += 1
|
||||
if r.source == "mcp":
|
||||
mcp += 1
|
||||
else:
|
||||
web += 1
|
||||
|
||||
return ActivitySummary(
|
||||
days=days,
|
||||
total=len(rows),
|
||||
actions_per_day=dict(per_day),
|
||||
top_resources=resource_counts.most_common(5),
|
||||
web_count=web,
|
||||
mcp_count=mcp,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/activity", response_model=list[ActivityOut])
|
||||
async def admin_list_activity(
|
||||
user_id: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(ActivityLog).order_by(ActivityLog.created_at.desc())
|
||||
if user_id:
|
||||
stmt = stmt.where(ActivityLog.user_id == user_id)
|
||||
stmt = stmt.offset((page - 1) * limit).limit(limit)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [_to_out(r) for r in rows]
|
||||
|
||||
|
||||
async def prune_old_activity(db: AsyncSession, days: int = 90) -> int:
|
||||
"""Delete rows older than N days. Returns rows deleted."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
stmt = select(func.count()).select_from(ActivityLog).where(
|
||||
ActivityLog.created_at < cutoff
|
||||
)
|
||||
count = (await db.execute(stmt)).scalar_one()
|
||||
if count:
|
||||
from sqlalchemy import delete
|
||||
|
||||
await db.execute(delete(ActivityLog).where(
|
||||
ActivityLog.created_at < cutoff
|
||||
))
|
||||
await db.commit()
|
||||
return int(count or 0)
|
||||
@@ -0,0 +1,288 @@
|
||||
"""OAuth + JWT auth routes (mounted at /api/auth and /api/me)."""
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from authlib.integrations.base_client import OAuthError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import (
|
||||
create_access_token,
|
||||
email_domain_allowed,
|
||||
find_or_create_google_user,
|
||||
get_current_user,
|
||||
hash_refresh_token,
|
||||
issue_refresh_token,
|
||||
oauth,
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
ActivityLog,
|
||||
DiscoveryConversation,
|
||||
DiscoveryProfile,
|
||||
RefreshToken,
|
||||
User,
|
||||
)
|
||||
from sqlalchemy import func
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["auth"])
|
||||
|
||||
|
||||
# -- response shapes ----------------------------------------------------------
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
display_name: str
|
||||
avatar_url: str | None = None
|
||||
role: str
|
||||
|
||||
@classmethod
|
||||
def from_orm_user(cls, user: User) -> "UserOut":
|
||||
return cls(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
role=user.role,
|
||||
)
|
||||
|
||||
|
||||
class TokenBundle(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
|
||||
|
||||
class RefreshIn(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class AccessOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class DisplayNamePatch(BaseModel):
|
||||
display_name: str
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
id: str
|
||||
device: str | None
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
# -- OAuth flow ---------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/auth/login")
|
||||
async def login(request: Request):
|
||||
"""Kick off the OAuth dance: 302 to Google's consent screen."""
|
||||
redirect_uri = os.getenv("OAUTH_REDIRECT_URI") or str(
|
||||
request.url_for("auth_callback")
|
||||
)
|
||||
return await oauth.google.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@router.get("/auth/callback", name="auth_callback")
|
||||
async def auth_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Google redirects here with `code`; exchange it, mint our tokens."""
|
||||
try:
|
||||
token = await oauth.google.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"OAuth error: {exc.error or exc}",
|
||||
) from exc
|
||||
|
||||
user_info = token.get("userinfo")
|
||||
if not user_info or not user_info.get("email") or not user_info.get("sub"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Google did not return email/sub",
|
||||
)
|
||||
if user_info.get("email_verified") is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Email not verified by Google",
|
||||
)
|
||||
if not email_domain_allowed(user_info["email"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Email domain not allowed",
|
||||
)
|
||||
|
||||
user = await find_or_create_google_user(db, user_info)
|
||||
access = create_access_token(user.id, user.email)
|
||||
refresh = await issue_refresh_token(
|
||||
db, user, request.headers.get("user-agent")
|
||||
)
|
||||
return TokenBundle(
|
||||
access_token=access,
|
||||
refresh_token=refresh,
|
||||
user=UserOut.from_orm_user(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/refresh", response_model=AccessOut)
|
||||
async def refresh_access_token(
|
||||
body: RefreshIn, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
stmt = select(RefreshToken).where(
|
||||
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
|
||||
)
|
||||
row = (await db.execute(stmt)).scalar_one_or_none()
|
||||
now = datetime.now(timezone.utc)
|
||||
if row is None or row.revoked_at is not None or _expired(row.expires_at, now):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh token invalid or expired",
|
||||
)
|
||||
user = await db.get(User, row.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User no longer exists",
|
||||
)
|
||||
access = create_access_token(user.id, user.email)
|
||||
return AccessOut(access_token=access)
|
||||
|
||||
|
||||
def _expired(expires_at: datetime, now: datetime) -> bool:
|
||||
# SQLite stores naive datetimes; compare in UTC.
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
return expires_at < now
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
async def logout(
|
||||
body: RefreshIn, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Revoke a single refresh token. Idempotent — unknown token returns 200."""
|
||||
stmt = select(RefreshToken).where(
|
||||
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
|
||||
)
|
||||
row = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if row is not None and row.revoked_at is None:
|
||||
row.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# -- profile / sessions -------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/auth/me", response_model=UserOut)
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def get_me(user: User = Depends(get_current_user)):
|
||||
return UserOut.from_orm_user(user)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserOut)
|
||||
async def patch_me(
|
||||
body: DisplayNamePatch,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
new_name = body.display_name.strip()
|
||||
if not new_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="display_name cannot be empty",
|
||||
)
|
||||
user.display_name = new_name
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return UserOut.from_orm_user(user)
|
||||
|
||||
|
||||
class MeStats(BaseModel):
|
||||
conversations: int
|
||||
profiles: int
|
||||
locked_profiles: int
|
||||
activity_last_30d: int
|
||||
last_login_at: datetime | None
|
||||
|
||||
|
||||
@router.get("/me/stats", response_model=MeStats)
|
||||
async def my_stats(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
convo_count = (await db.execute(
|
||||
select(func.count())
|
||||
.select_from(DiscoveryConversation)
|
||||
.where(DiscoveryConversation.user_id == user.id)
|
||||
)).scalar_one()
|
||||
profile_count = (await db.execute(
|
||||
select(func.count())
|
||||
.select_from(DiscoveryProfile)
|
||||
.where(DiscoveryProfile.user_id == user.id)
|
||||
)).scalar_one()
|
||||
locked_count = (await db.execute(
|
||||
select(func.count())
|
||||
.select_from(DiscoveryProfile)
|
||||
.where(DiscoveryProfile.user_id == user.id)
|
||||
.where(DiscoveryProfile.locked.is_(True))
|
||||
)).scalar_one()
|
||||
since = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
activity_count = (await db.execute(
|
||||
select(func.count())
|
||||
.select_from(ActivityLog)
|
||||
.where(ActivityLog.user_id == user.id)
|
||||
.where(ActivityLog.created_at >= since)
|
||||
)).scalar_one()
|
||||
return MeStats(
|
||||
conversations=int(convo_count or 0),
|
||||
profiles=int(profile_count or 0),
|
||||
locked_profiles=int(locked_count or 0),
|
||||
activity_last_30d=int(activity_count or 0),
|
||||
last_login_at=user.last_login_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/sessions", response_model=list[SessionOut])
|
||||
async def list_sessions(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = (
|
||||
select(RefreshToken)
|
||||
.where(RefreshToken.user_id == user.id)
|
||||
.where(RefreshToken.revoked_at.is_(None))
|
||||
.order_by(RefreshToken.created_at.desc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [
|
||||
SessionOut(
|
||||
id=r.id,
|
||||
device=r.device,
|
||||
created_at=r.created_at,
|
||||
expires_at=r.expires_at,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/me/sessions/{session_id}")
|
||||
async def revoke_session(
|
||||
session_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
row = await db.get(RefreshToken, session_id)
|
||||
if row is None or row.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found",
|
||||
)
|
||||
if row.revoked_at is None:
|
||||
row.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return {"status": "revoked"}
|
||||
@@ -0,0 +1,230 @@
|
||||
"""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,
|
||||
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
|
||||
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 "",
|
||||
}
|
||||
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-5")
|
||||
|
||||
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"),
|
||||
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.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
|
||||
Reference in New Issue
Block a user