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>
This commit is contained in:
Joel Salmon
2026-06-16 21:37:33 -05:00
parent c4fc1cccd7
commit b9d7b0e22b
14 changed files with 798 additions and 9 deletions
+44 -1
View File
@@ -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,
+40
View File
@@ -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),
+28
View File
@@ -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
+50
View File
@@ -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": {<field>: 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()
+24 -5
View File
@@ -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}"
+137
View File
@@ -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": "<one of the valid keys>",
"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,
}
+3
View File
@@ -148,6 +148,9 @@
</p>
<p class="back-link" style="text-align:center;margin-top:10px">
<a href="/static/dashboard.html">Where your time goes →</a>
</p>
<p class="back-link" style="text-align:center;margin-top:10px">
<a href="/static/visuals.html">See your profile visualized →</a>
</p>`;
content.innerHTML = `
+24
View File
@@ -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;
+190
View File
@@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ImpactFlow — Your Profile, Visualized</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
<script src="/static/auth.js"></script>
</head>
<body>
<div class="wrap">
<div class="brand">ImpactFlow · Your Profile, Visualized</div>
<div id="content"><p>Loading…</p></div>
<p class="back-link"><a href="/static/profile.html">← Back to your profile</a></p>
</div>
<script>
const SVGNS = "http://www.w3.org/2000/svg";
function escapeHtml(s) {
if (s == null) return "";
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function el(tag, attrs, text) {
const n = document.createElementNS(SVGNS, tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
if (text != null) n.textContent = text;
return n;
}
// ---- Ikigai: four overlapping circles ----
function ikigaiSvg(p) {
const W = 460, H = 460, cx = W / 2, cy = H / 2, r = 130, off = 78;
const svg = el("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz" });
const circles = [
{ dx: 0, dy: -off, fill: "#c9a84c", label: "What you love", key: "love_summary" },
{ dx: off, dy: 0, fill: "#2e7d32", label: "Good at", key: "strength_summary" },
{ dx: 0, dy: off, fill: "#1c3049", label: "World needs", key: "mission_summary" },
{ dx: -off, dy: 0, fill: "#a0522d", label: "Paid for", key: "vocation_summary" },
];
circles.forEach((c) => {
svg.appendChild(
el("circle", {
cx: cx + c.dx, cy: cy + c.dy, r,
fill: c.fill, "fill-opacity": "0.22",
stroke: c.fill, "stroke-opacity": "0.7", "stroke-width": "1.5",
})
);
});
circles.forEach((c) => {
const lx = cx + c.dx * 1.7;
const ly = cy + c.dy * 1.7 + (c.dy === 0 ? 4 : 0);
svg.appendChild(
el("text", {
x: lx, y: ly, "text-anchor": "middle",
"font-size": "14", "font-weight": "600", fill: "#0d1b2a",
}, c.label)
);
});
svg.appendChild(
el("text", { x: cx, y: cy + 4, "text-anchor": "middle", "font-size": "15", "font-weight": "700", fill: "#0d1b2a" }, "Ikigai")
);
return svg;
}
// ---- Enneagram: 9 points, inner triangle + hexad, type/wing highlighted.
// We keep callouts in plain language rather than headlining a type number.
function enneagramSvg(p) {
const W = 420, H = 420, cx = W / 2, cy = H / 2, R = 160;
const deg = { 9: 0, 1: 40, 2: 80, 3: 120, 4: 160, 5: 200, 6: 240, 7: 280, 8: 320 };
const pt = (n) => {
const t = (deg[n] * Math.PI) / 180;
return [cx + R * Math.sin(t), cy - R * Math.cos(t)];
};
const svg = el("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz" });
svg.appendChild(el("circle", { cx, cy, r: R, fill: "none", stroke: "#1c3049", "stroke-opacity": "0.3", "stroke-width": "1.5" }));
const line = (a, b) => {
const [x1, y1] = pt(a), [x2, y2] = pt(b);
svg.appendChild(el("line", { x1, y1, x2, y2, stroke: "#1c3049", "stroke-opacity": "0.25", "stroke-width": "1.2" }));
};
[[9, 3], [3, 6], [6, 9]].forEach(([a, b]) => line(a, b));
[[1, 4], [4, 2], [2, 8], [8, 5], [5, 7], [7, 1]].forEach(([a, b]) => line(a, b));
const type = p.probable_type, wing = p.wing;
for (let n = 1; n <= 9; n++) {
const [x, y] = pt(n);
const isType = n === type, isWing = n === wing;
svg.appendChild(
el("circle", {
cx: x, cy: y, r: isType ? 17 : 12,
fill: isType ? "#c9a84c" : "#fff",
stroke: isWing ? "#c9a84c" : "#1c3049",
"stroke-width": isWing ? "3" : "1.5",
})
);
svg.appendChild(
el("text", {
x, y: y + 4, "text-anchor": "middle", "font-size": "13",
"font-weight": isType ? "700" : "500",
fill: isType ? "#0d1b2a" : "#1c3049",
}, String(n))
);
}
return svg;
}
const TRIAD_LABEL = {
gut: "You lead with instinct and will",
heart: "You lead with feeling and connection",
head: "You lead with thought and perception",
};
async function loadGoalHistory() {
try {
const res = await authedFetch("/discovery/profile/me/goal-history");
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
function timelineHtml(entries) {
if (!entries || !entries.length)
return `<p class="edit-help">No changes recorded yet.</p>`;
return entries
.map((e) => {
const when = e.at ? new Date(e.at).toLocaleDateString() : "";
return `<div class="card" style="margin-bottom:12px">
<p style="margin:0 0 6px"><span class="dot ${
e.source === "extraction" ? "medium" : "high"
}"></span> <strong>${escapeHtml(e.source || "")}</strong> · ${when}</p>
<p style="margin:0">${escapeHtml(e.value) || "—"}</p>
</div>`;
})
.join("");
}
async function load() {
const content = document.getElementById("content");
let p;
try {
const res = await authedFetch("/discovery/profile/me");
if (!res.ok) throw new Error("Profile not found");
p = await res.json();
} catch (e) {
content.innerHTML = `<div class="error-box">${escapeHtml(e.message)}</div>`;
return;
}
content.innerHTML = `
<p class="section-label">Your Ikigai</p>
<div id="ikigai" class="viz-wrap"></div>
<div class="ikigai-grid">
<div class="card"><h3>♥ What you love</h3><p>${escapeHtml(p.love_summary) || "—"}</p></div>
<div class="card"><h3>★ What you're good at</h3><p>${escapeHtml(p.strength_summary) || "—"}</p></div>
<div class="card"><h3>◆ What the world needs</h3><p>${escapeHtml(p.mission_summary) || "—"}</p></div>
<div class="card"><h3>$ What you can be paid for</h3><p>${escapeHtml(p.vocation_summary) || "—"}</p></div>
</div>
<p class="section-label" style="margin-top:30px">Your centre of gravity</p>
<div id="enneagram" class="viz-wrap"></div>
<div class="triad-block"><p>${escapeHtml(
TRIAD_LABEL[p.triad] || "Your pattern"
)}. This map highlights where your energy sits and the point it leans toward.</p></div>
<p class="section-label" style="margin-top:30px">How your goals have evolved</p>
<div class="goal-cols">
<div><p class="edit-help">Near-term (612 months)</p><div id="st"></div></div>
<div><p class="edit-help">Long-term (35 years)</p><div id="lt"></div></div>
</div>
`;
document.getElementById("ikigai").appendChild(ikigaiSvg(p));
document.getElementById("enneagram").appendChild(enneagramSvg(p));
const hist = await loadGoalHistory();
document.getElementById("st").innerHTML = timelineHtml(hist && hist.short_term_goals);
document.getElementById("lt").innerHTML = timelineHtml(hist && hist.long_term_goals);
}
load();
</script>
</body>
</html>