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
+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,
}