Persist discovery answers to CSV for reprocessing and recovery

Save each discovery conversation's prompts and answers to durable CSV
files (per-conversation + append-only master log) on both save and
completion, so answers survive an extraction error, can be re-fed to the
AI, and can be reviewed/resumed by the user.

- app/services/answer_store.py: canonical prompt list + atomic CSV writes,
  master append, and read-back helpers (DB stays system of record; CSV
  failures are logged, never fatal).
- discovery router: write CSV on /respond and /complete; new endpoints
  GET /answers, GET /answers.csv, POST /reprocess (shared extraction
  helper; locked profiles return 409).
- discovery.html: prefill/resume from saved answers after an error and a
  "Re-run analysis" button wired to /reprocess.
- scripts/reprocess_csv.py: offline CLI to re-run extraction from a CSV
  (print or --write-db).
- QUESTIONS_DIR / QUESTIONS_MASTER_CSV config, .gitignore, README, tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg6XWUwprmP5QCL18HxssY
This commit is contained in:
Claude
2026-06-19 01:06:40 +00:00
parent c1daef6411
commit 866bd60225
10 changed files with 988 additions and 51 deletions
+102 -5
View File
@@ -133,11 +133,62 @@
}
});
const STORAGE_KEY = "discovery_conversation_id";
async function startConversation() {
const res = await authedFetch("/discovery/start", { method: "POST" });
if (!res.ok) throw new Error("Could not start conversation");
const data = await res.json();
conversationId = data.conversation_id;
// Remember the in-progress conversation so a reload after an error can
// resume from the original answers rather than losing them.
try {
localStorage.setItem(STORAGE_KEY, conversationId);
} catch (e) {
/* storage unavailable — non-fatal */
}
}
async function resumeOrStart() {
let savedId = null;
try {
savedId = localStorage.getItem(STORAGE_KEY);
} catch (e) {
/* storage unavailable */
}
if (savedId) {
try {
const res = await authedFetch(`/discovery/${savedId}/answers`);
if (res.ok) {
const data = await res.json();
const hasText = (data.answers || []).some((a) => a.answer.trim());
if (!data.completed_at && hasText) {
// Resume: restore the saved answers into the flow.
conversationId = savedId;
const byKey = {};
data.answers.forEach((a) => {
byKey[a.prompt_key] = a.answer;
});
PROMPTS.forEach((p, i) => {
// CSV/key uses the short key; PROMPTS uses prompt_* column names.
const shortKey = p.key.replace(/^prompt_/, "");
answers[i] = byKey[shortKey] || "";
});
render();
return;
}
}
} catch (e) {
/* fall through to a fresh start */
}
}
// No resumable conversation — start a fresh one.
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
/* ignore */
}
await startConversation();
}
async function submit() {
@@ -173,24 +224,70 @@
throw new Error(detail.detail || "Extraction failed");
}
// Success: the conversation is complete, drop the resume marker.
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
/* ignore */
}
window.location.href = "/static/profile.html";
} catch (err) {
el.loading.classList.remove("active");
el.flow.style.display = "block";
el.flow.innerHTML =
`<div class="error-box"><strong>Something went wrong.</strong><br/>` +
`${err.message}<br/><br/>Your answers are still here — ` +
`please try submitting again.</div>` +
`${err.message}<br/><br/>Your answers are saved — you can ` +
`re-run the analysis or reload to keep editing.</div>` +
`<div class="nav">` +
`<button class="btn-ghost" onclick="location.reload()">Reload</button>` +
`<button class="btn-primary" id="rerunBtn">Re-run analysis</button>` +
`</div>`;
const rerun = document.getElementById("rerunBtn");
if (rerun) rerun.addEventListener("click", reprocess);
}
}
async function reprocess() {
if (!conversationId) {
location.reload();
return;
}
el.flow.style.display = "none";
el.loading.classList.add("active");
try {
const res = await authedFetch(
`/discovery/${conversationId}/reprocess`,
{ method: "POST" }
);
if (!res.ok) {
const detail = await res
.json()
.catch(() => ({ detail: "Re-run failed" }));
throw new Error(detail.detail || "Re-run failed");
}
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
/* ignore */
}
window.location.href = "/static/profile.html";
} catch (err) {
el.loading.classList.remove("active");
el.flow.style.display = "block";
el.flow.innerHTML =
`<div class="error-box"><strong>Re-run failed.</strong><br/>` +
`${err.message}<br/><br/>Your answers are still saved.</div>` +
`<div class="nav"><span></span>` +
`<button class="btn-primary" onclick="location.reload()">Reload</button></div>`;
}
}
// Kick off a conversation as soon as the page loads so the id is ready.
startConversation().catch(() => {
// Resume an in-progress conversation if one exists, otherwise start a
// fresh one so the id is ready by submit time.
render();
resumeOrStart().catch(() => {
/* will retry on submit */
});
render();
</script>
</body>
</html>