Phase 45 slice 3: doc_drift check + resolve endpoints
lakehouse/auditor cloud: claim not backed — "Previously the hybrid fixture honestly reported layer 5 as 404/unimplemented. With this PR it flips "
lakehouse/auditor cloud: claim not backed — "Previously the hybrid fixture honestly reported layer 5 as 404/unimplemented. With this PR it flips "
Closes the last open loop of Phase 45. Previously, playbooks could
carry doc_refs (slice 1) and the context7 bridge could report drift
(slice 2) — but nothing tied them together. An operator had no way
to say "check this playbook against its doc sources and flag it if
the docs moved." This slice wires that.
Ships:
- crates/vectord/src/doc_drift.rs — thin context7 bridge client.
No cache (bridge has its own 5-min TTL). No retry (transient
failure = Unknown outcome, caller decides).
- PlaybookMemory::flag_doc_drift(id) — stamps doc_drift_flagged_at
idempotently. Once flagged, compute_boost_for_filtered_with_role
excludes the entry from both the non-geo and geo-indexed boost
paths until resolved.
- PlaybookMemory::resolve_doc_drift(id) — human re-admission.
Stamps doc_drift_reviewed_at which clears the boost exclusion.
- PlaybookMemory::get_entry(id) — new read-only accessor the
handler uses to read doc_refs without exposing the state lock.
- POST /vectors/playbook_memory/doc_drift/check/{id}
- POST /vectors/playbook_memory/doc_drift/resolve/{id}
Design call: Unknown outcomes from the bridge (bridge down, tool
not in context7, no snippet_hash recorded) are NEVER enough to
flag. Only a positive drifted=true from the bridge flips the flag.
A down bridge doesn't silently drift-flag every playbook.
Tests (5 new, in upsert_tests mod):
- flag_doc_drift_stamps_timestamp_and_persists
- flag_doc_drift_is_idempotent_on_already_flagged
- resolve_doc_drift_clears_flag_admission_gate
- boost_excludes_flagged_unreviewed_entries
- boost_re_admits_resolved_entries
14/14 upsert tests pass (9 pre-existing + 5 new).
Live end-to-end — hybrid fixture on auditor/scaffold (merged to
main at b6d69b2) now shows:
overall: PASS
shipped: [38, 40, 45.1, 45.2, 45.3]
placeholder: [—]
✓ Phase 38 /v1/chat 4039ms
✓ Phase 40 Langfuse trace 11ms
✓ Phase 45.1 seed + doc_refs 748ms
✓ Phase 45.2 bridge diff 563ms
✓ Phase 45.3 drift-check endpoint 116ms ← was a 404 before this
First time the fixture reports overall=PASS with zero placeholder
layers. The honest "not built" signal on layer 5 is now honestly
"built and working."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e57ab8ad01
commit
8bacd43465
@@ -133,6 +133,9 @@ pub fn router(state: VectorState) -> Router {
|
||||
.route("/playbook_memory/revise", post(revise_playbook_memory))
|
||||
.route("/playbook_memory/history/{id}", get(playbook_memory_history))
|
||||
.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))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -2500,6 +2503,94 @@ async fn retire_playbook_memory(
|
||||
"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
|
||||
};
|
||||
|
||||
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,
|
||||
})))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// shape as a seed request minus `append` (revise is always
|
||||
/// append-semantics for a specific parent) plus `parent_id`. The new
|
||||
|
||||
Reference in New Issue
Block a user