Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fe47babd9 | ||
|
|
6d7b251607 | ||
|
|
8bacd43465 | ||
|
|
e57ab8ad01 |
@@ -26,3 +26,4 @@ chrono = { workspace = true }
|
|||||||
instant-distance = { workspace = true }
|
instant-distance = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Phase 45 slice 3 — context7 bridge client for doc-drift detection.
|
||||||
|
//
|
||||||
|
// Calls the Bun context7 bridge on :3900 (mcp-server/context7_bridge.ts)
|
||||||
|
// which itself wraps context7's public API. For each DocRef on a
|
||||||
|
// playbook, queries /docs/:tool/diff?since=<snippet_hash> and parses
|
||||||
|
// the response.
|
||||||
|
//
|
||||||
|
// Kept deliberately thin: no caching here (bridge caches for 5 min),
|
||||||
|
// no retry logic (transient fail = report as "unknown" drift status,
|
||||||
|
// don't flag). The handler decides whether to flag the playbook based
|
||||||
|
// on whether ANY tool came back drifted=true.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::playbook_memory::DocRef;
|
||||||
|
|
||||||
|
const DEFAULT_BRIDGE_URL: &str = "http://localhost:3900";
|
||||||
|
const CALL_TIMEOUT_SECS: u64 = 15;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DriftCheck {
|
||||||
|
pub tool: String,
|
||||||
|
pub version_seen: String,
|
||||||
|
pub outcome: DriftOutcome,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum DriftOutcome {
|
||||||
|
/// Bridge reports drift (current snippet_hash != previous).
|
||||||
|
Drifted { current_snippet_hash: String, source_url: Option<String> },
|
||||||
|
/// Bridge reports no drift — the stored snippet_hash still matches
|
||||||
|
/// the current context7 docs.
|
||||||
|
Unchanged,
|
||||||
|
/// Bridge unreachable, 404 on the tool, or returned unparseable
|
||||||
|
/// data. We deliberately don't flag on these — a down bridge
|
||||||
|
/// shouldn't silently mark every playbook drift-flagged.
|
||||||
|
Unknown { reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct BridgeDiffResponse {
|
||||||
|
drifted: bool,
|
||||||
|
current_snippet_hash: Option<String>,
|
||||||
|
source_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DriftCheckerConfig {
|
||||||
|
pub bridge_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DriftCheckerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
bridge_url: std::env::var("LH_BRIDGE_URL")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_BRIDGE_URL.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For every doc_ref, ask the bridge whether it drifted against the
|
||||||
|
/// recorded snippet_hash. Returns per-tool outcomes.
|
||||||
|
pub async fn check_all_refs(
|
||||||
|
cfg: &DriftCheckerConfig,
|
||||||
|
doc_refs: &[DocRef],
|
||||||
|
) -> Vec<DriftCheck> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(CALL_TIMEOUT_SECS))
|
||||||
|
.build()
|
||||||
|
.expect("reqwest client build");
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(doc_refs.len());
|
||||||
|
for r in doc_refs {
|
||||||
|
let hash = r.snippet_hash.as_deref().unwrap_or("");
|
||||||
|
if hash.is_empty() {
|
||||||
|
// No hash to compare against — can't detect drift. Report
|
||||||
|
// unknown so the caller isn't forced to flag.
|
||||||
|
out.push(DriftCheck {
|
||||||
|
tool: r.tool.clone(),
|
||||||
|
version_seen: r.version_seen.clone(),
|
||||||
|
outcome: DriftOutcome::Unknown {
|
||||||
|
reason: "no snippet_hash recorded on doc_ref".into(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let url = format!(
|
||||||
|
"{}/docs/{}/diff?since={}",
|
||||||
|
cfg.bridge_url.trim_end_matches('/'),
|
||||||
|
urlencoding_minimal(&r.tool),
|
||||||
|
urlencoding_minimal(hash),
|
||||||
|
);
|
||||||
|
let outcome = match client.get(&url).send().await {
|
||||||
|
Err(e) => DriftOutcome::Unknown {
|
||||||
|
reason: format!("bridge unreachable: {}", e),
|
||||||
|
},
|
||||||
|
Ok(resp) => {
|
||||||
|
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
DriftOutcome::Unknown {
|
||||||
|
reason: format!("bridge 404 — no context7 library for tool '{}'", r.tool),
|
||||||
|
}
|
||||||
|
} else if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
DriftOutcome::Unknown {
|
||||||
|
reason: format!("bridge {}: {}", status, resp.text().await.unwrap_or_default()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match resp.json::<BridgeDiffResponse>().await {
|
||||||
|
Err(e) => DriftOutcome::Unknown {
|
||||||
|
reason: format!("bridge response parse: {}", e),
|
||||||
|
},
|
||||||
|
Ok(body) => {
|
||||||
|
if body.drifted {
|
||||||
|
DriftOutcome::Drifted {
|
||||||
|
current_snippet_hash: body.current_snippet_hash.unwrap_or_default(),
|
||||||
|
source_url: body.source_url,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DriftOutcome::Unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.push(DriftCheck {
|
||||||
|
tool: r.tool.clone(),
|
||||||
|
version_seen: r.version_seen.clone(),
|
||||||
|
outcome,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal URL path+query encoder for ASCII tool names. We're not
|
||||||
|
/// pulling in the `urlencoding` crate for this single use — tool
|
||||||
|
/// names are short alphanumeric (docker/terraform/react), snippet
|
||||||
|
/// hashes are hex. Handles space + `/` + `?` + `&` defensively so
|
||||||
|
/// unusual tool names don't corrupt the URL.
|
||||||
|
fn urlencoding_minimal(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for b in s.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
|
||||||
|
_ => out.push_str(&format!("%{:02X}", b)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urlencoding_handles_ascii_safe_chars_passthrough() {
|
||||||
|
assert_eq!(urlencoding_minimal("docker"), "docker");
|
||||||
|
assert_eq!(urlencoding_minimal("next.js"), "next.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urlencoding_encodes_slash_and_space() {
|
||||||
|
assert_eq!(urlencoding_minimal("foo/bar"), "foo%2Fbar");
|
||||||
|
assert_eq!(urlencoding_minimal("foo bar"), "foo%20bar");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// Phase 45 slice 4 — T3 drift-correction synthesis.
|
||||||
|
//
|
||||||
|
// When a playbook is flagged for doc drift, the auditor / operator
|
||||||
|
// wants to know WHAT changed and WHAT the playbook should do
|
||||||
|
// differently — not just that something drifted. This module asks
|
||||||
|
// gpt-oss:120b on Ollama Cloud to synthesize that explanation and
|
||||||
|
// appends it to data/_kb/doc_drift_corrections.jsonl.
|
||||||
|
//
|
||||||
|
// Fire-and-forget: hooked into /check via tokio::spawn so the
|
||||||
|
// response to the operator returns immediately. Cloud failures are
|
||||||
|
// logged and dropped — they never block the flag itself.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const CLOUD_URL: &str = "https://ollama.com/api/generate";
|
||||||
|
const SYNTH_MODEL: &str = "gpt-oss:120b";
|
||||||
|
const CALL_TIMEOUT_SECS: u64 = 90;
|
||||||
|
const CORRECTIONS_FILE: &str = "/home/profit/lakehouse/data/_kb/doc_drift_corrections.jsonl";
|
||||||
|
|
||||||
|
/// One drift-correction record. Mirrors the shape declared in
|
||||||
|
/// docs/CONTROL_PLANE_PRD.md Phase 45 spec.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DriftCorrection {
|
||||||
|
pub playbook_id: String,
|
||||||
|
pub operation: String,
|
||||||
|
pub tool: String,
|
||||||
|
pub version_seen: String,
|
||||||
|
pub current_snippet_hash: Option<String>,
|
||||||
|
pub diff_summary: String,
|
||||||
|
pub recommended_action: String,
|
||||||
|
pub generated_at: String,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the Ollama Cloud key from the same sources v1/ollama_cloud.rs
|
||||||
|
/// uses. Returns None if not available — caller should skip synthesis.
|
||||||
|
pub fn resolve_cloud_key() -> Option<String> {
|
||||||
|
if let Ok(k) = std::env::var("OLLAMA_CLOUD_KEY") {
|
||||||
|
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
||||||
|
}
|
||||||
|
if let Ok(raw) = std::fs::read_to_string("/root/llm_team_config.json") {
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||||
|
if let Some(k) = v.pointer("/providers/ollama_cloud/api_key").and_then(|x| x.as_str()) {
|
||||||
|
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(k) = std::env::var("OLLAMA_CLOUD_API_KEY") {
|
||||||
|
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the cloud model to produce a diff_summary + recommended_action
|
||||||
|
/// for a playbook's specific drift. Returns a complete DriftCorrection
|
||||||
|
/// ready to append. Caller-provided docs_preview (from the context7
|
||||||
|
/// bridge's diff response) is optional but dramatically improves
|
||||||
|
/// specificity when available.
|
||||||
|
pub async fn synthesize(
|
||||||
|
playbook_id: &str,
|
||||||
|
operation: &str,
|
||||||
|
tool: &str,
|
||||||
|
version_seen: &str,
|
||||||
|
current_snippet_hash: Option<&str>,
|
||||||
|
docs_preview: Option<&str>,
|
||||||
|
cloud_key: &str,
|
||||||
|
) -> Result<DriftCorrection, String> {
|
||||||
|
let prompt = build_prompt(tool, version_seen, operation, docs_preview);
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"model": SYNTH_MODEL,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": false,
|
||||||
|
"options": { "temperature": 0.2, "num_predict": 600 },
|
||||||
|
});
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(CALL_TIMEOUT_SECS))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("client build: {e}"))?;
|
||||||
|
let resp = client
|
||||||
|
.post(CLOUD_URL)
|
||||||
|
.bearer_auth(cloud_key)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("cloud unreachable: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"cloud {}: {}",
|
||||||
|
resp.status(),
|
||||||
|
resp.text().await.unwrap_or_default().chars().take(200).collect::<String>(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let j: serde_json::Value = resp.json().await.map_err(|e| format!("parse response: {e}"))?;
|
||||||
|
let text: &str = j.get("response").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let parsed = extract_json_object(text).ok_or_else(|| {
|
||||||
|
format!("cloud output not valid JSON — head: {}", text.chars().take(200).collect::<String>())
|
||||||
|
})?;
|
||||||
|
let diff_summary = parsed
|
||||||
|
.get("diff_summary").and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("").trim().to_string();
|
||||||
|
let recommended_action = parsed
|
||||||
|
.get("recommended_action").and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("").trim().to_string();
|
||||||
|
if diff_summary.is_empty() && recommended_action.is_empty() {
|
||||||
|
return Err("cloud returned JSON but diff_summary + recommended_action both empty".into());
|
||||||
|
}
|
||||||
|
Ok(DriftCorrection {
|
||||||
|
playbook_id: playbook_id.to_string(),
|
||||||
|
operation: operation.to_string(),
|
||||||
|
tool: tool.to_string(),
|
||||||
|
version_seen: version_seen.to_string(),
|
||||||
|
current_snippet_hash: current_snippet_hash.map(String::from),
|
||||||
|
diff_summary,
|
||||||
|
recommended_action,
|
||||||
|
generated_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
model: SYNTH_MODEL.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one correction as a JSONL line. Creates the parent dir and
|
||||||
|
/// file if missing. Atomic at the line level (single write_all); no
|
||||||
|
/// guarantee against concurrent writers interleaving bytes, but
|
||||||
|
/// tokio's append mode on Linux is effectively atomic for typical
|
||||||
|
/// correction sizes (< 4KB).
|
||||||
|
pub async fn append_correction(c: &DriftCorrection) -> Result<(), String> {
|
||||||
|
use tokio::fs::{create_dir_all, OpenOptions};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
let path = std::path::PathBuf::from(CORRECTIONS_FILE);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
create_dir_all(parent).await.map_err(|e| format!("mkdir _kb: {e}"))?;
|
||||||
|
}
|
||||||
|
let mut f = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("open corrections: {e}"))?;
|
||||||
|
let mut line = serde_json::to_string(c).map_err(|e| format!("serialize: {e}"))?;
|
||||||
|
line.push('\n');
|
||||||
|
f.write_all(line.as_bytes()).await.map_err(|e| format!("write corrections: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a fire-and-forget synthesis + append task. Never blocks the
|
||||||
|
/// caller. On failure, logs warn. On success, appends the correction.
|
||||||
|
/// The caller (typically a check or scan handler) doesn't need to
|
||||||
|
/// await — the operator's response goes out immediately.
|
||||||
|
pub fn spawn_synthesize_and_append(
|
||||||
|
playbook_id: String,
|
||||||
|
operation: String,
|
||||||
|
tool: String,
|
||||||
|
version_seen: String,
|
||||||
|
current_snippet_hash: Option<String>,
|
||||||
|
docs_preview: Option<String>,
|
||||||
|
) {
|
||||||
|
let Some(key) = resolve_cloud_key() else {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "drift_synth",
|
||||||
|
playbook_id,
|
||||||
|
tool,
|
||||||
|
"no cloud key — synthesis skipped",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let r = synthesize(
|
||||||
|
&playbook_id,
|
||||||
|
&operation,
|
||||||
|
&tool,
|
||||||
|
&version_seen,
|
||||||
|
current_snippet_hash.as_deref(),
|
||||||
|
docs_preview.as_deref(),
|
||||||
|
&key,
|
||||||
|
).await;
|
||||||
|
match r {
|
||||||
|
Ok(c) => match append_correction(&c).await {
|
||||||
|
Ok(_) => tracing::info!(
|
||||||
|
target: "drift_synth",
|
||||||
|
playbook_id = c.playbook_id,
|
||||||
|
tool = c.tool,
|
||||||
|
"drift correction appended",
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!(target: "drift_synth", "append failed: {e}"),
|
||||||
|
},
|
||||||
|
Err(e) => tracing::warn!(target: "drift_synth", "synthesis failed: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_prompt(tool: &str, version_seen: &str, operation: &str, preview: Option<&str>) -> String {
|
||||||
|
let preview_block = match preview {
|
||||||
|
Some(p) if !p.trim().is_empty() => format!(
|
||||||
|
"Current docs preview (first ~400 chars from context7):\n---\n{}\n---\n",
|
||||||
|
p.chars().take(1000).collect::<String>(),
|
||||||
|
),
|
||||||
|
_ => "(no current docs preview available)\n".to_string(),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"A playbook references tool `{tool}` at version_seen=`{version_seen}`. \
|
||||||
|
The docs for that tool have drifted upstream and the playbook's captured \
|
||||||
|
snippet_hash no longer matches.
|
||||||
|
|
||||||
|
Playbook operation: {operation}
|
||||||
|
|
||||||
|
{preview_block}
|
||||||
|
Respond with STRICT JSON ONLY, no prose, no markdown fences. Shape:
|
||||||
|
{{
|
||||||
|
\"diff_summary\": \"<1-2 sentences describing what meaningful change in the tool's docs is visible in the preview, or state clearly that the preview is insufficient to tell>\",
|
||||||
|
\"recommended_action\": \"<1-2 sentences on what the playbook should do differently now, given what you can see>\"
|
||||||
|
}}
|
||||||
|
|
||||||
|
If the preview doesn't give you enough information, say so in diff_summary honestly. Don't fabricate a diff."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_json_object(text: &str) -> Option<serde_json::Value> {
|
||||||
|
// Strip common code-fence wrappers.
|
||||||
|
let cleaned = text
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches("```json")
|
||||||
|
.trim_start_matches("```")
|
||||||
|
.trim_end_matches("```")
|
||||||
|
.trim();
|
||||||
|
let bytes = cleaned.as_bytes();
|
||||||
|
let mut depth = 0i32;
|
||||||
|
let mut start: Option<usize> = None;
|
||||||
|
for (i, &b) in bytes.iter().enumerate() {
|
||||||
|
match b {
|
||||||
|
b'{' => {
|
||||||
|
if depth == 0 {
|
||||||
|
start = Some(i);
|
||||||
|
}
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
b'}' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
if let Some(s) = start {
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&cleaned[s..=i]) {
|
||||||
|
return Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_json_finds_first_complete_object() {
|
||||||
|
let text = r#"prose here {"a": 1, "b": [1,2]} trailing"#;
|
||||||
|
let got = extract_json_object(text).expect("should parse");
|
||||||
|
assert_eq!(got["a"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_json_handles_code_fences() {
|
||||||
|
let text = "```json\n{\"diff_summary\": \"x\", \"recommended_action\": \"y\"}\n```";
|
||||||
|
let got = extract_json_object(text).unwrap();
|
||||||
|
assert_eq!(got["diff_summary"], "x");
|
||||||
|
assert_eq!(got["recommended_action"], "y");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_json_rejects_unclosed_object() {
|
||||||
|
let text = "{ \"a\": 1, \"b\": ";
|
||||||
|
assert!(extract_json_object(text).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn append_correction_creates_parent_dir_and_appends_jsonl() {
|
||||||
|
// Use a temp path under /tmp so we don't pollute the real
|
||||||
|
// data/_kb. Override via a local fn that takes the path;
|
||||||
|
// append_correction is bound to CORRECTIONS_FILE so we write
|
||||||
|
// a mirror helper here instead of mocking globals.
|
||||||
|
let tmp = std::env::temp_dir().join("lakehouse_drift_synth_test");
|
||||||
|
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
||||||
|
let nested = tmp.join("sub/dir/doc_drift_corrections.jsonl");
|
||||||
|
tokio::fs::create_dir_all(nested.parent().unwrap()).await.unwrap();
|
||||||
|
|
||||||
|
let c = DriftCorrection {
|
||||||
|
playbook_id: "pb-1".into(),
|
||||||
|
operation: "fill: X in Y, Z".into(),
|
||||||
|
tool: "docker".into(),
|
||||||
|
version_seen: "24.0.7".into(),
|
||||||
|
current_snippet_hash: Some("abc".into()),
|
||||||
|
diff_summary: "docs changed default TLS policy".into(),
|
||||||
|
recommended_action: "update the playbook step to set TLS explicitly".into(),
|
||||||
|
generated_at: "2026-04-22T00:00:00Z".into(),
|
||||||
|
model: "gpt-oss:120b".into(),
|
||||||
|
};
|
||||||
|
let line = serde_json::to_string(&c).unwrap();
|
||||||
|
tokio::fs::write(&nested, format!("{line}\n")).await.unwrap();
|
||||||
|
|
||||||
|
// Read back and verify shape
|
||||||
|
let back = tokio::fs::read_to_string(&nested).await.unwrap();
|
||||||
|
assert!(back.ends_with('\n'), "line should end with newline");
|
||||||
|
let parsed: DriftCorrection = serde_json::from_str(back.trim()).unwrap();
|
||||||
|
assert_eq!(parsed.playbook_id, "pb-1");
|
||||||
|
assert_eq!(parsed.tool, "docker");
|
||||||
|
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_prompt_includes_tool_and_operation() {
|
||||||
|
let p = build_prompt("terraform", "1.5.0", "fill: Electrician x2 in Cleveland, OH", Some("provider registry changed"));
|
||||||
|
assert!(p.contains("terraform"));
|
||||||
|
assert!(p.contains("1.5.0"));
|
||||||
|
assert!(p.contains("Cleveland"));
|
||||||
|
assert!(p.contains("provider registry changed"));
|
||||||
|
assert!(p.contains("STRICT JSON"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_prompt_handles_missing_preview_honestly() {
|
||||||
|
let p = build_prompt("docker", "24.0", "fill: X", None);
|
||||||
|
assert!(p.contains("no current docs preview available"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ pub mod hnsw;
|
|||||||
pub mod index_registry;
|
pub mod index_registry;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod playbook_memory;
|
pub mod playbook_memory;
|
||||||
|
pub mod doc_drift;
|
||||||
|
pub mod drift_synth;
|
||||||
pub mod promotion;
|
pub mod promotion;
|
||||||
pub mod refresh;
|
pub mod refresh;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|||||||
@@ -398,6 +398,64 @@ impl PlaybookMemory {
|
|||||||
Ok(touched)
|
Ok(touched)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 45 slice 3 — stamp `doc_drift_flagged_at` on a playbook.
|
||||||
|
/// Idempotent: if already flagged and not yet reviewed, this is a
|
||||||
|
/// no-op. Callers should check the return value: Ok(true) means we
|
||||||
|
/// made a new flag, Ok(false) means already flagged (unreviewed) or
|
||||||
|
/// playbook not found, Err means persist failed.
|
||||||
|
pub async fn flag_doc_drift(&self, playbook_id: &str) -> Result<bool, String> {
|
||||||
|
let mut touched = false;
|
||||||
|
{
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
for e in state.entries.iter_mut() {
|
||||||
|
if e.playbook_id == playbook_id {
|
||||||
|
// Skip if already flagged + unreviewed (avoid
|
||||||
|
// churning the timestamp on every re-check).
|
||||||
|
if e.doc_drift_flagged_at.is_some() && e.doc_drift_reviewed_at.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
e.doc_drift_flagged_at = Some(chrono::Utc::now().to_rfc3339());
|
||||||
|
e.doc_drift_reviewed_at = None; // fresh flag clears any stale review
|
||||||
|
touched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if touched {
|
||||||
|
self.persist().await?;
|
||||||
|
}
|
||||||
|
Ok(touched)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 45 slice 3 — stamp `doc_drift_reviewed_at` on a playbook,
|
||||||
|
/// re-admitting it to boost calculation. Idempotent.
|
||||||
|
pub async fn resolve_doc_drift(&self, playbook_id: &str) -> Result<bool, String> {
|
||||||
|
let mut touched = false;
|
||||||
|
{
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
for e in state.entries.iter_mut() {
|
||||||
|
if e.playbook_id == playbook_id && e.doc_drift_flagged_at.is_some()
|
||||||
|
&& e.doc_drift_reviewed_at.is_none()
|
||||||
|
{
|
||||||
|
e.doc_drift_reviewed_at = Some(chrono::Utc::now().to_rfc3339());
|
||||||
|
touched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if touched {
|
||||||
|
self.persist().await?;
|
||||||
|
}
|
||||||
|
Ok(touched)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only: get a playbook entry by id. Used by the drift-check
|
||||||
|
/// handler to read doc_refs without exposing the full state lock.
|
||||||
|
pub async fn get_entry(&self, playbook_id: &str) -> Option<PlaybookEntry> {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
state.entries.iter().find(|e| e.playbook_id == playbook_id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
/// Phase 25 — retire every entry matching (city, state) whose
|
/// Phase 25 — retire every entry matching (city, state) whose
|
||||||
/// schema_fingerprint doesn't match the current one. Entries with
|
/// schema_fingerprint doesn't match the current one. Entries with
|
||||||
/// no fingerprint (legacy) are skipped — caller can use
|
/// no fingerprint (legacy) are skipped — caller can use
|
||||||
@@ -785,6 +843,13 @@ impl PlaybookMemory {
|
|||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
if e.retired_at.is_some() { return false; }
|
if e.retired_at.is_some() { return false; }
|
||||||
if e.superseded_at.is_some() { return false; }
|
if e.superseded_at.is_some() { return false; }
|
||||||
|
// Phase 45 slice 3 — entries flagged for doc drift and
|
||||||
|
// not yet human-reviewed are excluded from boost. The
|
||||||
|
// flag is the same strength of exclusion as retirement
|
||||||
|
// for this purpose; reviewing (via /resolve) re-admits.
|
||||||
|
if e.doc_drift_flagged_at.is_some() && e.doc_drift_reviewed_at.is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if let Some(vu) = &e.valid_until {
|
if let Some(vu) = &e.valid_until {
|
||||||
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(vu) {
|
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(vu) {
|
||||||
if now > parsed.with_timezone(&chrono::Utc) { return false; }
|
if now > parsed.with_timezone(&chrono::Utc) { return false; }
|
||||||
@@ -815,6 +880,13 @@ impl PlaybookMemory {
|
|||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
if e.retired_at.is_some() { return false; }
|
if e.retired_at.is_some() { return false; }
|
||||||
if e.superseded_at.is_some() { return false; }
|
if e.superseded_at.is_some() { return false; }
|
||||||
|
// Phase 45 slice 3 — same drift exclusion as the
|
||||||
|
// non-geo path above. Keeps the two filter paths
|
||||||
|
// consistent so a flagged entry is invisible to
|
||||||
|
// BOTH geo-targeted and global boost queries.
|
||||||
|
if e.doc_drift_flagged_at.is_some() && e.doc_drift_reviewed_at.is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if let Some(vu) = &e.valid_until {
|
if let Some(vu) = &e.valid_until {
|
||||||
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(vu) {
|
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(vu) {
|
||||||
if now > parsed.with_timezone(&chrono::Utc) { return false; }
|
if now > parsed.with_timezone(&chrono::Utc) { return false; }
|
||||||
@@ -1795,6 +1867,86 @@ mod upsert_tests {
|
|||||||
assert_eq!(snap[0].doc_refs[0].tool, "docker");
|
assert_eq!(snap[0].doc_refs[0].tool, "docker");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Phase 45 slice 3 — doc_drift flag / resolve / boost exclusion ───
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn flag_doc_drift_stamps_timestamp_and_persists() {
|
||||||
|
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||||
|
let e = mk("fill: Welder x1 in Nashville, TN", "2026-04-22", &["Alice"]);
|
||||||
|
pm.set_entries(vec![e.clone()]).await.unwrap();
|
||||||
|
let touched = pm.flag_doc_drift(&e.playbook_id).await.unwrap();
|
||||||
|
assert!(touched, "first flag should return true");
|
||||||
|
|
||||||
|
let fetched = pm.get_entry(&e.playbook_id).await.unwrap();
|
||||||
|
assert!(fetched.doc_drift_flagged_at.is_some(), "flag_doc_drift should stamp the timestamp");
|
||||||
|
assert!(fetched.doc_drift_reviewed_at.is_none(), "reviewed_at starts None on fresh flag");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn flag_doc_drift_is_idempotent_on_already_flagged() {
|
||||||
|
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||||
|
let mut e = mk("fill: Welder x1 in Nashville, TN", "2026-04-22", &["Alice"]);
|
||||||
|
e.doc_drift_flagged_at = Some("2026-04-01T00:00:00Z".into());
|
||||||
|
pm.set_entries(vec![e.clone()]).await.unwrap();
|
||||||
|
let touched = pm.flag_doc_drift(&e.playbook_id).await.unwrap();
|
||||||
|
assert!(!touched, "already-flagged, unreviewed entry shouldn't re-stamp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolve_doc_drift_clears_flag_admission_gate() {
|
||||||
|
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||||
|
let mut e = mk("fill: Welder x1 in Nashville, TN", "2026-04-22", &["Alice"]);
|
||||||
|
e.doc_drift_flagged_at = Some("2026-04-21T00:00:00Z".into());
|
||||||
|
pm.set_entries(vec![e.clone()]).await.unwrap();
|
||||||
|
|
||||||
|
let resolved = pm.resolve_doc_drift(&e.playbook_id).await.unwrap();
|
||||||
|
assert!(resolved);
|
||||||
|
let fetched = pm.get_entry(&e.playbook_id).await.unwrap();
|
||||||
|
assert!(fetched.doc_drift_reviewed_at.is_some());
|
||||||
|
|
||||||
|
// Second resolve is a no-op
|
||||||
|
let again = pm.resolve_doc_drift(&e.playbook_id).await.unwrap();
|
||||||
|
assert!(!again);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boost_excludes_flagged_unreviewed_entries() {
|
||||||
|
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||||
|
let e_clean = mk("fill: Welder x1 in Nashville, TN", "2026-04-22", &["Alice"]);
|
||||||
|
let mut e_flagged = mk("fill: Welder x1 in Nashville, TN", "2026-04-21", &["Bob"]);
|
||||||
|
e_flagged.doc_drift_flagged_at = Some("2026-04-22T00:00:00Z".into());
|
||||||
|
e_flagged.doc_drift_reviewed_at = None;
|
||||||
|
pm.set_entries(vec![e_clean, e_flagged]).await.unwrap();
|
||||||
|
|
||||||
|
let boosts = pm
|
||||||
|
.compute_boost_for_filtered_with_role(
|
||||||
|
&[1.0, 0.0, 0.0], 10, 0.5,
|
||||||
|
Some(("Nashville", "TN")), None,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let keys: Vec<_> = boosts.keys().map(|(_, _, n)| n.clone()).collect();
|
||||||
|
assert!(keys.contains(&"Alice".to_string()));
|
||||||
|
assert!(!keys.contains(&"Bob".to_string()), "flagged+unreviewed entry leaked into boost");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boost_re_admits_resolved_entries() {
|
||||||
|
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||||
|
let mut e = mk("fill: Welder x1 in Nashville, TN", "2026-04-22", &["Alice"]);
|
||||||
|
e.doc_drift_flagged_at = Some("2026-04-22T00:00:00Z".into());
|
||||||
|
e.doc_drift_reviewed_at = Some("2026-04-22T00:01:00Z".into()); // human reviewed
|
||||||
|
pm.set_entries(vec![e]).await.unwrap();
|
||||||
|
|
||||||
|
let boosts = pm
|
||||||
|
.compute_boost_for_filtered_with_role(
|
||||||
|
&[1.0, 0.0, 0.0], 10, 0.5,
|
||||||
|
Some(("Nashville", "TN")), None,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let keys: Vec<_> = boosts.keys().map(|(_, _, n)| n.clone()).collect();
|
||||||
|
assert!(keys.contains(&"Alice".to_string()), "resolved entry should re-enter boost pool");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_refreshes_valid_until_when_caller_provides_one() {
|
async fn update_refreshes_valid_until_when_caller_provides_one() {
|
||||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ pub fn router(state: VectorState) -> Router {
|
|||||||
.route("/playbook_memory/revise", post(revise_playbook_memory))
|
.route("/playbook_memory/revise", post(revise_playbook_memory))
|
||||||
.route("/playbook_memory/history/{id}", get(playbook_memory_history))
|
.route("/playbook_memory/history/{id}", get(playbook_memory_history))
|
||||||
.route("/playbook_memory/status", get(playbook_memory_status))
|
.route("/playbook_memory/status", get(playbook_memory_status))
|
||||||
|
// Phase 45 slice 3 — doc drift detection + human re-admission.
|
||||||
|
.route("/playbook_memory/doc_drift/check/{id}", post(check_doc_drift))
|
||||||
|
.route("/playbook_memory/doc_drift/resolve/{id}", post(resolve_doc_drift))
|
||||||
|
// Phase 45 slice 4 — batch scan across all active playbooks.
|
||||||
|
.route("/playbook_memory/doc_drift/scan", post(scan_doc_drift))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2500,6 +2505,217 @@ async fn retire_playbook_memory(
|
|||||||
"supply either {playbook_id, reason} or {city, state, current_schema_fingerprint, reason}".into()))
|
"supply either {playbook_id, reason} or {city, state, current_schema_fingerprint, reason}".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 45 slice 3 — POST /playbook_memory/doc_drift/check/{id}
|
||||||
|
///
|
||||||
|
/// Iterates the playbook's `doc_refs`, asks the context7 bridge whether
|
||||||
|
/// each one drifted against the recorded snippet_hash. If any tool
|
||||||
|
/// returned `drifted: true`, stamps `doc_drift_flagged_at` on the
|
||||||
|
/// entry — which excludes it from boost (via the filter in
|
||||||
|
/// `compute_boost_for_filtered_with_role`) until a human reviews and
|
||||||
|
/// resolves.
|
||||||
|
///
|
||||||
|
/// Unknown outcomes (bridge down, tool not in context7, no snippet
|
||||||
|
/// hash) are explicitly NOT enough to flag. Only a positive drifted=true
|
||||||
|
/// from the bridge flips the flag.
|
||||||
|
async fn check_doc_drift(
|
||||||
|
State(state): State<VectorState>,
|
||||||
|
axum::extract::Path(id): axum::extract::Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
use crate::doc_drift::{check_all_refs, DriftCheckerConfig, DriftOutcome};
|
||||||
|
|
||||||
|
let entry = state.playbook_memory.get_entry(&id).await
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, format!("playbook not found: {id}")))?;
|
||||||
|
|
||||||
|
if entry.doc_refs.is_empty() {
|
||||||
|
return Ok(Json(serde_json::json!({
|
||||||
|
"playbook_id": id,
|
||||||
|
"checked_tools": [],
|
||||||
|
"drifted": false,
|
||||||
|
"flagged": false,
|
||||||
|
"reason": "entry has no doc_refs — nothing to check",
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg = DriftCheckerConfig::default();
|
||||||
|
let results = check_all_refs(&cfg, &entry.doc_refs).await;
|
||||||
|
|
||||||
|
let per_tool: Vec<serde_json::Value> = results.iter().map(|r| {
|
||||||
|
let (drifted, current, src, reason) = match &r.outcome {
|
||||||
|
DriftOutcome::Drifted { current_snippet_hash, source_url } =>
|
||||||
|
(true, Some(current_snippet_hash.clone()), source_url.clone(), None),
|
||||||
|
DriftOutcome::Unchanged =>
|
||||||
|
(false, None, None, None),
|
||||||
|
DriftOutcome::Unknown { reason } =>
|
||||||
|
(false, None, None, Some(reason.clone())),
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"tool": r.tool,
|
||||||
|
"version_seen": r.version_seen,
|
||||||
|
"drifted": drifted,
|
||||||
|
"current_snippet_hash": current,
|
||||||
|
"source_url": src,
|
||||||
|
"unknown_reason": reason,
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
let any_drifted = results.iter().any(|r| matches!(r.outcome, DriftOutcome::Drifted { .. }));
|
||||||
|
|
||||||
|
let flagged = if any_drifted {
|
||||||
|
state.playbook_memory.flag_doc_drift(&id).await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("flag: {e}")))?
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 45 slice 4 — fire-and-forget T3 synthesis per drifted tool.
|
||||||
|
// Never blocks this response: spawn_synthesize_and_append uses
|
||||||
|
// tokio::spawn internally, logs warn on any failure, appends to
|
||||||
|
// data/_kb/doc_drift_corrections.jsonl on success. No cloud key
|
||||||
|
// present → synthesis is skipped silently.
|
||||||
|
if flagged {
|
||||||
|
for r in &results {
|
||||||
|
if let crate::doc_drift::DriftOutcome::Drifted { current_snippet_hash, source_url: _ } = &r.outcome {
|
||||||
|
crate::drift_synth::spawn_synthesize_and_append(
|
||||||
|
entry.playbook_id.clone(),
|
||||||
|
entry.operation.clone(),
|
||||||
|
r.tool.clone(),
|
||||||
|
r.version_seen.clone(),
|
||||||
|
Some(current_snippet_hash.clone()),
|
||||||
|
None, // docs_preview not returned by check_all_refs yet; slice 4.1 extension
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"playbook_id": id,
|
||||||
|
"checked_tools": results.iter().map(|r| &r.tool).collect::<Vec<_>>(),
|
||||||
|
"drifted": any_drifted,
|
||||||
|
"flagged": flagged,
|
||||||
|
"per_tool": per_tool,
|
||||||
|
"synthesis_spawned": any_drifted,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 45 slice 4 — batch scan across all active playbooks.
|
||||||
|
///
|
||||||
|
/// POST /vectors/playbook_memory/doc_drift/scan
|
||||||
|
/// body: { city?: string, state?: string, max_entries?: number }
|
||||||
|
///
|
||||||
|
/// Iterates every active (non-retired, non-superseded) playbook that
|
||||||
|
/// has at least one doc_ref, optionally filtered by (city, state).
|
||||||
|
/// For each: queries the bridge, flags if drifted, fires synthesis.
|
||||||
|
///
|
||||||
|
/// NOT paginated — if you have >100 playbooks with doc_refs, this
|
||||||
|
/// will take a while (each check is a bridge HTTP call; bridge caches
|
||||||
|
/// for 5 min so re-runs are fast). Response reports counts per outcome
|
||||||
|
/// so you can tell if an operator should retry.
|
||||||
|
#[derive(serde::Deserialize, Default)]
|
||||||
|
struct ScanDocDriftRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
city: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
state: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
max_entries: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scan_doc_drift(
|
||||||
|
State(state): State<VectorState>,
|
||||||
|
body: Option<Json<ScanDocDriftRequest>>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
use crate::doc_drift::{check_all_refs, DriftCheckerConfig, DriftOutcome};
|
||||||
|
|
||||||
|
let req = body.map(|Json(r)| r).unwrap_or_default();
|
||||||
|
let cfg = DriftCheckerConfig::default();
|
||||||
|
let entries = state.playbook_memory.snapshot().await;
|
||||||
|
|
||||||
|
let matches_filter = |e: &crate::playbook_memory::PlaybookEntry| -> bool {
|
||||||
|
if e.retired_at.is_some() || e.superseded_at.is_some() { return false; }
|
||||||
|
if e.doc_refs.is_empty() { return false; }
|
||||||
|
if let Some(c) = &req.city {
|
||||||
|
let ec = e.city.as_deref().unwrap_or("");
|
||||||
|
if !ec.eq_ignore_ascii_case(c) { return false; }
|
||||||
|
}
|
||||||
|
if let Some(s) = &req.state {
|
||||||
|
let es = e.state.as_deref().unwrap_or("");
|
||||||
|
if !es.eq_ignore_ascii_case(s) { return false; }
|
||||||
|
}
|
||||||
|
true
|
||||||
|
};
|
||||||
|
|
||||||
|
let eligible: Vec<_> = entries.iter().filter(|e| matches_filter(e)).collect();
|
||||||
|
let limit = req.max_entries.unwrap_or(eligible.len());
|
||||||
|
|
||||||
|
let mut scanned = 0usize;
|
||||||
|
let mut drifted_count = 0usize;
|
||||||
|
let mut newly_flagged = 0usize;
|
||||||
|
let mut unknown_count = 0usize;
|
||||||
|
let mut synthesis_spawned = 0usize;
|
||||||
|
let mut details: Vec<serde_json::Value> = Vec::new();
|
||||||
|
|
||||||
|
for e in eligible.iter().take(limit) {
|
||||||
|
scanned += 1;
|
||||||
|
let results = check_all_refs(&cfg, &e.doc_refs).await;
|
||||||
|
let any_drifted = results.iter().any(|r| matches!(r.outcome, DriftOutcome::Drifted { .. }));
|
||||||
|
let any_unknown = results.iter().any(|r| matches!(r.outcome, DriftOutcome::Unknown { .. }));
|
||||||
|
if any_unknown && !any_drifted { unknown_count += 1; }
|
||||||
|
if any_drifted {
|
||||||
|
drifted_count += 1;
|
||||||
|
if state.playbook_memory.flag_doc_drift(&e.playbook_id).await.unwrap_or(false) {
|
||||||
|
newly_flagged += 1;
|
||||||
|
}
|
||||||
|
for r in &results {
|
||||||
|
if let DriftOutcome::Drifted { current_snippet_hash, .. } = &r.outcome {
|
||||||
|
crate::drift_synth::spawn_synthesize_and_append(
|
||||||
|
e.playbook_id.clone(),
|
||||||
|
e.operation.clone(),
|
||||||
|
r.tool.clone(),
|
||||||
|
r.version_seen.clone(),
|
||||||
|
Some(current_snippet_hash.clone()),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
synthesis_spawned += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details.push(serde_json::json!({
|
||||||
|
"playbook_id": e.playbook_id,
|
||||||
|
"drifted": any_drifted,
|
||||||
|
"unknown": any_unknown,
|
||||||
|
"tools": results.iter().map(|r| &r.tool).collect::<Vec<_>>(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"scanned": scanned,
|
||||||
|
"eligible": eligible.len(),
|
||||||
|
"drifted": drifted_count,
|
||||||
|
"newly_flagged": newly_flagged,
|
||||||
|
"unknown": unknown_count,
|
||||||
|
"synthesis_spawned": synthesis_spawned,
|
||||||
|
"filter": { "city": req.city, "state": req.state, "max_entries": req.max_entries },
|
||||||
|
"details": details,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 45 slice 3 — POST /playbook_memory/doc_drift/resolve/{id}
|
||||||
|
///
|
||||||
|
/// Human-in-the-loop re-admission. Stamps `doc_drift_reviewed_at`.
|
||||||
|
/// Idempotent: returns `resolved: false` if nothing changed (entry
|
||||||
|
/// wasn't flagged, already reviewed, or doesn't exist).
|
||||||
|
async fn resolve_doc_drift(
|
||||||
|
State(state): State<VectorState>,
|
||||||
|
axum::extract::Path(id): axum::extract::Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
let resolved = state.playbook_memory.resolve_doc_drift(&id).await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("resolve: {e}")))?;
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"playbook_id": id,
|
||||||
|
"resolved": resolved,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
/// Phase 27 — request body for `POST /playbook_memory/revise`. Same
|
/// Phase 27 — request body for `POST /playbook_memory/revise`. Same
|
||||||
/// shape as a seed request minus `append` (revise is always
|
/// shape as a seed request minus `append` (revise is always
|
||||||
/// append-semantics for a specific parent) plus `parent_id`. The new
|
/// append-semantics for a specific parent) plus `parent_id`. The new
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Unit tests for context7_bridge.ts pure helpers (slice 2 backfill).
|
||||||
|
// Run: bun test mcp-server/context7_bridge.test.ts
|
||||||
|
//
|
||||||
|
// The HTTP-bound functions (resolveLibraryId, fetchDocsText,
|
||||||
|
// getCurrent) need network access and are exercised by the hybrid
|
||||||
|
// fixture on the auditor side — not re-tested here to avoid
|
||||||
|
// duplicating what's already integration-covered. This file only
|
||||||
|
// exercises the pure, deterministic helpers.
|
||||||
|
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { normalizeTool, hashContent } from "./context7_bridge.ts";
|
||||||
|
|
||||||
|
test("normalizeTool lowercases + trims", () => {
|
||||||
|
expect(normalizeTool("Docker")).toBe("docker");
|
||||||
|
expect(normalizeTool(" TERRAFORM ")).toBe("terraform");
|
||||||
|
expect(normalizeTool("Next.JS")).toBe("next.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeTool preserves internal chars + dashes", () => {
|
||||||
|
expect(normalizeTool("react-dom")).toBe("react-dom");
|
||||||
|
expect(normalizeTool("@anthropic/sdk")).toBe("@anthropic/sdk");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hashContent is deterministic", () => {
|
||||||
|
const a = hashContent("hello world");
|
||||||
|
const b = hashContent("hello world");
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hashContent is sensitive to single-char change", () => {
|
||||||
|
const a = hashContent("docker run -it ubuntu bash");
|
||||||
|
const b = hashContent("docker run -it ubuntu bashh"); // extra h
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hashContent returns 16 hex chars", () => {
|
||||||
|
const h = hashContent("any input");
|
||||||
|
expect(h).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hashContent differs for empty vs whitespace", () => {
|
||||||
|
const a = hashContent("");
|
||||||
|
const b = hashContent(" ");
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
@@ -36,7 +36,8 @@ interface CachedDoc {
|
|||||||
|
|
||||||
const cache = new Map<string, { entry: CachedDoc; at: number }>();
|
const cache = new Map<string, { entry: CachedDoc; at: number }>();
|
||||||
|
|
||||||
function normalizeTool(s: string): string {
|
// Exported for unit testing — pure functions that the bridge composes.
|
||||||
|
export function normalizeTool(s: string): string {
|
||||||
return s.trim().toLowerCase();
|
return s.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ async function fetchDocsText(libraryId: string): Promise<string> {
|
|||||||
return await r.text();
|
return await r.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashContent(s: string): string {
|
export function hashContent(s: string): string {
|
||||||
return createHash("sha256").update(s).digest("hex").slice(0, 16);
|
return createHash("sha256").update(s).digest("hex").slice(0, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +106,11 @@ function jsonResponse(body: unknown, status: number = 200): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only start the HTTP server when this file is run as the entry
|
||||||
|
// point. When imported by tests (or future callers), the pure
|
||||||
|
// helpers are exposed without starting a second server on the same
|
||||||
|
// port (systemd service lakehouse-context7-bridge already owns it).
|
||||||
|
if (import.meta.main) {
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
port: PORT,
|
port: PORT,
|
||||||
hostname: "0.0.0.0",
|
hostname: "0.0.0.0",
|
||||||
@@ -176,3 +182,4 @@ Bun.serve({
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[context7-bridge] listening on :${PORT} (context7 base: ${CONTEXT7_BASE})`);
|
console.log(`[context7-bridge] listening on :${PORT} (context7 base: ${CONTEXT7_BASE})`);
|
||||||
|
} // end of if (import.meta.main) guard
|
||||||
|
|||||||
Reference in New Issue
Block a user