mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 09:00:36 +00:00
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:
+147
-46
@@ -11,6 +11,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -24,6 +25,8 @@ from app.models import (
|
||||
ReflectionMessage,
|
||||
User,
|
||||
)
|
||||
from app.services import answer_store
|
||||
from app.services.answer_store import DISCOVERY_PROMPTS
|
||||
from app.services.extractor import DiscoveryExtractionError, DiscoveryExtractor
|
||||
from app.services.reflector import (
|
||||
EDITABLE_FIELDS,
|
||||
@@ -66,6 +69,60 @@ def _record_revision(
|
||||
)
|
||||
|
||||
|
||||
async def _generate_profile(
|
||||
db: AsyncSession,
|
||||
conversation: DiscoveryConversation,
|
||||
source: str,
|
||||
) -> tuple[DiscoveryProfile, str | None]:
|
||||
"""Run the extractor over a conversation's answers and stage a new profile
|
||||
(plus a revision snapshot) on the session. The caller commits.
|
||||
|
||||
Shared by ``/complete`` (source="extraction") and ``/reprocess``
|
||||
(source="reprocess"). Raises HTTPException(400) when there is nothing to
|
||||
analyze and HTTPException(502) on an extractor failure.
|
||||
"""
|
||||
responses = {
|
||||
p["key"]: getattr(conversation, p["column"], None) or ""
|
||||
for p in DISCOVERY_PROMPTS
|
||||
}
|
||||
if not any(text.strip() for text in responses.values()):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No responses available to analyze"
|
||||
)
|
||||
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||
try:
|
||||
extractor = DiscoveryExtractor(api_key=api_key, model=model)
|
||||
data = await extractor.extract(responses)
|
||||
except DiscoveryExtractionError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
profile = DiscoveryProfile(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=conversation.user_id,
|
||||
conversation_id=conversation.id,
|
||||
generated_at=_now(),
|
||||
triad=data.get("triad"),
|
||||
probable_type=_as_int(data.get("probable_type")),
|
||||
wing=_as_int(data.get("wing")),
|
||||
instinctual_variant=data.get("instinctual_variant"),
|
||||
instinctual_stack=data.get("instinctual_stack"),
|
||||
love_summary=data.get("love_summary"),
|
||||
strength_summary=data.get("strength_summary"),
|
||||
mission_summary=data.get("mission_summary"),
|
||||
vocation_summary=data.get("vocation_summary"),
|
||||
overlap_narrative=data.get("overlap_narrative"),
|
||||
short_term_goals=data.get("short_term_goals"),
|
||||
long_term_goals=data.get("long_term_goals"),
|
||||
confidence_json=json.dumps(data.get("confidence", {})),
|
||||
locked=False,
|
||||
)
|
||||
db.add(profile)
|
||||
_record_revision(db, profile, source=source)
|
||||
return profile, data.get("extraction_notes")
|
||||
|
||||
|
||||
def _to_profile_response(
|
||||
profile: DiscoveryProfile, extraction_notes: str | None = None
|
||||
) -> schemas.ProfileResponse:
|
||||
@@ -161,6 +218,10 @@ async def save_responses(
|
||||
conversation.prompt_goals_long = payload.prompt_goals_long
|
||||
await db.commit()
|
||||
|
||||
# Durable CSV copy, written before extraction runs so the answers survive
|
||||
# an extraction error and can be reviewed or re-processed later.
|
||||
answer_store.save_answers(conversation, user, status="responses_saved")
|
||||
|
||||
return schemas.RespondResponse(
|
||||
conversation_id=conversation_id, status="responses_saved"
|
||||
)
|
||||
@@ -176,59 +237,99 @@ async def complete_conversation(
|
||||
):
|
||||
conversation = await _owned_conversation(db, conversation_id, user)
|
||||
|
||||
responses = {
|
||||
"alive": conversation.prompt_alive or "",
|
||||
"friction": conversation.prompt_friction or "",
|
||||
"pull": conversation.prompt_pull or "",
|
||||
"recognition": conversation.prompt_recognition or "",
|
||||
"future": conversation.prompt_future or "",
|
||||
"goals_short": conversation.prompt_goals_short or "",
|
||||
"goals_long": conversation.prompt_goals_long or "",
|
||||
}
|
||||
if not any(text.strip() for text in responses.values()):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No responses available to analyze"
|
||||
)
|
||||
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||
|
||||
try:
|
||||
extractor = DiscoveryExtractor(api_key=api_key, model=model)
|
||||
data = await extractor.extract(responses)
|
||||
except DiscoveryExtractionError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
profile = DiscoveryProfile(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=conversation.user_id,
|
||||
conversation_id=conversation.id,
|
||||
generated_at=_now(),
|
||||
triad=data.get("triad"),
|
||||
probable_type=_as_int(data.get("probable_type")),
|
||||
wing=_as_int(data.get("wing")),
|
||||
instinctual_variant=data.get("instinctual_variant"),
|
||||
instinctual_stack=data.get("instinctual_stack"),
|
||||
love_summary=data.get("love_summary"),
|
||||
strength_summary=data.get("strength_summary"),
|
||||
mission_summary=data.get("mission_summary"),
|
||||
vocation_summary=data.get("vocation_summary"),
|
||||
overlap_narrative=data.get("overlap_narrative"),
|
||||
short_term_goals=data.get("short_term_goals"),
|
||||
long_term_goals=data.get("long_term_goals"),
|
||||
confidence_json=json.dumps(data.get("confidence", {})),
|
||||
locked=False,
|
||||
profile, extraction_notes = await _generate_profile(
|
||||
db, conversation, source="extraction"
|
||||
)
|
||||
conversation.completed_at = _now()
|
||||
db.add(profile)
|
||||
_record_revision(db, profile, source="extraction")
|
||||
await db.commit()
|
||||
|
||||
return _to_profile_response(
|
||||
profile, extraction_notes=data.get("extraction_notes")
|
||||
# Refresh the durable CSV copy now that the conversation is complete.
|
||||
answer_store.save_answers(conversation, user, status="completed")
|
||||
|
||||
return _to_profile_response(profile, extraction_notes=extraction_notes)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{conversation_id}/answers", response_model=schemas.AnswersResponse
|
||||
)
|
||||
async def get_answers(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""The saved prompts and answers for a conversation, so the person can
|
||||
review or resume from their original responses (e.g. after an error)."""
|
||||
conversation = await _owned_conversation(db, conversation_id, user)
|
||||
answers = [
|
||||
schemas.AnswerItem(
|
||||
prompt_key=p["key"],
|
||||
prompt_title=p["title"],
|
||||
answer=getattr(conversation, p["column"], None) or "",
|
||||
)
|
||||
for p in DISCOVERY_PROMPTS
|
||||
]
|
||||
return schemas.AnswersResponse(
|
||||
conversation_id=conversation.id,
|
||||
started_at=conversation.started_at,
|
||||
completed_at=conversation.completed_at,
|
||||
answers=answers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{conversation_id}/answers.csv")
|
||||
async def download_answers_csv(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Download a conversation's answers as a CSV file (built from the DB so it
|
||||
works even if the on-disk copy was never written)."""
|
||||
conversation = await _owned_conversation(db, conversation_id, user)
|
||||
status = "completed" if conversation.completed_at else "responses_saved"
|
||||
csv_text = answer_store.conversation_csv_text(conversation, user, status)
|
||||
filename = f"discovery-{conversation_id}.csv"
|
||||
return Response(
|
||||
content=csv_text,
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{conversation_id}/reprocess", response_model=schemas.ProfileResponse
|
||||
)
|
||||
async def reprocess_conversation(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Re-run AI extraction over a conversation's saved answers, producing a
|
||||
fresh profile. Used to recover from an extraction error or to regenerate a
|
||||
profile after the answers were re-fed. The latest profile must be unlocked.
|
||||
"""
|
||||
conversation = await _owned_conversation(db, conversation_id, user)
|
||||
|
||||
existing = await _latest_profile(db, user.id)
|
||||
if existing is not None and existing.locked:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Profile is affirmed and locked; it cannot be reprocessed.",
|
||||
)
|
||||
|
||||
profile, extraction_notes = await _generate_profile(
|
||||
db, conversation, source="reprocess"
|
||||
)
|
||||
if conversation.completed_at is None:
|
||||
conversation.completed_at = _now()
|
||||
await db.commit()
|
||||
|
||||
answer_store.save_answers(conversation, user, status="completed")
|
||||
|
||||
return _to_profile_response(profile, extraction_notes=extraction_notes)
|
||||
|
||||
|
||||
@router.get("/profile/me", response_model=schemas.ProfileResponse)
|
||||
async def get_my_profile(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -24,6 +24,21 @@ class RespondResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class AnswerItem(BaseModel):
|
||||
prompt_key: str
|
||||
prompt_title: str
|
||||
answer: str = ""
|
||||
|
||||
|
||||
class AnswersResponse(BaseModel):
|
||||
"""The saved prompts and answers for a conversation, for review/resume."""
|
||||
|
||||
conversation_id: str
|
||||
started_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
answers: list[AnswerItem] = []
|
||||
|
||||
|
||||
class Confidence(BaseModel):
|
||||
triad: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Durable CSV record of discovery questions and answers.
|
||||
|
||||
The SQLite database remains the system of record. This module keeps a
|
||||
secondary, portable copy of every conversation's prompts and answers on disk
|
||||
so that:
|
||||
|
||||
* answers survive even if the AI extraction step errors (they are written
|
||||
on save, before extraction runs);
|
||||
* a saved conversation can be re-fed to the extractor (``reprocess``);
|
||||
* a person can review or resume from their original answers.
|
||||
|
||||
Two artifacts are written for every save:
|
||||
|
||||
* a *per-conversation* file (``data/questions/{conversation_id}.csv``) that
|
||||
is rewritten in full on each save — always the latest answers for that
|
||||
conversation, easy to hand to an AI or download; and
|
||||
* a *master* append-only log (``data/questions_master.csv``) that records
|
||||
every save/complete event across all conversations, for batch
|
||||
re-processing.
|
||||
|
||||
CSV writes must never break an API request: the caller's DB commit has
|
||||
already succeeded, so any I/O failure here is logged and swallowed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from app.models import DiscoveryConversation, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Canonical list of discovery prompts: the short key used by the extractor and
|
||||
# CSV, the DB column on DiscoveryConversation, and the human-facing title.
|
||||
# This is the single source of truth shared by the store, the router, and the
|
||||
# reprocess paths.
|
||||
DISCOVERY_PROMPTS = [
|
||||
{"key": "alive", "column": "prompt_alive", "title": "The Alive Moment"},
|
||||
{"key": "friction", "column": "prompt_friction", "title": "The Friction Moment"},
|
||||
{"key": "pull", "column": "prompt_pull", "title": "The Natural Pull"},
|
||||
{"key": "recognition", "column": "prompt_recognition", "title": "The Recognition Moment"},
|
||||
{"key": "future", "column": "prompt_future", "title": "The Future Pull"},
|
||||
{"key": "goals_short", "column": "prompt_goals_short", "title": "Near-Term Goals (6–12 months)"},
|
||||
{"key": "goals_long", "column": "prompt_goals_long", "title": "Long-Term Goals (3–5 years)"},
|
||||
]
|
||||
|
||||
CSV_FIELDS = [
|
||||
"conversation_id",
|
||||
"user_id",
|
||||
"user_email",
|
||||
"status",
|
||||
"saved_at",
|
||||
"prompt_key",
|
||||
"prompt_title",
|
||||
"answer",
|
||||
]
|
||||
|
||||
|
||||
def _questions_dir() -> str:
|
||||
return os.getenv("QUESTIONS_DIR", "./data/questions")
|
||||
|
||||
|
||||
def _master_path() -> str:
|
||||
return os.getenv("QUESTIONS_MASTER_CSV", "./data/questions_master.csv")
|
||||
|
||||
|
||||
def conversation_csv_path(conversation_id: str) -> str:
|
||||
"""Filesystem path of the per-conversation CSV for ``conversation_id``."""
|
||||
return os.path.join(_questions_dir(), f"{conversation_id}.csv")
|
||||
|
||||
|
||||
def _rows_for(
|
||||
conversation: "DiscoveryConversation",
|
||||
user_email: str,
|
||||
status: str,
|
||||
saved_at: str,
|
||||
) -> list[dict]:
|
||||
"""One row per prompt, in canonical prompt order."""
|
||||
rows = []
|
||||
for prompt in DISCOVERY_PROMPTS:
|
||||
answer = getattr(conversation, prompt["column"], None) or ""
|
||||
rows.append(
|
||||
{
|
||||
"conversation_id": conversation.id,
|
||||
"user_id": conversation.user_id,
|
||||
"user_email": user_email,
|
||||
"status": status,
|
||||
"saved_at": saved_at,
|
||||
"prompt_key": prompt["key"],
|
||||
"prompt_title": prompt["title"],
|
||||
"answer": answer,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def write_conversation_csv(
|
||||
conversation: "DiscoveryConversation",
|
||||
user: "User | None",
|
||||
status: str,
|
||||
) -> bool:
|
||||
"""Rewrite the per-conversation CSV with the conversation's current answers.
|
||||
|
||||
The write is atomic (temp file + ``os.replace``) so a crash mid-write can
|
||||
never leave a half-written file. Returns True on success, False if the
|
||||
write failed (failures are logged, never raised).
|
||||
"""
|
||||
saved_at = datetime.now(timezone.utc).isoformat()
|
||||
email = getattr(user, "email", "") or ""
|
||||
rows = _rows_for(conversation, email, status, saved_at)
|
||||
path = conversation_csv_path(conversation.id)
|
||||
tmp_path = f"{path}.tmp"
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(tmp_path, "w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.DictWriter(fh, fieldnames=CSV_FIELDS)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
os.replace(tmp_path, path)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to write conversation CSV for %s: %s", conversation.id, exc
|
||||
)
|
||||
try:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def append_master(
|
||||
conversation: "DiscoveryConversation",
|
||||
user: "User | None",
|
||||
status: str,
|
||||
) -> bool:
|
||||
"""Append this save/complete event's rows to the master log.
|
||||
|
||||
Writes the header row once, when the file is first created. Returns True on
|
||||
success, False on a logged (never raised) failure.
|
||||
"""
|
||||
saved_at = datetime.now(timezone.utc).isoformat()
|
||||
email = getattr(user, "email", "") or ""
|
||||
rows = _rows_for(conversation, email, status, saved_at)
|
||||
path = _master_path()
|
||||
try:
|
||||
directory = os.path.dirname(path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
is_new = not os.path.exists(path) or os.path.getsize(path) == 0
|
||||
with open(path, "a", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.DictWriter(fh, fieldnames=CSV_FIELDS)
|
||||
if is_new:
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to append master CSV for %s: %s", conversation.id, exc
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def save_answers(
|
||||
conversation: "DiscoveryConversation",
|
||||
user: "User | None",
|
||||
status: str,
|
||||
) -> None:
|
||||
"""Persist both CSV artifacts for a conversation. Never raises."""
|
||||
write_conversation_csv(conversation, user, status)
|
||||
append_master(conversation, user, status)
|
||||
|
||||
|
||||
def conversation_csv_text(
|
||||
conversation: "DiscoveryConversation",
|
||||
user: "User | None",
|
||||
status: str = "responses_saved",
|
||||
) -> str:
|
||||
"""Render a conversation's answers as CSV text, built from the DB row.
|
||||
|
||||
Used by the download endpoint so it works even if the on-disk file was
|
||||
never written (DB stays the system of record).
|
||||
"""
|
||||
import io
|
||||
|
||||
saved_at = datetime.now(timezone.utc).isoformat()
|
||||
email = getattr(user, "email", "") or ""
|
||||
rows = _rows_for(conversation, email, status, saved_at)
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=CSV_FIELDS)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def read_conversation_answers(conversation_id: str) -> dict[str, str]:
|
||||
"""Read saved answers from the per-conversation CSV.
|
||||
|
||||
Returns a ``{prompt_key: answer}`` dict, or ``{}`` if the file is missing
|
||||
or unreadable. The per-conversation file is rewritten in full on each save,
|
||||
so it always holds the latest answers.
|
||||
"""
|
||||
path = conversation_csv_path(conversation_id)
|
||||
answers: dict[str, str] = {}
|
||||
try:
|
||||
with open(path, newline="", encoding="utf-8") as fh:
|
||||
for row in csv.DictReader(fh):
|
||||
key = row.get("prompt_key")
|
||||
if key:
|
||||
answers[key] = row.get("answer", "") or ""
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to read conversation CSV for %s: %s", conversation_id, exc
|
||||
)
|
||||
return {}
|
||||
return answers
|
||||
+102
-5
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user