mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:00:35 +00:00
b9d7b0e22b
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>
194 lines
6.4 KiB
Python
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)
|