Phase 2: AI coach reflection loop

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>
This commit is contained in:
Joel Salmon
2026-06-16 20:58:45 -05:00
parent 33674f92f4
commit b4d8d17aed
12 changed files with 1353 additions and 6 deletions
+42
View File
@@ -146,3 +146,45 @@ class DiscoveryProfile(Base):
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)