learn: skip empty responses; show prior conversation context on F6

Two fixes to the F6 candidate display:

1. Turns where the assistant produced nothing human-visible (an
   interrupted generation, a turn consisting of only a tool call the
   renderer folds to the tool name) were landing as candidates with
   an empty response_text. They'd render as blank cards and, worse,
   we'd still burn a full alternate generation on each one. Filter
   them out before they reach the candidate list.

2. The detail pane showed only the scored response + alternate, with
   no hint of what the user had actually asked. Pre-compute the last
   two user/assistant exchanges on each candidate as a rendered
   prior_context string ([user]/[assistant] markers) and show them
   above the response, under a new "context & response" section
   heading.

render_branch_text and render_prior_context extracted as helpers —
the response-text rendering and prior-context rendering share the
same "flatten Branch children to text" pass.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-16 13:20:03 -04:00
parent 7ef02c97d1
commit 2eddf3b4cf
2 changed files with 76 additions and 16 deletions

View file

@ -474,12 +474,59 @@ pub async fn score_finetune(
Ok(results) 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::<Vec<_>>()
.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. /// Enriched finetune candidate with context for review.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct FinetuneCandidate { pub struct FinetuneCandidate {
pub entry_idx: usize, pub entry_idx: usize,
pub divergence: f64, pub divergence: f64,
pub response_text: String, 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). /// Token IDs for context (everything before the response).
pub context_ids: Vec<u32>, pub context_ids: Vec<u32>,
/// Token IDs for the response (what we're training on). /// Token IDs for the response (what we're training on).
@ -529,20 +576,22 @@ pub async fn score_finetune_candidates(
continue; continue;
} }
// Extract response text. // Extract response text — content of the assistant turn.
let response_text = match node { let response_text = match node {
AstNode::Branch { children, .. } => { AstNode::Branch { children, .. } => render_branch_text(children),
children.iter()
.filter_map(|c| match c {
AstNode::Leaf(leaf) => Some(leaf.body().text().to_string()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
_ => continue, _ => 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. // Build token IDs: context = everything before response, continuation = response.
let (context_ids, _) = build_token_ids(context, 0..entry_idx, Filter::None); let (context_ids, _) = build_token_ids(context, 0..entry_idx, Filter::None);
let continuation_ids: Vec<u32> = node.token_ids().into_iter().collect(); let continuation_ids: Vec<u32> = node.token_ids().into_iter().collect();
@ -551,6 +600,7 @@ pub async fn score_finetune_candidates(
entry_idx, entry_idx,
divergence, divergence,
response_text, response_text,
prior_context,
context_ids, context_ids,
continuation_ids, continuation_ids,
alternate_text: None, alternate_text: None,

View file

@ -23,6 +23,8 @@ pub struct FinetuneCandidate {
pub divergence: f64, pub divergence: f64,
/// The assistant response text. /// The assistant response text.
pub response_text: String, pub response_text: String,
/// Prior user/assistant messages for review context.
pub prior_context: String,
/// Status: pending, approved, rejected, sent. /// Status: pending, approved, rejected, sent.
pub status: CandidateStatus, pub status: CandidateStatus,
/// Token IDs for context. /// Token IDs for context.
@ -49,6 +51,7 @@ impl From<crate::subconscious::learn::FinetuneCandidate> for FinetuneCandidate {
entry_idx: c.entry_idx, entry_idx: c.entry_idx,
divergence: c.divergence, divergence: c.divergence,
response_text: c.response_text, response_text: c.response_text,
prior_context: c.prior_context,
status: CandidateStatus::Pending, status: CandidateStatus::Pending,
context_ids: c.context_ids, context_ids: c.context_ids,
continuation_ids: c.continuation_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); 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() let content_block = Block::default()
.borders(Borders::TOP) .borders(Borders::TOP)
.title(" response "); .title(" context & response ");
let text = match &c.alternate_text { let mut text = String::new();
Some(alt) => format!(" {}\n\n─── without memories ───\n\n {}", c.response_text, alt), if !c.prior_context.is_empty() {
None => format!(" {}", c.response_text), 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) let content = Paragraph::new(text)
.block(content_block) .block(content_block)