mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 05:00:37 +00:00
b9d7b0e22b
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>
251 lines
10 KiB
Python
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
|