Skip to content

Commit bb89300

Browse files
committed
feat(ai): add AI report generation module
- generate_ai_report() builds a prompt from commit list + stats and calls the configured provider - Supports OpenAI (Bearer auth + json_object response_format), Anthropic (x-api-key header + messages API) and Ollama (local, no auth) - Returns AiReport with: summary (executive), report_markdown (ready to send) and hours_by_area (estimated hours per work area) - Handles JSON responses wrapped in markdown code blocks
1 parent c4a956b commit bb89300

2 files changed

Lines changed: 190 additions & 0 deletions

File tree

src/ai.rs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
use serde::{Deserialize, Serialize};
2+
use serde_json::{json, Value};
3+
4+
use crate::analysis::Report;
5+
use crate::config::AiConfig;
6+
7+
/// Resultado del análisis de IA para un periodo y repositorio.
8+
#[derive(Debug, Clone, Serialize, Deserialize)]
9+
pub struct AiReport {
10+
/// Resumen ejecutivo breve (2-4 líneas).
11+
pub summary: String,
12+
/// Reporte listo para enviar en formato Markdown.
13+
pub report_markdown: String,
14+
/// Estimación de horas por área de trabajo.
15+
pub hours_by_area: std::collections::HashMap<String, f32>,
16+
}
17+
18+
/// Genera un reporte de IA a partir de los commits del `Report`.
19+
/// Devuelve `None` si no hay commits para analizar.
20+
pub fn generate_ai_report(
21+
report: &Report,
22+
ai: &AiConfig,
23+
profile_name: &str,
24+
) -> Result<Option<AiReport>, String> {
25+
if report.commits.is_empty() {
26+
return Ok(None);
27+
}
28+
29+
let commits_text: String = report
30+
.commits
31+
.iter()
32+
.map(|c| {
33+
format!(
34+
"- [{}] {} (+{} -{} líneas, {} archivos)",
35+
c.date, c.message, c.insertions, c.deletions, c.files_changed
36+
)
37+
})
38+
.collect::<Vec<_>>()
39+
.join("\n");
40+
41+
let prompt = format!(
42+
r#"Eres un asistente técnico que analiza actividad de desarrollo de software.
43+
44+
Perfil: {profile_name}
45+
Repositorio: {repo}
46+
Periodo: {period}
47+
Total commits: {total_commits}
48+
Inserciones: {insertions} líneas | Deleciones: {deletions} líneas
49+
50+
Commits:
51+
{commits}
52+
53+
Basándote en los mensajes de commit y las estadísticas, genera un JSON con EXACTAMENTE esta estructura:
54+
{{
55+
"summary": "<resumen ejecutivo del trabajo realizado, 2-4 oraciones en primera persona>",
56+
"report_markdown": "<reporte profesional completo en Markdown, listo para enviar a un cliente o supervisor>",
57+
"hours_by_area": {{
58+
"<área1>": <horas estimadas como número>,
59+
"<área2>": <horas estimadas como número>
60+
}}
61+
}}
62+
63+
Para hours_by_area, clasifica el trabajo en áreas según los commits (backend, frontend, devops, testing, documentación, etc.) y estima horas razonables. Responde SOLO con el JSON, sin texto adicional."#,
64+
profile_name = profile_name,
65+
repo = report.repository,
66+
period = report.period,
67+
total_commits = report.total_commits,
68+
insertions = report.total_insertions,
69+
deletions = report.total_deletions,
70+
commits = commits_text,
71+
);
72+
73+
let response_text = match ai.provider.as_str() {
74+
"openai" => call_openai(&prompt, ai)?,
75+
"anthropic" => call_anthropic(&prompt, ai)?,
76+
"ollama" => call_ollama(&prompt, ai)?,
77+
other => return Err(format!("Provider de IA no soportado: '{}'", other)),
78+
};
79+
80+
parse_ai_response(&response_text)
81+
.map(Some)
82+
.map_err(|e| format!("Error al parsear respuesta de IA: {}\nRespuesta: {}", e, response_text))
83+
}
84+
85+
fn call_openai(prompt: &str, ai: &AiConfig) -> Result<String, String> {
86+
let client = reqwest::blocking::Client::new();
87+
let body = json!({
88+
"model": ai.model,
89+
"messages": [{"role": "user", "content": prompt}],
90+
"response_format": {"type": "json_object"}
91+
});
92+
93+
let resp = client
94+
.post(ai.endpoint())
95+
.header("Authorization", format!("Bearer {}", ai.api_key))
96+
.header("Content-Type", "application/json")
97+
.json(&body)
98+
.send()
99+
.map_err(|e| format!("Error en request a OpenAI: {}", e))?;
100+
101+
if !resp.status().is_success() {
102+
let status = resp.status();
103+
let text = resp.text().unwrap_or_default();
104+
return Err(format!("OpenAI error {}: {}", status, text));
105+
}
106+
107+
let json: Value = resp.json().map_err(|e| format!("Error leyendo respuesta OpenAI: {}", e))?;
108+
json["choices"][0]["message"]["content"]
109+
.as_str()
110+
.map(|s| s.to_string())
111+
.ok_or_else(|| format!("Respuesta inesperada de OpenAI: {}", json))
112+
}
113+
114+
fn call_anthropic(prompt: &str, ai: &AiConfig) -> Result<String, String> {
115+
let client = reqwest::blocking::Client::new();
116+
let body = json!({
117+
"model": ai.model,
118+
"max_tokens": 2048,
119+
"messages": [{"role": "user", "content": prompt}]
120+
});
121+
122+
let resp = client
123+
.post(ai.endpoint())
124+
.header("x-api-key", &ai.api_key)
125+
.header("anthropic-version", "2023-06-01")
126+
.header("Content-Type", "application/json")
127+
.json(&body)
128+
.send()
129+
.map_err(|e| format!("Error en request a Anthropic: {}", e))?;
130+
131+
if !resp.status().is_success() {
132+
let status = resp.status();
133+
let text = resp.text().unwrap_or_default();
134+
return Err(format!("Anthropic error {}: {}", status, text));
135+
}
136+
137+
let json: Value = resp.json().map_err(|e| format!("Error leyendo respuesta Anthropic: {}", e))?;
138+
json["content"][0]["text"]
139+
.as_str()
140+
.map(|s| s.to_string())
141+
.ok_or_else(|| format!("Respuesta inesperada de Anthropic: {}", json))
142+
}
143+
144+
fn call_ollama(prompt: &str, ai: &AiConfig) -> Result<String, String> {
145+
let client = reqwest::blocking::Client::new();
146+
let body = json!({
147+
"model": ai.model,
148+
"messages": [{"role": "user", "content": prompt}],
149+
"stream": false,
150+
"format": "json"
151+
});
152+
153+
let resp = client
154+
.post(ai.endpoint())
155+
.header("Content-Type", "application/json")
156+
.json(&body)
157+
.send()
158+
.map_err(|e| format!("Error en request a Ollama: {}", e))?;
159+
160+
if !resp.status().is_success() {
161+
let status = resp.status();
162+
let text = resp.text().unwrap_or_default();
163+
return Err(format!("Ollama error {}: {}", status, text));
164+
}
165+
166+
let json: Value = resp.json().map_err(|e| format!("Error leyendo respuesta Ollama: {}", e))?;
167+
json["message"]["content"]
168+
.as_str()
169+
.map(|s| s.to_string())
170+
.ok_or_else(|| format!("Respuesta inesperada de Ollama: {}", json))
171+
}
172+
173+
fn parse_ai_response(text: &str) -> Result<AiReport, String> {
174+
// Intentar parsear directamente
175+
if let Ok(report) = serde_json::from_str::<AiReport>(text) {
176+
return Ok(report);
177+
}
178+
179+
// Si la respuesta viene envuelta en un bloque de código ```json ... ```
180+
let cleaned = text
181+
.trim()
182+
.trim_start_matches("```json")
183+
.trim_start_matches("```")
184+
.trim_end_matches("```")
185+
.trim();
186+
187+
serde_json::from_str::<AiReport>(cleaned)
188+
.map_err(|e| format!("JSON inválido: {}", e))
189+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod ai;
12
pub mod analysis;
23
pub mod config;
34

0 commit comments

Comments
 (0)