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)
+216 -1
View File
@@ -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,
+37
View File
@@ -71,6 +71,43 @@ class ConfirmResponse(BaseModel):
status: str
# -- Phase 2: AI coach reflection loop ---------------------------------------
class ReflectRequest(BaseModel):
# Empty/omitted starts the loop (the coach's opening reflection).
message: str = ""
class ReflectionMessageOut(BaseModel):
role: str # "coach" or "person"
content: str
sequence: int
created_at: datetime
class ReflectTurnResponse(BaseModel):
"""One coach turn, plus the (possibly revised) profile."""
message: ReflectionMessageOut
profile: ProfileResponse
revised: bool = False
revision_note: Optional[str] = None
class ReflectionThreadResponse(BaseModel):
messages: list[ReflectionMessageOut]
profile: ProfileResponse
class ProfileRevisionOut(BaseModel):
id: str
source: str # "extraction" | "reflection" | "manual_edit"
fields: dict
note: Optional[str] = None
created_at: datetime
class ConversationResponse(BaseModel):
id: str
user_id: str
+231
View File
@@ -0,0 +1,231 @@
"""ReflectionCoach: the Phase 2 AI-coach reflection loop.
The coach is a MIRROR, never a compass. It reflects the person's own profile
back to them in plain language, listens to their reactions, and — only when
they explicitly correct or add something — proposes revised text for the
affected prose fields using the person's own direction. It never prescribes
goals or invents direction.
Like DiscoveryExtractor, this class is responsible only for plumbing: building
the messages, calling the model, and parsing/validating the JSON it returns.
"""
import json
from typing import Any, Dict, List, Optional
from anthropic import AsyncAnthropic
DEFAULT_MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 1200
# The only profile fields the coach may propose changes to. The structural
# Enneagram read (triad/type/wing/variant) is never editable via reflection.
EDITABLE_FIELDS = (
"love_summary",
"strength_summary",
"mission_summary",
"vocation_summary",
"overlap_narrative",
"short_term_goals",
"long_term_goals",
)
# Maps a stored ReflectionMessage.role to an Anthropic message role.
ROLE_TO_API = {"coach": "assistant", "person": "user"}
# Sent as the first (user) turn on every call so the conversation always
# starts with a user message, and to frame the coach's task. Not stored.
PRIMER = (
"I have just completed my self-discovery profile (it is in your "
"instructions). Reflect it back to me so I can see whether it fits."
)
SYSTEM_PROMPT = """You are an AI coach inside a self-discovery tool. You are a MIRROR, never a compass.
THE PERSON'S CURRENT PROFILE:
{profile}
YOUR ROLE:
- Reflect this profile back in warm, plain language and ask whether it lands: in spirit, "Here is what I am hearing — do you recognize yourself? What would you add, change, or disagree with?"
- Listen to how the person reacts. When they correct, add to, or push back on something, reflect their own words back to them — clarify and sharpen what THEY mean.
- Ask gentle, open questions that help the person articulate their own sense of direction.
ABSOLUTE RULES (mirror, not compass):
- NEVER prescribe goals, paths, careers, or what they "should" do.
- NEVER invent a direction the person did not express. If you are unsure what they mean, ask rather than assume.
- Do NOT mention Enneagram type numbers; describe patterns in plain language.
- Keep replies short and conversational — 2 to 5 sentences, at most one question.
PROPOSING REVISIONS:
- Only when the person explicitly corrects, adds to, or asks to change part of their profile, propose updated text for the affected field(s), written in their own direction. Editable fields: love_summary, strength_summary, mission_summary, vocation_summary, overlap_narrative, short_term_goals, long_term_goals.
- Otherwise set "revisions" to null. Never change their Enneagram type, triad, or instinctual variant. Never revise just because you could — only to capture what the person said.
OUTPUT FORMAT:
Respond ONLY with valid JSON. No preamble, no markdown fences.
{{
"message": "your reflective reply to the person, in second person (you/your), warm and plain",
"revisions": {{ "<field>": "<revised text in the person's own direction>" }} or null,
"revision_note": "a short phrase naming what changed, or null"
}}"""
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 ReflectionError(Exception):
"""Raised when a reflection turn fails (API error or unparseable output)."""
class ReflectionCoach:
"""Generates one coach turn given the profile and the dialogue so far."""
def __init__(self, api_key: str, model: str = DEFAULT_MODEL):
if not api_key:
raise ReflectionError(
"ANTHROPIC_API_KEY is not set; cannot run reflection."
)
self.model = model
self.client = AsyncAnthropic(api_key=api_key)
@staticmethod
def profile_context(profile: Dict[str, Any]) -> str:
"""Render the current profile as plain text for the system prompt."""
triad = {
"gut": "leads with instinct and will (gut-centered)",
"heart": "leads with feeling and connection (heart-centered)",
"head": "leads with thought and perception (head-centered)",
}.get(profile.get("triad") or "", "centered pattern not yet clear")
lines = [
f"- Core pattern: {triad}",
f"- What you love: {profile.get('love_summary') or '(none)'}",
f"- What you are good at: {profile.get('strength_summary') or '(none)'}",
f"- What the world needs from you: {profile.get('mission_summary') or '(none)'}",
f"- What you can be paid for: {profile.get('vocation_summary') or '(none)'}",
f"- Where it converges: {profile.get('overlap_narrative') or '(none)'}",
f"- Near-term goals (6-12mo): {profile.get('short_term_goals') or '(none stated)'}",
f"- Long-term goals (3-5yr): {profile.get('long_term_goals') or '(none stated)'}",
]
return "\n".join(lines)
def _build_messages(
self, history: List[Dict[str, str]]
) -> List[Dict[str, str]]:
"""Build the Anthropic messages array: a fixed user primer followed by
the stored turns mapped to user/assistant roles."""
messages = [{"role": "user", "content": PRIMER}]
for turn in history:
api_role = ROLE_TO_API.get(turn["role"])
if api_role is None:
continue
messages.append({"role": api_role, "content": turn["content"]})
return messages
async def _call_model(
self, profile: Dict[str, Any], messages: List[Dict[str, str]]
) -> str:
response = await self.client.messages.create(
model=self.model,
max_tokens=MAX_TOKENS,
system=SYSTEM_PROMPT.format(
profile=self.profile_context(profile)
),
messages=messages,
)
return response.content[0].text
async def reflect(
self, profile: Dict[str, Any], history: List[Dict[str, str]]
) -> Dict[str, Any]:
"""Produce one coach turn.
Args:
profile: the current profile dict (at least the editable fields and
triad).
history: prior turns as ``[{"role": "coach"|"person", "content": ...}]``
in order. Empty for the opening reflection. The last turn, if
any, should be the person's latest message.
Returns:
``{"message": str, "revisions": dict|None, "revision_note": str|None}``
with revisions filtered to the editable fields only.
Raises:
ReflectionError on API failure or repeated parse failure.
"""
messages = self._build_messages(history)
try:
raw = await self._call_model(profile, messages)
except Exception as exc: # noqa: BLE001 - surface any SDK/transport error
raise ReflectionError(f"Anthropic API call failed: {exc}") from exc
try:
return self._parse(raw)
except (json.JSONDecodeError, ValueError):
retry = messages + [
{"role": "assistant", "content": raw},
{"role": "user", "content": RETRY_REMINDER},
]
try:
raw_retry = await self._call_model(profile, retry)
except Exception as exc: # noqa: BLE001
raise ReflectionError(
f"Anthropic API call failed on retry: {exc}"
) from exc
try:
return self._parse(raw_retry)
except (json.JSONDecodeError, ValueError) as exc:
raise ReflectionError(
f"Model did not return valid JSON after retry: {exc}"
) from exc
@staticmethod
def _strip_fences(text: str) -> str:
stripped = (text or "").strip()
if stripped.startswith("```"):
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]:
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")
message = data.get("message")
if not isinstance(message, str) or not message.strip():
raise ValueError("missing or empty 'message'")
revisions = cls._clean_revisions(data.get("revisions"))
note = data.get("revision_note")
if not isinstance(note, str) or not note.strip():
note = None
return {
"message": message.strip(),
"revisions": revisions,
"revision_note": note,
}
@staticmethod
def _clean_revisions(revisions: Any) -> Optional[Dict[str, str]]:
"""Keep only editable string fields; drop anything else (e.g. an
attempt to change triad/type). Returns None if nothing valid remains."""
if not isinstance(revisions, dict):
return None
cleaned = {
k: v
for k, v in revisions.items()
if k in EDITABLE_FIELDS and isinstance(v, str) and v.strip()
}
return cleaned or None
+4 -1
View File
@@ -137,7 +137,10 @@
: `<div class="nav">
<button class="btn-ghost" id="editBtn">Edit my words</button>
<button class="btn-primary" id="confirmBtn">This is me</button>
</div>`;
</div>
<p class="back-link" style="text-align:center;margin-top:18px">
<a href="/static/reflect.html">Not quite right? Talk it through with your coach →</a>
</p>`;
content.innerHTML = `
<div class="profile-narrative">${escapeHtml(
+216
View File
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ImpactFlow — Reflect With Your Coach</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
<script src="/static/auth.js"></script>
</head>
<body>
<div class="wrap">
<div class="brand">ImpactFlow · Reflect With Your Coach</div>
<div id="content"><p>Loading…</p></div>
</div>
<script>
const content = document.getElementById("content");
let profile = null;
let locked = false;
let busy = false;
function escapeHtml(s) {
if (s == null) return "";
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// The coach mirrors the person's own direction; this panel shows the
// current profile prose so the person can watch it sharpen as they talk.
function profileSummary() {
if (!profile) return "";
const goals = [
["Near-term (612 months)", profile.short_term_goals],
["Long-term (35 years)", profile.long_term_goals],
].filter(([, v]) => v && v.trim());
const goalRows = goals
.map(([t, v]) => `<p><strong>${t}:</strong> ${escapeHtml(v)}</p>`)
.join("");
return `
<div class="triad-block" id="summary">
<h2>Your profile, in your words</h2>
<p>${escapeHtml(profile.overlap_narrative)}</p>
${goalRows}
</div>`;
}
function bubble(role, text) {
const who = role === "coach" ? "Coach" : "You";
return `<div class="bubble ${role}"><span class="who">${who}</span>${escapeHtml(
text
)}</div>`;
}
function render(messages) {
const thread = messages.map((m) => bubble(m.role, m.content)).join("");
content.innerHTML = `
${profileSummary()}
<p class="section-label">Reflection</p>
<div class="thread" id="thread">${thread}</div>
${
locked
? `<div class="confirm-row"><button class="btn-primary confirmed" disabled>Affirmed — this is you ✓</button></div>`
: `<div class="reflect-input">
<textarea id="msg" class="edit" placeholder="Tell your coach what fits, what doesn't, what you'd change…"></textarea>
<div class="nav">
<button class="btn-ghost" id="affirmBtn">This is me — affirm</button>
<button class="btn-primary" id="sendBtn">Send</button>
</div>
</div>`
}
<p class="back-link"><a href="/static/profile.html">← Back to your profile</a></p>
`;
scrollThread();
if (!locked) {
document.getElementById("sendBtn").addEventListener("click", send);
document.getElementById("affirmBtn").addEventListener("click", affirm);
const ta = document.getElementById("msg");
ta.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) send();
});
ta.focus();
}
}
function scrollThread() {
const t = document.getElementById("thread");
if (t) t.scrollTop = t.scrollHeight;
}
function appendBubble(role, text) {
const t = document.getElementById("thread");
if (t) {
t.insertAdjacentHTML("beforeend", bubble(role, text));
scrollThread();
}
}
let messages = [];
async function load() {
try {
const res = await authedFetch("/discovery/profile/me/reflection");
if (!res.ok) throw new Error("Could not load your reflection");
const data = await res.json();
profile = data.profile;
locked = !!profile.locked;
messages = data.messages || [];
} catch (err) {
content.innerHTML = `<div class="error-box">${escapeHtml(
err.message
)}</div>`;
return;
}
render(messages);
if (!locked && messages.length === 0) {
// Kick off the coach's opening reflection.
await turn("");
}
}
// One reflection turn. Empty text = opener.
async function turn(text) {
if (busy) return;
busy = true;
const sendBtn = document.getElementById("sendBtn");
if (sendBtn) sendBtn.disabled = true;
if (text) appendBubble("person", text);
appendBubble("coach", "…");
try {
const res = await authedFetch("/discovery/profile/me/reflect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({ detail: "Reflection failed" }));
throw new Error(d.detail || "Reflection failed");
}
const data = await res.json();
profile = data.profile; // may carry applied revisions
// Replace the "…" placeholder with the real coach reply.
const t = document.getElementById("thread");
t.lastElementChild.remove();
appendBubble("coach", data.message.content);
if (data.revised) {
refreshSummary();
appendNote(
data.revision_note
? `Updated your profile: ${data.revision_note}`
: "Updated your profile to match what you said."
);
}
} catch (err) {
const t = document.getElementById("thread");
if (t && t.lastElementChild) t.lastElementChild.remove();
appendNote(err.message, true);
} finally {
busy = false;
const b = document.getElementById("sendBtn");
if (b) b.disabled = false;
}
}
function refreshSummary() {
const old = document.getElementById("summary");
if (old) old.outerHTML = profileSummary();
}
function appendNote(text, isError) {
const t = document.getElementById("thread");
if (t)
t.insertAdjacentHTML(
"beforeend",
`<div class="note ${isError ? "err" : ""}">${escapeHtml(text)}</div>`
);
scrollThread();
}
async function send() {
const ta = document.getElementById("msg");
const text = ta.value.trim();
if (!text) return;
ta.value = "";
await turn(text);
}
async function affirm() {
const btn = document.getElementById("affirmBtn");
btn.disabled = true;
try {
const res = await authedFetch("/discovery/profile/me/confirm", {
method: "PUT",
});
if (!res.ok) throw new Error("Could not affirm");
locked = true;
render(messages.length ? messages : []);
// Re-render reads from the live thread, so just lock the controls.
location.reload();
} catch (err) {
btn.disabled = false;
appendNote(err.message, true);
}
}
load();
</script>
</body>
</html>
+80
View File
@@ -302,6 +302,86 @@ textarea.edit {
font-size: 0.98rem;
}
/* ---------- Reflection chat ---------- */
.thread {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 55vh;
overflow-y: auto;
padding: 4px 2px 8px;
margin-bottom: 22px;
}
.bubble {
max-width: 80%;
padding: 14px 18px;
border-radius: 16px;
font-size: 1rem;
line-height: 1.55;
white-space: pre-wrap;
}
.bubble .who {
display: block;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
margin-bottom: 5px;
opacity: 0.7;
}
.bubble.coach {
align-self: flex-start;
background: #fff;
border: 1px solid rgba(13, 27, 42, 0.1);
color: var(--navy-soft);
border-bottom-left-radius: 5px;
}
.bubble.person {
align-self: flex-end;
background: var(--navy);
color: var(--cream);
border-bottom-right-radius: 5px;
}
.bubble.person .who {
color: var(--gold);
opacity: 1;
}
.note {
align-self: center;
font-size: 0.85rem;
color: var(--gold);
font-style: italic;
}
.note.err {
color: var(--red);
}
.reflect-input textarea.edit {
min-height: 90px;
}
.back-link {
margin-top: 8px;
}
.back-link a {
color: var(--navy-soft);
text-decoration: none;
font-size: 0.92rem;
}
.back-link a:hover {
color: var(--gold);
}
.error-box {
background: #fdecea;
border: 1px solid var(--red);