Files
impactflow_discovery/app/models.py
T
Joel Salmon 50453901b3 Phase 3: coaching preferences + weekly check-in engine
Add coaching preferences (auto-derived from the profile, user-overridable) and
a periodic check-in engine that quotes the person's own words and asks whether
their direction still feels valid — mirror, not compass.

- Preferences are deterministic: a documented triad mapping (gut → direct/
  higher-friction, heart → warm/drift-sensitive, head → reflective/question-led)
  produces defaults for the six fields (coaching_frequency, coaching_style,
  misalignment_threshold, friction_tolerance, prefer_questions_over_directives,
  time_of_day_preference). PUT overrides; regenerate re-derives.
- CheckinCoach (app/services/coaching.py): Anthropic-backed; writes a check-in
  that quotes the person's goals back and asks if the direction still holds.
- Endpoints (app/routers/coaching.py): GET/PUT/regenerate preferences;
  GET/POST checkins; respond (records still_valid); admin POST /run is the
  weekly batch (due = cadence elapsed + locked profile), intended for a cron.
- Models + migration 005: coaching_preferences (per user) and coaching_checkin.
- Frontend: coaching.html (preferences form + check-in feed); linked from
  profile.html.

Tests: 68 passing (added deterministic-preference unit tests and coaching
endpoint/batch tests; run in-container). README updated for Phase 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:10:54 -05:00

249 lines
10 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)
class CoachingPreferences(Base):
"""Phase 3: how this person wants to be coached. Auto-generated from their
Enneagram/Ikigai profile, then overridable. One row per user."""
__tablename__ = "coaching_preferences"
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id"), unique=True, nullable=False, index=True
)
# The profile the defaults were derived from (provenance).
profile_id: Mapped[Optional[str]] = mapped_column(
String, ForeignKey("discovery_profile.id"), nullable=True
)
coaching_frequency: Mapped[str] = mapped_column(String, nullable=False)
coaching_style: Mapped[str] = mapped_column(String, nullable=False)
misalignment_threshold: Mapped[str] = mapped_column(String, nullable=False)
friction_tolerance: Mapped[str] = mapped_column(String, nullable=False)
prefer_questions_over_directives: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
time_of_day_preference: Mapped[str] = mapped_column(String, nullable=False)
# True while still using auto-derived defaults; False once the user edits.
auto_generated: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class CoachingCheckin(Base):
"""Phase 3: a periodic coaching check-in. The body quotes the person's own
words and asks whether their stated direction still feels valid (mirror,
not compass). The person's answer is recorded in still_valid."""
__tablename__ = "coaching_checkin"
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id"), nullable=False, index=True
)
profile_id: Mapped[str] = mapped_column(
String, ForeignKey("discovery_profile.id"), nullable=False
)
body: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, index=True
)
# The person's self-assessment: is their direction still valid?
still_valid: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
response_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
acknowledged_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True
)