Files
Joel Salmon 33674f92f4 Complete Phase 1: goals, cookie auth, profile editing
Close the remaining Phase 1 DoD gaps and reconcile the browser flow with
the auth layer.

Goals (5 -> 7 prompts):
- Add near-term (6-12mo) and long-term (3-5yr) goal prompts; collect raw
  text on the conversation and store AI-articulated goal summaries on the
  profile. Extractor articulates the person's own stated goals (mirror,
  not compass) and never fabricates. Alembic 003 adds the four columns.

Cookie-based browser sessions (fixes frontend<->auth desync):
- OAuth callback now sets httpOnly session cookies and redirects into the
  app instead of returning JSON. get_current_user gains a cookie fallback
  (X-API-Key -> Bearer -> cookie). refresh/logout read the refresh cookie
  and set/clear cookies. New shared auth.js (authedFetch) sends cookies and
  silently refreshes on 401. Static pages drop the bogus user_id and call
  the correct /me endpoints.

Profile editing (read/edit/affirm):
- PATCH /discovery/profile/me edits the prose (Ikigai summaries, overlap
  narrative, goals); owner-scoped, partial update, 409 when locked. Edit
  mode in profile.html with Save/Cancel.

Also: bump default model to claude-sonnet-4-6, align ports to 8011
(OAuth redirect, CORS), add COOKIE_SECURE/POST_LOGIN_REDIRECT config, and
refresh the README to match the shipped behavior.

