innate_core/kb/
situation.rs1use crate::utils::content_hash;
19
20use super::normalize_query;
21
22#[derive(Debug, Clone, Default)]
24pub struct Situation<'a> {
25 pub query: Option<&'a str>,
27 pub last_error: Option<&'a str>,
29 pub recent_actions: &'a [String],
31 pub stage: Option<&'a str>,
33 pub file_context: Option<&'a str>,
35}
36
37impl<'a> Situation<'a> {
38 pub fn from_query(query: &'a str) -> Self {
41 Situation {
42 query: Some(query),
43 ..Default::default()
44 }
45 }
46
47 fn is_query_only(&self) -> bool {
50 self.last_error.map(str::trim).unwrap_or("").is_empty()
51 && self.recent_actions.iter().all(|a| a.trim().is_empty())
52 && self.stage.map(str::trim).unwrap_or("").is_empty()
53 && self.file_context.map(str::trim).unwrap_or("").is_empty()
54 }
55
56 pub fn embed_text(&self) -> String {
62 let query = self.query.map(str::trim).unwrap_or("");
63 if self.is_query_only() {
64 return query.to_string();
65 }
66 let mut parts: Vec<String> = Vec::new();
67 if !query.is_empty() {
68 parts.push(format!("[query] {query}"));
69 }
70 if let Some(err) = self.last_error.map(str::trim).filter(|s| !s.is_empty()) {
71 parts.push(format!("[error] {err}"));
72 }
73 let actions: Vec<&str> = self
74 .recent_actions
75 .iter()
76 .map(|a| a.trim())
77 .filter(|a| !a.is_empty())
78 .collect();
79 if !actions.is_empty() {
80 parts.push(format!("[actions] {}", actions.join(" ; ")));
81 }
82 if let Some(stage) = self.stage.map(str::trim).filter(|s| !s.is_empty()) {
83 parts.push(format!("[stage] {stage}"));
84 }
85 if let Some(files) = self.file_context.map(str::trim).filter(|s| !s.is_empty()) {
86 parts.push(format!("[files] {files}"));
87 }
88 parts.join("\n")
89 }
90
91 pub fn context_key(&self, coarse_keys: &str) -> String {
97 if self.is_query_only() {
98 return content_hash(&normalize_query(self.query.unwrap_or("")));
99 }
100 content_hash(&self.coarse_signature(coarse_keys))
101 }
102
103 pub fn coarse_signature(&self, coarse_keys: &str) -> String {
107 let keys: Vec<&str> = coarse_keys
108 .split(',')
109 .map(str::trim)
110 .filter(|k| !k.is_empty())
111 .collect();
112 let mut parts: Vec<String> = Vec::new();
113 for key in keys {
114 match key {
115 "stage" => parts.push(format!(
116 "stage={}",
117 self.stage.map(str::trim).unwrap_or("").to_lowercase()
118 )),
119 "error_class" => parts.push(format!("err={}", self.error_class())),
120 "file_type" => parts.push(format!("file={}", self.file_type())),
121 _ => {}
123 }
124 }
125 parts.join("|")
126 }
127
128 fn error_class(&self) -> String {
135 let err = self.last_error.map(str::trim).unwrap_or("");
136 if err.is_empty() {
137 return String::new();
138 }
139 if let Some(code) = find_rust_error_code(err) {
141 return code;
142 }
143 if let Some(name) = err
145 .split(|c: char| !(c.is_alphanumeric() || c == '_'))
146 .find(|tok| tok.len() > 3 && (tok.ends_with("Error") || tok.ends_with("Exception")))
147 {
148 return name.to_string();
149 }
150 let low = err.to_lowercase();
151 if low.contains("panic") {
152 return "panic".to_string();
153 }
154 low.split(|c: char| !c.is_alphabetic())
156 .find(|t| !t.is_empty())
157 .map(|t| t.chars().take(24).collect())
158 .unwrap_or_default()
159 }
160
161 fn file_type(&self) -> String {
164 let ctx = self.file_context.map(str::trim).unwrap_or("");
165 if ctx.is_empty() {
166 return String::new();
167 }
168 let token = ctx
170 .split(|c: char| c.is_whitespace() || c == ',')
171 .find(|t| !t.is_empty())
172 .unwrap_or("");
173 match token.rsplit_once('.') {
174 Some((_, ext)) if !ext.is_empty() && ext.len() <= 8 => ext.to_lowercase(),
175 _ => token
176 .rsplit(['/', '\\'])
177 .next()
178 .unwrap_or(token)
179 .to_lowercase(),
180 }
181 }
182}
183
184fn find_rust_error_code(err: &str) -> Option<String> {
186 let bytes = err.as_bytes();
187 let mut i = 0;
188 while i < bytes.len() {
189 if (bytes[i] == b'E' || bytes[i] == b'e')
190 && i + 1 < bytes.len()
191 && bytes[i + 1].is_ascii_digit()
192 {
193 let start = i;
194 let mut j = i + 1;
195 while j < bytes.len() && bytes[j].is_ascii_digit() {
196 j += 1;
197 }
198 if j - (start + 1) >= 3 {
200 return Some(format!("E{}", &err[start + 1..j]));
201 }
202 }
203 i += 1;
204 }
205 None
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn query_only_degrades_to_legacy_key() {
214 let s = Situation::from_query("How to fix the Merge?");
215 let legacy = content_hash(&normalize_query("How to fix the Merge?"));
216 assert_eq!(s.context_key("stage,error_class,file_type"), legacy);
217 assert_eq!(s.embed_text(), "How to fix the Merge?");
219 }
220
221 #[test]
222 fn context_key_stable_across_differing_error_text() {
223 let a = Situation {
225 stage: Some("merge"),
226 last_error: Some("TypeError: cannot read property 'x' of undefined at line 42"),
227 file_context: Some("src/components/Foo.tsx"),
228 ..Default::default()
229 };
230 let b = Situation {
231 stage: Some("merge"),
232 last_error: Some("TypeError: undefined is not a function in handler"),
233 file_context: Some("src/pages/Bar.tsx"),
234 ..Default::default()
235 };
236 let keys = "stage,error_class,file_type";
237 assert_eq!(a.context_key(keys), b.context_key(keys));
238 assert_eq!(
239 a.coarse_signature(keys),
240 "stage=merge|err=TypeError|file=tsx"
241 );
242 }
243
244 #[test]
245 fn differing_class_yields_different_key() {
246 let keys = "stage,error_class,file_type";
247 let a = Situation {
248 stage: Some("merge"),
249 last_error: Some("TypeError: boom"),
250 file_context: Some("a.tsx"),
251 ..Default::default()
252 };
253 let b = Situation {
254 stage: Some("merge"),
255 last_error: Some("RangeError: boom"),
256 file_context: Some("a.tsx"),
257 ..Default::default()
258 };
259 assert_ne!(a.context_key(keys), b.context_key(keys));
260 }
261
262 #[test]
263 fn rust_error_code_classified() {
264 let s = Situation {
265 stage: Some("build"),
266 last_error: Some("error[E0599]: no method named `foo` found"),
267 file_context: Some("src/lib.rs"),
268 ..Default::default()
269 };
270 assert_eq!(s.coarse_signature("error_class"), "err=E0599");
271 }
272
273 #[test]
274 fn embed_text_includes_all_nonempty_fields() {
275 let actions = vec!["git merge".to_string(), "cargo test".to_string()];
276 let s = Situation {
277 query: Some("why did merge fail"),
278 last_error: Some("conflict"),
279 recent_actions: &actions,
280 stage: Some("merge"),
281 file_context: Some("Cargo.toml"),
282 };
283 let text = s.embed_text();
284 assert!(text.contains("[query] why did merge fail"));
285 assert!(text.contains("[error] conflict"));
286 assert!(text.contains("git merge"));
287 assert!(text.contains("[stage] merge"));
288 assert!(text.contains("[files] Cargo.toml"));
289 }
290}