Phase 4: task-to-goal integration (Vision side)

Build the boundary the ImpactFlow core time-tracker plugs into. A task maps to
a foundation — one of the six stable profile elements (love, strength, mission,
vocation, short_term, long_term) — so the tracker can ask "which goal does this
build toward?" and post the answer back to Vision.

- Models + migration 006: task_mapping (one row per logged time entry).
- app/services/foundations.py: the six foundations + a pure, testable
  work-pattern aggregator (rollup) and a plain-language summary.
- app/routers/integration.py (user-scoped; tracker calls as the user or via
  X-API-Key): GET /foundations, POST/GET /task-mappings,
  GET /work-patterns?days=N (per-foundation minutes/share/neglected).
- Reminder engine now pulls from real work patterns: CheckinCoach takes an
  optional work-pattern summary (last 14 days) and reflects where time has gone
  against the person's own words — an observation, never a verdict.
- Frontend: dashboard.html (time per foundation + neglected); linked from
  profile.html.

Documented the core-tracker integration contract in the README. Phase 4
completes the Vision module's roadmap on the Discovery side; the core tracker
integrates by calling these endpoints.

Tests: 86 passing (added pure-aggregator, integration-endpoint, and
work-pattern-into-check-in tests; run in-container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joel Salmon
2026-06-16 21:24:58 -05:00
parent 50453901b3
commit c4fc1cccd7
16 changed files with 857 additions and 5 deletions
+11 -1
View File
@@ -27,6 +27,11 @@ from app.services.coaching import (
CheckinError,
generate_preferences,
)
from app.services.foundations import work_pattern_text
from app.routers.integration import work_patterns_for
# Look-back window (days) for the work-pattern signal fed into a check-in.
_WORK_PATTERN_DAYS = 14
router = APIRouter(prefix="/discovery/coaching", tags=["coaching"])
@@ -206,8 +211,13 @@ async def _generate_checkin(
api_key = os.getenv("ANTHROPIC_API_KEY")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
coach = CheckinCoach(api_key=api_key, model=model)
# Phase 4: feed recent work patterns into the check-in so it can reflect
# where time has actually gone against the person's stated direction.
summary = await work_patterns_for(db, user_id, _WORK_PATTERN_DAYS)
body = await coach.generate(
_profile_dict(profile), _prefs_out(prefs).model_dump()
_profile_dict(profile),
_prefs_out(prefs).model_dump(),
work_patterns=work_pattern_text(summary, _WORK_PATTERN_DAYS),
)
checkin = CoachingCheckin(
id=str(uuid.uuid4()),
+153
View File
@@ -0,0 +1,153 @@
"""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 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,
)
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.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)