From 5d9d3ffc5b3d109ed26fd39d0eb51b605b0c96f1 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Thu, 16 Apr 2026 00:34:00 -0400 Subject: [PATCH] learn: wire up /train endpoint for approved candidates When 's' is pressed on the learn screen, approved candidates are now sent to the inference server's /train endpoint. Samples are marked as sent immediately in the UI, and mark_trained() is called after successful API response to prevent re-scoring. Co-Authored-By: Proof of Concept --- src/subconscious/learn.rs | 66 +++++++++++++++++++++++++++++++++++++++ src/user/mod.rs | 31 ++++++++++++++++-- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/subconscious/learn.rs b/src/subconscious/learn.rs index e775693..811db3a 100644 --- a/src/subconscious/learn.rs +++ b/src/subconscious/learn.rs @@ -648,3 +648,69 @@ pub fn node_timestamp_ms(node: &AstNode) -> Option { }?; Some(ts.timestamp_millis()) } + +// ── Training API ──────────────────────────────────────────────── + +/// Training sample for /train endpoint. +#[derive(serde::Serialize)] +struct TrainingSample { + context_ids: Vec, + continuation_ids: Vec, +} + +/// Data needed to send a training sample. +pub struct TrainData { + pub context_ids: Vec, + pub continuation_ids: Vec, + pub timestamp_ms: i64, +} + +/// Send training samples to the server. +/// +/// Returns job_id on success, marks each sample as trained. +pub async fn send_to_train( + samples: Vec, + client: &ApiClient, +) -> anyhow::Result { + if samples.is_empty() { + anyhow::bail!("no samples to train"); + } + + let api_samples: Vec = samples.iter() + .map(|s| TrainingSample { + context_ids: s.context_ids.clone(), + continuation_ids: s.continuation_ids.clone(), + }) + .collect(); + + let body = serde_json::json!({ + "training_data": { + "samples": api_samples, + } + }); + + let http = http_client(); + let url = format!("{}/train", client.base_url()); + let response = http.send_json("POST", &url, &[], &body).await?; + + let status = response.status(); + let result: serde_json::Value = response.json().await?; + + if !status.is_success() { + let msg = result.get("error").and_then(|e| e.as_str()).unwrap_or("unknown error"); + anyhow::bail!("train API HTTP {}: {}", status, msg); + } + + // Mark all samples as trained + for s in &samples { + mark_trained(s.timestamp_ms); + } + + let job_id = result.get("job_id") + .and_then(|j| j.as_str()) + .unwrap_or("unknown") + .to_string(); + + dbglog!("[finetune] sent {} samples, job_id={}", samples.len(), job_id); + Ok(job_id) +} diff --git a/src/user/mod.rs b/src/user/mod.rs index f6991ba..8577ec0 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -165,14 +165,39 @@ impl App { } fn finetune_send_approved(&mut self) { - // TODO: Send approved candidates to /finetune endpoint - // For now, just mark them as sent and record as trained + // Collect approved candidates + let samples: Vec = self.finetune_candidates.iter() + .filter(|c| c.status == learn::CandidateStatus::Approved) + .map(|c| crate::subconscious::learn::TrainData { + context_ids: c.context_ids.clone(), + continuation_ids: c.continuation_ids.clone(), + timestamp_ms: c.timestamp_ms, + }) + .collect(); + + if samples.is_empty() { + return; + } + + // Mark as sent in UI immediately for candidate in &mut self.finetune_candidates { if candidate.status == learn::CandidateStatus::Approved { - crate::subconscious::learn::mark_trained(candidate.timestamp_ms); candidate.status = learn::CandidateStatus::Sent; } } + + // Spawn async task to send to training server + let client = self.agent.client.clone(); + tokio::spawn(async move { + match crate::subconscious::learn::send_to_train(samples, &client).await { + Ok(job_id) => { + dbglog!("[finetune] training started: {}", job_id); + } + Err(e) => { + dbglog!("[finetune] send failed: {:#}", e); + } + } + }); }