mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 07:10:37 +00:00
Initial commit: ImpactFlow Discovery + Google OAuth auth layer
Discovery service (pre-existing): FastAPI + async SQLAlchemy + Alembic +
SQLite + Anthropic, with a five-prompt static UI that produces an Enneagram
+ Ikigai profile.
Auth implementation (this change set) follows
Impact_Flow_Auth_Plan_OAuth.html, adapted to the discovery_conversation /
discovery_profile schema:
- app/auth.py: Google OAuth registration, JWT issue/decode, dual-auth
dependency (Bearer JWT or X-API-Key), refresh-token hashing, domain
allow-list, synthetic api-key-admin user
- app/tracking.py: ActivityTrackingMiddleware + log_activity helper;
tags machine-to-machine calls source=mcp
- app/routers/auth.py: /api/auth/{login,callback,refresh,logout},
/api/me, /api/me/{stats,sessions,sessions/{id}}
- app/routers/activity.py: /api/activity, /api/activity/summary,
/api/admin/activity, plus prune_old_activity (90-day retention)
- app/routers/discovery.py: every route now user-scoped via the auth
dependency; /discovery/profile/{user_id} -> /discovery/profile/me
- alembic/versions/002_add_auth.py: users, refresh_tokens, activity_log
- tests/test_auth.py: 8 tests covering 401 paths, X-API-Key admin
resolution, JWT round-trip, admin gating, domain allow-list
- README.md: Authentication section, expanded env-var table, updated
data-model and API-reference tables
- .env.example: new GOOGLE_*, JWT_*, IMPACTFLOW_API_KEY, CORS_*,
ALLOWED_EMAIL_DOMAINS placeholders
- .gitignore: also exclude data/*.log
Tests: 19/19 pass (11 pre-existing + 8 new). smoke_test.py exercises the
full discovery flow under X-API-Key plus 401 paths, OAuth login redirect,
activity logging, and /api/me/stats.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
"""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-5"
|
||||
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",
|
||||
}
|
||||
|
||||
# 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",
|
||||
"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
|
||||
|
||||
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.",
|
||||
"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
|
||||
Reference in New Issue
Block a user