Files
g3/crates/g3-cli/src/coach_feedback.rs
Dhanji R. Prasanna c2aa80647a Remove legacy logs/ directory, consolidate all data under .g3/
This change removes the legacy logs/ directory and consolidates all
session data, error logs, and discovery files under the .g3/ directory.

New directory structure:
- .g3/sessions/<session_id>/session.json - session logs
- .g3/errors/ - error logs (was logs/errors/)
- .g3/background_processes/ - background process logs
- .g3/discovery/ - planner discovery files (was workspace/logs/)

Changes:
- paths.rs: Remove get_logs_dir()/logs_dir(), add get_errors_dir(),
  get_background_processes_dir(), get_discovery_dir()
- session.rs: Anonymous sessions now use .g3/sessions/anonymous_<ts>/
- error_handling.rs: Errors now saved to .g3/errors/
- project.rs: Remove logs_dir() and ensure_logs_dir() methods
- feedback_extraction.rs: Remove logs_dir field and fallback logic
- planner: Use .g3/ for workspace data and .g3/discovery/ for reports
- flock.rs: Look for session metrics in .g3/sessions/
- coach_feedback.rs: Remove fallback to logs/ path
- Update all tests to use new paths
- Update README.md and .gitignore
2026-01-12 18:20:08 +05:30

125 lines
3.7 KiB
Rust

//! 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 {
g3_core::get_session_file(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
}
}