diff --git a/Cargo.lock b/Cargo.lock index acf2dd2150..9decbcfc11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -720,7 +720,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codegraph" -version = "2.2.12" +version = "2.2.13" dependencies = [ "anyhow", "camino", @@ -743,9 +743,10 @@ dependencies = [ [[package]] name = "codegraph-api" -version = "2.2.12" +version = "2.2.13" dependencies = [ "anyhow", + "async-trait", "camino", "codegraph-context", "codegraph-core", @@ -761,7 +762,7 @@ dependencies = [ [[package]] name = "codegraph-bench" -version = "2.2.12" +version = "2.2.13" dependencies = [ "anyhow", "camino", @@ -780,7 +781,7 @@ dependencies = [ [[package]] name = "codegraph-binary" -version = "2.2.12" +version = "2.2.13" dependencies = [ "camino", "codegraph-core", @@ -797,7 +798,7 @@ dependencies = [ [[package]] name = "codegraph-context" -version = "2.2.12" +version = "2.2.13" dependencies = [ "async-trait", "camino", @@ -814,7 +815,7 @@ dependencies = [ [[package]] name = "codegraph-core" -version = "2.2.12" +version = "2.2.13" dependencies = [ "async-graphql", "camino", @@ -825,7 +826,7 @@ dependencies = [ [[package]] name = "codegraph-docs" -version = "2.2.12" +version = "2.2.13" dependencies = [ "anyhow", "codegraph-core", @@ -843,7 +844,7 @@ dependencies = [ [[package]] name = "codegraph-extract" -version = "2.2.12" +version = "2.2.13" dependencies = [ "camino", "codegraph-binary", @@ -884,7 +885,7 @@ dependencies = [ [[package]] name = "codegraph-graph" -version = "2.2.12" +version = "2.2.13" dependencies = [ "async-trait", "bincode", @@ -914,7 +915,7 @@ dependencies = [ [[package]] name = "codegraph-graphql" -version = "2.2.12" +version = "2.2.13" dependencies = [ "anyhow", "async-graphql", @@ -940,7 +941,7 @@ dependencies = [ [[package]] name = "codegraph-installer" -version = "2.2.12" +version = "2.2.13" dependencies = [ "anyhow", "camino", @@ -956,7 +957,7 @@ dependencies = [ [[package]] name = "codegraph-mcp" -version = "2.2.12" +version = "2.2.13" dependencies = [ "anyhow", "axum", @@ -980,7 +981,7 @@ dependencies = [ [[package]] name = "codegraph-sboxes" -version = "2.2.12" +version = "2.2.13" dependencies = [ "camino", "codegraph-core", @@ -1002,7 +1003,7 @@ dependencies = [ [[package]] name = "codegraph-source" -version = "2.2.12" +version = "2.2.13" dependencies = [ "async-trait", "camino", @@ -1015,7 +1016,7 @@ dependencies = [ [[package]] name = "codegraph-web" -version = "2.2.12" +version = "2.2.13" dependencies = [ "axum", "mime_guess", diff --git a/Cargo.toml b/Cargo.toml index 57c5300d45..d83d3fbb6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ members = [ ] [workspace.package] -version = "2.2.12" +version = "2.2.13" edition = "2021" rust-version = "1.80" license = "MIT" diff --git a/crates/codegraph-api/Cargo.toml b/crates/codegraph-api/Cargo.toml index a17a99cc89..f6dd0ca657 100644 --- a/crates/codegraph-api/Cargo.toml +++ b/crates/codegraph-api/Cargo.toml @@ -19,6 +19,7 @@ anyhow = { workspace = true } tokio = { workspace = true } [dev-dependencies] +async-trait = { workspace = true } tempfile = "3" camino = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/codegraph-api/src/lib.rs b/crates/codegraph-api/src/lib.rs index d45192fad9..21dc4da5c9 100644 --- a/crates/codegraph-api/src/lib.rs +++ b/crates/codegraph-api/src/lib.rs @@ -4,7 +4,7 @@ //! mới nhất (`ensure_fresh`: rebuild khi version file đổi). Query surface mới //! của semgraph: search/symbol/flow/search_flow/callers/callees/references. -use codegraph_context::ContextRequest; +use codegraph_context::{Candidate, ContextRequest, Judge, RerankInfo}; use codegraph_core::{ CallSiteResult, ClassInfo, DependenciesReport, Error, FileInfo, FlowResult, FullFlowPath, FunctionScope, MemberInfo, ResolveResult, Result, SearchFlowResult, Symbol, SymbolKind, @@ -176,6 +176,16 @@ pub struct ResumeSearchOutcome { pub index_version: u64, } +/// Kết quả `search_symbol_reranked`: symbol kèm xác suất (nếu có). +#[derive(Debug)] +pub struct RerankedSearch { + pub symbols: Vec<(Symbol, Option)>, + /// Tổng số khớp gốc của search (trước rerank) — không phải số phần tử trả về. + pub total: usize, + pub no_match: bool, + pub rerank: Option, +} + /// Kết quả resumable cho search trả `Vec` (`codegraph_references` /// / `codegraph_search_by_call`). Cùng hình dạng [`ResumeSearchOutcome`]. #[derive(Debug)] @@ -331,6 +341,102 @@ impl GraphApi { }) } + /// Symbol search có **rerank bằng Jev** — chỉ dùng cho trang đầu. + /// + /// Pool rộng hơn `limit` (×4, cap `[jev] max_candidates`) để model có lựa + /// chọn, sau đó cắt về `limit`. Rerank phá tính ổn định của phân trang nên + /// chỉ nhận `offset = 0` và không có `resume`; caller phải từ chối hai + /// trường hợp đó trước khi gọi. + /// + /// Fail-open: Jev lỗi/tắt → trả page theo thứ tự lexical kèm + /// `rerank.used = false`. + pub async fn search_symbol_reranked( + &self, + query: &str, + kind: Option, + mode: SymbolMatch, + limit: u32, + timeout_ms: u64, + judge: Option<&dyn Judge>, + ) -> Result { + let cfg = codegraph_core::jev_config(); + let pool_limit = ((limit as usize) * 4) + .min(cfg.max_candidates) + .max(limit as usize) as u32; + let out = self + .search_symbol_paged_resumable( + query, + kind, + mode, + Pagination { + limit: pool_limit, + offset: 0, + }, + None, + timeout_ms, + ) + .await?; + if out.timed_out { + return Ok(RerankedSearch { + symbols: out.page.into_iter().map(|s| (s, None)).collect(), + total: out.total, + no_match: false, + rerank: Some(RerankInfo { + used: false, + reason: Some( + "search timed out — trả page thô, thử lại với timeout_ms lớn hơn".into(), + ), + }), + }); + } + + let total = out.total; + let candidates: Vec = out + .page + .iter() + .map(|s| Candidate::symbol(s, None)) + .collect(); + let outcome = + codegraph_context::rank(query, cfg.repo.as_deref(), &candidates, cfg, judge).await; + if !outcome.used { + return Ok(RerankedSearch { + symbols: out + .page + .into_iter() + .take(limit as usize) + .map(|s| (s, None)) + .collect(), + total, + no_match: false, + rerank: Some(outcome.info()), + }); + } + let probabilities: std::collections::HashMap = + outcome.ranking.iter().copied().collect(); + let by_id: std::collections::HashMap = + out.page.into_iter().map(|s| (s.id, s)).collect(); + let symbols: Vec<(Symbol, Option)> = if outcome.no_match { + Vec::new() + } else { + outcome + .ranking + .iter() + .take(limit as usize) + .filter_map(|(id, _)| { + by_id + .get(id) + .map(|s| (s.clone(), probabilities.get(id).copied())) + }) + .collect() + }; + Ok(RerankedSearch { + symbols, + total, + no_match: outcome.no_match, + rerank: Some(outcome.info()), + }) + } + /// Methods của class (compact projection). pub async fn class_methods(&self, id: u64) -> Vec { self.index().await.list_methods_of_class(id) diff --git a/crates/codegraph-api/tests/api.rs b/crates/codegraph-api/tests/api.rs index dcca101aea..edebc10988 100644 --- a/crates/codegraph-api/tests/api.rs +++ b/crates/codegraph-api/tests/api.rs @@ -780,3 +780,146 @@ async fn search_symbol_paged_resume_paging() { .await .is_err()); } + +/// Judge giả: đảo ngược thứ tự candidate để kiểm tra rerank có thật sự +/// quyết định order, không cần gọi API. +struct ReverseJudge; + +#[async_trait::async_trait] +impl codegraph_context::Judge for ReverseJudge { + async fn rank( + &self, + _query: &str, + _repo: Option<&str>, + candidates: &[codegraph_context::Candidate], + ) -> codegraph_core::Result { + let mut ranking: Vec<(u64, f32)> = candidates.iter().map(|c| (c.id, 0.1)).collect(); + ranking.reverse(); + for (i, (_, p)) in ranking.iter_mut().enumerate() { + *p = 0.9 - (i as f32) * 0.1; + } + Ok(codegraph_context::Verdict { + ranking, + no_match: false, + confidence: Some(0.9), + none_probability: 0.01, + fits: 1.0, + }) + } +} + +#[tokio::test] +async fn search_symbol_reranked_đổi_thứ_tự_và_gắn_xác_suất() { + let dir = tempfile::tempdir().unwrap(); + let db_str = format!( + "sqlite://{}", + dir.path().join("db.sqlite").to_string_lossy() + ); + seed_index(&db_str).await; + let api = api(&db_str).await; + + // Thứ tự lexical (không rerank) để so sánh. + let lexical: Vec = api + .search_symbol_paged_resumable( + "call", + None, + SymbolMatch::Contains, + Pagination { + limit: 2, + offset: 0, + }, + None, + 0, + ) + .await + .unwrap() + .page + .iter() + .map(|s| s.id) + .collect(); + assert_eq!(lexical.len(), 2); + + let out = api + .search_symbol_reranked( + "call", + None, + SymbolMatch::Contains, + 2, + 0, + Some(&ReverseJudge), + ) + .await + .unwrap(); + assert!(out.rerank.as_ref().is_some_and(|r| r.used)); + assert!(!out.no_match); + assert_eq!(out.symbols.len(), 2, "cắt theo limit, không trả cả pool"); + let reranked: Vec = out.symbols.iter().map(|(s, _)| s.id).collect(); + assert_eq!( + reranked, + vec![lexical[1], lexical[0]], + "judge đảo ngược → thứ tự đổi so với lexical" + ); + assert!(out.symbols[0].1.unwrap() > out.symbols[1].1.unwrap()); + assert_eq!(out.total, 2, "total là số khớp gốc, không phải page"); +} + +#[tokio::test] +async fn search_symbol_reranked_no_match_trả_rỗng() { + struct NoneJudge; + + #[async_trait::async_trait] + impl codegraph_context::Judge for NoneJudge { + async fn rank( + &self, + _q: &str, + _r: Option<&str>, + candidates: &[codegraph_context::Candidate], + ) -> codegraph_core::Result { + assert!(!candidates.is_empty()); + Ok(codegraph_context::Verdict { + ranking: Vec::new(), + no_match: true, + confidence: None, + none_probability: 0.9, + fits: 0.0, + }) + } + } + + let dir = tempfile::tempdir().unwrap(); + let db_str = format!( + "sqlite://{}", + dir.path().join("db.sqlite").to_string_lossy() + ); + seed_index(&db_str).await; + let api = api(&db_str).await; + + let out = api + .search_symbol_reranked("call", None, SymbolMatch::Contains, 5, 0, Some(&NoneJudge)) + .await + .unwrap(); + assert!(out.no_match); + assert!(out.symbols.is_empty()); + assert!(out.rerank.as_ref().is_some_and(|r| r.used)); +} + +#[tokio::test] +async fn search_symbol_reranked_tắt_jev_thì_fail_open() { + let dir = tempfile::tempdir().unwrap(); + let db_str = format!( + "sqlite://{}", + dir.path().join("db.sqlite").to_string_lossy() + ); + seed_index(&db_str).await; + let api = api(&db_str).await; + + // Không truyền judge + `[jev] enabled = false` (default) → giữ lexical. + let out = api + .search_symbol_reranked("call", None, SymbolMatch::Contains, 2, 0, None) + .await + .unwrap(); + assert!(!out.rerank.as_ref().unwrap().used); + assert!(!out.no_match); + assert_eq!(out.symbols.len(), 2); + assert!(out.symbols.iter().all(|(_, p)| p.is_none())); +} diff --git a/crates/codegraph-context/src/jev.rs b/crates/codegraph-context/src/jev.rs index f75e3f9931..13ce2c7f75 100644 --- a/crates/codegraph-context/src/jev.rs +++ b/crates/codegraph-context/src/jev.rs @@ -1,4 +1,5 @@ -//! Rerank candidate symbol bằng Jev (TypeSafe SystemOne). +//! Rerank candidate bằng Jev (TypeSafe SystemOne) — dùng chung cho context, +//! symbol search và document search. //! //! Jev không phải chat model: nó nhận `state` + `questions` trả về //! `probabilities`. Ở đây một request gồm: @@ -14,57 +15,81 @@ //! mình luôn tự tin kể cả với danh sách vô nghĩa (đo được: 8 candidate rác → //! `confidence = 1.0`). //! -//! Mọi lỗi (timeout, 4xx, JSON lạ, thiếu token) đều trả `Err` để caller -//! **fail-open**: giữ nguyên hành vi cũ, không làm hỏng `codegraph_context`. +//! Mọi lỗi (timeout, 4xx, JSON lạ, thiếu token) đều trả `used = false` để +//! caller **fail-open**: giữ nguyên thứ tự cũ, không làm hỏng tool. -use crate::rel_path; use async_trait::async_trait; -use codegraph_core::{Error, JevConfig, Result, Symbol, SymbolKind, NONE_OPTION}; +use codegraph_core::{Error, JevConfig, Result, Symbol, NONE_OPTION}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Duration; -/// Một ứng viên gửi cho Jev — chỉ giữ những gì model cần để phân biệt. +/// Một ứng viên gửi cho Jev — mọi nguồn (symbol, doc node) quy về đây. #[derive(Debug, Clone, PartialEq)] pub struct Candidate { pub id: u64, - pub name: String, - pub kind: SymbolKind, - /// Đường dẫn tương đối (đã strip workspace root) — tiết kiệm token. - pub file: String, - pub line: u32, - pub signature: Option, + /// Dòng mô tả chính: kind + tên/khoá + vị trí (file:line hoặc doc path). + pub label: String, + /// Chi tiết thêm: signature (code) hoặc value (doc) — có thể đã bị cắt. + pub detail: Option, } impl Candidate { - /// Dựng từ symbol, strip `strip` khỏi `file` như markdown hiển thị. - pub fn from_symbol(s: &Symbol, strip: Option<&str>) -> Self { + /// Candidate từ symbol code. + pub fn symbol(s: &Symbol, strip: Option<&str>) -> Self { + let sig = s + .signature + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); Self { id: s.id, - name: s.name.clone(), - kind: s.kind, - file: rel_path(&s.file, strip).to_string(), - line: s.line, - signature: s.signature.clone(), + label: format!( + "{} `{}` in `{}:{}`", + s.kind.as_str(), + s.name, + crate::rel_path(&s.file, strip), + s.line + ), + detail: sig, + } + } + + /// Candidate từ doc node. + /// + /// `value` chỉ được đưa vào `detail` khi `cfg.doc_include_values = true`: + /// value giúp chọn đúng key (`spec.replicas` xuất hiện ở nhiều file) nhưng + /// tốn token theo số candidate, nên là opt-in. + pub fn doc( + id: u64, + key_path: &str, + doc_path: &str, + kind: &str, + value: Option<&str>, + cfg: &JevConfig, + ) -> Self { + let detail = if cfg.doc_include_values { + value + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(|v| truncate_chars(v, cfg.doc_value_chars)) + } else { + None + }; + Self { + id, + label: format!("{kind} `{key_path}` in `{doc_path}`"), + detail, } } /// Dòng mô tả đưa vào `criteria` của Choice. fn describe(&self) -> String { - let sig = self - .signature - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| format!(" — {s}")) - .unwrap_or_default(); - format!( - "{} `{}` in `{}:{}`{sig}", - self.kind.as_str(), - self.name, - self.file, - self.line - ) + match &self.detail { + Some(d) => format!("{} — {d}", self.label), + None => self.label.clone(), + } } /// Key option trong request/response — `s` để không phụ thuộc tên. @@ -73,14 +98,65 @@ impl Candidate { } } -/// Kết quả rerank. +fn truncate_chars(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let mut out: String = s.chars().take(max).collect(); + out.push('…'); + out +} + +/// Trạng thái rerank — trả về cho client biết vì sao kết quả trông cũ. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RerankInfo { + /// Jev có thực sự quyết định thứ tự không. + pub used: bool, + /// Lý do khi `used = false` (tắt, thiếu token, timeout...). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// Kết quả một lần rerank, đã fail-open. +#[derive(Debug, Clone)] +pub struct RankOutcome { + pub used: bool, + pub reason: Option, + /// `(id, probability)` sort giảm dần. Rỗng khi `used = false`. + pub ranking: Vec<(u64, f32)>, + pub no_match: bool, + /// `confidence` của Choice (0–1) — chỉ để log, không dùng để quyết định. + pub confidence: Option, +} + +impl RankOutcome { + /// Không rerank được — caller giữ thứ tự cũ. + pub fn unavailable(reason: impl Into) -> Self { + Self { + used: false, + reason: Some(reason.into()), + ranking: Vec::new(), + no_match: false, + confidence: None, + } + } + + /// `RerankInfo` để nhét vào response. + pub fn info(&self) -> RerankInfo { + RerankInfo { + used: self.used, + reason: self.reason.clone(), + } + } +} + +/// Kết quả rerank thô từ model. #[derive(Debug, Clone, PartialEq)] pub struct Verdict { - /// `(symbol_id, probability)` đã sort giảm dần và **đã lọc** dưới ngưỡng. + /// `(id, probability)` đã sort giảm dần. pub ranking: Vec<(u64, f32)>, - /// Không có symbol nào đủ liên quan — caller trả `hits: []`. + /// Không candidate nào đủ liên quan. pub no_match: bool, - /// `confidence` của Choice (0–1) — chỉ để log, không dùng để quyết định. pub confidence: Option, /// Xác suất của option "không có cái nào". pub none_probability: f32, @@ -89,19 +165,19 @@ pub struct Verdict { } impl Verdict { - /// Verdict "không khớp" — dùng khi không gọi được API hoặc pool quá nhỏ. - pub fn no_match(reason: f32) -> Self { + /// Verdict "không khớp" — dùng khi pool rỗng. + pub fn no_match(fits: f32) -> Self { Self { ranking: Vec::new(), no_match: true, confidence: None, none_probability: 1.0, - fits: reason, + fits, } } } -/// Bộ chọn symbol — tách trait để test không cần mạng. +/// Bộ chọn candidate — tách trait để test không cần mạng. // `async_trait` sinh `#[must_use]` trên boxed future, trùng với `#[must_use]` // của `Result` → clippy `double_must_use`. Không phải vấn đề thật. #[allow(clippy::double_must_use)] @@ -116,6 +192,53 @@ pub trait Judge: Send + Sync { ) -> Result; } +/// Chạy rerank với fail-open: dùng `judge` được truyền (test) hoặc tự dựng +/// [`JevJudge`] từ config global. Mọi lỗi → `used = false` + `reason`. +pub async fn rank( + query: &str, + repo: Option<&str>, + candidates: &[Candidate], + cfg: &JevConfig, + judge: Option<&dyn Judge>, +) -> RankOutcome { + if candidates.is_empty() { + return RankOutcome::unavailable("pool rỗng — không có gì để chọn"); + } + let owned; + let judge: &dyn Judge = match judge { + Some(j) => j, + None if cfg.is_ready() => { + owned = JevJudge::new(cfg.clone()); + &owned + } + None => { + return RankOutcome::unavailable(format!( + "jev không sẵn sàng (enabled={}, env `{}` {})", + cfg.enabled, + cfg.api_key_env, + if cfg.api_key().is_some() { + "có" + } else { + "thiếu" + } + )); + } + }; + match judge.rank(query, repo, candidates).await { + Ok(v) => RankOutcome { + used: true, + reason: None, + ranking: v.ranking, + no_match: v.no_match, + confidence: v.confidence, + }, + Err(err) => { + tracing::warn!(error = %err, "Jev rerank thất bại — fail-open"); + RankOutcome::unavailable(format!("jev lỗi: {err}")) + } + } +} + /// `Judge` gọi Jev qua HTTP. pub struct JevJudge { cfg: JevConfig, @@ -182,8 +305,8 @@ fn build_state(query: &str, repo: Option<&str>) -> String { /// Map response → Verdict. Tách khỏi HTTP để test được bằng JSON tĩnh. fn verdict_from_reply(raw: &str, candidates: &[Candidate], cfg: &JevConfig) -> Result { - let reply: Reply = serde_json::from_str(raw) - .map_err(|e| Error::Other(format!("Jev trả JSON không hợp lệ: {e}")))?; + let reply: Reply = + serde_json::from_str(raw).map_err(|e| Error::Other(format!("Jev trả JSON lỗi: {e}")))?; let best = reply .answers .get("best_symbol") @@ -300,11 +423,8 @@ mod tests { fn cand(id: u64, name: &str) -> Candidate { Candidate { id, - name: name.into(), - kind: SymbolKind::Function, - file: "src/lib.rs".into(), - line: 10, - signature: Some(format!("fn {name}()")), + label: format!("function `{name}` in `src/lib.rs:10`"), + detail: Some(format!("fn {name}()")), } } @@ -317,7 +437,7 @@ mod tests { } #[test] - fn verdict_sort_và_lọc_ngưỡng() { + fn verdict_sort_theo_xác_suất() { let cands = vec![cand(1, "alpha"), cand(2, "beta"), cand(3, "gamma")]; let raw = r#"{"answers":{ "best_symbol":{"type":"choice","choice":"s2","confidence":0.8, @@ -397,14 +517,11 @@ mod tests { } #[test] - fn describe_gồm_kind_tên_và_vị_trí() { + fn symbol_describe_gồm_kind_tên_và_vị_trí() { let c = Candidate { id: 7, - name: "load_config".into(), - kind: SymbolKind::Function, - file: "crates/a/src/config.rs".into(), - line: 42, - signature: Some("fn load_config() -> Config".into()), + label: "function `load_config` in `crates/a/src/config.rs:42`".into(), + detail: Some("fn load_config() -> Config".into()), }; assert_eq!( c.describe(), @@ -412,4 +529,74 @@ mod tests { ); assert_eq!(c.key(), "s7"); } + + #[test] + fn doc_candidate_không_có_value_khi_tắt() { + let cfg = JevConfig::default(); + let c = Candidate::doc( + 9, + "spec.replicas", + "deploy/api.yaml", + "Field", + Some("3"), + &cfg, + ); + assert_eq!(c.label, "Field `spec.replicas` in `deploy/api.yaml`"); + assert_eq!(c.detail, None, "mặc định không đưa value (tốn token)"); + } + + #[test] + fn doc_candidate_có_value_và_cắt_theo_config() { + let cfg = JevConfig { + doc_include_values: true, + doc_value_chars: 5, + ..JevConfig::default() + }; + let c = Candidate::doc( + 9, + "spec.replicas", + "deploy/api.yaml", + "Field", + Some("abcdefghij"), + &cfg, + ); + assert_eq!(c.detail.as_deref(), Some("abcde…")); + assert_eq!( + c.describe(), + "Field `spec.replicas` in `deploy/api.yaml` — abcde…" + ); + + let empty = Candidate::doc(10, "a.b", "x.yaml", "Field", Some(" "), &cfg); + assert_eq!(empty.detail, None, "value rỗng → không thêm detail"); + } + + #[test] + fn rank_fail_open_khi_judge_lỗi_hoặc_chưa_ready() { + struct Fail; + #[async_trait] + impl Judge for Fail { + async fn rank(&self, _q: &str, _r: Option<&str>, _c: &[Candidate]) -> Result { + Err(Error::Other("timeout".into())) + } + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let cands = vec![cand(1, "alpha")]; + let out = rt.block_on(rank("q", None, &cands, &cfg(), Some(&Fail))); + assert!(!out.used); + assert!(out.reason.unwrap_or_default().contains("timeout")); + + let mut off = cfg(); + off.enabled = false; + let out = rt.block_on(rank("q", None, &cands, &off, None)); + assert!(!out.used); + assert!(out.reason.unwrap_or_default().contains("enabled=false")); + + let out = rt.block_on(rank("q", None, &[], &cfg(), None)); + assert!(!out.used); + assert!(out.reason.unwrap_or_default().contains("pool rỗng")); + } } diff --git a/crates/codegraph-context/src/lib.rs b/crates/codegraph-context/src/lib.rs index 3ac9eece1d..f5c2fcd3cb 100644 --- a/crates/codegraph-context/src/lib.rs +++ b/crates/codegraph-context/src/lib.rs @@ -10,7 +10,7 @@ mod jev; -pub use jev::{Candidate, JevJudge, Judge, Verdict}; +pub use jev::{rank, Candidate, JevJudge, Judge, RankOutcome, RerankInfo, Verdict}; use codegraph_core::{Error, Result, Symbol, SymbolKind, SymbolMatch}; use codegraph_graph::{Pagination, SharedGraphIndex}; @@ -87,16 +87,6 @@ pub struct MatchScore { pub confidence: Option, } -/// Trạng thái rerank của lần gọi này — để client biết vì sao kết quả trông cũ. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RerankInfo { - /// Jev có thực sự quyết định thứ tự không. - pub used: bool, - /// Lý do khi `used = false` (tắt, thiếu token, timeout...). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextHit { pub symbol: Symbol, @@ -237,40 +227,6 @@ async fn order_candidates( } let cfg = codegraph_core::jev_config(); - let judge_owned; - let judge: Option<&dyn Judge> = match judge { - Some(j) => Some(j), - None if cfg.is_ready() => { - judge_owned = JevJudge::new(cfg.clone()); - Some(&judge_owned) - } - None => None, - }; - let Some(judge) = judge else { - // Tắt hoặc thiếu token: vẫn dùng pool (rộng hơn Contains 1 query) nhưng - // không gọi mạng — hành vi gần nhất với "không có Jev". - let pool = build_pool(idx, req).await?; - let symbols: Vec = pool.into_iter().take(req.limit as usize).collect(); - return Ok(( - symbols, - HashMap::new(), - false, - Some(RerankInfo { - used: false, - reason: Some(format!( - "jev không sẵn sàng (enabled={}, env `{}` {})", - cfg.enabled, - cfg.api_key_env, - if cfg.api_key().is_some() { - "có" - } else { - "thiếu" - } - )), - }), - )); - }; - let pool = build_pool(idx, req).await?; if pool.len() < MIN_POOL { return Ok(( @@ -287,62 +243,68 @@ async fn order_candidates( )); } - let repo = cfg.repo.as_deref().or_else(|| { - req.strip_prefix - .as_deref() - .and_then(|p| p.trim_end_matches('/').rsplit('/').next()) - .filter(|s| !s.is_empty()) - }); + let repo = cfg + .repo + .as_deref() + .or_else(|| repo_from_path(req.strip_prefix.as_deref())); let candidates: Vec = pool .iter() - .map(|s| Candidate::from_symbol(s, req.strip_prefix.as_deref())) + .map(|s| Candidate::symbol(s, req.strip_prefix.as_deref())) .collect(); - match judge.rank(&req.query, repo, &candidates).await { - Ok(verdict) => { - let by_id: HashMap = pool.into_iter().map(|s| (s.id, s)).collect(); - let mut scores = HashMap::new(); - let mut symbols = Vec::new(); - for (id, p) in verdict.ranking { - scores.insert( - id, - MatchScore { - probability: p, - confidence: verdict.confidence, - }, - ); - if symbols.len() >= req.limit as usize { - continue; - } - if let Some(sym) = by_id.get(&id) { - symbols.push(sym.clone()); - } - } - let no_match = verdict.no_match; - Ok(( - if no_match { Vec::new() } else { symbols }, - scores, - no_match, - Some(RerankInfo { - used: true, - reason: None, - }), - )) + let outcome = rank(&req.query, repo, &candidates, cfg, judge).await; + Ok(apply_outcome(outcome, pool, req.limit)) +} + +/// Basename của workspace root — dùng làm tên repo trong `state` khi `[jev] repo` +/// không khai báo. +pub fn repo_from_path(strip_prefix: Option<&str>) -> Option<&str> { + strip_prefix + .and_then(|p| p.trim_end_matches('/').rsplit('/').next()) + .filter(|s| !s.is_empty()) +} + +/// Áp kết quả rerank lên pool: cắt `limit`, gắn probability, fail-open khi +/// `outcome.used = false`. +pub fn apply_outcome( + outcome: RankOutcome, + pool: Vec, + limit: u32, +) -> ( + Vec, + HashMap, + bool, + Option, +) { + let limit = limit as usize; + if !outcome.used { + let symbols: Vec = pool.into_iter().take(limit).collect(); + return (symbols, HashMap::new(), false, Some(outcome.info())); + } + let info = outcome.info(); + let by_id: HashMap = pool.into_iter().map(|s| (s.id, s)).collect(); + let mut scores = HashMap::new(); + let mut symbols = Vec::new(); + for (id, p) in outcome.ranking { + scores.insert( + id, + MatchScore { + probability: p, + confidence: outcome.confidence, + }, + ); + if symbols.len() >= limit { + continue; } - Err(err) => { - tracing::warn!(error = %err, "Jev rerank thất bại — fail-open về thứ tự pool"); - let symbols: Vec = pool.into_iter().take(req.limit as usize).collect(); - Ok(( - symbols, - HashMap::new(), - false, - Some(RerankInfo { - used: false, - reason: Some(format!("jev lỗi: {err}")), - }), - )) + if let Some(sym) = by_id.get(&id) { + symbols.push(sym.clone()); } } + if outcome.no_match { + symbols.clear(); + scores.clear(); + } + (symbols, scores, outcome.no_match, Some(info)) } /// Pool tối thiểu để gọi Jev — dưới mức này coi như "không có gì để chọn". @@ -865,7 +827,7 @@ mod tests { .filter_map(|(name, p)| { _candidates .iter() - .find(|c| c.name == *name) + .find(|c| c.label.contains(&format!("`{name}`"))) .map(|c| (c.id, *p)) }) .collect(), diff --git a/crates/codegraph-context/tests/jev_live.rs b/crates/codegraph-context/tests/jev_live.rs index 669aee8f20..ee632785ae 100644 --- a/crates/codegraph-context/tests/jev_live.rs +++ b/crates/codegraph-context/tests/jev_live.rs @@ -7,16 +7,13 @@ //! Cần `OPENCODE_API_KEY` (hoặc `[jev] api_key_env` trỏ biến khác). use codegraph_context::{Candidate, JevJudge, Judge}; -use codegraph_core::{JevConfig, SymbolKind}; +use codegraph_core::JevConfig; -fn candidate(id: u64, name: &str, kind: SymbolKind, file: &str, sig: &str) -> Candidate { +fn candidate(id: u64, kind: &str, name: &str, file: &str, sig: &str) -> Candidate { Candidate { id, - name: name.into(), - kind, - file: file.into(), - line: 10, - signature: Some(sig.into()), + label: format!("{kind} `{name}` in `{file}:10`"), + detail: Some(sig.into()), } } @@ -41,29 +38,29 @@ async fn jev_chọn_đúng_symbol_và_trả_xác_suất() { let candidates = vec![ candidate( 1, + "function", "validate_file_key", - SymbolKind::Function, "tools/figma-cli/src/figma_lib/utils.py", "def validate_file_key(file_key: str) -> str:", ), candidate( 2, + "method", "clear_embeddings", - SymbolKind::Method, "crates/codegraph-graph/src/storage/sqlite.rs", "async fn clear_embeddings(&mut self) -> Result<()>", ), candidate( 3, + "function", "parse_config", - SymbolKind::Function, "crates/codegraph-extract/src/config.rs", "fn parse_config(text: &str) -> Config", ), candidate( 123, + "function", "unrelated_worker", - SymbolKind::Function, "src/worker.rs", "async fn unrelated_worker(&self)", ), @@ -86,3 +83,66 @@ async fn jev_chọn_đúng_symbol_và_trả_xác_suất() { "phải chọn parse_config" ); } + +/// Cùng key ở nhiều document → chỉ **value** mới phân biệt được. Chạy khi +/// `[jev] doc_include_values = true` (mặc định tắt vì tốn token). +#[tokio::test] +#[ignore = "gọi API thật: cần OPENCODE_API_KEY + mạng"] +async fn jev_chọn_đúng_doc_node_nhờ_value() { + let cfg = JevConfig { + doc_include_values: true, + ..cfg() + }; + assert!(cfg.api_key().is_some(), "thiếu env `{}`", cfg.api_key_env); + + let doc = |id: u64, key: &str, path: &str, kind: &str, value: &str| { + Candidate::doc(id, key, path, kind, Some(value), &cfg) + }; + let candidates = vec![ + doc( + 1, + "spec.rules.host", + "deploy/ingress-public.yaml", + "Field", + "www.example.com", + ), + doc( + 2, + "spec.rules.host", + "deploy/ingress-internal.yaml", + "Field", + "db-staging.internal", + ), + doc( + 3, + "spec.rules.host", + "deploy/ingress-legacy.yaml", + "Field", + "legacy.example.com", + ), + doc( + 4, + "spec.rules.backend.serviceName", + "deploy/ingress-internal.yaml", + "Field", + "db", + ), + ]; + + let verdict = JevJudge::new(cfg.clone()) + .rank( + "which ingress rule routes traffic to the staging database host?", + Some("demo-repo"), + &candidates, + ) + .await + .expect("Jev phải trả được verdict (kiểm tra token/model)"); + + println!("{verdict:#?}"); + assert!(!verdict.no_match, "phải match — có host staging trong pool"); + assert_eq!( + verdict.ranking.first().map(|(id, _)| *id), + Some(2), + "phải chọn host `db-staging.internal`" + ); +} diff --git a/crates/codegraph-core/src/jev.rs b/crates/codegraph-core/src/jev.rs index b82d1aa815..7d74772502 100644 --- a/crates/codegraph-core/src/jev.rs +++ b/crates/codegraph-core/src/jev.rs @@ -44,6 +44,14 @@ pub struct JevConfig { pub min_probability: f32, /// Tên repo đưa vào `state`. `None` → caller tự lấy basename workspace root. pub repo: Option, + /// Đưa **value** của doc node vào mô tả option khi rerank doc search. + /// + /// `key` một mình thường không đủ phân biệt (`spec.replicas` có ở nhiều + /// file); value giúp Jev chọn đúng node nhưng tốn token theo số candidate, + /// nên mặc định TẮT. + pub doc_include_values: bool, + /// Cắt value còn bao nhiêu ký tự khi `doc_include_values = true`. + pub doc_value_chars: usize, } impl Default for JevConfig { @@ -57,6 +65,8 @@ impl Default for JevConfig { max_candidates: 40, min_probability: 0.25, repo: None, + doc_include_values: false, + doc_value_chars: 120, } } } @@ -76,6 +86,8 @@ impl JevConfig { max_candidates: Option, min_probability: Option, repo: Option<&str>, + doc_include_values: Option, + doc_value_chars: Option, ) -> Self { let d = Self::default(); let text = |v: Option<&str>, fallback: &str| { @@ -100,6 +112,10 @@ impl JevConfig { .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string), + doc_include_values: doc_include_values.unwrap_or(d.doc_include_values), + doc_value_chars: doc_value_chars + .filter(|v| *v > 0) + .unwrap_or(d.doc_value_chars), } } @@ -145,7 +161,7 @@ mod tests { #[test] fn from_raw_rỗng_thì_dùng_default_và_tắt() { - let cfg = JevConfig::from_raw(None, None, None, None, None, None, None, None); + let cfg = JevConfig::from_raw(None, None, None, None, None, None, None, None, None, None); assert!(!cfg.enabled); assert_eq!(cfg.endpoint, DEFAULT_ENDPOINT); assert_eq!(cfg.model, DEFAULT_MODEL); @@ -154,6 +170,11 @@ mod tests { assert_eq!(cfg.max_candidates, 40); assert_eq!(cfg.min_probability, 0.25); assert!(cfg.repo.is_none()); + assert!( + !cfg.doc_include_values, + "value doc tắt mặc định (tốn token)" + ); + assert_eq!(cfg.doc_value_chars, 120); } #[test] @@ -167,6 +188,8 @@ mod tests { Some(0), Some(-0.2), Some(" "), + Some(true), + Some(0), ); assert!(cfg.enabled); assert_eq!(cfg.endpoint, DEFAULT_ENDPOINT, "chuỗi rỗng → default"); @@ -176,6 +199,8 @@ mod tests { assert_eq!(cfg.max_candidates, 40, "0 → default"); assert_eq!(cfg.min_probability, 0.25, "ngoài [0,1] → default"); assert!(cfg.repo.is_none(), "chuỗi rỗng → None"); + assert!(cfg.doc_include_values); + assert_eq!(cfg.doc_value_chars, 120, "0 → default"); } #[test] diff --git a/crates/codegraph-extract/src/config.rs b/crates/codegraph-extract/src/config.rs index 05c6de478f..e934ecc436 100644 --- a/crates/codegraph-extract/src/config.rs +++ b/crates/codegraph-extract/src/config.rs @@ -239,6 +239,12 @@ struct JevSection { /// Tên repo trong `state`; rỗng → lấy basename workspace root. #[serde(default)] repo: Option, + /// Đưa value của doc node vào mô tả option khi rerank doc search. + #[serde(default)] + doc_include_values: Option, + /// Số ký tự tối đa của value đưa vào option. + #[serde(default)] + doc_value_chars: Option, } // ── Effect-rule runtime config (MCP `codegraph_config_effects` + GraphQL) ── @@ -546,6 +552,8 @@ impl ExtractConfig { file.jev.max_candidates, file.jev.min_probability, file.jev.repo.as_deref(), + file.jev.doc_include_values, + file.jev.doc_value_chars, ), storage: StorageConfig { kind: file @@ -1209,6 +1217,8 @@ timeout_ms = 500 max_candidates = 12 min_probability = 0.42 repo = "codegraph-rs" +doc_include_values = true +doc_value_chars = 80 "#, ); let jev = fx.load().jev; @@ -1217,6 +1227,8 @@ repo = "codegraph-rs" assert_eq!(jev.timeout_ms, 500); assert_eq!(jev.max_candidates, 12); assert!((jev.min_probability - 0.42).abs() < 1e-6); + assert!(jev.doc_include_values); + assert_eq!(jev.doc_value_chars, 80); assert_eq!(jev.repo.as_deref(), Some("codegraph-rs")); assert_eq!( jev.endpoint, diff --git a/crates/codegraph-mcp/src/lib.rs b/crates/codegraph-mcp/src/lib.rs index b94ea126f1..114ef0ff7c 100644 --- a/crates/codegraph-mcp/src/lib.rs +++ b/crates/codegraph-mcp/src/lib.rs @@ -322,7 +322,8 @@ impl CodegraphServer { ) })?; let depth = args.get("depth").and_then(|v| v.as_u64()).unwrap_or(1) as usize; - tools::dispatch_doc_search(doc_graph, pattern, depth) + let rerank = tools::rerank_from_args(&args); + tools::dispatch_doc_search(doc_graph, pattern, depth, rerank) .await .map_err(|e| McpError::internal_error(e.to_string(), None)) .map(|text| ToolOutput::Text { @@ -360,7 +361,8 @@ impl CodegraphServer { ) })?; let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; - tools::dispatch_doc_search_value(doc_graph, query, limit) + let rerank = tools::rerank_from_args(&args); + tools::dispatch_doc_search_value(doc_graph, query, limit, rerank) .await .map_err(|e| McpError::internal_error(e.to_string(), None)) .map(|text| ToolOutput::Text { @@ -432,7 +434,8 @@ impl CodegraphServer { })?; let depth = args.get("depth").and_then(|v| v.as_u64()).unwrap_or(1) as usize; let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; - tools::dispatch_doc_search_struct(doc_graph, pattern, depth, limit) + let rerank = tools::rerank_from_args(&args); + tools::dispatch_doc_search_struct(doc_graph, pattern, depth, limit, rerank) .await .map_err(|e| McpError::internal_error(e.to_string(), None)) .map(|text| ToolOutput::Text { diff --git a/crates/codegraph-mcp/src/rerank_tests.rs b/crates/codegraph-mcp/src/rerank_tests.rs new file mode 100644 index 0000000000..3413007c8b --- /dev/null +++ b/crates/codegraph-mcp/src/rerank_tests.rs @@ -0,0 +1,247 @@ +use super::*; +use codegraph_context::{RerankInfo, Verdict}; + +fn row(id: u64, key: &str, doc: &str, kind: &str, value: Value) -> Value { + json!({ "id": id, "doc": doc, "key": key, "kind": kind, "value": value }) +} + +fn cfg(value: bool) -> codegraph_core::JevConfig { + codegraph_core::JevConfig { + enabled: true, + doc_include_values: value, + doc_value_chars: 10, + ..codegraph_core::JevConfig::default() + } +} + +#[test] +fn scalar_text_bóc_lớp_scalar_và_bỏ_rỗng() { + assert_eq!( + scalar_text(&json!({"String": "nginx"})).as_deref(), + Some("nginx") + ); + assert_eq!(scalar_text(&json!({"Number": 3.0})).as_deref(), Some("3.0")); + assert_eq!(scalar_text(&json!({"Bool": true})).as_deref(), Some("true")); + assert_eq!(scalar_text(&json!({"Null": null})), None); + assert_eq!(scalar_text(&json!({"String": " "})), None); + assert_eq!(scalar_text(&json!(["x"])), None); +} + +#[test] +fn doc_candidate_chỉ_có_value_khi_bật_config() { + let r = row( + 7, + "spec.replicas", + "deploy/api.yaml", + "Field", + json!({"Number": 3.0}), + ); + + let off = doc_candidate(&r, &cfg(false)).unwrap(); + assert_eq!(off.id, 7); + assert_eq!(off.label, "Field `spec.replicas` in `deploy/api.yaml`"); + assert_eq!(off.detail, None, "mặc định không đưa value (tốn token)"); + + let on = doc_candidate(&r, &cfg(true)).unwrap(); + assert_eq!( + on.detail.as_deref(), + Some("3.0"), + "value giúp chọn đúng key" + ); +} + +#[test] +fn doc_candidate_dùng_path_khi_không_có_key_và_từ_chối_row_thiếu_id() { + let r = json!({ "id": 9, "doc": "a.yaml", "path": "a.b.c", "kind": "Field" }); + assert_eq!( + doc_candidate(&r, &cfg(true)).unwrap().label, + "Field `a.b.c` in `a.yaml`" + ); + assert!(doc_candidate(&json!({ "doc": "a.yaml" }), &cfg(true)).is_none()); +} + +#[test] +fn apply_doc_outcome_fail_open_giữ_thứ_tự_cũ() { + let rows: Vec<(Candidate, Value)> = (1..=4) + .map(|id| { + let v = row(id, "k", "a.yaml", "Field", Value::Null); + let c = doc_candidate(&v, &cfg(false)).unwrap(); + (c, v) + }) + .collect(); + let out = apply_doc_outcome(RankOutcome::unavailable("timeout"), rows, 3); + assert!(!out.no_match); + assert_eq!(out.rows.len(), 3, "fail-open vẫn cắt theo limit"); + assert_eq!(out.rows[0]["id"], 1); + assert_eq!(out.info["used"], false); + assert_eq!(out.info["reason"], "timeout"); +} + +#[test] +fn apply_doc_outcome_no_match_trả_rỗng() { + let v = row(1, "k", "a.yaml", "Field", Value::Null); + let c = doc_candidate(&v, &cfg(false)).unwrap(); + let mut outcome = RankOutcome::unavailable(""); + outcome.used = true; + outcome.reason = None; + outcome.no_match = true; + let out = apply_doc_outcome(outcome, vec![(c, v)], 10); + assert!(out.no_match); + assert!(out.rows.is_empty()); + assert_eq!(out.info["used"], true); +} + +#[test] +fn apply_doc_outcome_sắp_theo_xác_suất_và_gắn_match_score() { + let rows: Vec<(Candidate, Value)> = (1..=3) + .map(|id| { + let v = row(id, "k", "a.yaml", "Field", Value::Null); + let c = doc_candidate(&v, &cfg(false)).unwrap(); + (c, v) + }) + .collect(); + let mut outcome = RankOutcome::unavailable(""); + outcome.used = true; + outcome.reason = None; + outcome.ranking = vec![(3, 0.7), (1, 0.2)]; + let out = apply_doc_outcome(outcome, rows, 2); + assert!(!out.no_match); + assert_eq!(out.rows.len(), 2); + assert_eq!(out.rows[0]["id"], 3); + assert_eq!(out.rows[1]["id"], 1); + assert!((out.rows[0]["match_score"]["probability"].as_f64().unwrap() - 0.7).abs() < 1e-6); + assert!( + out.rows[1].get("idf").is_none(), + "không đụng field khác của row" + ); +} + +#[test] +fn rerank_info_serialize_gọn() { + let info = RerankInfo { + used: false, + reason: None, + }; + assert_eq!( + serde_json::to_value(info).unwrap(), + json!({ "used": false }), + "reason None bị bỏ, không serialize null" + ); + let v = Verdict::no_match(0.0); + assert!(v.no_match); +} + +/// 3 document → pool ≥ `DOC_MIN_POOL` để đi tới nhánh gọi Jev (và fail-open vì +/// `[jev] enabled = false` trong test). +async fn seed_doc() -> Arc { + let dir = tempfile::tempdir().unwrap(); + let files: Vec = ["api.yaml", "worker.yaml", "cache.yaml"] + .iter() + .map(|name| { + let path = dir.path().join(name); + std::fs::write(&path, "spec:\n replicas: 3\n image: nginx\n").unwrap(); + path + }) + .collect(); + let shared = Arc::new(crate::SharedDocGraph::in_memory()); + { + let graph = shared.graph().await; + for path in &files { + graph + .write() + .await + .ingest_file(path.to_str().unwrap(), None) + .await + .unwrap(); + } + } + shared +} + +#[tokio::test] +async fn dispatch_doc_search_value_giữ_array_khi_tắt_rerank() { + let shared = seed_doc().await; + let text = dispatch_doc_search_value(shared, "nginx", 10, Rerank::None) + .await + .unwrap(); + let v: Value = serde_json::from_str(&text).unwrap(); + assert!(v.is_array(), "rerank=none giữ nguyên shape cũ"); + assert_eq!(v.as_array().unwrap().len(), 3, "mỗi document 1 node value"); + assert_eq!(v[0]["key"], "image"); +} + +#[tokio::test] +async fn dispatch_doc_search_value_bọc_object_và_fail_open_khi_jev_tắt() { + let shared = seed_doc().await; + let text = dispatch_doc_search_value(shared, "nginx", 10, Rerank::Jev) + .await + .unwrap(); + let v: Value = serde_json::from_str(&text).unwrap(); + assert!(v["results"].is_array(), "rerank=jev → object có `results`"); + assert_eq!(v["no_match"], false); + assert_eq!(v["rerank"]["used"], false, "jev tắt mặc định → fail-open"); + assert!(v["rerank"]["reason"] + .as_str() + .is_some_and(|r| r.contains("enabled=false"))); + assert_eq!(v["results"][0]["key"], "image", "fail-open giữ thứ tự cũ"); +} + +async fn empty_api(root: &Utf8Path) -> GraphApi { + let dsn = format!("sqlite://{}", root.join("index.db")); + GraphApi::new_with_index(Arc::new( + codegraph_graph::SharedGraphIndex::open(Some(dsn)) + .await + .unwrap(), + )) +} + +#[tokio::test] +async fn search_symbol_rerank_jev_chỉ_nhận_trang_đầu() { + let dir = tempfile::tempdir().unwrap(); + let root = Utf8Path::from_path(dir.path()).unwrap(); + let api = empty_api(root).await; + + for args in [ + json!({"query": "x", "rerank": "jev", "offset": 1}), + json!({"query": "x", "rerank": "jev", "resume": "abc"}), + ] { + let err = dispatch_with_api( + &api, + root, + DetailLevel::Minimal, + OutputStyle::Minimal, + false, + "codegraph_search_symbol", + args, + ) + .await + .unwrap_err(); + assert!( + err.to_string().contains("trang đầu"), + "offset/resume + rerank=jev phải bị từ chối: {err}" + ); + } +} + +#[tokio::test] +async fn search_symbol_không_rerank_vẫn_phân_trang_được() { + let dir = tempfile::tempdir().unwrap(); + let root = Utf8Path::from_path(dir.path()).unwrap(); + let api = empty_api(root).await; + + let out = dispatch_with_api( + &api, + root, + DetailLevel::Minimal, + OutputStyle::Minimal, + false, + "codegraph_search_symbol", + json!({"query": "x", "offset": 1}), + ) + .await + .unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["offset"], 1); + assert!(v["no_match"].is_boolean(), "shape mới có no_match/rerank"); + assert!(v["rerank"].is_null()); +} diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index 172fb70ecf..b6e808f74a 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -1,7 +1,7 @@ use crate::session::{DetailLevel, OutputStyle}; use camino::Utf8Path; use codegraph_api::{GraphApi, Pagination}; -use codegraph_context::{ContextRequest, Format}; +use codegraph_context::{Candidate, ContextRequest, Format, RankOutcome, Rerank}; use codegraph_core::{Error, Result, Symbol, SymbolKind, SymbolMatch}; use codegraph_docs::tokenize::DocToken; use rmcp::model::Tool; @@ -23,6 +23,10 @@ use std::sync::Arc; #[path = "callers_tests.rs"] mod callers_tests; +#[cfg(test)] +#[path = "rerank_tests.rs"] +mod rerank_tests; + struct ToolDef { name: &'static str, desc: &'static str, @@ -236,6 +240,7 @@ fn tool_defs() -> Vec { "offset": { "type": "integer", "default": 0 }, "resume": { "type": "string", "description": "Resume id from a previous timeout (or from a previous response with more pages) — retry the same call with this to continue where it stopped." }, "timeout_ms": { "type": "integer", "default": 20000, "description": "Soft time budget in ms; 0 = no limit. On timeout the tool errors with a resume id." }, + "rerank": { "type": "string", "enum": ["none", "jev"], "default": "none", "description": "\"jev\" = rerank the top candidates with TypeSafe SystemOne and attach `match_score.probability` per hit plus `no_match`/`rerank` in the response. Opt-in (`[jev] enabled = true` + token env), fail-open. First page only (offset=0, no resume); binary rows keep their order." }, "detail": { "type": "string", "enum": ["minimal", "medium", "verbose"], "description": "Symbol detail for this call (overrides session default): minimal = id/name/kind/file/line, medium = + signature, verbose = full Symbol." }, "format": { "type": "string", "enum": ["minimal", "medium"], "description": "Output format for this call (overrides session default): minimal = items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } }, "required": ["query"] }), @@ -360,7 +365,8 @@ fn tool_defs() -> Vec { "Search document nodes by dotted key path (e.g. `spec.replicas` matches nodes under any `spec` → `replicas` chain across all ingested documents). Returns matching node IDs with path, key and value.", json!({ "type": "object", "properties": { "pattern": { "type": "string", "description": "Dotted key path, e.g. `spec.replicas`. Only the last segments need to match at increasing depth." }, - "depth": { "type": "integer", "default": 1, "description": "Search depth (extra levels below the pattern where the chain may still match)." } + "depth": { "type": "integer", "default": 1, "description": "Search depth (extra levels below the pattern where the chain may still match)." }, + "rerank": { "type": "string", "enum": ["none", "jev"], "default": "none", "description": "\"jev\" = rerank matches with TypeSafe SystemOne (KeyRegistry routing) and attach `match_score.probability`; response becomes `{results, no_match, rerank}`. Opt-in (`[jev] enabled = true` + token env), fail-open. Set `[jev] doc_include_values = true` to include node values in the option text (better picks, more tokens)." }, }, "required": ["pattern"] }), ), tool( @@ -368,7 +374,8 @@ fn tool_defs() -> Vec { "Search document nodes whose scalar value (string/number) contains the query substring, case-insensitive. Good for finding images, hosts, ports across Kubernetes manifests / Terraform files.", json!({ "type": "object", "properties": { "query": { "type": "string", "description": "Value substring to search, e.g. `nginx`." }, - "limit": { "type": "integer", "default": 20, "description": "Max results." } + "limit": { "type": "integer", "default": 20, "description": "Max results." }, + "rerank": { "type": "string", "enum": ["none", "jev"], "default": "none", "description": "\"jev\" = rerank matches with TypeSafe SystemOne (KeyRegistry routing) and attach `match_score.probability`; response becomes `{results, no_match, rerank}`. Opt-in (`[jev] enabled = true` + token env), fail-open. Set `[jev] doc_include_values = true` to include node values in the option text (better picks, more tokens)." }, }, "required": ["query"] }), ), tool( @@ -424,7 +431,8 @@ fn tool_defs() -> Vec { json!({ "type": "object", "properties": { "pattern": { "type": "string", "description": "Comma-separated kind labels: MAP, ARRAY, FIELD, INDEX, STRING, NUMBER, BOOL, NULL, ROOT." }, "depth": { "type": "integer", "default": 1, "description": "Search depth." }, - "limit": { "type": "integer", "default": 20, "description": "Max results." } + "limit": { "type": "integer", "default": 20, "description": "Max results." }, + "rerank": { "type": "string", "enum": ["none", "jev"], "default": "none", "description": "\"jev\" = rerank matches with TypeSafe SystemOne (KeyRegistry routing) and attach `match_score.probability`; response becomes `{results, no_match, rerank}`. Opt-in (`[jev] enabled = true` + token env), fail-open. Set `[jev] doc_include_values = true` to include node values in the option text (better picks, more tokens)." }, }, "required": ["pattern"] }), ), // ── Binary tools (dataset riêng .codegraph/binary.sqlite — lazy SQL). @@ -694,11 +702,7 @@ pub async fn dispatch_with_api( limit: args.get("limit").and_then(|v| v.as_u64()).unwrap_or(5) as u32, format: Format::Json, strip_prefix: Some(root.as_str().to_string()), - rerank: args - .get("rerank") - .and_then(|v| v.as_str()) - .and_then(codegraph_context::Rerank::parse) - .unwrap_or(codegraph_context::Rerank::None), + rerank: rerank_from_args(&args), }; // `include_source` đọc qua `Source` trait (đường query), root lấy // từ session — không còn `std::fs` rải rác trong context builder. @@ -783,6 +787,14 @@ pub async fn dispatch_with_api( .get("timeout_ms") .and_then(|v| v.as_u64()) .unwrap_or(20000); + let rerank = rerank_from_args(&args); + if rerank == codegraph_context::Rerank::Jev && (offset > 0 || resume.is_some()) { + return Err(Error::Invalid( + "codegraph_search_symbol: rerank=jev chỉ hỗ trợ trang đầu (offset=0, không `resume`). \ + Bỏ `rerank` để lấy page tiếp, hoặc tăng `limit` để lấy đủ trong một lần." + .into(), + )); + } let code_out = if source == "binary" { // Chỉ binary — bỏ qua code index (tránh scan không cần). None @@ -812,19 +824,43 @@ pub async fn dispatch_with_api( } let detail = detail_from_args(&args, session_detail); let format = format_from_args(&args, session_format); - let total = code_out.as_ref().map_or(0, |out| out.total); - let code_resume = code_out.as_ref().and_then(|out| out.resume.clone()); - let has_more = code_out - .as_ref() - .is_some_and(|out| offset as usize + out.page.len() < out.total); - let results: Vec = code_out - .map(|out| { - out.page - .into_iter() - .map(|s| symbol_json(root.as_str(), &s, detail, format)) - .collect() - }) - .unwrap_or_default(); + let mut no_match = false; + let mut rerank_info: Option = None; + let (results, total, code_resume, has_more): (Vec, usize, Option, bool) = + if rerank == codegraph_context::Rerank::Jev && source != "binary" { + // Rerank chỉ trên code index: thứ tự binary rows không đổi. + let out = api + .search_symbol_reranked(q, kind, mode, limit, timeout_ms, None) + .await?; + let mut rows = Vec::with_capacity(out.symbols.len()); + for (sym, probability) in out.symbols { + let mut row = symbol_json(root.as_str(), &sym, detail, format); + if let (Some(p), Some(map)) = (probability, row.as_object_mut()) { + map.insert("match_score".into(), json!({ "probability": p })); + } + rows.push(row); + } + no_match = out.no_match; + rerank_info = out + .rerank + .map(|r| json!({ "used": r.used, "reason": r.reason })); + (rows, out.total, None, false) + } else { + let total = code_out.as_ref().map_or(0, |out| out.total); + let code_resume = code_out.as_ref().and_then(|out| out.resume.clone()); + let has_more = code_out + .as_ref() + .is_some_and(|out| offset as usize + out.page.len() < out.total); + let rows: Vec = code_out + .map(|out| { + out.page + .into_iter() + .map(|s| symbol_json(root.as_str(), &s, detail, format)) + .collect() + }) + .unwrap_or_default(); + (rows, total, code_resume, has_more) + }; // Nhánh binary: `source: "all"|"binary"` — mở BinaryGraph lazy, // open fail (chưa có dataset) → bỏ phần binary khỏi response. // semantic/hybrid chỉ tồn tại ở code index — binary fallback contains. @@ -865,6 +901,8 @@ pub async fn dispatch_with_api( "has_more": has_more, "resume": code_resume, "binary": binary, + "no_match": no_match, + "rerank": rerank_info, }), ) } @@ -1130,6 +1168,14 @@ fn arg_str<'a>(v: &'a Value, k: &str) -> Result<&'a str> { .and_then(|x| x.as_str()) .ok_or_else(|| Error::Invalid(format!("missing string arg: {k}"))) } +/// `rerank` arg dùng chung cho context / symbol search / doc search. +pub(crate) fn rerank_from_args(args: &Value) -> codegraph_context::Rerank { + args.get("rerank") + .and_then(|v| v.as_str()) + .and_then(codegraph_context::Rerank::parse) + .unwrap_or(codegraph_context::Rerank::None) +} + fn arg_u64(v: &Value, k: &str) -> Result { v.get(k) .and_then(|x| x.as_u64()) @@ -1587,6 +1633,7 @@ pub async fn dispatch_doc_search( doc_graph: Arc, pattern: &str, depth: usize, + rerank: Rerank, ) -> Result { let graph = doc_graph.graph().await; let graph = graph.read().await; @@ -1644,7 +1691,7 @@ pub async fn dispatch_doc_search( }) }) .collect(); - return emit_value("", Value::Array(results)); + return emit_doc_rows(pattern, results, 100, rerank).await; } let hits = graph.search_key_fuzzy(&last, 50); if hits.is_empty() { @@ -1658,6 +1705,7 @@ pub async fn dispatch_doc_search( json!({ "id": h.node.id, "doc": h.node.doc, + "key": h.matched_key, "matched_key": h.matched_key, "score": (h.score * 1000.0).round() / 1000.0, "kind": format!("{:?}", h.node.kind), @@ -1665,7 +1713,7 @@ pub async fn dispatch_doc_search( }) }) .collect(); - return emit_value("", Value::Array(results)); + return emit_doc_rows(pattern, results, 50, rerank).await; } let mut results = Vec::new(); for id in ids.iter().take(100) { @@ -1681,7 +1729,7 @@ pub async fn dispatch_doc_search( })); } } - emit_value("", Value::Array(results)) + emit_doc_rows(pattern, results, 100, rerank).await } /// Search node theo kind chain cấu trúc (vd "MAP, FIELD, NUMBER") — kết quả @@ -1692,6 +1740,7 @@ pub async fn dispatch_doc_search_struct( pattern: &str, depth: usize, limit: usize, + rerank: Rerank, ) -> Result { let graph = doc_graph.graph().await; let graph = graph.read().await; @@ -1731,8 +1780,8 @@ pub async fn dispatch_doc_search_struct( )); } rows.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); - let results: Vec = rows.into_iter().take(limit).map(|(_, v)| v).collect(); - emit_value("", Value::Array(results)) + let results: Vec = rows.into_iter().map(|(_, v)| v).collect(); + emit_doc_rows(pattern, results, limit, rerank).await } /// Mine structural patterns — đếm kind chain trên mọi node lá scalar, cấp @@ -1766,10 +1815,18 @@ pub async fn dispatch_doc_search_value( doc_graph: Arc, query: &str, limit: usize, + rerank: Rerank, ) -> Result { let graph = doc_graph.graph().await; let graph = graph.read().await; - let hits = graph.search_value_substring(query, limit); + // Rerank cần pool rộng hơn `limit` để model có lựa chọn; cap + // `[jev] max_candidates` áp sau trong `rerank_doc_rows`. + let fetch = if rerank == Rerank::Jev { + limit.saturating_mul(4).max(limit) + } else { + limit + }; + let hits = graph.search_value_substring(query, fetch); if hits.is_empty() { return Ok(format!("no scalar values matched `{query}`")); } @@ -1785,7 +1842,150 @@ pub async fn dispatch_doc_search_value( }) }) .collect(); - emit_value("", Value::Array(results)) + emit_doc_rows(query, results, limit, rerank).await +} + +/// Pool tối thiểu để gọi Jev cho doc search — dưới ngưỡng này model không có +/// gì để chọn mà request vẫn tốn token. +const DOC_MIN_POOL: usize = 3; + +/// Kết quả rerank một lô doc row. +struct DocRerank { + rows: Vec, + no_match: bool, + /// Khối `rerank` trả cho client (`used`/`reason`). + info: Value, +} + +/// Rút text từ `value` của doc node — `Scalar` serialize thành +/// `{"String": "..."}` / `{"Number": 3.0}` nên phải bóc lớp ngoài. +fn scalar_text(v: &Value) -> Option { + let text = match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Object(m) => match m.iter().next()? { + (k, inner) if k == "String" => inner.as_str()?.to_string(), + (k, inner) if k == "Number" || k == "Bool" => inner.to_string(), + _ => return None, + }, + _ => return None, + }; + (!text.trim().is_empty()).then_some(text) +} + +/// Dựng `Candidate` từ một row JSON của doc search. +fn doc_candidate(row: &Value, cfg: &codegraph_core::JevConfig) -> Option { + let id = row.get("id")?.as_u64()?; + let key_path = row + .get("path") + .or_else(|| row.get("key")) + .and_then(Value::as_str) + .unwrap_or(""); + let doc_path = row.get("doc").and_then(Value::as_str).unwrap_or(""); + let kind = row.get("kind").and_then(Value::as_str).unwrap_or("node"); + let value = row.get("value").and_then(scalar_text); + Some(Candidate::doc( + id, + key_path, + doc_path, + kind, + value.as_deref(), + cfg, + )) +} + +/// Áp kết quả rerank lên rows: sắp theo xác suất, cắt `limit`, gắn +/// `match_score`. Fail-open khi `outcome.used = false`. +fn apply_doc_outcome( + outcome: RankOutcome, + rows: Vec<(Candidate, Value)>, + limit: usize, +) -> DocRerank { + let info = serde_json::to_value(outcome.info()).unwrap_or(Value::Null); + if !outcome.used { + return DocRerank { + rows: rows.into_iter().take(limit).map(|(_, v)| v).collect(), + no_match: false, + info, + }; + } + if outcome.no_match { + return DocRerank { + rows: Vec::new(), + no_match: true, + info, + }; + } + let by_id: std::collections::HashMap = + rows.into_iter().map(|(c, v)| (c.id, v)).collect(); + let mut out = Vec::new(); + for (id, p) in &outcome.ranking { + if out.len() >= limit { + break; + } + let Some(row) = by_id.get(id) else { continue }; + let mut row = row.clone(); + if let Some(map) = row.as_object_mut() { + map.insert("match_score".into(), json!({ "probability": p })); + } + out.push(row); + } + DocRerank { + rows: out, + no_match: false, + info, + } +} + +/// Rerank một lô doc row bằng Jev (fail-open). +async fn rerank_doc_rows(query: &str, rows: Vec, limit: usize) -> DocRerank { + let cfg = codegraph_core::jev_config(); + let pool: Vec<(Candidate, Value)> = rows + .into_iter() + .filter_map(|row| doc_candidate(&row, cfg).map(|c| (c, row))) + .take(cfg.max_candidates) + .collect(); + if pool.len() < DOC_MIN_POOL { + let n = pool.len(); + return DocRerank { + rows: pool.into_iter().take(limit).map(|(_, v)| v).collect(), + no_match: false, + info: json!({ + "used": false, + "reason": format!("chỉ có {n} candidate (< {DOC_MIN_POOL}) — không gọi Jev"), + }), + }; + } + let candidates: Vec = pool.iter().map(|(c, _)| c.clone()).collect(); + let outcome = codegraph_context::rank(query, cfg.repo.as_deref(), &candidates, cfg, None).await; + apply_doc_outcome(outcome, pool, limit) +} + +/// Emit rows doc search, có rerank khi `rerank = jev`. +/// +/// `rerank = none` giữ nguyên shape cũ (JSON array). Bật `jev` thì bọc thành +/// `{"results": [...], "no_match": ..., "rerank": ...}` để client thấy được +/// vì sao thứ tự đổi (hoặc vì sao Jev bị bỏ qua). +async fn emit_doc_rows( + query: &str, + rows: Vec, + limit: usize, + rerank: Rerank, +) -> Result { + if rerank != Rerank::Jev { + // Giữ đúng shape cũ: array, cắt theo `limit` của tool. + return emit_value("", Value::Array(rows.into_iter().take(limit).collect())); + } + let out = rerank_doc_rows(query, rows, limit).await; + emit_value( + "", + json!({ + "results": out.rows, + "no_match": out.no_match, + "rerank": out.info, + }), + ) } pub async fn dispatch_doc_hydrate( diff --git a/docs/configuration.md b/docs/configuration.md index 6da3922d4b..5f94fca9db 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -76,6 +76,8 @@ type = "sqlite" # "sqlite" | "lmdb" | "redis" | "memory" | "postgres" | "mysql" # max_candidates = 40 # số candidate gửi cho Jev # min_probability = 0.25 # xác suất tối thiểu của symbol hạng 1 # repo = "" # tên repo trong `state`; rỗng → basename workspace root +# doc_include_values = false # đưa value của doc node vào mô tả option (tốn token) +# doc_value_chars = 120 # cắt value còn bao nhiêu ký tự khi bật cờ trên # Document graph — ingest structured docs (yaml/json/toml/hcl/nginx/markdown) [docgraph] @@ -177,8 +179,10 @@ dsns = [ ### `[jev]` -Bật rerank ngữ nghĩa cho `codegraph_context` bằng **Jev** (TypeSafe SystemOne — -một *decision model*, không phải chat model). +Bật rerank ngữ nghĩa bằng **Jev** (TypeSafe SystemOne — một *decision model*, +không phải chat model) cho `codegraph_context`, `codegraph_search_symbol` và ba +tool document search (`codegraph_graphdoc_search`, +`codegraph_graphdoc_search_value`, `codegraph_graphdoc_search_struct`). | Key | Type | Default | Description | |-----|------|---------|-------------| @@ -190,14 +194,24 @@ một *decision model*, không phải chat model). | `max_candidates` | `usize` | `40` | Số candidate tối đa gửi Jev (Choice nhận tối đa 255 options) | | `min_probability` | `f32` | `0.25` | Xác suất tối thiểu của symbol **hạng 1** để coi là khớp | | `repo` | `String` | unset | Tên repo trong `state`; rỗng → basename của workspace root | +| `doc_include_values` | `bool` | `false` | Khi rerank doc: đưa **value** của node vào mô tả option. Key một mình thường không phân biệt được (`spec.replicas` ở nhiều file); giá trị giúp Jev chọn đúng nhưng tốn token theo số candidate | +| `doc_value_chars` | `usize` | `120` | Cắt value còn bao nhiêu ký tự khi `doc_include_values = true` | **Behavior**: - **Off by default.** Chỉ chạy khi `enabled = true` **và** env `api_key_env` có giá trị. -- Bật `rerank = "jev"` ở `codegraph_context` (MCP arg / GraphQL field). Response có - `match_score.probability` cho từng hit và `no_match` khi không symbol nào đủ liên quan. +- Bật `rerank = "jev"` ở MCP arg của `codegraph_context`, `codegraph_search_symbol` + và 3 tool document search. Response có `match_score.probability` cho từng hit và + `no_match` khi không candidate nào đủ liên quan. +- `codegraph_search_symbol` chỉ rerank **trang đầu**: `offset > 0` hoặc có `resume` + → lỗi rõ ràng (rerank phá tính ổn định của phân trang). Pool = `limit × 4` + (cap `max_candidates`), binary rows giữ nguyên thứ tự. +- Doc tool trả **object** `{results, no_match, rerank}` khi `rerank = "jev"` (mặc + định vẫn là array như cũ). Pool rộng hơn `limit` để model có lựa chọn. +- `doc_include_values` mặc định **tắt**: bật khi các node trùng key ở nhiều file và + bạn cần Jev phân biệt bằng giá trị. - Candidate pool = query gốc + từng token của query (`Contains`), lọc kind code (function/method/class/interface/enum/module/file) và **loại test/bench**. -- Pool < 3 candidate → trả `no_match` luôn, không gọi API. +- Pool < 3 candidate → không gọi API (trả thứ tự cũ / `no_match` tuỳ tool). - **Fail-open** toàn bộ: timeout, 4xx, JSON lỗi, thiếu token → giữ thứ tự cũ, kèm `rerank.used = false` và `reason`. - Quyết định "không khớp" dựa trên option `none_of_the_above` thắng xác suất candidate diff --git a/packaging/aur/codegraph-rs-bin/PKGBUILD b/packaging/aur/codegraph-rs-bin/PKGBUILD index 5e2f6af01c..819472dd83 100644 --- a/packaging/aur/codegraph-rs-bin/PKGBUILD +++ b/packaging/aur/codegraph-rs-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Hung Pham pkgname=codegraph-rs-bin -pkgver=2.2.12 +pkgver=2.2.13 pkgrel=1 pkgdesc="Local-first code intelligence: tree-sitter knowledge graph + MCP server (prebuilt binary)" arch=('x86_64' 'aarch64') diff --git a/packaging/choco/codegraph.nuspec b/packaging/choco/codegraph.nuspec index 8f08f8f7c1..322566aefc 100644 --- a/packaging/choco/codegraph.nuspec +++ b/packaging/choco/codegraph.nuspec @@ -2,7 +2,7 @@ codegraph - 2.2.12 + 2.2.13 codegraph Hung Pham https://github.com/hungpham10/codegraph-rs diff --git a/packaging/winget/codegraph.yaml b/packaging/winget/codegraph.yaml index 5cf9ff77dc..d8be949273 100644 --- a/packaging/winget/codegraph.yaml +++ b/packaging/winget/codegraph.yaml @@ -6,7 +6,7 @@ # release time (or automate it in the release pipeline before submitting to # microsoft/winget-pkgs). PackageIdentifier: hungpham10.codegraph -PackageVersion: 2.2.12 +PackageVersion: 2.2.13 PackageName: codegraph Publisher: Hung Pham PublisherUrl: https://github.com/hungpham10/codegraph-rs @@ -18,7 +18,7 @@ PackageUrl: https://github.com/hungpham10/codegraph-rs InstallerType: zip Installers: - Architecture: x64 - InstallerUrl: https://github.com/hungpham10/codegraph-rs/releases/download/v2.2.12/codegraph-x86_64-pc-windows-msvc.zip + InstallerUrl: https://github.com/hungpham10/codegraph-rs/releases/download/v2.2.13/codegraph-x86_64-pc-windows-msvc.zip InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000 InstallerType: zip ManifestType: singleton diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 885940c610..93fcc2b6c6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -5,11 +5,11 @@ # # Usage (pin a version / download the script first): # irm https://raw.githubusercontent.com/hungpham10/codegraph-rs/main/scripts/install.ps1 -OutFile install.ps1 -# .\install.ps1 -Version 2.2.12 +# .\install.ps1 -Version 2.2.13 [CmdletBinding()] param( - # Pin a specific version, e.g. "2.2.12". Empty = latest release. + # Pin a specific version, e.g. "2.2.13". Empty = latest release. [string]$Version )