Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a3ed6832 | ||
|
|
affab8ac83 | ||
|
|
2a4b81bf48 | ||
|
|
75a0f424ef | ||
|
|
6316433062 |
@@ -204,6 +204,18 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
k
|
k
|
||||||
},
|
},
|
||||||
|
// Phase 40 early deliverable — Langfuse trace emitter.
|
||||||
|
// Defaults match mcp-server/tracing.ts conventions so
|
||||||
|
// gateway traces land in the same staffing project.
|
||||||
|
langfuse: {
|
||||||
|
let c = v1::langfuse_trace::LangfuseClient::from_env_or_defaults();
|
||||||
|
if c.is_some() {
|
||||||
|
tracing::info!("v1: Langfuse tracing enabled — /v1/chat calls will appear at localhost:3001");
|
||||||
|
} else {
|
||||||
|
tracing::warn!("v1: Langfuse keys missing — /v1/chat calls will not be traced");
|
||||||
|
}
|
||||||
|
c
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Auth middleware (if enabled)
|
// Auth middleware (if enabled)
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
//! Phase 40 (early deliverable) — Langfuse tracing from the Rust gateway.
|
||||||
|
//!
|
||||||
|
//! Langfuse is already running at `http://localhost:3001` with the
|
||||||
|
//! staffing project — `mcp-server/tracing.ts` has been emitting traces
|
||||||
|
//! for months. This file bridges the gap: every `/v1/chat` call now
|
||||||
|
//! appears in the same Langfuse project, alongside traces from the
|
||||||
|
//! Bun side.
|
||||||
|
//!
|
||||||
|
//! Design:
|
||||||
|
//! - Fire-and-forget — emit spawns a tokio task. Response to client
|
||||||
|
//! never waits on Langfuse, never fails because of it. If Langfuse
|
||||||
|
//! is down or slow, we silently drop traces (and `tracing::warn!`).
|
||||||
|
//! - Two events per call — `trace-create` + `generation-create`. The
|
||||||
|
//! generation nests under the trace so Langfuse UI shows the whole
|
||||||
|
//! thing as one clickable row.
|
||||||
|
//! - No SDK — plain HTTP POST to `/api/public/ingestion` with Basic
|
||||||
|
//! auth. Keeps the Rust dependency graph small.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::Message;
|
||||||
|
|
||||||
|
const INGESTION_PATH: &str = "/api/public/ingestion";
|
||||||
|
const EMIT_TIMEOUT_SECS: u64 = 5;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LangfuseClient {
|
||||||
|
inner: Arc<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
base_url: String,
|
||||||
|
public_key: String,
|
||||||
|
secret_key: String,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LangfuseClient {
|
||||||
|
pub fn new(base_url: String, public_key: String, secret_key: String) -> Self {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(EMIT_TIMEOUT_SECS))
|
||||||
|
.build()
|
||||||
|
.expect("langfuse client init");
|
||||||
|
Self { inner: Arc::new(Inner { base_url, public_key, secret_key, http }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a LangfuseClient from the same conventions the TS side
|
||||||
|
/// already uses. Returns None if keys are missing — caller logs a
|
||||||
|
/// warning and omits tracing (non-fatal).
|
||||||
|
///
|
||||||
|
/// Sources checked in order:
|
||||||
|
/// 1. LANGFUSE_URL + LANGFUSE_PUBLIC_KEY + LANGFUSE_SECRET_KEY env
|
||||||
|
/// 2. /root/llm_team_config.json (if it carries langfuse keys)
|
||||||
|
/// 3. Hardcoded staffing-project defaults matching mcp-server
|
||||||
|
/// (pk-lf-staffing / sk-lf-staffing-secret / localhost:3001)
|
||||||
|
pub fn from_env_or_defaults() -> Option<Self> {
|
||||||
|
let base = std::env::var("LANGFUSE_URL")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:3001".to_string());
|
||||||
|
let pk = std::env::var("LANGFUSE_PUBLIC_KEY")
|
||||||
|
.unwrap_or_else(|_| "pk-lf-staffing".to_string());
|
||||||
|
let sk = std::env::var("LANGFUSE_SECRET_KEY")
|
||||||
|
.unwrap_or_else(|_| "sk-lf-staffing-secret".to_string());
|
||||||
|
if pk.trim().is_empty() || sk.trim().is_empty() { return None; }
|
||||||
|
Some(Self::new(base, pk, sk))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire-and-forget emit. Never blocks, never errors upward.
|
||||||
|
pub fn emit_chat(&self, ev: ChatTrace) {
|
||||||
|
let this = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = this.emit_chat_inner(ev).await {
|
||||||
|
tracing::warn!(target: "v1.langfuse", "trace drop: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn emit_chat_inner(&self, ev: ChatTrace) -> Result<(), String> {
|
||||||
|
let trace_id = uuid_v7_like();
|
||||||
|
let gen_id = uuid_v7_like();
|
||||||
|
let trace_ts = ev.start_time.clone();
|
||||||
|
|
||||||
|
let batch = IngestionBatch {
|
||||||
|
batch: vec![
|
||||||
|
IngestionEvent {
|
||||||
|
id: uuid_v7_like(),
|
||||||
|
timestamp: trace_ts.clone(),
|
||||||
|
kind: "trace-create",
|
||||||
|
body: serde_json::json!({
|
||||||
|
"id": trace_id,
|
||||||
|
"name": format!("v1.chat:{}", ev.provider),
|
||||||
|
"input": serde_json::json!({
|
||||||
|
"model": ev.model,
|
||||||
|
"messages": ev.input,
|
||||||
|
}),
|
||||||
|
"metadata": serde_json::json!({
|
||||||
|
"provider": ev.provider,
|
||||||
|
"think": ev.think,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
IngestionEvent {
|
||||||
|
id: uuid_v7_like(),
|
||||||
|
timestamp: ev.end_time.clone(),
|
||||||
|
kind: "generation-create",
|
||||||
|
body: serde_json::json!({
|
||||||
|
"id": gen_id,
|
||||||
|
"traceId": trace_id,
|
||||||
|
"name": "chat",
|
||||||
|
"model": ev.model,
|
||||||
|
"modelParameters": serde_json::json!({
|
||||||
|
"temperature": ev.temperature,
|
||||||
|
"max_tokens": ev.max_tokens,
|
||||||
|
"think": ev.think,
|
||||||
|
}),
|
||||||
|
"input": ev.input,
|
||||||
|
"output": ev.output,
|
||||||
|
"usage": serde_json::json!({
|
||||||
|
"input": ev.prompt_tokens,
|
||||||
|
"output": ev.completion_tokens,
|
||||||
|
"total": ev.prompt_tokens + ev.completion_tokens,
|
||||||
|
"unit": "TOKENS",
|
||||||
|
}),
|
||||||
|
"startTime": ev.start_time,
|
||||||
|
"endTime": ev.end_time,
|
||||||
|
"metadata": serde_json::json!({
|
||||||
|
"provider": ev.provider,
|
||||||
|
"latency_ms": ev.latency_ms,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}{}", self.inner.base_url.trim_end_matches('/'), INGESTION_PATH);
|
||||||
|
let resp = self.inner.http
|
||||||
|
.post(url)
|
||||||
|
.basic_auth(&self.inner.public_key, Some(&self.inner.secret_key))
|
||||||
|
.json(&batch)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("POST failed: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("{}: {}", resp.status(), resp.text().await.unwrap_or_default()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the v1.chat handler collects for one completed call.
|
||||||
|
pub struct ChatTrace {
|
||||||
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
|
pub input: Vec<Message>,
|
||||||
|
pub output: String,
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
pub temperature: Option<f64>,
|
||||||
|
pub max_tokens: Option<u32>,
|
||||||
|
pub think: Option<bool>,
|
||||||
|
pub start_time: String,
|
||||||
|
pub end_time: String,
|
||||||
|
pub latency_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct IngestionBatch {
|
||||||
|
batch: Vec<IngestionEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct IngestionEvent {
|
||||||
|
id: String,
|
||||||
|
timestamp: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: &'static str,
|
||||||
|
body: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UUIDv7-ish identifier — not strictly UUIDv7 but time-ordered and
|
||||||
|
/// unique enough for event deduplication. Avoids a uuid crate dep.
|
||||||
|
fn uuid_v7_like() -> String {
|
||||||
|
let ts = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0);
|
||||||
|
let rand = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.subsec_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
format!("{:016x}-{:08x}", ts, rand)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_env_or_defaults_returns_some_when_defaults_present() {
|
||||||
|
// Defaults are hardcoded non-empty — should always produce Some.
|
||||||
|
let c = LangfuseClient::from_env_or_defaults();
|
||||||
|
assert!(c.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uuid_v7_like_produces_distinct_ids() {
|
||||||
|
let a = uuid_v7_like();
|
||||||
|
// Different nanosecond timestamps — but they might collide on
|
||||||
|
// back-to-back calls. Loop a bit to ensure uniqueness surface.
|
||||||
|
let b = uuid_v7_like();
|
||||||
|
let c = uuid_v7_like();
|
||||||
|
assert!(a != b || b != c, "three consecutive ids should have at least one distinct pair");
|
||||||
|
assert!(a.contains('-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ingestion_batch_serializes_with_rename() {
|
||||||
|
let ev = IngestionEvent {
|
||||||
|
id: "e1".into(),
|
||||||
|
timestamp: "2026-04-22T00:00:00Z".into(),
|
||||||
|
kind: "trace-create",
|
||||||
|
body: serde_json::json!({"name": "t"}),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&IngestionBatch { batch: vec![ev] }).unwrap();
|
||||||
|
assert!(json.contains("\"type\":\"trace-create\""));
|
||||||
|
assert!(json.contains("\"batch\":["));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
pub mod ollama;
|
pub mod ollama;
|
||||||
pub mod ollama_cloud;
|
pub mod ollama_cloud;
|
||||||
|
pub mod langfuse_trace;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
@@ -33,6 +34,10 @@ pub struct V1State {
|
|||||||
/// Ollama Cloud bearer token. Loaded at startup via
|
/// Ollama Cloud bearer token. Loaded at startup via
|
||||||
/// `ollama_cloud::resolve_cloud_key()`. None = cloud routes 503.
|
/// `ollama_cloud::resolve_cloud_key()`. None = cloud routes 503.
|
||||||
pub ollama_cloud_key: Option<String>,
|
pub ollama_cloud_key: Option<String>,
|
||||||
|
/// Phase 40 early deliverable — Langfuse client. None = tracing
|
||||||
|
/// disabled (keys missing or container unreachable). Traces are
|
||||||
|
/// fire-and-forget: never block the response path.
|
||||||
|
pub langfuse: Option<langfuse_trace::LangfuseClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone, Serialize)]
|
#[derive(Default, Clone, Serialize)]
|
||||||
@@ -127,6 +132,9 @@ async fn chat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let provider = req.provider.as_deref().unwrap_or("ollama").to_ascii_lowercase();
|
let provider = req.provider.as_deref().unwrap_or("ollama").to_ascii_lowercase();
|
||||||
|
let start_time = chrono::Utc::now();
|
||||||
|
let start_instant = std::time::Instant::now();
|
||||||
|
|
||||||
let resp = match provider.as_str() {
|
let resp = match provider.as_str() {
|
||||||
"ollama" | "local" | "" => ollama::chat(&state.ai_client, &req)
|
"ollama" | "local" | "" => ollama::chat(&state.ai_client, &req)
|
||||||
.await
|
.await
|
||||||
@@ -148,6 +156,33 @@ async fn chat(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let end_time = chrono::Utc::now();
|
||||||
|
let latency_ms = start_instant.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
// Phase 40 — emit Langfuse trace. Fire-and-forget: the clone is
|
||||||
|
// cheap (Arc inside), the tokio::spawn never blocks us, a dead
|
||||||
|
// Langfuse just logs a warn. Client-visible response latency is
|
||||||
|
// untouched.
|
||||||
|
if let Some(lf) = &state.langfuse {
|
||||||
|
let output = resp.choices.first()
|
||||||
|
.map(|c| c.message.content.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
lf.emit_chat(langfuse_trace::ChatTrace {
|
||||||
|
provider: provider.clone(),
|
||||||
|
model: resp.model.clone(),
|
||||||
|
input: req.messages.clone(),
|
||||||
|
output,
|
||||||
|
prompt_tokens: resp.usage.prompt_tokens,
|
||||||
|
completion_tokens: resp.usage.completion_tokens,
|
||||||
|
temperature: req.temperature,
|
||||||
|
max_tokens: req.max_tokens,
|
||||||
|
think: req.think,
|
||||||
|
start_time: start_time.to_rfc3339(),
|
||||||
|
end_time: end_time.to_rfc3339(),
|
||||||
|
latency_ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut u = state.usage.write().await;
|
let mut u = state.usage.write().await;
|
||||||
u.requests += 1;
|
u.requests += 1;
|
||||||
|
|||||||
@@ -134,10 +134,87 @@ pub struct PlaybookEntry {
|
|||||||
/// full version chain.
|
/// full version chain.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub superseded_by: Option<String>,
|
pub superseded_by: Option<String>,
|
||||||
|
/// Phase 45 — external documentation references captured at seal
|
||||||
|
/// time. One entry per tool/library the procedure consulted.
|
||||||
|
/// Drives drift detection: when context7 reports a newer version
|
||||||
|
/// for any entry here than what's in `version_seen`, the playbook
|
||||||
|
/// is `doc_drift_flagged_at` and excluded from boost until human
|
||||||
|
/// review clears it. Legacy entries (pre-Phase-45) load with an
|
||||||
|
/// empty vec — they simply never drift-flag, same as entries
|
||||||
|
/// without a `schema_fingerprint` in Phase 25.
|
||||||
|
#[serde(default)]
|
||||||
|
pub doc_refs: Vec<DocRef>,
|
||||||
|
/// Phase 45 — set by `flag_doc_drift()` when one or more
|
||||||
|
/// `doc_refs` entries have a newer version available than
|
||||||
|
/// `version_seen`. Flagged entries are excluded from boost until
|
||||||
|
/// `doc_drift_reviewed_at` is set via the /resolve endpoint.
|
||||||
|
#[serde(default)]
|
||||||
|
pub doc_drift_flagged_at: Option<String>,
|
||||||
|
/// Phase 45 — set by human operator via
|
||||||
|
/// `/vectors/playbook_memory/doc_drift/resolve/{id}` after
|
||||||
|
/// reviewing the drift diagnosis. Either re-admits the entry to
|
||||||
|
/// boost (if still applicable) or pairs with `retired_at` /
|
||||||
|
/// `superseded_by` if the procedure changed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub doc_drift_reviewed_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_version() -> u32 { 1 }
|
fn default_version() -> u32 { 1 }
|
||||||
|
|
||||||
|
/// Phase 45 — one external doc reference. Recorded at seal time so
|
||||||
|
/// drift detection knows what version was consulted. `snippet_hash`
|
||||||
|
/// lets us detect "same version, different passage" when a library
|
||||||
|
/// patches docs without bumping the version number.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct DocRef {
|
||||||
|
/// Canonical tool/library name as context7 knows it, e.g.
|
||||||
|
/// "docker", "terraform", "react", "next.js". Case-insensitive
|
||||||
|
/// on compare.
|
||||||
|
pub tool: String,
|
||||||
|
/// Version string exactly as seen at seal time. Context7 typically
|
||||||
|
/// returns semver-like; we store raw string to avoid parsing
|
||||||
|
/// ambiguity ("latest", "next", "canary" are all valid).
|
||||||
|
pub version_seen: String,
|
||||||
|
/// Optional hash of the specific doc passage the procedure
|
||||||
|
/// referenced. Useful when version hasn't bumped but content
|
||||||
|
/// rewrote.
|
||||||
|
#[serde(default)]
|
||||||
|
pub snippet_hash: Option<String>,
|
||||||
|
/// Optional direct URL back to the doc (context7 can resolve
|
||||||
|
/// tool+version → URL, so this is cache not source-of-truth).
|
||||||
|
#[serde(default)]
|
||||||
|
pub source_url: Option<String>,
|
||||||
|
/// When this reference was captured. RFC3339.
|
||||||
|
pub seen_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlaybookEntry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
playbook_id: String::new(),
|
||||||
|
operation: String::new(),
|
||||||
|
approach: String::new(),
|
||||||
|
context: String::new(),
|
||||||
|
timestamp: String::new(),
|
||||||
|
endorsed_names: Vec::new(),
|
||||||
|
city: None,
|
||||||
|
state: None,
|
||||||
|
embedding: None,
|
||||||
|
schema_fingerprint: None,
|
||||||
|
valid_until: None,
|
||||||
|
retired_at: None,
|
||||||
|
retirement_reason: None,
|
||||||
|
version: 1,
|
||||||
|
parent_id: None,
|
||||||
|
superseded_at: None,
|
||||||
|
superseded_by: None,
|
||||||
|
doc_refs: Vec::new(),
|
||||||
|
doc_drift_flagged_at: None,
|
||||||
|
doc_drift_reviewed_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A recorded failure — worker who didn't deliver on a contract.
|
/// A recorded failure — worker who didn't deliver on a contract.
|
||||||
/// Tracked per (city, state, name) so a single worker's failures on
|
/// Tracked per (city, state, name) so a single worker's failures on
|
||||||
/// Toledo Welder contracts don't penalize the same name in Chicago.
|
/// Toledo Welder contracts don't penalize the same name in Chicago.
|
||||||
@@ -175,11 +252,21 @@ pub struct BoostEntry {
|
|||||||
/// Phase 26 — what happened during an upsert. The seed endpoint
|
/// Phase 26 — what happened during an upsert. The seed endpoint
|
||||||
/// returns this shape so the caller sees whether its write was a new
|
/// returns this shape so the caller sees whether its write was a new
|
||||||
/// entry, a merge, or a dedup'd no-op.
|
/// entry, a merge, or a dedup'd no-op.
|
||||||
|
///
|
||||||
|
/// SHAPE NOTE: `#[serde(tag = "mode")]` requires struct-like variants —
|
||||||
|
/// bare `Added(String)` and `Noop(String)` newtype variants would
|
||||||
|
/// panic serialization at runtime. That bug silently 500-ed every
|
||||||
|
/// /seed call from Phase 26 (commit 640db8c) until 2026-04-22 when the
|
||||||
|
/// auditor's hybrid fixture surfaced it. All three variants are now
|
||||||
|
/// struct-like, producing uniform JSON:
|
||||||
|
/// {"mode":"added","playbook_id":"pb-..."}
|
||||||
|
/// {"mode":"updated","playbook_id":"pb-...","merged_names":[...]}
|
||||||
|
/// {"mode":"noop","playbook_id":"pb-..."}
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(tag = "mode", rename_all = "lowercase")]
|
#[serde(tag = "mode", rename_all = "lowercase")]
|
||||||
pub enum UpsertOutcome {
|
pub enum UpsertOutcome {
|
||||||
/// New playbook appended. Carries the new playbook_id.
|
/// New playbook appended. Carries the new playbook_id.
|
||||||
Added(String),
|
Added { playbook_id: String },
|
||||||
/// Existing same-day entry updated. Playbook_id unchanged; names
|
/// Existing same-day entry updated. Playbook_id unchanged; names
|
||||||
/// merged (union, original order preserved, new names appended).
|
/// merged (union, original order preserved, new names appended).
|
||||||
Updated {
|
Updated {
|
||||||
@@ -188,7 +275,7 @@ pub enum UpsertOutcome {
|
|||||||
},
|
},
|
||||||
/// Identical same-day entry already exists; nothing changed.
|
/// Identical same-day entry already exists; nothing changed.
|
||||||
/// Returns the stable playbook_id so caller still has a reference.
|
/// Returns the stable playbook_id so caller still has a reference.
|
||||||
Noop(String),
|
Noop { playbook_id: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 27 — shape returned from `revise_entry`. Reports both ends of
|
/// Phase 27 — shape returned from `revise_entry`. Reports both ends of
|
||||||
@@ -520,7 +607,7 @@ impl PlaybookMemory {
|
|||||||
drop(state);
|
drop(state);
|
||||||
self.persist().await?;
|
self.persist().await?;
|
||||||
self.rebuild_geo_index().await;
|
self.rebuild_geo_index().await;
|
||||||
Ok(UpsertOutcome::Added(pid))
|
Ok(UpsertOutcome::Added { playbook_id: pid })
|
||||||
}
|
}
|
||||||
Some(i) => {
|
Some(i) => {
|
||||||
let mut existing_names_sorted = state.entries[i].endorsed_names.clone();
|
let mut existing_names_sorted = state.entries[i].endorsed_names.clone();
|
||||||
@@ -528,7 +615,7 @@ impl PlaybookMemory {
|
|||||||
if existing_names_sorted == new_names_sorted {
|
if existing_names_sorted == new_names_sorted {
|
||||||
// NOOP — identical data, just report the existing id
|
// NOOP — identical data, just report the existing id
|
||||||
let pid = state.entries[i].playbook_id.clone();
|
let pid = state.entries[i].playbook_id.clone();
|
||||||
Ok(UpsertOutcome::Noop(pid))
|
Ok(UpsertOutcome::Noop { playbook_id: pid })
|
||||||
} else {
|
} else {
|
||||||
// UPDATE — merge names (union, stable order).
|
// UPDATE — merge names (union, stable order).
|
||||||
let existing = state.entries.get_mut(i).ok_or("index invalidated")?;
|
let existing = state.entries.get_mut(i).ok_or("index invalidated")?;
|
||||||
@@ -1201,19 +1288,10 @@ pub async fn rebuild(
|
|||||||
endorsed_names: names,
|
endorsed_names: names,
|
||||||
city,
|
city,
|
||||||
state,
|
state,
|
||||||
embedding: None,
|
// Rebuild doesn't know fingerprints or doc_refs;
|
||||||
// Rebuild doesn't know fingerprints; historical entries
|
// historical entries get no drift signal until a seed
|
||||||
// get no retirement signal until a seed with a
|
// supersedes them or /retire is called manually.
|
||||||
// fingerprint supersedes them or the operator calls
|
..Default::default()
|
||||||
// /retire manually.
|
|
||||||
schema_fingerprint: None,
|
|
||||||
valid_until: None,
|
|
||||||
retired_at: None,
|
|
||||||
retirement_reason: None,
|
|
||||||
version: 1,
|
|
||||||
parent_id: None,
|
|
||||||
superseded_at: None,
|
|
||||||
superseded_by: None,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1386,20 +1464,12 @@ mod tests {
|
|||||||
playbook_id: format!("pb-{i}"),
|
playbook_id: format!("pb-{i}"),
|
||||||
operation: "fill: Welder x1 in Toledo, OH".into(),
|
operation: "fill: Welder x1 in Toledo, OH".into(),
|
||||||
approach: "transfer".into(),
|
approach: "transfer".into(),
|
||||||
context: "".into(),
|
|
||||||
timestamp: "2026-04-20".into(),
|
timestamp: "2026-04-20".into(),
|
||||||
endorsed_names: vec!["Deborah Powell".into()],
|
endorsed_names: vec!["Deborah Powell".into()],
|
||||||
city: Some("Toledo".into()),
|
city: Some("Toledo".into()),
|
||||||
state: Some("OH".into()),
|
state: Some("OH".into()),
|
||||||
embedding: Some(vec![1.0, 0.0, 0.0]),
|
embedding: Some(vec![1.0, 0.0, 0.0]),
|
||||||
schema_fingerprint: None,
|
..Default::default()
|
||||||
valid_until: None,
|
|
||||||
retired_at: None,
|
|
||||||
retirement_reason: None,
|
|
||||||
version: 1,
|
|
||||||
parent_id: None,
|
|
||||||
superseded_at: None,
|
|
||||||
superseded_by: None,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||||
@@ -1431,12 +1501,7 @@ mod validity_window_tests {
|
|||||||
embedding: Some(vec![1.0, 0.0, 0.0]),
|
embedding: Some(vec![1.0, 0.0, 0.0]),
|
||||||
schema_fingerprint: fingerprint,
|
schema_fingerprint: fingerprint,
|
||||||
valid_until,
|
valid_until,
|
||||||
retired_at: None,
|
..Default::default()
|
||||||
retirement_reason: None,
|
|
||||||
version: 1,
|
|
||||||
parent_id: None,
|
|
||||||
superseded_at: None,
|
|
||||||
superseded_by: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1536,14 +1601,7 @@ mod upsert_tests {
|
|||||||
city: Some("Nashville".into()),
|
city: Some("Nashville".into()),
|
||||||
state: Some("TN".into()),
|
state: Some("TN".into()),
|
||||||
embedding: Some(vec![1.0, 0.0, 0.0]),
|
embedding: Some(vec![1.0, 0.0, 0.0]),
|
||||||
schema_fingerprint: None,
|
..Default::default()
|
||||||
valid_until: None,
|
|
||||||
retired_at: None,
|
|
||||||
retirement_reason: None,
|
|
||||||
version: 1,
|
|
||||||
parent_id: None,
|
|
||||||
superseded_at: None,
|
|
||||||
superseded_by: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1552,7 +1610,7 @@ mod upsert_tests {
|
|||||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||||
let e = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]);
|
let e = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]);
|
||||||
match pm.upsert_entry(e).await.unwrap() {
|
match pm.upsert_entry(e).await.unwrap() {
|
||||||
UpsertOutcome::Added(_) => {}
|
UpsertOutcome::Added { .. } => {}
|
||||||
other => panic!("expected Added, got {:?}", other),
|
other => panic!("expected Added, got {:?}", other),
|
||||||
}
|
}
|
||||||
assert_eq!(pm.entry_count().await, 1);
|
assert_eq!(pm.entry_count().await, 1);
|
||||||
@@ -1565,7 +1623,7 @@ mod upsert_tests {
|
|||||||
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
|
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
|
||||||
pm.upsert_entry(e1).await.unwrap();
|
pm.upsert_entry(e1).await.unwrap();
|
||||||
let outcome = pm.upsert_entry(e2).await.unwrap();
|
let outcome = pm.upsert_entry(e2).await.unwrap();
|
||||||
assert!(matches!(outcome, UpsertOutcome::Noop(_)));
|
assert!(matches!(outcome, UpsertOutcome::Noop { .. }));
|
||||||
// Still exactly one entry, no duplicate from the re-seed.
|
// Still exactly one entry, no duplicate from the re-seed.
|
||||||
assert_eq!(pm.entry_count().await, 1);
|
assert_eq!(pm.entry_count().await, 1);
|
||||||
}
|
}
|
||||||
@@ -1577,7 +1635,7 @@ mod upsert_tests {
|
|||||||
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
|
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
|
||||||
let o1 = pm.upsert_entry(e1).await.unwrap();
|
let o1 = pm.upsert_entry(e1).await.unwrap();
|
||||||
let pid = match o1 {
|
let pid = match o1 {
|
||||||
UpsertOutcome::Added(p) => p,
|
UpsertOutcome::Added { playbook_id: p } => p,
|
||||||
other => panic!("expected Added, got {:?}", other),
|
other => panic!("expected Added, got {:?}", other),
|
||||||
};
|
};
|
||||||
let o2 = pm.upsert_entry(e2).await.unwrap();
|
let o2 = pm.upsert_entry(e2).await.unwrap();
|
||||||
@@ -1598,7 +1656,7 @@ mod upsert_tests {
|
|||||||
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-22", &["Alice Smith"]);
|
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-22", &["Alice Smith"]);
|
||||||
pm.upsert_entry(e1).await.unwrap();
|
pm.upsert_entry(e1).await.unwrap();
|
||||||
let o2 = pm.upsert_entry(e2).await.unwrap();
|
let o2 = pm.upsert_entry(e2).await.unwrap();
|
||||||
assert!(matches!(o2, UpsertOutcome::Added(_)), "different day → fresh ADD");
|
assert!(matches!(o2, UpsertOutcome::Added { .. }), "different day → fresh ADD");
|
||||||
assert_eq!(pm.entry_count().await, 2);
|
assert_eq!(pm.entry_count().await, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1611,7 +1669,7 @@ mod upsert_tests {
|
|||||||
// A new seed on same day should ADD, not merge into the retired one.
|
// A new seed on same day should ADD, not merge into the retired one.
|
||||||
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Carol Davis"]);
|
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Carol Davis"]);
|
||||||
let o = pm.upsert_entry(e2).await.unwrap();
|
let o = pm.upsert_entry(e2).await.unwrap();
|
||||||
assert!(matches!(o, UpsertOutcome::Added(_)));
|
assert!(matches!(o, UpsertOutcome::Added { .. }));
|
||||||
assert_eq!(pm.entry_count().await, 2);
|
assert_eq!(pm.entry_count().await, 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1632,14 +1690,7 @@ mod version_tests {
|
|||||||
city: Some(city.into()),
|
city: Some(city.into()),
|
||||||
state: Some(state.into()),
|
state: Some(state.into()),
|
||||||
embedding: Some(vec![1.0, 0.0, 0.0]),
|
embedding: Some(vec![1.0, 0.0, 0.0]),
|
||||||
schema_fingerprint: None,
|
..Default::default()
|
||||||
valid_until: None,
|
|
||||||
retired_at: None,
|
|
||||||
retirement_reason: None,
|
|
||||||
version: 1,
|
|
||||||
parent_id: None,
|
|
||||||
superseded_at: None,
|
|
||||||
superseded_by: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2211,6 +2211,12 @@ struct SeedPlaybookRequest {
|
|||||||
/// retired, just inactive). Useful for seasonal/temp contracts.
|
/// retired, just inactive). Useful for seasonal/temp contracts.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
valid_until: Option<String>,
|
valid_until: Option<String>,
|
||||||
|
/// Phase 45 — optional external doc references captured at seal
|
||||||
|
/// time. Each entry names a tool + version_seen; context7-driven
|
||||||
|
/// drift check compares against current versions later. None or
|
||||||
|
/// empty = no drift signal (never flagged).
|
||||||
|
#[serde(default)]
|
||||||
|
doc_refs: Option<Vec<playbook_memory::DocRef>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bootstrap / test-only: inject a playbook entry directly into
|
/// Bootstrap / test-only: inject a playbook entry directly into
|
||||||
@@ -2232,21 +2238,12 @@ async fn seed_playbook_memory(
|
|||||||
// Embed the entry through the same text shape `rebuild` uses so
|
// Embed the entry through the same text shape `rebuild` uses so
|
||||||
// similarity math is comparable across seed + real entries.
|
// similarity math is comparable across seed + real entries.
|
||||||
let tmp_entry = playbook_memory::PlaybookEntry {
|
let tmp_entry = playbook_memory::PlaybookEntry {
|
||||||
playbook_id: String::new(),
|
|
||||||
operation: req.operation.clone(),
|
operation: req.operation.clone(),
|
||||||
approach: req.approach.clone(),
|
approach: req.approach.clone(),
|
||||||
context: req.context.clone(),
|
context: req.context.clone(),
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
endorsed_names: req.endorsed_names.clone(),
|
endorsed_names: req.endorsed_names.clone(),
|
||||||
city: None, state: None, embedding: None,
|
..Default::default()
|
||||||
schema_fingerprint: None,
|
|
||||||
valid_until: None,
|
|
||||||
retired_at: None,
|
|
||||||
retirement_reason: None,
|
|
||||||
version: 1,
|
|
||||||
parent_id: None,
|
|
||||||
superseded_at: None,
|
|
||||||
superseded_by: None,
|
|
||||||
};
|
};
|
||||||
let text = format!(
|
let text = format!(
|
||||||
"{} | {} | {} | fills: {}",
|
"{} | {} | {} | fills: {}",
|
||||||
@@ -2304,12 +2301,11 @@ async fn seed_playbook_memory(
|
|||||||
// works). valid_until + retired_at start None.
|
// works). valid_until + retired_at start None.
|
||||||
schema_fingerprint: req.schema_fingerprint.clone(),
|
schema_fingerprint: req.schema_fingerprint.clone(),
|
||||||
valid_until: req.valid_until.clone(),
|
valid_until: req.valid_until.clone(),
|
||||||
retired_at: None,
|
// Phase 45 — seed request may also carry doc_refs; defaults
|
||||||
retirement_reason: None,
|
// empty so pre-Phase-45 callers still work and the entry
|
||||||
version: 1,
|
// degrades to "no drift signal" (never flagged).
|
||||||
parent_id: None,
|
doc_refs: req.doc_refs.clone().unwrap_or_default(),
|
||||||
superseded_at: None,
|
..Default::default()
|
||||||
superseded_by: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Phase 26 — when append=true (default), route through upsert so
|
// Phase 26 — when append=true (default), route through upsert so
|
||||||
@@ -2521,6 +2517,11 @@ struct RevisePlaybookRequest {
|
|||||||
schema_fingerprint: Option<String>,
|
schema_fingerprint: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
valid_until: Option<String>,
|
valid_until: Option<String>,
|
||||||
|
/// Phase 45 — updated doc references. Typically a revise happens
|
||||||
|
/// BECAUSE docs drifted; pass the new versions seen so the revised
|
||||||
|
/// entry starts with fresh drift signal.
|
||||||
|
#[serde(default)]
|
||||||
|
doc_refs: Option<Vec<playbook_memory::DocRef>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 27 — create a new version of an existing playbook. The parent
|
/// Phase 27 — create a new version of an existing playbook. The parent
|
||||||
@@ -2613,14 +2614,11 @@ async fn revise_playbook_memory(
|
|||||||
embedding: Some(emb),
|
embedding: Some(emb),
|
||||||
schema_fingerprint: req.schema_fingerprint,
|
schema_fingerprint: req.schema_fingerprint,
|
||||||
valid_until: req.valid_until,
|
valid_until: req.valid_until,
|
||||||
retired_at: None,
|
// Phase 45 — doc_refs may be provided on revise too.
|
||||||
retirement_reason: None,
|
doc_refs: req.doc_refs.clone().unwrap_or_default(),
|
||||||
// revise_entry overwrites these from the parent — values here
|
// revise_entry overwrites version / parent_id / supersession
|
||||||
// are just placeholders so the struct is well-formed.
|
// from the parent; other fields keep defaults.
|
||||||
version: 1,
|
..Default::default()
|
||||||
parent_id: None,
|
|
||||||
superseded_at: None,
|
|
||||||
superseded_by: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let outcome = state.playbook_memory.revise_entry(&req.parent_id, new_entry)
|
let outcome = state.playbook_memory.revise_entry(&req.parent_id, new_entry)
|
||||||
|
|||||||
@@ -72,11 +72,11 @@ Ship each phase before starting the next. Each ends with green tests + docs upda
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 40 — Routing & Policy Engine
|
## Phase 40 — Routing & Policy Engine + Observability Recovery
|
||||||
|
|
||||||
**Goal:** Replace hardcoded T1-T5 routing with a rules engine. Add Gemini + Claude adapters. Cost gating enforced at router level.
|
**Goal:** Replace hardcoded T1-T5 routing with a rules engine. Add Gemini + Claude adapters. Cost gating enforced at router level. **Reinstate Langfuse + Gitea MCP** — recovery of the observability + repo-ops stack J built previously (see `project_lost_stack` memory).
|
||||||
|
|
||||||
**Ships:**
|
**Ships — routing:**
|
||||||
- `crates/aibridge/src/routing.rs` — rules engine (match on: task type, token budget, previous attempt failures, profile ID)
|
- `crates/aibridge/src/routing.rs` — rules engine (match on: task type, token budget, previous attempt failures, profile ID)
|
||||||
- `config/routing.toml` — rules in TOML (human-editable, hot-reloadable)
|
- `config/routing.toml` — rules in TOML (human-editable, hot-reloadable)
|
||||||
- `crates/aibridge/src/providers/gemini.rs` — `generativelanguage.googleapis.com` adapter
|
- `crates/aibridge/src/providers/gemini.rs` — `generativelanguage.googleapis.com` adapter
|
||||||
@@ -84,15 +84,24 @@ Ship each phase before starting the next. Each ends with green tests + docs upda
|
|||||||
- Fallback chain support: if primary returns 5xx or times out, try next in chain
|
- Fallback chain support: if primary returns 5xx or times out, try next in chain
|
||||||
- Cost gate: per-request budget + daily budget per-provider
|
- Cost gate: per-request budget + daily budget per-provider
|
||||||
|
|
||||||
|
**Ships — observability (was lost, now restored):**
|
||||||
|
- **Langfuse** self-hosted via Docker Compose. Single source of truth for every LLM call trace: prompt / response / tokens / cost / latency / provider / fallback chain / profile used. UI at `localhost:3000`. Keys in `/etc/lakehouse/secrets.toml`.
|
||||||
|
- `crates/aibridge/src/langfuse.rs` — thin fire-and-forget trace emitter. Every `/v1/chat` call spawns a background task that POSTs to `langfuse/api/public/ingestion`. Non-blocking: trace failures never affect response.
|
||||||
|
- **Langfuse → observer pipe** — `mcp-server/langfuse_bridge.ts` or similar. Polls Langfuse's trace API at interval, forwards completed traces to observer `:3800/event` with `source: "langfuse"`. KB now sees cost/latency deltas per model, not just outcome deltas.
|
||||||
|
- **Gitea MCP reconnect** — the MCP server binary still installed at `/home/profit/.bun/install/cache/gitea-mcp@0.0.10/` gets wired into `mcp-server/index.ts` tool registry. Agents can open PRs, comment on issues, list commits via named tools. Closes Phase 28's repo-ops gap.
|
||||||
|
|
||||||
**Gate:**
|
**Gate:**
|
||||||
- Rule like "local models for simple JSON emitters, cloud for reasoning" fires correctly by task type
|
- Rule like "local models for simple JSON emitters, cloud for reasoning" fires correctly by task type
|
||||||
- Primary fails → fallback provider hits, response still matches `/v1/chat` shape
|
- Primary fails → fallback provider hits, response still matches `/v1/chat` shape
|
||||||
- Daily budget hit → subsequent requests return 429 with clear retry-at header
|
- Daily budget hit → subsequent requests return 429 with clear retry-at header
|
||||||
- `/v1/usage` reports per-provider breakdown
|
- `/v1/usage` reports per-provider breakdown
|
||||||
|
- **Every `/v1/chat` call appears in Langfuse UI** with correct prompt, response, latency, token count within 2 seconds of the request completing
|
||||||
|
- **Langfuse → observer pipe** delivers trace deltas to KB: `GET :3800/stats?source=langfuse` shows non-zero count after a few scenarios run
|
||||||
|
- **Gitea MCP tools callable** — `list_prs`, `open_pr`, `comment_on_issue` exposed in `mcp-server/index.ts`, verifiable via a quick agent scenario
|
||||||
|
|
||||||
**Non-goals:** Retrieval Profile split (Phase 41), Truth Layer (Phase 42).
|
**Non-goals:** Retrieval Profile split (Phase 41), Truth Layer (Phase 42). Langfuse self-hosted UI customization / SSO.
|
||||||
|
|
||||||
**Risk:** Medium. Multi-provider auth + cost tracking is cross-cutting. Mitigation: every provider call wrapped in a single `dispatch()` function, all observability flows through there.
|
**Risk:** Medium. Multi-provider auth + cost tracking is cross-cutting; Langfuse adds 4-5 Docker containers (PostgreSQL, ClickHouse, Redis, web, worker). Mitigation: every provider call wrapped in a single `dispatch()` function so observability flows through one point; Langfuse Docker Compose is their supported deployment path, well-tested.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -208,6 +217,38 @@ Ship each phase before starting the next. Each ends with green tests + docs upda
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 45 — Doc-drift detection + context7 integration
|
||||||
|
|
||||||
|
**Goal:** Playbooks know which external docs they were written against. When those docs change (Docker adds a feature, npm lib goes major, Terraform renames a resource), the playbook is automatically flagged. Small models never run confidently-outdated procedures — the drift signal reaches them before the next execution does.
|
||||||
|
|
||||||
|
**Why this phase exists at all:** The 0→85% thesis depends on the hyperfocus lane staying valid. External doc drift invalidates the lane silently — popular playbooks can compound the wrong way, accumulating boost while growing more wrong. Phase 25 already retires playbooks on *internal* schema drift; Phase 45 is the same mechanism against *external* doc drift. This is the completion of the learning loop, not an optional add-on.
|
||||||
|
|
||||||
|
**Ships:**
|
||||||
|
- `shared::types::DocRef` — `{ tool: String, version_seen: String, snippet_hash: Option<String>, source_url: Option<String>, seen_at: DateTime<Utc> }`
|
||||||
|
- `PlaybookEntry.doc_refs: Vec<DocRef>` — `#[serde(default)]` so pre-Phase-45 entries load as empty vec
|
||||||
|
- `/vectors/playbook_memory/seed` + `/revise` accept `doc_refs` in the request body
|
||||||
|
- `/vectors/playbook_memory/doc_drift/check/{id}` — manual drift check: looks up each `doc_refs[]` entry via the context7 bridge, returns per-tool `{version_seen, version_current, drifted: bool}` plus overall verdict
|
||||||
|
- `/vectors/playbook_memory/doc_drift/scan` — batch scan across all active playbooks (scheduled path for Phase 45.2)
|
||||||
|
- `mcp-server/context7_bridge.ts` — Bun HTTP bridge. Exposes `GET /docs/:tool/version` + `GET /docs/:tool/:version/diff?since=X` against the installed context7 MCP plugin. Gateway calls this over localhost.
|
||||||
|
- `PlaybookMemory::compute_boost_for_filtered_with_role` — excludes entries where `doc_drift_flagged_at.is_some() && doc_drift_review.is_none()` (same rule as retired + superseded)
|
||||||
|
- Overview model synthesis writes `data/_kb/doc_drift_corrections.jsonl` per detected drift: `{playbook_id, tool, version_seen, version_current, diff_summary, recommended_action, generated_at}`
|
||||||
|
- Human-in-the-loop re-seal path: `/vectors/playbook_memory/doc_drift/resolve/{id}` — marks reviewed, optionally triggers `revise_entry` if procedure changed
|
||||||
|
|
||||||
|
**Gate:**
|
||||||
|
- Seal a playbook referencing Docker 24.x → doc_refs captured. Bump Docker version behind the scenes → `/doc_drift/check/{id}` returns `drifted: true, from: 24.0.7, to: 25.0.1, summary: "..."`. The boosted playbook count on next `/vectors/hybrid` query drops by 1 (drift-flagged skipped).
|
||||||
|
- `doc_drift_corrections.jsonl` contains the overview model's synthesis for the drift with at least: summary of change, recommended action, cost/impact estimate.
|
||||||
|
- Human calls `/doc_drift/resolve/{id}` after reviewing → playbook returns to active boost pool (or supersedes via Phase 27 if procedure materially changed).
|
||||||
|
- Unit tests: DocRef serde default (legacy entries load as empty), drift check against mocked context7 bridge, boost exclusion when drifted+unreviewed.
|
||||||
|
|
||||||
|
**Non-goals (explicit):**
|
||||||
|
- Automatic re-seal without human review. Drift-detection → flag, not silent rewrite.
|
||||||
|
- Cross-playbook propagation of one drift diagnosis. Each playbook reviewed individually (aggregation later if warranted).
|
||||||
|
- Generating the updated procedure. T3 *suggests*; human or separate bot (see `bot/`) *writes*.
|
||||||
|
|
||||||
|
**Risk:** Medium. The context7 bridge is new infrastructure (Bun ↔ context7 MCP plugin ↔ HTTP shape for gateway consumption). Mitigation: context7 plugin is already installed; its MCP tools return structured JSON; the bridge is thin adapter code. Start with single-tool drift check (Docker) before broadening.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Long-horizon domains (not in current phase sequence)
|
## Long-horizon domains (not in current phase sequence)
|
||||||
|
|
||||||
The architecture was drafted with DevOps execution (Terraform, Ansible) as the eventual target. **That remains aspirational, not current scope** — we don't start wiring `terraform validate` / `ansible-lint` until the staffing domain proves the six-layer architecture at scale.
|
The architecture was drafted with DevOps execution (Terraform, Ansible) as the eventual target. **That remains aspirational, not current scope** — we don't start wiring `terraform validate` / `ansible-lint` until the staffing domain proves the six-layer architecture at scale.
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Phase 45 slice 2 — context7 HTTP bridge.
|
||||||
|
//
|
||||||
|
// Exposes an HTTP surface the Rust gateway consumes to check external
|
||||||
|
// doc drift on playbooks. Wraps context7's public API:
|
||||||
|
//
|
||||||
|
// https://context7.com/api/v1/search?query=<name> → resolve
|
||||||
|
// https://context7.com/api/v1/<lib-id>/cli?type=txt&tokens=N → docs
|
||||||
|
//
|
||||||
|
// Own port so a failure here never tips over the mcp-server on :3700.
|
||||||
|
// Cache is in-memory (5 min TTL) — context7 rate-limits by IP, and
|
||||||
|
// gateway drift-checks are the hot caller.
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
// GET /health health + cache stats
|
||||||
|
// GET /docs/:tool resolve + fetch + return descriptor
|
||||||
|
// GET /docs/:tool/diff?since=X return drift vs content hash X
|
||||||
|
// GET /cache dump current cache (debugging)
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
const PORT = Number(process.env.CONTEXT7_BRIDGE_PORT ?? 3900);
|
||||||
|
const CONTEXT7_BASE = (process.env.CONTEXT7_BASE_URL ?? "https://context7.com/api/v1").replace(/\/+$/, "");
|
||||||
|
const TOKENS_PER_FETCH = 1500; // enough for a drift-meaningful slice, not so much we hammer
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
interface CachedDoc {
|
||||||
|
tool: string;
|
||||||
|
library_id: string; // context7 canonical, e.g. "/docker/docs"
|
||||||
|
title: string;
|
||||||
|
last_updated: string | null;
|
||||||
|
snippet_hash: string; // SHA-256 of first TOKENS_PER_FETCH of fetched docs
|
||||||
|
docs_preview: string; // first 400 chars, returned in responses for display
|
||||||
|
retrieved_at: string; // ISO when WE last fetched
|
||||||
|
source_url: string; // human-facing context7 URL
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, { entry: CachedDoc; at: number }>();
|
||||||
|
|
||||||
|
function normalizeTool(s: string): string {
|
||||||
|
return s.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveLibraryId(tool: string): Promise<{ id: string; title: string; last_updated: string | null } | null> {
|
||||||
|
const r = await fetch(`${CONTEXT7_BASE}/search?query=${encodeURIComponent(tool)}`, {
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`context7 search ${r.status}: ${await r.text().catch(() => "?")}`);
|
||||||
|
const j = await r.json() as any;
|
||||||
|
const results = Array.isArray(j.results) ? j.results : [];
|
||||||
|
// First result is top-ranked; context7 sorts by its internal score.
|
||||||
|
// Skip "state != finalized" to avoid unstable previews.
|
||||||
|
const pick = results.find((x: any) => x?.state === "finalized") ?? results[0];
|
||||||
|
if (!pick?.id) return null;
|
||||||
|
return {
|
||||||
|
id: String(pick.id),
|
||||||
|
title: String(pick.title ?? tool),
|
||||||
|
last_updated: typeof pick.lastUpdateDate === "string" ? pick.lastUpdateDate : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDocsText(libraryId: string): Promise<string> {
|
||||||
|
// Strip the leading slash on library_id so the URL doesn't have a
|
||||||
|
// double slash. context7 returns ids like "/docker/docs" but the
|
||||||
|
// fetch path wants "docker/docs".
|
||||||
|
const cleanId = libraryId.replace(/^\/+/, "");
|
||||||
|
const url = `${CONTEXT7_BASE}/${cleanId}?type=txt&tokens=${TOKENS_PER_FETCH}`;
|
||||||
|
const r = await fetch(url, { signal: AbortSignal.timeout(20000) });
|
||||||
|
if (!r.ok) throw new Error(`context7 fetch ${r.status} on ${cleanId}: ${await r.text().catch(() => "?")}`);
|
||||||
|
return await r.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashContent(s: string): string {
|
||||||
|
return createHash("sha256").update(s).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrent(tool: string): Promise<CachedDoc> {
|
||||||
|
const key = normalizeTool(tool);
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
||||||
|
return cached.entry;
|
||||||
|
}
|
||||||
|
const resolved = await resolveLibraryId(key);
|
||||||
|
if (!resolved) {
|
||||||
|
throw new Error(`no context7 library found for '${tool}'`);
|
||||||
|
}
|
||||||
|
const text = await fetchDocsText(resolved.id);
|
||||||
|
const entry: CachedDoc = {
|
||||||
|
tool: key,
|
||||||
|
library_id: resolved.id,
|
||||||
|
title: resolved.title,
|
||||||
|
last_updated: resolved.last_updated,
|
||||||
|
snippet_hash: hashContent(text),
|
||||||
|
docs_preview: text.slice(0, 400),
|
||||||
|
retrieved_at: new Date().toISOString(),
|
||||||
|
source_url: `https://context7.com${resolved.id.startsWith("/") ? resolved.id : "/" + resolved.id}`,
|
||||||
|
};
|
||||||
|
cache.set(key, { entry, at: Date.now() });
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status: number = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
port: PORT,
|
||||||
|
hostname: "0.0.0.0",
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/health") {
|
||||||
|
return jsonResponse({
|
||||||
|
status: "ok",
|
||||||
|
cache_size: cache.size,
|
||||||
|
context7_base: CONTEXT7_BASE,
|
||||||
|
ttl_ms: CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/cache") {
|
||||||
|
const dump = Array.from(cache.entries()).map(([k, v]) => ({
|
||||||
|
tool: k,
|
||||||
|
library_id: v.entry.library_id,
|
||||||
|
snippet_hash: v.entry.snippet_hash,
|
||||||
|
retrieved_at: v.entry.retrieved_at,
|
||||||
|
age_ms: Date.now() - v.at,
|
||||||
|
}));
|
||||||
|
return jsonResponse({ entries: dump });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /docs/:tool — return current descriptor (fetches + caches)
|
||||||
|
const m1 = url.pathname.match(/^\/docs\/([^/]+)$/);
|
||||||
|
if (m1 && req.method === "GET") {
|
||||||
|
const tool = decodeURIComponent(m1[1]);
|
||||||
|
try {
|
||||||
|
const entry = await getCurrent(tool);
|
||||||
|
return jsonResponse(entry);
|
||||||
|
} catch (e) {
|
||||||
|
return jsonResponse({ error: (e as Error).message }, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /docs/:tool/diff?since=hash — compare current vs recorded hash
|
||||||
|
const m2 = url.pathname.match(/^\/docs\/([^/]+)\/diff$/);
|
||||||
|
if (m2 && req.method === "GET") {
|
||||||
|
const tool = decodeURIComponent(m2[1]);
|
||||||
|
const since = url.searchParams.get("since");
|
||||||
|
if (!since) {
|
||||||
|
return jsonResponse({ error: "query param 'since' (snippet_hash) required" }, 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const entry = await getCurrent(tool);
|
||||||
|
const drifted = entry.snippet_hash !== since;
|
||||||
|
return jsonResponse({
|
||||||
|
tool: entry.tool,
|
||||||
|
drifted,
|
||||||
|
previous_snippet_hash: since,
|
||||||
|
current_snippet_hash: entry.snippet_hash,
|
||||||
|
library_id: entry.library_id,
|
||||||
|
title: entry.title,
|
||||||
|
last_updated_upstream: entry.last_updated,
|
||||||
|
retrieved_at: entry.retrieved_at,
|
||||||
|
source_url: entry.source_url,
|
||||||
|
docs_preview: drifted ? entry.docs_preview : null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return jsonResponse({ error: (e as Error).message }, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ error: "not found", paths: ["/health", "/docs/:tool", "/docs/:tool/diff?since=HASH", "/cache"] }, 404);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[context7-bridge] listening on :${PORT} (context7 base: ${CONTEXT7_BASE})`);
|
||||||
Reference in New Issue
Block a user