Skip to main content

scc_cli/
embed_cli.rs

1//! Thin CLI shell: `cmd_embed` renders `embeddings.build`; the scorer,
2//! reranker, policy, and constructor live in `scc_engine::inference`
3//! (single implementation — DoD 5: no transport reimplements ranking).
4
5use std::path::Path;
6
7/// `scc embed` — terminal rendering over the `embeddings.build` operation.
8// trace:v1 id=impl.scc-cli-embed-cli work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
9pub fn cmd_embed(root: &Path) -> crate::Result<()> {
10    let out = scc_engine::invoke(root, "embeddings.build", serde_json::json!({}))
11        .map_err(|e| crate::CliError::Other(e.to_string()))?;
12    println!(
13        "embedding with model '{}' stored {} embeddings",
14        out.get("model").and_then(|m| m.as_str()).unwrap_or(""),
15        out.get("stored").and_then(|n| n.as_u64()).unwrap_or(0),
16    );
17    Ok(())
18}
19
20/// Back-compat re-exports: keep external `scc_cli::embed_cli::` paths
21/// compiling during migration (single implementation in the engine).
22pub use scc_engine::inference::{EmbeddingScorer, EngineReranker as CliReranker, rankers, remote_inference_allowed};
23
24#[cfg(test)]
25mod tests {
26    use super::*;
27    use scc_context::rank::{Reranker, ScoredEntity, SemanticScorer};
28    use scc_indexer::embed::EmbedConfig;
29    use scc_store::Store;
30
31    fn tmp_store() -> (Store, tempfile::TempDir) {
32        let dir = tempfile::TempDir::new().unwrap();
33        let root = dir.path().join("repo");
34        std::fs::create_dir_all(&root).unwrap();
35        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
36        (store, dir)
37    }
38
39    #[test]
40    fn reranker_degrades_without_model() {
41        let cfg = EmbedConfig {
42            base_url: "http://127.0.0.1:1".into(),
43            model: "m".into(),
44            api_key: None,
45            rerank_model: None,
46        };
47        let rr = CliReranker::new(&cfg);
48        let mut cands = vec![ScoredEntity {
49            id: "a".into(),
50            kind: "symbol".into(),
51            name: "x".into(),
52            score: 1.0,
53            reason: "lexical".into(),
54        }];
55        rr.rerank("goal", &mut cands);
56        assert_eq!(cands.len(), 1); // no panic, no reorder
57    }
58
59    #[test]
60    fn remote_policy_fails_closed() {
61        // loopback: allowed with inference.enabled alone
62        let mut local = scc_indexer::Config::default();
63        local.inference.enabled = true;
64        local.inference.base_url = "http://127.0.0.1:11434/v1".into();
65        assert!(remote_inference_allowed(&local));
66
67        // remote endpoint: blocked unless allow_remote_models is set
68        let mut remote = local.clone();
69        remote.inference.base_url = "https://api.openai.com/v1".into();
70        assert!(!remote_inference_allowed(&remote), "remote must fail closed");
71        remote.security.allow_remote_models = true;
72        assert!(remote_inference_allowed(&remote));
73
74        // inference disabled: nothing allowed
75        let mut off = remote.clone();
76        off.inference.enabled = false;
77        assert!(!remote_inference_allowed(&off));
78
79        // empty base_url resolves to the local ollama default
80        let mut local2 = scc_indexer::Config::default();
81        local2.inference.enabled = true;
82        local2.inference.provider = "local".into();
83        assert!(remote_inference_allowed(&local2));
84    }
85
86    #[test]
87    fn remote_classification_covers_common_hosts() {
88        let mk = |base_url: &str| EmbedConfig {
89            base_url: base_url.into(),
90            model: "m".into(),
91            api_key: None,
92            rerank_model: None,
93        };
94        assert!(!mk("http://127.0.0.1:11434/v1").is_remote());
95        assert!(!mk("http://localhost:11434").is_remote());
96        assert!(!mk("http://[::1]:11434/v1").is_remote());
97        assert!(!mk("http://0.0.0.0:8080").is_remote());
98        assert!(mk("https://api.openai.com/v1").is_remote());
99        assert!(mk("https://gateway.example/v1").is_remote());
100        assert!(mk("http://192.168.1.10:8080").is_remote());
101    }
102
103    #[test]
104// trace:exempt reason=unit-test
105    fn scorer_uses_stored_embeddings() {
106        let (store, _d) = tmp_store();
107        let mut e = scc_core::Entity::new("repo://r/symbol/a.py/boosted", "symbol", "boosted");
108        e.attr("file", serde_json::json!("a.py"));
109        store.insert_entity(&e, &["a.py".into()]).unwrap();
110        // store a vector aligned with a goal vector [1,0,0...]
111        let mut v = vec![0.0f32; 8];
112        v[0] = 1.0;
113        store.put_embedding(&e.id, &v, "test").unwrap();
114        let _cfg = EmbedConfig {
115            base_url: "http://127.0.0.1:1".into(),
116            model: "test".into(),
117            api_key: None,
118            rerank_model: None,
119        };
120        // scorer construction needs to embed the goal — bypass via a
121        // hand-built scorer with a known goal vector
122        let mut m = std::collections::HashMap::new();
123        m.insert(e.id.clone(), v);
124        let scorer = EmbeddingScorer::from_vectors(
125            vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
126            m,
127        );
128assert!((scorer.score("goal", &e) - 1.0).abs() < 1e-6);
129        // unrelated entity scores 0
130        let other = scc_core::Entity::new("repo://r/symbol/a.py/z", "symbol", "z");
131        assert_eq!(scorer.score("goal", &other), 0.0);
132    }
133}