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:
Dhanji R. Prasanna
2026-01-11 16:41:41 +05:30
parent 83c9b5d434
commit cf3727f50d
4 changed files with 471 additions and 417 deletions

View 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
}
}