From 023644be28292a68af13edeb239f978cd9c8b6f5 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Wed, 28 Oct 2026 22:43:22 +0700 Subject: [PATCH 1/4] feat(context): rerank candidates with Jev (TypeSafe SystemOne) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codegraph_context` chỉ biết `Contains` trên tên symbol, nên query tự nhiên ("where do we validate config.toml") trả rỗng hoặc trả symbol không liên quan. Thêm `rerank = "jev"` (MCP arg + GraphQL field, mặc định off): - Pool: query gốc + từng token (camelCase/snake_case, bỏ stopword) qua `Contains`, lọc kind code và loại test/bench, cap `max_candidates`. - Một request Jev: Choice (mỗi candidate một option + `none_of_the_above`) cộng Noul để chẩn đoán. Trả `probabilities` → xếp hạng, `no_match` khi `none` thắng xác suất hạng 1 hoặc hạng 1 dưới `min_probability`. - Pool < 3 candidate → `no_match` luôn, không gọi API. - Fail-open: thiếu token, timeout, 4xx, JSON lỗi → giữ thứ tự pool cũ kèm `rerank.used = false` + `reason`; không bao giờ làm hỏng context. - Response: `ContextHit.match_score { probability, confidence }`, `ContextResponse.no_match`/`rerank`, markdown in thêm `(p=0.62)`. - Config `[jev]` opt-in (dữ liệu rời máy): endpoint/model/api_key_env/ timeout_ms/max_candidates/min_probability/repo. Token chỉ đọc từ env. Đo với jev-1.13-free: 4 candidate → 1.0s, ~530 input token, cost 0; chọn đúng `parse_config` (p=0.62, none 0.38). Choice một mình luôn `confidence` cao kể cả với pool rác (đo được 1.0 với 8 candidate sai), nên `none` + `min_probability` mới là tín hiệu "không khớp" — không dùng Noul làm cổng vì Jev trả noul thấp (0.33) ngay cả khi đã chọn đúng. Test: 18 unit test (pool/tokenize/ngưỡng/fail-open/markdown) + 1 live `#[ignore]` gọi API thật. --- Cargo.lock | 2 + crates/codegraph-api/tests/api.rs | 1 + crates/codegraph-context/Cargo.toml | 2 + crates/codegraph-context/src/jev.rs | 415 +++++++++++++ crates/codegraph-context/src/lib.rs | 670 +++++++++++++++++++-- crates/codegraph-context/tests/jev_live.rs | 88 +++ crates/codegraph-core/src/jev.rs | 189 ++++++ crates/codegraph-core/src/lib.rs | 2 + crates/codegraph-extract/src/config.rs | 90 +++ crates/codegraph-graphql/src/types.rs | 22 + crates/codegraph-mcp/src/tools.rs | 10 +- docs/configuration.md | 48 ++ 12 files changed, 1484 insertions(+), 55 deletions(-) create mode 100644 crates/codegraph-context/src/jev.rs create mode 100644 crates/codegraph-context/tests/jev_live.rs create mode 100644 crates/codegraph-core/src/jev.rs diff --git a/Cargo.lock b/Cargo.lock index 7245ceb1fc..acf2dd2150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -799,10 +799,12 @@ dependencies = [ name = "codegraph-context" version = "2.2.12" dependencies = [ + "async-trait", "camino", "codegraph-core", "codegraph-graph", "codegraph-source", + "reqwest", "serde", "serde_json", "tempfile", diff --git a/crates/codegraph-api/tests/api.rs b/crates/codegraph-api/tests/api.rs index 406722194b..dcca101aea 100644 --- a/crates/codegraph-api/tests/api.rs +++ b/crates/codegraph-api/tests/api.rs @@ -278,6 +278,7 @@ async fn files_stats_and_context() { limit: 5, format: codegraph_context::Format::Markdown, strip_prefix: None, + rerank: codegraph_context::Rerank::None, }; let md = api.context_markdown(&req, None).await.unwrap(); assert!(md.contains("caller")); diff --git a/crates/codegraph-context/Cargo.toml b/crates/codegraph-context/Cargo.toml index a231ef4f6d..cff5dadcee 100644 --- a/crates/codegraph-context/Cargo.toml +++ b/crates/codegraph-context/Cargo.toml @@ -9,6 +9,8 @@ repository.workspace = true codegraph-core = { path = "../codegraph-core" } codegraph-graph = { path = "../codegraph-graph" } codegraph-source = { path = "../codegraph-source" } +async-trait = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } diff --git a/crates/codegraph-context/src/jev.rs b/crates/codegraph-context/src/jev.rs new file mode 100644 index 0000000000..f75e3f9931 --- /dev/null +++ b/crates/codegraph-context/src/jev.rs @@ -0,0 +1,415 @@ +//! Rerank candidate symbol bằng Jev (TypeSafe SystemOne). +//! +//! Jev không phải chat model: nó nhận `state` + `questions` trả về +//! `probabilities`. Ở đây một request gồm: +//! +//! - `best_symbol` (Choice): mỗi candidate là một option, cộng option +//! [`codegraph_core::NONE_OPTION`] để model có thể nói "không có cái nào". +//! - `any_fits` (Noul): 0–1, **chỉ để báo cáo/chẩn đoán** — không dùng làm +//! cổng. Jev trả noul thấp cả khi đã chọn đúng (đo được: top 0.75, noul 0.33), +//! nên lấy noul làm ngưỡng sẽ chặn oan kết quả tốt. +//! +//! Tín hiệu "không khớp" là option `none_of_the_above` **thắng** (hoặc hoà) +//! xác suất của candidate đứng đầu — không phải `confidence`, vì Choice một +//! 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`. + +use crate::rel_path; +use async_trait::async_trait; +use codegraph_core::{Error, JevConfig, Result, Symbol, SymbolKind, 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. +#[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, +} + +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 { + 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(), + } + } + + /// 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 + ) + } + + /// Key option trong request/response — `s` để không phụ thuộc tên. + fn key(&self) -> String { + format!("s{}", self.id) + } +} + +/// Kết quả rerank. +#[derive(Debug, Clone, PartialEq)] +pub struct Verdict { + /// `(symbol_id, probability)` đã sort giảm dần và **đã lọc** dưới ngưỡng. + pub ranking: Vec<(u64, f32)>, + /// Không có symbol nào đủ liên quan — caller trả `hits: []`. + 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, + /// Điểm của câu Noul "có candidate nào khớp?". + pub fits: f32, +} + +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 { + Self { + ranking: Vec::new(), + no_match: true, + confidence: None, + none_probability: 1.0, + fits: reason, + } + } +} + +/// Bộ chọn symbol — 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)] +#[async_trait] +pub trait Judge: Send + Sync { + /// Trả ranking cho `candidates`; `Err` = caller fail-open. + async fn rank( + &self, + query: &str, + repo: Option<&str>, + candidates: &[Candidate], + ) -> Result; +} + +/// `Judge` gọi Jev qua HTTP. +pub struct JevJudge { + cfg: JevConfig, +} + +impl JevJudge { + pub fn new(cfg: JevConfig) -> Self { + Self { cfg } + } +} + +#[derive(Serialize)] +struct Ask { + model: String, + state: String, + questions: Questions, +} + +#[derive(Serialize)] +struct Questions { + best_symbol: ChoiceQuestion, + any_fits: NoulQuestion, +} + +#[derive(Serialize)] +struct ChoiceQuestion { + #[serde(rename = "type")] + kind: &'static str, + instructions: String, + criteria: HashMap, +} + +#[derive(Serialize)] +struct NoulQuestion { + #[serde(rename = "type")] + kind: &'static str, + instructions: String, +} + +#[derive(Deserialize)] +struct Answer { + #[serde(default)] + choice: Option, + #[serde(default)] + probabilities: HashMap, + #[serde(default)] + confidence: Option, + #[serde(default)] + noul: Option, +} + +#[derive(Deserialize)] +struct Reply { + #[serde(default)] + answers: HashMap, +} + +fn build_state(query: &str, repo: Option<&str>) -> String { + match repo.filter(|r| !r.trim().is_empty()) { + Some(repo) => format!("Repo: {repo}. Request: {query}"), + None => format!("Request: {query}"), + } +} + +/// 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 best = reply + .answers + .get("best_symbol") + .ok_or_else(|| Error::Other("Jev thiếu answer `best_symbol`".into()))?; + let fits = reply + .answers + .get("any_fits") + .and_then(|a| a.noul) + .unwrap_or(0.0); + + let none_probability = best.probabilities.get(NONE_OPTION).copied().unwrap_or(0.0); + let mut ranking: Vec<(u64, f32)> = candidates + .iter() + .filter_map(|c| { + let p = best.probabilities.get(&c.key()).copied().unwrap_or(0.0); + (p > 0.0).then_some((c.id, p)) + }) + .collect(); + ranking.sort_by(|a, b| b.1.total_cmp(&a.1)); + + // `none` thắng hoặc hoà = model thấy "không có cái nào" đáng tin hơn mọi + // candidate. Noul CHỈ để báo cáo: Jev trả noul thấp cả khi đã chọn đúng + // (đo được: top 0.75 / noul 0.33), nên dùng nó làm cổng sẽ chặn oan. + let top = ranking.first().map(|(_, p)| *p).unwrap_or(0.0); + let no_match = best.choice.as_deref() == Some(NONE_OPTION) + || ranking.is_empty() + || none_probability >= top + || top < cfg.min_probability; + + Ok(Verdict { + ranking: if no_match { Vec::new() } else { ranking }, + no_match, + confidence: best.confidence, + none_probability, + fits, + }) +} + +#[async_trait] +impl Judge for JevJudge { + async fn rank( + &self, + query: &str, + repo: Option<&str>, + candidates: &[Candidate], + ) -> Result { + if candidates.is_empty() { + return Ok(Verdict::no_match(0.0)); + } + let key = self + .cfg + .api_key() + .ok_or_else(|| Error::Other(format!("thiếu env `{}` cho Jev", self.cfg.api_key_env)))?; + + let mut criteria = HashMap::with_capacity(candidates.len() + 1); + for c in candidates { + criteria.insert(c.key(), c.describe()); + } + criteria.insert( + NONE_OPTION.to_string(), + "No candidate implements the requested behaviour".into(), + ); + + let ask = Ask { + model: self.cfg.model.clone(), + state: build_state(query, repo), + questions: Questions { + best_symbol: ChoiceQuestion { + kind: "choice", + instructions: "Which candidate is the code the request is asking about?".into(), + criteria, + }, + any_fits: NoulQuestion { + kind: "noul", + instructions: + "Does any candidate implement the behaviour described in the request?" + .into(), + }, + }, + }; + let body = serde_json::to_vec(&ask) + .map_err(|e| Error::Other(format!("serialize request Jev: {e}")))?; + + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(self.cfg.timeout_ms)) + .build() + .map_err(|e| Error::Other(format!("tạo HTTP client cho Jev: {e}")))?; + let resp = client + .post(&self.cfg.endpoint) + .header(reqwest::header::AUTHORIZATION, format!("Bearer {key}")) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body) + .send() + .await + .map_err(|e| Error::Other(format!("gọi Jev thất bại: {e}")))?; + + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| Error::Other(format!("đọc response Jev: {e}")))?; + if !status.is_success() { + let snippet: String = text.chars().take(200).collect(); + return Err(Error::Other(format!("Jev trả {status}: {snippet}"))); + } + verdict_from_reply(&text, candidates, &self.cfg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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}()")), + } + } + + fn cfg() -> JevConfig { + JevConfig { + enabled: true, + min_probability: 0.25, + ..JevConfig::default() + } + } + + #[test] + fn verdict_sort_và_lọc_ngưỡng() { + 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, + "probabilities":{"s1":0.05,"s2":0.7,"s3":0.2,"none_of_the_above":0.05}}, + "any_fits":{"type":"noul","noul":0.9}}}"#; + let v = verdict_from_reply(raw, &cands, &cfg()).unwrap(); + assert!(!v.no_match); + assert_eq!( + v.ranking, + vec![(2, 0.7), (3, 0.2), (1, 0.05)], + "giữ đủ thứ tự; ngưỡng chỉ áp cho hạng 1" + ); + assert_eq!(v.confidence, Some(0.8)); + assert!((v.none_probability - 0.05).abs() < 1e-6); + } + + #[test] + fn choice_none_thì_no_match_dù_confidence_cao() { + let cands = vec![cand(1, "alpha")]; + let raw = r#"{"answers":{ + "best_symbol":{"type":"choice","choice":"none_of_the_above","confidence":0.99, + "probabilities":{"s1":0.4,"none_of_the_above":0.6}}, + "any_fits":{"type":"noul","noul":0.2}}}"#; + let v = verdict_from_reply(raw, &cands, &cfg()).unwrap(); + assert!(v.no_match); + assert!(v.ranking.is_empty()); + } + + #[test] + fn noul_thấp_không_chặn_kết_quả_choice() { + let cands = vec![cand(1, "alpha"), cand(2, "beta")]; + let raw = r#"{"answers":{ + "best_symbol":{"type":"choice","choice":"s1","confidence":0.9, + "probabilities":{"s1":0.6,"s2":0.2,"none_of_the_above":0.2}}, + "any_fits":{"type":"noul","noul":0.3}}}"#; + let v = verdict_from_reply(raw, &cands, &cfg()).unwrap(); + assert!(!v.no_match, "Noul chỉ để báo cáo, không phải cổng"); + assert_eq!(v.ranking.first().map(|(id, _)| *id), Some(1)); + assert!( + (v.fits - 0.3).abs() < 1e-6, + "vẫn giữ điểm noul để chẩn đoán" + ); + } + + #[test] + fn mọi_xác_suất_dưới_ngưỡng_thì_no_match() { + let cands = vec![cand(1, "alpha"), cand(2, "beta")]; + let raw = r#"{"answers":{ + "best_symbol":{"type":"choice","choice":"s1","confidence":0.5, + "probabilities":{"s1":0.24,"s2":0.2,"none_of_the_above":0.2}}, + "any_fits":{"type":"noul","noul":0.8}}}"#; + let v = verdict_from_reply(raw, &cands, &cfg()).unwrap(); + assert!( + v.no_match, + "hạng 1 = 0.24 < min_probability 0.25 → không khớp" + ); + } + + #[test] + fn state_có_repo_khi_cấu_hình() { + assert_eq!( + build_state("where is auth", Some("codegraph-rs")), + "Repo: codegraph-rs. Request: where is auth" + ); + assert_eq!(build_state("where is auth", None), "Request: where is auth"); + assert_eq!( + build_state("where is auth", Some(" ")), + "Request: where is auth" + ); + } + + #[test] + fn reply_thiếu_best_symbol_là_lỗi_để_fail_open() { + let cands = vec![cand(1, "alpha")]; + let err = verdict_from_reply(r#"{"answers":{}}"#, &cands, &cfg()).unwrap_err(); + assert!(err.to_string().contains("best_symbol")); + } + + #[test] + fn 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()), + }; + assert_eq!( + c.describe(), + "function `load_config` in `crates/a/src/config.rs:42` — fn load_config() -> Config" + ); + assert_eq!(c.key(), "s7"); + } +} diff --git a/crates/codegraph-context/src/lib.rs b/crates/codegraph-context/src/lib.rs index ce255d872c..3ac9eece1d 100644 --- a/crates/codegraph-context/src/lib.rs +++ b/crates/codegraph-context/src/lib.rs @@ -3,8 +3,16 @@ //! Chạy trên `SharedGraphIndex` (snapshot mới nhất qua `ensure_fresh`), không //! còn `Db`/`Traversal` cũ — query surface mới của `GraphIndex`: //! `search_symbol` → `callers`/`callees` (BFS trên chain engine). +//! +//! Tuỳ chọn `rerank = "jev"`: trước khi lấy callers/callees, gom candidate +//! (token hoá query + `Contains` từng token) rồi để Jev chọn symbol gần nhất +//! kèm xác suất. Mặc định TẮT và **fail-open**: Jev lỗi → xếp hạng cũ. + +mod jev; -use codegraph_core::{Error, Result, Symbol, SymbolMatch}; +pub use jev::{Candidate, JevJudge, Judge, Verdict}; + +use codegraph_core::{Error, Result, Symbol, SymbolKind, SymbolMatch}; use codegraph_graph::{Pagination, SharedGraphIndex}; use codegraph_source::{entry_from_symbol_file, read_entry, Source}; use serde::{Deserialize, Serialize}; @@ -12,7 +20,7 @@ use std::collections::HashMap; use std::fmt::Write; use std::sync::Arc; -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Format { #[default] @@ -20,6 +28,27 @@ pub enum Format { Json, } +/// Cách chọn symbol gốc trước khi mở rộng callers/callees. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Rerank { + /// `Contains` trên tên symbol (hành vi cũ, không gọi mạng). + #[default] + None, + /// Jev chọn trong pool candidate — cần `[jev] enabled = true` + env token. + Jev, +} + +impl Rerank { + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "none" | "" => Some(Self::None), + "jev" => Some(Self::Jev), + _ => None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextRequest { pub query: String, @@ -31,6 +60,9 @@ pub struct ContextRequest { /// kiệm token cho LLM). `None` = giữ absolute (CLI/HTTP không biết root). #[serde(default)] pub strip_prefix: Option, + /// `Jev` = rerank bằng Jev (opt-in qua `[jev]` trong config). + #[serde(default)] + pub rerank: Rerank, } impl Default for ContextRequest { @@ -42,22 +74,50 @@ impl Default for ContextRequest { limit: 5, format: Format::Markdown, strip_prefix: None, + rerank: Rerank::None, } } } +/// Xác suất Jev gán cho symbol — `Some` chỉ khi `rerank = "jev"` chạy được. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct MatchScore { + pub probability: f32, + #[serde(default, skip_serializing_if = "Option::is_none")] + 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, pub callers: Vec, pub callees: Vec, pub source: Option, + /// Điểm khớp do Jev gán (chỉ khi rerank chạy được). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub match_score: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextResponse { pub query: String, pub hits: Vec, + /// Không symbol nào đủ liên quan — `hits` rỗng vì lý do ngữ nghĩa, không + /// phải vì index rỗng. + #[serde(default)] + pub no_match: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rerank: Option, } /// Build context markdown/json trên shared index (snapshot fresh). @@ -77,60 +137,27 @@ pub async fn build_response( index: &Arc, req: &ContextRequest, source: Option<&dyn Source>, +) -> Result { + build_response_with_judge(index, req, source, None).await +} + +/// Như [`build_response`] nhưng nhận `judge` ngoài — test truyền fake, production +/// truyền `None` để tự dựng [`JevJudge`] từ config global. +pub async fn build_response_with_judge( + index: &Arc, + req: &ContextRequest, + source: Option<&dyn Source>, + judge: Option<&dyn Judge>, ) -> Result { let idx = index.ensure_fresh().await; - // Try symbol-name search first, then fallback to file-path search. - let mut candidates = idx - .search_symbol_paged_resumable( - &req.query, - None, - SymbolMatch::Contains, - Pagination { - limit: req.limit as usize, - offset: 0, - }, - None, - None, - ) - .await? - .page; - if candidates.is_empty() { - // Fallback: query as filename (strip extension for symbol-name search). - let query_stripped = req - .query - .split('/') - .next_back() - .and_then(|f| { - let without_ext = f.rsplit_once('.')?.0; - if without_ext.is_empty() { - None - } else { - Some(without_ext.to_string()) - } - }) - .unwrap_or_else(|| req.query.clone()); - candidates = idx - .search_symbol_paged_resumable( - &query_stripped, - None, - SymbolMatch::Contains, - Pagination { - limit: req.limit as usize, - offset: 0, - }, - None, - None, - ) - .await? - .page; - } + let (mut symbols, scores, no_match, rerank_info) = order_candidates(&idx, req, judge).await?; // Pre-load mỗi file một lần khi cần source. Đọc qua `Source` trait để // đường query không còn đọc `std::fs` trực tiếp — cùng abstraction với // đường ingest, nên đổi provider (disk → git/remote) là một chỗ. let file_cache: HashMap> = if req.include_source { let mut cache = HashMap::new(); - for s in &candidates { + for s in &symbols { // Đọc **trước**, xong mới insert — `cache.entry(..)` giữ borrow `&mut` // không qua được `.await`. if cache.contains_key(&s.file) { @@ -157,8 +184,8 @@ pub async fn build_response( HashMap::new() }; - let mut hits = Vec::new(); - for s in candidates { + let mut hits = Vec::with_capacity(symbols.len()); + for s in symbols.drain(..) { let callers = idx.callers(s.id, req.depth as usize).await?; let callees = idx.callees(s.id).await?; let source = if req.include_source { @@ -170,19 +197,314 @@ pub async fn build_response( } else { None }; + let match_score = scores.get(&s.id).copied(); hits.push(ContextHit { symbol: s, callers, callees, source, + match_score, }); } Ok(ContextResponse { query: req.query.clone(), hits, + no_match, + rerank: rerank_info, }) } +/// Chọn danh sách symbol gốc (đã cắt `limit`) + điểm khớp + trạng thái no-match. +type Ordered = ( + Vec, + HashMap, + bool, + Option, +); + +async fn order_candidates( + idx: &codegraph_graph::GraphIndex, + req: &ContextRequest, + judge: Option<&dyn Judge>, +) -> Result { + if req.rerank != Rerank::Jev { + return Ok(( + legacy_candidates(idx, req).await?, + HashMap::new(), + false, + None, + )); + } + + 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(( + Vec::new(), + HashMap::new(), + true, + Some(RerankInfo { + used: false, + reason: Some(format!( + "chỉ có {} candidate (< {MIN_POOL}) — không gọi Jev", + pool.len() + )), + }), + )); + } + + 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 candidates: Vec = pool + .iter() + .map(|s| Candidate::from_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, + }), + )) + } + 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}")), + }), + )) + } + } +} + +/// Pool tối thiểu để gọi Jev — dưới mức này coi như "không có gì để chọn". +const MIN_POOL: usize = 3; + +/// Kind mặc định coi là "symbol code" — bỏ variable/parameter/field/constant. +const DEFAULT_KINDS: [SymbolKind; 7] = [ + SymbolKind::Function, + SymbolKind::Method, + SymbolKind::Class, + SymbolKind::Interface, + SymbolKind::Enum, + SymbolKind::Module, + SymbolKind::File, +]; + +/// Từ dừng khi token hoá query tự nhiên. +const STOPWORDS: [&str; 28] = [ + "the", "and", "for", "from", "with", "that", "this", "these", "those", "where", "when", + "which", "what", "how", "why", "does", "did", "the", "are", "was", "were", "you", "our", + "your", "please", "find", "show", "code", +]; + +/// Tách query thành token tìm kiếm: snake_case, camelCase, bỏ stopword/ngắn. +fn query_tokens(query: &str) -> Vec { + let mut out = Vec::new(); + for raw in query.split(|c: char| !c.is_alphanumeric()) { + if raw.is_empty() { + continue; + } + // camelCase / PascalCase → nhiều token. + let mut cur = String::new(); + let mut prev_lower = false; + for ch in raw.chars() { + if ch.is_uppercase() && prev_lower { + if cur.len() >= 3 { + out.push(cur.to_ascii_lowercase()); + } + cur.clear(); + } + prev_lower = ch.is_lowercase() || ch.is_numeric(); + cur.push(ch); + } + if cur.len() >= 3 { + out.push(cur.to_ascii_lowercase()); + } + } + out.retain(|t| !STOPWORDS.contains(&t.as_str())); + out.dedup(); + out +} + +/// Symbol test/bench — Jev từng chọn đúng file nhưng là `#[test]` fn, nên loại +/// trước khi đưa vào pool. +fn is_test_symbol(s: &Symbol) -> bool { + let name = s.name.as_str(); + let file = s.file.as_str(); + name.starts_with("test_") + || name.ends_with("_test") + || name.ends_with("_tests") + || file.contains("/tests/") + || file.contains("/benches/") + || file.starts_with("tests/") +} + +/// Gom pool candidate cho Jev: query gốc + từng token, dedupe theo id. +async fn build_pool( + idx: &codegraph_graph::GraphIndex, + req: &ContextRequest, +) -> Result> { + let max = codegraph_core::jev_config().max_candidates; + let mut seen = HashMap::::new(); + + let push = |syms: Vec, seen: &mut HashMap| { + for s in syms { + if seen.len() >= max { + break; + } + if !DEFAULT_KINDS.contains(&s.kind) || is_test_symbol(&s) { + continue; + } + seen.entry(s.id).or_insert(s); + } + }; + + // Query gốc trước (giữ hành vi lexical cũ khi query là tên symbol). + let mut queries = vec![req.query.clone()]; + queries.extend(query_tokens(&req.query)); + for q in queries { + if seen.len() >= max { + break; + } + let page = idx + .search_symbol_paged_resumable( + &q, + None, + SymbolMatch::Contains, + Pagination { + limit: 8, + offset: 0, + }, + None, + None, + ) + .await?; + push(page.page, &mut seen); + } + Ok(seen.into_values().collect()) +} + +/// Hành vi cũ: `Contains` trên tên, fallback sang tên file bỏ extension. +async fn legacy_candidates( + idx: &codegraph_graph::GraphIndex, + req: &ContextRequest, +) -> Result> { + let mut candidates = idx + .search_symbol_paged_resumable( + &req.query, + None, + SymbolMatch::Contains, + Pagination { + limit: req.limit as usize, + offset: 0, + }, + None, + None, + ) + .await? + .page; + if candidates.is_empty() { + // Fallback: query as filename (strip extension for symbol-name search). + let query_stripped = req + .query + .split('/') + .next_back() + .and_then(|f| { + let without_ext = f.rsplit_once('.')?.0; + if without_ext.is_empty() { + None + } else { + Some(without_ext.to_string()) + } + }) + .unwrap_or_else(|| req.query.clone()); + candidates = idx + .search_symbol_paged_resumable( + &query_stripped, + None, + SymbolMatch::Contains, + Pagination { + limit: req.limit as usize, + offset: 0, + }, + None, + None, + ) + .await? + .page; + } + Ok(candidates) +} + /// Đọc nội dung 1 file qua `Source`, trả về dạng dòng. /// /// `Symbol.file` lưu dạng `/` — nhờ quy ước đó, tách `source.root()` @@ -208,7 +530,7 @@ async fn load_source_lines(source: Option<&dyn Source>, file: &str) -> Result(p: &'a str, strip: Option<&str>) -> &'a str { +pub(crate) fn rel_path<'a>(p: &'a str, strip: Option<&str>) -> &'a str { if let Some(root) = strip { if let Some(rest) = p.strip_prefix(root) { if let Some(rest) = rest.strip_prefix('/') { @@ -223,13 +545,21 @@ fn render_markdown(resp: &ContextResponse, strip: Option<&str>) -> String { let mut out = String::new(); let _ = writeln!(out, "# Context: `{}`", resp.query); if resp.hits.is_empty() { - let _ = writeln!(out, "\n_No matches._"); + if resp.no_match { + let _ = writeln!(out, "\n_No symbol matches `{}`._", resp.query); + } else { + let _ = writeln!(out, "\n_No matches._"); + } return out; } for h in &resp.hits { + let score = h + .match_score + .map(|s| format!(" (p={:.2})", s.probability)) + .unwrap_or_default(); let _ = writeln!( out, - "\n## `{}` — {} — `{}:{}`", + "\n## `{}` — {} — `{}:{}`{score}", h.symbol.name, h.symbol.kind.as_str(), rel_path(&h.symbol.file, strip), @@ -419,6 +749,7 @@ mod tests { limit: 5, format: Format::Markdown, strip_prefix: None, + rerank: Rerank::None, }; let sgi_arc: Arc = Arc::new(sgi); let resp = build_response(&sgi_arc, &req, None).await.unwrap(); @@ -434,4 +765,237 @@ mod tests { assert!(!resp2.hits.is_empty()); assert_eq!(resp2.hits[0].symbol.name, "RestEndpoint"); } + + #[test] + fn query_tokens_tách_snake_camel_và_bỏ_stopword() { + assert_eq!( + query_tokens("where do we ValidateUserInput in the config"), + vec!["validate", "user", "input", "config"] + ); + assert_eq!( + query_tokens("load_config_file"), + vec!["load", "config", "file"], + "snake_case cũng được tách để tìm theo từng phần" + ); + assert_eq!(query_tokens("a b"), Vec::::new()); + } + + #[test] + fn is_test_symbol_nhận_diện_test_và_bench() { + let mut s = sym("test_load", 1); + assert!(is_test_symbol(&s)); + s = sym("load_tests", 2); + assert!(is_test_symbol(&s)); + s = sym("load", 3); + assert!(!is_test_symbol(&s)); + s = sym_at("load", 4, "/repo/tests/fixtures/load.rs"); + assert!(is_test_symbol(&s)); + s = sym_at("load", 5, "/repo/benches/bench.rs"); + assert!(is_test_symbol(&s)); + } + + #[test] + fn markdown_hiện_xác_suất_và_no_match() { + let resp = ContextResponse { + query: "auth".into(), + hits: vec![ContextHit { + symbol: sym("auth", 1), + callers: Vec::new(), + callees: Vec::new(), + source: None, + match_score: Some(MatchScore { + probability: 0.62, + confidence: Some(0.9), + }), + }], + no_match: false, + rerank: None, + }; + let md = render_markdown(&resp, None); + assert!(md.contains("(p=0.62)"), "{md}"); + + let empty = ContextResponse { + query: "nonexistent".into(), + hits: Vec::new(), + no_match: true, + rerank: None, + }; + assert!(render_markdown(&empty, None).contains("_No symbol matches `nonexistent`._")); + } + + // ── rerank = "jev" (judge giả, không gọi mạng) ── + + enum Fake { + /// `(tên symbol, xác suất)` — map sang id thật của pool trong test. + Rank(Vec<(&'static str, f32)>), + NoMatch, + Fail, + } + + struct FakeJudge { + fake: Fake, + calls: std::sync::atomic::AtomicUsize, + } + + impl FakeJudge { + fn new(fake: Fake) -> Self { + Self { + fake, + calls: std::sync::atomic::AtomicUsize::new(0), + } + } + fn calls(&self) -> usize { + self.calls.load(std::sync::atomic::Ordering::SeqCst) + } + } + + #[async_trait::async_trait] + impl Judge for FakeJudge { + async fn rank( + &self, + _query: &str, + _repo: Option<&str>, + _candidates: &[Candidate], + ) -> Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + match &self.fake { + Fake::Rank(r) => Ok(Verdict { + ranking: r + .iter() + .filter_map(|(name, p)| { + _candidates + .iter() + .find(|c| c.name == *name) + .map(|c| (c.id, *p)) + }) + .collect(), + no_match: false, + confidence: Some(0.8), + none_probability: 0.1, + fits: 0.9, + }), + Fake::NoMatch => Ok(Verdict::no_match(0.1)), + Fake::Fail => Err(Error::Other("timeout".into())), + } + } + } + + /// Index 3 symbol: `load_config`, `save_config`, `validate_user_input`; + /// mọi symbol nằm trong `src/` nên không bị lọc test. + async fn index_ba_symbol(dir: &tempfile::TempDir) -> Arc { + let db_str = format!("sqlite://{}", dir.path().join("t.db").to_string_lossy()); + { + let mut idx = codegraph_graph::GraphIndex::open(&db_str).await.unwrap(); + let r = codegraph_graph::ParseResult { + path: "/repo/src/config.rs".into(), + language: "rust".into(), + bytes: 0, + lines: 3, + symbols: vec![ + sym_at("load_config", 1, "/repo/src/config.rs"), + sym_at("save_config", 2, "/repo/src/config.rs"), + sym_at("validate_user_input", 3, "/repo/src/input.rs"), + ], + chains: std::collections::HashMap::new(), + calls: vec![], + fn_ptr_assigns: Vec::new(), + file_deps: None, + }; + idx.ingest(&[r], None).await.unwrap(); + } + Arc::new(SharedGraphIndex::open(Some(db_str)).await.unwrap()) + } + + fn jev_req() -> ContextRequest { + ContextRequest { + query: "config validate user input".into(), + limit: 2, + strip_prefix: Some("/repo".into()), + rerank: Rerank::Jev, + ..ContextRequest::default() + } + } + + #[tokio::test] + async fn rerank_jev_sắp_thứ_tự_theo_xác_suất_và_gắn_score() { + let dir = tempfile::tempdir().unwrap(); + let sgi = index_ba_symbol(&dir).await; + // Jev trả ngược thứ tự pool: ưu tiên id 3, rồi 1, 2 ở cuối. + let judge = FakeJudge::new(Fake::Rank(vec![ + ("validate_user_input", 0.72), + ("load_config", 0.4), + ("save_config", 0.33), + ])); + let resp = build_response_with_judge(&sgi, &jev_req(), None, Some(&judge)) + .await + .unwrap(); + assert!(!resp.no_match); + assert_eq!(judge.calls(), 1, "gọi Jev đúng 1 lần cho cả pool"); + assert_eq!(resp.hits.len(), 2, "cắt theo limit, không theo pool"); + assert_eq!(resp.hits[0].symbol.name, "validate_user_input"); + assert_eq!(resp.hits[1].symbol.name, "load_config"); + assert_eq!(resp.hits[0].match_score.map(|m| m.probability), Some(0.72)); + assert!( + resp.rerank.as_ref().map(|r| r.used).unwrap_or(false), + "báo đã rerank" + ); + let md = render_markdown(&resp, Some("/repo")); + assert!(md.contains("(p=0.72)"), "{md}"); + } + + #[tokio::test] + async fn rerank_jev_no_match_thì_hits_rỗng() { + let dir = tempfile::tempdir().unwrap(); + let sgi = index_ba_symbol(&dir).await; + let judge = FakeJudge::new(Fake::NoMatch); + let resp = build_response_with_judge(&sgi, &jev_req(), None, Some(&judge)) + .await + .unwrap(); + assert!(resp.no_match); + assert!(resp.hits.is_empty(), "no-match không được trả symbol bừa"); + let md = render_markdown(&resp, Some("/repo")); + assert!(md.contains("_No symbol matches"), "{md}"); + } + + #[tokio::test] + async fn rerank_jev_lỗi_thì_fail_open_về_pool() { + let dir = tempfile::tempdir().unwrap(); + let sgi = index_ba_symbol(&dir).await; + let judge = FakeJudge::new(Fake::Fail); + let resp = build_response_with_judge(&sgi, &jev_req(), None, Some(&judge)) + .await + .unwrap(); + assert!(!resp.no_match, "lỗi mạng không được làm mất kết quả"); + assert!(!resp.hits.is_empty()); + assert!(resp.hits.iter().all(|h| h.match_score.is_none())); + let info = resp.rerank.expect("có lý do fail-open"); + assert!(!info.used); + assert!(info.reason.unwrap_or_default().contains("timeout")); + } + + #[tokio::test] + async fn rerank_jev_pool_quá_nhỏ_thì_không_gọi_api() { + let dir = tempfile::tempdir().unwrap(); + let sgi = index_ba_symbol(&dir).await; + let judge = FakeJudge::new(Fake::Rank(vec![("load_config", 0.9)])); + let req = ContextRequest { + query: "config".into(), + rerank: Rerank::Jev, + ..ContextRequest::default() + }; + let resp = build_response_with_judge(&sgi, &req, None, Some(&judge)) + .await + .unwrap(); + assert_eq!(judge.calls(), 0, "2 candidate < MIN_POOL → không gọi Jev"); + assert!(resp.no_match); + assert!(resp.hits.is_empty()); + } + + #[test] + fn rerank_parse() { + assert_eq!(Rerank::parse("jev"), Some(Rerank::Jev)); + assert_eq!(Rerank::parse("none"), Some(Rerank::None)); + assert_eq!(Rerank::parse(""), Some(Rerank::None)); + assert_eq!(Rerank::parse("llm"), None); + } } diff --git a/crates/codegraph-context/tests/jev_live.rs b/crates/codegraph-context/tests/jev_live.rs new file mode 100644 index 0000000000..669aee8f20 --- /dev/null +++ b/crates/codegraph-context/tests/jev_live.rs @@ -0,0 +1,88 @@ +//! Test gọi API Jev thật — `#[ignore]` vì cần mạng + token. +//! +//! ```bash +//! cargo test -p codegraph-context --test jev_live -- --ignored --nocapture +//! ``` +//! +//! 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}; + +fn candidate(id: u64, name: &str, kind: SymbolKind, file: &str, sig: &str) -> Candidate { + Candidate { + id, + name: name.into(), + kind, + file: file.into(), + line: 10, + signature: Some(sig.into()), + } +} + +fn cfg() -> JevConfig { + let mut cfg = JevConfig { + enabled: true, + repo: Some("demo-repo".into()), + timeout_ms: 20_000, + ..JevConfig::default() + }; + if let Ok(model) = std::env::var("JEV_MODEL") { + cfg.model = model; + } + cfg +} + +#[tokio::test] +#[ignore = "gọi API thật: cần OPENCODE_API_KEY + mạng"] +async fn jev_chọn_đúng_symbol_và_trả_xác_suất() { + let cfg = cfg(); + assert!(cfg.api_key().is_some(), "thiếu env `{}`", cfg.api_key_env); + let candidates = vec![ + candidate( + 1, + "validate_file_key", + SymbolKind::Function, + "tools/figma-cli/src/figma_lib/utils.py", + "def validate_file_key(file_key: str) -> str:", + ), + candidate( + 2, + "clear_embeddings", + SymbolKind::Method, + "crates/codegraph-graph/src/storage/sqlite.rs", + "async fn clear_embeddings(&mut self) -> Result<()>", + ), + candidate( + 3, + "parse_config", + SymbolKind::Function, + "crates/codegraph-extract/src/config.rs", + "fn parse_config(text: &str) -> Config", + ), + candidate( + 123, + "unrelated_worker", + SymbolKind::Function, + "src/worker.rs", + "async fn unrelated_worker(&self)", + ), + ]; + + let verdict = JevJudge::new(cfg) + .rank( + "where do we check that config.toml parses correctly?", + Some("demo-repo"), + &candidates, + ) + .await + .expect("Jev phải trả được verdict (kiểm tra token/model)"); + + println!("{verdict:#?}"); + assert!(!verdict.no_match, "pool có `parse_config` — phải match"); + assert_eq!( + verdict.ranking.first().map(|(id, _)| *id), + Some(3), + "phải chọn parse_config" + ); +} diff --git a/crates/codegraph-core/src/jev.rs b/crates/codegraph-core/src/jev.rs new file mode 100644 index 0000000000..b82d1aa815 --- /dev/null +++ b/crates/codegraph-core/src/jev.rs @@ -0,0 +1,189 @@ +//! Config cho Jev (TypeSafe SystemOne) — rerank kết quả `codegraph_context`. +//! +//! Jev là **decision model**, không phải chat model: nhận `state` + `questions` +//! và trả xác suất. Ở đây nó dùng để chọn symbol gần nhất trong danh sách +//! candidate (Choice) kèm một câu hỏi Noul "có cái nào khớp không". +//! +//! Config **OPT-IN**, mặc định TẮT: khi bật, query + tên symbol + đường dẫn file +//! sẽ rời máy. Chỉ đọc `[jev]` trong `.codegraph/config.toml`. +//! +//! Module này chỉ giữ **data + global config** (không HTTP) để `codegraph-extract` +//! (đọc config) và `codegraph-context` (gọi API) dùng chung mà không thêm +//! dependency chéo. Client HTTP nằm ở `codegraph-context`. + +use std::sync::OnceLock; + +/// Endpoint mặc định của Jev qua OpenCode Zen (OpenAI-agnostic gateway). +pub const DEFAULT_ENDPOINT: &str = "https://opencode.ai/zen/v1/systemone"; +/// Model miễn phí — model trả phí (`jev-1.13`) phải tự khai báo trong config. +pub const DEFAULT_MODEL: &str = "jev-1.13-free"; +/// Env var mặc định chứa bearer token của OpenCode Zen. +pub const DEFAULT_API_KEY_ENV: &str = "OPENCODE_API_KEY"; +/// Nhãn option "không có symbol nào liên quan" trong câu hỏi Choice của Jev. +pub const NONE_OPTION: &str = "none_of_the_above"; + +/// Cấu hình Jev đã parse từ `[jev]`. +#[derive(Debug, Clone, PartialEq)] +pub struct JevConfig { + /// Bật rerank bằng Jev. Mặc định `false` (dữ liệu rời máy). + pub enabled: bool, + /// URL đầy đủ của SystemOne endpoint (không phải base URL). + pub endpoint: String, + /// Model id — free mặc định; đổi sang `jev-1.13` nếu muốn trả phí. + pub model: String, + /// Tên biến môi trường chứa token (không bao giờ đọc key từ config.toml). + pub api_key_env: String, + /// Timeout request; quá hạn → fail-open (giữ hành vi cũ). + pub timeout_ms: u64, + /// Số candidate tối đa gửi cho Jev (Choice nhận tối đa 255 options). + pub max_candidates: usize, + /// Xác suất tối thiểu của **symbol đứng đầu** để coi là khớp. + /// + /// Chỉ áp cho hạng 1: khi Jev chia xác suất cho nhiều candidate gần nhau, + /// các hạng sau vẫn giữ nguyên thứ tự thay vì bị cắt sạch. + pub min_probability: f32, + /// Tên repo đưa vào `state`. `None` → caller tự lấy basename workspace root. + pub repo: Option, +} + +impl Default for JevConfig { + fn default() -> Self { + Self { + enabled: false, + endpoint: DEFAULT_ENDPOINT.to_string(), + model: DEFAULT_MODEL.to_string(), + api_key_env: DEFAULT_API_KEY_ENV.to_string(), + timeout_ms: 2_000, + max_candidates: 40, + min_probability: 0.25, + repo: None, + } + } +} + +impl JevConfig { + /// Parse từ các raw string của `[jev]` — `None`/chuỗi rỗng dùng default. + /// + /// `enabled` chỉ `true` khi khai báo tường minh `enabled = true`: giống + /// `[embedding]`, không có key thì không gửi gì ra ngoài. + #[allow(clippy::too_many_arguments)] + pub fn from_raw( + enabled: Option, + endpoint: Option<&str>, + model: Option<&str>, + api_key_env: Option<&str>, + timeout_ms: Option, + max_candidates: Option, + min_probability: Option, + repo: Option<&str>, + ) -> Self { + let d = Self::default(); + let text = |v: Option<&str>, fallback: &str| { + v.map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(fallback) + .to_string() + }; + Self { + enabled: enabled.unwrap_or(false), + endpoint: text(endpoint, &d.endpoint), + model: text(model, &d.model), + api_key_env: text(api_key_env, &d.api_key_env), + timeout_ms: timeout_ms.filter(|v| *v > 0).unwrap_or(d.timeout_ms), + max_candidates: max_candidates + .filter(|v| *v > 0) + .unwrap_or(d.max_candidates), + min_probability: min_probability + .filter(|v| (0.0..=1.0).contains(v)) + .unwrap_or(d.min_probability), + repo: repo + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string), + } + } + + /// Token đọc từ env var đã cấu hình. `None` → không gọi API (fail-open). + pub fn api_key(&self) -> Option { + let raw = std::env::var(&self.api_key_env).ok()?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + + /// Chỉ chạy khi bật + có token. Thiếu một trong hai → giữ hành vi cũ. + pub fn is_ready(&self) -> bool { + self.enabled && self.api_key().is_some() + } +} + +static JEV_CONFIG: OnceLock = OnceLock::new(); +static DEFAULT_JEV_CONFIG: OnceLock = OnceLock::new(); + +/// Áp dụng config (chỉ lần đầu; các lần sau bị bỏ qua, như `set_embedding_config`). +pub fn set_jev_config(cfg: JevConfig) { + let _ = JEV_CONFIG.set(cfg); +} + +/// Config hiện tại — mặc định TẮT nếu chưa set. +/// +/// Không tự khởi tạo [`JEV_CONFIG`] bằng default khi đọc: nếu làm vậy, config +/// mặc định (tắt) sẽ bị "khoá" trước khi startup kịp set. +pub fn jev_config() -> &'static JevConfig { + match JEV_CONFIG.get() { + Some(cfg) => cfg, + None => DEFAULT_JEV_CONFIG.get_or_init(JevConfig::default), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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); + assert!(!cfg.enabled); + assert_eq!(cfg.endpoint, DEFAULT_ENDPOINT); + assert_eq!(cfg.model, DEFAULT_MODEL); + assert_eq!(cfg.api_key_env, DEFAULT_API_KEY_ENV); + assert_eq!(cfg.timeout_ms, 2_000); + assert_eq!(cfg.max_candidates, 40); + assert_eq!(cfg.min_probability, 0.25); + assert!(cfg.repo.is_none()); + } + + #[test] + fn from_raw_bỏ_qua_giá_trị_vô_lý() { + let cfg = JevConfig::from_raw( + Some(true), + Some(" "), + Some("jev-1.13"), + Some(""), + Some(0), + Some(0), + Some(-0.2), + Some(" "), + ); + assert!(cfg.enabled); + assert_eq!(cfg.endpoint, DEFAULT_ENDPOINT, "chuỗi rỗng → default"); + assert_eq!(cfg.model, "jev-1.13"); + assert_eq!(cfg.api_key_env, DEFAULT_API_KEY_ENV); + assert_eq!(cfg.timeout_ms, 2_000, "0 → default"); + 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"); + } + + #[test] + fn ready_cần_cả_enabled_lẫn_env() { + let mut cfg = JevConfig::default(); + assert!(!cfg.is_ready(), "mặc định tắt"); + cfg.enabled = true; + cfg.api_key_env = "CODEGRAPH_TEST_JEV_KEY_ABSENT".into(); + assert!(!cfg.is_ready(), "thiếu env → không chạy"); + } +} diff --git a/crates/codegraph-core/src/lib.rs b/crates/codegraph-core/src/lib.rs index 038b19bd4a..05e9d749d5 100644 --- a/crates/codegraph-core/src/lib.rs +++ b/crates/codegraph-core/src/lib.rs @@ -4,11 +4,13 @@ //! semgraph (`semgraph` module) — wire breaking đã chốt ở plan. mod error; +pub mod jev; pub mod prof; mod route; mod semgraph; pub use error::{Error, Result}; +pub use jev::{jev_config, set_jev_config, JevConfig, NONE_OPTION}; pub use route::StorageRoute; pub use semgraph::{ is_marker, marker_id, marker_name, Annotation, BranchLabel, CallRecord, CallSite, diff --git a/crates/codegraph-extract/src/config.rs b/crates/codegraph-extract/src/config.rs index 179d931ac8..05c6de478f 100644 --- a/crates/codegraph-extract/src/config.rs +++ b/crates/codegraph-extract/src/config.rs @@ -73,6 +73,9 @@ struct ConfigFile { /// Embedding backend cho semantic search (fastembed / hashing) + cache model. #[serde(default)] embedding: EmbeddingSection, + /// Rerank `codegraph_context` bằng Jev (TypeSafe SystemOne) — opt-in. + #[serde(default)] + jev: JevSection, /// Document graph — ingest tài liệu cấu trúc lúc `codegraph init`. #[serde(default)] docgraph: DocGraphSection, @@ -205,6 +208,39 @@ struct EmbeddingSection { execution_provider: Option, } +/// Section `[jev]` — rerank `codegraph_context` bằng Jev (TypeSafe SystemOne). +/// +/// OPT-IN: khi bật, query + tên symbol + đường dẫn file được gửi tới +/// `endpoint`. Token **không** nằm trong file này — chỉ đọc từ env var +/// `api_key_env`. +#[derive(Debug, Default, Deserialize)] +struct JevSection { + /// Bật rerank. Thiếu key → tắt (fail-open về hành vi cũ). + #[serde(default)] + enabled: Option, + /// URL đầy đủ của SystemOne endpoint. + #[serde(default)] + endpoint: Option, + /// Model id (`jev-1.13-free` mặc định). + #[serde(default)] + model: Option, + /// Tên env var chứa bearer token. + #[serde(default)] + api_key_env: Option, + /// Timeout request (ms) — quá hạn thì fail-open. + #[serde(default)] + timeout_ms: Option, + /// Số candidate tối đa gửi cho Jev. + #[serde(default)] + max_candidates: Option, + /// Xác suất tối thiểu của symbol hạng 1 để coi là khớp. + #[serde(default)] + min_probability: Option, + /// Tên repo trong `state`; rỗng → lấy basename workspace root. + #[serde(default)] + repo: Option, +} + // ── Effect-rule runtime config (MCP `codegraph_config_effects` + GraphQL) ── // Macro/call semantics do LLM khai báo lúc runtime: đọc/ghi `[[effect_rules]]` // trong .codegraph/config.toml bằng toml_edit (giữ nguyên mọi section khác). @@ -429,6 +465,8 @@ pub struct ExtractConfig { pub storage: StorageConfig, /// Cấu hình embedding backend (semantic search) — đọc từ `[embedding]`. pub embedding: codegraph_graph::embeddings::EmbeddingConfig, + /// Cấu hình Jev rerank cho `codegraph_context` — đọc từ `[jev]`. + pub jev: codegraph_core::JevConfig, /// Cấu hình document graph — đọc từ `[docgraph]`. pub docgraph: DocGraphSection, /// Cấu hình binary graph — đọc từ `[bingraph]`. @@ -499,6 +537,16 @@ impl ExtractConfig { file.embedding.vss_extension.as_deref(), file.embedding.execution_provider.as_deref(), ), + jev: codegraph_core::JevConfig::from_raw( + file.jev.enabled, + file.jev.endpoint.as_deref(), + file.jev.model.as_deref(), + file.jev.api_key_env.as_deref(), + file.jev.timeout_ms, + file.jev.max_candidates, + file.jev.min_probability, + file.jev.repo.as_deref(), + ), storage: StorageConfig { kind: file .storage @@ -551,6 +599,10 @@ impl ExtractConfig { // mở index — `GraphIndex::new_with_storage` đọc global này để quyết định // có bật vector index hay không (opt-in: chỉ khi backend = "fastembed"). codegraph_graph::embeddings::set_embedding_config(self.embedding.clone()); + // Cùng thời điểm: `[jev]` là config toàn cục cho `codegraph-context` + // (module context không phụ thuộc `codegraph-extract`, nên truyền qua + // global giống embedding). + codegraph_core::set_jev_config(self.jev.clone()); match self.storage.kind { StorageKind::Memory => Some(StorageRoute::Memory), StorageKind::Postgres | StorageKind::MySql => { @@ -1140,6 +1192,44 @@ headers = "cpp" )); } + /// `[jev]` parse đủ field; thiếu section → tắt hoàn toàn (opt-in). + #[test] + fn parse_jev_section() { + let fx = CfgFixture::without_config(); + let cfg = fx.load(); + assert!(!cfg.jev.enabled, "thiếu [jev] → tắt"); + assert_eq!(cfg.jev.model, codegraph_core::jev::DEFAULT_MODEL); + + fx.rewrite( + r#" +[jev] +enabled = true +model = "jev-1.13" +timeout_ms = 500 +max_candidates = 12 +min_probability = 0.42 +repo = "codegraph-rs" +"#, + ); + let jev = fx.load().jev; + assert!(jev.enabled); + assert_eq!(jev.model, "jev-1.13"); + assert_eq!(jev.timeout_ms, 500); + assert_eq!(jev.max_candidates, 12); + assert!((jev.min_probability - 0.42).abs() < 1e-6); + assert_eq!(jev.repo.as_deref(), Some("codegraph-rs")); + assert_eq!( + jev.endpoint, + codegraph_core::jev::DEFAULT_ENDPOINT, + "không khai báo endpoint → default" + ); + assert_eq!( + jev.api_key_env, + codegraph_core::jev::DEFAULT_API_KEY_ENV, + "token chỉ đọc từ env, không nằm trong config" + ); + } + #[test] fn parse_storage_kind() { assert_eq!(StorageKind::parse("sqlite"), StorageKind::Sqlite); diff --git a/crates/codegraph-graphql/src/types.rs b/crates/codegraph-graphql/src/types.rs index 74a2f25c2f..2933ba5f8d 100644 --- a/crates/codegraph-graphql/src/types.rs +++ b/crates/codegraph-graphql/src/types.rs @@ -77,6 +77,24 @@ pub struct ContextRequestInput { pub limit: Option, pub format: Option, pub strip_prefix: Option, + /// `NONE` (mặc định) hoặc `JEV` — rerank bằng Jev, cần `[jev] enabled = true`. + pub rerank: Option, +} + +/// Cách chọn symbol gốc của `context` (mirror `codegraph_context::Rerank`). +#[derive(Enum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContextRerank { + None, + Jev, +} + +impl From for codegraph_context::Rerank { + fn from(r: ContextRerank) -> Self { + match r { + ContextRerank::None => Self::None, + ContextRerank::Jev => Self::Jev, + } + } } impl From for codegraph_context::ContextRequest { @@ -91,6 +109,10 @@ impl From for codegraph_context::ContextRequest { .map(|f| f.into()) .unwrap_or(CoreCtxFormat::Markdown), strip_prefix: i.strip_prefix, + rerank: i + .rerank + .map(|r| r.into()) + .unwrap_or(codegraph_context::Rerank::None), } } } diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index acd6fdad29..172fb70ecf 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -153,12 +153,13 @@ fn tool_defs() -> Vec { ), tool( "codegraph_context", - "Composed context for a symbol or topic (search + callers + callees + optional source).", + "Composed context for a symbol or topic (search + callers + callees + optional source). \"rerank\": \"jev\" lets Jev pick the closest symbol from a token-expanded candidate pool and returns a probability per hit plus \"no_match\" when nothing fits (requires `[jev] enabled = true` + token env).", json!({ "type": "object", "properties": { "query": { "type": "string" }, "depth": { "type": "integer", "default": 1 }, "include_source": { "type": "boolean", "default": false }, - "limit": { "type": "integer", "default": 5 } + "limit": { "type": "integer", "default": 5 }, + "rerank": { "type": "string", "enum": ["none", "jev"], "default": "none", "description": "\"jev\" = semantic rerank via TypeSafe SystemOne (opt-in, fail-open)." } }, "required": ["query"] }), ), tool( @@ -693,6 +694,11 @@ 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), }; // `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. diff --git a/docs/configuration.md b/docs/configuration.md index f0db48f6c0..6da3922d4b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,6 +66,17 @@ type = "sqlite" # "sqlite" | "lmdb" | "redis" | "memory" | "postgres" | "mysql" # vss_extension = "~/.cache/codegraph/embeddings/vss" # execution_provider = "coreml" # macOS hardware acceleration +# Jev rerank cho codegraph_context — OFF by default (dữ liệu rời máy khi bật) +[jev] +# enabled = true +# endpoint = "https://opencode.ai/zen/v1/systemone" +# model = "jev-1.13-free" # "jev-1.13" = bản trả phí +# api_key_env = "OPENCODE_API_KEY" # token CHỈ đọc từ env, không lưu trong file này +# timeout_ms = 2000 # quá hạn → fail-open về hành vi cũ +# 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 + # Document graph — ingest structured docs (yaml/json/toml/hcl/nginx/markdown) [docgraph] # enabled = true # default: on when `paths` is non-empty @@ -164,6 +175,43 @@ dsns = [ - Schema **not auto-applied** — run SQL files from `sql/postgres/` or `sql/mysql/` manually before indexing - See `sql/README.md` for full schema and sharding design +### `[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). + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `enabled` | `bool` | `false` | Opt-in: khi bật, query + tên symbol + đường dẫn file được gửi tới `endpoint` | +| `endpoint` | `String` | `https://opencode.ai/zen/v1/systemone` | URL đầy đủ của SystemOne endpoint | +| `model` | `String` | `"jev-1.13-free"` | Model id. `jev-1.13` là bản trả phí (phải tự khai báo) | +| `api_key_env` | `String` | `"OPENCODE_API_KEY"` | Tên biến môi trường chứa token — **không** đọc key từ config | +| `timeout_ms` | `u64` | `2000` | Timeout request; quá hạn → fail-open | +| `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 | + +**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. +- 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. +- **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 + hạng 1 (hoặc hạng 1 dưới `min_probability`). Câu hỏi Noul chỉ để chẩn đoán. + +```toml +[jev] +enabled = true +``` + +```bash +export OPENCODE_API_KEY="..." +``` + ### `[embedding]` Enables optional semantic search (vector KNN over symbol embeddings). From 8694fe0f2717272dc20595ef3d6fc7d560d9d3cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=E1=BA=A1m=20Nguy=E1=BB=85n=20Xu=C3=A2n=20H=C3=B9ng?= <136320753+hungpham10@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:34:07 +0700 Subject: [PATCH 2/4] feat(search): rerank symbol and document search with Jev (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(search): rerank symbol and document search with Jev Mở rộng rerank Jev (TypeSafe SystemOne) từ `codegraph_context` sang `codegraph_search_symbol` và 3 tool document search (`graphdoc_search`, `graphdoc_search_value`, `graphdoc_search_struct`). - `codegraph_search_symbol`: arg `rerank = "jev"`, pool = `limit × 4` (cap `[jev] max_candidates`), gắn `match_score.probability`, trả `no_match`/`rerank`. Chỉ trang đầu — `offset > 0` hoặc `resume` bị từ chối vì rerank phá tính ổn định phân trang. - Doc search: cùng arg `rerank`; response đổi thành object `{results, no_match, rerank}` khi bật (mặc định vẫn là array). Candidate = `kind \`key\` in \`doc\`` + value tuỳ chọn. - Config `[jev] doc_include_values` (default false) và `doc_value_chars` (default 120): value giúp phân biệt node trùng key ở nhiều file nhưng tốn token nên là opt-in. - Fail-open toàn bộ: Jev lỗi/tắt/timeout → giữ thứ tự cũ + `rerank.used=false`. - Gộp phần chọn candidate + fail-open vào `codegraph_context::rank()` dùng chung cho cả 3 nguồn. Test: unit cho candidate/apply outcome (MCP), `search_symbol_reranked` với judge giả (API), live test `#[ignore]` thêm case doc-node-nhờ-value (đã chạy thật: chọn đúng id 2, p=0.81). * Bump version to v2.2.13 --- Cargo.lock | 31 +-- Cargo.toml | 2 +- crates/codegraph-api/Cargo.toml | 1 + crates/codegraph-api/src/lib.rs | 108 +++++++- crates/codegraph-api/tests/api.rs | 143 ++++++++++ crates/codegraph-context/src/jev.rs | 297 +++++++++++++++++---- crates/codegraph-context/src/lib.rs | 152 ++++------- crates/codegraph-context/tests/jev_live.rs | 82 +++++- crates/codegraph-core/src/jev.rs | 27 +- crates/codegraph-extract/src/config.rs | 12 + crates/codegraph-mcp/src/lib.rs | 9 +- crates/codegraph-mcp/src/rerank_tests.rs | 247 +++++++++++++++++ crates/codegraph-mcp/src/tools.rs | 258 ++++++++++++++++-- docs/configuration.md | 24 +- packaging/aur/codegraph-rs-bin/PKGBUILD | 2 +- packaging/choco/codegraph.nuspec | 2 +- packaging/winget/codegraph.yaml | 4 +- scripts/install.ps1 | 4 +- 18 files changed, 1183 insertions(+), 222 deletions(-) create mode 100644 crates/codegraph-mcp/src/rerank_tests.rs 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 ) From 677659e3b7fa2d775d186f4f35150c6144b8f7a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=E1=BA=A1m=20Nguy=E1=BB=85n=20Xu=C3=A2n=20H=C3=B9ng?= <136320753+hungpham10@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:34:38 +0700 Subject: [PATCH 3/4] Potential fix for pull request finding 'CodeQL / Cleartext logging of sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- crates/codegraph-context/tests/jev_live.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/codegraph-context/tests/jev_live.rs b/crates/codegraph-context/tests/jev_live.rs index ee632785ae..a6fe33fb48 100644 --- a/crates/codegraph-context/tests/jev_live.rs +++ b/crates/codegraph-context/tests/jev_live.rs @@ -34,7 +34,10 @@ fn cfg() -> JevConfig { #[ignore = "gọi API thật: cần OPENCODE_API_KEY + mạng"] async fn jev_chọn_đúng_symbol_và_trả_xác_suất() { let cfg = cfg(); - assert!(cfg.api_key().is_some(), "thiếu env `{}`", cfg.api_key_env); + assert!( + cfg.api_key().is_some(), + "thiếu biến môi trường API key cho Jev" + ); let candidates = vec![ candidate( 1, From 37ed9d74477b0f29c3d054548b1151a6bed77709 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Thu, 29 Oct 2026 06:34:31 +0700 Subject: [PATCH 4/4] =?UTF-8?q?docs(config):=20th=C3=AAm=20block=20[jev]?= =?UTF-8?q?=20v=C3=A0o=20config.toml=20m=E1=BA=B7c=20=C4=91=E1=BB=8Bnh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theo quy ước: thêm config key phải cập nhật DEFAULT_CONFIG_TOML để sinh file mẫu có đủ key (kèm ghi chú opt-in + token chỉ đọc từ env). --- crates/codegraph-extract/src/config.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/codegraph-extract/src/config.rs b/crates/codegraph-extract/src/config.rs index e934ecc436..bad166b07f 100644 --- a/crates/codegraph-extract/src/config.rs +++ b/crates/codegraph-extract/src/config.rs @@ -974,6 +974,22 @@ type = "sqlite" # type = "sqlite" # dsn = "sqlite:///tmp/binary.db" +# [jev] +# Rerank ngữ nghĩa bằng Jev (TypeSafe SystemOne) cho `codegraph_context`, +# `codegraph_search_symbol` và các tool `codegraph_graphdoc_search*`. +# OPT-IN: cần `enabled = true` + token trong env (`api_key_env`). +# [jev] +# enabled = true +# endpoint = "https://opencode.ai/zen/v1/systemone" +# model = "jev-1.13-free" # "jev-1.13" = bản trả phí +# api_key_env = "OPENCODE_API_KEY" # token CHỈ đọc từ env, không lưu trong file này +# timeout_ms = 2000 # quá hạn → fail-open (giữ hành vi cũ) +# max_candidates = 40 # số candidate gửi cho Jev +# min_probability = 0.25 # xác suất tối thiểu của candidate 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 + # [docgraph] # Document graph — ingest tài liệu cấu trúc (HCL/Terraform, YAML, JSON, TOML) # lúc `codegraph init`, truy vấn qua MCP (`codegraph_graphdoc_*`) hoặc `codegraph doc`.