1use crate::error::{Error, Result};
31use serde::{Deserialize, Serialize};
32use std::io::Write;
33use std::process::{Command, Stdio};
34
35pub const MAX_LLM_DRAFTS: usize = 8;
38pub const MAX_GUIDANCE_LEN: usize = 600;
39pub const MAX_SUMMARY_LEN: usize = 200;
40
41pub trait LlmBackend: Send + Sync {
44 fn model(&self) -> &str;
46 fn complete(&self, request: &str) -> Result<String>;
49}
50
51#[derive(Debug, Clone, Serialize)]
56pub struct FindingBrief {
57 pub analyzer: String,
58 pub summary: String,
59 pub target: String,
60 pub severity: String,
61}
62
63#[derive(Debug, Clone, Serialize)]
65pub struct EvidenceItem {
66 pub hash: String,
67 pub grain_type: String,
68 pub text: String,
69}
70
71#[derive(Debug, Clone, Serialize)]
74pub struct LlmRequest<'a> {
75 #[serde(rename = "loop")]
76 pub loop_proto: u8,
77 pub op: &'a str,
78 pub instructions: &'a str,
79 #[serde(skip_serializing_if = "Vec::is_empty")]
80 pub findings: Vec<FindingBrief>,
81 #[serde(skip_serializing_if = "Vec::is_empty")]
82 pub evidence: Vec<EvidenceItem>,
83 #[serde(skip_serializing_if = "Vec::is_empty")]
86 pub rejected: Vec<String>,
87 #[serde(skip_serializing_if = "Vec::is_empty")]
88 pub approved: Vec<String>,
89}
90
91#[derive(Debug, Clone, Deserialize, Default)]
97#[serde(default)]
98pub struct LlmDraft {
99 pub summary: String,
100 pub target: String,
101 pub guidance: String,
102 pub evidence: Vec<String>,
103 pub confidence: f64,
107}
108
109#[derive(Debug, Clone, Deserialize, Default)]
111#[serde(default)]
112pub struct DiscoverResponse {
113 pub recommendations: Vec<LlmDraft>,
114}
115
116#[derive(Debug, Clone, Deserialize, Default)]
118#[serde(default)]
119pub struct EnrichResponse {
120 pub notes: Vec<EnrichNote>,
122}
123
124#[derive(Debug, Clone, Deserialize, Default)]
125#[serde(default)]
126pub struct EnrichNote {
127 pub target: String,
128 pub guidance: String,
129}
130
131#[derive(Debug, Clone, Serialize)]
138pub struct GroundRequest<'a> {
139 #[serde(rename = "loop")]
140 pub loop_proto: u8,
141 pub op: &'a str, pub instructions: &'a str,
143 pub claims: Vec<GroundItem>,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct GroundItem {
148 pub id: usize,
149 pub claim: String,
150 pub evidence: Vec<EvidenceItem>,
151}
152
153#[derive(Debug, Clone, Deserialize, Default)]
154#[serde(default)]
155pub struct GroundResponse {
156 pub results: Vec<GroundResult>,
157}
158
159#[derive(Debug, Clone, Deserialize, Default)]
160#[serde(default)]
161pub struct GroundResult {
162 pub id: usize,
163 pub supported: bool,
164 pub reason: String,
165}
166
167#[derive(Debug, Clone, Serialize)]
173pub struct VerifyRequest<'a> {
174 #[serde(rename = "loop")]
175 pub loop_proto: u8,
176 pub op: &'a str, pub instructions: &'a str,
178 pub findings: Vec<VerifyItem>,
179}
180
181#[derive(Debug, Clone, Serialize)]
182pub struct VerifyItem {
183 pub id: usize,
184 pub summary: String,
185 pub target: String,
186 pub evidence: Vec<EvidenceItem>,
187}
188
189#[derive(Debug, Clone, Deserialize, Default)]
190#[serde(default)]
191pub struct VerifyResponse {
192 pub results: Vec<VerifyResult>,
193}
194
195#[derive(Debug, Clone, Deserialize, Default)]
196#[serde(default)]
197pub struct VerifyResult {
198 pub id: usize,
199 pub keep: bool,
200 pub confidence: f64,
201 pub reason: String,
202}
203
204#[derive(Debug, Clone, Deserialize, Default)]
206#[serde(default)]
207struct ProbeResponse {
208 model: String,
209}
210
211pub struct CommandLlm {
214 argv: Vec<String>,
215 model: String,
216}
217
218impl CommandLlm {
219 pub fn new(cmd: &str, model: Option<&str>) -> Result<Self> {
223 let argv: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
224 if argv.is_empty() {
225 return Err(Error::LlmBackend("--llm-cmd is empty".into()));
226 }
227 let mut me = CommandLlm {
228 argv,
229 model: model.unwrap_or("").to_string(),
230 };
231 let probe = me.run(r#"{"loop":1,"op":"probe"}"#)?;
232 let parsed: ProbeResponse = serde_json::from_str(probe.trim()).map_err(|e| {
233 Error::LlmBackend(format!("--llm-cmd probe did not return JSON with a model: {e}"))
234 })?;
235 if me.model.is_empty() {
236 me.model = if parsed.model.is_empty() {
237 "unspecified".to_string()
238 } else {
239 parsed.model
240 };
241 }
242 Ok(me)
243 }
244
245 fn run(&self, request: &str) -> Result<String> {
246 let mut child = Command::new(&self.argv[0])
247 .args(&self.argv[1..])
248 .stdin(Stdio::piped())
249 .stdout(Stdio::piped())
250 .stderr(Stdio::inherit())
251 .spawn()
252 .map_err(|e| Error::LlmBackend(format!("spawn --llm-cmd {:?}: {e}", self.argv[0])))?;
253 {
254 let mut stdin = child.stdin.take().expect("stdin piped");
255 stdin
256 .write_all(request.as_bytes())
257 .map_err(|e| Error::LlmBackend(format!("write to --llm-cmd: {e}")))?;
258 }
259 let out = child
260 .wait_with_output()
261 .map_err(|e| Error::LlmBackend(format!("--llm-cmd wait: {e}")))?;
262 if !out.status.success() {
263 return Err(Error::LlmBackend(format!(
264 "--llm-cmd exited with {}",
265 out.status
266 )));
267 }
268 String::from_utf8(out.stdout)
269 .map_err(|e| Error::LlmBackend(format!("--llm-cmd stdout not UTF-8: {e}")))
270 }
271}
272
273impl LlmBackend for CommandLlm {
274 fn model(&self) -> &str {
275 &self.model
276 }
277 fn complete(&self, request: &str) -> Result<String> {
278 self.run(request)
279 }
280}
281
282pub fn parse_discover(raw: &str) -> DiscoverResponse {
285 serde_json::from_str(raw.trim()).unwrap_or_default()
286}
287
288pub fn parse_enrich(raw: &str) -> EnrichResponse {
290 serde_json::from_str(raw.trim()).unwrap_or_default()
291}
292
293pub fn parse_ground(raw: &str) -> GroundResponse {
296 serde_json::from_str(raw.trim()).unwrap_or_default()
297}
298
299pub fn parse_verify(raw: &str) -> VerifyResponse {
301 serde_json::from_str(raw.trim()).unwrap_or_default()
302}
303
304pub fn cap(s: &str, max: usize) -> String {
306 if s.chars().count() <= max {
307 s.to_string()
308 } else {
309 s.chars().take(max).collect()
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn parse_discover_drops_garbage() {
319 assert!(parse_discover("not json").recommendations.is_empty());
320 let r = parse_discover(r#"{"recommendations":[{"summary":"s","target":"entity:x/y","evidence":["h1"],"junk":1}]}"#);
321 assert_eq!(r.recommendations.len(), 1);
322 assert_eq!(r.recommendations[0].summary, "s");
323 assert_eq!(r.recommendations[0].evidence, vec!["h1"]);
324 }
325
326 #[test]
327 fn parse_enrich_reads_notes() {
328 let r = parse_enrich(r#"{"notes":[{"target":"entity:a/b","guidance":"g"}]}"#);
329 assert_eq!(r.notes.len(), 1);
330 assert_eq!(r.notes[0].guidance, "g");
331 }
332
333 #[test]
334 fn cap_respects_char_boundaries() {
335 assert_eq!(cap("hello", 3), "hel");
336 assert_eq!(cap("héllo", 2), "hé");
337 assert_eq!(cap("hi", 5), "hi");
338 }
339}