insight-chat: bootstrap insight on first Discuss message + regenerate flag

Tap-Discuss-on-no-insight previously failed silently: ImageApi's
/insights/chat/stream required an existing agentic insight, errored
when missing, and emitted the failure as `event: error` — which the
frontend SSE consumer ignored (it listens for `error_message`).

This commit closes both gaps with a server-side state machine:

- /insights/chat/stream now branches on insight presence. Missing
  insight (or `regenerate: true` in the body) → bootstrap path:
  builds [System(req.system_prompt), User(req.user_message + image)],
  runs the agentic loop, generates a title, persists a new row via
  store_insight (which auto-flips priors). Existing insight →
  continuation path (unchanged behaviour).
- New `regenerate: bool` request field forces bootstrap even when an
  insight exists. Takes precedence over `amend`.
- `done` SSE payload field-name alignment with Apollo's frontend
  convention: prompt_eval_count → prompt_tokens, eval_count →
  eval_tokens, num_ctx echo added.
- `amended_insight_id` semantics broaden — now populated whenever the
  turn produced a new row (bootstrap, regenerate, or amend). Existing
  amend clients keep working unchanged; new clients get the new row's
  id for free.
- `event: error` → `event: error_message` so frontend errors stop
  silently dropping.

Refactor: extracted run_streaming_agentic_loop, build_chat_clients,
and generate_title as shared helpers between bootstrap and
continuation. Continuation path's outer logic moves to
run_continuation_streaming with no behaviour change.

Mobile-ready: any client (Apollo backend, mobile, future) sends one
request to /insights/chat/stream and gets the right path. Apollo's
proxy stays a dumb pipe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-05-08 10:41:50 -04:00
parent bdafd39546
commit 928efe49f9
2 changed files with 447 additions and 160 deletions

View File

@@ -641,11 +641,18 @@ pub struct ChatTurnHttpRequest {
#[serde(default)]
pub max_iterations: Option<usize>,
/// Per-turn system-prompt override. Ephemeral in append mode,
/// persisted in amend mode. See ChatTurnRequest for semantics.
/// persisted in amend / regenerate mode. See ChatTurnRequest for
/// semantics. Also seeds the bootstrap path when no insight exists.
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default)]
pub amend: bool,
/// When true, force the bootstrap path even if an insight already
/// exists: flip the existing row(s) to `is_current=false` and create
/// a new insight row from this turn. Takes precedence over `amend`.
/// Collapses to a normal bootstrap when no insight exists.
#[serde(default)]
pub regenerate: bool,
}
#[derive(Debug, Serialize)]
@@ -701,6 +708,7 @@ pub async fn chat_turn_handler(
max_iterations: request.max_iterations,
system_prompt: request.system_prompt.clone(),
amend: request.amend,
regenerate: request.regenerate,
};
match app_state.insight_chat.chat_turn(chat_req).await {
@@ -916,6 +924,7 @@ pub async fn chat_stream_handler(
max_iterations: request.max_iterations,
system_prompt: request.system_prompt.clone(),
amend: request.amend,
regenerate: request.regenerate,
};
let service = app_state.insight_chat.clone();
@@ -967,8 +976,9 @@ fn render_sse_frame(ev: &ChatStreamEvent) -> String {
tool_calls_made,
iterations_used,
truncated,
prompt_eval_count,
eval_count,
prompt_tokens,
eval_tokens,
num_ctx,
amended_insight_id,
backend_used,
model_used,
@@ -978,14 +988,20 @@ fn render_sse_frame(ev: &ChatStreamEvent) -> String {
"tool_calls_made": tool_calls_made,
"iterations_used": iterations_used,
"truncated": truncated,
"prompt_eval_count": prompt_eval_count,
"eval_count": eval_count,
"prompt_tokens": prompt_tokens,
"eval_tokens": eval_tokens,
"num_ctx": num_ctx,
"amended_insight_id": amended_insight_id,
"backend": backend_used,
"model": model_used,
}),
),
ChatStreamEvent::Error(msg) => ("error", serde_json::json!({ "message": msg })),
// Apollo's frontend SSE consumer (and its free-chat backend, which
// is the de-facto convention) listens for `error_message`. Emitting
// `error` here meant any failure on the photo-chat path (e.g.
// "no insight found for path") was silently dropped, leaving an
// empty assistant bubble with no clue why the turn died.
ChatStreamEvent::Error(msg) => ("error_message", serde_json::json!({ "message": msg })),
};
let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
format!("event: {}\ndata: {}\n\n", event_name, data)