From b9d7b0e22ba8b169751192c05b1b3d00fde65745 Mon Sep 17 00:00:00 2001 From: Joel Salmon Date: Tue, 16 Jun 2026 21:37:33 -0500 Subject: [PATCH] Phase 5: iteration & polish (visualizations, goal history, smart tagging) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 38 ++++++- app/routers/discovery.py | 45 +++++++- app/routers/integration.py | 40 +++++++ app/schemas.py | 28 +++++ app/services/profile_history.py | 50 +++++++++ app/services/reflector.py | 29 ++++- app/services/tagging.py | 137 +++++++++++++++++++++++ app/static/profile.html | 3 + app/static/style.css | 24 ++++ app/static/visuals.html | 190 ++++++++++++++++++++++++++++++++ tests/test_phase5.py | 145 ++++++++++++++++++++++++ tests/test_profile_history.py | 47 ++++++++ tests/test_reflection.py | 17 ++- tests/test_static_discovery.py | 14 +++ 14 files changed, 798 insertions(+), 9 deletions(-) create mode 100644 app/services/profile_history.py create mode 100644 app/services/tagging.py create mode 100644 app/static/visuals.html create mode 100644 tests/test_phase5.py create mode 100644 tests/test_profile_history.py diff --git a/README.md b/README.md index 4691fa6..aa0aa38 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ If you need to explain this app in detail, use this mental model: profile foundation via `POST /discovery/integration/task-mappings`. The rolled-up work patterns drive `/static/dashboard.html` and are fed into the coaching check-ins so they can reflect where time has actually gone. +15. (Phase 5) `/static/visuals.html` shows the Ikigai/Enneagram visuals and the + goal-evolution timeline; the tracker can call `suggest-foundation` for + smart tagging, and the reflect loop accepts a `focus` for deeper + goal-refinement. Machine-to-machine callers (e.g. the MCP server) skip the OAuth dance and authenticate with `X-API-Key: $IMPACTFLOW_API_KEY` instead. That header @@ -152,13 +156,16 @@ Important files: | `app/static/reflect.html` | Phase 2 AI-coach reflection chat (mirror loop, applies revisions, affirm) | | `app/static/coaching.html` | Phase 3 coaching preferences form + check-in feed | | `app/static/dashboard.html` | Phase 4 goal dashboard: time per foundation + neglected ones | +| `app/static/visuals.html` | Phase 5 visualizations: Ikigai Venn, Enneagram diagram, goal-evolution timeline | | `app/static/auth.js` | Shared `authedFetch` helper: sends session cookies, silently refreshes on `401`, redirects to login | | `app/static/style.css` | Shared UI styling | | `app/services/reflector.py` | `ReflectionCoach`: Anthropic-backed mirror loop, JSON parsing, revision filtering | | `app/services/coaching.py` | Deterministic preference generator + `CheckinCoach` (Anthropic check-in text) | | `app/services/foundations.py` | The six foundations + pure work-pattern aggregator (Phase 4) | +| `app/services/profile_history.py` | Pure goal-evolution aggregator over profile revisions (Phase 5) | +| `app/services/tagging.py` | `FoundationTagger`: Anthropic smart-tagging suggestion (Phase 5) | | `app/routers/coaching.py` | Phase 3 coaching routes: preferences, check-ins, weekly batch `/run` | -| `app/routers/integration.py` | Phase 4 task-to-goal integration: foundations, task-mappings, work-patterns | +| `app/routers/integration.py` | Phase 4/5 integration: foundations, task-mappings, work-patterns, suggest-foundation | | `alembic/versions/001_initial.py` | Initial database schema migration | | `alembic/versions/002_add_auth.py` | Adds `users`, `refresh_tokens`, and `activity_log` tables | | `alembic/versions/003_add_goals.py` | Adds the goal columns to `discovery_conversation` and `discovery_profile` | @@ -172,6 +179,8 @@ Important files: | `tests/test_coaching.py` | Tests for coaching endpoints (preferences, check-ins, due-logic batch, work-pattern wiring) | | `tests/test_foundations.py` | Unit tests for the pure work-pattern aggregator | | `tests/test_integration.py` | Tests for the task-to-goal integration endpoints | +| `tests/test_profile_history.py` | Unit tests for the pure goal-evolution aggregator | +| `tests/test_phase5.py` | Tests for goal-history and smart-tagging endpoints | | `tests/test_auth.py` | Tests for the dual-auth dependency (JWT + cookie + API key), token refresh/logout, admin enforcement, and domain allow-list | | `tests/test_profile_edit.py` | Tests for `PATCH /discovery/profile/me` (edit, partial update, lock/`409`) | | `tests/test_reflection.py` | Tests for the reflection endpoints (turns, applied revisions, lock/`409`, history) | @@ -461,6 +470,23 @@ the user (forwarded session/JWT) or service-to-service with `X-API-Key`. time has and hasn't gone — as an observation to check against the person's own words, never a verdict (mirror, not compass). +### 11. Iteration & Polish (Phase 5) + +- **Goal-evolution history:** `GET /discovery/profile/me/goal-history` derives a + per-goal timeline (near- and long-term) from the `profile_revision` snapshots + captured since Phase 2 — one entry per actual change, with `source` and `at`. + No new storage; it reads existing revisions. +- **Smart tagging:** `POST /discovery/integration/suggest-foundation` + (`{task_label}`) returns a suggested `foundation` + `rationale` + `confidence`. + It only suggests — the person confirms by posting the task mapping. The core + tracker uses this to pre-fill "which goal does this build toward?". +- **Deeper goal-refinement:** the reflect loop (Phase 2) accepts an optional + `focus` (e.g. `"goals"`) that steers the coach toward sharpening the + near/long-term goals — still a mirror. +- **Visualizations:** `visuals.html` renders an Ikigai four-circle Venn and an + Enneagram diagram from the profile (plain-language callouts — it does not + headline the raw type number), plus the goal-evolution timeline. + ## API Reference All `/discovery/*`, `/api/me*`, `/api/activity*`, and `/api/admin/*` routes @@ -507,6 +533,8 @@ clients.) `/api/auth/login`, `/api/auth/callback`, `/health`, `/`, and | `POST` | `/discovery/integration/task-mappings` | yes | Record a logged unit of work mapped to a foundation | | `GET` | `/discovery/integration/task-mappings` | yes | List the user's task mappings in a window | | `GET` | `/discovery/integration/work-patterns` | yes | Per-foundation work-pattern rollup over a window | +| `POST` | `/discovery/integration/suggest-foundation` | yes | Smart tagging: suggest a foundation for a task (person confirms) | +| `GET` | `/discovery/profile/me/goal-history` | yes | Timeline of how the person's goals evolved | ## Data Model @@ -780,6 +808,14 @@ callback), so the pages hold no tokens of their own. the person wants their energy — it observes, it does not prescribe - linked from `profile.html` ("Where your time goes") +`visuals.html` (Phase 5 visualizations): + +- reads `GET /discovery/profile/me` and renders an Ikigai four-circle Venn and + an Enneagram diagram in SVG (callouts stay in plain language) +- reads `GET /discovery/profile/me/goal-history` and shows how the near- and + long-term goals have changed over time +- linked from `profile.html` ("See your profile visualized") + ## Configuration Populate `.env` with at minimum the Anthropic key and the auth-related diff --git a/app/routers/discovery.py b/app/routers/discovery.py index 8bbffbe..292ef4c 100644 --- a/app/routers/discovery.py +++ b/app/routers/discovery.py @@ -30,6 +30,7 @@ from app.services.reflector import ( ReflectionCoach, ReflectionError, ) +from app.services.profile_history import goal_history router = APIRouter(prefix="/discovery", tags=["discovery"]) @@ -383,7 +384,11 @@ async def reflect_on_profile( model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6") try: coach = ReflectionCoach(api_key=api_key, model=model) - result = await coach.reflect(_profile_fields(profile) | {"triad": profile.triad}, coach_history) + result = await coach.reflect( + _profile_fields(profile) | {"triad": profile.triad}, + coach_history, + focus=payload.focus, + ) except ReflectionError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc @@ -459,6 +464,44 @@ async def get_profile_revisions( return out +@router.get( + "/profile/me/goal-history", response_model=schemas.GoalHistoryOut +) +async def get_goal_history( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """How the person's near/long-term goals evolved over time, derived from + the profile revision snapshots (Phase 5).""" + profile = await _latest_profile(db, user.id) + if profile is None: + raise HTTPException(status_code=404, detail="No profile for this user") + rev_stmt = ( + select(ProfileRevision) + .where(ProfileRevision.profile_id == profile.id) + .order_by(ProfileRevision.created_at) + ) + rows = (await db.execute(rev_stmt)).scalars().all() + revisions = [] + for r in rows: + try: + fields = json.loads(r.fields_json) + except (json.JSONDecodeError, TypeError): + fields = {} + revisions.append( + {"fields": fields, "source": r.source, "created_at": r.created_at} + ) + timelines = goal_history(revisions) + return schemas.GoalHistoryOut( + short_term_goals=[ + schemas.GoalHistoryEntry(**e) for e in timelines["short_term_goals"] + ], + long_term_goals=[ + schemas.GoalHistoryEntry(**e) for e in timelines["long_term_goals"] + ], + ) + + @router.get( "/conversation/{conversation_id}", response_model=schemas.ConversationResponse, diff --git a/app/routers/integration.py b/app/routers/integration.py index ad7f10e..6f47a7e 100644 --- a/app/routers/integration.py +++ b/app/routers/integration.py @@ -9,6 +9,7 @@ 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 @@ -25,6 +26,7 @@ from app.services.foundations import ( FOUNDATIONS, rollup, ) +from app.services.tagging import FoundationTagger, TaggingError router = APIRouter(prefix="/discovery/integration", tags=["integration"]) @@ -141,6 +143,44 @@ async def work_patterns_for( 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), diff --git a/app/schemas.py b/app/schemas.py index 324b5cc..8e9beb2 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -77,6 +77,9 @@ class ConfirmResponse(BaseModel): class ReflectRequest(BaseModel): # Empty/omitted starts the loop (the coach's opening reflection). message: str = "" + # Phase 5: optional refinement focus, e.g. "goals" to steer the coach + # toward sharpening the near/long-term goals. + focus: str = "" class ReflectionMessageOut(BaseModel): @@ -194,6 +197,31 @@ class WorkPatternsOut(BaseModel): neglected: list[str] +# -- Phase 5: iteration & polish --------------------------------------------- + + +class GoalHistoryEntry(BaseModel): + value: Optional[str] = None + source: Optional[str] = None + at: Optional[datetime] = None + + +class GoalHistoryOut(BaseModel): + short_term_goals: list[GoalHistoryEntry] + long_term_goals: list[GoalHistoryEntry] + + +class SuggestFoundationRequest(BaseModel): + task_label: str + + +class SuggestFoundationOut(BaseModel): + foundation: str + label: str + rationale: str = "" + confidence: str = "low" + + class ConversationResponse(BaseModel): id: str user_id: str diff --git a/app/services/profile_history.py b/app/services/profile_history.py new file mode 100644 index 0000000..53de224 --- /dev/null +++ b/app/services/profile_history.py @@ -0,0 +1,50 @@ +"""Phase 5: derive how a person's goals evolved from the profile_revision +snapshots already captured since Phase 2. + +Pure functions over revision rows — no DB access, fully testable. +""" +from typing import Any, Dict, List + +# The goal fields we track an evolution timeline for. +GOAL_FIELDS = ("short_term_goals", "long_term_goals") + + +def goal_history(revisions: List[Dict[str, Any]]) -> Dict[str, List[dict]]: + """Build a per-goal timeline of distinct values over time. + + Args: + revisions: each ``{"fields": {: value, ...}, "source": str, + "created_at": datetime}``, in ascending chronological order. + + Returns: + ``{"short_term_goals": [{"value", "source", "at"}...], + "long_term_goals": [...]}`` — one entry per *change*; consecutive + identical values are collapsed so the timeline shows only when a goal + actually moved. + """ + timelines: Dict[str, List[dict]] = {f: [] for f in GOAL_FIELDS} + last: Dict[str, Any] = {f: _SENTINEL for f in GOAL_FIELDS} + + for rev in revisions: + fields = rev.get("fields") or {} + for field in GOAL_FIELDS: + value = fields.get(field) + if value == last[field]: + continue + last[field] = value + timelines[field].append( + { + "value": value, + "source": rev.get("source"), + "at": rev.get("created_at"), + } + ) + return timelines + + +class _Sentinel: + pass + + +# Distinct from None so a first snapshot whose goal is None still emits once. +_SENTINEL = _Sentinel() diff --git a/app/services/reflector.py b/app/services/reflector.py index f4f8639..f96ceee 100644 --- a/app/services/reflector.py +++ b/app/services/reflector.py @@ -124,20 +124,36 @@ class ReflectionCoach: return messages async def _call_model( - self, profile: Dict[str, Any], messages: List[Dict[str, str]] + self, + profile: Dict[str, Any], + messages: List[Dict[str, str]], + system_suffix: str = "", ) -> str: response = await self.client.messages.create( model=self.model, max_tokens=MAX_TOKENS, system=SYSTEM_PROMPT.format( profile=self.profile_context(profile) - ), + ) + + system_suffix, messages=messages, ) return response.content[0].text + # Optional focus steers (Phase 5). Appended to the system prompt. + FOCUS_STEERS = { + "goals": ( + "\n\nFOCUS: Concentrate this turn on helping the person sharpen " + "their near-term and long-term goals — make them concrete and " + "theirs. Still a mirror: clarify what THEY said, never assign goals." + ) + } + async def reflect( - self, profile: Dict[str, Any], history: List[Dict[str, str]] + self, + profile: Dict[str, Any], + history: List[Dict[str, str]], + focus: str = "", ) -> Dict[str, Any]: """Produce one coach turn. @@ -156,9 +172,10 @@ class ReflectionCoach: ReflectionError on API failure or repeated parse failure. """ messages = self._build_messages(history) + steer = self.FOCUS_STEERS.get(focus, "") try: - raw = await self._call_model(profile, messages) + raw = await self._call_model(profile, messages, system_suffix=steer) except Exception as exc: # noqa: BLE001 - surface any SDK/transport error raise ReflectionError(f"Anthropic API call failed: {exc}") from exc @@ -170,7 +187,9 @@ class ReflectionCoach: {"role": "user", "content": RETRY_REMINDER}, ] try: - raw_retry = await self._call_model(profile, retry) + raw_retry = await self._call_model( + profile, retry, system_suffix=steer + ) except Exception as exc: # noqa: BLE001 raise ReflectionError( f"Anthropic API call failed on retry: {exc}" diff --git a/app/services/tagging.py b/app/services/tagging.py new file mode 100644 index 0000000..e0451e4 --- /dev/null +++ b/app/services/tagging.py @@ -0,0 +1,137 @@ +"""Phase 5 smart tagging: suggest which foundation a logged task builds toward. + +The suggestion is exactly that — a suggestion the person confirms (by posting a +task mapping). The tagger reads the person's own foundation text and the task +label and proposes the best-fit foundation with a short rationale. +""" +import json +from typing import Any, Dict + +from anthropic import AsyncAnthropic + +from app.services.foundations import FOUNDATIONS + +DEFAULT_MODEL = "claude-sonnet-4-6" +MAX_TOKENS = 400 + +SYSTEM_PROMPT = """You help a person tag a unit of work to one of their own self-discovery "foundations". You SUGGEST; the person confirms — never decide for them. + +THEIR FOUNDATIONS (their own words): +{foundations} + +Given a task, pick the ONE foundation it most plausibly builds toward. If it is genuinely unclear, pick the closest and mark confidence low. Do not invent foundations. + +Valid foundation keys: love, strength, mission, vocation, short_term, long_term. + +Respond ONLY with valid JSON, no markdown fences: +{{ + "foundation": "", + "rationale": "one short sentence, addressed to the person (you/your)", + "confidence": "high | medium | low" +}}""" + +RETRY_REMINDER = ( + "Your previous response could not be parsed. Respond ONLY with the single " + "valid JSON object described — no preamble, no markdown fences." +) + + +class TaggingError(Exception): + """Raised when tagging fails (API error or unparseable/invalid output).""" + + +class FoundationTagger: + """Suggests a foundation for a task label.""" + + def __init__(self, api_key: str, model: str = DEFAULT_MODEL): + if not api_key: + raise TaggingError( + "ANTHROPIC_API_KEY is not set; cannot suggest a foundation." + ) + self.model = model + self.client = AsyncAnthropic(api_key=api_key) + + @staticmethod + def _foundations_block(foundations: Dict[str, str]) -> str: + return "\n".join( + f"- {key} ({FOUNDATIONS[key]}): {foundations.get(key) or '(none)'}" + for key in FOUNDATIONS + ) + + async def _call(self, system: str, messages: list) -> str: + resp = await self.client.messages.create( + model=self.model, + max_tokens=MAX_TOKENS, + system=system, + messages=messages, + ) + return resp.content[0].text + + async def suggest( + self, task_label: str, foundations: Dict[str, str] + ) -> Dict[str, Any]: + """Return ``{"foundation", "rationale", "confidence"}``. The foundation + is guaranteed to be a valid key. Raises TaggingError on failure.""" + system = SYSTEM_PROMPT.format( + foundations=self._foundations_block(foundations) + ) + messages = [ + {"role": "user", "content": f"Task: {task_label}"} + ] + try: + raw = await self._call(system, messages) + except Exception as exc: # noqa: BLE001 + raise TaggingError(f"Anthropic API call failed: {exc}") from exc + + try: + return self._parse(raw) + except (json.JSONDecodeError, ValueError): + retry = messages + [ + {"role": "assistant", "content": raw}, + {"role": "user", "content": RETRY_REMINDER}, + ] + try: + raw_retry = await self._call(system, retry) + except Exception as exc: # noqa: BLE001 + raise TaggingError( + f"Anthropic API call failed on retry: {exc}" + ) from exc + try: + return self._parse(raw_retry) + except (json.JSONDecodeError, ValueError) as exc: + raise TaggingError( + f"Model did not return a valid suggestion: {exc}" + ) from exc + + @staticmethod + def _strip_fences(text: str) -> str: + s = (text or "").strip() + if s.startswith("```"): + nl = s.find("\n") + if nl != -1: + s = s[nl + 1 :] + if s.rstrip().endswith("```"): + s = s.rstrip()[:-3] + return s.strip() + + @classmethod + def _parse(cls, raw: str) -> Dict[str, Any]: + if not raw or not raw.strip(): + raise ValueError("empty response") + data = json.loads(cls._strip_fences(raw)) + if not isinstance(data, dict): + raise ValueError("not an object") + foundation = data.get("foundation") + if foundation not in FOUNDATIONS: + raise ValueError(f"invalid foundation: {foundation!r}") + confidence = data.get("confidence") + if confidence not in ("high", "medium", "low"): + confidence = "low" + rationale = data.get("rationale") + if not isinstance(rationale, str): + rationale = "" + return { + "foundation": foundation, + "rationale": rationale.strip(), + "confidence": confidence, + } diff --git a/app/static/profile.html b/app/static/profile.html index 8b720c3..36ba61d 100644 --- a/app/static/profile.html +++ b/app/static/profile.html @@ -148,6 +148,9 @@

