Feature/insight chat branching #109
+35
-7
@@ -1335,6 +1335,9 @@ pub struct ChatHistoryHttpResponse {
|
|||||||
pub struct ChatForkInfo {
|
pub struct ChatForkInfo {
|
||||||
pub position: usize,
|
pub position: usize,
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
|
/// The divergence tree node. Pass as `node_id` to the branches endpoint
|
||||||
|
/// for a list scoped to this exact fork.
|
||||||
|
pub node_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -1443,6 +1446,7 @@ pub async fn chat_history_handler(
|
|||||||
f.map(|fi| ChatForkInfo {
|
f.map(|fi| ChatForkInfo {
|
||||||
position: fi.position,
|
position: fi.position,
|
||||||
total: fi.total,
|
total: fi.total,
|
||||||
|
node_id: fi.node_id,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1487,14 +1491,32 @@ pub async fn chat_history_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /insights/chat/branches — return the list of branch leaf IDs for a
|
/// Query for GET /insights/chat/branches.
|
||||||
/// photo's conversation tree. Each branch is a leaf node; the active branch
|
#[derive(Debug, Deserialize)]
|
||||||
/// is the one matching `active_leaf_id`. Use with `branch_id` on the
|
pub struct ChatBranchesQuery {
|
||||||
/// history endpoint to load an alternate conversation path.
|
pub path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub library: Option<String>,
|
||||||
|
/// When set (from `ForkInfo.node_id`), scope the list to the sibling
|
||||||
|
/// branches diverging at this tree node instead of every leaf.
|
||||||
|
#[serde(default)]
|
||||||
|
pub node_id: Option<u64>,
|
||||||
|
/// The leaf the client is currently viewing. Scoped options in the
|
||||||
|
/// same subtree anchor to it so the client can identify its own branch.
|
||||||
|
/// Defaults to the active leaf when absent.
|
||||||
|
#[serde(default)]
|
||||||
|
pub viewing_branch_id: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /insights/chat/branches — return the branch list for a photo's
|
||||||
|
/// conversation tree. Without `node_id`, each entry is a leaf node;
|
||||||
|
/// with `node_id`, entries are the position-ranked sibling branches at
|
||||||
|
/// that divergence point. Use an entry's `id` with `branch_id` on the
|
||||||
|
/// history endpoint (or the switch-branch endpoint) to load that path.
|
||||||
#[get("/insights/chat/branches")]
|
#[get("/insights/chat/branches")]
|
||||||
pub async fn chat_branches_handler(
|
pub async fn chat_branches_handler(
|
||||||
_claims: Claims,
|
_claims: Claims,
|
||||||
query: web::Query<ChatHistoryQuery>,
|
query: web::Query<ChatBranchesQuery>,
|
||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let library = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
let library = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||||
@@ -1502,8 +1524,12 @@ pub async fn chat_branches_handler(
|
|||||||
.flatten()
|
.flatten()
|
||||||
.unwrap_or_else(|| app_state.primary_library());
|
.unwrap_or_else(|| app_state.primary_library());
|
||||||
|
|
||||||
let (branches, active_leaf_id) =
|
let (branches, active_leaf_id) = match app_state.insight_chat.get_branches(
|
||||||
match app_state.insight_chat.get_branches(library.id, &query.path) {
|
library.id,
|
||||||
|
&query.path,
|
||||||
|
query.node_id,
|
||||||
|
query.viewing_branch_id,
|
||||||
|
) {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("{}", e);
|
let msg = format!("{}", e);
|
||||||
@@ -1511,6 +1537,8 @@ pub async fn chat_branches_handler(
|
|||||||
return HttpResponse::NotFound().json(serde_json::json!({ "error": msg }));
|
return HttpResponse::NotFound().json(serde_json::json!({ "error": msg }));
|
||||||
} else if msg.contains("no chat history") {
|
} else if msg.contains("no chat history") {
|
||||||
return HttpResponse::Conflict().json(serde_json::json!({ "error": msg }));
|
return HttpResponse::Conflict().json(serde_json::json!({ "error": msg }));
|
||||||
|
} else if msg.contains("not found in tree") {
|
||||||
|
return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }));
|
||||||
} else {
|
} else {
|
||||||
return HttpResponse::InternalServerError()
|
return HttpResponse::InternalServerError()
|
||||||
.json(serde_json::json!({ "error": msg }));
|
.json(serde_json::json!({ "error": msg }));
|
||||||
|
|||||||
+36
-5
@@ -717,12 +717,20 @@ impl InsightChatService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return branch leaf metadata (id, snippet, message_count) and the
|
/// Return branch metadata and the active leaf ID for a photo's
|
||||||
/// active leaf ID for a photo's conversation tree.
|
/// conversation tree.
|
||||||
|
///
|
||||||
|
/// With `node_id = None` this lists every leaf in the tree. With
|
||||||
|
/// `node_id = Some(fork)` (from `ForkInfo.node_id`) it lists only the
|
||||||
|
/// sibling branches diverging at that node, position-ranked; options in
|
||||||
|
/// the same subtree as `viewing_leaf` anchor to that leaf so the client
|
||||||
|
/// can recognise "the branch I'm on".
|
||||||
pub fn get_branches(
|
pub fn get_branches(
|
||||||
&self,
|
&self,
|
||||||
library_id: i32,
|
library_id: i32,
|
||||||
file_path: &str,
|
file_path: &str,
|
||||||
|
node_id: Option<u64>,
|
||||||
|
viewing_leaf: Option<u64>,
|
||||||
) -> Result<(Vec<BranchLeafInfo>, u64)> {
|
) -> Result<(Vec<BranchLeafInfo>, u64)> {
|
||||||
let normalized = normalize_path(file_path);
|
let normalized = normalize_path(file_path);
|
||||||
let cx = opentelemetry::Context::new();
|
let cx = opentelemetry::Context::new();
|
||||||
@@ -745,7 +753,16 @@ impl InsightChatService {
|
|||||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((store.leaves_with_info(), store.active_leaf_id))
|
let list = match node_id {
|
||||||
|
Some(fork_node) => {
|
||||||
|
if !store.nodes.iter().any(|n| n.id == fork_node) {
|
||||||
|
bail!("node_id {} not found in tree", fork_node);
|
||||||
|
}
|
||||||
|
store.branch_options_at(fork_node, viewing_leaf.unwrap_or(store.active_leaf_id))
|
||||||
|
}
|
||||||
|
None => store.leaves_with_info(),
|
||||||
|
};
|
||||||
|
Ok((list, store.active_leaf_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streaming variant of `chat_turn`. Emits user-facing events as the
|
/// Streaming variant of `chat_turn`. Emits user-facing events as the
|
||||||
@@ -2465,6 +2482,10 @@ pub(crate) fn restore_system_prompt_override(
|
|||||||
pub struct ForkInfo {
|
pub struct ForkInfo {
|
||||||
pub position: usize,
|
pub position: usize,
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
|
/// The divergence node itself (the parent whose children fork). Pass as
|
||||||
|
/// `node_id` to the branches endpoint to list the sibling branches at
|
||||||
|
/// this exact point rather than every leaf in the tree.
|
||||||
|
pub node_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a path of tree nodes into the UI-friendly `RenderedMessage` format,
|
/// Render a path of tree nodes into the UI-friendly `RenderedMessage` format,
|
||||||
@@ -2498,8 +2519,16 @@ fn render_tree_path(
|
|||||||
// Update the carried fork BEFORE rendering: a non-rendered fork child
|
// Update the carried fork BEFORE rendering: a non-rendered fork child
|
||||||
// (e.g. a tool-dispatch assistant) must colour the rendered message
|
// (e.g. a tool-dispatch assistant) must colour the rendered message
|
||||||
// that follows it.
|
// that follows it.
|
||||||
if let Some((position, total)) = store.fork_at_node(node.id) {
|
// (fork_at_node only fires for nodes with a parent, so the pair
|
||||||
last_fork = Some(ForkInfo { position, total });
|
// pattern below can't drop a real fork.)
|
||||||
|
if let (Some(node_id), Some((position, total))) =
|
||||||
|
(node.parent_id, store.fork_at_node(node.id))
|
||||||
|
{
|
||||||
|
last_fork = Some(ForkInfo {
|
||||||
|
position,
|
||||||
|
total,
|
||||||
|
node_id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let msg = &node.message;
|
let msg = &node.message;
|
||||||
match msg.role.as_str() {
|
match msg.role.as_str() {
|
||||||
@@ -2839,6 +2868,7 @@ mod tests {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("fork indicator carried onto the rendered reply");
|
.expect("fork indicator carried onto the rendered reply");
|
||||||
assert_eq!((f.position, f.total), (2, 2));
|
assert_eq!((f.position, f.total), (2, 2));
|
||||||
|
assert_eq!(f.node_id, u, "divergence node is the forked user turn");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2859,6 +2889,7 @@ mod tests {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("fork indicator on the divergent rendered reply");
|
.expect("fork indicator on the divergent rendered reply");
|
||||||
assert_eq!((f.position, f.total), (2, 2));
|
assert_eq!((f.position, f.total), (2, 2));
|
||||||
|
assert_eq!(f.node_id, u, "divergence node is the forked user turn");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -288,6 +288,67 @@ impl ChatHistoryStore {
|
|||||||
Some((position, total))
|
Some((position, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All leaf node IDs within the subtree rooted at `node_id` (the node
|
||||||
|
/// itself when it has no children). Empty when the node doesn't exist.
|
||||||
|
pub fn descendant_leaves(&self, node_id: u64) -> Vec<u64> {
|
||||||
|
if !self.nodes.iter().any(|n| n.id == node_id) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut stack = vec![node_id];
|
||||||
|
while let Some(id) = stack.pop() {
|
||||||
|
let children = self.children_of(id);
|
||||||
|
if children.is_empty() {
|
||||||
|
result.push(id);
|
||||||
|
} else {
|
||||||
|
stack.extend(children.iter().map(|c| c.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Branch options at a divergence point: one entry per direct child of
|
||||||
|
/// `fork_node_id`, ordered by id (chronological — matches the stable
|
||||||
|
/// position rank shown in "X/Y" fork chips).
|
||||||
|
///
|
||||||
|
/// Each entry's `id` is the leaf to switch to for that branch: the
|
||||||
|
/// `preferred_leaf` when it lives in that child's subtree (so the
|
||||||
|
/// caller's current view maps onto its own option and can be filtered
|
||||||
|
/// out client-side), otherwise the most recent (max-id) descendant leaf.
|
||||||
|
pub fn branch_options_at(&self, fork_node_id: u64, preferred_leaf: u64) -> Vec<BranchLeafInfo> {
|
||||||
|
self.children_of(fork_node_id)
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, child)| {
|
||||||
|
let leaves = self.descendant_leaves(child.id);
|
||||||
|
let rep = if leaves.contains(&preferred_leaf) {
|
||||||
|
preferred_leaf
|
||||||
|
} else {
|
||||||
|
leaves.iter().copied().max().unwrap_or(child.id)
|
||||||
|
};
|
||||||
|
let path = self.path_to_leaf(rep).unwrap_or_default();
|
||||||
|
let message_count = path.len();
|
||||||
|
// Snippet: first visible message *after* the divergence point —
|
||||||
|
// skip tool scaffolding (tool results, empty dispatch turns).
|
||||||
|
let snippet = path
|
||||||
|
.iter()
|
||||||
|
.skip_while(|n| n.id != child.id)
|
||||||
|
.find(|n| {
|
||||||
|
(n.message.role == "user" || n.message.role == "assistant")
|
||||||
|
&& !n.message.content.trim().is_empty()
|
||||||
|
})
|
||||||
|
.map(|n| n.message.content.chars().take(100).collect::<String>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
BranchLeafInfo {
|
||||||
|
id: rep,
|
||||||
|
snippet,
|
||||||
|
message_count,
|
||||||
|
position: Some(i + 1),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// All leaf node IDs (nodes with no children).
|
/// All leaf node IDs (nodes with no children).
|
||||||
pub fn leaves(&self) -> Vec<u64> {
|
pub fn leaves(&self) -> Vec<u64> {
|
||||||
let child_ids: std::collections::HashSet<u64> =
|
let child_ids: std::collections::HashSet<u64> =
|
||||||
@@ -328,6 +389,7 @@ impl ChatHistoryStore {
|
|||||||
id: leaf_id,
|
id: leaf_id,
|
||||||
snippet,
|
snippet,
|
||||||
message_count,
|
message_count,
|
||||||
|
position: None,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -341,6 +403,11 @@ pub struct BranchLeafInfo {
|
|||||||
#[serde(skip_serializing_if = "String::is_empty")]
|
#[serde(skip_serializing_if = "String::is_empty")]
|
||||||
pub snippet: String,
|
pub snippet: String,
|
||||||
pub message_count: usize,
|
pub message_count: usize,
|
||||||
|
/// 1-based sibling rank at the divergence point. Only present for
|
||||||
|
/// scoped queries (`branch_options_at`); tree-wide leaf listings have
|
||||||
|
/// no single divergence to rank against.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub position: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip a leading `<think>…</think>` reasoning block from model output.
|
/// Strip a leading `<think>…</think>` reasoning block from model output.
|
||||||
@@ -515,6 +582,76 @@ mod tests {
|
|||||||
assert_eq!(store.next_id(), 3);
|
assert_eq!(store.next_id(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_descendant_leaves_walks_subtree() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "root"));
|
||||||
|
let c1 = store.append_node(Some(root), mk_msg("assistant", "c1"));
|
||||||
|
let c2 = store.append_node(Some(root), mk_msg("user", "c2"));
|
||||||
|
let d1 = store.append_node(Some(c1), mk_msg("assistant", "d1"));
|
||||||
|
let d2 = store.append_node(Some(c1), mk_msg("assistant", "d2"));
|
||||||
|
// c1's subtree has two leaves; c2 is its own leaf; root sees all.
|
||||||
|
let mut c1_leaves = store.descendant_leaves(c1);
|
||||||
|
c1_leaves.sort_unstable();
|
||||||
|
assert_eq!(c1_leaves, vec![d1, d2]);
|
||||||
|
assert_eq!(store.descendant_leaves(c2), vec![c2]);
|
||||||
|
let mut all = store.descendant_leaves(root);
|
||||||
|
all.sort_unstable();
|
||||||
|
assert_eq!(all, vec![c2, d1, d2]);
|
||||||
|
assert!(store.descendant_leaves(999).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_options_ranks_children_and_prefers_viewing_leaf() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "root"));
|
||||||
|
// First branch: two leaves (older + newer).
|
||||||
|
let c1 = store.append_node(Some(root), mk_msg("assistant", "reply one"));
|
||||||
|
let old_leaf = store.append_node(Some(c1), mk_msg("user", "old follow-up"));
|
||||||
|
let new_leaf = store.append_node(Some(c1), mk_msg("user", "new follow-up"));
|
||||||
|
// Second branch: single leaf.
|
||||||
|
let c2 = store.append_node(Some(root), mk_msg("assistant", "reply two"));
|
||||||
|
|
||||||
|
// Viewing the *older* leaf of branch 1: its option must anchor to
|
||||||
|
// that exact leaf, not the max-id one, so the client's
|
||||||
|
// "hide the branch I'm on" filter matches.
|
||||||
|
let opts = store.branch_options_at(root, old_leaf);
|
||||||
|
assert_eq!(opts.len(), 2);
|
||||||
|
assert_eq!(opts[0].id, old_leaf);
|
||||||
|
assert_eq!(opts[0].position, Some(1));
|
||||||
|
assert_eq!(opts[0].snippet, "reply one");
|
||||||
|
assert_eq!(opts[1].id, c2);
|
||||||
|
assert_eq!(opts[1].position, Some(2));
|
||||||
|
|
||||||
|
// Viewing branch 2: branch 1 falls back to its most recent leaf.
|
||||||
|
let opts = store.branch_options_at(root, c2);
|
||||||
|
assert_eq!(opts[0].id, new_leaf);
|
||||||
|
assert_eq!(opts[1].id, c2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_options_snippet_skips_tool_scaffolding() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "root"));
|
||||||
|
// Branch whose diverging child is an empty tool-dispatch turn.
|
||||||
|
let dispatch = store.append_node(
|
||||||
|
Some(root),
|
||||||
|
ChatMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: String::new(),
|
||||||
|
tool_calls: None,
|
||||||
|
images: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let tool = store.append_node(Some(dispatch), mk_msg("tool", "raw tool output"));
|
||||||
|
let reply = store.append_node(Some(tool), mk_msg("assistant", "final answer"));
|
||||||
|
let _other = store.append_node(Some(root), mk_msg("assistant", "direct reply"));
|
||||||
|
|
||||||
|
let opts = store.branch_options_at(root, reply);
|
||||||
|
assert_eq!(opts[0].snippet, "final answer");
|
||||||
|
assert_eq!(opts[1].snippet, "direct reply");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn store_append_node_sets_parent() {
|
fn store_append_node_sets_parent() {
|
||||||
let mut store = ChatHistoryStore::empty();
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
|||||||
Reference in New Issue
Block a user