1use crate::core::content_chunk::ContentChunk;
15
16#[derive(Debug, Clone)]
18pub struct ExtractedFact {
19 pub origin: crate::core::knowledge::FactOrigin,
20 pub category: String,
21 pub key: String,
22 pub value: String,
23 pub confidence: f32,
24}
25
26pub fn extract_facts(chunks: &[ContentChunk]) -> Vec<ExtractedFact> {
28 let mut facts = Vec::new();
29
30 for chunk in chunks {
31 if !chunk.is_external() {
32 continue;
33 }
34
35 let provider = chunk.provider_id().unwrap_or("unknown");
36 if provider == "emspace" {
42 continue;
43 }
44 let start = facts.len();
45 match chunk.kind {
46 crate::core::bm25_index::ChunkKind::Issue
47 | crate::core::bm25_index::ChunkKind::Ticket => {
48 extract_issue_facts(chunk, provider, &mut facts);
49 }
50 crate::core::bm25_index::ChunkKind::PullRequest => {
51 extract_pr_facts(chunk, provider, &mut facts);
52 }
53 crate::core::bm25_index::ChunkKind::WikiPage => {
54 extract_wiki_facts(chunk, provider, &mut facts);
55 }
56 crate::core::bm25_index::ChunkKind::DbSchema => {
57 extract_db_facts(chunk, provider, &mut facts);
58 }
59 _ => {}
60 }
61 for fact in &mut facts[start..] {
62 fact.origin = chunk
63 .origin
64 .clone()
65 .map_or(crate::core::knowledge::FactOrigin::Unverified, |origin| {
66 crate::core::knowledge::FactOrigin::Provider(Box::new(origin))
67 });
68 }
69 }
70
71 facts
72}
73
74fn extract_issue_facts(chunk: &ContentChunk, provider: &str, facts: &mut Vec<ExtractedFact>) {
75 let state = chunk
76 .metadata
77 .as_ref()
78 .and_then(|m| m["state"].as_str())
79 .unwrap_or("unknown");
80
81 let labels: Vec<&str> = chunk
82 .metadata
83 .as_ref()
84 .and_then(|m| m["labels"].as_array())
85 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
86 .unwrap_or_default();
87
88 let category = if labels.iter().any(|l| {
89 let lower = l.to_lowercase();
90 lower.contains("bug") || lower.contains("defect")
91 }) {
92 "known_bugs"
93 } else if labels.iter().any(|l| {
94 let lower = l.to_lowercase();
95 lower.contains("feature") || lower.contains("enhancement")
96 }) {
97 "known_features"
98 } else {
99 "known_issues"
100 };
101
102 let issue_id = chunk
103 .file_path
104 .rsplit('/')
105 .next()
106 .unwrap_or(&chunk.file_path);
107
108 facts.push(ExtractedFact {
109 origin: crate::core::knowledge::FactOrigin::Unverified,
110 category: category.to_string(),
111 key: format!("{provider}#{issue_id}"),
112 value: format!("{} [{}]", chunk.symbol_name, state),
113 confidence: if state == "open" { 0.9 } else { 0.7 },
114 });
115
116 for ref_path in &chunk.references {
117 facts.push(ExtractedFact {
118 origin: crate::core::knowledge::FactOrigin::Unverified,
119 category: "file_mentions".to_string(),
120 key: ref_path.clone(),
121 value: format!(
122 "Referenced in {} {provider}#{issue_id}: {}",
123 category, chunk.symbol_name
124 ),
125 confidence: 0.85,
126 });
127 }
128}
129
130fn extract_pr_facts(chunk: &ContentChunk, provider: &str, facts: &mut Vec<ExtractedFact>) {
131 let state = chunk
132 .metadata
133 .as_ref()
134 .and_then(|m| m["state"].as_str())
135 .unwrap_or("unknown");
136
137 let pr_id = chunk
138 .file_path
139 .rsplit('/')
140 .next()
141 .unwrap_or(&chunk.file_path);
142
143 facts.push(ExtractedFact {
144 origin: crate::core::knowledge::FactOrigin::Unverified,
145 category: "recent_changes".to_string(),
146 key: format!("{provider}#PR{pr_id}"),
147 value: format!("{} [{}]", chunk.symbol_name, state),
148 confidence: if state == "open" { 0.95 } else { 0.8 },
149 });
150
151 for ref_path in &chunk.references {
152 facts.push(ExtractedFact {
153 origin: crate::core::knowledge::FactOrigin::Unverified,
154 category: "changed_files".to_string(),
155 key: ref_path.clone(),
156 value: format!("Changed in PR {provider}#{pr_id}: {}", chunk.symbol_name),
157 confidence: 0.9,
158 });
159 }
160}
161
162fn extract_wiki_facts(chunk: &ContentChunk, provider: &str, facts: &mut Vec<ExtractedFact>) {
163 let page_id = chunk
164 .file_path
165 .rsplit('/')
166 .next()
167 .unwrap_or(&chunk.file_path);
168
169 facts.push(ExtractedFact {
170 origin: crate::core::knowledge::FactOrigin::Unverified,
171 category: "documentation".to_string(),
172 key: format!("{provider}#{page_id}"),
173 value: chunk.symbol_name.clone(),
174 confidence: 0.85,
175 });
176
177 for ref_path in &chunk.references {
178 facts.push(ExtractedFact {
179 origin: crate::core::knowledge::FactOrigin::Unverified,
180 category: "documented_files".to_string(),
181 key: ref_path.clone(),
182 value: format!("Documented in {provider}#{page_id}: {}", chunk.symbol_name),
183 confidence: 0.8,
184 });
185 }
186}
187
188fn extract_db_facts(chunk: &ContentChunk, provider: &str, facts: &mut Vec<ExtractedFact>) {
189 let table_id = chunk
190 .file_path
191 .rsplit('/')
192 .next()
193 .unwrap_or(&chunk.file_path);
194
195 facts.push(ExtractedFact {
196 origin: crate::core::knowledge::FactOrigin::Unverified,
197 category: "data_model".to_string(),
198 key: format!("{provider}#{table_id}"),
199 value: chunk.symbol_name.clone(),
200 confidence: 0.95,
201 });
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::core::bm25_index::ChunkKind;
208 use crate::core::content_chunk::ContentChunk;
209
210 fn issue_with_labels(id: &str, title: &str, labels: &[&str], refs: Vec<&str>) -> ContentChunk {
211 ContentChunk::from_provider(
212 "github",
213 "issues",
214 id,
215 title,
216 ChunkKind::Issue,
217 format!("Body of {title}"),
218 refs.into_iter().map(String::from).collect(),
219 Some(serde_json::json!({
220 "state": "open",
221 "labels": labels,
222 })),
223 )
224 }
225
226 #[test]
227 fn bug_label_creates_known_bugs_fact() {
228 let chunk = issue_with_labels("42", "Auth crash", &["bug", "p1"], vec!["src/auth.rs"]);
229 let facts = extract_facts(&[chunk]);
230
231 let bug_fact = facts.iter().find(|f| f.category == "known_bugs");
232 assert!(bug_fact.is_some());
233 assert!(bug_fact.unwrap().key.contains("42"));
234 assert!(bug_fact.unwrap().value.contains("Auth crash"));
235 assert!(bug_fact.unwrap().value.contains("[open]"));
236 }
237
238 #[test]
239 fn feature_label_creates_known_features_fact() {
240 let chunk = issue_with_labels("10", "Dark mode", &["enhancement"], vec![]);
241 let facts = extract_facts(&[chunk]);
242
243 assert!(facts.iter().any(|f| f.category == "known_features"));
244 }
245
246 #[test]
247 fn generic_issue_creates_known_issues_fact() {
248 let chunk = issue_with_labels("5", "Question about API", &["question"], vec![]);
249 let facts = extract_facts(&[chunk]);
250
251 assert!(facts.iter().any(|f| f.category == "known_issues"));
252 }
253
254 #[test]
255 fn issue_with_refs_creates_file_mentions() {
256 let chunk = issue_with_labels(
257 "42",
258 "Auth crash",
259 &["bug"],
260 vec!["src/auth.rs", "src/db.rs"],
261 );
262 let facts = extract_facts(&[chunk]);
263
264 let mentions: Vec<_> = facts
265 .iter()
266 .filter(|f| f.category == "file_mentions")
267 .collect();
268 assert_eq!(mentions.len(), 2);
269 assert!(mentions.iter().any(|f| f.key == "src/auth.rs"));
270 assert!(mentions.iter().any(|f| f.key == "src/db.rs"));
271 }
272
273 #[test]
274 fn pr_creates_recent_changes_and_changed_files() {
275 let chunk = ContentChunk::from_provider(
276 "github",
277 "pull_requests",
278 "100",
279 "Fix auth token expiry",
280 ChunkKind::PullRequest,
281 "Fixes token expiry".into(),
282 vec!["src/auth.rs".into()],
283 Some(serde_json::json!({"state": "open"})),
284 );
285
286 let facts = extract_facts(&[chunk]);
287 assert!(facts.iter().any(|f| f.category == "recent_changes"));
288 assert!(
289 facts
290 .iter()
291 .any(|f| f.category == "changed_files" && f.key == "src/auth.rs")
292 );
293 }
294
295 #[test]
296 fn wiki_creates_documentation_facts() {
297 let chunk = ContentChunk::from_provider(
298 "confluence",
299 "wikis",
300 "auth-guide",
301 "Authentication Guide",
302 ChunkKind::WikiPage,
303 "How auth works".into(),
304 vec!["src/auth/mod.rs".into()],
305 None,
306 );
307
308 let facts = extract_facts(&[chunk]);
309 assert!(facts.iter().any(|f| f.category == "documentation"));
310 assert!(facts.iter().any(|f| f.category == "documented_files"));
311 }
312
313 #[test]
314 fn emspace_pages_stay_in_the_space_and_are_not_copied_into_facts() {
315 let page = ContentChunk::from_provider(
316 "emspace",
317 "pages",
318 "core/patterns",
319 "Core: Shell Output Patterns",
320 ChunkKind::WikiPage,
321 "Compresses CLI output".into(),
322 vec!["rust/src/core/patterns/mod.rs".into()],
323 None,
324 );
325 assert!(extract_facts(&[page]).is_empty());
326 }
327
328 #[test]
329 fn db_creates_data_model_facts() {
330 let chunk = ContentChunk::from_provider(
331 "postgres",
332 "schemas",
333 "users",
334 "public.users",
335 ChunkKind::DbSchema,
336 "CREATE TABLE users (id serial, email varchar)".into(),
337 vec![],
338 None,
339 );
340
341 let facts = extract_facts(&[chunk]);
342 assert_eq!(facts.len(), 1);
343 assert_eq!(facts[0].category, "data_model");
344 assert_eq!(facts[0].confidence, 0.95);
345 }
346
347 #[test]
348 fn code_chunks_are_skipped() {
349 let chunk = ContentChunk::from(crate::core::bm25_index::CodeChunk {
350 file_path: "src/main.rs".into(),
351 symbol_name: "main".into(),
352 kind: ChunkKind::Function,
353 start_line: 1,
354 end_line: 5,
355 content: "fn main() {}".into(),
356 tokens: vec![],
357 token_count: 0,
358 });
359
360 let facts = extract_facts(&[chunk]);
361 assert!(facts.is_empty());
362 }
363
364 #[test]
365 fn closed_issues_have_lower_confidence() {
366 let chunk = ContentChunk::from_provider(
367 "github",
368 "issues",
369 "99",
370 "Old bug",
371 ChunkKind::Issue,
372 "Fixed".into(),
373 vec![],
374 Some(serde_json::json!({"state": "closed", "labels": ["bug"]})),
375 );
376
377 let facts = extract_facts(&[chunk]);
378 let fact = facts.iter().find(|f| f.category == "known_bugs").unwrap();
379 assert!(fact.confidence < 0.9);
380 }
381}