diff --git a/src/subconscious/learn.rs b/src/subconscious/learn.rs index 00f0834..7137211 100644 --- a/src/subconscious/learn.rs +++ b/src/subconscious/learn.rs @@ -474,12 +474,59 @@ pub async fn score_finetune( Ok(results) } +/// Concatenate the text of a Branch's Leaf children — what the model +/// actually produced on that turn (Content + Thinking + ToolCall name). +fn render_branch_text(children: &[AstNode]) -> String { + children.iter() + .filter_map(|c| match c { + AstNode::Leaf(leaf) => Some(leaf.body().text().to_string()), + _ => None, + }) + .collect::>() + .join("") +} + +/// Render the last `max_msgs` user/assistant branches before `idx` as a +/// review-friendly string with `[user]` / `[assistant]` markers. +fn render_prior_context(entries: &[AstNode], idx: usize, max_msgs: usize) -> String { + use crate::agent::context::Role; + let mut picked: Vec<&AstNode> = Vec::with_capacity(max_msgs); + for i in (0..idx).rev() { + if picked.len() >= max_msgs { break; } + if let AstNode::Branch { role, .. } = &entries[i] { + if matches!(role, Role::User | Role::Assistant) { + picked.push(&entries[i]); + } + } + } + picked.reverse(); + + let mut out = String::new(); + for node in picked { + if let AstNode::Branch { role, children, .. } = node { + let marker = match role { + Role::User => "[user]", + Role::Assistant => "[assistant]", + _ => continue, + }; + out.push_str(marker); + out.push('\n'); + out.push_str(render_branch_text(children).trim()); + out.push_str("\n\n"); + } + } + out.trim_end().to_string() +} + /// Enriched finetune candidate with context for review. #[derive(Clone, Debug)] pub struct FinetuneCandidate { pub entry_idx: usize, pub divergence: f64, pub response_text: String, + /// Last couple of user/assistant messages before this response, + /// already rendered with role markers, for F6 display context. + pub prior_context: String, /// Token IDs for context (everything before the response). pub context_ids: Vec, /// Token IDs for the response (what we're training on). @@ -529,20 +576,22 @@ pub async fn score_finetune_candidates( continue; } - // Extract response text. + // Extract response text — content of the assistant turn. let response_text = match node { - AstNode::Branch { children, .. } => { - children.iter() - .filter_map(|c| match c { - AstNode::Leaf(leaf) => Some(leaf.body().text().to_string()), - _ => None, - }) - .collect::>() - .join("") - } + AstNode::Branch { children, .. } => render_branch_text(children), _ => continue, }; + // Skip turns that produced nothing human-visible (e.g., a + // tool-only turn, or an interrupted generation). They'd show + // up as blank cards and we'd still burn alternate-gen on them. + if response_text.trim().is_empty() { + continue; + } + + // Build the last couple of user/assistant exchanges for review. + let prior_context = render_prior_context(entries, entry_idx, 2); + // Build token IDs: context = everything before response, continuation = response. let (context_ids, _) = build_token_ids(context, 0..entry_idx, Filter::None); let continuation_ids: Vec = node.token_ids().into_iter().collect(); @@ -551,6 +600,7 @@ pub async fn score_finetune_candidates( entry_idx, divergence, response_text, + prior_context, context_ids, continuation_ids, alternate_text: None, diff --git a/src/user/learn.rs b/src/user/learn.rs index 8f3d1bf..0bd351f 100644 --- a/src/user/learn.rs +++ b/src/user/learn.rs @@ -23,6 +23,8 @@ pub struct FinetuneCandidate { pub divergence: f64, /// The assistant response text. pub response_text: String, + /// Prior user/assistant messages for review context. + pub prior_context: String, /// Status: pending, approved, rejected, sent. pub status: CandidateStatus, /// Token IDs for context. @@ -49,6 +51,7 @@ impl From for FinetuneCandidate { entry_idx: c.entry_idx, divergence: c.divergence, response_text: c.response_text, + prior_context: c.prior_context, status: CandidateStatus::Pending, context_ids: c.context_ids, continuation_ids: c.continuation_ids, @@ -305,15 +308,22 @@ fn render_detail(frame: &mut Frame, c: &FinetuneCandidate, area: Rect) { ]); frame.render_widget(header, header_area); - // Content: response and alternate (if available) + // Content: prior context, the scored response, and alternate + // (if available). let content_block = Block::default() .borders(Borders::TOP) - .title(" response "); + .title(" context & response "); - let text = match &c.alternate_text { - Some(alt) => format!(" {}\n\n─── without memories ───\n\n {}", c.response_text, alt), - None => format!(" {}", c.response_text), - }; + let mut text = String::new(); + if !c.prior_context.is_empty() { + text.push_str(&c.prior_context); + text.push_str("\n\n─── response ───\n\n"); + } + text.push_str(&c.response_text); + if let Some(alt) = &c.alternate_text { + text.push_str("\n\n─── without memories ───\n\n"); + text.push_str(alt); + } let content = Paragraph::new(text) .block(content_block)