mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:20:36 +00:00
b4d8d17aed
Add the mirror-not-compass reflection layer between profile generation and
affirmation. The coach reflects the person's profile back, and only when they
explicitly correct or add something does it propose revisions in their own
direction — never prescribing goals.
- ReflectionCoach service (app/services/reflector.py): Anthropic-backed,
returns {message, revisions, revision_note}; revisions filtered to the seven
editable prose fields (never triad/type); one-retry JSON handling.
- Endpoints (owner-scoped, 409 when locked): POST /discovery/profile/me/reflect
(opener + turns, applies revisions), GET .../reflection (dialogue),
GET .../revisions (iteration history). complete records an 'extraction'
revision; PATCH records 'manual_edit'.
- Models + migration 004: reflection_message (coach/person turns) and
profile_revision (snapshots: extraction | reflection | manual_edit) —
captures edits and iterations rather than overwriting.
- Frontend: reflect.html chat (coach/person bubbles, live profile summary that
refreshes on revision, affirm); linked from profile.html.
- Affirmation remains the existing confirm/lock.
Also refresh README for Phase 2 and for the HTTPS deployment
(https://impactflow.teamci.org:8011, OAUTH_REDIRECT_URI + COOKIE_SECURE notes).
Tests: 50 passing (added reflector unit tests and reflection endpoint tests;
run in-container).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
232 lines
9.5 KiB
Python
232 lines
9.5 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]]
|
|
) -> str:
|
|
response = await self.client.messages.create(
|
|
model=self.model,
|
|
max_tokens=MAX_TOKENS,
|
|
system=SYSTEM_PROMPT.format(
|
|
profile=self.profile_context(profile)
|
|
),
|
|
messages=messages,
|
|
)
|
|
return response.content[0].text
|
|
|
|
async def reflect(
|
|
self, profile: Dict[str, Any], history: List[Dict[str, 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)
|
|
|
|
try:
|
|
raw = await self._call_model(profile, messages)
|
|
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)
|
|
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
|