Tests: 33 passing (added cookie-auth, profile-edit, goal-extraction cases;
factored a shared app_client fixture into conftest.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:26:08 -05:00

228 lines
9.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""DiscoveryExtractor: turns five narrative responses into a structured
enneagram + Ikigai profile via the Anthropic API.
The extractor is responsible only for plumbing: building the message,
calling the model, parsing/validating the JSON it returns, and retrying
once if the first response is not valid JSON. The actual analysis lives
in the model behind ``SYSTEM_PROMPT``.
"""
import json
from typing import Any, Dict
from anthropic import AsyncAnthropic
DEFAULT_MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 2000
# Ordered mapping of response keys -> the human-facing prompt label, used to
# label each section of the concatenated user message.
PROMPT_LABELS = {
"alive": "The Alive Moment",
"friction": "The Friction Moment",
"pull": "The Natural Pull",
"recognition": "The Recognition Moment",
"future": "The Future Pull",
"goals_short": "Near-Term Goals (612 months)",
"goals_long": "Long-Term Goals (35 years)",
}
# Keys the model must return for a profile to be considered well-formed.
REQUIRED_KEYS = (
"triad",
"probable_type",
"wing",
"instinctual_variant",
"instinctual_stack",
"love_summary",
"strength_summary",
"mission_summary",
"vocation_summary",
"overlap_narrative",
"short_term_goals",
"long_term_goals",
"confidence",
)
REQUIRED_CONFIDENCE_KEYS = ("triad", "type", "variant", "ikigai")
SYSTEM_PROMPT = """You are a skilled personality analyst trained in the Enneagram system and the Ikigai framework.
You will receive five narrative responses from a person answering open-ended reflection prompts.
Your job is to extract a structured self-discovery profile from their stories.
ENNEAGRAM EXTRACTION RULES:
- The nine types cluster into three triads based on emotional center:
- Gut (instinctive): Types 8, 9, 1 — driven by anger, focused on control, body-based decisions
- Heart (feeling): Types 2, 3, 4 — driven by shame, focused on image and connection
- Head (thinking): Types 5, 6, 7 — driven by fear, focused on safety and understanding
- The Friction Moment response reveals the triad most clearly — gut types act against injustice, heart types feel exposed or unseen, head types analyze and strategize
- The Alive Moment and Recognition Moment reveal the type's core need
- The Natural Pull reveals instinctual variant: sp (self-preservation) = tasks/systems/stability, so (social) = groups/community/belonging, sx (sexual/one-to-one) = intensity/connection/depth
- The Future Pull reveals the type's idealized self and Ikigai vocation
IKIGAI EXTRACTION RULES:
- Love: what activities, topics, or experiences appear across responses with energy and enthusiasm
- Strength: what the person describes doing well or being recognized for
- Mission: what problem or need in the world their stories orbit around
- Vocation: where their strength and the world's need intersect with economic potential
GOAL ARTICULATION RULES:
- The last two responses are the person's own near-term (6-12 month) and long-term (3-5 year) goals.
- You are a MIRROR, not a compass. Articulate the goals THEY stated — clarify and sharpen their own words, connecting each goal to the Ikigai and enneagram pattern you found. Never invent goals, never prescribe a direction, never substitute your judgment for theirs.
- If a goal response is vague, reflect back the direction you can hear in it and note in extraction_notes that it is still forming — do not fill the gap with goals of your own.
- If a goal response is empty, return an empty string for that field. Do not fabricate.
- Write each goal summary directly to the person in second person (you/your), 2-4 sentences.
CONFIDENCE RULES:
- high: strong consistent signal across 2+ responses
- medium: signal present but only in one response or partially contradicted
- low: weak or absent signal — do not guess, flag it
OUTPUT FORMAT:
Respond ONLY with valid JSON. No preamble, no explanation, no markdown fences.
{
"triad": "gut | heart | head",
"probable_type": 1-9,
"wing": 1-9 (must be adjacent to probable_type),
"instinctual_variant": "sp | so | sx",
"instinctual_stack": "e.g. sp/so/sx",
"love_summary": "2-3 sentence summary of what they love",
"strength_summary": "2-3 sentence summary of what they are good at",
"mission_summary": "2-3 sentence summary of what the world needs from them",
"vocation_summary": "2-3 sentence summary of what they can be paid for",
"overlap_narrative": "One paragraph (4-6 sentences) describing where their four Ikigai circles converge and how their enneagram type shapes that intersection. Write directly to the person in second person (you/your). Do not mention enneagram type numbers — describe the pattern in plain language.",
"short_term_goals": "2-4 sentences articulating the person's OWN stated near-term (6-12 month) goals, clarified and connected to their pattern. Empty string if they gave no goal.",
"long_term_goals": "2-4 sentences articulating the person's OWN stated long-term (3-5 year) goals, clarified and connected to their pattern. Empty string if they gave no goal.",
"confidence": {
"triad": "high | medium | low",
"type": "high | medium | low",
"variant": "high | medium | low",
"ikigai": "high | medium | low"
},
"extraction_notes": "Optional: flag anything ambiguous, contradictory, or uncertain that the user should know"
}"""
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 DiscoveryExtractionError(Exception):
"""Raised when extraction fails (API error or unparseable output)."""
class DiscoveryExtractor:
"""Extracts a self-discovery profile from narrative responses."""
def __init__(self, api_key: str, model: str = DEFAULT_MODEL):
if not api_key:
raise DiscoveryExtractionError(
"ANTHROPIC_API_KEY is not set; cannot run extraction."
)
self.model = model
self.client = AsyncAnthropic(api_key=api_key)
def _build_user_message(self, responses: Dict[str, str]) -> str:
"""Concatenate the five responses, each under its prompt heading."""
sections = []
for key, label in PROMPT_LABELS.items():
text = (responses.get(key) or "").strip()
sections.append(f"## {label}\n{text if text else '(no response)'}")
return "\n\n".join(sections)
async def _call_model(self, user_message: str) -> str:
"""Make a single Anthropic API call and return the raw text."""
response = await self.client.messages.create(
model=self.model,
max_tokens=MAX_TOKENS,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_message}],
)
return response.content[0].text
async def extract(self, responses: Dict[str, str]) -> Dict[str, Any]:
"""Run extraction. Retries once if the first output is not valid JSON.
Args:
responses: dict with keys alive, friction, pull, recognition, future.
Returns:
Parsed profile dict matching the system-prompt schema.
Raises:
DiscoveryExtractionError: on API failure or repeated parse failure.
"""
user_message = self._build_user_message(responses)
try:
raw = await self._call_model(user_message)
except Exception as exc: # noqa: BLE001 - surface any SDK/transport error
raise DiscoveryExtractionError(
f"Anthropic API call failed: {exc}"
) from exc
try:
return self._parse(raw)
except (json.JSONDecodeError, ValueError):
# One retry with an explicit JSON-only reminder appended.
retry_message = f"{user_message}\n\n{RETRY_REMINDER}"
try:
raw_retry = await self._call_model(retry_message)
except Exception as exc: # noqa: BLE001
raise DiscoveryExtractionError(
f"Anthropic API call failed on retry: {exc}"
) from exc
try:
return self._parse(raw_retry)
except (json.JSONDecodeError, ValueError) as exc:
raise DiscoveryExtractionError(
f"Model did not return valid JSON after retry: {exc}"
) from exc
@staticmethod
def _strip_fences(text: str) -> str:
"""Remove a leading/trailing markdown code fence if present."""
stripped = text.strip()
if stripped.startswith("```"):
# drop the opening fence line (``` or ```json)
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]:
"""Parse and validate the model's JSON output.
Raises json.JSONDecodeError if the text is not JSON, or ValueError if
required keys are missing — both of which trigger a retry upstream.
"""
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")
missing = [k for k in REQUIRED_KEYS if k not in data]
if missing:
raise ValueError(f"missing required keys: {', '.join(missing)}")
confidence = data.get("confidence")
if not isinstance(confidence, dict):
raise ValueError("confidence must be an object")
missing_conf = [
k for k in REQUIRED_CONFIDENCE_KEYS if k not in confidence
]
if missing_conf:
raise ValueError(
f"missing confidence keys: {', '.join(missing_conf)}"
)
return data