+ `; content.innerHTML = ` diff --git a/app/static/style.css b/app/static/style.css index ab5328f..1184428 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -398,6 +398,30 @@ textarea.edit { } } +.viz-wrap { + display: flex; + justify-content: center; + margin-bottom: 20px; +} + +.viz { + width: 100%; + max-width: 420px; + height: auto; +} + +.goal-cols { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; +} + +@media (max-width: 560px) { + .goal-cols { + grid-template-columns: 1fr; + } +} + .bars { display: flex; flex-direction: column; diff --git a/app/static/visuals.html b/app/static/visuals.html new file mode 100644 index 0000000..2df263b --- /dev/null +++ b/app/static/visuals.html @@ -0,0 +1,190 @@ + + + + + + ImpactFlow — Your Profile, Visualized + + + + + + + +
+
ImpactFlow · Your Profile, Visualized
+

Loading…

+ +
+ + + + diff --git a/tests/test_phase5.py b/tests/test_phase5.py new file mode 100644 index 0000000..7a9c97e --- /dev/null +++ b/tests/test_phase5.py @@ -0,0 +1,145 @@ +"""Tests for Phase 5 endpoints: goal-history and smart-tagging suggestion.""" +import json +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +API_KEY = {"X-API-Key": "test-api-key"} + + +async def _seed_profile() -> str: + from app.auth import API_KEY_ADMIN_ID, ensure_api_key_admin + from app.database import AsyncSessionLocal + from app.models import DiscoveryConversation, DiscoveryProfile + + async with AsyncSessionLocal() as db: + await ensure_api_key_admin(db) + conv = DiscoveryConversation( + id=str(uuid.uuid4()), + user_id=API_KEY_ADMIN_ID, + started_at=datetime.now(timezone.utc), + ) + db.add(conv) + await db.commit() + profile = DiscoveryProfile( + id=str(uuid.uuid4()), + user_id=API_KEY_ADMIN_ID, + conversation_id=conv.id, + generated_at=datetime.now(timezone.utc), + triad="gut", + short_term_goals="run a pilot", + long_term_goals="statewide outfit", + ) + db.add(profile) + await db.commit() + return profile.id + + +async def _add_revision(profile_id, short, source, day): + from app.auth import API_KEY_ADMIN_ID + from app.database import AsyncSessionLocal + from app.models import ProfileRevision + + async with AsyncSessionLocal() as db: + db.add( + ProfileRevision( + id=str(uuid.uuid4()), + profile_id=profile_id, + user_id=API_KEY_ADMIN_ID, + source=source, + fields_json=json.dumps( + {"short_term_goals": short, "long_term_goals": "statewide outfit"} + ), + note=None, + created_at=datetime(2026, 6, day, tzinfo=timezone.utc), + ) + ) + await db.commit() + + +# ---- goal history ---------------------------------------------------------- + + +async def test_goal_history_timeline(app_client): + pid = await _seed_profile() + await _add_revision(pid, "run a pilot", "extraction", 1) + await _add_revision(pid, "run a pilot", "manual_edit", 2) # unchanged + await _add_revision(pid, "launch fall cohort", "reflection", 3) + + r = await app_client.get( + "/discovery/profile/me/goal-history", headers=API_KEY + ) + assert r.status_code == 200 + st = r.json()["short_term_goals"] + assert [e["value"] for e in st] == ["run a pilot", "launch fall cohort"] + assert st[-1]["source"] == "reflection" + + +async def test_goal_history_without_profile_404(app_client): + r = await app_client.get( + "/discovery/profile/me/goal-history", headers=API_KEY + ) + assert r.status_code == 404 + + +# ---- smart tagging --------------------------------------------------------- + + +class FakeTagger: + result = {"foundation": "short_term", "rationale": "builds your pilot", "confidence": "high"} + + def __init__(self, api_key=None, model=None): + pass + + async def suggest(self, task_label, foundations): + FakeTagger.last_label = task_label + return dict(FakeTagger.result) + + +@pytest.fixture(autouse=True) +async def fake_tagger(monkeypatch, app_client): + monkeypatch.setattr("app.routers.integration.FoundationTagger", FakeTagger) + yield + + +async def test_suggest_foundation(app_client): + await _seed_profile() + r = await app_client.post( + "/discovery/integration/suggest-foundation", + headers=API_KEY, + json={"task_label": "practice welding for the cohort"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["foundation"] == "short_term" + assert body["label"] # human label filled in + assert body["confidence"] == "high" + assert FakeTagger.last_label == "practice welding for the cohort" + + +async def test_suggest_foundation_empty_label_400(app_client): + await _seed_profile() + r = await app_client.post( + "/discovery/integration/suggest-foundation", + headers=API_KEY, + json={"task_label": " "}, + ) + assert r.status_code == 400 + + +async def test_suggest_foundation_without_profile_404(app_client): + r = await app_client.post( + "/discovery/integration/suggest-foundation", + headers=API_KEY, + json={"task_label": "anything"}, + ) + assert r.status_code == 404 + + +async def test_suggest_requires_auth(app_client): + r = await app_client.post( + "/discovery/integration/suggest-foundation", + json={"task_label": "x"}, + ) + assert r.status_code == 401 diff --git a/tests/test_profile_history.py b/tests/test_profile_history.py new file mode 100644 index 0000000..506794a --- /dev/null +++ b/tests/test_profile_history.py @@ -0,0 +1,47 @@ +"""Unit tests for the pure goal-evolution aggregator.""" +from datetime import datetime, timezone + +from app.services.profile_history import goal_history + + +def _rev(short, long_, source, day): + return { + "fields": {"short_term_goals": short, "long_term_goals": long_}, + "source": source, + "created_at": datetime(2026, 6, day, tzinfo=timezone.utc), + } + + +def test_collapses_consecutive_identical_values(): + revs = [ + _rev("a", "x", "extraction", 1), + _rev("a", "x", "manual_edit", 2), # no change -> skipped + _rev("b", "x", "reflection", 3), # short changed + ] + h = goal_history(revs) + assert [e["value"] for e in h["short_term_goals"]] == ["a", "b"] + assert [e["value"] for e in h["long_term_goals"]] == ["x"] + assert h["short_term_goals"][1]["source"] == "reflection" + + +def test_each_field_tracked_independently(): + revs = [ + _rev("a", "x", "extraction", 1), + _rev("a", "y", "reflection", 2), # only long changed + ] + h = goal_history(revs) + assert len(h["short_term_goals"]) == 1 + assert [e["value"] for e in h["long_term_goals"]] == ["x", "y"] + + +def test_empty_revisions_yield_empty_timelines(): + h = goal_history([]) + assert h == {"short_term_goals": [], "long_term_goals": []} + + +def test_first_value_emitted_even_if_none(): + revs = [_rev(None, None, "extraction", 1), _rev("a", None, "reflection", 2)] + h = goal_history(revs) + assert [e["value"] for e in h["short_term_goals"]] == [None, "a"] + # long stayed None throughout -> a single None entry + assert [e["value"] for e in h["long_term_goals"]] == [None] diff --git a/tests/test_reflection.py b/tests/test_reflection.py index dc38bd7..51628cc 100644 --- a/tests/test_reflection.py +++ b/tests/test_reflection.py @@ -55,8 +55,10 @@ class FakeCoach: def __init__(self, api_key=None, model=None): pass - async def reflect(self, profile, history): - FakeCoach.calls.append({"profile": profile, "history": list(history)}) + async def reflect(self, profile, history, focus=""): + FakeCoach.calls.append( + {"profile": profile, "history": list(history), "focus": focus} + ) return dict(FakeCoach.response) @@ -180,6 +182,17 @@ async def test_empty_message_after_opener_returns_400(app_client): assert r.status_code == 400 +async def test_reflect_passes_focus_to_coach(app_client): + """Phase 5: a goal-refinement focus reaches the coach.""" + await _seed_profile() + await app_client.post( + "/discovery/profile/me/reflect", + headers=API_KEY, + json={"message": "let's sharpen my goals", "focus": "goals"}, + ) + assert FakeCoach.calls[-1]["focus"] == "goals" + + async def test_reflect_requires_auth(app_client): r = await app_client.post("/discovery/profile/me/reflect", json={}) assert r.status_code == 401 diff --git a/tests/test_static_discovery.py b/tests/test_static_discovery.py index 5f88c9a..3b0a6d2 100644 --- a/tests/test_static_discovery.py +++ b/tests/test_static_discovery.py @@ -75,6 +75,20 @@ def test_profile_page_links_to_dashboard(): assert "/static/dashboard.html" in html +def test_visuals_page_reads_profile_and_goal_history(): + """Phase 5 visualizations read the profile and goal history via authedFetch.""" + html = Path("app/static/visuals.html").read_text(encoding="utf-8") + assert "/discovery/profile/me" in html + assert "/discovery/profile/me/goal-history" in html + assert "authedFetch" in html + assert "user_id" not in html + + +def test_profile_page_links_to_visuals(): + html = Path("app/static/profile.html").read_text(encoding="utf-8") + assert "/static/visuals.html" in html + + def test_auth_helper_sends_credentials_and_refreshes(): js = Path("app/static/auth.js").read_text(encoding="utf-8")