Files
impactflow_discovery/app/services/reflector.py
T
Joel Salmon b9d7b0e22b 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>
2026-06-16 21:37:33 -05:00

251 lines
10 KiB
Python

"""ReflectionCoach: the Phase 2 AI-coach reflection loop.
The coach is a MIRROR, never a compass. It reflects the person's own profile
back to them in plain language, listens to their reactions, and — only when
they explicitly correct or add something — proposes revised text for the
affected prose fields using the person's own direction. It never prescribes
goals or invents direction.
Like DiscoveryExtractor, this class is responsible only for plumbing: building
the messages, calling the model, and parsing/validating the JSON it returns.
"""
import json
from typing import Any, Dict, List, Optional
from anthropic import AsyncAnthropic
DEFAULT_MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 1200
# The only profile fields the coach may propose changes to. The structural
# Enneagram read (triad/type/wing/variant) is never editable via reflection.
EDITABLE_FIELDS = (
"love_summary",
"strength_summary",
"mission_summary",
"vocation_summary",
"overlap_narrative",
"short_term_goals",
"long_term_goals",
)
# Maps a stored ReflectionMessage.role to an Anthropic message role.
ROLE_TO_API = {"coach": "assistant", "person": "user"}
# Sent as the first (user) turn on every call so the conversation always
# starts with a user message, and to frame the coach's task. Not stored.
PRIMER = (
"I have just completed my self-discovery profile (it is in your "
"instructions). Reflect it back to me so I can see whether it fits."
)
SYSTEM_PROMPT = """You are an AI coach inside a self-discovery tool. You are a MIRROR, never a compass.
THE PERSON'S CURRENT PROFILE:
{profile}
YOUR ROLE:
- Reflect this profile back in warm, plain language and ask whether it lands: in spirit, "Here is what I am hearing — do you recognize yourself? What would you add, change, or disagree with?"
- Listen to how the person reacts. When they correct, add to, or push back on something, reflect their own words back to them — clarify and sharpen what THEY mean.
- Ask gentle, open questions that help the person articulate their own sense of direction.
ABSOLUTE RULES (mirror, not compass):
- NEVER prescribe goals, paths, careers, or what they "should" do.
- NEVER invent a direction the person did not express. If you are unsure what they mean, ask rather than assume.
- Do NOT mention Enneagram type numbers; describe patterns in plain language.
- Keep replies short and conversational — 2 to 5 sentences, at most one question.
PROPOSING REVISIONS:
- Only when the person explicitly corrects, adds to, or asks to change part of their profile, propose updated text for the affected field(s), written in their own direction. Editable fields: love_summary, strength_summary, mission_summary, vocation_summary, overlap_narrative, short_term_goals, long_term_goals.
- Otherwise set "revisions" to null. Never change their Enneagram type, triad, or instinctual variant. Never revise just because you could — only to capture what the person said.
OUTPUT FORMAT:
Respond ONLY with valid JSON. No preamble, no markdown fences.
{{
"message": "your reflective reply to the person, in second person (you/your), warm and plain",
"revisions": {{ "<field>": "<revised text in the person's own direction>" }} or null,
"revision_note": "a short phrase naming what changed, or null"
}}"""
RETRY_REMINDER = (
"Your previous response could not be parsed as JSON. Respond ONLY with the "
"single valid JSON object described in your instructions — no preamble, no "
"explanation, and no markdown code fences."
)
class ReflectionError(Exception):
"""Raised when a reflection turn fails (API error or unparseable output)."""
class ReflectionCoach:
"""Generates one coach turn given the profile and the dialogue so far."""
def __init__(self, api_key: str, model: str = DEFAULT_MODEL):
if not api_key:
raise ReflectionError(
"ANTHROPIC_API_KEY is not set; cannot run reflection."
)
self.model = model
self.client = AsyncAnthropic(api_key=api_key)
@staticmethod
def profile_context(profile: Dict[str, Any]) -> str:
"""Render the current profile as plain text for the system prompt."""
triad = {
"gut": "leads with instinct and will (gut-centered)",
"heart": "leads with feeling and connection (heart-centered)",
"head": "leads with thought and perception (head-centered)",
}.get(profile.get("triad") or "", "centered pattern not yet clear")
lines = [
f"- Core pattern: {triad}",
f"- What you love: {profile.get('love_summary') or '(none)'}",
f"- What you are good at: {profile.get('strength_summary') or '(none)'}",
f"- What the world needs from you: {profile.get('mission_summary') or '(none)'}",
f"- What you can be paid for: {profile.get('vocation_summary') or '(none)'}",
f"- Where it converges: {profile.get('overlap_narrative') or '(none)'}",
f"- Near-term goals (6-12mo): {profile.get('short_term_goals') or '(none stated)'}",
f"- Long-term goals (3-5yr): {profile.get('long_term_goals') or '(none stated)'}",
]
return "\n".join(lines)
def _build_messages(
self, history: List[Dict[str, str]]
) -> List[Dict[str, str]]:
"""Build the Anthropic messages array: a fixed user primer followed by
the stored turns mapped to user/assistant roles."""
messages = [{"role": "user", "content": PRIMER}]
for turn in history:
api_role = ROLE_TO_API.get(turn["role"])
if api_role is None:
continue
messages.append({"role": api_role, "content": turn["content"]})
return messages
async def _call_model(
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]],
focus: str = "",
) -> Dict[str, Any]:
"""Produce one coach turn.
Args:
profile: the current profile dict (at least the editable fields and
triad).
history: prior turns as ``[{"role": "coach"|"person", "content": ...}]``
in order. Empty for the opening reflection. The last turn, if
any, should be the person's latest message.
Returns:
``{"message": str, "revisions": dict|None, "revision_note": str|None}``
with revisions filtered to the editable fields only.
Raises:
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, system_suffix=steer)
except Exception as exc: # noqa: BLE001 - surface any SDK/transport error
raise ReflectionError(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_model(
profile, retry, system_suffix=steer
)
except Exception as exc: # noqa: BLE001
raise ReflectionError(
f"Anthropic API call failed on retry: {exc}"
) from exc
try:
return self._parse(raw_retry)
except (json.JSONDecodeError, ValueError) as exc:
raise ReflectionError(
f"Model did not return valid JSON after retry: {exc}"
) from exc
@staticmethod
def _strip_fences(text: str) -> str:
stripped = (text or "").strip()
if stripped.startswith("```"):
newline = stripped.find("\n")
if newline != -1:
stripped = stripped[newline + 1 :]
if stripped.rstrip().endswith("```"):
stripped = stripped.rstrip()[: -len("```")]
return stripped.strip()
@classmethod
def _parse(cls, raw: str) -> Dict[str, Any]:
if not raw or not raw.strip():
raise ValueError("empty response from model")
data = json.loads(cls._strip_fences(raw))
if not isinstance(data, dict):
raise ValueError("top-level JSON value is not an object")
message = data.get("message")
if not isinstance(message, str) or not message.strip():
raise ValueError("missing or empty 'message'")
revisions = cls._clean_revisions(data.get("revisions"))
note = data.get("revision_note")
if not isinstance(note, str) or not note.strip():
note = None
return {
"message": message.strip(),
"revisions": revisions,
"revision_note": note,
}
@staticmethod
def _clean_revisions(revisions: Any) -> Optional[Dict[str, str]]:
"""Keep only editable string fields; drop anything else (e.g. an
attempt to change triad/type). Returns None if nothing valid remains."""
if not isinstance(revisions, dict):
return None
cleaned = {
k: v
for k, v in revisions.items()
if k in EDITABLE_FIELDS and isinstance(v, str) and v.strip()
}
return cleaned or None