Files
impactflow_discovery/app/static/profile.html
T
Joel Salmon b4d8d17aed 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>
2026-06-16 20:58:45 -05:00

273 lines
9.0 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ImpactFlow — Your Profile</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 · Your Direction</div>
<div id="content">
<p>Loading your profile…</p>
</div>
</div>
<script>
// Plain-language description of each enneagram triad. We never show the
// type number on this page — only the pattern.
const TRIAD_INFO = {
gut: {
label: "You lead with instinct and will",
text:
"You move through the world from your gut. You sense what's right before you can fully explain it, and you're driven to act, to protect what matters, and to set things right. Autonomy and integrity are non-negotiable for you, and you'd rather meet a problem head-on than wait for permission.",
},
heart: {
label: "You lead with feeling and connection",
text:
"You navigate through emotion and relationship. You're finely attuned to how things land — for you and for the people around you — and you care deeply about being genuinely seen for who you are. Your energy comes alive in connection, recognition, and making others feel that they matter.",
},
head: {
label: "You lead with thought and perception",
text:
"You meet the world through your mind. You like to understand before you commit — gathering information, mapping possibilities, and anticipating what's ahead so you can feel prepared and secure. Your gift is seeing patterns and thinking your way toward clarity others miss.",
},
};
const IKIGAI = [
{ key: "love_summary", title: "What you love", icon: "♥" },
{ key: "strength_summary", title: "What you're good at", icon: "★" },
{ key: "mission_summary", title: "What the world needs", icon: "◆" },
{ key: "vocation_summary", title: "What you can be paid for", icon: "$" },
];
function confClass(level) {
if (level === "high") return "high";
if (level === "medium") return "medium";
return "low";
}
function dot(level) {
const c = confClass(level);
const title = `Confidence: ${level || "low"}`;
return `<span class="dot ${c}" title="${title}"></span>`;
}
function escapeHtml(s) {
if (s == null) return "";
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// The editable prose. Order/labels mirror the read view. The AI's
// structural read (triad/type/wing/variant) is shown but not edited here.
const GOAL_FIELDS = [
{ key: "short_term_goals", title: "Next 612 months" },
{ key: "long_term_goals", title: "Next 35 years" },
];
const content = document.getElementById("content");
let currentProfile = null;
async function load() {
try {
const res = await authedFetch("/discovery/profile/me");
if (!res.ok) throw new Error("Profile not found");
currentProfile = await res.json();
} catch (err) {
content.innerHTML = `<div class="error-box">Could not load your profile: ${escapeHtml(
err.message
)}</div>`;
return;
}
renderRead();
}
function renderRead() {
const profile = currentProfile;
const conf = profile.confidence || {};
const triad = TRIAD_INFO[profile.triad] || TRIAD_INFO.gut;
const ikigaiCards = IKIGAI.map(
(item) => `
<div class="card">
<h3>${dot(conf.ikigai)} ${item.icon} ${item.title}</h3>
<p>${escapeHtml(profile[item.key]) || "—"}</p>
</div>`
).join("");
const locked = profile.locked;
// Goals are the person's own articulated direction. Only show the
// section if at least one horizon has content.
const goals = GOAL_FIELDS.filter(
(g) => profile[g.key] && profile[g.key].trim()
);
const goalsBlock = goals.length
? `
<p class="section-label">Where you're headed</p>
<div class="ikigai-grid">${goals
.map(
(g) => `
<div class="card">
<h3>→ ${g.title}</h3>
<p>${escapeHtml(profile[g.key])}</p>
</div>`
)
.join("")}</div>
`
: "";
// Unlocked profiles can be edited and then affirmed; locked profiles are
// final and show neither control as actionable.
const actions = locked
? `<div class="confirm-row">
<button class="btn-primary confirmed" disabled>Profile confirmed ✓</button>
</div>`
: `<div class="nav">
<button class="btn-ghost" id="editBtn">Edit my words</button>
<button class="btn-primary" id="confirmBtn">This is me</button>
</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(
profile.overlap_narrative
)}</div>
<p class="section-label">Where your four circles meet</p>
<div class="ikigai-grid">${ikigaiCards}</div>
<div class="triad-block">
<h2>${dot(conf.triad)} ${triad.label}</h2>
<p>${triad.text}</p>
</div>
${goalsBlock}
${actions}
`;
if (locked) return;
document
.getElementById("editBtn")
.addEventListener("click", renderEdit);
const btn = document.getElementById("confirmBtn");
btn.addEventListener("click", async () => {
btn.disabled = true;
try {
const res = await authedFetch("/discovery/profile/me/confirm", {
method: "PUT",
});
if (!res.ok) throw new Error("confirm failed");
currentProfile.locked = true;
renderRead();
} catch (err) {
btn.disabled = false;
btn.textContent = "Try again";
}
});
}
function editField(key, label, value) {
return `
<div class="edit-field">
<label for="edit_${key}">${label}</label>
<textarea class="edit" id="edit_${key}">${escapeHtml(
value
)}</textarea>
</div>`;
}
function renderEdit() {
const profile = currentProfile;
const ikigaiFields = IKIGAI.map((item) =>
editField(item.key, `${item.icon} ${item.title}`, profile[item.key])
).join("");
const goalFields = GOAL_FIELDS.map((g) =>
editField(g.key, `${g.title}`, profile[g.key])
).join("");
content.innerHTML = `
<p class="section-label">Edit your words</p>
<p class="edit-help">These are your words to own. Revise anything that
doesn't sound like you, then save.</p>
${editField(
"overlap_narrative",
"Where it all comes together",
profile.overlap_narrative
)}
<div class="ikigai-grid">${ikigaiFields}</div>
<div class="ikigai-grid">${goalFields}</div>
<div id="editError"></div>
<div class="nav">
<button class="btn-ghost" id="cancelBtn">Cancel</button>
<button class="btn-primary" id="saveBtn">Save changes</button>
</div>
`;
document.getElementById("cancelBtn").addEventListener("click", renderRead);
document.getElementById("saveBtn").addEventListener("click", saveEdits);
}
const EDITABLE_KEYS = [
"overlap_narrative",
...IKIGAI.map((i) => i.key),
...GOAL_FIELDS.map((g) => g.key),
];
async function saveEdits() {
const saveBtn = document.getElementById("saveBtn");
saveBtn.disabled = true;
const payload = {};
EDITABLE_KEYS.forEach((key) => {
payload[key] = document.getElementById(`edit_${key}`).value;
});
try {
const res = await authedFetch("/discovery/profile/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const detail = await res
.json()
.catch(() => ({ detail: "Could not save changes" }));
throw new Error(detail.detail || "Could not save changes");
}
currentProfile = await res.json();
renderRead();
} catch (err) {
saveBtn.disabled = false;
document.getElementById(
"editError"
).innerHTML = `<div class="error-box">${escapeHtml(
err.message
)}</div>`;
}
}
load();
</script>
</body>
</html>