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 @@
Where your time goes →
+
+
+ See your profile visualized →
`;
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
+
+
+
+
+
+
+
+
+
+
+
+
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")