// learn.rs — F6: fine-tuning review screen // // Shows responses identified as training candidates (high divergence // when memories stripped). Queue for review before sending to /finetune. use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, }; use ratatui::crossterm::event::{Event, KeyCode, KeyEvent}; use super::{App, ScreenView, screen_legend}; /// A candidate response identified for fine-tuning. #[derive(Clone, Debug)] pub struct FinetuneCandidate { /// Index in conversation entries. pub entry_idx: usize, /// Divergence score (higher = more dependent on memories). pub divergence: f64, /// The assistant response text. pub response_text: String, /// Status: pending, approved, rejected, sent. pub status: CandidateStatus, /// Token IDs for context. pub context_ids: Vec, /// Token IDs for continuation (what we're training on). pub continuation_ids: Vec, /// What the model would have said without memories (if generated). pub alternate_text: Option, /// Timestamp in nanos — used as unique key for trained-set dedup. pub timestamp_ns: i64, } #[derive(Clone, Debug, PartialEq)] pub enum CandidateStatus { Pending, Approved, Rejected, Sent, } impl From for FinetuneCandidate { fn from(c: crate::subconscious::learn::FinetuneCandidate) -> Self { FinetuneCandidate { entry_idx: c.entry_idx, divergence: c.divergence, response_text: c.response_text, status: CandidateStatus::Pending, context_ids: c.context_ids, continuation_ids: c.continuation_ids, alternate_text: c.alternate_text, timestamp_ns: c.timestamp_ns, } } } pub(crate) struct LearnScreen { list_state: ListState, } impl LearnScreen { pub fn new() -> Self { Self { list_state: ListState::default(), } } fn selected_idx(&self) -> Option { self.list_state.selected() } } impl ScreenView for LearnScreen { fn label(&self) -> &'static str { "learn" } fn tick(&mut self, frame: &mut Frame, area: Rect, events: &[Event], app: &mut App) { // Handle input first (before borrowing candidates for rendering) let candidate_count = app.finetune_candidates.len(); for event in events { if let Event::Key(KeyEvent { code, .. }) = event { match code { KeyCode::Up | KeyCode::Char('k') => { let i = self.list_state.selected().unwrap_or(0); self.list_state.select(Some(i.saturating_sub(1))); } KeyCode::Down | KeyCode::Char('j') => { let i = self.list_state.selected().unwrap_or(0); let max = candidate_count.saturating_sub(1); self.list_state.select(Some((i + 1).min(max))); } KeyCode::Char('a') => { if let Some(idx) = self.selected_idx() { app.finetune_action(idx, CandidateStatus::Approved); } } KeyCode::Char('r') => { if let Some(idx) = self.selected_idx() { app.finetune_action(idx, CandidateStatus::Rejected); } } KeyCode::Char('g') => { // Toggle alternate generation and persist let current = crate::subconscious::learn::alternates_enabled(); crate::subconscious::learn::set_alternates(!current); } KeyCode::Char('s') => { app.finetune_send_approved(); } _ => {} } } } // Ensure selection is valid if candidate_count > 0 { let sel = self.list_state.selected().unwrap_or(0).min(candidate_count - 1); self.list_state.select(Some(sel)); } // Get scoring progress from mind state let progress = app.mind_state.as_ref() .map(|ms| ms.finetune_progress.as_str()) .unwrap_or(""); // Now render let gen_on = crate::subconscious::learn::alternates_enabled(); let title_right = if !progress.is_empty() { format!(" {} ", progress) } else if gen_on { " learn [gen] ".to_string() } else { " learn ".to_string() }; let block = Block::default() .title_top(Line::from(screen_legend()).left_aligned()) .title_top(Line::from(title_right).right_aligned()) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Magenta)); let inner = block.inner(area); frame.render_widget(block, area); let candidates = &app.finetune_candidates; if candidates.is_empty() { let msg = if progress.is_empty() { " No candidates yet — scoring runs after each turn." } else { " Scoring in progress..." }; frame.render_widget( Paragraph::new(Line::styled(msg, Style::default().fg(Color::DarkGray))), inner, ); return; } // Layout: list on left, detail on right let [list_area, detail_area] = Layout::horizontal([ Constraint::Percentage(40), Constraint::Percentage(60), ]).areas(inner); // Render candidate list let items: Vec = candidates.iter().map(|c| { let status_char = match c.status { CandidateStatus::Pending => ' ', CandidateStatus::Approved => '+', CandidateStatus::Rejected => '-', CandidateStatus::Sent => '*', }; let style = match c.status { CandidateStatus::Pending => Style::default(), CandidateStatus::Approved => Style::default().fg(Color::Green), CandidateStatus::Rejected => Style::default().fg(Color::DarkGray), CandidateStatus::Sent => Style::default().fg(Color::Cyan), }; ListItem::new(Line::from(vec![ Span::styled(format!("[{}] ", status_char), style), Span::styled(format!("{:.2} ", c.divergence), Style::default().fg(Color::Yellow)), Span::raw(truncate(&c.response_text, 30)), ])) }).collect(); let list = List::new(items) .block(Block::default().borders(Borders::RIGHT).title(" candidates ")) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); frame.render_stateful_widget(list, list_area, &mut self.list_state); // Render detail for selected candidate if let Some(idx) = self.selected_idx() { if let Some(candidate) = candidates.get(idx) { render_detail(frame, candidate, detail_area); } } // Render help at bottom let help = Line::from(vec![ Span::styled(" j/k/\u{2191}\u{2193}", Style::default().fg(Color::Cyan)), Span::raw("=nav "), Span::styled("a", Style::default().fg(Color::Green)), Span::raw("=approve "), Span::styled("r", Style::default().fg(Color::Red)), Span::raw("=reject "), Span::styled("g", Style::default().fg(Color::Yellow)), Span::raw("=gen "), Span::styled("s", Style::default().fg(Color::Magenta)), Span::raw("=send "), ]); let help_area = Rect { y: area.y + area.height - 1, height: 1, ..area }; frame.render_widget(Paragraph::new(help), help_area); } } fn render_detail(frame: &mut Frame, c: &FinetuneCandidate, area: Rect) { let [header_area, content_area] = Layout::vertical([ Constraint::Length(3), Constraint::Min(1), ]).areas(area); // Header: divergence, status let alt_status = if c.alternate_text.is_some() { "yes" } else { "no" }; let header = Paragraph::new(vec![ Line::from(vec![ Span::raw(" divergence: "), Span::styled(format!("{:.3}", c.divergence), Style::default().fg(Color::Yellow)), Span::raw(format!(" entry: {} alt: {}", c.entry_idx, alt_status)), ]), ]); frame.render_widget(header, header_area); // Content: response and alternate (if available) let content_block = Block::default() .borders(Borders::TOP) .title(" 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 content = Paragraph::new(text) .block(content_block) .wrap(Wrap { trim: false }); frame.render_widget(content, content_area); } fn truncate(s: &str, max: usize) -> String { let first_line = s.lines().next().unwrap_or(""); if first_line.len() > max { format!("{}...", &first_line[..max]) } else { first_line.to_string() } }