Skip to main content

spool/engine/
mod.rs

1pub mod project_matcher;
2pub mod scenario_matcher;
3pub mod scorer;
4pub mod selector;
5
6#[cfg(feature = "bm25")]
7pub mod bm25;
8
9#[cfg(feature = "embedding")]
10pub mod embedding;
11
12use crate::config::{AppConfig, ProjectConfig};
13use crate::domain::{
14    ContextBundle, DebugTrace, LifecycleCandidate, MemoryRecord, Note, RouteInput, RouteResult,
15};
16use std::collections::HashMap;
17
18pub fn build_context(
19    config: &AppConfig,
20    notes: &[Note],
21    input: RouteInput,
22    debug: DebugTrace,
23) -> ContextBundle {
24    build_context_with_lifecycle(config, notes, &[], input, debug)
25}
26
27pub fn build_context_with_lifecycle(
28    config: &AppConfig,
29    notes: &[Note],
30    lifecycle_records: &[(String, MemoryRecord)],
31    input: RouteInput,
32    debug: DebugTrace,
33) -> ContextBundle {
34    build_context_with_lifecycle_and_refs(config, notes, lifecycle_records, input, debug, None)
35}
36
37pub fn build_context_with_lifecycle_and_refs(
38    config: &AppConfig,
39    notes: &[Note],
40    lifecycle_records: &[(String, MemoryRecord)],
41    input: RouteInput,
42    debug: DebugTrace,
43    reference_map: Option<&crate::reference_tracker::ReferenceMap>,
44) -> ContextBundle {
45    let project = project_matcher::match_project(config, &input.cwd);
46    let project_config = project.as_ref().and_then(|matched| {
47        config
48            .projects
49            .iter()
50            .find(|project| project.id == matched.id)
51    });
52    let modules = project_config
53        .map(|project| scenario_matcher::match_modules(project, &input))
54        .unwrap_or_default();
55    let scenes = scenario_matcher::match_scenes(config, &input);
56    let scored_notes = selector::select_scored_notes(
57        project_config,
58        project.as_ref(),
59        &modules,
60        &scenes,
61        notes,
62        &input,
63        config.output.max_notes,
64    );
65    let mut excluded_record_ids = selector::excluded_record_ids_from_scored(&scored_notes);
66    // P5: knowledge 综合页覆盖的源碎片 / 显式 supersedes 关系 也排除。
67    excluded_record_ids.extend(selector::superseded_record_ids(lifecycle_records));
68    let candidates: Vec<crate::domain::CandidateNote> = scored_notes
69        .iter()
70        .map(crate::domain::CandidateNote::from)
71        .collect();
72
73    let lifecycle_candidates = select_lifecycle_with_available_signals(
74        config,
75        project.as_ref(),
76        lifecycle_records,
77        &input,
78        &excluded_record_ids,
79        reference_map,
80    );
81
82    let crystallize_hint = detect_crystallize_hint(&lifecycle_candidates, lifecycle_records);
83
84    let sources = candidates
85        .iter()
86        .map(|candidate| candidate.relative_path.clone())
87        .collect();
88
89    ContextBundle {
90        input,
91        route: RouteResult {
92            project,
93            modules,
94            scenes,
95            candidates,
96            lifecycle_candidates,
97            sources,
98            debug,
99            crystallize_hint,
100        },
101    }
102}
103
104fn select_lifecycle_with_available_signals(
105    config: &AppConfig,
106    project: Option<&crate::domain::MatchedProject>,
107    lifecycle_records: &[(String, MemoryRecord)],
108    input: &RouteInput,
109    excluded_record_ids: &std::collections::HashSet<String>,
110    reference_map: Option<&crate::reference_tracker::ReferenceMap>,
111) -> Vec<crate::domain::LifecycleCandidate> {
112    let limit = config.output.max_lifecycle;
113
114    #[cfg(feature = "embedding")]
115    {
116        let embedding_results = try_embedding_search(config, &input.task, limit * 2);
117        if !embedding_results.is_empty() {
118            return selector::select_lifecycle_candidates_fused(
119                project,
120                lifecycle_records,
121                input,
122                limit,
123                excluded_record_ids,
124                reference_map,
125                #[cfg(feature = "bm25")]
126                None, // BM25 path handled inside fused fn
127                &embedding_results,
128            );
129        }
130    }
131
132    selector::select_lifecycle_candidates(
133        project,
134        lifecycle_records,
135        input,
136        limit,
137        excluded_record_ids,
138        reference_map,
139    )
140}
141
142#[cfg(feature = "embedding")]
143fn try_embedding_search(config: &AppConfig, query: &str, limit: usize) -> Vec<(String, f32)> {
144    if !config.embedding.enabled {
145        return Vec::new();
146    }
147    let index_path = config.embedding.resolved_index_path();
148    if !index_path.exists() {
149        return Vec::new();
150    }
151    let index = match embedding::EmbeddingIndex::load(&index_path) {
152        Ok(idx) => idx,
153        Err(_) => return Vec::new(),
154    };
155    let Some(model) = embedding::cached_model_for(config.embedding.model_id.as_deref()) else {
156        return Vec::new();
157    };
158    match embedding::EmbeddingIndex::embed_query(model, query) {
159        Ok(query_emb) => index.search(&query_emb, limit),
160        Err(_) => Vec::new(),
161    }
162}
163
164pub fn project_config_for_input<'a>(
165    config: &'a AppConfig,
166    cwd: &std::path::Path,
167) -> Option<&'a ProjectConfig> {
168    project_matcher::match_project(config, cwd)
169        .as_ref()
170        .and_then(|matched| {
171            config
172                .projects
173                .iter()
174                .find(|project| project.id == matched.id)
175        })
176}
177
178/// Minimum number of lifecycle candidates sharing a topic before emitting a hint.
179const CRYSTALLIZE_THRESHOLD: usize = 3;
180
181/// Detect whether 3+ selected lifecycle candidates share entities or tags,
182/// indicating they could be merged into a structured knowledge page.
183/// Returns a human-readable hint if a cluster is found, otherwise `None`.
184fn detect_crystallize_hint(
185    candidates: &[LifecycleCandidate],
186    records: &[(String, MemoryRecord)],
187) -> Option<String> {
188    if candidates.len() < CRYSTALLIZE_THRESHOLD {
189        return None;
190    }
191
192    // Build a frequency map of entities and tags across selected candidates.
193    let mut entity_freq: HashMap<String, usize> = HashMap::new();
194    let mut tag_freq: HashMap<String, usize> = HashMap::new();
195
196    for candidate in candidates {
197        if let Some((_, record)) = records.iter().find(|(id, _)| id == &candidate.record_id) {
198            for entity in &record.entities {
199                let key = entity.to_lowercase();
200                *entity_freq.entry(key).or_insert(0) += 1;
201            }
202            for tag in &record.tags {
203                let key = tag.to_lowercase();
204                *tag_freq.entry(key).or_insert(0) += 1;
205            }
206        }
207    }
208
209    // Find the most shared entity or tag that meets the threshold.
210    let best_entity = entity_freq
211        .iter()
212        .filter(|(_, count)| **count >= CRYSTALLIZE_THRESHOLD)
213        .max_by_key(|(_, count)| *count);
214
215    let best_tag = tag_freq
216        .iter()
217        .filter(|(_, count)| **count >= CRYSTALLIZE_THRESHOLD)
218        .max_by_key(|(_, count)| *count);
219
220    // Pick whichever has the higher frequency; prefer entity on tie.
221    let (topic, count) = match (best_entity, best_tag) {
222        (Some((entity, e_count)), Some((tag, t_count))) => {
223            if e_count >= t_count {
224                (entity.clone(), *e_count)
225            } else {
226                (tag.clone(), *t_count)
227            }
228        }
229        (Some((entity, e_count)), None) => (entity.clone(), *e_count),
230        (None, Some((tag, t_count))) => (tag.clone(), *t_count),
231        (None, None) => return None,
232    };
233
234    Some(format!(
235        "{count} fragments share topic \"{topic}\" — consider running `memory consolidate` to crystallize them into a knowledge page"
236    ))
237}