mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 07: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>
191 lines
7.7 KiB
Python
191 lines
7.7 KiB
Python
"""SQLAlchemy ORM models for the self-discovery module."""
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from app.database import Base
|
|
|
|
|
|
class User(Base):
|
|
"""A signed-in human (Google OAuth) or the synthetic admin record used
|
|
by the X-API-Key dual-auth path for the MCP server."""
|
|
|
|
__tablename__ = "users"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
display_name: Mapped[str] = mapped_column(String, nullable=False)
|
|
avatar_url: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
# Google's "sub" claim. Null only for the synthetic API-key admin user.
|
|
google_id: Mapped[Optional[str]] = mapped_column(
|
|
String, unique=True, nullable=True
|
|
)
|
|
role: Mapped[str] = mapped_column(String, nullable=False, default="user")
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
last_login_at: Mapped[Optional[datetime]] = mapped_column(
|
|
DateTime, nullable=True
|
|
)
|
|
|
|
|
|
class RefreshToken(Base):
|
|
"""One row per issued refresh token. token_hash stores a SHA-256 of the
|
|
raw token so a DB leak can't be replayed back at the auth endpoint."""
|
|
|
|
__tablename__ = "refresh_tokens"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String, ForeignKey("users.id"), nullable=False, index=True
|
|
)
|
|
token_hash: Mapped[str] = mapped_column(
|
|
String, unique=True, nullable=False
|
|
)
|
|
device: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
revoked_at: Mapped[Optional[datetime]] = mapped_column(
|
|
DateTime, nullable=True
|
|
)
|
|
|
|
|
|
class ActivityLog(Base):
|
|
"""Append-only audit trail. Pruned to 90 days on app startup."""
|
|
|
|
__tablename__ = "activity_log"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String, ForeignKey("users.id"), nullable=False, index=True
|
|
)
|
|
action: Mapped[str] = mapped_column(String, nullable=False)
|
|
resource: Mapped[str] = mapped_column(String, nullable=False)
|
|
resource_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
source: Mapped[str] = mapped_column(String, nullable=False, default="web")
|
|
ip_address: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime, nullable=False, index=True
|
|
)
|
|
|
|
|
|
class DiscoveryConversation(Base):
|
|
"""A single self-discovery conversation: the five narrative responses."""
|
|
|
|
__tablename__ = "discovery_conversation"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String, ForeignKey("users.id"), nullable=False, index=True
|
|
)
|
|
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
|
DateTime, nullable=True
|
|
)
|
|
|
|
prompt_alive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
prompt_friction: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
prompt_pull: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
prompt_recognition: Mapped[Optional[str]] = mapped_column(
|
|
Text, nullable=True
|
|
)
|
|
prompt_future: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
|
|
# Phase 1 goal-articulation prompts: the person's own near- and long-term
|
|
# goals, in their own words.
|
|
prompt_goals_short: Mapped[Optional[str]] = mapped_column(
|
|
Text, nullable=True
|
|
)
|
|
prompt_goals_long: Mapped[Optional[str]] = mapped_column(
|
|
Text, nullable=True
|
|
)
|
|
|
|
|
|
class DiscoveryProfile(Base):
|
|
"""The extracted enneagram + Ikigai profile for a conversation."""
|
|
|
|
__tablename__ = "discovery_profile"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String, ForeignKey("users.id"), nullable=False, index=True
|
|
)
|
|
conversation_id: Mapped[str] = mapped_column(
|
|
String, ForeignKey("discovery_conversation.id"), nullable=False
|
|
)
|
|
generated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
|
|
triad: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
probable_type: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
wing: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
instinctual_variant: Mapped[Optional[str]] = mapped_column(
|
|
String, nullable=True
|
|
)
|
|
instinctual_stack: Mapped[Optional[str]] = mapped_column(
|
|
String, nullable=True
|
|
)
|
|
|
|
love_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
strength_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
mission_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
vocation_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
overlap_narrative: Mapped[Optional[str]] = mapped_column(
|
|
Text, nullable=True
|
|
)
|
|
|
|
# AI-articulated goals: the person's own stated goals, clarified and
|
|
# connected to their Ikigai/enneagram pattern (mirror, never prescription).
|
|
short_term_goals: Mapped[Optional[str]] = mapped_column(
|
|
Text, nullable=True
|
|
)
|
|
long_term_goals: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
|
|
confidence_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
locked: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=False
|
|
)
|
|
|
|
|
|
class ReflectionMessage(Base):
|
|
"""One turn in the Phase 2 AI-coach reflection loop. The coach mirrors the
|
|
profile back; the person reacts; iterate until they affirm (lock)."""
|
|
|
|
__tablename__ = "reflection_message"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
profile_id: Mapped[str] = mapped_column(
|
|
String, ForeignKey("discovery_profile.id"), nullable=False, index=True
|
|
)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String, ForeignKey("users.id"), nullable=False, index=True
|
|
)
|
|
# "coach" (AI mirror) or "person" (the human).
|
|
role: Mapped[str] = mapped_column(String, nullable=False)
|
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
# Monotonic order within a profile's reflection thread.
|
|
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
|
|
|
|
class ProfileRevision(Base):
|
|
"""A snapshot of a profile's editable prose at a point in time, so edits
|
|
and iterations are captured rather than overwritten. source is one of
|
|
'extraction' (initial), 'reflection' (AI-coach loop), 'manual_edit'."""
|
|
|
|
__tablename__ = "profile_revision"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
profile_id: Mapped[str] = mapped_column(
|
|
String, ForeignKey("discovery_profile.id"), nullable=False, index=True
|
|
)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String, ForeignKey("users.id"), nullable=False, index=True
|
|
)
|
|
source: Mapped[str] = mapped_column(String, nullable=False)
|
|
# JSON snapshot of the seven editable prose fields at this revision.
|
|
fields_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|