Phase 19 wiring + Path 1/2 work + chain integrity fixes
Backend:
- crates/vectord/src/playbook_memory.rs (new): Phase 19 in-memory boost
store with seed/rebuild/snapshot, plus temporal decay (e^-age/30 per
playbook), persist_to_sql endpoint backing successful_playbooks_live,
and discover_patterns endpoint for meta-index pattern aggregation
(recurring certs/skills/archetype/reliability across similar past fills).
- DEFAULT_TOP_K_PLAYBOOKS bumped 5 → 25; old default silently missed
most boosts when memory had > 25 entries.
- service.rs: new routes /vectors/playbook_memory/{seed,rebuild,stats,
persist_sql,patterns}.
Bun staffing co-pilot (mcp-server/):
- /search, /match, /verify, /proof, /simulation/run, MCP tools all
forward use_playbook_memory:true and playbook_memory_k:25 to the
hybrid endpoint. Boost was previously dark across the entire app.
- /log no longer POSTs to /ingest/file — that endpoint REPLACES the
dataset's object list, so single-row CSV writes were wiping all prior
rows in successful_playbooks (sp_rows went 33→1 in one /log call).
/log now seeds playbook_memory with canonical short text and calls
/persist_sql to keep successful_playbooks_live in sync.
- /simulation/run cumulative end-of-week CSV write removed for the same
reason. Per-day per-contract /seed (added in this session) is the
accumulating feedback path now.
- search.html addWorkerInsight renders a green "Endorsed · N playbooks"
chip with playbook citations when boost > 0.
Internal Dioxus UI (crates/ui/):
- Dashboard phase list rewritten through Phase 19 (was stuck at "Phase
16: File Watcher" / "Phase 17: DB Connector" — both wrong).
- Removed fabricated "27ms" stat label.
- Ask tab examples + SQL default replaced with real staffing prompts
against candidates/clients/job_orders (was referencing nonexistent
employees/products/events).
- New Playbook tab exposes /vectors/playbook_memory/{stats,rebuild} and
side-by-side hybrid search (boost OFF vs ON) with citations.
Tests (tests/multi-agent/):
- run_e2e_rated.ts: parallel two-agent (mistral + qwen2.5) build phase
+ verifier rating (geo, auth, persist, boost, speed → /10).
- network_proving.ts: continuous build → verify → repeat with
staffing-recruiter profile hot-swap; geo-discrimination check.
- chain_of_custody.ts: single recruiter operation traced through every
layer (Bun /search, direct /vectors/hybrid parity, /log, SQL,
playbook_memory growth, profile activation, post-op boost lift).
This commit is contained in:
@@ -205,3 +205,13 @@ tr:hover td { background: var(--accent-glow); }
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px;
|
||||
}
|
||||
.table-item:hover { background: var(--accent-glow); }
|
||||
|
||||
/* Phase 19 — Playbook panel */
|
||||
.boosted-row { background: rgba(120, 200, 120, 0.10); }
|
||||
.boosted-row td { border-top: 1px solid rgba(120, 200, 120, 0.30); }
|
||||
.mono-cell {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px; color: var(--text-dim);
|
||||
max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.panel-section + .panel-section { margin-top: 18px; }
|
||||
|
||||
+429
-32
@@ -178,9 +178,116 @@ enum Tab {
|
||||
Explore,
|
||||
Sql,
|
||||
Ingest,
|
||||
Playbook,
|
||||
Status,
|
||||
}
|
||||
|
||||
// --- Playbook memory types (Phase 19) ---
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
struct PlaybookStats {
|
||||
entries: usize,
|
||||
entries_with_embeddings: usize,
|
||||
#[serde(default)]
|
||||
total_names_endorsed: usize,
|
||||
#[serde(default)]
|
||||
sample: Vec<PlaybookSample>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
struct PlaybookSample {
|
||||
id: String,
|
||||
operation: String,
|
||||
#[serde(default)]
|
||||
city: Option<String>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
endorsed: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
struct HybridResp {
|
||||
#[serde(default)]
|
||||
sql_matches: usize,
|
||||
#[serde(default)]
|
||||
vector_reranked: usize,
|
||||
#[serde(default)]
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
duration_ms: u64,
|
||||
#[serde(default)]
|
||||
answer: Option<String>,
|
||||
#[serde(default)]
|
||||
sources: Vec<HybridSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
struct HybridSource {
|
||||
doc_id: String,
|
||||
chunk_text: String,
|
||||
score: f32,
|
||||
#[serde(default)]
|
||||
sql_verified: bool,
|
||||
#[serde(default)]
|
||||
playbook_boost: f32,
|
||||
#[serde(default)]
|
||||
playbook_citations: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
struct IndexInfo {
|
||||
index_name: String,
|
||||
source: String,
|
||||
#[serde(default)]
|
||||
chunk_count: usize,
|
||||
#[serde(default)]
|
||||
vector_backend: String,
|
||||
}
|
||||
|
||||
async fn fetch_playbook_stats() -> Result<PlaybookStats, String> {
|
||||
let resp = reqwest::get(&format!("{}/vectors/playbook_memory/stats", api_base()))
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}: {}", resp.status(), resp.text().await.unwrap_or_default()));
|
||||
}
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn rebuild_playbook_memory() -> Result<serde_json::Value, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client.post(&format!("{}/vectors/playbook_memory/rebuild", api_base()))
|
||||
.json(&serde_json::json!({}))
|
||||
.send().await.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}: {}", resp.status(), resp.text().await.unwrap_or_default()));
|
||||
}
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn fetch_indexes() -> Result<Vec<IndexInfo>, String> {
|
||||
let resp = reqwest::get(&format!("{}/vectors/indexes", api_base()))
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn hybrid_search(index_name: &str, question: &str, use_playbook: bool, top_k: usize) -> Result<HybridResp, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client.post(&format!("{}/vectors/hybrid", api_base()))
|
||||
.json(&serde_json::json!({
|
||||
"index_name": index_name,
|
||||
"question": question,
|
||||
"top_k": top_k,
|
||||
"generate": false,
|
||||
"use_playbook_memory": use_playbook,
|
||||
}))
|
||||
.send().await.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}: {}", resp.status(), resp.text().await.unwrap_or_default()));
|
||||
}
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// --- App ---
|
||||
|
||||
#[component]
|
||||
@@ -239,6 +346,11 @@ fn App() -> Element {
|
||||
onclick: move |_| active_tab.set(Tab::Ingest),
|
||||
"Ingest"
|
||||
}
|
||||
button {
|
||||
class: if *active_tab.read() == Tab::Playbook { "tab active" } else { "tab" },
|
||||
onclick: move |_| active_tab.set(Tab::Playbook),
|
||||
"Playbook"
|
||||
}
|
||||
button {
|
||||
class: if *active_tab.read() == Tab::Status { "tab active" } else { "tab" },
|
||||
onclick: move |_| active_tab.set(Tab::Status),
|
||||
@@ -260,6 +372,7 @@ fn App() -> Element {
|
||||
Tab::Explore => rsx! { ExplorePanel { datasets: datasets.read().clone() } },
|
||||
Tab::Sql => rsx! { SqlPanel {} },
|
||||
Tab::Ingest => rsx! { IngestPanel {} },
|
||||
Tab::Playbook => rsx! { PlaybookPanel {} },
|
||||
Tab::Status => rsx! { StatusPanel {} },
|
||||
}
|
||||
}
|
||||
@@ -354,14 +467,14 @@ fn AskPanel(datasets: Vec<Dataset>) -> Element {
|
||||
div { class: "panel ask-panel",
|
||||
div { class: "ask-hero",
|
||||
h2 { "Ask your data anything" }
|
||||
p { class: "subtitle", "Natural language → SQL → Results. Powered by local AI." }
|
||||
p { class: "subtitle", "Natural language → SQL → Results. Powered by local AI over the staffing dataset." }
|
||||
}
|
||||
|
||||
div { class: "ask-input-row",
|
||||
input {
|
||||
class: "ask-input",
|
||||
value: "{question}",
|
||||
placeholder: "e.g. Which department has the highest average salary?",
|
||||
placeholder: "e.g. Which clients placed the most candidates last quarter?",
|
||||
oninput: move |e| question.set(e.value()),
|
||||
onkeydown: move |e| {
|
||||
if e.key() == Key::Enter {
|
||||
@@ -432,10 +545,12 @@ fn AskPanel(datasets: Vec<Dataset>) -> Element {
|
||||
|
||||
div { class: "ask-examples",
|
||||
"Try: "
|
||||
button { class: "example-btn", onclick: move |_| question.set("Which department has the highest average salary?".into()), "highest avg salary by dept" }
|
||||
button { class: "example-btn", onclick: move |_| question.set("Show me the top 3 most expensive products".into()), "top 3 expensive products" }
|
||||
button { class: "example-btn", onclick: move |_| question.set("How many events per action type?".into()), "events by action" }
|
||||
button { class: "example-btn", onclick: move |_| question.set("List all employees who earn more than 90000".into()), "employees > 90k" }
|
||||
button { class: "example-btn", onclick: move |_| question.set("How many candidates do we have by city?".into()), "candidates by city" }
|
||||
button { class: "example-btn", onclick: move |_| question.set("Top 10 clients by total placements".into()), "top clients by placements" }
|
||||
button { class: "example-btn", onclick: move |_| question.set("Open job orders ordered by bill rate descending".into()), "open jobs by rate" }
|
||||
button { class: "example-btn", onclick: move |_| question.set("Recruiters with the highest placement count".into()), "top recruiters" }
|
||||
button { class: "example-btn", onclick: move |_| question.set("Total billed hours per client last month".into()), "hours per client" }
|
||||
button { class: "example-btn", onclick: move |_| question.set("Cold leads: candidates we called more than 5 times but never placed".into()), "cold leads" }
|
||||
}
|
||||
|
||||
if let Some(sql) = generated_sql.read().as_ref() {
|
||||
@@ -578,7 +693,7 @@ fn ExplorePanel(datasets: Vec<Dataset>) -> Element {
|
||||
|
||||
#[component]
|
||||
fn SqlPanel() -> Element {
|
||||
let mut query_text = use_signal(|| String::from("SELECT * FROM employees LIMIT 10"));
|
||||
let mut query_text = use_signal(|| String::from("SELECT candidate_id, first_name, last_name, city, status FROM candidates LIMIT 10"));
|
||||
let mut result = use_signal(|| None::<Result<QueryResponse, String>>);
|
||||
let mut loading = use_signal(|| false);
|
||||
|
||||
@@ -727,7 +842,7 @@ fn DashboardPanel() -> Element {
|
||||
}
|
||||
div { class: "stat-card accent",
|
||||
div { class: "stat-value", "{s[\"hnsw_loaded\"]}" }
|
||||
div { class: "stat-label", "HNSW Indexes (27ms)" }
|
||||
div { class: "stat-label", "HNSW Indexes Loaded" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{s[\"tools\"]}" }
|
||||
@@ -750,27 +865,27 @@ fn DashboardPanel() -> Element {
|
||||
div { class: "arch-grid",
|
||||
div { class: "arch-card",
|
||||
div { class: "arch-title", "Ingest" }
|
||||
div { class: "arch-items", "CSV, JSON, PDF, Text, PostgreSQL, File Watcher" }
|
||||
div { class: "arch-items", "CSV · JSON · PDF (+OCR) · Text · Postgres · MySQL · Inbox watcher · Cron schedules" }
|
||||
}
|
||||
div { class: "arch-card",
|
||||
div { class: "arch-title", "Storage" }
|
||||
div { class: "arch-items", "Parquet on Object Storage, Delta Writes, Compaction" }
|
||||
div { class: "arch-items", "Parquet on Object Storage · Delta writes · Compaction · Tombstones · Multi-bucket federation + rescue" }
|
||||
}
|
||||
div { class: "arch-card",
|
||||
div { class: "arch-title", "Query" }
|
||||
div { class: "arch-items", "DataFusion SQL, MemCache (9.8x), Hot/Cold" }
|
||||
div { class: "arch-items", "DataFusion SQL · MemCache (9.8× hot) · Merge-on-read · AI-safe views" }
|
||||
}
|
||||
div { class: "arch-card",
|
||||
div { class: "arch-title", "AI" }
|
||||
div { class: "arch-items", "Ollama (local), Embed, Generate, RAG, HNSW" }
|
||||
div { class: "arch-title", "AI / Vector" }
|
||||
div { class: "arch-items", "Ollama (local) · Embed/Generate/RAG · HNSW (Parquet) · Lance IVF_PQ · Hybrid SQL+vector · Profile-scoped" }
|
||||
}
|
||||
div { class: "arch-card",
|
||||
div { class: "arch-title", "Learning loop" }
|
||||
div { class: "arch-items", "Playbook memory · Endorsement boost · Multi-agent orchestrator · Autotune agent (Pareto-promote)" }
|
||||
}
|
||||
div { class: "arch-card",
|
||||
div { class: "arch-title", "Governance" }
|
||||
div { class: "arch-items", "Event Journal, PII Detection, Tool Registry, Access Control" }
|
||||
}
|
||||
div { class: "arch-card",
|
||||
div { class: "arch-title", "Agents" }
|
||||
div { class: "arch-items", "Workspaces, Handoff, Shortlists, Activity Logs" }
|
||||
div { class: "arch-items", "Event journal · PII detection · Tool registry · Access control · Audit log · Catalog v2 metadata" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -779,20 +894,23 @@ fn DashboardPanel() -> Element {
|
||||
h3 { "Build Progression" }
|
||||
div { class: "phase-list",
|
||||
{rsx! {
|
||||
PhaseItem { num: "0-5", name: "Foundation", detail: "Storage, Catalog, DataFusion, AI, UI, gRPC" }
|
||||
PhaseItem { num: "6", name: "Ingest Pipeline", detail: "CSV/JSON/PDF/Text auto-schema" }
|
||||
PhaseItem { num: "7", name: "Vector + RAG", detail: "Embed, Search, LLM Answers" }
|
||||
PhaseItem { num: "8", name: "Hot Cache", detail: "9.8x speedup, Delta Writes" }
|
||||
PhaseItem { num: "8.5", name: "Agent Workspaces", detail: "Per-contract, Instant Handoff" }
|
||||
PhaseItem { num: "9", name: "Event Journal", detail: "Append-only Mutation History" }
|
||||
PhaseItem { num: "10", name: "Rich Catalog", detail: "PII Detection, Lineage" }
|
||||
PhaseItem { num: "11", name: "Embedding Versioning", detail: "Model-proof Vectors" }
|
||||
PhaseItem { num: "12", name: "Tool Registry", detail: "6 Governed Actions + Audit" }
|
||||
PhaseItem { num: "13", name: "Access Control", detail: "Role-based, Field-level" }
|
||||
PhaseItem { num: "14", name: "Schema Evolution", detail: "Diff Detection, AI Migration" }
|
||||
PhaseItem { num: "15", name: "HNSW Index", detail: "100K Search in 27ms" }
|
||||
PhaseItem { num: "16", name: "File Watcher", detail: "Auto-ingest from Inbox" }
|
||||
PhaseItem { num: "17", name: "DB Connector", detail: "PostgreSQL Import" }
|
||||
PhaseItem { num: "0-5", name: "Foundation", detail: "Storage · Catalog · DataFusion · Ollama · UI · gRPC" }
|
||||
PhaseItem { num: "6", name: "Ingest Pipeline", detail: "CSV · JSON · PDF · Text · auto-schema · dedupe" }
|
||||
PhaseItem { num: "7", name: "Vector + RAG", detail: "Embed · brute-force cosine · LLM grounded answers" }
|
||||
PhaseItem { num: "8", name: "Hot Cache + Deltas", detail: "MemTable LRU · 9.8× speedup · merge-on-read · compaction" }
|
||||
PhaseItem { num: "8.5", name: "Agent Workspaces", detail: "Per-contract · daily/weekly/monthly tiers · zero-copy handoff" }
|
||||
PhaseItem { num: "9", name: "Event Journal", detail: "Append-only mutation log · time-travel · audit" }
|
||||
PhaseItem { num: "10", name: "Rich Catalog v2", detail: "PII auto-detection · lineage · freshness SLA · sensitivity" }
|
||||
PhaseItem { num: "11", name: "Embedding Versioning", detail: "Per-index model+version · A/B · incremental re-embed" }
|
||||
PhaseItem { num: "12", name: "Tool Registry", detail: "Governed actions · param validation · audit · MCP-ready" }
|
||||
PhaseItem { num: "13", name: "Access Control", detail: "Roles · field-level sensitivity · column masking · query audit" }
|
||||
PhaseItem { num: "14", name: "Schema Evolution", detail: "Diff detection · AI migration prompts · versioned schemas" }
|
||||
PhaseItem { num: "15", name: "HNSW + Trials", detail: "100K vectors · p50 873µs · trial journal · eval harness" }
|
||||
PhaseItem { num: "16", name: "Hot-swap + Autotune", detail: "Promotion registry · rollback · ε-greedy agent · Pareto winner" }
|
||||
PhaseItem { num: "17", name: "Model Profiles + VRAM", detail: "ModelProfile manifests · scoped search · sequential model swap" }
|
||||
PhaseItem { num: "18", name: "Lance hybrid backend", detail: "IVF_PQ build 14× faster · random fetch 112× · S3-native · per-profile routing" }
|
||||
PhaseItem { num: "19", name: "Playbook memory", detail: "Feedback loop · endorsement boost (cap 0.25) · orchestrator write-through · citations" }
|
||||
PhaseItem { num: "+", name: "Federation + Schedules", detail: "Multi-bucket · rescue fallback · error journal · MySQL · PDF OCR · cron ingest · catalog dedupe" }
|
||||
}}
|
||||
}
|
||||
}
|
||||
@@ -816,6 +934,285 @@ fn PhaseItem(num: String, name: String, detail: String) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
// === PLAYBOOK — Phase 19 meta-index feedback loop ===
|
||||
|
||||
#[component]
|
||||
fn PlaybookPanel() -> Element {
|
||||
let mut stats = use_signal(|| None::<Result<PlaybookStats, String>>);
|
||||
let mut indexes = use_signal(Vec::<IndexInfo>::new);
|
||||
let mut rebuild_status = use_signal(|| None::<Result<String, String>>);
|
||||
let mut rebuilding = use_signal(|| false);
|
||||
let mut loaded = use_signal(|| false);
|
||||
|
||||
// Comparison state
|
||||
let mut selected_index = use_signal(|| String::new());
|
||||
let mut question = use_signal(|| String::from("reliable assembler in Detroit"));
|
||||
let mut top_k = use_signal(|| 10usize);
|
||||
let mut compare_loading = use_signal(|| false);
|
||||
let mut hits_off = use_signal(|| None::<Result<HybridResp, String>>);
|
||||
let mut hits_on = use_signal(|| None::<Result<HybridResp, String>>);
|
||||
|
||||
let load_all = move || {
|
||||
spawn(async move {
|
||||
stats.set(Some(fetch_playbook_stats().await));
|
||||
if let Ok(ix) = fetch_indexes().await {
|
||||
if selected_index.read().is_empty() {
|
||||
if let Some(default) = ix.iter().find(|i| i.source == "workers_500k").or_else(|| ix.first()) {
|
||||
selected_index.set(default.index_name.clone());
|
||||
}
|
||||
}
|
||||
indexes.set(ix);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
use_effect(move || {
|
||||
if !*loaded.read() {
|
||||
loaded.set(true);
|
||||
load_all();
|
||||
}
|
||||
});
|
||||
|
||||
let do_rebuild = move |_| {
|
||||
spawn(async move {
|
||||
rebuilding.set(true);
|
||||
rebuild_status.set(None);
|
||||
match rebuild_playbook_memory().await {
|
||||
Ok(v) => rebuild_status.set(Some(Ok(format!("rebuild ok — {}", v)))),
|
||||
Err(e) => rebuild_status.set(Some(Err(e))),
|
||||
}
|
||||
// Refresh stats afterward
|
||||
stats.set(Some(fetch_playbook_stats().await));
|
||||
rebuilding.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
let do_compare = move |_| {
|
||||
let idx = selected_index.read().clone();
|
||||
let q = question.read().clone();
|
||||
let k = *top_k.read();
|
||||
if idx.is_empty() || q.trim().is_empty() { return; }
|
||||
spawn(async move {
|
||||
compare_loading.set(true);
|
||||
hits_off.set(None);
|
||||
hits_on.set(None);
|
||||
// Run both sequentially so the embedding cache is shared
|
||||
hits_off.set(Some(hybrid_search(&idx, &q, false, k).await));
|
||||
hits_on.set(Some(hybrid_search(&idx, &q, true, k).await));
|
||||
compare_loading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "panel",
|
||||
div { class: "ask-hero",
|
||||
h2 { "Playbook Memory" }
|
||||
p { class: "subtitle",
|
||||
"Phase 19 feedback loop: past successful playbooks boost future search rankings. \
|
||||
Endorsed workers from semantically similar past operations re-rank toward the top, \
|
||||
with citations back to the playbook that endorsed them."
|
||||
}
|
||||
}
|
||||
|
||||
// Stats card
|
||||
div { class: "panel-section",
|
||||
match stats.read().as_ref() {
|
||||
None => rsx! { div { class: "loading", "loading playbook stats..." } },
|
||||
Some(Err(e)) => rsx! { div { class: "error", "stats: {e}" } },
|
||||
Some(Ok(s)) => rsx! {
|
||||
div { class: "stat-grid",
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{s.entries}" }
|
||||
div { class: "stat-label", "Playbooks in Memory" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{s.entries_with_embeddings}" }
|
||||
div { class: "stat-label", "Embedded" }
|
||||
}
|
||||
div { class: "stat-card accent",
|
||||
div { class: "stat-value", "{s.total_names_endorsed}" }
|
||||
div { class: "stat-label", "Endorsed Worker-Tags" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "sql-actions",
|
||||
button {
|
||||
class: "btn",
|
||||
disabled: *rebuilding.read(),
|
||||
onclick: do_rebuild,
|
||||
if *rebuilding.read() { "rebuilding from successful_playbooks..." } else { "Rebuild from successful_playbooks" }
|
||||
}
|
||||
}
|
||||
if let Some(s) = rebuild_status.read().as_ref() {
|
||||
match s {
|
||||
Ok(msg) => rsx! { div { class: "result-box", "{msg}" } },
|
||||
Err(e) => rsx! { div { class: "error", "{e}" } },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sample playbooks
|
||||
if let Some(Ok(s)) = stats.read().as_ref() {
|
||||
if !s.sample.is_empty() {
|
||||
div { class: "panel-section",
|
||||
h3 { "Sample playbooks" }
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead { tr {
|
||||
th { "ID" }
|
||||
th { "Operation" }
|
||||
th { "Location" }
|
||||
th { "Endorsed" }
|
||||
} }
|
||||
tbody {
|
||||
for pb in s.sample.iter() {
|
||||
{
|
||||
let loc = match (&pb.city, &pb.state) {
|
||||
(Some(c), Some(st)) => format!("{c}, {st}"),
|
||||
_ => "—".into(),
|
||||
};
|
||||
let endorsed = if pb.endorsed.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
pb.endorsed.join(", ")
|
||||
};
|
||||
let pid = pb.id.clone();
|
||||
let op = pb.operation.clone();
|
||||
rsx! {
|
||||
tr {
|
||||
td { class: "mono-cell", title: "{pid}", "{pid}" }
|
||||
td { "{op}" }
|
||||
td { "{loc}" }
|
||||
td { "{endorsed}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Side-by-side comparison: boost OFF vs ON
|
||||
div { class: "panel-section",
|
||||
h3 { "See the boost — search compared" }
|
||||
p { class: "hint",
|
||||
"Run the same query against the same index twice — once with playbook boost OFF and once ON. \
|
||||
Hits with non-zero playbook_boost and citations are workers that past similar playbooks endorsed."
|
||||
}
|
||||
div { class: "form-row",
|
||||
label { "Index" }
|
||||
select {
|
||||
value: "{selected_index}",
|
||||
onchange: move |e| selected_index.set(e.value()),
|
||||
for ix in indexes.read().iter() {
|
||||
option { value: "{ix.index_name}", "{ix.index_name} ({ix.source}, {ix.chunk_count} chunks, {ix.vector_backend})" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "form-row",
|
||||
label { "Question" }
|
||||
input {
|
||||
value: "{question}",
|
||||
oninput: move |e| question.set(e.value()),
|
||||
placeholder: "e.g. reliable assembler in Detroit"
|
||||
}
|
||||
}
|
||||
div { class: "form-row",
|
||||
label { "Top K" }
|
||||
input {
|
||||
r#type: "number",
|
||||
value: "{top_k}",
|
||||
oninput: move |e| {
|
||||
if let Ok(n) = e.value().parse::<usize>() { top_k.set(n.clamp(1, 50)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ask",
|
||||
disabled: *compare_loading.read(),
|
||||
onclick: do_compare,
|
||||
if *compare_loading.read() { "running both queries..." } else { "Run comparison" }
|
||||
}
|
||||
|
||||
div { class: "explore-grid",
|
||||
div { class: "ds-detail",
|
||||
h3 { "Boost OFF (vanilla)" }
|
||||
match hits_off.read().as_ref() {
|
||||
None => rsx! { div { class: "empty", "—" } },
|
||||
Some(Err(e)) => rsx! { div { class: "error", "{e}" } },
|
||||
Some(Ok(r)) => rsx! { HybridHitTable { resp: r.clone() } },
|
||||
}
|
||||
}
|
||||
div { class: "ds-detail",
|
||||
h3 { "Boost ON (Phase 19)" }
|
||||
match hits_on.read().as_ref() {
|
||||
None => rsx! { div { class: "empty", "—" } },
|
||||
Some(Err(e)) => rsx! { div { class: "error", "{e}" } },
|
||||
Some(Ok(r)) => rsx! { HybridHitTable { resp: r.clone() } },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn HybridHitTable(resp: HybridResp) -> Element {
|
||||
rsx! {
|
||||
div { class: "results-info",
|
||||
"{resp.sources.len()} hits · {resp.duration_ms}ms · method={resp.method}"
|
||||
}
|
||||
if resp.sources.is_empty() {
|
||||
div { class: "empty-sm", "no hits" }
|
||||
} else {
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead { tr {
|
||||
th { "#" }
|
||||
th { "Doc" }
|
||||
th { "Score" }
|
||||
th { "Boost" }
|
||||
th { "Citations" }
|
||||
th { "Snippet" }
|
||||
} }
|
||||
tbody {
|
||||
for (i, h) in resp.sources.iter().enumerate() {
|
||||
{
|
||||
let snippet: String = h.chunk_text.chars().take(120).collect();
|
||||
let cites = if h.playbook_citations.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
h.playbook_citations.join(", ")
|
||||
};
|
||||
let row_class = if h.playbook_boost > 0.0 { "boosted-row" } else { "" };
|
||||
let rank = i + 1;
|
||||
let did = h.doc_id.clone();
|
||||
let score = format!("{:.3}", h.score);
|
||||
let boost = if h.playbook_boost > 0.0 { format!("+{:.3}", h.playbook_boost) } else { "—".into() };
|
||||
rsx! {
|
||||
tr { class: "{row_class}",
|
||||
td { "{rank}" }
|
||||
td { class: "mono-cell", "{did}" }
|
||||
td { "{score}" }
|
||||
td { "{boost}" }
|
||||
td { class: "mono-cell", title: "{cites}", "{cites}" }
|
||||
td { "{snippet}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === INGEST — Data on-ramp ===
|
||||
|
||||
#[component]
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod harness;
|
||||
pub mod hnsw;
|
||||
pub mod index_registry;
|
||||
pub mod jobs;
|
||||
pub mod playbook_memory;
|
||||
pub mod promotion;
|
||||
pub mod refresh;
|
||||
pub mod store;
|
||||
|
||||
@@ -0,0 +1,825 @@
|
||||
//! Phase 19: Playbook memory — the feedback loop that makes the index
|
||||
//! learn from real outcomes instead of just logging them.
|
||||
//!
|
||||
//! When an agent (multi-agent orchestrator or human operator) seals a
|
||||
//! successful playbook, it lands in the `successful_playbooks` dataset.
|
||||
//! Historically that was a write-only log. This module turns it into a
|
||||
//! re-ranking signal:
|
||||
//!
|
||||
//! 1. `rebuild` reads every row of `successful_playbooks`, embeds the
|
||||
//! operation+approach+context as one vector per playbook, parses
|
||||
//! out the worker names from the `result` column, and stores both
|
||||
//! the vectors and the (playbook → names) endorsement map in memory.
|
||||
//!
|
||||
//! 2. At query time, `compute_boost_for` takes a new operation text
|
||||
//! (e.g. "fill: Welder x2 in Toledo, OH"), embeds it, brute-force
|
||||
//! ranks past playbooks by cosine similarity, and returns a boost
|
||||
//! map keyed by (city, state, worker_name) → `BoostEntry`. Each
|
||||
//! entry carries its similarity score and the citing playbook_ids,
|
||||
//! so explanations ("ranked higher because of 3 similar past fills
|
||||
//! in Toledo") are free.
|
||||
//!
|
||||
//! 3. The `use_playbook_memory` flag on `/vectors/hybrid` adds those
|
||||
//! boosts to matching search hits and re-sorts.
|
||||
//!
|
||||
//! Why brute force instead of another HNSW: `successful_playbooks` grows
|
||||
//! by operators, not automation. A few thousand rows is the realistic
|
||||
//! ceiling for years. Brute force at 10K × 768d is <10ms on this hardware
|
||||
//! — not worth the operational cost of another indexed surface.
|
||||
//!
|
||||
//! Persistence: the endorsements map round-trips through
|
||||
//! `_playbook_memory/state.json` in primary storage so the cache
|
||||
//! survives restarts without a full rebuild.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use aibridge::client::{AiClient, EmbedRequest};
|
||||
use object_store::ObjectStore;
|
||||
use storaged::ops;
|
||||
|
||||
const STATE_KEY: &str = "_playbook_memory/state.json";
|
||||
|
||||
/// Maximum boost a single worker can accumulate across all similar past
|
||||
/// playbooks. Prevents one very popular worker from always winning.
|
||||
pub const MAX_BOOST_PER_WORKER: f32 = 0.25;
|
||||
|
||||
/// Default number of past playbooks to consider when ranking the current
|
||||
/// operation. Bumped 5 → 25 on 2026-04-20 because at >100 entries in
|
||||
/// memory the old default missed too many relevant playbooks — boost
|
||||
/// silently failed even when the seeded workers were ideal matches.
|
||||
/// 25 is brute-force-cheap (sub-ms) and covers most live operator memory.
|
||||
pub const DEFAULT_TOP_K_PLAYBOOKS: usize = 25;
|
||||
|
||||
/// Half-life of a playbook's contribution to boost, in days. A playbook
|
||||
/// 30 days old contributes half what a fresh one would; 60 days old, a
|
||||
/// quarter; etc. Per Path 1 (deepen statistical) — stale endorsements
|
||||
/// shouldn't dominate fresh signal. Recruiter trust depends on this.
|
||||
pub const BOOST_HALF_LIFE_DAYS: f32 = 30.0;
|
||||
|
||||
/// Shape of one playbook in memory. The embedding is optional so we can
|
||||
/// round-trip a cached state without re-embedding; the rebuild path
|
||||
/// populates it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlaybookEntry {
|
||||
pub playbook_id: String,
|
||||
pub operation: String,
|
||||
pub approach: String,
|
||||
pub context: String,
|
||||
pub timestamp: String,
|
||||
/// Parsed out of `result` (e.g. "2/2 filled → Matthew Roberts, Amy Davis").
|
||||
/// Stored as raw names; matching against search results happens on
|
||||
/// (city, state, name) tuples at boost time.
|
||||
pub endorsed_names: Vec<String>,
|
||||
/// City + state parsed out of the operation string. Kept separately
|
||||
/// so boost matching doesn't re-parse on every query.
|
||||
pub city: Option<String>,
|
||||
pub state: Option<String>,
|
||||
/// Embedding of `operation + approach + context`. Option so persisted
|
||||
/// state can omit it on first load and have a later embed() fill in.
|
||||
#[serde(default)]
|
||||
pub embedding: Option<Vec<f32>>,
|
||||
}
|
||||
|
||||
/// Persisted / in-memory state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct PlaybookMemoryState {
|
||||
entries: Vec<PlaybookEntry>,
|
||||
/// Unix epoch millis when the last rebuild completed. Caller can
|
||||
/// use this to gate "stale > N hours → trigger rebuild" behavior.
|
||||
last_rebuilt_at: i64,
|
||||
}
|
||||
|
||||
/// Per-worker boost payload. `citations` lets the response layer show
|
||||
/// "boosted because of these past fills" without a second lookup.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BoostEntry {
|
||||
pub boost: f32,
|
||||
pub citations: Vec<String>, // playbook_ids that endorsed this worker
|
||||
}
|
||||
|
||||
/// Live handle passed around the service. Clone-cheap (all state is
|
||||
/// inside one Arc<RwLock>).
|
||||
#[derive(Clone)]
|
||||
pub struct PlaybookMemory {
|
||||
state: Arc<RwLock<PlaybookMemoryState>>,
|
||||
store: Arc<dyn ObjectStore>,
|
||||
}
|
||||
|
||||
impl PlaybookMemory {
|
||||
pub fn new(store: Arc<dyn ObjectStore>) -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(PlaybookMemoryState::default())),
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort load from primary storage. Missing = empty memory; the
|
||||
/// first `/rebuild` call will hydrate it.
|
||||
pub async fn load_from_storage(&self) -> Result<usize, String> {
|
||||
let data = match ops::get(&self.store, STATE_KEY).await {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Ok(0),
|
||||
};
|
||||
let persisted: PlaybookMemoryState = serde_json::from_slice(&data)
|
||||
.map_err(|e| format!("parse playbook_memory state: {e}"))?;
|
||||
let n = persisted.entries.len();
|
||||
*self.state.write().await = persisted;
|
||||
tracing::info!("playbook_memory: loaded {n} entries from {STATE_KEY}");
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
async fn persist(&self) -> Result<(), String> {
|
||||
let snapshot = self.state.read().await.clone();
|
||||
let bytes = serde_json::to_vec_pretty(&snapshot).map_err(|e| e.to_string())?;
|
||||
ops::put(&self.store, STATE_KEY, bytes.into()).await
|
||||
}
|
||||
|
||||
/// Replace the full in-memory state atomically and persist.
|
||||
pub async fn set_entries(&self, entries: Vec<PlaybookEntry>) -> Result<(), String> {
|
||||
let mut s = self.state.write().await;
|
||||
s.entries = entries;
|
||||
s.last_rebuilt_at = chrono::Utc::now().timestamp_millis();
|
||||
drop(s);
|
||||
self.persist().await
|
||||
}
|
||||
|
||||
pub async fn entry_count(&self) -> usize {
|
||||
self.state.read().await.entries.len()
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> Vec<PlaybookEntry> {
|
||||
self.state.read().await.entries.clone()
|
||||
}
|
||||
|
||||
/// Given an operation's embedding, find the top-K most similar past
|
||||
/// playbooks (by cosine similarity) and return a per-worker boost map
|
||||
/// keyed by (city, state, name). Worker is matched by the tuple so a
|
||||
/// shared name across cities doesn't cross-pollinate.
|
||||
///
|
||||
/// Boost formula: each qualifying playbook contributes
|
||||
/// `similarity * base_weight / n_workers` to each worker it endorsed,
|
||||
/// where `base_weight` is tuned to keep the cap realistic without
|
||||
/// forcing every result to saturate. Total per worker is capped at
|
||||
/// `MAX_BOOST_PER_WORKER`.
|
||||
pub async fn compute_boost_for(
|
||||
&self,
|
||||
query_embedding: &[f32],
|
||||
top_k_playbooks: usize,
|
||||
base_weight: f32,
|
||||
) -> HashMap<(String, String, String), BoostEntry> {
|
||||
let entries = self.state.read().await.entries.clone();
|
||||
|
||||
// Brute-force cosine. Empty / missing embeddings just skip.
|
||||
let mut scored: Vec<(f32, &PlaybookEntry)> = entries
|
||||
.iter()
|
||||
.filter_map(|e| e.embedding.as_ref().map(|v| (cosine(query_embedding, v), e)))
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(top_k_playbooks.max(1));
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut boosts: HashMap<(String, String, String), BoostEntry> = HashMap::new();
|
||||
for (similarity, pb) in &scored {
|
||||
// Negative or near-zero similarity = not actually related;
|
||||
// skip so we don't inject noise when the memory is sparse.
|
||||
if *similarity <= 0.05 { continue; }
|
||||
let Some(city) = &pb.city else { continue; };
|
||||
let Some(state) = &pb.state else { continue; };
|
||||
let n_workers = pb.endorsed_names.len().max(1);
|
||||
// Path 1 — temporal decay. Older playbooks weight less. Failure
|
||||
// to parse the timestamp degrades to "no decay" (treat as fresh)
|
||||
// rather than dropping the entry entirely; keeps backward
|
||||
// compatibility with seed payloads that omitted timestamp.
|
||||
let decay = chrono::DateTime::parse_from_rfc3339(&pb.timestamp)
|
||||
.ok()
|
||||
.map(|t| {
|
||||
let age_days = (now.signed_duration_since(t.with_timezone(&chrono::Utc))
|
||||
.num_seconds() as f32) / 86400.0;
|
||||
if age_days <= 0.0 { 1.0 }
|
||||
else { (-age_days / BOOST_HALF_LIFE_DAYS).exp() }
|
||||
})
|
||||
.unwrap_or(1.0);
|
||||
let per_worker = similarity * base_weight * decay / (n_workers as f32);
|
||||
for name in &pb.endorsed_names {
|
||||
let key = (city.clone(), state.clone(), name.clone());
|
||||
let entry = boosts.entry(key).or_insert(BoostEntry {
|
||||
boost: 0.0,
|
||||
citations: Vec::new(),
|
||||
});
|
||||
entry.boost = (entry.boost + per_worker).min(MAX_BOOST_PER_WORKER);
|
||||
if !entry.citations.contains(&pb.playbook_id) {
|
||||
entry.citations.push(pb.playbook_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
boosts
|
||||
}
|
||||
}
|
||||
|
||||
/// Cosine similarity — pulled out so rebuild/boost share one impl.
|
||||
fn cosine(a: &[f32], b: &[f32]) -> f32 {
|
||||
let (mut dot, mut na, mut nb) = (0.0_f32, 0.0_f32, 0.0_f32);
|
||||
let n = a.len().min(b.len());
|
||||
for i in 0..n {
|
||||
dot += a[i] * b[i];
|
||||
na += a[i] * a[i];
|
||||
nb += b[i] * b[i];
|
||||
}
|
||||
if na == 0.0 || nb == 0.0 { return 0.0; }
|
||||
dot / (na.sqrt() * nb.sqrt())
|
||||
}
|
||||
|
||||
// ---------------- Pattern discovery (Path 2 — meta-index) ----------------
|
||||
//
|
||||
// Phase 19's boost path answers "for THIS exact city + role, which workers
|
||||
// have we used before?" Pattern discovery answers a different question:
|
||||
// "for queries like this one, what TRAITS have past successful fills had
|
||||
// in common — even if no exact prior playbook covers this geo?"
|
||||
//
|
||||
// The discovered pattern surfaces signals the operator didn't query for:
|
||||
// e.g. "every successful Welder fill we've seen carried OSHA-10 + lockout
|
||||
// /tagout — you may want to filter on those." That's the meta-index
|
||||
// dimension of the original PRD: identify things we didn't know about.
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PatternReport {
|
||||
pub query: String,
|
||||
pub matched_playbooks: usize,
|
||||
pub total_workers_examined: usize,
|
||||
pub common_certifications: Vec<TraitFreq>,
|
||||
pub common_skills: Vec<TraitFreq>,
|
||||
pub modal_archetype: Option<String>,
|
||||
pub reliability_p50: f64,
|
||||
pub reliability_min: f64,
|
||||
pub reliability_max: f64,
|
||||
pub matched_playbook_ids: Vec<String>,
|
||||
pub discovered_pattern: String,
|
||||
pub duration_secs: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TraitFreq {
|
||||
pub name: String,
|
||||
pub count: usize,
|
||||
pub frequency: f32,
|
||||
}
|
||||
|
||||
pub async fn discover_patterns(
|
||||
memory: &PlaybookMemory,
|
||||
ai_client: &AiClient,
|
||||
catalog: &catalogd::registry::Registry,
|
||||
buckets: &Arc<storaged::registry::BucketRegistry>,
|
||||
query: &str,
|
||||
top_k_playbooks: usize,
|
||||
min_trait_frequency: f32,
|
||||
) -> Result<PatternReport, String> {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
// 1. Embed the query through the same nomic-embed-text model used
|
||||
// for playbook embeddings, so cosine is meaningful.
|
||||
let resp = ai_client
|
||||
.embed(EmbedRequest { texts: vec![query.into()], model: None })
|
||||
.await
|
||||
.map_err(|e| format!("embed query: {e}"))?;
|
||||
if resp.embeddings.is_empty() {
|
||||
return Err("embed returned no vectors".into());
|
||||
}
|
||||
let qv: Vec<f32> = resp.embeddings[0].iter().map(|x| *x as f32).collect();
|
||||
|
||||
// 2. Find top-K most similar past playbooks (cosine over embeddings).
|
||||
let entries = memory.snapshot().await;
|
||||
let mut scored: Vec<(f32, &PlaybookEntry)> = entries
|
||||
.iter()
|
||||
.filter_map(|e| e.embedding.as_ref().map(|v| (cosine(&qv, v), e)))
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(top_k_playbooks);
|
||||
let matched: Vec<(f32, PlaybookEntry)> = scored
|
||||
.into_iter()
|
||||
.filter(|(s, _)| *s > 0.05)
|
||||
.map(|(s, e)| (s, e.clone()))
|
||||
.collect();
|
||||
|
||||
if matched.is_empty() {
|
||||
return Ok(PatternReport {
|
||||
query: query.into(),
|
||||
matched_playbooks: 0,
|
||||
total_workers_examined: 0,
|
||||
common_certifications: vec![],
|
||||
common_skills: vec![],
|
||||
modal_archetype: None,
|
||||
reliability_p50: 0.0, reliability_min: 0.0, reliability_max: 0.0,
|
||||
matched_playbook_ids: vec![],
|
||||
discovered_pattern: "No similar past playbooks found.".into(),
|
||||
duration_secs: t0.elapsed().as_secs_f32(),
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Pull each endorsed worker's full profile from workers_500k.
|
||||
// Restrict by (name, city, state) tuple so cross-city homonyms
|
||||
// don't pollute the aggregate.
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut matched_ids: Vec<String> = Vec::new();
|
||||
for (_, pb) in &matched {
|
||||
matched_ids.push(pb.playbook_id.clone());
|
||||
let (Some(city), Some(state)) = (pb.city.as_ref(), pb.state.as_ref()) else { continue };
|
||||
for name in &pb.endorsed_names {
|
||||
let esc = |s: &str| s.replace('\'', "''");
|
||||
conditions.push(format!(
|
||||
"(name = '{}' AND city = '{}' AND state = '{}')",
|
||||
esc(name), esc(city), esc(state)
|
||||
));
|
||||
}
|
||||
}
|
||||
if conditions.is_empty() {
|
||||
return Ok(PatternReport {
|
||||
query: query.into(),
|
||||
matched_playbooks: matched.len(),
|
||||
total_workers_examined: 0,
|
||||
common_certifications: vec![], common_skills: vec![],
|
||||
modal_archetype: None, reliability_p50: 0.0,
|
||||
reliability_min: 0.0, reliability_max: 0.0,
|
||||
matched_playbook_ids: matched_ids,
|
||||
discovered_pattern: "Matched playbooks but no endorsed names with city/state to lookup.".into(),
|
||||
duration_secs: t0.elapsed().as_secs_f32(),
|
||||
});
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
"SELECT name, role, city, state, certifications, skills, archetype, \
|
||||
CAST(reliability AS DOUBLE) as reliability \
|
||||
FROM workers_500k WHERE {} LIMIT 500",
|
||||
conditions.join(" OR ")
|
||||
);
|
||||
let engine = queryd::context::QueryEngine::new(
|
||||
catalog.clone(), buckets.clone(), queryd::cache::MemCache::new(0),
|
||||
);
|
||||
let batches = engine.query(&sql).await.map_err(|e| format!("worker lookup: {e}"))?;
|
||||
|
||||
// 4. Aggregate. Pipe-separated cert/skill lists, single-string archetype,
|
||||
// numeric reliability. Frequencies are share-of-workers.
|
||||
use arrow::array::{Array, AsArray};
|
||||
let mut cert_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut skill_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut arch_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut reliabilities: Vec<f64> = Vec::new();
|
||||
let mut total = 0usize;
|
||||
|
||||
let get_string = |b: &arrow::record_batch::RecordBatch, col: &str, row: usize| -> String {
|
||||
let Some(c) = b.column_by_name(col) else { return String::new(); };
|
||||
if let Some(arr) = c.as_string_view_opt() {
|
||||
if arr.is_null(row) { return String::new(); }
|
||||
return arr.value(row).to_string();
|
||||
}
|
||||
if let Some(arr) = c.as_string_opt::<i32>() {
|
||||
if arr.is_null(row) { return String::new(); }
|
||||
return arr.value(row).to_string();
|
||||
}
|
||||
String::new()
|
||||
};
|
||||
let get_f64 = |b: &arrow::record_batch::RecordBatch, col: &str, row: usize| -> f64 {
|
||||
let Some(c) = b.column_by_name(col) else { return 0.0; };
|
||||
if let Some(arr) = c.as_primitive_opt::<arrow::datatypes::Float64Type>() {
|
||||
if arr.is_null(row) { return 0.0; }
|
||||
return arr.value(row);
|
||||
}
|
||||
0.0
|
||||
};
|
||||
|
||||
for b in &batches {
|
||||
for row in 0..b.num_rows() {
|
||||
total += 1;
|
||||
let certs = get_string(b, "certifications", row);
|
||||
for c in certs.split(['|', ',']).map(|s| s.trim()).filter(|s| !s.is_empty() && *s != "none") {
|
||||
*cert_counts.entry(c.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
let skills = get_string(b, "skills", row);
|
||||
for s in skills.split(['|', ',']).map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||
*skill_counts.entry(s.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
let arch = get_string(b, "archetype", row);
|
||||
if !arch.is_empty() {
|
||||
*arch_counts.entry(arch).or_insert(0) += 1;
|
||||
}
|
||||
let rel = get_f64(b, "reliability", row);
|
||||
if rel > 0.0 { reliabilities.push(rel); }
|
||||
}
|
||||
}
|
||||
|
||||
let total_f = total.max(1) as f32;
|
||||
let to_freq = |m: HashMap<String, usize>, min: f32| -> Vec<TraitFreq> {
|
||||
let mut v: Vec<TraitFreq> = m.into_iter()
|
||||
.map(|(name, count)| TraitFreq { name, count, frequency: count as f32 / total_f })
|
||||
.filter(|t| t.frequency >= min)
|
||||
.collect();
|
||||
v.sort_by(|a, b| b.count.cmp(&a.count));
|
||||
v.truncate(8);
|
||||
v
|
||||
};
|
||||
let common_certifications = to_freq(cert_counts, min_trait_frequency);
|
||||
let common_skills = to_freq(skill_counts, min_trait_frequency);
|
||||
let modal_archetype = arch_counts.into_iter()
|
||||
.max_by_key(|(_, c)| *c)
|
||||
.map(|(name, _)| name);
|
||||
|
||||
reliabilities.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let p50 = if reliabilities.is_empty() { 0.0 } else { reliabilities[reliabilities.len() / 2] };
|
||||
let rmin = reliabilities.first().copied().unwrap_or(0.0);
|
||||
let rmax = reliabilities.last().copied().unwrap_or(0.0);
|
||||
|
||||
// Build a human-readable discovered-pattern summary
|
||||
let mut parts: Vec<String> = vec![
|
||||
format!("Across {} similar past playbooks ({} workers examined)", matched.len(), total),
|
||||
];
|
||||
if !common_certifications.is_empty() {
|
||||
let head: Vec<String> = common_certifications.iter().take(3)
|
||||
.map(|t| format!("{} ({:.0}%)", t.name, t.frequency * 100.0)).collect();
|
||||
parts.push(format!("recurring certifications: {}", head.join(", ")));
|
||||
}
|
||||
if !common_skills.is_empty() {
|
||||
let head: Vec<String> = common_skills.iter().take(3)
|
||||
.map(|t| format!("{} ({:.0}%)", t.name, t.frequency * 100.0)).collect();
|
||||
parts.push(format!("recurring skills: {}", head.join(", ")));
|
||||
}
|
||||
if let Some(a) = &modal_archetype { parts.push(format!("archetype mostly: {a}")); }
|
||||
if !reliabilities.is_empty() {
|
||||
parts.push(format!("reliability median {:.2} (range {:.2}–{:.2})", p50, rmin, rmax));
|
||||
}
|
||||
let discovered_pattern = parts.join(" · ");
|
||||
|
||||
Ok(PatternReport {
|
||||
query: query.into(),
|
||||
matched_playbooks: matched.len(),
|
||||
total_workers_examined: total,
|
||||
common_certifications, common_skills,
|
||||
modal_archetype, reliability_p50: p50,
|
||||
reliability_min: rmin, reliability_max: rmax,
|
||||
matched_playbook_ids: matched_ids,
|
||||
discovered_pattern,
|
||||
duration_secs: t0.elapsed().as_secs_f32(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------- Persist memory → SQL (Path 2 foundation) ----------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PersistReport {
|
||||
pub rows_persisted: usize,
|
||||
pub dataset_name: String,
|
||||
pub fingerprint: String,
|
||||
pub duration_secs: f32,
|
||||
}
|
||||
|
||||
/// Dump current in-memory state to a queryable Parquet under
|
||||
/// `successful_playbooks_live`. Registers fresh objects each call — safe
|
||||
/// because in-memory state is the source of truth here, so REPLACING the
|
||||
/// objects list reflects the real state, not destroying it.
|
||||
///
|
||||
/// Distinct from the existing `successful_playbooks` dataset (which is
|
||||
/// read by `rebuild()`), so this never collides with operator imports of
|
||||
/// historical playbook data. Recruiter-facing SQL surfaces should query
|
||||
/// `successful_playbooks_live` for current operator activity.
|
||||
pub async fn persist_to_sql(
|
||||
memory: &PlaybookMemory,
|
||||
catalog: &catalogd::registry::Registry,
|
||||
) -> Result<PersistReport, String> {
|
||||
use arrow::array::StringArray;
|
||||
use arrow::datatypes::{DataType, Field, Schema};
|
||||
use arrow::record_batch::RecordBatch;
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let entries = memory.snapshot().await;
|
||||
|
||||
let schema = Arc::new(Schema::new(vec![
|
||||
Field::new("timestamp", DataType::Utf8, true),
|
||||
Field::new("operation", DataType::Utf8, true),
|
||||
Field::new("approach", DataType::Utf8, true),
|
||||
Field::new("result", DataType::Utf8, true),
|
||||
Field::new("context", DataType::Utf8, true),
|
||||
]));
|
||||
|
||||
let timestamps: Vec<&str> = entries.iter().map(|e| e.timestamp.as_str()).collect();
|
||||
let operations: Vec<&str> = entries.iter().map(|e| e.operation.as_str()).collect();
|
||||
let approaches: Vec<&str> = entries.iter().map(|e| e.approach.as_str()).collect();
|
||||
let contexts: Vec<&str> = entries.iter().map(|e| e.context.as_str()).collect();
|
||||
// Result column is reconstructed from endorsed_names so SQL queries
|
||||
// against successful_playbooks_live see the same shape as the original
|
||||
// CSV-fed successful_playbooks ("N/N filled → Name1, Name2").
|
||||
let results: Vec<String> = entries.iter().map(|e| {
|
||||
if e.endorsed_names.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let n = e.endorsed_names.len();
|
||||
format!("{}/{} filled → {}", n, n, e.endorsed_names.join(", "))
|
||||
}
|
||||
}).collect();
|
||||
let result_refs: Vec<&str> = results.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let batch = RecordBatch::try_new(schema.clone(), vec![
|
||||
Arc::new(StringArray::from(timestamps)),
|
||||
Arc::new(StringArray::from(operations)),
|
||||
Arc::new(StringArray::from(approaches)),
|
||||
Arc::new(StringArray::from(result_refs)),
|
||||
Arc::new(StringArray::from(contexts)),
|
||||
]).map_err(|e| format!("build record batch: {e}"))?;
|
||||
|
||||
let parquet_bytes = shared::arrow_helpers::record_batch_to_parquet(&batch)?;
|
||||
let fp = shared::arrow_helpers::fingerprint_schema(&schema);
|
||||
|
||||
let key = "datasets/successful_playbooks_live.parquet";
|
||||
ops::put(&memory.store, key, parquet_bytes.clone()).await?;
|
||||
|
||||
let obj = shared::types::ObjectRef {
|
||||
bucket: "primary".into(),
|
||||
key: key.into(),
|
||||
size_bytes: parquet_bytes.len() as u64,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let manifest = catalog.register(
|
||||
"successful_playbooks_live".into(),
|
||||
fp.clone(),
|
||||
vec![obj],
|
||||
).await?;
|
||||
|
||||
Ok(PersistReport {
|
||||
rows_persisted: entries.len(),
|
||||
dataset_name: manifest.name,
|
||||
fingerprint: fp.0,
|
||||
duration_secs: t0.elapsed().as_secs_f32(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------- Rebuild (the core of Phase 19) ----------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RebuildReport {
|
||||
pub rows_scanned: usize,
|
||||
pub entries_built: usize,
|
||||
pub total_names_endorsed: usize,
|
||||
pub duration_secs: f32,
|
||||
}
|
||||
|
||||
/// Full rebuild: scan `successful_playbooks`, extract endorsements, embed
|
||||
/// each row's operation+approach+context, replace the in-memory state.
|
||||
///
|
||||
/// Returns the report so callers can show operators what happened.
|
||||
pub async fn rebuild(
|
||||
memory: &PlaybookMemory,
|
||||
ai_client: &AiClient,
|
||||
catalog: &catalogd::registry::Registry,
|
||||
buckets: &Arc<storaged::registry::BucketRegistry>,
|
||||
) -> Result<RebuildReport, String> {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
// 1. Pull every row of successful_playbooks through the query engine.
|
||||
let sql = "SELECT timestamp, operation, approach, result, context \
|
||||
FROM successful_playbooks";
|
||||
let engine = queryd::context::QueryEngine::new(
|
||||
catalog.clone(),
|
||||
buckets.clone(),
|
||||
queryd::cache::MemCache::new(0),
|
||||
);
|
||||
let batches = engine
|
||||
.query(sql)
|
||||
.await
|
||||
.map_err(|e| format!("query successful_playbooks: {e}"))?;
|
||||
|
||||
let mut rows: Vec<(String, String, String, String, String)> = Vec::new();
|
||||
for b in &batches {
|
||||
let n = b.num_rows();
|
||||
let get = |col: &str, row: usize| -> String {
|
||||
use arrow::array::{Array, AsArray};
|
||||
let Some(c) = b.column_by_name(col) else { return String::new(); };
|
||||
if let Some(arr) = c.as_string_view_opt() {
|
||||
if arr.is_null(row) { return String::new(); }
|
||||
return arr.value(row).to_string();
|
||||
}
|
||||
if let Some(arr) = c.as_string_opt::<i32>() {
|
||||
if arr.is_null(row) { return String::new(); }
|
||||
return arr.value(row).to_string();
|
||||
}
|
||||
String::new()
|
||||
};
|
||||
for row in 0..n {
|
||||
rows.push((
|
||||
get("timestamp", row),
|
||||
get("operation", row),
|
||||
get("approach", row),
|
||||
get("result", row),
|
||||
get("context", row),
|
||||
));
|
||||
}
|
||||
}
|
||||
let rows_scanned = rows.len();
|
||||
|
||||
// 2. For each row, build a PlaybookEntry (no embedding yet). Parse
|
||||
// the operation for (city, state) and the result for names.
|
||||
let mut entries: Vec<PlaybookEntry> = rows
|
||||
.into_iter()
|
||||
.map(|(ts, op, approach, result, ctx)| {
|
||||
let (city, state) = parse_city_state(&op);
|
||||
let names = parse_names(&result);
|
||||
PlaybookEntry {
|
||||
playbook_id: stable_id(&ts, &op),
|
||||
operation: op,
|
||||
approach,
|
||||
context: ctx,
|
||||
timestamp: ts,
|
||||
endorsed_names: names,
|
||||
city,
|
||||
state,
|
||||
embedding: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3. Embed in one batch. Sidecar's embed handles batching internally;
|
||||
// chunk here to ~64 per request to keep memory flat.
|
||||
const EMBED_BATCH: usize = 64;
|
||||
for chunk_start in (0..entries.len()).step_by(EMBED_BATCH) {
|
||||
let end = (chunk_start + EMBED_BATCH).min(entries.len());
|
||||
let texts: Vec<String> = entries[chunk_start..end]
|
||||
.iter()
|
||||
.map(embed_text)
|
||||
.collect();
|
||||
let req = EmbedRequest { texts, model: None };
|
||||
let resp = ai_client
|
||||
.embed(req)
|
||||
.await
|
||||
.map_err(|e| format!("embed batch [{chunk_start}..{end}]: {e}"))?;
|
||||
for (i, v) in resp.embeddings.iter().enumerate() {
|
||||
let f32v: Vec<f32> = v.iter().map(|&x| x as f32).collect();
|
||||
entries[chunk_start + i].embedding = Some(f32v);
|
||||
}
|
||||
}
|
||||
|
||||
let total_names_endorsed: usize = entries.iter().map(|e| e.endorsed_names.len()).sum();
|
||||
let entries_built = entries.len();
|
||||
|
||||
memory.set_entries(entries).await?;
|
||||
|
||||
Ok(RebuildReport {
|
||||
rows_scanned,
|
||||
entries_built,
|
||||
total_names_endorsed,
|
||||
duration_secs: t0.elapsed().as_secs_f32(),
|
||||
})
|
||||
}
|
||||
|
||||
fn embed_text(e: &PlaybookEntry) -> String {
|
||||
// Compact one-liner per playbook. Excludes timestamp (no semantic
|
||||
// signal) and includes the fills as words (they're occasionally
|
||||
// meaningful — "Luis Harris" might semantically correlate with
|
||||
// Spanish-speaker names in future queries).
|
||||
format!(
|
||||
"{} | {} | {} | fills: {}",
|
||||
e.operation,
|
||||
e.approach,
|
||||
e.context,
|
||||
e.endorsed_names.join(", "),
|
||||
)
|
||||
}
|
||||
|
||||
/// Derive a stable id from (timestamp, operation). Two playbooks with
|
||||
/// identical timestamp+operation collapse to one — benign dedup.
|
||||
fn stable_id(ts: &str, op: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(ts.as_bytes());
|
||||
h.update(b"|");
|
||||
h.update(op.as_bytes());
|
||||
let bytes = h.finalize();
|
||||
format!("pb-{}", hex_short(&bytes, 12))
|
||||
}
|
||||
|
||||
fn hex_short(b: &[u8], n: usize) -> String {
|
||||
let mut s = String::with_capacity(n * 2);
|
||||
for byte in &b[..b.len().min(n)] {
|
||||
s.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse "fill: Welder x2 in Toledo, OH" → ("Toledo", "OH").
|
||||
/// Returns None for malformed operations.
|
||||
fn parse_city_state(op: &str) -> (Option<String>, Option<String>) {
|
||||
// Split on " in " then parse "City, ST"
|
||||
let after_in = match op.split(" in ").nth(1) {
|
||||
Some(s) => s,
|
||||
None => return (None, None),
|
||||
};
|
||||
let parts: Vec<&str> = after_in.splitn(2, ',').collect();
|
||||
if parts.len() != 2 {
|
||||
return (None, None);
|
||||
}
|
||||
let city = parts[0].trim().to_string();
|
||||
// state might be followed by more context; take leading alpha chars
|
||||
let state: String = parts[1].trim()
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphabetic())
|
||||
.collect();
|
||||
if city.is_empty() || state.is_empty() {
|
||||
return (None, None);
|
||||
}
|
||||
(Some(city), Some(state))
|
||||
}
|
||||
|
||||
/// Parse "2/2 filled → Matthew Roberts, Amy Davis" → ["Matthew Roberts", "Amy Davis"].
|
||||
fn parse_names(result: &str) -> Vec<String> {
|
||||
// Everything after the arrow; split on ", ".
|
||||
let after_arrow = match result.split('→').nth(1) {
|
||||
Some(s) => s.trim(),
|
||||
None => return Vec::new(),
|
||||
};
|
||||
// Strip trailing noise like "(and N more)" that some emitters add.
|
||||
let cleaned = after_arrow.split(" (").next().unwrap_or(after_arrow);
|
||||
cleaned
|
||||
.split(',')
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_city_state_extracts_both() {
|
||||
let (c, s) = parse_city_state("fill: Welder x2 in Toledo, OH");
|
||||
assert_eq!(c.as_deref(), Some("Toledo"));
|
||||
assert_eq!(s.as_deref(), Some("OH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_city_state_handles_multiword_city() {
|
||||
let (c, s) = parse_city_state("fill: Loader x1 in Grand Rapids, MI");
|
||||
assert_eq!(c.as_deref(), Some("Grand Rapids"));
|
||||
assert_eq!(s.as_deref(), Some("MI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_city_state_malformed_returns_none() {
|
||||
let (c, s) = parse_city_state("fill: something weird");
|
||||
assert!(c.is_none());
|
||||
assert!(s.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_names_extracts_after_arrow() {
|
||||
let ns = parse_names("2/2 filled → Matthew Roberts, Amy Davis");
|
||||
assert_eq!(ns, vec!["Matthew Roberts".to_string(), "Amy Davis".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_names_handles_single_fill() {
|
||||
let ns = parse_names("1/1 filled → Jose Reed");
|
||||
assert_eq!(ns, vec!["Jose Reed".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_names_handles_no_arrow() {
|
||||
let ns = parse_names("0/2 filled");
|
||||
assert!(ns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_id_is_deterministic() {
|
||||
let a = stable_id("2026-04-20T00:00:00Z", "fill: Welder x2 in Toledo, OH");
|
||||
let b = stable_id("2026-04-20T00:00:00Z", "fill: Welder x2 in Toledo, OH");
|
||||
assert_eq!(a, b);
|
||||
assert!(a.starts_with("pb-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boost_caps_per_worker() {
|
||||
// Even with 100 similar playbooks all endorsing the same name, the
|
||||
// boost never exceeds MAX_BOOST_PER_WORKER.
|
||||
let pm = PlaybookMemory::new(Arc::new(object_store::memory::InMemory::new()));
|
||||
let entries: Vec<PlaybookEntry> = (0..100)
|
||||
.map(|i| PlaybookEntry {
|
||||
playbook_id: format!("pb-{i}"),
|
||||
operation: "fill: Welder x1 in Toledo, OH".into(),
|
||||
approach: "transfer".into(),
|
||||
context: "".into(),
|
||||
timestamp: "2026-04-20".into(),
|
||||
endorsed_names: vec!["Deborah Powell".into()],
|
||||
city: Some("Toledo".into()),
|
||||
state: Some("OH".into()),
|
||||
embedding: Some(vec![1.0, 0.0, 0.0]),
|
||||
})
|
||||
.collect();
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
pm.set_entries(entries).await.unwrap();
|
||||
let boosts = pm.compute_boost_for(&[1.0, 0.0, 0.0], 100, 0.5).await;
|
||||
let key = ("Toledo".into(), "OH".into(), "Deborah Powell".into());
|
||||
let entry = boosts.get(&key).expect("boost entry present");
|
||||
assert!(entry.boost <= MAX_BOOST_PER_WORKER + 1e-6,
|
||||
"boost {} exceeded cap {}", entry.boost, MAX_BOOST_PER_WORKER);
|
||||
});
|
||||
}
|
||||
}
|
||||
+500
-19
@@ -12,7 +12,7 @@ use std::sync::Arc;
|
||||
use aibridge::client::{AiClient, EmbedRequest, GenerateRequest};
|
||||
use catalogd::registry::Registry as CatalogRegistry;
|
||||
use storaged::registry::BucketRegistry;
|
||||
use crate::{agent, autotune, chunker, embedding_cache, harness, hnsw, index_registry, jobs, lance_backend, promotion, rag, refresh, search, store, supervisor, trial};
|
||||
use crate::{agent, autotune, chunker, embedding_cache, harness, hnsw, index_registry, jobs, lance_backend, playbook_memory, promotion, rag, refresh, search, store, supervisor, trial};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VectorState {
|
||||
@@ -23,6 +23,9 @@ pub struct VectorState {
|
||||
pub hnsw_store: hnsw::HnswStore,
|
||||
pub embedding_cache: embedding_cache::EmbeddingCache,
|
||||
pub trial_journal: trial::TrialJournal,
|
||||
/// Federation-aware harness store — resolves eval artifacts to each
|
||||
/// index's recorded bucket, falling back to primary for legacy evals.
|
||||
pub harness_store: harness::HarnessStore,
|
||||
/// Catalog registry — needed by the Phase C refresh path to mark/clear
|
||||
/// staleness and look up dataset manifests.
|
||||
pub catalog: CatalogRegistry,
|
||||
@@ -46,6 +49,10 @@ pub struct VectorState {
|
||||
/// ADR-019 hybrid: handles to Lance datasets keyed by index name.
|
||||
/// Lazy-created on first /vectors/lance/* call.
|
||||
pub lance: lance_backend::LanceRegistry,
|
||||
/// Phase 19 — meta-index feedback. Embeds past successful_playbooks
|
||||
/// and, when `use_playbook_memory` is set on /vectors/hybrid, boosts
|
||||
/// workers that were actually filled in semantically-similar past ops.
|
||||
pub playbook_memory: playbook_memory::PlaybookMemory,
|
||||
}
|
||||
|
||||
/// What the active-profile singleton records. Narrow — we don't need the
|
||||
@@ -63,6 +70,7 @@ pub fn router(state: VectorState) -> Router {
|
||||
.route("/index", post(create_index))
|
||||
.route("/indexes", get(list_indexes))
|
||||
.route("/indexes/{name}", get(get_index_meta))
|
||||
.route("/indexes/{name}/bucket", axum::routing::patch(migrate_index_bucket))
|
||||
.route("/jobs", get(list_jobs))
|
||||
.route("/jobs/{id}", get(get_job))
|
||||
.route("/search", post(search_index))
|
||||
@@ -110,6 +118,12 @@ pub fn router(state: VectorState) -> Router {
|
||||
.route("/lance/stats/{index_name}", get(lance_stats))
|
||||
.route("/lance/scalar-index/{index_name}/{column}", post(lance_build_scalar_index))
|
||||
.route("/lance/recall/{index_name}", post(lance_recall_harness))
|
||||
// Phase 19: playbook memory — the meta-index feedback loop
|
||||
.route("/playbook_memory/rebuild", post(rebuild_playbook_memory))
|
||||
.route("/playbook_memory/stats", get(playbook_memory_stats))
|
||||
.route("/playbook_memory/seed", post(seed_playbook_memory))
|
||||
.route("/playbook_memory/persist_sql", post(persist_playbook_memory_sql))
|
||||
.route("/playbook_memory/patterns", post(discover_playbook_patterns))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -259,6 +273,174 @@ async fn get_index_meta(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MigrateBucketRequest {
|
||||
dest_bucket: String,
|
||||
/// If true, delete artifacts from the source bucket after the pointer
|
||||
/// flip. Default false — keeping source copies means a failed migration
|
||||
/// is recoverable by editing IndexMeta.bucket back, and a successful
|
||||
/// migration leaves inspectable forensics until an operator sweeps.
|
||||
#[serde(default)]
|
||||
delete_source: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MigrateBucketReport {
|
||||
index_name: String,
|
||||
source_bucket: String,
|
||||
dest_bucket: String,
|
||||
/// Artifact keys that were copied (or attempted). Order follows copy order.
|
||||
copied: Vec<String>,
|
||||
/// Artifact prefixes that had nothing to copy (optional files missing,
|
||||
/// trial journal empty, etc).
|
||||
skipped: Vec<String>,
|
||||
/// Subset of `copied` that was subsequently deleted from the source.
|
||||
deleted_source: Vec<String>,
|
||||
duration_secs: f32,
|
||||
}
|
||||
|
||||
/// Move an index's artifacts from its current bucket to `dest_bucket`.
|
||||
/// Parquet-backed indexes only — Lance migration needs URI rewriting that
|
||||
/// isn't in scope for this endpoint. Copies the vector data, trial journal,
|
||||
/// promotion file, and auto-generated harness; updates `IndexMeta.bucket`
|
||||
/// last so a mid-flight failure leaves the index still usable at its
|
||||
/// original location. Evicts the `EmbeddingCache` entry so the next load
|
||||
/// re-reads from the new bucket.
|
||||
async fn migrate_index_bucket(
|
||||
State(state): State<VectorState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<MigrateBucketRequest>,
|
||||
) -> Result<Json<MigrateBucketReport>, (StatusCode, String)> {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
let mut meta = state
|
||||
.index_registry
|
||||
.get(&name)
|
||||
.await
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("index '{name}' not found")))?;
|
||||
|
||||
if meta.vector_backend == shared::types::VectorBackend::Lance {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Lance-backed indexes cannot be migrated via this endpoint — \
|
||||
Lance URIs are bucket-specific; a separate migrate_lance tool \
|
||||
is needed".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !state.bucket_registry.contains(&req.dest_bucket) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("dest bucket '{}' not registered", req.dest_bucket),
|
||||
));
|
||||
}
|
||||
|
||||
let source_bucket = meta.bucket.clone();
|
||||
if source_bucket == req.dest_bucket {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("source and dest are both '{source_bucket}' — nothing to migrate"),
|
||||
));
|
||||
}
|
||||
|
||||
let src = state
|
||||
.bucket_registry
|
||||
.get(&source_bucket)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
let dst = state
|
||||
.bucket_registry
|
||||
.get(&req.dest_bucket)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
let mut copied: Vec<String> = Vec::new();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
|
||||
// 1. Vector data (single parquet file for this backend).
|
||||
copy_key(&src, &dst, &meta.storage_key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("copy {}: {e}", meta.storage_key))
|
||||
})?;
|
||||
copied.push(meta.storage_key.clone());
|
||||
|
||||
// 2. Trial journal batches — per-index directory of JSONL files.
|
||||
let trial_prefix = format!("_hnsw_trials/{name}/");
|
||||
let trial_keys = storaged::ops::list(&src, Some(&trial_prefix))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if trial_keys.is_empty() {
|
||||
skipped.push(trial_prefix);
|
||||
}
|
||||
for k in &trial_keys {
|
||||
copy_key(&src, &dst, k)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("copy {k}: {e}")))?;
|
||||
copied.push(k.clone());
|
||||
}
|
||||
|
||||
// 3. Promotion file (optional — absent for never-promoted indexes).
|
||||
let promo_key = format!("_hnsw_promotions/{name}.json");
|
||||
match copy_key(&src, &dst, &promo_key).await {
|
||||
Ok(()) => copied.push(promo_key),
|
||||
Err(_) => skipped.push(promo_key),
|
||||
}
|
||||
|
||||
// 4. Auto-generated harness (optional — absent if agent never ran).
|
||||
let harness_key = format!("_hnsw_evals/{name}_auto.json");
|
||||
match copy_key(&src, &dst, &harness_key).await {
|
||||
Ok(()) => copied.push(harness_key),
|
||||
Err(_) => skipped.push(harness_key),
|
||||
}
|
||||
|
||||
// 5. Pointer flip — IndexMeta.bucket now points at destination. This
|
||||
// is the commit point; earlier failures leave copies in dest but the
|
||||
// index still usable at source.
|
||||
meta.bucket = req.dest_bucket.clone();
|
||||
state
|
||||
.index_registry
|
||||
.register(meta)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("update meta: {e}")))?;
|
||||
|
||||
// 6. Cache eviction — next load reads the new bucket's parquet.
|
||||
state.embedding_cache.evict(&name).await;
|
||||
|
||||
// 7. Optional source cleanup.
|
||||
let mut deleted_source: Vec<String> = Vec::new();
|
||||
if req.delete_source {
|
||||
for k in &copied {
|
||||
if storaged::ops::delete(&src, k).await.is_ok() {
|
||||
deleted_source.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(MigrateBucketReport {
|
||||
index_name: name,
|
||||
source_bucket,
|
||||
dest_bucket: req.dest_bucket,
|
||||
copied,
|
||||
skipped,
|
||||
deleted_source,
|
||||
duration_secs: t0.elapsed().as_secs_f32(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Stream a single object from one bucket to another. Uses the existing
|
||||
/// `storaged::ops` get + put primitives — no native copy in object_store
|
||||
/// across heterogeneous backends (local ↔ S3), so an in-memory hop is
|
||||
/// unavoidable. Bounded by individual object size, which for our parquet
|
||||
/// + jsonl artifacts tops out around a few hundred MB.
|
||||
async fn copy_key(
|
||||
src: &Arc<dyn ObjectStore>,
|
||||
dst: &Arc<dyn ObjectStore>,
|
||||
key: &str,
|
||||
) -> Result<(), String> {
|
||||
let data = storaged::ops::get(src, key).await?;
|
||||
storaged::ops::put(dst, key, data).await
|
||||
}
|
||||
|
||||
// --- unused legacy function below, kept for reference ---
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -420,6 +602,15 @@ struct HybridRequest {
|
||||
/// If false, just return the ranked matches (faster, no Ollama gen).
|
||||
#[serde(default = "default_true")]
|
||||
generate: bool,
|
||||
/// Phase 19: consult `playbook_memory` and boost workers that past
|
||||
/// similar playbooks successfully filled. Off by default so current
|
||||
/// callers keep deterministic ranking; opt-in unlocks the feedback.
|
||||
#[serde(default)]
|
||||
use_playbook_memory: bool,
|
||||
/// Number of past playbooks to consider when `use_playbook_memory`
|
||||
/// is on. Ignored otherwise. Defaults to 5.
|
||||
#[serde(default)]
|
||||
playbook_memory_k: Option<usize>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool { true }
|
||||
@@ -442,8 +633,18 @@ struct HybridSource {
|
||||
chunk_text: String,
|
||||
score: f32,
|
||||
sql_verified: bool,
|
||||
/// Phase 19: how much the playbook_memory boost lifted this hit's
|
||||
/// score. 0.0 when `use_playbook_memory=false` or no past playbook
|
||||
/// endorsed this worker.
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
playbook_boost: f32,
|
||||
/// playbook_ids whose endorsement contributed to `playbook_boost`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
playbook_citations: Vec<String>,
|
||||
}
|
||||
|
||||
fn is_zero(x: &f32) -> bool { x.abs() < 1e-6 }
|
||||
|
||||
async fn hybrid_search(
|
||||
State(state): State<VectorState>,
|
||||
Json(req): Json<HybridRequest>,
|
||||
@@ -556,6 +757,11 @@ async fn hybrid_search(
|
||||
.and_then(|m| m.id_prefix.clone());
|
||||
|
||||
let sql_count = valid_ids.as_ref().map(|s| s.len()).unwrap_or(0);
|
||||
// Phase 19: when playbook_memory is consulted, pull a wider candidate
|
||||
// pool so endorsed workers outside the vanilla top-K can still be
|
||||
// boosted into visibility. 5× is a conservative multiplier — plenty
|
||||
// for a +0.25 boost to flip rankings without dragging the cost up.
|
||||
let fetch_k = if req.use_playbook_memory { req.top_k * 5 } else { req.top_k };
|
||||
let filtered: Vec<search::SearchResult> = if let Some(ref ids) = valid_ids {
|
||||
all_results.into_iter()
|
||||
.filter(|r| {
|
||||
@@ -572,20 +778,54 @@ async fn hybrid_search(
|
||||
};
|
||||
ids.contains(raw_id)
|
||||
})
|
||||
.take(req.top_k)
|
||||
.take(fetch_k)
|
||||
.collect()
|
||||
} else {
|
||||
all_results.into_iter().take(req.top_k).collect()
|
||||
all_results.into_iter().take(fetch_k).collect()
|
||||
};
|
||||
|
||||
// Step 4: Build sources with SQL-verified flag.
|
||||
let sources: Vec<HybridSource> = filtered.iter().map(|r| HybridSource {
|
||||
let mut sources: Vec<HybridSource> = filtered.iter().map(|r| HybridSource {
|
||||
doc_id: r.doc_id.clone(),
|
||||
chunk_text: r.chunk_text.clone(),
|
||||
score: r.score,
|
||||
sql_verified: valid_ids.is_some(),
|
||||
playbook_boost: 0.0,
|
||||
playbook_citations: Vec::new(),
|
||||
}).collect();
|
||||
|
||||
// Step 4b (Phase 19): if use_playbook_memory, look up semantically
|
||||
// similar past playbooks and boost workers they endorsed. Name-match
|
||||
// is on the tuple (city, state, name) extracted from chunk_text —
|
||||
// hybrid_search's SQL filter already narrowed to one city+state, so
|
||||
// this just needs to check the name against each playbook's endorsed
|
||||
// set. Additive boost on the existing vector score, then re-sort.
|
||||
if req.use_playbook_memory {
|
||||
let boost_k = req.playbook_memory_k.unwrap_or(playbook_memory::DEFAULT_TOP_K_PLAYBOOKS);
|
||||
// We embedded the question as `qv` above — reuse it for the
|
||||
// playbook similarity lookup so we don't double-pay Ollama.
|
||||
let boosts = state.playbook_memory.compute_boost_for(&qv, boost_k, 0.5).await;
|
||||
for src in sources.iter_mut() {
|
||||
// Parse "{Name} — {Role} in {City}, {State}. …" chunk. Being
|
||||
// defensive: chunks from other datasets may not follow this
|
||||
// exact shape, so absent fields just skip the boost.
|
||||
if let Some((name, city, state)) = parse_worker_chunk(&src.chunk_text) {
|
||||
let key = (city, state, name);
|
||||
if let Some(entry) = boosts.get(&key) {
|
||||
src.score += entry.boost;
|
||||
src.playbook_boost = entry.boost;
|
||||
src.playbook_citations = entry.citations.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-rank: boosted scores can flip ordering.
|
||||
sources.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
// Finally trim to the caller's requested top_k — we pulled fetch_k
|
||||
// (5× wider) above specifically so the boost could reach workers
|
||||
// that would otherwise have been trimmed pre-boost.
|
||||
sources.truncate(req.top_k);
|
||||
}
|
||||
|
||||
// Step 5: Generate answer if requested.
|
||||
let answer = if req.generate && !sources.is_empty() {
|
||||
let context: String = sources.iter().enumerate().map(|(i, s)| {
|
||||
@@ -734,7 +974,7 @@ async fn run_trial(
|
||||
State(state): State<VectorState>,
|
||||
Json(req): Json<TrialRequest>,
|
||||
) -> Result<Json<trial::Trial>, (StatusCode, String)> {
|
||||
let mut harness_set = harness::EvalSet::load(&state.store, &req.harness)
|
||||
let mut harness_set = state.harness_store.load_for_index(&req.index_name, &req.harness)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, format!("harness not found: {e}")))?;
|
||||
|
||||
@@ -764,8 +1004,8 @@ async fn run_trial(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("ground truth: {e}")))?;
|
||||
tracing::info!("trial: ground truth built in {:.1}s", t0.elapsed().as_secs_f32());
|
||||
harness_set
|
||||
.save(&state.store)
|
||||
state.harness_store
|
||||
.save(&harness_set)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("save harness: {e}")))?;
|
||||
}
|
||||
@@ -890,17 +1130,14 @@ async fn best_trial(
|
||||
// --- Harness management ---
|
||||
|
||||
async fn list_evals(State(state): State<VectorState>) -> impl IntoResponse {
|
||||
match harness::EvalSet::list(&state.store).await {
|
||||
Ok(names) => Ok(Json(names)),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
Json(state.harness_store.list_all().await)
|
||||
}
|
||||
|
||||
async fn get_eval(
|
||||
State(state): State<VectorState>,
|
||||
Path(name): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match harness::EvalSet::load(&state.store, &name).await {
|
||||
match state.harness_store.get_any(&name).await {
|
||||
Ok(e) => Ok(Json(e)),
|
||||
Err(err) => Err((StatusCode::NOT_FOUND, err)),
|
||||
}
|
||||
@@ -916,7 +1153,7 @@ async fn put_eval(
|
||||
.queries
|
||||
.iter()
|
||||
.all(|q| q.ground_truth.is_some());
|
||||
match harness_set.save(&state.store).await {
|
||||
match state.harness_store.save(&harness_set).await {
|
||||
Ok(()) => Ok(Json(harness_set)),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
@@ -957,8 +1194,8 @@ async fn autogen_eval(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("ground truth: {e}")))?;
|
||||
|
||||
harness_set
|
||||
.save(&state.store)
|
||||
state.harness_store
|
||||
.save(&harness_set)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("save: {e}")))?;
|
||||
|
||||
@@ -1407,7 +1644,12 @@ async fn profile_scoped_search(
|
||||
let lance_store = state.lance.store_for(&req.index_name).await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let t0 = std::time::Instant::now();
|
||||
match lance_store.search(&query_vec, top_k).await {
|
||||
match lance_store.search(
|
||||
&query_vec,
|
||||
top_k,
|
||||
Some(LANCE_DEFAULT_NPROBES),
|
||||
Some(LANCE_DEFAULT_REFINE_FACTOR),
|
||||
).await {
|
||||
Ok(hits) => Ok(Json(serde_json::json!({
|
||||
"profile": profile.id,
|
||||
"source": index_meta.source,
|
||||
@@ -1516,6 +1758,7 @@ async fn run_autotune_endpoint(
|
||||
&state.index_registry,
|
||||
&state.trial_journal,
|
||||
&state.promotion_registry,
|
||||
&state.harness_store,
|
||||
&state.job_tracker,
|
||||
).await {
|
||||
Ok(result) => Ok(Json(result)),
|
||||
@@ -1636,8 +1879,25 @@ struct LanceSearchRequest {
|
||||
query: String,
|
||||
#[serde(default = "default_top_k")]
|
||||
top_k: usize,
|
||||
/// IVF partitions to probe. `None` uses Lance's built-in default of
|
||||
/// 1, which caps recall well below the index's real capability.
|
||||
/// Recommended: 5–10% of num_partitions (≈20 for a 316-partition
|
||||
/// index). Omitting it here picks the server-side default.
|
||||
#[serde(default)]
|
||||
nprobes: Option<usize>,
|
||||
/// Refine factor — re-rank `top_k * factor` PQ-approximate candidates
|
||||
/// with exact distances before returning `top_k`. Recovers recall
|
||||
/// lost to product quantization.
|
||||
#[serde(default)]
|
||||
refine_factor: Option<u32>,
|
||||
}
|
||||
|
||||
/// Server-side defaults when the caller doesn't pin nprobes / refine
|
||||
/// themselves. Tuned for the ~100K × 768d reference workload; see
|
||||
/// docs/ADR-019-vector-storage.md for the recall / latency trade-off.
|
||||
const LANCE_DEFAULT_NPROBES: usize = 20;
|
||||
const LANCE_DEFAULT_REFINE_FACTOR: u32 = 5;
|
||||
|
||||
fn default_top_k() -> usize { 5 }
|
||||
|
||||
/// Vector search against a Lance dataset. Embeds the query text via the
|
||||
@@ -1660,7 +1920,9 @@ async fn lance_search(
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let hits = lance_store.search(&qv, req.top_k).await
|
||||
let nprobes = req.nprobes.or(Some(LANCE_DEFAULT_NPROBES));
|
||||
let refine = req.refine_factor.or(Some(LANCE_DEFAULT_REFINE_FACTOR));
|
||||
let hits = lance_store.search(&qv, req.top_k, nprobes, refine).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
@@ -1761,6 +2023,12 @@ struct LanceRecallRequest {
|
||||
harness: String,
|
||||
#[serde(default = "default_top_k")]
|
||||
top_k: usize,
|
||||
/// Override server defaults so operators can sweep nprobes /
|
||||
/// refine_factor to chart the recall-vs-latency curve.
|
||||
#[serde(default)]
|
||||
nprobes: Option<usize>,
|
||||
#[serde(default)]
|
||||
refine_factor: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -1784,6 +2052,214 @@ struct LanceRecallQuery {
|
||||
hits_returned: usize,
|
||||
}
|
||||
|
||||
// --- Phase 19: playbook memory endpoints ---
|
||||
|
||||
/// Extract (name, city, state) from a chunk formatted like
|
||||
/// "{Name} — {Role} in {City}, {State}. Skills: …".
|
||||
/// Returns None if the chunk doesn't match the shape; callers simply
|
||||
/// skip the boost for that hit.
|
||||
fn parse_worker_chunk(chunk: &str) -> Option<(String, String, String)> {
|
||||
// "Name — Role in City, ST. …" → split on "—" then " in " then ","
|
||||
let (name_part, rest) = chunk.split_once('—')?;
|
||||
let rest = rest.trim();
|
||||
let (_role, loc_part) = rest.split_once(" in ")?;
|
||||
let loc_part = loc_part.trim();
|
||||
let (city, state_plus) = loc_part.split_once(',')?;
|
||||
let state: String = state_plus.trim()
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphabetic())
|
||||
.collect();
|
||||
let name = name_part.trim().to_string();
|
||||
let city = city.trim().to_string();
|
||||
if name.is_empty() || city.is_empty() || state.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((name, city, state))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SeedPlaybookRequest {
|
||||
/// One playbook with {operation, approach, context, endorsed_names}.
|
||||
/// City + state are parsed from the operation text.
|
||||
operation: String,
|
||||
#[serde(default)]
|
||||
approach: String,
|
||||
#[serde(default)]
|
||||
context: String,
|
||||
endorsed_names: Vec<String>,
|
||||
/// Append to the existing memory rather than replacing. Default true —
|
||||
/// seeding is a bootstrap/demo tool, not a rebuild substitute.
|
||||
#[serde(default = "default_true")]
|
||||
append: bool,
|
||||
}
|
||||
|
||||
/// Bootstrap / test-only: inject a playbook entry directly into
|
||||
/// `playbook_memory` without going through `successful_playbooks`. Useful
|
||||
/// when the source dataset has stale or phantom entries (as the initial
|
||||
/// staffing seed did — names that don't correspond to real workers), and
|
||||
/// you want to demonstrate the feedback loop with a known-good fixture.
|
||||
///
|
||||
/// Production path is always `/rebuild` — this endpoint is for operators
|
||||
/// who need to prime the memory before real playbooks accumulate.
|
||||
async fn seed_playbook_memory(
|
||||
State(state): State<VectorState>,
|
||||
Json(req): Json<SeedPlaybookRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// Embed the entry through the same text shape `rebuild` uses so
|
||||
// similarity math is comparable across seed + real entries.
|
||||
let tmp_entry = playbook_memory::PlaybookEntry {
|
||||
playbook_id: String::new(),
|
||||
operation: req.operation.clone(),
|
||||
approach: req.approach.clone(),
|
||||
context: req.context.clone(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
endorsed_names: req.endorsed_names.clone(),
|
||||
city: None, state: None, embedding: None,
|
||||
};
|
||||
let text = format!(
|
||||
"{} | {} | {} | fills: {}",
|
||||
tmp_entry.operation, tmp_entry.approach, tmp_entry.context,
|
||||
tmp_entry.endorsed_names.join(", "),
|
||||
);
|
||||
let resp = match state.ai_client.embed(EmbedRequest { texts: vec![text], model: None }).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err((StatusCode::BAD_GATEWAY, format!("embed seed: {e}"))),
|
||||
};
|
||||
if resp.embeddings.is_empty() {
|
||||
return Err((StatusCode::BAD_GATEWAY, "embed returned nothing".into()));
|
||||
}
|
||||
let emb: Vec<f32> = resp.embeddings[0].iter().map(|&x| x as f32).collect();
|
||||
|
||||
// Parse city/state from the operation ("fill: Role xN in City, ST").
|
||||
// Parser lives in playbook_memory::rebuild — expose via a tiny helper
|
||||
// or inline the same logic here; duplicated briefly since this seed
|
||||
// path is stable but infrequently called.
|
||||
let (city, state_) = {
|
||||
let after_in = req.operation.split(" in ").nth(1).unwrap_or("");
|
||||
let mut parts = after_in.splitn(2, ',');
|
||||
let city = parts.next().map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
let state = parts.next().map(|s| s.trim().chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>()).filter(|s| !s.is_empty());
|
||||
(city, state)
|
||||
};
|
||||
if city.is_none() || state_.is_none() {
|
||||
return Err((StatusCode::BAD_REQUEST,
|
||||
"operation must match 'fill: Role xN in City, ST' shape".into()));
|
||||
}
|
||||
|
||||
// Stable id: hash of timestamp + operation. Callers get the id back
|
||||
// so they can reference it in citations.
|
||||
let ts = chrono::Utc::now().to_rfc3339();
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(ts.as_bytes());
|
||||
h.update(b"|");
|
||||
h.update(req.operation.as_bytes());
|
||||
let bytes = h.finalize();
|
||||
let pid = format!("pb-seed-{}", bytes.iter().take(8).map(|b| format!("{b:02x}")).collect::<String>());
|
||||
|
||||
let new_entry = playbook_memory::PlaybookEntry {
|
||||
playbook_id: pid.clone(),
|
||||
operation: req.operation,
|
||||
approach: req.approach,
|
||||
context: req.context,
|
||||
timestamp: ts,
|
||||
endorsed_names: req.endorsed_names,
|
||||
city, state: state_,
|
||||
embedding: Some(emb),
|
||||
};
|
||||
|
||||
let mut current = state.playbook_memory.snapshot().await;
|
||||
if req.append {
|
||||
current.push(new_entry);
|
||||
} else {
|
||||
current = vec![new_entry];
|
||||
}
|
||||
if let Err(e) = state.playbook_memory.set_entries(current).await {
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("persist: {e}")));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "playbook_id": pid, "entries_after": state.playbook_memory.entry_count().await })))
|
||||
}
|
||||
|
||||
async fn rebuild_playbook_memory(
|
||||
State(state): State<VectorState>,
|
||||
) -> impl IntoResponse {
|
||||
match playbook_memory::rebuild(
|
||||
&state.playbook_memory,
|
||||
&state.ai_client,
|
||||
&state.catalog,
|
||||
&state.bucket_registry,
|
||||
).await {
|
||||
Ok(report) => Ok(Json(report)),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Path 2 foundation — dump in-memory playbook_memory state to a fresh
|
||||
// `successful_playbooks_live` dataset. Cheap to call (writes one parquet,
|
||||
// updates one manifest), so /log can call it after every seed to keep the
|
||||
// SQL-queryable surface honest without the destructive REPLACE bug that
|
||||
// /ingest/file has.
|
||||
async fn persist_playbook_memory_sql(
|
||||
State(state): State<VectorState>,
|
||||
) -> impl IntoResponse {
|
||||
match playbook_memory::persist_to_sql(&state.playbook_memory, &state.catalog).await {
|
||||
Ok(report) => Ok(Json(report)),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PatternsRequest {
|
||||
query: String,
|
||||
#[serde(default = "default_pattern_k")]
|
||||
top_k_playbooks: usize,
|
||||
/// Minimum frequency (0.0-1.0) for a trait to make the report.
|
||||
/// Default 0.4 — at least 40% of examined workers must share it.
|
||||
#[serde(default = "default_pattern_min_freq")]
|
||||
min_trait_frequency: f32,
|
||||
}
|
||||
fn default_pattern_k() -> usize { 10 }
|
||||
fn default_pattern_min_freq() -> f32 { 0.4 }
|
||||
|
||||
// Path 2 — meta-index discovery surface. "What did past similar fills
|
||||
// have in common that I didn't ask about?" — surfaces signals like
|
||||
// recurring certifications, skill clusters, archetype tendencies.
|
||||
async fn discover_playbook_patterns(
|
||||
State(state): State<VectorState>,
|
||||
Json(req): Json<PatternsRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match playbook_memory::discover_patterns(
|
||||
&state.playbook_memory,
|
||||
&state.ai_client,
|
||||
&state.catalog,
|
||||
&state.bucket_registry,
|
||||
&req.query,
|
||||
req.top_k_playbooks,
|
||||
req.min_trait_frequency,
|
||||
).await {
|
||||
Ok(report) => Ok(Json(report)),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn playbook_memory_stats(
|
||||
State(state): State<VectorState>,
|
||||
) -> impl IntoResponse {
|
||||
let entries = state.playbook_memory.snapshot().await;
|
||||
Json(serde_json::json!({
|
||||
"entries": entries.len(),
|
||||
"total_names_endorsed": entries.iter().map(|e| e.endorsed_names.len()).sum::<usize>(),
|
||||
"entries_with_embeddings": entries.iter().filter(|e| e.embedding.is_some()).count(),
|
||||
"sample": entries.iter().take(3).map(|e| serde_json::json!({
|
||||
"id": e.playbook_id,
|
||||
"operation": e.operation,
|
||||
"city": e.city,
|
||||
"state": e.state,
|
||||
"endorsed": e.endorsed_names,
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn lance_recall_harness(
|
||||
State(state): State<VectorState>,
|
||||
Path(index_name): Path<String>,
|
||||
@@ -1791,7 +2267,7 @@ async fn lance_recall_harness(
|
||||
) -> impl IntoResponse {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
let harness_set = harness::EvalSet::load(&state.store, &req.harness).await
|
||||
let harness_set = state.harness_store.load_for_index(&index_name, &req.harness).await
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, format!("harness: {e}")))?;
|
||||
if !harness_set.ground_truth_built {
|
||||
return Err((StatusCode::BAD_REQUEST,
|
||||
@@ -1817,7 +2293,12 @@ async fn lance_recall_harness(
|
||||
};
|
||||
|
||||
let qt0 = std::time::Instant::now();
|
||||
let hits = lance_store.search(qv, k).await
|
||||
let hits = lance_store.search(
|
||||
qv,
|
||||
k,
|
||||
Some(req.nprobes.unwrap_or(LANCE_DEFAULT_NPROBES)),
|
||||
Some(req.refine_factor.unwrap_or(LANCE_DEFAULT_REFINE_FACTOR)),
|
||||
).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("search: {e}")))?;
|
||||
let lat_us = qt0.elapsed().as_micros() as f32;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user