Files
impactflow_discovery/app/routers/integration.py
T
Joel Salmon b9d7b0e22b Phase 5: iteration & polish (visualizations, goal history, smart tagging)
Final roadmap phase. No DB migration — it reads data already captured.

- Goal-evolution history: GET /discovery/profile/me/goal-history derives a
  per-goal timeline from the profile_revision snapshots (pure aggregator in
  app/services/profile_history.py).
- Smart tagging: POST /discovery/integration/suggest-foundation suggests which
  foundation a task builds toward + rationale/confidence (FoundationTagger,
  app/services/tagging.py). Suggestion only; the person confirms by posting the
  task mapping.
- Deeper goal-refinement: the reflect loop accepts an optional focus ("goals")
  that steers the coach toward sharpening goals — still mirror, not compass.
- Visualizations: visuals.html renders an Ikigai Venn and an Enneagram diagram
  (plain-language callouts, not the raw type number) plus the goal-evolution
  timeline; linked from profile.html.

Tests: 99 passing (added pure goal-history tests, goal-history + suggest
endpoint tests, reflect-focus passthrough; run in-container). README updated.

This completes the ImpactFlow Vision roadmap (Phases 1-5) on the Discovery side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:37:33 -05:00

194 lines
6.4 KiB
Python

"""Phase 4 integration routes: the boundary the ImpactFlow core time-tracker
plugs into.
When a user logs time in the core tracker, the tracker asks "which goal does
this build toward?" — fetching the options from ``GET /foundations`` — and
posts the answer to ``POST /task-mappings``. ``GET /work-patterns`` rolls those
up to feed the coaching reminder engine and the goal dashboard.
All routes are user-scoped via the dual-auth dependency; the tracker calls as
the user (forwarded session/JWT) or, service-to-service, with ``X-API-Key``.
"""
import os
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
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 DiscoveryProfile, TaskMapping, User
from app.services.foundations import (
FOUNDATION_TO_FIELD,
FOUNDATIONS,
rollup,
)
from app.services.tagging import FoundationTagger, TaggingError
router = APIRouter(prefix="/discovery/integration", tags=["integration"])
def _now() -> datetime:
return datetime.now(timezone.utc)
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())
)
return (await db.execute(stmt)).scalars().first()
@router.get("/foundations", response_model=list[schemas.FoundationOut])
async def list_foundations(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""The mappable foundations for the user's latest profile, with the
person's own text — what the tracker shows as "which goal does this build
toward?"."""
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
return [
schemas.FoundationOut(
key=key,
label=label,
text=getattr(profile, FOUNDATION_TO_FIELD[key], None),
)
for key, label in FOUNDATIONS.items()
]
@router.post("/task-mappings", response_model=schemas.TaskMappingOut)
async def create_task_mapping(
payload: schemas.TaskMappingCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Record one logged unit of work, mapped to the foundation it builds
toward. Called by the core tracker when time is logged."""
if payload.foundation not in FOUNDATIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid foundation: {payload.foundation!r}. "
f"Allowed: {sorted(FOUNDATIONS)}",
)
if payload.minutes < 0:
raise HTTPException(status_code=400, detail="minutes must be >= 0")
mapping = TaskMapping(
id=str(uuid.uuid4()),
user_id=user.id,
external_task_id=payload.external_task_id,
task_label=payload.task_label or None,
foundation=payload.foundation,
minutes=payload.minutes,
occurred_at=payload.occurred_at or _now(),
created_at=_now(),
)
db.add(mapping)
await db.commit()
await db.refresh(mapping)
return schemas.TaskMappingOut.model_validate(mapping, from_attributes=True)
@router.get("/task-mappings", response_model=list[schemas.TaskMappingOut])
async def list_task_mappings(
days: int = Query(30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
cutoff = _now() - timedelta(days=days)
stmt = (
select(TaskMapping)
.where(TaskMapping.user_id == user.id)
.where(TaskMapping.occurred_at >= cutoff)
.order_by(TaskMapping.occurred_at.desc())
)
rows = (await db.execute(stmt)).scalars().all()
return [
schemas.TaskMappingOut.model_validate(r, from_attributes=True)
for r in rows
]
async def work_patterns_for(
db: AsyncSession, user_id: str, days: int
) -> dict:
"""Roll up the user's task mappings over the window. Shared with the
coaching check-in engine."""
cutoff = _now() - timedelta(days=days)
stmt = (
select(TaskMapping)
.where(TaskMapping.user_id == user_id)
.where(TaskMapping.occurred_at >= cutoff)
)
rows = (await db.execute(stmt)).scalars().all()
entries = [
{
"foundation": r.foundation,
"minutes": r.minutes,
"occurred_at": r.occurred_at,
}
for r in rows
]
return rollup(entries)
@router.post(
"/suggest-foundation", response_model=schemas.SuggestFoundationOut
)
async def suggest_foundation(
payload: schemas.SuggestFoundationRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Phase 5 smart tagging: suggest which foundation a task builds toward.
The person confirms by posting the task mapping. Suggestion only — nothing
is stored here."""
label = payload.task_label.strip()
if not label:
raise HTTPException(status_code=400, detail="task_label is required")
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
foundations_text = {
key: getattr(profile, field, None)
for key, field in FOUNDATION_TO_FIELD.items()
}
api_key = os.getenv("ANTHROPIC_API_KEY")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
try:
tagger = FoundationTagger(api_key=api_key, model=model)
result = await tagger.suggest(label, foundations_text)
except TaggingError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return schemas.SuggestFoundationOut(
foundation=result["foundation"],
label=FOUNDATIONS[result["foundation"]],
rationale=result["rationale"],
confidence=result["confidence"],
)
@router.get("/work-patterns", response_model=schemas.WorkPatternsOut)
async def get_work_patterns(
days: int = Query(30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Per-foundation work patterns over the window (powers the dashboard and
the coaching reminder engine)."""
summary = await work_patterns_for(db, user.id, days)
return schemas.WorkPatternsOut(window_days=days, **summary)