1use crate::error::{Error, Result};
31use serde::{Deserialize, Serialize};
32
33pub const MAX_LLM_DRAFTS: usize = 8;
36pub const MAX_GUIDANCE_LEN: usize = 600;
37pub const MAX_SUMMARY_LEN: usize = 200;
38
39pub trait LlmBackend: Send + Sync {
42 fn model(&self) -> &str;
44 fn complete(&self, request: &str) -> Result<String>;
47}
48
49impl<T: LlmBackend + ?Sized> LlmBackend for Box<T> {
52 fn model(&self) -> &str {
53 (**self).model()
54 }
55 fn complete(&self, request: &str) -> Result<String> {
56 (**self).complete(request)
57 }
58}
59
60#[derive(Debug, Clone, Serialize)]
65pub struct FindingBrief {
66 pub analyzer: String,
67 pub summary: String,
68 pub target: String,
69 pub severity: String,
70}
71
72#[derive(Debug, Clone, Serialize)]
74pub struct EvidenceItem {
75 pub hash: String,
76 pub grain_type: String,
77 pub text: String,
78}
79
80#[derive(Debug, Clone, Serialize)]
83pub struct LlmRequest<'a> {
84 #[serde(rename = "loop")]
85 pub loop_proto: u8,
86 pub op: &'a str,
87 pub instructions: &'a str,
88 #[serde(skip_serializing_if = "Vec::is_empty")]
89 pub findings: Vec<FindingBrief>,
90 #[serde(skip_serializing_if = "Vec::is_empty")]
91 pub evidence: Vec<EvidenceItem>,
92 #[serde(skip_serializing_if = "Vec::is_empty")]
95 pub rejected: Vec<String>,
96 #[serde(skip_serializing_if = "Vec::is_empty")]
97 pub approved: Vec<String>,
98}
99
100#[derive(Debug, Clone, Deserialize, Default)]
106#[serde(default)]
107pub struct LlmDraft {
108 pub summary: String,
109 pub target: String,
110 pub guidance: String,
111 pub evidence: Vec<String>,
112 pub confidence: f64,
116}
117
118#[derive(Debug, Clone, Deserialize, Default)]
120#[serde(default)]
121pub struct DiscoverResponse {
122 pub recommendations: Vec<LlmDraft>,
123}
124
125#[derive(Debug, Clone, Deserialize, Default)]
127#[serde(default)]
128pub struct EnrichResponse {
129 pub notes: Vec<EnrichNote>,
131}
132
133#[derive(Debug, Clone, Deserialize, Default)]
134#[serde(default)]
135pub struct EnrichNote {
136 pub target: String,
137 pub guidance: String,
138}
139
140#[derive(Debug, Clone, Serialize)]
147pub struct GroundRequest<'a> {
148 #[serde(rename = "loop")]
149 pub loop_proto: u8,
150 pub op: &'a str, pub instructions: &'a str,
152 pub claims: Vec<GroundItem>,
153}
154
155#[derive(Debug, Clone, Serialize)]
156pub struct GroundItem {
157 pub id: usize,
158 pub claim: String,
159 pub evidence: Vec<EvidenceItem>,
160}
161
162#[derive(Debug, Clone, Deserialize, Default)]
163#[serde(default)]
164pub struct GroundResponse {
165 pub results: Vec<GroundResult>,
166}
167
168#[derive(Debug, Clone, Deserialize, Default)]
169#[serde(default)]
170pub struct GroundResult {
171 pub id: usize,
172 pub supported: bool,
173 pub reason: String,
174}
175
176#[derive(Debug, Clone, Serialize)]
182pub struct VerifyRequest<'a> {
183 #[serde(rename = "loop")]
184 pub loop_proto: u8,
185 pub op: &'a str, pub instructions: &'a str,
187 pub findings: Vec<VerifyItem>,
188}
189
190#[derive(Debug, Clone, Serialize)]
191pub struct VerifyItem {
192 pub id: usize,
193 pub summary: String,
194 pub target: String,
195 pub evidence: Vec<EvidenceItem>,
196}
197
198#[derive(Debug, Clone, Deserialize, Default)]
199#[serde(default)]
200pub struct VerifyResponse {
201 pub results: Vec<VerifyResult>,
202}
203
204#[derive(Debug, Clone, Deserialize, Default)]
205#[serde(default)]
206pub struct VerifyResult {
207 pub id: usize,
208 pub keep: bool,
209 pub confidence: f64,
210 pub reason: String,
211}
212
213#[derive(Debug, Clone, Deserialize, Default)]
215#[serde(default)]
216struct ProbeResponse {
217 model: String,
218}
219
220pub struct CommandLlm {
223 argv: Vec<String>,
224 model: String,
225}
226
227impl CommandLlm {
228 pub fn new(cmd: &str, model: Option<&str>) -> Result<Self> {
232 let argv: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
233 if argv.is_empty() {
234 return Err(Error::LlmBackend("--llm-cmd is empty".into()));
235 }
236 let mut me = CommandLlm {
237 argv,
238 model: model.unwrap_or("").to_string(),
239 };
240 let probe = me.run(r#"{"loop":1,"op":"probe"}"#)?;
241 let parsed: ProbeResponse = serde_json::from_str(probe.trim()).map_err(|e| {
242 Error::LlmBackend(format!("--llm-cmd probe did not return JSON with a model: {e}"))
243 })?;
244 if me.model.is_empty() {
245 me.model = if parsed.model.is_empty() {
246 "unspecified".to_string()
247 } else {
248 parsed.model
249 };
250 }
251 Ok(me)
252 }
253
254 fn run(&self, request: &str) -> Result<String> {
255 let out = crate::proc::run_argv(&self.argv, request, Some(crate::proc::DEFAULT_TIMEOUT))
256 .map_err(|e| Error::LlmBackend(format!("spawn --llm-cmd {:?}: {e}", self.argv[0])))?;
257 if let Some(why) = out.failure("--llm-cmd") {
258 return Err(Error::LlmBackend(why));
259 }
260 String::from_utf8(out.stdout)
261 .map_err(|e| Error::LlmBackend(format!("--llm-cmd stdout not UTF-8: {e}")))
262 }
263}
264
265impl LlmBackend for CommandLlm {
266 fn model(&self) -> &str {
267 &self.model
268 }
269 fn complete(&self, request: &str) -> Result<String> {
270 self.run(request)
271 }
272}
273
274pub fn parse_discover(raw: &str) -> DiscoverResponse {
277 serde_json::from_str(raw.trim()).unwrap_or_default()
278}
279
280pub fn parse_enrich(raw: &str) -> EnrichResponse {
282 serde_json::from_str(raw.trim()).unwrap_or_default()
283}
284
285pub fn parse_ground(raw: &str) -> GroundResponse {
288 serde_json::from_str(raw.trim()).unwrap_or_default()
289}
290
291pub fn parse_verify(raw: &str) -> VerifyResponse {
293 serde_json::from_str(raw.trim()).unwrap_or_default()
294}
295
296pub fn cap(s: &str, max: usize) -> String {
298 if s.chars().count() <= max {
299 s.to_string()
300 } else {
301 s.chars().take(max).collect()
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn parse_discover_drops_garbage() {
311 assert!(parse_discover("not json").recommendations.is_empty());
312 let r = parse_discover(r#"{"recommendations":[{"summary":"s","target":"entity:x/y","evidence":["h1"],"junk":1}]}"#);
313 assert_eq!(r.recommendations.len(), 1);
314 assert_eq!(r.recommendations[0].summary, "s");
315 assert_eq!(r.recommendations[0].evidence, vec!["h1"]);
316 }
317
318 #[test]
319 fn parse_enrich_reads_notes() {
320 let r = parse_enrich(r#"{"notes":[{"target":"entity:a/b","guidance":"g"}]}"#);
321 assert_eq!(r.notes.len(), 1);
322 assert_eq!(r.notes[0].guidance, "g");
323 }
324
325 #[test]
326 fn cap_respects_char_boundaries() {
327 assert_eq!(cap("hello", 3), "hel");
328 assert_eq!(cap("héllo", 2), "hé");
329 assert_eq!(cap("hi", 5), "hi");
330 }
331}