Add conversation branching to insight chat (rewind preserves forks)
Store chat history as a tree in training_messages instead of a flat
array. Rewind now sets active_leaf_id to the target node rather than
truncating, so discarded paths survive as alternate branches. Fork
indicators ("X/Y") mark divergence points and let the client load or
switch to alternate paths.
- llm_client: StoredChatNode/ChatHistoryStore with tree traversal
helpers (path_to_leaf, children_of, fork_at_node, leaves*, etc.) and
from_flat_array for backward-compatible reads of the old flat format.
- insight_chat: load_history/chat_turn/rewind_history operate on the
tree; new switch_branch and get_branches. render_tree_path walks every
node (including non-rendered tool-dispatch nodes) when computing fork
info, so a fork whose diverging child is a tool call still surfaces on
the following rendered message.
- handlers: GET /insights/chat/branches, POST /insights/chat/switch-branch,
and a branch_id param on the history endpoint.
Backward-compatible: flat arrays are converted to a tree lazily on read.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3732faef71
commit
1512fc5bdc
5 files changed
+851
-143
No files matched your search
@@ -171,6 +171,178 @@ pub struct ModelCapabilities {
|
||||
pub has_tool_calling: bool,
|
||||
}
|
||||
|
||||
/// A single node in the conversation tree. Wraps ChatMessage with structural
|
||||
/// metadata so the tree can be persisted as JSON in `training_messages`.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct StoredChatNode {
|
||||
pub id: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<u64>,
|
||||
pub message: ChatMessage,
|
||||
}
|
||||
|
||||
/// Top-level container for tree-based chat history. Stored as JSON in the
|
||||
/// `training_messages` column. Backward-compatible reader detects whether
|
||||
/// the stored value is a flat array (old format) or this object (new format).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ChatHistoryStore {
|
||||
pub nodes: Vec<StoredChatNode>,
|
||||
pub active_leaf_id: u64,
|
||||
}
|
||||
|
||||
impl ChatHistoryStore {
|
||||
/// Create an empty store. Test-only: production stores are always built
|
||||
/// from `from_flat_array` or deserialized from `training_messages`.
|
||||
#[cfg(test)]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
nodes: Vec::new(),
|
||||
active_leaf_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a flat `Vec<ChatMessage>` (old format) into a tree.
|
||||
/// Each message becomes a node chained by parent_id.
|
||||
pub fn from_flat_array(messages: Vec<ChatMessage>) -> Self {
|
||||
let mut nodes = Vec::with_capacity(messages.len());
|
||||
let mut prev_id: Option<u64> = None;
|
||||
for (i, msg) in messages.into_iter().enumerate() {
|
||||
let id = (i + 1) as u64;
|
||||
nodes.push(StoredChatNode {
|
||||
id,
|
||||
parent_id: prev_id,
|
||||
message: msg,
|
||||
});
|
||||
prev_id = Some(id);
|
||||
}
|
||||
let active_leaf_id = prev_id.unwrap_or(0);
|
||||
Self {
|
||||
nodes,
|
||||
active_leaf_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate the next node ID (max existing + 1, or 1 if empty).
|
||||
pub fn next_id(&self) -> u64 {
|
||||
self.nodes.iter().map(|n| n.id).max().unwrap_or(0) + 1
|
||||
}
|
||||
|
||||
/// Append a new node as a child of the given parent. Returns the new node's id.
|
||||
pub fn append_node(&mut self, parent_id: Option<u64>, message: ChatMessage) -> u64 {
|
||||
let id = self.next_id();
|
||||
self.nodes.push(StoredChatNode {
|
||||
id,
|
||||
parent_id,
|
||||
message,
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
/// Traverse from root to the given leaf_id, returning the path in order.
|
||||
/// Returns None if the leaf_id doesn't exist.
|
||||
pub fn path_to_leaf(&self, leaf_id: u64) -> Option<Vec<&StoredChatNode>> {
|
||||
// Build a map from id to node for fast lookup.
|
||||
let node_map: std::collections::HashMap<u64, usize> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (n.id, i))
|
||||
.collect();
|
||||
let &_leaf_idx = node_map.get(&leaf_id)?;
|
||||
|
||||
let mut path_indices = Vec::new();
|
||||
let mut current_id = leaf_id;
|
||||
while let Some(&idx) = node_map.get(¤t_id) {
|
||||
path_indices.push(idx);
|
||||
match self.nodes[idx].parent_id {
|
||||
Some(pid) => current_id = pid,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
path_indices.reverse();
|
||||
Some(path_indices.into_iter().map(|i| &self.nodes[i]).collect())
|
||||
}
|
||||
|
||||
/// All direct children of a given node, ordered by id (chronological).
|
||||
pub fn children_of(&self, node_id: u64) -> Vec<&StoredChatNode> {
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| n.parent_id == Some(node_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// For a given node, return (position, total) where:
|
||||
/// - total = number of children of the parent of this node (i.e., siblings + self)
|
||||
/// - position = 1-based rank of this node among siblings, ordered by id
|
||||
///
|
||||
/// Returns None if the node has no parent (root) or the parent has 0 or 1 child.
|
||||
pub fn fork_at_node(&self, node_id: u64) -> Option<(usize, usize)> {
|
||||
let node = self.nodes.iter().find(|n| n.id == node_id)?;
|
||||
let parent_id = node.parent_id?;
|
||||
let children = self.children_of(parent_id);
|
||||
let total = children.len();
|
||||
if total <= 1 {
|
||||
return None;
|
||||
}
|
||||
let position = children.iter().position(|c| c.id == node_id)? + 1;
|
||||
Some((position, total))
|
||||
}
|
||||
|
||||
/// All leaf node IDs (nodes with no children).
|
||||
pub fn leaves(&self) -> Vec<u64> {
|
||||
let child_ids: std::collections::HashSet<u64> =
|
||||
self.nodes.iter().filter_map(|n| n.parent_id).collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| !child_ids.contains(&n.id))
|
||||
.map(|n| n.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The parent of the given node, if any. Test-only helper.
|
||||
#[cfg(test)]
|
||||
pub fn parent_of(&self, node_id: u64) -> Option<&StoredChatNode> {
|
||||
let node = self.nodes.iter().find(|n| n.id == node_id)?;
|
||||
let parent_id = node.parent_id?;
|
||||
self.nodes.iter().find(|n| n.id == parent_id)
|
||||
}
|
||||
|
||||
/// Return all leaf nodes with metadata: id, snippet (first 100 chars of
|
||||
/// the first user-visible message after the divergence point), and
|
||||
/// message_count (number of nodes in the path from root to leaf).
|
||||
pub fn leaves_with_info(&self) -> Vec<BranchLeafInfo> {
|
||||
let leaf_ids = self.leaves();
|
||||
leaf_ids
|
||||
.into_iter()
|
||||
.map(|leaf_id| {
|
||||
let path = self.path_to_leaf(leaf_id).unwrap_or_default();
|
||||
let message_count = path.len();
|
||||
// Snippet: first 100 chars of the first user/assistant message
|
||||
// (skip system messages).
|
||||
let snippet = path
|
||||
.iter()
|
||||
.find(|n| n.message.role != "system")
|
||||
.map(|n| n.message.content.chars().take(100).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
BranchLeafInfo {
|
||||
id: leaf_id,
|
||||
snippet,
|
||||
message_count,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata about a branch leaf, returned by the branches endpoint.
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct BranchLeafInfo {
|
||||
pub id: u64,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub snippet: String,
|
||||
pub message_count: usize,
|
||||
}
|
||||
|
||||
/// Strip a leading `<think>…</think>` reasoning block from model output.
|
||||
///
|
||||
/// Thinking models sometimes emit chain-of-thought inside think tags before
|
||||
@@ -221,4 +393,135 @@ mod tests {
|
||||
let raw = "<think>thinking forever";
|
||||
assert_eq!(strip_think_blocks(raw), raw);
|
||||
}
|
||||
|
||||
// ── ChatHistoryStore tree traversal tests ──────────────────────────
|
||||
|
||||
fn mk_msg(role: &str, content: &str) -> ChatMessage {
|
||||
match role {
|
||||
"system" => ChatMessage::system(content),
|
||||
"user" => ChatMessage::user(content),
|
||||
_ => ChatMessage {
|
||||
role: role.to_string(),
|
||||
content: content.to_string(),
|
||||
tool_calls: None,
|
||||
images: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_from_flat_array_chains_parent_ids() {
|
||||
let msgs = vec![
|
||||
mk_msg("system", "sys"),
|
||||
mk_msg("user", "hello"),
|
||||
mk_msg("assistant", "hi there"),
|
||||
];
|
||||
let store = ChatHistoryStore::from_flat_array(msgs);
|
||||
assert_eq!(store.nodes.len(), 3);
|
||||
assert_eq!(store.nodes[0].id, 1);
|
||||
assert_eq!(store.nodes[0].parent_id, None);
|
||||
assert_eq!(store.nodes[1].parent_id, Some(1));
|
||||
assert_eq!(store.nodes[2].parent_id, Some(2));
|
||||
assert_eq!(store.active_leaf_id, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_path_to_leaf_returns_ordered_path() {
|
||||
let mut store = ChatHistoryStore::empty();
|
||||
let n1 = store.append_node(None, mk_msg("user", "u1"));
|
||||
let n2 = store.append_node(Some(n1), mk_msg("assistant", "a1"));
|
||||
let n3 = store.append_node(Some(n2), mk_msg("user", "u2"));
|
||||
let path = store.path_to_leaf(n3).unwrap();
|
||||
assert_eq!(path.len(), 3);
|
||||
assert_eq!(path[0].id, n1);
|
||||
assert_eq!(path[1].id, n2);
|
||||
assert_eq!(path[2].id, n3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_path_to_leaf_returns_none_for_missing_id() {
|
||||
let store = ChatHistoryStore::empty();
|
||||
assert!(store.path_to_leaf(999).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_children_of_returns_direct_children() {
|
||||
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 children = store.children_of(root);
|
||||
assert_eq!(children.len(), 2);
|
||||
assert_eq!(children[0].id, c1);
|
||||
assert_eq!(children[1].id, c2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_fork_at_node_detects_fork() {
|
||||
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 c3 = store.append_node(Some(root), mk_msg("user", "c3"));
|
||||
// c1 is position 1 of 3 siblings
|
||||
assert_eq!(store.fork_at_node(c1), Some((1, 3)));
|
||||
assert_eq!(store.fork_at_node(c2), Some((2, 3)));
|
||||
assert_eq!(store.fork_at_node(c3), Some((3, 3)));
|
||||
// root has no parent, so no fork
|
||||
assert!(store.fork_at_node(root).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_fork_at_node_returns_none_for_single_child() {
|
||||
let mut store = ChatHistoryStore::empty();
|
||||
let root = store.append_node(None, mk_msg("user", "root"));
|
||||
let _only = store.append_node(Some(root), mk_msg("assistant", "only"));
|
||||
assert!(store.fork_at_node(2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_leaves_returns_leaf_ids() {
|
||||
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 leaves = store.leaves();
|
||||
assert_eq!(leaves.len(), 2);
|
||||
assert!(leaves.contains(&c2));
|
||||
assert!(leaves.contains(&_d1));
|
||||
assert!(!leaves.contains(&root));
|
||||
assert!(!leaves.contains(&c1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_parent_of_returns_parent() {
|
||||
let mut store = ChatHistoryStore::empty();
|
||||
let root = store.append_node(None, mk_msg("user", "root"));
|
||||
let child = store.append_node(Some(root), mk_msg("assistant", "child"));
|
||||
let parent = store.parent_of(child).unwrap();
|
||||
assert_eq!(parent.id, root);
|
||||
assert!(store.parent_of(root).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_next_id_increments() {
|
||||
let mut store = ChatHistoryStore::empty();
|
||||
assert_eq!(store.next_id(), 1);
|
||||
store.append_node(None, mk_msg("user", "a"));
|
||||
assert_eq!(store.next_id(), 2);
|
||||
store.append_node(Some(1), mk_msg("assistant", "b"));
|
||||
assert_eq!(store.next_id(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_append_node_sets_parent() {
|
||||
let mut store = ChatHistoryStore::empty();
|
||||
let parent = store.append_node(None, mk_msg("user", "parent"));
|
||||
let child = store.append_node(Some(parent), mk_msg("assistant", "child"));
|
||||
assert_eq!(store.nodes.len(), 2);
|
||||
assert_eq!(store.nodes[1].parent_id, Some(parent));
|
||||
assert_eq!(store.nodes[1].id, child);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user