mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:20:36 +00:00
b4d8d17aed
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>
217 lines
7.1 KiB
HTML
217 lines
7.1 KiB
HTML
<!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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
// 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 (6–12 months)", profile.short_term_goals],
|
||
["Long-term (3–5 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>
|