mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 07:10:37 +00:00
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:
+216
-1
@@ -17,8 +17,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app import schemas
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models import DiscoveryConversation, DiscoveryProfile, User
|
||||
from app.models import (
|
||||
DiscoveryConversation,
|
||||
DiscoveryProfile,
|
||||
ProfileRevision,
|
||||
ReflectionMessage,
|
||||
User,
|
||||
)
|
||||
from app.services.extractor import DiscoveryExtractionError, DiscoveryExtractor
|
||||
from app.services.reflector import (
|
||||
EDITABLE_FIELDS,
|
||||
ReflectionCoach,
|
||||
ReflectionError,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/discovery", tags=["discovery"])
|
||||
|
||||
@@ -27,6 +38,33 @@ def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _profile_fields(profile: DiscoveryProfile) -> dict:
|
||||
"""The seven editable prose fields as a plain dict (for snapshots and the
|
||||
reflector's profile context)."""
|
||||
return {f: getattr(profile, f) for f in EDITABLE_FIELDS}
|
||||
|
||||
|
||||
def _record_revision(
|
||||
db: AsyncSession,
|
||||
profile: DiscoveryProfile,
|
||||
source: str,
|
||||
note: str | None = None,
|
||||
) -> None:
|
||||
"""Snapshot the profile's editable prose into profile_revision. Caller
|
||||
commits."""
|
||||
db.add(
|
||||
ProfileRevision(
|
||||
id=str(uuid.uuid4()),
|
||||
profile_id=profile.id,
|
||||
user_id=profile.user_id,
|
||||
source=source,
|
||||
fields_json=json.dumps(_profile_fields(profile)),
|
||||
note=note,
|
||||
created_at=_now(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _to_profile_response(
|
||||
profile: DiscoveryProfile, extraction_notes: str | None = None
|
||||
) -> schemas.ProfileResponse:
|
||||
@@ -182,6 +220,7 @@ async def complete_conversation(
|
||||
)
|
||||
conversation.completed_at = _now()
|
||||
db.add(profile)
|
||||
_record_revision(db, profile, source="extraction")
|
||||
await db.commit()
|
||||
|
||||
return _to_profile_response(
|
||||
@@ -223,6 +262,7 @@ async def update_my_profile(
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
for field, value in updates.items():
|
||||
setattr(profile, field, value)
|
||||
_record_revision(db, profile, source="manual_edit", note="manual edit")
|
||||
await db.commit()
|
||||
await db.refresh(profile)
|
||||
|
||||
@@ -244,6 +284,181 @@ async def confirm_my_profile(
|
||||
return schemas.ConfirmResponse(status="locked")
|
||||
|
||||
|
||||
# -- Phase 2: AI coach reflection loop ---------------------------------------
|
||||
|
||||
|
||||
async def _reflection_history(
|
||||
db: AsyncSession, profile_id: str
|
||||
) -> list[ReflectionMessage]:
|
||||
stmt = (
|
||||
select(ReflectionMessage)
|
||||
.where(ReflectionMessage.profile_id == profile_id)
|
||||
.order_by(ReflectionMessage.sequence)
|
||||
)
|
||||
return list((await db.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
def _msg_out(m: ReflectionMessage) -> schemas.ReflectionMessageOut:
|
||||
return schemas.ReflectionMessageOut(
|
||||
role=m.role,
|
||||
content=m.content,
|
||||
sequence=m.sequence,
|
||||
created_at=m.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/profile/me/reflection", response_model=schemas.ReflectionThreadResponse
|
||||
)
|
||||
async def get_reflection(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""The reflection dialogue so far for the user's latest profile."""
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||
history = await _reflection_history(db, profile.id)
|
||||
return schemas.ReflectionThreadResponse(
|
||||
messages=[_msg_out(m) for m in history],
|
||||
profile=_to_profile_response(profile),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/profile/me/reflect", response_model=schemas.ReflectTurnResponse
|
||||
)
|
||||
async def reflect_on_profile(
|
||||
payload: schemas.ReflectRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Advance the AI-coach reflection loop by one turn.
|
||||
|
||||
An empty message starts the loop (the coach's opening reflection); a
|
||||
non-empty message is recorded as the person's turn before the coach
|
||||
replies. When the person's input implies a correction, the coach proposes
|
||||
revisions which are applied to the profile (mirror, not compass) and
|
||||
snapshotted. Affirming is the separate ``/confirm`` lock.
|
||||
"""
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||
if profile.locked:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Profile is affirmed and locked; reflection is closed.",
|
||||
)
|
||||
|
||||
history = await _reflection_history(db, profile.id)
|
||||
next_seq = (history[-1].sequence + 1) if history else 0
|
||||
|
||||
person_text = payload.message.strip()
|
||||
if person_text:
|
||||
db.add(
|
||||
ReflectionMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
profile_id=profile.id,
|
||||
user_id=profile.user_id,
|
||||
role="person",
|
||||
content=person_text,
|
||||
sequence=next_seq,
|
||||
created_at=_now(),
|
||||
)
|
||||
)
|
||||
next_seq += 1
|
||||
elif history:
|
||||
# No new message and the loop has already opened — nothing to do.
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Provide a message to continue reflecting."
|
||||
)
|
||||
|
||||
coach_history = [
|
||||
{"role": m.role, "content": m.content} for m in history
|
||||
]
|
||||
if person_text:
|
||||
coach_history.append({"role": "person", "content": person_text})
|
||||
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||
try:
|
||||
coach = ReflectionCoach(api_key=api_key, model=model)
|
||||
result = await coach.reflect(_profile_fields(profile) | {"triad": profile.triad}, coach_history)
|
||||
except ReflectionError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
revised = False
|
||||
revisions = result.get("revisions")
|
||||
if revisions:
|
||||
for field, value in revisions.items():
|
||||
if field in EDITABLE_FIELDS:
|
||||
setattr(profile, field, value)
|
||||
revised = True
|
||||
_record_revision(
|
||||
db,
|
||||
profile,
|
||||
source="reflection",
|
||||
note=result.get("revision_note") or "reflection revision",
|
||||
)
|
||||
|
||||
coach_msg = ReflectionMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
profile_id=profile.id,
|
||||
user_id=profile.user_id,
|
||||
role="coach",
|
||||
content=result["message"],
|
||||
sequence=next_seq,
|
||||
created_at=_now(),
|
||||
)
|
||||
db.add(coach_msg)
|
||||
await db.commit()
|
||||
await db.refresh(profile)
|
||||
await db.refresh(coach_msg)
|
||||
|
||||
return schemas.ReflectTurnResponse(
|
||||
message=_msg_out(coach_msg),
|
||||
profile=_to_profile_response(profile),
|
||||
revised=revised,
|
||||
revision_note=result.get("revision_note") if revised else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/profile/me/revisions",
|
||||
response_model=list[schemas.ProfileRevisionOut],
|
||||
)
|
||||
async def get_profile_revisions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""The profile's edit/iteration history, newest first."""
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||
stmt = (
|
||||
select(ProfileRevision)
|
||||
.where(ProfileRevision.profile_id == profile.id)
|
||||
.order_by(ProfileRevision.created_at.desc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
out = []
|
||||
for r in rows:
|
||||
try:
|
||||
fields = json.loads(r.fields_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
fields = {}
|
||||
out.append(
|
||||
schemas.ProfileRevisionOut(
|
||||
id=r.id,
|
||||
source=r.source,
|
||||
fields=fields,
|
||||
note=r.note,
|
||||
created_at=r.created_at,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get(
|
||||
"/conversation/{conversation_id}",
|
||||
response_model=schemas.ConversationResponse,
|
||||
|
||||
Reference in New Issue
Block a user