refactor(g3-cli): Extract focused modules from lib.rs for improved readability
Extract three cohesive modules from the monolithic lib.rs (3188 -> 2785 lines): - metrics.rs (147 lines): Turn metrics tracking and histogram generation - TurnMetrics struct - format_elapsed_time() for human-readable durations - generate_turn_histogram() for performance visualization - Added unit tests for core functions - project_files.rs (181 lines): Project file reading utilities - read_agents_config() for AGENTS.md loading - read_project_readme() for README detection - read_project_memory() for .g3/memory.md - extract_readme_heading() for display - Added unit tests - coach_feedback.rs (129 lines): Coach feedback extraction from session logs - extract_from_logs() main entry point - Helper functions for log parsing and text extraction All modules have clear single responsibilities, improved documentation, and maintain identical behavior to the original inline functions. Agent: carmack
This commit is contained in:
129
crates/g3-cli/src/coach_feedback.rs
Normal file
129
crates/g3-cli/src/coach_feedback.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
//! Coach feedback extraction from session logs.
|
||||
//!
|
||||
//! Extracts feedback from the coach agent's session logs for the coach-player loop.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
|
||||
use g3_core::Agent;
|
||||
|
||||
use crate::simple_output::SimpleOutput;
|
||||
use crate::ui_writer_impl::ConsoleUiWriter;
|
||||
|
||||
/// Extract coach feedback by reading from the coach agent's specific log file.
|
||||
///
|
||||
/// Uses the coach agent's session ID to find the exact log file.
|
||||
pub fn extract_from_logs(
|
||||
coach_result: &g3_core::TaskResult,
|
||||
coach_agent: &Agent<ConsoleUiWriter>,
|
||||
output: &SimpleOutput,
|
||||
) -> Result<String> {
|
||||
let session_id = coach_agent
|
||||
.get_session_id()
|
||||
.ok_or_else(|| anyhow::anyhow!("Coach agent has no session ID"))?;
|
||||
|
||||
let log_file_path = resolve_log_path(&session_id);
|
||||
|
||||
// Try to extract from session log
|
||||
if let Some(feedback) = try_extract_from_log(&log_file_path) {
|
||||
output.print(&format!("✅ Extracted coach feedback from session: {}", session_id));
|
||||
return Ok(feedback);
|
||||
}
|
||||
|
||||
// Fallback: use the TaskResult's extract_summary method
|
||||
let fallback = coach_result.extract_summary();
|
||||
if !fallback.is_empty() {
|
||||
output.print(&format!(
|
||||
"✅ Extracted coach feedback from response: {} chars",
|
||||
fallback.len()
|
||||
));
|
||||
return Ok(fallback);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Could not extract coach feedback from session: {}\n\
|
||||
Log file path: {:?}\n\
|
||||
Log file exists: {}\n\
|
||||
Coach result response length: {} chars",
|
||||
session_id,
|
||||
log_file_path,
|
||||
log_file_path.exists(),
|
||||
coach_result.response.len()
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolve the log file path, trying new path first then falling back to old.
|
||||
fn resolve_log_path(session_id: &str) -> std::path::PathBuf {
|
||||
let new_path = g3_core::get_session_file(session_id);
|
||||
if new_path.exists() {
|
||||
new_path
|
||||
} else {
|
||||
Path::new("logs").join(format!("g3_session_{}.json", session_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract feedback from a session log file.
|
||||
///
|
||||
/// Searches backwards for the last assistant message with substantial text content.
|
||||
fn try_extract_from_log(log_file_path: &Path) -> Option<String> {
|
||||
if !log_file_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let log_content = std::fs::read_to_string(log_file_path).ok()?;
|
||||
let log_json: serde_json::Value = serde_json::from_str(&log_content).ok()?;
|
||||
|
||||
let messages = log_json
|
||||
.get("context_window")?
|
||||
.get("conversation_history")?
|
||||
.as_array()?;
|
||||
|
||||
// Search backwards for the last assistant message with text content
|
||||
for msg in messages.iter().rev() {
|
||||
if let Some(feedback) = extract_assistant_text(msg) {
|
||||
return Some(feedback);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract text content from an assistant message.
|
||||
fn extract_assistant_text(msg: &serde_json::Value) -> Option<String> {
|
||||
let role = msg.get("role").and_then(|v| v.as_str())?;
|
||||
if !role.eq_ignore_ascii_case("assistant") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = msg.get("content")?;
|
||||
|
||||
// Handle string content
|
||||
if let Some(content_str) = content.as_str() {
|
||||
return filter_substantial_text(content_str);
|
||||
}
|
||||
|
||||
// Handle array content (native tool calling format)
|
||||
if let Some(content_array) = content.as_array() {
|
||||
for block in content_array {
|
||||
if block.get("type").and_then(|v| v.as_str()) == Some("text") {
|
||||
if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
|
||||
if let Some(result) = filter_substantial_text(text) {
|
||||
return Some(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Filter out empty or very short responses (likely just tool calls).
|
||||
fn filter_substantial_text(text: &str) -> Option<String> {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() && trimmed.len() > 10 {
|
||||
Some(trimmed.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user