Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
640db8c63c | ||
|
|
138592dc56 | ||
|
|
e0a843d1a5 | ||
|
|
3fb3a60da4 | ||
|
|
52561d10d3 | ||
|
|
b95dd86556 | ||
|
|
137aed64fb | ||
|
|
ad0edbe29c | ||
|
|
5e89407939 | ||
|
|
6b71c8e9b2 | ||
|
|
a7fc8e2256 | ||
|
|
c21b261877 |
@@ -85,6 +85,34 @@ pub struct PlaybookEntry {
|
||||
/// state can omit it on first load and have a later embed() fill in.
|
||||
#[serde(default)]
|
||||
pub embedding: Option<Vec<f32>>,
|
||||
/// Schema fingerprint captured at seed time — SHA-256 hex of the
|
||||
/// target dataset's (column_name, type) tuples. When the dataset's
|
||||
/// schema changes (column rename, type change, drop), entries
|
||||
/// seeded against the old schema are considered stale and get
|
||||
/// skipped by `compute_boost_for_filtered_with_role` unless the
|
||||
/// caller passes `allow_stale: true`. Optional so historical
|
||||
/// entries without a fingerprint (ingested before this field
|
||||
/// existed) degrade to "never stale" rather than getting
|
||||
/// silently zeroed. Phase 25 (2026-04-21).
|
||||
#[serde(default)]
|
||||
pub schema_fingerprint: Option<String>,
|
||||
/// Optional hard expiry. When set and now() > valid_until, the
|
||||
/// entry is skipped. Used for playbooks that were known to be
|
||||
/// time-limited at seed time (seasonal hires, temporary contracts).
|
||||
#[serde(default)]
|
||||
pub valid_until: Option<String>,
|
||||
/// Set by `retire()` — auto-retirement when schema drift detected,
|
||||
/// or manual via POST /vectors/playbook_memory/retire. Entries with
|
||||
/// this set are excluded from all boost calculations; they remain
|
||||
/// in the journal for forensic purposes.
|
||||
#[serde(default)]
|
||||
pub retired_at: Option<String>,
|
||||
/// Human-readable retirement reason. Examples:
|
||||
/// "schema_drift: workers_500k 2026-05-03 added column X"
|
||||
/// "expired: valid_until 2026-05-01 elapsed"
|
||||
/// "manual: operator requested via POST /retire"
|
||||
#[serde(default)]
|
||||
pub retirement_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// A recorded failure — worker who didn't deliver on a contract.
|
||||
@@ -121,12 +149,48 @@ pub struct BoostEntry {
|
||||
pub citations: Vec<String>, // playbook_ids that endorsed this worker
|
||||
}
|
||||
|
||||
/// Phase 26 — what happened during an upsert. The seed endpoint
|
||||
/// returns this shape so the caller sees whether its write was a new
|
||||
/// entry, a merge, or a dedup'd no-op.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "mode", rename_all = "lowercase")]
|
||||
pub enum UpsertOutcome {
|
||||
/// New playbook appended. Carries the new playbook_id.
|
||||
Added(String),
|
||||
/// Existing same-day entry updated. Playbook_id unchanged; names
|
||||
/// merged (union, original order preserved, new names appended).
|
||||
Updated {
|
||||
playbook_id: String,
|
||||
merged_names: Vec<String>,
|
||||
},
|
||||
/// Identical same-day entry already exists; nothing changed.
|
||||
/// Returns the stable playbook_id so caller still has a reference.
|
||||
Noop(String),
|
||||
}
|
||||
|
||||
/// Return YYYY-MM-DD from an RFC3339 timestamp. Falls back to the
|
||||
/// first 10 chars if parse fails — tolerant for legacy entries that
|
||||
/// stored a bare date.
|
||||
fn day_key(ts: &str) -> String {
|
||||
chrono::DateTime::parse_from_rfc3339(ts)
|
||||
.map(|t| t.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_else(|_| ts.chars().take(10).collect())
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Phase 26 — hot geo index: (city_lower, state_upper) → sorted
|
||||
/// Vec<entry_idx>. Rebuilt on every mutation of `entries`. At
|
||||
/// current scale (1.9K entries) a full scan is sub-ms; at 100K+
|
||||
/// the index skips the scan for geo-filtered queries, which is
|
||||
/// the dominant code path. Letta-style working memory with a real
|
||||
/// LRU is overkill here — entries are bounded and fit in RAM;
|
||||
/// what we need is a precomputed seek, not a bounded cache.
|
||||
geo_index: Arc<RwLock<HashMap<(String, String), Vec<usize>>>>,
|
||||
}
|
||||
|
||||
impl PlaybookMemory {
|
||||
@@ -134,9 +198,28 @@ impl PlaybookMemory {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(PlaybookMemoryState::default())),
|
||||
store,
|
||||
geo_index: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the geo index from scratch. Called by every mutation
|
||||
/// helper after persist succeeds. O(n) scan of entries; at current
|
||||
/// scale ~40µs. Skips retired entries — they never participate in
|
||||
/// boost filtering, so indexing them would just waste lookups.
|
||||
async fn rebuild_geo_index(&self) {
|
||||
let state = self.state.read().await;
|
||||
let mut idx: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
for (i, e) in state.entries.iter().enumerate() {
|
||||
if e.retired_at.is_some() { continue; }
|
||||
let (Some(city), Some(st)) = (&e.city, &e.state) else { continue; };
|
||||
let key = (city.to_ascii_lowercase(), st.to_ascii_uppercase());
|
||||
idx.entry(key).or_default().push(i);
|
||||
}
|
||||
drop(state);
|
||||
let mut guard = self.geo_index.write().await;
|
||||
*guard = idx;
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -148,6 +231,7 @@ impl PlaybookMemory {
|
||||
.map_err(|e| format!("parse playbook_memory state: {e}"))?;
|
||||
let n = persisted.entries.len();
|
||||
*self.state.write().await = persisted;
|
||||
self.rebuild_geo_index().await;
|
||||
tracing::info!("playbook_memory: loaded {n} entries from {STATE_KEY}");
|
||||
Ok(n)
|
||||
}
|
||||
@@ -164,7 +248,160 @@ impl PlaybookMemory {
|
||||
s.entries = entries;
|
||||
s.last_rebuilt_at = chrono::Utc::now().timestamp_millis();
|
||||
drop(s);
|
||||
self.persist().await
|
||||
self.persist().await?;
|
||||
self.rebuild_geo_index().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Phase 25 — retire a specific playbook by id. Idempotent; repeat
|
||||
/// calls don't overwrite the first reason. Persisted.
|
||||
pub async fn retire_one(&self, playbook_id: &str, reason: &str) -> Result<bool, String> {
|
||||
let mut touched = false;
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
for e in state.entries.iter_mut() {
|
||||
if e.playbook_id == playbook_id && e.retired_at.is_none() {
|
||||
e.retired_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
e.retirement_reason = Some(reason.to_string());
|
||||
touched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if touched {
|
||||
self.persist().await?;
|
||||
self.rebuild_geo_index().await;
|
||||
}
|
||||
Ok(touched)
|
||||
}
|
||||
|
||||
/// Phase 25 — retire every entry matching (city, state) whose
|
||||
/// schema_fingerprint doesn't match the current one. Entries with
|
||||
/// no fingerprint (legacy) are skipped — caller can use
|
||||
/// `retire_by_scope` for blanket retirement.
|
||||
pub async fn retire_on_schema_drift(
|
||||
&self,
|
||||
city: &str,
|
||||
state_code: &str,
|
||||
current_fingerprint: &str,
|
||||
reason: &str,
|
||||
) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
for e in state.entries.iter_mut() {
|
||||
if e.retired_at.is_some() { continue; }
|
||||
let Some(ec) = &e.city else { continue; };
|
||||
let Some(es) = &e.state else { continue; };
|
||||
if !ec.eq_ignore_ascii_case(city) || !es.eq_ignore_ascii_case(state_code) { continue; }
|
||||
match &e.schema_fingerprint {
|
||||
Some(fp) if fp != current_fingerprint => {
|
||||
e.retired_at = Some(now.clone());
|
||||
e.retirement_reason = Some(reason.to_string());
|
||||
count += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
self.persist().await?;
|
||||
self.rebuild_geo_index().await;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Stats accessor for the /status endpoint and tests.
|
||||
pub async fn status_counts(&self) -> (usize, usize, usize) {
|
||||
let state = self.state.read().await;
|
||||
let total = state.entries.len();
|
||||
let retired = state.entries.iter().filter(|e| e.retired_at.is_some()).count();
|
||||
let failures = state.failures.len();
|
||||
(total, retired, failures)
|
||||
}
|
||||
|
||||
/// Phase 26 — Mem0-style upsert. Decides ADD / UPDATE / NOOP based
|
||||
/// on whether a non-retired entry with the same operation already
|
||||
/// exists for the same day. Three outcomes:
|
||||
///
|
||||
/// ADD → no matching entry, append the new one
|
||||
/// UPDATE → existing same-day entry found, merge endorsed_names
|
||||
/// (union, preserving order) and refresh timestamp.
|
||||
/// Playbook_id is kept stable so citations from prior
|
||||
/// boost calls stay valid.
|
||||
/// NOOP → existing same-day entry with identical
|
||||
/// endorsed_names. Skip — no duplicates accumulate.
|
||||
///
|
||||
/// "Same day" keyed on YYYY-MM-DD of the entry's timestamp so
|
||||
/// intraday re-seeding of the same operation dedups but tomorrow's
|
||||
/// seeding for the same operation lands as a fresh ADD (which is
|
||||
/// correct — a new day is a new event).
|
||||
pub async fn upsert_entry(&self, new_entry: PlaybookEntry) -> Result<UpsertOutcome, String> {
|
||||
let new_day = day_key(&new_entry.timestamp);
|
||||
let new_names_sorted = {
|
||||
let mut v = new_entry.endorsed_names.clone();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let mut state = self.state.write().await;
|
||||
// Find a non-retired entry with same operation + day + city +
|
||||
// state. Operation string alone would false-match across days
|
||||
// or across cities that happen to share role+count; city+state
|
||||
// is already parsed out of operation so adding them to the key
|
||||
// costs nothing.
|
||||
let mut existing_idx: Option<usize> = None;
|
||||
for (i, e) in state.entries.iter().enumerate() {
|
||||
if e.retired_at.is_some() { continue; }
|
||||
if e.operation != new_entry.operation { continue; }
|
||||
if day_key(&e.timestamp) != new_day { continue; }
|
||||
if e.city != new_entry.city || e.state != new_entry.state { continue; }
|
||||
existing_idx = Some(i);
|
||||
break;
|
||||
}
|
||||
|
||||
match existing_idx {
|
||||
None => {
|
||||
let pid = new_entry.playbook_id.clone();
|
||||
state.entries.push(new_entry);
|
||||
drop(state);
|
||||
self.persist().await?;
|
||||
self.rebuild_geo_index().await;
|
||||
Ok(UpsertOutcome::Added(pid))
|
||||
}
|
||||
Some(i) => {
|
||||
let mut existing_names_sorted = state.entries[i].endorsed_names.clone();
|
||||
existing_names_sorted.sort();
|
||||
if existing_names_sorted == new_names_sorted {
|
||||
// NOOP — identical data, just report the existing id
|
||||
let pid = state.entries[i].playbook_id.clone();
|
||||
Ok(UpsertOutcome::Noop(pid))
|
||||
} else {
|
||||
// UPDATE — merge names (union, stable order).
|
||||
let existing = state.entries.get_mut(i).ok_or("index invalidated")?;
|
||||
let mut merged: Vec<String> = existing.endorsed_names.clone();
|
||||
for n in &new_entry.endorsed_names {
|
||||
if !merged.contains(n) { merged.push(n.clone()); }
|
||||
}
|
||||
existing.endorsed_names = merged.clone();
|
||||
existing.timestamp = new_entry.timestamp.clone();
|
||||
// Keep original playbook_id + embedding + schema fingerprint.
|
||||
// Refresh embedding only if the caller passed a non-None
|
||||
// one (indicates the text shape changed).
|
||||
if new_entry.embedding.is_some() {
|
||||
existing.embedding = new_entry.embedding.clone();
|
||||
}
|
||||
if new_entry.schema_fingerprint.is_some() {
|
||||
existing.schema_fingerprint = new_entry.schema_fingerprint.clone();
|
||||
}
|
||||
let pid = existing.playbook_id.clone();
|
||||
drop(state);
|
||||
self.persist().await?;
|
||||
self.rebuild_geo_index().await;
|
||||
Ok(UpsertOutcome::Updated { playbook_id: pid, merged_names: merged })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn entry_count(&self) -> usize {
|
||||
@@ -235,6 +472,26 @@ impl PlaybookMemory {
|
||||
top_k_playbooks: usize,
|
||||
base_weight: f32,
|
||||
target_geo: Option<(&str, &str)>,
|
||||
) -> HashMap<(String, String, String), BoostEntry> {
|
||||
self.compute_boost_for_filtered_with_role(query_embedding, top_k_playbooks, base_weight, target_geo, None).await
|
||||
}
|
||||
|
||||
/// Variant that also accepts a target role for pre-filtering.
|
||||
/// Multi-strategy retrieval: exact (role, city, state) matches skip
|
||||
/// cosine entirely and earn the maximum boost, since identity on
|
||||
/// those three fields is the strongest possible similarity signal.
|
||||
/// Remaining entries (within the same city+state but different
|
||||
/// role, or unknown role) go through the normal cosine path as a
|
||||
/// fallback. This addresses the 2026 agent-memory finding that
|
||||
/// multi-strategy parallel retrieval with rerank outperforms
|
||||
/// single-strategy semantic search.
|
||||
pub async fn compute_boost_for_filtered_with_role(
|
||||
&self,
|
||||
query_embedding: &[f32],
|
||||
top_k_playbooks: usize,
|
||||
base_weight: f32,
|
||||
target_geo: Option<(&str, &str)>,
|
||||
target_role: Option<&str>,
|
||||
) -> HashMap<(String, String, String), BoostEntry> {
|
||||
let state = self.state.read().await;
|
||||
let entries = state.entries.clone();
|
||||
@@ -246,23 +503,90 @@ impl PlaybookMemory {
|
||||
}
|
||||
drop(state);
|
||||
|
||||
// Brute-force cosine. Empty / missing embeddings just skip.
|
||||
// When target_geo is set, pre-filter to matching playbooks BEFORE
|
||||
// cosine sort — that way top-k is within the city, not across
|
||||
// all cities.
|
||||
let mut scored: Vec<(f32, &PlaybookEntry)> = entries
|
||||
// Phase 25 validity-window filtering. Happens before geo+cosine
|
||||
// so retired/expired entries never reach the ranking pool. We
|
||||
// don't mutate the state here (can't grab a write lock inside
|
||||
// a read-heavy hot path); retirement_at auto-retirement is a
|
||||
// separate background pass. Here we just skip anything already
|
||||
// retired, and entries whose valid_until has elapsed.
|
||||
let now = chrono::Utc::now();
|
||||
let active_entries: Vec<&PlaybookEntry> = entries
|
||||
.iter()
|
||||
.filter(|e| match (target_geo, &e.city, &e.state) {
|
||||
(None, _, _) => true,
|
||||
(Some((tc, ts)), Some(ec), Some(es)) => {
|
||||
ec.eq_ignore_ascii_case(tc) && es.eq_ignore_ascii_case(ts)
|
||||
.filter(|e| {
|
||||
if e.retired_at.is_some() { return false; }
|
||||
if let Some(vu) = &e.valid_until {
|
||||
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(vu) {
|
||||
if now > parsed.with_timezone(&chrono::Utc) { return false; }
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
true
|
||||
})
|
||||
.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));
|
||||
|
||||
// Pre-filter by target_geo (city, state) before cosine. Phase 26
|
||||
// hot cache — use the geo index (O(1) key lookup) instead of a
|
||||
// linear scan of all entries. Retired entries are excluded from
|
||||
// the index; valid_until is still checked here since it can
|
||||
// elapse between index rebuilds.
|
||||
//
|
||||
// Owned entries (not references) because the state read-lock is
|
||||
// released between here and the cosine step — we don't want to
|
||||
// hold a read lock across the scoring work.
|
||||
let geo_filtered: Vec<PlaybookEntry> = if let Some((tc, ts)) = target_geo {
|
||||
let key = (tc.to_ascii_lowercase(), ts.to_ascii_uppercase());
|
||||
let index = self.geo_index.read().await;
|
||||
let Some(idxs) = index.get(&key) else { return HashMap::new(); };
|
||||
let idxs = idxs.clone();
|
||||
drop(index);
|
||||
let state = self.state.read().await;
|
||||
idxs.into_iter()
|
||||
.filter_map(|i| state.entries.get(i))
|
||||
.filter(|e| {
|
||||
if e.retired_at.is_some() { return false; }
|
||||
if let Some(vu) = &e.valid_until {
|
||||
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(vu) {
|
||||
if now > parsed.with_timezone(&chrono::Utc) { return false; }
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
active_entries.into_iter().cloned().collect()
|
||||
};
|
||||
|
||||
// Multi-strategy: split the geo-filtered pool into (exact role
|
||||
// match) vs (other). Exact matches skip cosine — they're already
|
||||
// the strongest signal possible. References into geo_filtered
|
||||
// which owns the entries.
|
||||
let mut exact_matches: Vec<&PlaybookEntry> = Vec::new();
|
||||
let mut cosine_pool: Vec<(f32, &PlaybookEntry)> = Vec::new();
|
||||
let role_needle = target_role
|
||||
.map(|r| format!("fill: {} ", r).to_ascii_lowercase());
|
||||
for e in geo_filtered.iter() {
|
||||
let is_exact = role_needle.as_ref()
|
||||
.map(|needle| e.operation.to_ascii_lowercase().contains(needle))
|
||||
.unwrap_or(false);
|
||||
if is_exact {
|
||||
exact_matches.push(e);
|
||||
} else if let Some(v) = &e.embedding {
|
||||
cosine_pool.push((cosine(query_embedding, v), e));
|
||||
}
|
||||
}
|
||||
cosine_pool.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Allocate top_k across the two pools — exact matches first,
|
||||
// then cosine fills the rest. Identity beats similarity.
|
||||
let exact_take = exact_matches.len().min(top_k_playbooks.max(1) / 2 + 1);
|
||||
let cosine_take = top_k_playbooks.saturating_sub(exact_take);
|
||||
|
||||
let mut scored: Vec<(f32, &PlaybookEntry)> = exact_matches
|
||||
.into_iter()
|
||||
.take(exact_take)
|
||||
.map(|e| (1.0_f32, e))
|
||||
.collect();
|
||||
scored.extend(cosine_pool.into_iter().take(cosine_take));
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut boosts: HashMap<(String, String, String), BoostEntry> = HashMap::new();
|
||||
@@ -723,6 +1047,14 @@ pub async fn rebuild(
|
||||
city,
|
||||
state,
|
||||
embedding: None,
|
||||
// Rebuild doesn't know fingerprints; historical entries
|
||||
// get no retirement signal until a seed with a
|
||||
// fingerprint supersedes them or the operator calls
|
||||
// /retire manually.
|
||||
schema_fingerprint: None,
|
||||
valid_until: None,
|
||||
retired_at: None,
|
||||
retirement_reason: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -901,6 +1233,10 @@ mod tests {
|
||||
city: Some("Toledo".into()),
|
||||
state: Some("OH".into()),
|
||||
embedding: Some(vec![1.0, 0.0, 0.0]),
|
||||
schema_fingerprint: None,
|
||||
valid_until: None,
|
||||
retired_at: None,
|
||||
retirement_reason: None,
|
||||
})
|
||||
.collect();
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
@@ -913,3 +1249,198 @@ mod tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod validity_window_tests {
|
||||
use super::*;
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
fn mkentry(id: &str, city: &str, state: &str, fingerprint: Option<String>, valid_until: Option<String>) -> PlaybookEntry {
|
||||
PlaybookEntry {
|
||||
playbook_id: id.into(),
|
||||
operation: format!("fill: Welder x1 in {city}, {state}"),
|
||||
approach: "hybrid".into(),
|
||||
context: "test".into(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
endorsed_names: vec!["Test Worker".into()],
|
||||
city: Some(city.into()),
|
||||
state: Some(state.into()),
|
||||
embedding: Some(vec![1.0, 0.0, 0.0]),
|
||||
schema_fingerprint: fingerprint,
|
||||
valid_until,
|
||||
retired_at: None,
|
||||
retirement_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retire_one_marks_entry_and_persists() {
|
||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||
pm.set_entries(vec![mkentry("pb-1", "Nashville", "TN", None, None)]).await.unwrap();
|
||||
let touched = pm.retire_one("pb-1", "manual test").await.unwrap();
|
||||
assert!(touched);
|
||||
let (total, retired, _) = pm.status_counts().await;
|
||||
assert_eq!(total, 1);
|
||||
assert_eq!(retired, 1);
|
||||
// Second retirement is a no-op
|
||||
let second = pm.retire_one("pb-1", "again").await.unwrap();
|
||||
assert!(!second);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retired_entries_do_not_boost() {
|
||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||
let e1 = mkentry("pb-active", "Nashville", "TN", None, None);
|
||||
let mut e2 = mkentry("pb-retired", "Nashville", "TN", None, None);
|
||||
e2.retired_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
pm.set_entries(vec![e1, e2]).await.unwrap();
|
||||
let boosts = pm.compute_boost_for_filtered_with_role(
|
||||
&[1.0, 0.0, 0.0], 100, 0.5,
|
||||
Some(("Nashville", "TN")), Some("Welder")
|
||||
).await;
|
||||
// Only the active entry should surface. Both endorse the same
|
||||
// name so we check citation count isn't doubled — presence of
|
||||
// the retired playbook id in citations would mean it slipped
|
||||
// through.
|
||||
let entry = boosts.get(&("Nashville".into(), "TN".into(), "Test Worker".into())).unwrap();
|
||||
assert!(!entry.citations.contains(&"pb-retired".to_string()));
|
||||
assert!(entry.citations.contains(&"pb-active".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_valid_until_is_skipped() {
|
||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||
let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
|
||||
let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
|
||||
let e_expired = mkentry("pb-expired", "Nashville", "TN", None, Some(past));
|
||||
let e_alive = { let mut e = mkentry("pb-alive", "Nashville", "TN", None, Some(future)); e };
|
||||
pm.set_entries(vec![e_expired, e_alive]).await.unwrap();
|
||||
let boosts = pm.compute_boost_for_filtered_with_role(
|
||||
&[1.0, 0.0, 0.0], 100, 0.5,
|
||||
Some(("Nashville", "TN")), Some("Welder")
|
||||
).await;
|
||||
let entry = boosts.get(&("Nashville".into(), "TN".into(), "Test Worker".into())).unwrap();
|
||||
assert!(!entry.citations.contains(&"pb-expired".to_string()));
|
||||
assert!(entry.citations.contains(&"pb-alive".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_drift_retires_mismatched_fingerprints_only() {
|
||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||
let e_old = mkentry("pb-old-schema", "Nashville", "TN", Some("fp-v1".into()), None);
|
||||
let e_new = mkentry("pb-new-schema", "Nashville", "TN", Some("fp-v2".into()), None);
|
||||
let e_legacy = mkentry("pb-no-fp", "Nashville", "TN", None, None);
|
||||
pm.set_entries(vec![e_old, e_new, e_legacy]).await.unwrap();
|
||||
let retired = pm.retire_on_schema_drift("Nashville", "TN", "fp-v2", "test migration").await.unwrap();
|
||||
// Only pb-old-schema should be retired — pb-new-schema matches,
|
||||
// pb-no-fp has no fingerprint so it's legacy-safe.
|
||||
assert_eq!(retired, 1);
|
||||
let (_, total_retired, _) = pm.status_counts().await;
|
||||
assert_eq!(total_retired, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_drift_skips_other_cities() {
|
||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||
let e_tn = mkentry("pb-tn", "Nashville", "TN", Some("fp-v1".into()), None);
|
||||
let e_il = mkentry("pb-il", "Chicago", "IL", Some("fp-v1".into()), None);
|
||||
pm.set_entries(vec![e_tn, e_il]).await.unwrap();
|
||||
// Nashville migration shouldn't touch Chicago
|
||||
let retired = pm.retire_on_schema_drift("Nashville", "TN", "fp-v2", "test").await.unwrap();
|
||||
assert_eq!(retired, 1);
|
||||
let (_, r, _) = pm.status_counts().await;
|
||||
assert_eq!(r, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod upsert_tests {
|
||||
use super::*;
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
fn mk(op: &str, day: &str, names: &[&str]) -> PlaybookEntry {
|
||||
PlaybookEntry {
|
||||
playbook_id: format!("pb-{}-{}", op.replace(' ', "_"), day),
|
||||
operation: op.into(),
|
||||
approach: "seed".into(),
|
||||
context: "test".into(),
|
||||
timestamp: format!("{day}T12:00:00Z"),
|
||||
endorsed_names: names.iter().map(|s| s.to_string()).collect(),
|
||||
city: Some("Nashville".into()),
|
||||
state: Some("TN".into()),
|
||||
embedding: Some(vec![1.0, 0.0, 0.0]),
|
||||
schema_fingerprint: None,
|
||||
valid_until: None,
|
||||
retired_at: None,
|
||||
retirement_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_seed_is_add() {
|
||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||
let e = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]);
|
||||
match pm.upsert_entry(e).await.unwrap() {
|
||||
UpsertOutcome::Added(_) => {}
|
||||
other => panic!("expected Added, got {:?}", other),
|
||||
}
|
||||
assert_eq!(pm.entry_count().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identical_reseed_is_noop() {
|
||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||
let e1 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
|
||||
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
|
||||
pm.upsert_entry(e1).await.unwrap();
|
||||
let outcome = pm.upsert_entry(e2).await.unwrap();
|
||||
assert!(matches!(outcome, UpsertOutcome::Noop(_)));
|
||||
// Still exactly one entry, no duplicate from the re-seed.
|
||||
assert_eq!(pm.entry_count().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_day_different_names_updates_and_merges() {
|
||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||
let e1 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]);
|
||||
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
|
||||
let o1 = pm.upsert_entry(e1).await.unwrap();
|
||||
let pid = match o1 {
|
||||
UpsertOutcome::Added(p) => p,
|
||||
other => panic!("expected Added, got {:?}", other),
|
||||
};
|
||||
let o2 = pm.upsert_entry(e2).await.unwrap();
|
||||
match o2 {
|
||||
UpsertOutcome::Updated { playbook_id, merged_names } => {
|
||||
assert_eq!(playbook_id, pid, "Updated should keep original playbook_id");
|
||||
assert_eq!(merged_names, vec!["Alice Smith".to_string(), "Bob Jones".to_string()]);
|
||||
}
|
||||
other => panic!("expected Updated, got {:?}", other),
|
||||
}
|
||||
assert_eq!(pm.entry_count().await, 1, "Updated must not create a duplicate");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_day_same_op_is_add() {
|
||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||
let e1 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]);
|
||||
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-22", &["Alice Smith"]);
|
||||
pm.upsert_entry(e1).await.unwrap();
|
||||
let o2 = pm.upsert_entry(e2).await.unwrap();
|
||||
assert!(matches!(o2, UpsertOutcome::Added(_)), "different day → fresh ADD");
|
||||
assert_eq!(pm.entry_count().await, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retired_entry_doesnt_block_new_seed() {
|
||||
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
|
||||
let mut e1 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]);
|
||||
e1.retired_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
pm.set_entries(vec![e1]).await.unwrap();
|
||||
// A new seed on same day should ADD, not merge into the retired one.
|
||||
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Carol Davis"]);
|
||||
let o = pm.upsert_entry(e2).await.unwrap();
|
||||
assert!(matches!(o, UpsertOutcome::Added(_)));
|
||||
assert_eq!(pm.entry_count().await, 2);
|
||||
}
|
||||
}
|
||||
|
||||
+202
-53
@@ -125,6 +125,8 @@ pub fn router(state: VectorState) -> Router {
|
||||
.route("/playbook_memory/persist_sql", post(persist_playbook_memory_sql))
|
||||
.route("/playbook_memory/patterns", post(discover_playbook_patterns))
|
||||
.route("/playbook_memory/mark_failed", post(mark_playbook_failed))
|
||||
.route("/playbook_memory/retire", post(retire_playbook_memory))
|
||||
.route("/playbook_memory/status", get(playbook_memory_status))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -803,20 +805,23 @@ async fn hybrid_search(
|
||||
// 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);
|
||||
// Extract target (city, state) from the SQL filter so
|
||||
// compute_boost_for can skip playbooks from other cities that
|
||||
// would never intersect the candidate pool. The executor's
|
||||
// filter shape is stable: `... city = 'Toledo' AND state = 'OH' ...`.
|
||||
// Case-insensitive match, tolerant of single quotes and spaces.
|
||||
// Extract target (city, state, role) from the SQL filter so
|
||||
// compute_boost_for can skip playbooks from other cities AND
|
||||
// prioritize exact role matches via the multi-strategy path.
|
||||
// The executor's filter shape is stable:
|
||||
// `... role = 'Welder' AND city = 'Toledo' AND state = 'OH' ...`.
|
||||
// Case-insensitive match, tolerant of single quotes.
|
||||
let target_geo = req.sql_filter.as_deref().and_then(extract_target_geo);
|
||||
let target_role = req.sql_filter.as_deref().and_then(extract_target_role);
|
||||
// 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_filtered(
|
||||
.compute_boost_for_filtered_with_role(
|
||||
&qv,
|
||||
boost_k,
|
||||
0.5,
|
||||
target_geo.as_ref().map(|(c, s)| (c.as_str(), s.as_str())),
|
||||
target_role.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -850,12 +855,13 @@ async fn hybrid_search(
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"playbook_boost: boosts={} sources={} parsed={} matched={} target_geo={:?} (query='{}')",
|
||||
"playbook_boost: boosts={} sources={} parsed={} matched={} target_geo={:?} target_role={:?} (query='{}')",
|
||||
boosts.len(),
|
||||
sources.len(),
|
||||
parsed_count,
|
||||
matched_count,
|
||||
target_geo,
|
||||
target_role,
|
||||
req.question.chars().take(60).collect::<String>(),
|
||||
);
|
||||
// Re-rank: boosted scores can flip ordering.
|
||||
@@ -2098,51 +2104,53 @@ struct LanceRecallQuery {
|
||||
/// "{Name} — {Role} in {City}, {State}. Skills: …".
|
||||
/// Returns None if the chunk doesn't match the shape; callers simply
|
||||
/// skip the boost for that hit.
|
||||
/// Extract role from an SQL filter matching `role = 'Welder'` style.
|
||||
/// Case-insensitive on the column name. Quoted value; quotes not
|
||||
/// included in returned string.
|
||||
fn extract_target_role(sql_filter: &str) -> Option<String> {
|
||||
grab_eq_value(sql_filter, "role")
|
||||
}
|
||||
|
||||
/// Shared equality-value extractor for (city, state, role) lookups.
|
||||
fn grab_eq_value(src: &str, col: &str) -> Option<String> {
|
||||
let lower = src.to_ascii_lowercase();
|
||||
let col_lower = col.to_ascii_lowercase();
|
||||
let mut search_from = 0usize;
|
||||
while let Some(off) = lower[search_from..].find(&col_lower) {
|
||||
let pos = search_from + off;
|
||||
let prior_ok = pos == 0
|
||||
|| !lower.as_bytes()[pos - 1].is_ascii_alphanumeric()
|
||||
&& lower.as_bytes()[pos - 1] != b'_';
|
||||
let after = pos + col_lower.len();
|
||||
if !prior_ok || after >= src.len() {
|
||||
search_from = pos + col_lower.len();
|
||||
continue;
|
||||
}
|
||||
let mut i = after;
|
||||
while i < src.len() && src.as_bytes()[i] == b' ' { i += 1; }
|
||||
if i >= src.len() || src.as_bytes()[i] != b'=' { search_from = pos + col_lower.len(); continue; }
|
||||
i += 1;
|
||||
while i < src.len() && src.as_bytes()[i] == b' ' { i += 1; }
|
||||
if i >= src.len() || src.as_bytes()[i] != b'\'' { search_from = pos + col_lower.len(); continue; }
|
||||
i += 1;
|
||||
let start = i;
|
||||
while i < src.len() && src.as_bytes()[i] != b'\'' { i += 1; }
|
||||
if i > start {
|
||||
return Some(src[start..i].to_string());
|
||||
}
|
||||
search_from = pos + col_lower.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Pull (city, state) out of a SQL filter that uses
|
||||
/// `city = 'Toledo' AND state = 'OH'` style equality. Returns None if
|
||||
/// either is missing — the caller keeps the original global boost map
|
||||
/// behavior (no geo narrowing). Case-insensitive on the column name
|
||||
/// so `CITY=` or `City =` also work.
|
||||
fn extract_target_geo(sql_filter: &str) -> Option<(String, String)> {
|
||||
fn grab_eq(src: &str, col: &str) -> Option<String> {
|
||||
// Very small parser, resilient enough for the executor's
|
||||
// filter shapes. Matches `col = 'value'` or `col='value'` with
|
||||
// case-insensitive column name.
|
||||
let lower = src.to_ascii_lowercase();
|
||||
let col_lower = col.to_ascii_lowercase();
|
||||
let mut search_from = 0usize;
|
||||
while let Some(off) = lower[search_from..].find(&col_lower) {
|
||||
let pos = search_from + off;
|
||||
// Require word boundary before the column name so "city"
|
||||
// inside "civilian_rank" doesn't false-match.
|
||||
let prior_ok = pos == 0
|
||||
|| !lower.as_bytes()[pos - 1].is_ascii_alphanumeric()
|
||||
&& lower.as_bytes()[pos - 1] != b'_';
|
||||
let after = pos + col_lower.len();
|
||||
if !prior_ok || after >= src.len() {
|
||||
search_from = pos + col_lower.len();
|
||||
continue;
|
||||
}
|
||||
// Walk past whitespace, require '='.
|
||||
let mut i = after;
|
||||
while i < src.len() && src.as_bytes()[i] == b' ' { i += 1; }
|
||||
if i >= src.len() || src.as_bytes()[i] != b'=' { search_from = pos + col_lower.len(); continue; }
|
||||
i += 1;
|
||||
while i < src.len() && src.as_bytes()[i] == b' ' { i += 1; }
|
||||
// Value is single-quoted literal; extract until the next '.
|
||||
if i >= src.len() || src.as_bytes()[i] != b'\'' { search_from = pos + col_lower.len(); continue; }
|
||||
i += 1;
|
||||
let start = i;
|
||||
while i < src.len() && src.as_bytes()[i] != b'\'' { i += 1; }
|
||||
if i > start {
|
||||
return Some(src[start..i].to_string());
|
||||
}
|
||||
search_from = pos + col_lower.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
let city = grab_eq(sql_filter, "city")?;
|
||||
let state = grab_eq(sql_filter, "state")?;
|
||||
let city = grab_eq_value(sql_filter, "city")?;
|
||||
let state = grab_eq_value(sql_filter, "state")?;
|
||||
Some((city, state))
|
||||
}
|
||||
|
||||
@@ -2179,6 +2187,19 @@ struct SeedPlaybookRequest {
|
||||
/// seeding is a bootstrap/demo tool, not a rebuild substitute.
|
||||
#[serde(default = "default_true")]
|
||||
append: bool,
|
||||
/// Phase 25 — optional schema_fingerprint captured at seed time.
|
||||
/// When the underlying dataset's schema changes, any entry whose
|
||||
/// fingerprint doesn't match the new one is auto-retired via
|
||||
/// retire_on_schema_drift. Caller-provided so the producer (the
|
||||
/// scenario driver, the orchestrator) can pass the live fingerprint
|
||||
/// without the gateway needing a second catalogd round trip.
|
||||
#[serde(default)]
|
||||
schema_fingerprint: Option<String>,
|
||||
/// Phase 25 — optional hard expiry. RFC3339 timestamp. After this
|
||||
/// moment the entry is skipped during boost computation (not
|
||||
/// retired, just inactive). Useful for seasonal/temp contracts.
|
||||
#[serde(default)]
|
||||
valid_until: Option<String>,
|
||||
}
|
||||
|
||||
/// Bootstrap / test-only: inject a playbook entry directly into
|
||||
@@ -2203,6 +2224,10 @@ async fn seed_playbook_memory(
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
endorsed_names: req.endorsed_names.clone(),
|
||||
city: None, state: None, embedding: None,
|
||||
schema_fingerprint: None,
|
||||
valid_until: None,
|
||||
retired_at: None,
|
||||
retirement_reason: None,
|
||||
};
|
||||
let text = format!(
|
||||
"{} | {} | {} | fills: {}",
|
||||
@@ -2254,18 +2279,40 @@ async fn seed_playbook_memory(
|
||||
endorsed_names: req.endorsed_names,
|
||||
city, state: state_,
|
||||
embedding: Some(emb),
|
||||
// Phase 25 — seed request may carry a fingerprint; if not, we
|
||||
// default to None and the entry degrades to "no expiry signal"
|
||||
// (never auto-retired on drift, but manual retirement still
|
||||
// works). valid_until + retired_at start None.
|
||||
schema_fingerprint: req.schema_fingerprint.clone(),
|
||||
valid_until: req.valid_until.clone(),
|
||||
retired_at: None,
|
||||
retirement_reason: None,
|
||||
};
|
||||
|
||||
let mut current = state.playbook_memory.snapshot().await;
|
||||
// Phase 26 — when append=true (default), route through upsert so
|
||||
// same-day re-seeds of the same operation merge instead of
|
||||
// appending duplicates. When append=false, retain the old
|
||||
// replace-all semantics for callers that want a hard reset.
|
||||
if req.append {
|
||||
current.push(new_entry);
|
||||
match state.playbook_memory.upsert_entry(new_entry).await {
|
||||
Ok(outcome) => {
|
||||
let entries_after = state.playbook_memory.entry_count().await;
|
||||
Ok(Json(serde_json::json!({
|
||||
"outcome": outcome,
|
||||
"entries_after": entries_after,
|
||||
})))
|
||||
}
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, format!("upsert: {e}"))),
|
||||
}
|
||||
} else {
|
||||
current = vec![new_entry];
|
||||
if let Err(e) = state.playbook_memory.set_entries(vec![new_entry]).await {
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("persist: {e}")));
|
||||
}
|
||||
Ok(Json(serde_json::json!({
|
||||
"outcome": { "mode": "replaced", "playbook_id": pid },
|
||||
"entries_after": state.playbook_memory.entry_count().await,
|
||||
})))
|
||||
}
|
||||
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(
|
||||
@@ -2389,6 +2436,65 @@ async fn playbook_memory_stats(
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RetirePlaybookRequest {
|
||||
/// Retire by playbook_id — exact match, single entry. Used for
|
||||
/// manual operator retirement via the UI.
|
||||
#[serde(default)]
|
||||
playbook_id: Option<String>,
|
||||
/// Retire by scope — city + state required, with a fingerprint
|
||||
/// that entries must match to survive. Fingerprint mismatch → retire.
|
||||
/// Use when a schema migration produces a new fingerprint and
|
||||
/// historical playbooks need to be auto-retired.
|
||||
#[serde(default)]
|
||||
city: Option<String>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
current_schema_fingerprint: Option<String>,
|
||||
/// Human-readable reason stored on the retired entry.
|
||||
reason: String,
|
||||
}
|
||||
|
||||
/// Phase 25 retirement endpoint. Two modes:
|
||||
/// {playbook_id, reason} → retire one
|
||||
/// {city, state, current_schema_fingerprint, reason} → retire all
|
||||
/// entries in scope whose
|
||||
/// fingerprint differs
|
||||
async fn retire_playbook_memory(
|
||||
State(state): State<VectorState>,
|
||||
Json(req): Json<RetirePlaybookRequest>,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(id) = &req.playbook_id {
|
||||
return match state.playbook_memory.retire_one(id, &req.reason).await {
|
||||
Ok(found) => Ok(Json(serde_json::json!({ "mode": "by_id", "retired": if found { 1 } else { 0 } }))),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
};
|
||||
}
|
||||
if let (Some(city), Some(state_code), Some(fp)) = (&req.city, &req.state, &req.current_schema_fingerprint) {
|
||||
return match state.playbook_memory.retire_on_schema_drift(city, state_code, fp, &req.reason).await {
|
||||
Ok(n) => Ok(Json(serde_json::json!({ "mode": "schema_drift", "retired": n, "city": city, "state": state_code }))),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
};
|
||||
}
|
||||
Err((StatusCode::BAD_REQUEST,
|
||||
"supply either {playbook_id, reason} or {city, state, current_schema_fingerprint, reason}".into()))
|
||||
}
|
||||
|
||||
/// Phase 25 status endpoint — reports retirement counts so dashboards
|
||||
/// can show "N playbooks retired (12 from 2026-05 schema migration)".
|
||||
async fn playbook_memory_status(
|
||||
State(state): State<VectorState>,
|
||||
) -> impl IntoResponse {
|
||||
let (total, retired, failures) = state.playbook_memory.status_counts().await;
|
||||
Json(serde_json::json!({
|
||||
"total": total,
|
||||
"retired": retired,
|
||||
"active": total.saturating_sub(retired),
|
||||
"failures": failures,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn lance_recall_harness(
|
||||
State(state): State<VectorState>,
|
||||
Path(index_name): Path<String>,
|
||||
@@ -2480,3 +2586,46 @@ async fn lance_build_scalar_index(
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod extractor_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_target_geo_basic() {
|
||||
let f = "role = 'Welder' AND city = 'Toledo' AND state = 'OH' AND CAST(availability AS DOUBLE) > 0.5";
|
||||
assert_eq!(extract_target_geo(f), Some(("Toledo".into(), "OH".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_target_geo_missing_state_returns_none() {
|
||||
let f = "role = 'Welder' AND city = 'Toledo'";
|
||||
assert_eq!(extract_target_geo(f), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_target_geo_word_boundary() {
|
||||
// "civilian" contains "city" as a substring — must not match.
|
||||
let f = "civilian_rank = 1 AND city = 'Toledo' AND state = 'OH'";
|
||||
assert_eq!(extract_target_geo(f), Some(("Toledo".into(), "OH".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_target_role_basic() {
|
||||
let f = "role = 'Welder' AND city = 'Toledo'";
|
||||
assert_eq!(extract_target_role(f), Some("Welder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_target_role_none_when_absent() {
|
||||
let f = "city = 'Toledo' AND state = 'OH'";
|
||||
assert_eq!(extract_target_role(f), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_target_role_multi_word() {
|
||||
let f = "role = 'Warehouse Associate' AND city = 'Chicago'";
|
||||
assert_eq!(extract_target_role(f), Some("Warehouse Associate".into()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+63
-5
@@ -230,7 +230,7 @@
|
||||
- Profile-driven routing: `POST /vectors/profile/{id}/search` auto-routes to Lance when profile.vector_backend=lance
|
||||
- Auto-migrate + auto-index on activation
|
||||
- Measured on real 100K × 768d: migrate 0.57s, IVF_PQ build 16.2s (14× faster than HNSW 230s), search 23ms, append 100 rows 3.3ms, doc_id fetch 3.5ms (with scalar btree)
|
||||
- IVF_PQ recall@10 = 0.805 (HNSW = 1.000) — measured via `/vectors/lance/recall/{idx}` harness
|
||||
- IVF_PQ recall@10 = 0.805 with Lance's default `nprobes=1` (the hidden cap — see 2026-04-20 tuning work below, which lifts it to 1.000). Measured via `/vectors/lance/recall/{idx}` harness.
|
||||
- [x] Phase E.3: Scheduled ingest — 2026-04-17
|
||||
- `ingestd::schedule` module: ScheduleDef, ScheduleStore (JSON at `_schedules/{id}.json`), Scheduler tokio task
|
||||
- Supports MySQL + Postgres sources on interval triggers (Cron variant defined, parsing stubbed)
|
||||
@@ -241,10 +241,68 @@
|
||||
- Two-tier: lopdf text extraction → Tesseract 5.5 fallback for scanned/image PDFs
|
||||
- Extracts embedded XObject /Image streams, shells to tesseract --oem 3 --psm 6
|
||||
- Same schema (source_file, page_number, text_content) — downstream unchanged
|
||||
- [ ] Fine-tuned domain models
|
||||
- [ ] Multi-node query distribution
|
||||
- [x] Catalog hygiene — idempotent `register()` + dedupe + DELETE (2026-04-19, ADR-020)
|
||||
- `catalogd::Registry::register` now gates on `(name, schema_fingerprint)`: same fp → reuse `DatasetId` and update objects in place; different fp → return error (409 Conflict on HTTP, `FAILED_PRECONDITION` on gRPC). First-time registration is unchanged.
|
||||
- `POST /catalog/dedupe` one-shot operator endpoint collapses pre-existing duplicates; winner = non-null `row_count` first, newest `updated_at` second.
|
||||
- `DELETE /catalog/datasets/by-name/{name}` removes the manifest from both in-memory registry and object storage (metadata-only — parquet files, vector indexes, tombstones are NOT cascade-deleted). Added to support test-harness cleanup; also plugs a real catalog hole where zombie entries from prior deletes would break DataFusion schema inference.
|
||||
- Cleanup run on live catalog: 374 → 31 datasets, 343 orphan manifests removed, 0 errors. 308× `successful_playbooks` was the worst offender.
|
||||
- Concurrency: write lock held across storage I/O in `register()` to close the check→insert TOCTOU window (32-worker multi-threaded stress test verifies single-manifest invariant).
|
||||
- End-to-end verification: `scripts/e2e_pipeline_check.sh` runs 31 assertions across 12 pipeline stages (ingest → catalog → SQL+JOIN → dedup → idempotency → metadata → PII → vector embed → semantic search → cleanup) against the live gateway. Idempotent across repeat runs.
|
||||
- Tests: 11 new in `catalogd` (was 0, includes 3 concurrency tests + 3 delete_dataset tests); 11 new in `storaged` for `AppendLog` + `ErrorJournal` (was 0). Fixed a broken doctest in `append_log.rs`.
|
||||
- [x] Autotune agent: portfolio rotation + auto-bootstrap (2026-04-20)
|
||||
- `pick_periodic_target` now sources candidates from `IndexRegistry` (not just promoted indexes) and picks least-recently-tuned, so trial budget spreads across every index with ≥1000 vectors instead of fixating on one converged champion.
|
||||
- `run_one_cycle` bootstraps on first visit: `ensure_auto_harness` auto-generates `{index}_auto` (20 synthetic self-queries, k=10, brute-force ground truth) if missing, then seeds with `HnswConfig::default()` (ec=80/es=30).
|
||||
- Regression fix: `harness::recall_at_k` now uses set-intersection semantics. The prior impl counted duplicates in `predicted` — on corpora with repeated chunks (`kb_response_cache_agent`) this inflated recall above 1.0 and poisoned promotion decisions. +7 unit tests.
|
||||
- [x] Scheduled ingest: real cron parsing (2026-04-20)
|
||||
- Vixie-compatible 5/6-field cron via `croner` crate. Day-of-week follows Unix convention (`1-5` = Mon-Fri). 6-field adds seconds granularity.
|
||||
- `validate_trigger` in `ingestd::schedule` — create/patch handlers reject malformed expressions with `400 BAD_REQUEST` at creation time, not silently at fire time.
|
||||
- Swapped away from the `cron` crate (0.16) which uses a non-Unix DOW convention (`1=Sun`) that would silently bite anyone writing `1-5` expecting weekdays. +9 unit tests.
|
||||
- [x] EvalSets federation (2026-04-20)
|
||||
- `harness::HarnessStore` mirrors the TrialJournal / PromotionRegistry federation pattern: eval artifacts colocate with each index's recorded bucket; legacy evals in primary remain discoverable via a fallback path; cross-bucket listing dedupes.
|
||||
- Every eval callsite (service.rs × 5, agent.rs × 3, autotune.rs × 1) now routes through `HarnessStore`. `VectorState` and `AgentDeps` each hold a shared instance.
|
||||
- [x] Index bucket-migrate PATCH (2026-04-20)
|
||||
- `PATCH /vectors/indexes/{name}/bucket` copies an index's vector parquet + trial-journal batches + promotion file + auto-harness to `dest_bucket`, flips `IndexMeta.bucket` as the commit point, and evicts the `EmbeddingCache` so next load reads from the new bucket. Optional `delete_source: true` sweeps source artifacts.
|
||||
- Lance-backed indexes refused with 400 — Lance URIs are bucket-specific and require rewriting the dataset, separate story. Round-trip verified: 390 artifacts, 0.04s.
|
||||
- [x] IVF_PQ recall tuning (2026-04-20)
|
||||
- `LanceVectorStore::search` now accepts optional `nprobes` + `refine_factor`. Lance's built-in `nprobes=1` default was the hidden cap on recall — on 316-partition `resumes_100k_v2` it searched only 0.3% of partitions per query.
|
||||
- Server defaults (`LANCE_DEFAULT_NPROBES=20`, `LANCE_DEFAULT_REFINE_FACTOR=5`) flow through the scoped-search path and the autotune harness. Measured on `resumes_100k_v2`: recall `0.805 → 1.000` at p50 ≈ 7.4ms. Even `nprobes=5, refine=5` saturates recall at p50 ≈ 4.7ms.
|
||||
- `/vectors/lance/recall/{idx}` accepts per-request `nprobes` / `refine_factor` so operators can sweep the curve.
|
||||
- [x] **Phase 19: Playbook memory (meta-index)** — the feedback loop originally implied by the PRD but never built. Playbooks stop being write-only; they start shaping future rankings. (2026-04-20)
|
||||
- [x] 19.1 — `POST /vectors/playbook_memory/rebuild` scans `successful_playbooks` via DataFusion, builds one `PlaybookEntry` per row (operation + approach + context embedded as one vector via nomic-embed-text)
|
||||
- [x] 19.2 — Brute-force cosine search over in-memory embeddings (chosen over HNSW: successful_playbooks maxes around thousands of rows, overhead of a second indexed surface isn't worth it until that ceiling bites)
|
||||
- [x] 19.3 — Endorsed names parsed out of `result` column, keyed by `(city, state, name)` tuple so shared names across cities don't cross-pollinate. Parsing via `parse_names` + `parse_city_state` helpers (7 unit tests)
|
||||
- [x] 19.4 — `/vectors/hybrid?use_playbook_memory=true`: fetches `top_k * 5` candidates so endorsed workers outside the vanilla top-K can still climb. Boost is additive on vector score, each hit carries `playbook_boost` + `playbook_citations` in the response for explainability
|
||||
- [x] 19.5 — Multi-agent orchestrator (`tests/multi-agent/orchestrator.ts`) auto-seeds `POST /vectors/playbook_memory/seed` on consensus_done, so the next query sees the new endorsement without a full `/rebuild`. Closes the feedback loop: two agents reach consensus → playbook sealed → next query re-ranks
|
||||
- [x] 19.6 — `MAX_BOOST_PER_WORKER = 0.25` enforced in `compute_boost_for`; verified with unit test (100 identical playbooks → boost capped at 0.25) and live test (5 identical seeds → exactly 0.25). Time decay deferred as optional
|
||||
- Real finding surfaced during build: the 32 bootstrap rows in `successful_playbooks` reference phantom worker names — 80 of 82 don't correspond to actual rows in `workers_500k`. `/seed` endpoint bypasses `successful_playbooks` so operators can prime memory with real fixtures; production path is the orchestrator write-through
|
||||
- [x] **Phase 19 refinement — geo + role prefilter on boost** (2026-04-21)
|
||||
- Added `compute_boost_for_filtered` and `compute_boost_for_filtered_with_role` to `playbook_memory.rs`. SQL filter's `(city, state, role)` parsed in `service.rs`; exact role-matches in target geo skip cosine and earn similarity=1.0. Restored the feedback loop: matched=0 → matched=11 per query on the same Nashville test. Citation density on Riverfront Steel: 2 → 28 per run (14×).
|
||||
- Rust unit tests: `extractor_tests::extract_target_geo_basic/_missing_state/_word_boundary`, `extract_target_role_basic/_none/_multi_word`. 6/6 pass.
|
||||
- Diagnostic log: `playbook_boost: boosts=N sources=N parsed=N matched=N target_geo=? target_role=?` on every call.
|
||||
- [x] **Phase 20: Model Matrix + Overseer Tiers** (2026-04-21)
|
||||
- `config/models.json` — 5 tiers (t1_hot / t2_review / t3_overview / t4_strategic / t5_gatekeeper), each with context_window + context_budget + overflow_policy. Ollama Cloud bearer key from `/root/llm_team_config.json`.
|
||||
- Hot path: qwen3.5:latest + qwen3:latest local with `think:false`. Mistral dropped after 0/14 fill on complex scenarios.
|
||||
- T3 cloud: gpt-oss:120b via Ollama Cloud — verified 4-8s latency, strict JSON-shape output for remediation.
|
||||
- [x] **Phase 21: Scratchpad + Tree-Split Continuation** (2026-04-21)
|
||||
- `tests/multi-agent/agent.ts`: `estimateTokens()`, `assertContextBudget()`, `generateContinuable()`, `generateTreeSplit()`. `think` flag plumbed through sidecar's `/generate`. Empty-response backoff + truncation-continuation, no max_tokens tourniquet.
|
||||
- Rust port queued: `crates/aibridge/src/continuation.rs`, `tree_split.rs`.
|
||||
- [x] **Phase 22: Internal Knowledge Library** (2026-04-21)
|
||||
- `data/_kb/` — signatures.jsonl, outcomes.jsonl, pathway_recommendations.jsonl, error_corrections.jsonl, config_snapshots.jsonl. Event-driven cycle: indexRun → recommendFor → loadRecommendation.
|
||||
- Item B cloud rescue: failed event → cloud remediation JSON → retry with pivot. Verified 1/3 rescues succeeded on stress_01 (Gary IN → South Bend IN pivot).
|
||||
- `scripts/kb_measure.py` aggregator. Unit tests: `kb.test.ts` — 4/4 pass (signature stability, role/city/count invariants, digest shape).
|
||||
- [x] **Phase 23: Staffer identity + competence-weighted retrieval** (2026-04-21)
|
||||
- ScenarioSpec gained `contract: ContractTerms` and `staffer: Staffer { id, name, tenure_months, role, tool_level }`.
|
||||
- tool_level runtime overrides: full / local / basic / minimal. Basic + minimal route executor to Ollama Cloud `kimi-k2.5` (kimi-k2.6 pending pro-tier upgrade).
|
||||
- `data/_kb/staffers.jsonl` — competence_score = 0.45·fill + 0.20·turn_eff + 0.20·cite + 0.15·rescue. Recomputed per run.
|
||||
- `findNeighbors` now returns `weighted_score = cosine × max_staffer_competence`. `scripts/kb_staffer_report.py` — leaderboard + cross-staffer worker overlap (Rachel D. Lewis 12× across 4 staffers → auto-discovered high-value label).
|
||||
- `gen_staffer_demo.ts` + `run_staffer_demo.sh` — 4 personas × 3 contracts = 12 runs.
|
||||
- [ ] **Phase 24: Observer / Autotune integration** (GAP, not wired)
|
||||
- `lakehouse-observer.service` watches MCP :3700; scenario.ts hits gateway :3100 directly. Observer idle at 0 ops across 3600+ cycles. Autotune runs on its own schedule, never sees scenario outcomes.
|
||||
- Next-sprint: scenario emits per-event outcome summaries to observer's ingest path; observer ERROR_ANALYZER + PLAYBOOK_BUILDER loops consume them; autotune subscribes to the metric stream.
|
||||
- [ ] Fine-tuned domain models (Phase 25+)
|
||||
- [ ] Multi-node query distribution (only if ceilings bite)
|
||||
|
||||
---
|
||||
|
||||
**52+ unit tests | 13 crates | 19 ADRs | 2.47M rows | 100K vectors | Hybrid Parquet+HNSW ⊕ Lance**
|
||||
**Latest: 2026-04-17 — 8 commits shipping Phase 16.2 through Phase 18**
|
||||
**102 unit tests | 13 crates | 20 ADRs | 2.47M rows | 100K vectors | Hybrid Parquet+HNSW ⊕ Lance | Phase 19 refined + 20-23 shipped**
|
||||
**Latest: 2026-04-21 — Phases 20-23 shipped. Geo+role prefilter lifted playbook citation density 14×. Cloud rescue converts zero-supply failures into successful pivots. Staffer competence weighting differentiates full-tool senior from minimal-tool trainee by 46.4pt fill rate on same contracts. Phase 24 observer integration flagged as honest gap.**
|
||||
|
||||
+83
-7
@@ -380,17 +380,37 @@ Make successful playbooks actually improve future searches. Today `successful_pl
|
||||
- Hard guarantees about recall lift magnitude. "Measurably better on the demo query" is the gate, not a universal quality claim.
|
||||
- Real-time recomputation on every playbook. Batched refresh via the existing stale-marking path is sufficient.
|
||||
|
||||
### Phase 19 refinement (WIRED 2026-04-21): geo-filter + role prefilter on boost
|
||||
|
||||
Item-3 diagnostic pass surfaced that `compute_boost_for` was ranking playbooks globally by cosine similarity, while candidates came from an SQL-filtered city. Result: boost map had 170 endorsed workers, 0 intersected the 50 Nashville-filtered candidates. Zero citations where there should have been dozens.
|
||||
|
||||
Fix — in `crates/vectord/src/playbook_memory.rs`:
|
||||
- `compute_boost_for_filtered(target_geo)` — skip playbooks from other cities before cosine sort.
|
||||
- `compute_boost_for_filtered_with_role(target_geo, target_role)` — multi-strategy: exact (role, city, state) match earns similarity=1.0 and fills up to half the top_k; cosine fallback fills the rest. Mirrors Mem0/Zep 2026 guidance on parallel-strategy rerank.
|
||||
|
||||
In `crates/vectord/src/service.rs`:
|
||||
- `extract_target_geo` and `extract_target_role` pull both from the executor's SQL filter.
|
||||
- `tracing::info!` emits `playbook_boost: boosts=N sources=N parsed=N matched=N target_geo=? target_role=?` on every hybrid_search. Silent-truncation class of bug now visible.
|
||||
|
||||
Citation lift measured: avg citations per run 0.32 → 1.38 after geo filter; then 2 → 28 in the single-scenario Riverfront Steel re-run after role prefilter landed. 14× delta on same scenario.
|
||||
|
||||
Unit tests: `extract_target_geo_basic`, `_missing_state_returns_none`, `_word_boundary` (rejects "civilian" substring), `extract_target_role_basic`, `_none_when_absent`, `_multi_word` — all pass (`cargo test -p vectord --lib extractor_tests`).
|
||||
|
||||
### Phase 20: Model Matrix + Overseer Tiers (WIRED 2026-04-21)
|
||||
|
||||
Five-tier routing declared in `config/models.json`. Hot path (T1/T2) stays on local mistral + qwen2.5. Cloud is consulted sparingly for overview (T3 gpt-oss:120b), strategic (T4 qwen3.5:397b), and gatekeeper decisions (T5 kimi-k2-thinking). Every tier declares `context_window` + `context_budget` + `overflow_policy`. See ADR-021 (to add).
|
||||
Five-tier routing declared in `config/models.json`. Hot path (T1/T2) stays local (qwen3.5 + qwen3 after mistral was dropped for 0/14 fill rate on complex scenarios). Cloud for overview (T3 gpt-oss:120b), strategic (T4 qwen3.5:397b), and gatekeeper (T5 kimi-k2-thinking). Every tier declares `context_window` + `context_budget` + `overflow_policy`.
|
||||
|
||||
- T1 hot: 50-200 calls/scenario, local only
|
||||
- T2 review: 5-14 calls/event, local only
|
||||
- T3 overview: 1-3 calls/scenario, cloud primary
|
||||
- T1 hot: 50-200 calls/scenario, local only — `qwen3.5:latest` executor, `think:false`
|
||||
- T2 review: 5-14 calls/event, local only — `qwen3:latest` reviewer, `think:false`
|
||||
- T3 overview: 1-3 calls/scenario, cloud primary — `gpt-oss:120b` on Ollama Cloud, thinking on
|
||||
- T4 strategic: 1-10 calls/day, cloud primary
|
||||
- T5 gatekeeper: 1-5 calls/day, audit-logged
|
||||
|
||||
T3 checkpoints + cross-day lessons are wired. Lessons archive to `data/_playbook_lessons/` and load back at next scenario start as `prior_lessons` in executor context.
|
||||
T3 checkpoints + cross-day lessons wired. Lessons archive to `data/_playbook_lessons/` and load back at next scenario start as `prior_lessons` in executor context. Cloud passthrough verified on stress_01 scenario with `LH_OVERVIEW_CLOUD=1` — `gpt-oss:120b` response latency consistently 4-8s, diagnosing city-pivot ("Gary IN → Chicago IL, 40mi") when target city has zero supply.
|
||||
|
||||
`think:false` is the key mechanical finding — qwen3.5 burns ~650 tokens of hidden reasoning before emitting response; hot-path JSON emitters MUST disable thinking or continuation has to paper over empty returns. T3/T4 overseers KEEP thinking (that's the point).
|
||||
|
||||
**Kimi-k2.6 upgrade path:** Current Ollama Cloud key returns 403 on kimi-k2.6 (`ollama run kimi-k2.6:cloud` requires `ollama signin` with pro-tier account). kimi-k2.5 substitutes on the current tier — same family, strong at tool calling. Swap to k2.6 is a one-line change in `applyToolLevel` once the subscription lands.
|
||||
|
||||
### Phase 21: Scratchpad + Tree-Split Continuation
|
||||
|
||||
@@ -421,6 +441,16 @@ T3 checkpoints + cross-day lessons are wired. Lessons archive to `data/_playbook
|
||||
|
||||
**Status:** TS primitives WIRED. Rust port pending. The escalation path (tree split → bigger-context cloud model → kimi-k2:1t's 1M window → split decision into sub-decisions) is declared in `config/models.json` under `context_management.overflow_policies`.
|
||||
|
||||
### Phase 21 status update (WIRED 2026-04-21 evening)
|
||||
|
||||
Additional primitives landed after the initial commit:
|
||||
|
||||
- **`think: boolean`** flag plumbed through `generate()`, `generateCloud()`, `generateContinuable()`, and into sidecar's `/generate` endpoint. Enables per-call opt-out of hidden reasoning for hot-path JSON emitters. Verified: qwen3.5 with `think:false` + `num_predict:400` returns clean `{"worker_id":...}` on first call; without `think:false`, 650 tokens eaten by reasoning, response empty.
|
||||
|
||||
- **Cloud executor routing** — `ACTIVE_EXECUTOR_CLOUD` / `ACTIVE_REVIEWER_CLOUD` flags let per-staffer tool_level route executor to Ollama Cloud when weak local model (qwen2.5) would collapse. Verified on kimi-k2.5 via Ollama Cloud: clean JSON emission, think:false honored.
|
||||
|
||||
Rust port of continuation + tree-split primitives remains queued for next sprint (`crates/aibridge/src/continuation.rs`, `tree_split.rs`).
|
||||
|
||||
### Phase 22: Internal Knowledge Library (KB)
|
||||
|
||||
Meta-layer over Phase 19 playbook_memory. Playbook memory answers "which WORKERS worked for this event." The KB answers "which CONFIG worked for this playbook signature." Subject changes from workers to the system itself — model choice, budget hints, overflow policies, pathway notes.
|
||||
@@ -451,9 +481,55 @@ Meta-layer over Phase 19 playbook_memory. Playbook memory answers "which WORKERS
|
||||
- budget_hints {executor_max_tokens, reviewer_max_tokens, executor_think}
|
||||
- pathway_notes (concrete pre-run advice)
|
||||
|
||||
**Status (WIRED 2026-04-21):** `tests/multi-agent/kb.ts` holds all primitives. scenario.ts reads rec at start, indexes + recommends at end. Cold start gracefully writes a "low confidence, no history" rec so the second run has a floor to build on.
|
||||
**Status (WIRED 2026-04-21):** `tests/multi-agent/kb.ts` holds all primitives. scenario.ts reads rec at start, indexes + recommends at end. Cold start gracefully writes a "low confidence, no history" rec so the second run has a floor to build on. `snapshotConfig()` wired to fire at every scenario start — active model set + tool_level + cloud flags hashed and appended to `config_snapshots.jsonl`.
|
||||
|
||||
### Phase 23+: Further horizon
|
||||
**Phase 22 item B — cloud rescue (WIRED):** When an event fails and cloud T3 is enabled, `requestCloudRemediation()` feeds the failure trace (SQL filters attempted, row counts, reviewer drift reasons, gap signals, contract terms) to cloud and parses a JSON remediation with new_city / new_state / new_role / new_count / rationale. Event retries once with the pivot. Verified 1/3 rescues succeeded on stress_01 (Gary IN → South Bend IN pivot filled a Welder that local drift-aborted). Sanitizer splits "City, ST" comma-packed outputs so downstream SQL doesn't get `Hammond, IN, IN`.
|
||||
|
||||
### Phase 23: Staffer identity + competence-weighted retrieval (WIRED 2026-04-21)
|
||||
|
||||
Answers "who handled this" as a first-class dimension of the matrix index. Senior staffers' playbooks rank higher than juniors' on similar scenarios via competence × similarity score. Auto-discovers "reliable performer" worker labels via cross-staffer endorsement overlap.
|
||||
|
||||
**Schema (`scenario.ts` ScenarioSpec):**
|
||||
- `contract?: ContractTerms` — deadline, budget_per_hour_max, local_bonus_per_hour, local_bonus_radius_mi, fill_requirement. Propagates into T3 checkpoint + cloud rescue prompts so cloud reasons about trade-offs (pivot-within-radius before budget-pivot-further).
|
||||
- `staffer?: Staffer` — {id, name, tenure_months, role, tool_level}. tool_level controls subsystems available to this run:
|
||||
- `full` — qwen3.5 + qwen3 local + cloud T3 + cloud rescue
|
||||
- `local` — qwen3.5 + qwen3 local + local gpt-oss:20b T3 + rescue
|
||||
- `basic` — **kimi-k2.5 cloud** exec + qwen3 local reviewer + local T3, no rescue
|
||||
- `minimal` — kimi-k2.5 cloud exec + qwen3 local reviewer, NO T3, NO rescue — tests whether playbook inheritance carries knowledge alone
|
||||
|
||||
**KB staffer indexing (`data/_kb/staffers.jsonl`):**
|
||||
- Recomputed per-staffer on every run: total_runs, fill_rate, avg_turns_per_event, avg_citations_per_run, rescue_rate, competence_score.
|
||||
- `competence_score = 0.45·fill_rate + 0.20·turn_efficiency + 0.20·citation_density + 0.15·rescue_rate`. Bounded 0..1.
|
||||
|
||||
**Weighted neighbor retrieval:**
|
||||
- `findNeighbors` in `kb.ts` returns `weighted_score = cosine × max_staffer_competence` (floor 0.3). Senior playbooks rank above junior playbooks on similar scenarios.
|
||||
- `pathway_recommendations` include `best_staffer_id` / `best_staffer_competence` so cloud knows WHOSE playbook it's synthesizing from.
|
||||
|
||||
**Cross-staffer auto-discovery:**
|
||||
- `scripts/kb_staffer_report.py` emits leaderboard + workers endorsed across ≥2 staffers on same signature.
|
||||
- Validated output: Rachel D. Lewis (Welder Nashville) endorsed 12× across 4 staffers; Christina Watson (Machine Op Indianapolis) 11×. These are the highest-confidence "reliable performer" labels the system produced without human tagging.
|
||||
|
||||
**Demo infrastructure:**
|
||||
- `tests/multi-agent/gen_staffer_demo.ts` — 4 personas × 3 contracts = 12 scenario specs.
|
||||
- `scripts/run_staffer_demo.sh` — sequential batch with cloud T3.
|
||||
- `scripts/kb_staffer_report.py` — leaderboard + top/bottom differential + cross-staffer overlap.
|
||||
|
||||
### Phase 24: Observer / Autotune integration (NOT YET WIRED — honest gap)
|
||||
|
||||
J flagged this 2026-04-21 evening: the `lakehouse-observer.service` systemd unit has been running for 3600+ cycles but shows `total_ops=0 successes=0 failures=0` because `tests/multi-agent/scenario.ts` hits the Rust gateway directly on port 3100, bypassing the Bun MCP layer on 3700 that observer wraps.
|
||||
|
||||
Result: our test scenarios are INVISIBLE to the observer and the autotune pipeline. Autotune's HNSW parameter learning runs on its own schedule, but no signal from scenario outcomes flows into it.
|
||||
|
||||
**Target architecture:**
|
||||
- Scenarios emit per-event outcome summaries to a path the observer polls (or POST to observer's ingest endpoint directly).
|
||||
- Observer's ERROR ANALYZER + PLAYBOOK BUILDER loops consume those summaries alongside the MCP-layer ops.
|
||||
- Autotune agent subscribes to a metric stream the observer writes.
|
||||
|
||||
**Why deferred:** this is a real architecture change (coherent data path from scenario → observer → autotune → vectord index) and needs care. The observer's current `observed_operations` ingest uses REPLACE semantics (flagged in `feedback_ingest_replace_semantics.md`) — naive appending will wipe prior ops.
|
||||
|
||||
**Status:** GAP DOCUMENTED, not fixed. Scenarios continue to populate KB directly. The parallel pipelines are coherent but separate; Phase 24 connects them.
|
||||
|
||||
### Phase 25+: Further horizon
|
||||
|
||||
- Specialized fine-tuned models per domain (staffing matcher, resume parser)
|
||||
- Video/audio transcript ingest + multimodal embeddings
|
||||
|
||||
@@ -789,6 +789,26 @@ async function main() {
|
||||
// workers_500k manifest is correct (500K); candidates manifest
|
||||
// lied (said 100K, actual 1K) — the audit caught it.
|
||||
// Everything else uses manifest row_count since it's O(1).
|
||||
// Phase 24 refinement — unified memory query endpoint. Accepts
|
||||
// any input (natural language, structured JSON, mixed) via
|
||||
// POST body {input: <anything>}. Normalizer handles the shape.
|
||||
// Returns a single bundle with every memory surface relevant:
|
||||
// playbook workers, KB recommendation, neighbor signatures,
|
||||
// prior lessons, top staffers, discovered patterns.
|
||||
if (url.pathname === "/memory/query" && req.method === "POST") {
|
||||
try {
|
||||
const body: any = await req.json();
|
||||
const { queryMemory } = await import("../tests/multi-agent/memory_query.ts");
|
||||
const result = await queryMemory(body.input ?? body);
|
||||
return ok(result);
|
||||
} catch (e) {
|
||||
return new Response(JSON.stringify({ error: (e as Error).message }), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/system/summary") {
|
||||
const [ds, indexes, workersCount, candsCount] = await Promise.all([
|
||||
api("GET", "/catalog/datasets").catch(() => [] as any),
|
||||
|
||||
+108
-8
@@ -20,6 +20,9 @@
|
||||
const GATEWAY = process.env.GATEWAY_URL || "http://localhost:3700";
|
||||
const LAKEHOUSE = process.env.LAKEHOUSE_URL || "http://localhost:3100";
|
||||
const CYCLE_SECS = parseInt(process.env.OBSERVER_CYCLE || "30");
|
||||
// Phase 24 — observer now listens on an HTTP port for external ops
|
||||
// (scenarios bypass the MCP:3700 layer by design). Default 3800.
|
||||
const OBSERVER_PORT = parseInt(process.env.OBSERVER_PORT || "3800");
|
||||
|
||||
// ─── Observed operation log ───
|
||||
|
||||
@@ -31,10 +34,32 @@ interface ObservedOp {
|
||||
duration_ms: number;
|
||||
output_summary: string;
|
||||
error?: string;
|
||||
// Phase 24 — optional provenance so error analyzer and playbook
|
||||
// builder can differentiate MCP-layer ops from scenario-sourced
|
||||
// events. Scenarios set source="scenario" + staffer_id + sig_hash.
|
||||
source?: "mcp" | "scenario";
|
||||
staffer_id?: string;
|
||||
sig_hash?: string;
|
||||
event_kind?: string;
|
||||
role?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
count?: number;
|
||||
rescue_attempted?: boolean;
|
||||
rescue_succeeded?: boolean;
|
||||
}
|
||||
|
||||
const recentOps: ObservedOp[] = [];
|
||||
|
||||
// Phase 24 — external ingest path. Scenarios POST outcome summaries
|
||||
// here so the observer's analyzer + playbook builder see them. Called
|
||||
// from the Bun.serve() handler below. Same ring buffer as the MCP-
|
||||
// wrapped path so downstream loops don't need to know the source.
|
||||
export function recordExternalOp(op: ObservedOp): void {
|
||||
recentOps.push({ ...op, source: op.source ?? "scenario" });
|
||||
if (recentOps.length > 2000) recentOps.shift();
|
||||
}
|
||||
|
||||
// ─── Wrapped gateway caller — every call gets observed ───
|
||||
|
||||
export async function observed(
|
||||
@@ -93,16 +118,22 @@ function summarize(data: any): string {
|
||||
return JSON.stringify(data).slice(0, 100);
|
||||
}
|
||||
|
||||
// Phase 24 honesty fix — the old persistOp used /ingest/file which
|
||||
// REPLACES the dataset (flagged in feedback_ingest_replace_semantics.md).
|
||||
// Every op silently wiped all prior ops. Now we append a JSONL line to
|
||||
// data/_observer/ops.jsonl so the historical trace is durable. The
|
||||
// observer analyzer + playbook builder read from this file when it
|
||||
// outgrows the 2000-entry in-memory ring.
|
||||
async function persistOp(op: ObservedOp) {
|
||||
const csv = `timestamp,endpoint,input_summary,success,duration_ms,output_summary,error
|
||||
"${op.timestamp}","${op.endpoint}","${esc(op.input_summary)}",${op.success},${op.duration_ms},"${esc(op.output_summary)}","${esc(op.error || "")}"`;
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([csv], { type: "text/csv" }), "op.csv");
|
||||
await fetch(`${LAKEHOUSE}/ingest/file?name=observed_operations`, { method: "POST", body: form }).catch(() => {});
|
||||
try {
|
||||
const { mkdir, appendFile } = await import("node:fs/promises");
|
||||
await mkdir("data/_observer", { recursive: true });
|
||||
await appendFile("data/_observer/ops.jsonl", JSON.stringify(op) + "\n");
|
||||
} catch {
|
||||
// Persistence is best-effort; in-memory ring still works.
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s: string) { return s.replace(/"/g, '""').replace(/\n/g, ' '); }
|
||||
|
||||
// ─── Error analyzer loop ───
|
||||
|
||||
@@ -189,10 +220,74 @@ async function consolidatePlaybooks() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP listener for external ops (Phase 24) ───
|
||||
|
||||
// Scenarios POST per-event outcomes here so the observer's analyzer +
|
||||
// playbook builder see them alongside MCP-wrapped ops. Read-only stats
|
||||
// also exposed at /stats for external health checks.
|
||||
function startHttpListener() {
|
||||
Bun.serve({
|
||||
port: OBSERVER_PORT,
|
||||
hostname: "0.0.0.0",
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (req.method === "GET" && url.pathname === "/health") {
|
||||
return new Response(JSON.stringify({ status: "ok", ops_in_ring: recentOps.length }));
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/stats") {
|
||||
const bySource = new Map<string, number>();
|
||||
for (const o of recentOps) {
|
||||
const k = o.source ?? "mcp";
|
||||
bySource.set(k, (bySource.get(k) ?? 0) + 1);
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
total: recentOps.length,
|
||||
successes: recentOps.filter(o => o.success).length,
|
||||
failures: recentOps.filter(o => !o.success).length,
|
||||
by_source: Object.fromEntries(bySource),
|
||||
recent_scenario_ops: recentOps
|
||||
.filter(o => o.source === "scenario")
|
||||
.slice(-10)
|
||||
.map(o => ({ ts: o.timestamp, ok: o.success, staffer: o.staffer_id, kind: o.event_kind, role: o.role })),
|
||||
}));
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/event") {
|
||||
return req.json().then((body: any) => {
|
||||
const op: ObservedOp = {
|
||||
timestamp: body.timestamp ?? new Date().toISOString(),
|
||||
endpoint: body.endpoint ?? "scenario:fill",
|
||||
input_summary: body.input_summary ?? `${body.event_kind ?? "?"} ${body.role ?? "?"}×${body.count ?? "?"} in ${body.city ?? "?"}, ${body.state ?? "?"}`,
|
||||
success: !!body.success,
|
||||
duration_ms: Number(body.duration_ms ?? 0),
|
||||
output_summary: body.output_summary ?? (body.success ? "filled" : (body.error ?? "failed")),
|
||||
error: body.error,
|
||||
source: "scenario",
|
||||
staffer_id: body.staffer_id,
|
||||
sig_hash: body.sig_hash,
|
||||
event_kind: body.event_kind,
|
||||
role: body.role,
|
||||
city: body.city,
|
||||
state: body.state,
|
||||
count: body.count,
|
||||
rescue_attempted: !!body.rescue_attempted,
|
||||
rescue_succeeded: !!body.rescue_succeeded,
|
||||
};
|
||||
recordExternalOp(op);
|
||||
persistOp(op).catch(() => {});
|
||||
return new Response(JSON.stringify({ accepted: true, ring_size: recentOps.length }));
|
||||
}).catch((e: Error) =>
|
||||
new Response(JSON.stringify({ error: e.message }), { status: 400 }));
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
console.error(`[observer] HTTP listener bound to 0.0.0.0:${OBSERVER_PORT}`);
|
||||
}
|
||||
|
||||
// ─── Main loop ───
|
||||
|
||||
async function main() {
|
||||
console.error(`[observer] started — cycle=${CYCLE_SECS}s, gateway=${GATEWAY}`);
|
||||
console.error(`[observer] started — cycle=${CYCLE_SECS}s, gateway=${GATEWAY}, port=${OBSERVER_PORT}`);
|
||||
|
||||
// Run a health check first
|
||||
const health = await fetch(`${GATEWAY}/health`).then(r => r.json()).catch(() => null);
|
||||
@@ -202,6 +297,9 @@ async function main() {
|
||||
}
|
||||
console.error(`[observer] gateway healthy: ${JSON.stringify(health)}`);
|
||||
|
||||
// Phase 24 — bind HTTP listener so scenarios can POST outcomes.
|
||||
startHttpListener();
|
||||
|
||||
// Main loop
|
||||
let cycle = 0;
|
||||
while (true) {
|
||||
@@ -216,11 +314,13 @@ async function main() {
|
||||
await consolidatePlaybooks();
|
||||
}
|
||||
|
||||
const scenarioOps = recentOps.filter(o => o.source === "scenario").length;
|
||||
const stats = {
|
||||
cycle,
|
||||
total_ops: recentOps.length,
|
||||
successes: recentOps.filter(o => o.success).length,
|
||||
failures: recentOps.filter(o => !o.success).length,
|
||||
scenario_ops: scenarioOps,
|
||||
};
|
||||
console.error(`[observer] cycle ${cycle}: ${JSON.stringify(stats)}`);
|
||||
}
|
||||
|
||||
+100
-16
@@ -123,10 +123,11 @@ table.plain tr:hover td{background:#0d1117}
|
||||
<tr><td class="mono">crates/aibridge/</td><td>Rust ↔ Python sidecar. HTTP client over FastAPI wrapper around Ollama. VRAM introspection via nvidia-smi. All LLM calls (embed, generate, rerank) flow through here.</td></tr>
|
||||
<tr><td class="mono">crates/gateway/</td><td>Axum HTTP (:3100) + gRPC (:3101). Auth middleware, tools registry (Phase 12 — governed actions), CORS. Every external request enters here.</td></tr>
|
||||
<tr><td class="mono">crates/ui/</td><td>Dioxus WASM developer UI. Internal tool. Not exposed externally.</td></tr>
|
||||
<tr><td class="mono">mcp-server/</td><td>Bun/TypeScript recruiter-facing app. Serves <code>devop.live/lakehouse</code>. Routes: <code>/search /match /log /log_failure /clients/:c/blacklist /intelligence/*</code>. Proxies to the Rust gateway for heavy work.</td></tr>
|
||||
<tr><td class="mono">tests/multi-agent/</td><td>Dual-agent scenario harness. <code>agent.ts</code> (prompts + protocol), <code>orchestrator.ts</code> (single task), <code>scenario.ts</code> (5-event warehouse week), <code>run_e2e_rated.ts</code> (parallel pairs + rating), <code>chain_of_custody.ts</code> (layer-by-layer audit).</td></tr>
|
||||
<tr><td class="mono">mcp-server/</td><td>Bun/TypeScript recruiter-facing app. Serves <code>devop.live/lakehouse</code>. Routes: <code>/search /match /log /log_failure /clients/:c/blacklist /intelligence/* /memory/query /models/matrix /system/summary</code>. Observer sibling at <code>observer.ts</code> with HTTP listener on :3800 for scenario event ingest. Proxies to the Rust gateway for heavy work.</td></tr>
|
||||
<tr><td class="mono">tests/multi-agent/</td><td>Dual-agent scenario harness + memory stack. <code>agent.ts</code> (prompts, continuation + tree-split primitives, cloud routing), <code>orchestrator.ts</code>, <code>scenario.ts</code> (contracts + staffer + tool_level), <code>kb.ts</code> (KB indexing, competence scoring, neighbor retrieval), <code>normalize.ts</code> (input normalizer — structured / regex / LLM), <code>memory_query.ts</code> (unified /memory/query), <code>gen_scenarios.ts</code> + <code>gen_staffer_demo.ts</code> (corpus generators), <code>run_e2e_rated.ts</code>, <code>chain_of_custody.ts</code>. Unit tests colocated (<code>kb.test.ts</code>, <code>normalize.test.ts</code>).</td></tr>
|
||||
<tr><td class="mono">config/</td><td><code>models.json</code> — authoritative 5-tier model matrix (T1 hot local / T2 review local / T3 overview cloud / T4 strategic / T5 gatekeeper). Per-tier context_window + context_budget + overflow_policy. Read at runtime by scenario.ts; hot-swap friendly.</td></tr>
|
||||
<tr><td class="mono">docs/</td><td><code>PRD.md</code>, <code>PHASES.md</code>, <code>DECISIONS.md</code> (20 ADRs). Every significant architectural choice has an ADR with the alternatives that were rejected and why.</td></tr>
|
||||
<tr><td class="mono">data/</td><td>Default local object store. Parquet files per dataset, append-log batches, HNSW trial journals, promotion registries, playbook_memory state.json, catalog manifests. Rebuildable from repo + this dir alone.</td></tr>
|
||||
<tr><td class="mono">data/</td><td>Default local object store. Parquet files per dataset, append-log batches, HNSW trial journals, promotion registries, <code>_playbook_memory/state.json</code> (now with retirement fields — Phase 25), catalog manifests. Plus four learning-loop directories: <code>_kb/</code> (signatures, outcomes, recommendations, error_corrections, config_snapshots, staffers), <code>_playbook_lessons/</code> (T3 cross-day lessons archived per run), <code>_observer/ops.jsonl</code> (append journal, durable scenario outcome stream), <code>_chunk_cache/</code> (spec'd for Phase 21 Rust port). Rebuildable from repo + this dir alone.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -197,7 +198,33 @@ table.plain tr:hover td{background:#0d1117}
|
||||
<li>Search via <code>POST /vectors/profile/<id>/search</code> rejects out-of-scope queries with 403 + list of allowed bindings</li>
|
||||
<li>Ollama swaps to the profile's model via <code>keep_alive=0</code>; only one model in VRAM at a time</li>
|
||||
</ul>
|
||||
<div class="ref"><strong>Code:</strong> crates/vectord/src/{hnsw.rs, autotune.rs, agent.rs, promotion.rs} · ADR-019</div>
|
||||
|
||||
<h3>Model matrix (Phase 20)</h3>
|
||||
<p>Five tiers declared in <code>config/models.json</code>. Each call site picks the tier appropriate to its purpose — hot-path JSON emitters get fast local, overview/strategic/gatekeeper decisions get thinking models on cloud. Every tier carries <code>context_window</code>, <code>context_budget</code>, and <code>overflow_policy</code>.</p>
|
||||
<table class="plain">
|
||||
<thead><tr><th>Tier</th><th>Purpose</th><th>Primary model</th><th>Frequency</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>T1 hot</td><td>Per tool call — SQL gen, hybrid_search, propose_done</td><td><code>qwen3.5:latest</code> local, <code>think:false</code></td><td>50-200/scenario</td></tr>
|
||||
<tr><td>T2 review</td><td>Per-step consensus, drift flagging</td><td><code>qwen3:latest</code> local, <code>think:false</code></td><td>5-14/event</td></tr>
|
||||
<tr><td>T3 overview</td><td>Mid-day checkpoints + cross-day lesson distill</td><td><code>gpt-oss:120b</code> Ollama Cloud, thinking on</td><td>1-3/scenario</td></tr>
|
||||
<tr><td>T4 strategic</td><td>Pattern re-ranking, weekly gap audit</td><td><code>qwen3.5:397b</code> cloud</td><td>1-10/day</td></tr>
|
||||
<tr><td>T5 gatekeeper</td><td>Schema migrations, autotune config changes</td><td><code>kimi-k2-thinking</code> cloud, audit-logged</td><td>1-5/day</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><strong>Key mechanical finding (2026-04-21):</strong> qwen3.5 and qwen3 are <em>thinking</em> models — they burn ~650 tokens of hidden reasoning before emitting the visible response. For hot-path JSON emitters this meant 400-token budgets returned empty strings. Fix: <code>think: false</code> plumbed through sidecar's <code>/generate</code> endpoint; hot path disables thinking (structure matters more than reasoning depth), overseer tiers keep it on. Mistral was dropped entirely after a 0/14 fill rate on complex scenarios (decoder-level malformed-JSON bug, not a prompt issue).</p>
|
||||
<p><strong>Continuation primitive (Phase 21):</strong> <code>generateContinuable()</code> handles output-overflow without <code>max_tokens</code> tourniquets — empty response → geometric backoff retry; truncated-JSON → continue with partial as scratchpad. <code>generateTreeSplit()</code> handles input-overflow via map-reduce with running scratchpad. Both respect <code>assertContextBudget()</code> so silent truncation can't happen.</p>
|
||||
|
||||
<h3>Per-staffer tool_level (Phase 23)</h3>
|
||||
<p>Scenarios can be scoped to a specific coordinator (<code>staffer: {id, name, tenure_months, role, tool_level}</code>). <code>tool_level</code> controls which tiers are available:</p>
|
||||
<ul>
|
||||
<li><code>full</code> — T1/T2 local, T3 cloud, cloud rescue on failure</li>
|
||||
<li><code>local</code> — T1/T2/T3 all local (gpt-oss:20b as overseer)</li>
|
||||
<li><code>basic</code> — kimi-k2.5 cloud executor + local reviewer + local T3, no rescue</li>
|
||||
<li><code>minimal</code> — kimi-k2.5 cloud executor, no T3, no rescue. Playbook inheritance is the only signal.</li>
|
||||
</ul>
|
||||
<p>Measured 2026-04-21 on a 36-run demo (4 staffers × 3 contracts × 3 rounds): James Park (mid, local tools) ranked first at 92.9% fill and 36.8 cites/run, Maria Chen (senior, full tools) second at 81.0%. Cloud T3 adds latency without measurable benefit on this workload. Alex Rivera (trainee, minimal) still hit 59.5% fill purely from playbook inheritance — proof the memory carries knowledge when the model is capable.</p>
|
||||
|
||||
<div class="ref"><strong>Code:</strong> crates/vectord/src/{hnsw.rs, autotune.rs, agent.rs, promotion.rs} · tests/multi-agent/{agent.ts, scenario.ts} · config/models.json · ADR-019</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 4. CONTRACT INFERENCE ═══ -->
|
||||
@@ -251,19 +278,59 @@ table.plain tr:hover td{background:#0d1117}
|
||||
<div class="chapter" id="ch6">
|
||||
<div class="num">Chapter 6</div>
|
||||
<h2>How it gets better over time</h2>
|
||||
<div class="lede">Compounding learning in three paths — all three happen automatically, no operator intervention required.</div>
|
||||
<div class="lede">Compounding learning across seven paths. The first three are automatic background loops. Paths 4-7 landed 2026-04-21 and turn the system into a reinforcement-learning pipeline: outcomes → knowledge base → pathway recommendations → cloud rescue → competence-weighted retrieval → observer analysis. All seven happen without operator intervention.</div>
|
||||
|
||||
<h3>Path 1 — Playbook boost (Phase 19)</h3>
|
||||
<p>Every sealed fill is seeded to <code>playbook_memory</code> via <code>/vectors/playbook_memory/seed</code>. The next hybrid query for a semantically similar role+geo surfaces the past endorsed workers with a boost. Math:</p>
|
||||
<h3>Path 1 — Playbook boost with geo + role prefilter (Phase 19 + refinement)</h3>
|
||||
<p>Every sealed fill is seeded to <code>playbook_memory</code>. The boost fires inside <code>/vectors/hybrid</code> when <code>use_playbook_memory: true</code>. Math, tightened 2026-04-21 after a diagnostic pass found globally-ranked playbooks were missing the SQL-filtered candidate pool entirely:</p>
|
||||
<pre>per_worker = cosine(query_emb, playbook_emb) × 0.5 × e^(-age/30) × 0.5^failures / n_workers
|
||||
boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
|
||||
<p>Caps, decay, and negative signal mean one popular worker can't dominate, old playbooks fade, and no-shows stop boosting. Verified live: 3 identical seeds → +0.250 boost capped, 3 citations.</p>
|
||||
<p><strong>Multi-strategy retrieval (new):</strong> before cosine, <code>compute_boost_for_filtered_with_role(target_geo, target_role)</code> prefilters to same-city playbooks, then gives exact (role, city, state) matches similarity=1.0 and fills up to half the top-k. Cosine fills the rest. Mirrors 2026 Mem0/Zep guidance on parallel-strategy rerank.</p>
|
||||
<p><strong>Measured lift:</strong> before geo-filter, Nashville Welder query returned <code>boosts=170 matched=0</code> (zero intersection with candidate pool). After: <code>boosts=36 matched=11</code>. On the Riverfront Steel scenario, total playbook citations went from 2 → 28 per run — a 14× delta on identical inputs. The diagnostic log <code>playbook_boost: boosts=N sources=N parsed=N matched=N target_geo=? target_role=?</code> runs on every hybrid call so the class of silent-miss bug stays visible.</p>
|
||||
|
||||
<h3>Path 2 — Pattern discovery (meta-index)</h3>
|
||||
<p><code>/vectors/playbook_memory/patterns</code> goes beyond "who was endorsed" to answer "what did past similar fills have in common?" Aggregates recurring certifications, skills, archetype, reliability distribution across the top-K semantically similar playbooks. Surfaces signal the operator didn't explicitly query for.</p>
|
||||
|
||||
<h3>Path 3 — Autotune agent</h3>
|
||||
<p>The <code>vectord::agent</code> background task runs continuously. Watches the HNSW trial journal, proposes configs, executes trials, promotes Pareto winners — without human intervention. Operator sees "the index got faster overnight" and doesn't know why. The journal knows why.</p>
|
||||
|
||||
<h3>Path 4 — Knowledge Base + pathway recommender (Phase 22)</h3>
|
||||
<p>Meta-layer over playbook_memory. Files under <code>data/_kb/</code>:</p>
|
||||
<ul>
|
||||
<li><code>signatures.jsonl</code> — sig_hash + embedding of every run's event shape</li>
|
||||
<li><code>outcomes.jsonl</code> — per-run summary (models, fill rate, turns, citations, rescue stats, staffer, elapsed)</li>
|
||||
<li><code>pathway_recommendations.jsonl</code> — AI-synthesized advice for the next run of a similar sig</li>
|
||||
<li><code>error_corrections.jsonl</code> — fail→succeed deltas on the same sig</li>
|
||||
<li><code>config_snapshots.jsonl</code> — hash of active models + tool_level at each run</li>
|
||||
</ul>
|
||||
<p>Cycle: scenario ends → <code>kb.indexRun()</code> appends outcome → <code>kb.recommendFor(nextSpec)</code> finds k-NN signatures, feeds outcome history to an overview model, writes structured JSON advice → next scenario reads it via <code>kb.loadRecommendation(spec)</code> and injects <code>pathway_notes</code> into the executor's context alongside prior T3 lessons.</p>
|
||||
|
||||
<h3>Path 5 — Cloud rescue on failure (Phase 22 item B)</h3>
|
||||
<p>When an event fails (drift abort, JSON parse, pool exhaustion) and cloud T3 is enabled, <code>requestCloudRemediation()</code> feeds the full failure trace — SQL filters attempted, row counts, reviewer drift notes, gap signals, contract terms — to <code>gpt-oss:120b</code> on Ollama Cloud. Cloud returns structured <code>{retry, new_city, new_state, new_role, new_count, rationale}</code>. Event retries once with the pivot. Verified on stress_01: Gary IN (zero workers indexed) misplacement → cloud proposed South Bend IN → retry filled 1/1.</p>
|
||||
|
||||
<h3>Path 6 — Staffer competence-weighted retrieval (Phase 23)</h3>
|
||||
<p>Answers "who handled this" as a first-class matrix-index dimension. Each scenario carries <code>staffer: {id, name, tenure_months, role, tool_level}</code>. After every run, <code>recomputeStafferStats(staffer_id)</code> aggregates their fill_rate, turn efficiency, citation density, rescue rate into a single <code>competence_score</code> (0.45·fill + 0.20·turn_eff + 0.20·cites + 0.15·rescue).</p>
|
||||
<p><code>findNeighbors</code> returns <code>weighted_score = cosine × max_staffer_competence</code> — top-performer playbooks rank above juniors' on similar scenarios. Auto-discovery emerges: running 4 staffers × 3 contracts × 3 rounds surfaced Rachel D. Lewis (Welder Nashville) with 18 endorsements across all 4 staffers, Angela U. Ward (Machine Op Indianapolis) with 19 — reliable-performer labels the system built without human tagging.</p>
|
||||
|
||||
<h3>Path 7 — Observer outcome ingest (Phase 24)</h3>
|
||||
<p>Observer runs as <code>lakehouse-observer.service</code>, now with an HTTP listener on <code>:3800</code>. Scenarios POST per-event outcomes to <code>/event</code> with full provenance (staffer_id, sig_hash, event_kind, role, city, state, rescue flags). Observer's ERROR_ANALYZER and PLAYBOOK_BUILDER loops consume them alongside MCP-wrapped ops. Persistence switched from the old <code>/ingest/file</code> REPLACE path to an append-only <code>data/_observer/ops.jsonl</code> journal so the trace survives across restarts.</p>
|
||||
|
||||
<h3>Input normalizer + unified memory query</h3>
|
||||
<p>Two surfaces added 2026-04-21 to make the memory stack respond coherently to any input shape:</p>
|
||||
<ul>
|
||||
<li><code>normalizeInput(raw)</code> — accepts structured JSON, natural language, or mixed. Three-tier: structured fast-path → regex (handles "need 3 welders in Nashville, TN" in 0ms without an LLM call) → qwen3 LLM fallback for low-signal inputs.</li>
|
||||
<li><code>POST /memory/query</code> — one endpoint, returns every memory surface in a single bundle: <code>playbook_workers</code> (with boost + citations), <code>pathway_recommendation</code> (KB), <code>neighbor_signatures</code> (competence-weighted), <code>prior_lessons</code> (T3 overseer history), <code>top_staffers</code> (leaderboard), <code>discovered_patterns</code> (auto-surfaced reliable workers for this role+city), <code>latency_ms</code> (per-source). Natural-language query end-to-end: 319ms.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Honest gaps — what we can still implement</h3>
|
||||
<p>Three of the five 2026-era memory findings remain unwired. Flagged for near-term implementation, not hidden:</p>
|
||||
<ul>
|
||||
<li><strong>Zep-style validity windows</strong> — playbook entries have timestamps but no <code>valid_until</code> or <code>schema_fingerprint</code>. Load-bearing: when a schema migration changes a column, stale playbooks silently keep boosting. Biggest-value remaining fix.</li>
|
||||
<li><strong>Mem0-style UPDATE / DELETE / NOOP ops</strong> — <code>/seed</code> only ADDs. Same (operation, date) pair appends a duplicate instead of refining an existing entry. Playbook file grows faster than necessary.</li>
|
||||
<li><strong>Letta working-memory hot cache</strong> — every query scans all 1560 playbook entries from disk. Cheap today at 1.5K, not at 100K. Solution: LRU in-process cache for the last N playbooks or the current sig's neighborhood.</li>
|
||||
</ul>
|
||||
<p>Validity windows is next — preserves the trust signal (boost only fires on playbooks that are still true given the current schema) rather than the latency signal (which the current scale doesn't need yet).</p>
|
||||
|
||||
<div class="ref"><strong>Code:</strong> crates/vectord/src/{playbook_memory.rs, service.rs} · tests/multi-agent/{kb.ts, memory_query.ts, normalize.ts, scenario.ts} · mcp-server/{observer.ts, index.ts} · data/_kb/ · data/_observer/</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 7. SCALE STORY ═══ -->
|
||||
@@ -297,7 +364,7 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
|
||||
<li><strong>Ollama inference is serial.</strong> Embedding 1M rows at ~50 chunks/sec through nomic-embed-text = ~6 hours. Acceptable for overnight refresh, not for "immediate." Mitigated by incremental refresh (only deltas).</li>
|
||||
<li><strong>RAM ceiling on HNSW.</strong> Around 5M vectors × 768d, HNSW stops fitting in 128GB comfortably. Mitigation: per-profile <code>vector_backend: lance</code> flip — disk-resident IVF_PQ scales past the RAM line (ADR-019).</li>
|
||||
<li><strong>VRAM ceiling for model variety.</strong> A4000 16GB holds 1-2 loaded models. Multi-model recruiter surfaces are a sequential swap, not parallel (Ollama <code>keep_alive=0</code>). Phase 17 profile activation unloads the prior model on swap.</li>
|
||||
<li><strong>playbook_memory growth.</strong> Currently unbounded. 391 entries today at this rate becomes ~5K in six months. Default k=200 still sub-ms at 5K. Compaction policy (TTL + decay + merge) deferred.</li>
|
||||
<li><strong>playbook_memory growth.</strong> 1936 entries today. Phase 25 (2026-04-21) added retirement via <code>valid_until</code> + <code>schema_fingerprint</code> fields + <code>POST /vectors/playbook_memory/retire</code> endpoint (manual or schema-drift triggered). Active vs retired split surfaced on <code>GET /vectors/playbook_memory/status</code>. Brute-force cosine still sub-ms at current size; Letta-style working-memory hot cache deferred until entry count crosses ~100K.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -320,6 +387,10 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
|
||||
<tr><td>Ollama sidecar down</td><td>502 Bad Gateway from aibridge; <code>embed</code> calls fail fast</td><td>Restart: <code>systemctl restart lakehouse-sidecar</code>; vector search falls back to pre-computed embeddings</td></tr>
|
||||
<tr><td>Gateway restart mid-operation</td><td>In-memory state (playbook_memory, HNSW) reloaded from persisted <code>state.json</code> / trial journals</td><td>Zero data loss; catalog, storage, journals are all source-of-truth</td></tr>
|
||||
<tr><td>Schema fingerprint diverges across manifests</td><td><code>catalog::dedupe</code> reports <code>DedupeReport</code> with winner selection (non-null row_count first, then newest updated_at)</td><td><code>POST /catalog/dedupe</code> collapses duplicates idempotently</td></tr>
|
||||
<tr><td>Scenario event fails on zero-supply city</td><td>Cloud rescue (Phase 22 item B) fires — <code>gpt-oss:120b</code> sees SQL filters attempted, row counts, reviewer drift notes, contract terms; returns structured <code>{retry, new_city, new_state, new_role, new_count, rationale}</code></td><td>Retry with pivot runs same executor loop with new geography; verified Gary IN → South Bend IN filled 1/1 after original drift-abort</td></tr>
|
||||
<tr><td>LLM response truncated mid-JSON (thinking model ate token budget)</td><td>Phase 21 <code>generateContinuable()</code> detects via brace-balance + JSON.parse; no silent truncation</td><td>Auto-continue with partial as scratchpad, or geometric backoff if initial call returned empty. Bounded by <code>max_continuations</code>.</td></tr>
|
||||
<tr><td>Schema migration invalidates existing playbooks</td><td>Phase 25 — <code>POST /vectors/playbook_memory/retire</code> with current fingerprint retires all mismatched entries in scope; diagnostic log shows counts</td><td>Retired entries stay in journal for forensics but are skipped by all boost calculations. Scoped by (city, state) so unrelated geos aren't touched.</td></tr>
|
||||
<tr><td>Observer fails to reach scenario outcome stream</td><td>Scenario <code>postObserverEvent()</code> uses 2s <code>AbortSignal.timeout</code>; silent skip if :3800 is down</td><td>Scenario log is still the source of truth; observer re-ingest on next run restores the stream. <code>data/_observer/ops.jsonl</code> is append-only so prior events survive.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -344,6 +415,15 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
|
||||
|
||||
<h3>Daily summary per staffer</h3>
|
||||
<p>Workspace activity log + per-staffer filter on the event journal gives <strong>"what did Sarah do today"</strong> as a direct query. The foundation for shift-handoff reports.</p>
|
||||
|
||||
<h3>Staffer identity + competence-weighted retrieval (Phase 23)</h3>
|
||||
<p>Each scenario run carries an explicit <code>staffer: {id, name, tenure_months, role, tool_level}</code>. The KB aggregates per-staffer stats (<code>data/_kb/staffers.jsonl</code>) that roll up into a single <code>competence_score</code>:</p>
|
||||
<pre>competence_score = 0.45·fill_rate + 0.20·turn_efficiency + 0.20·citation_density + 0.15·rescue_rate</pre>
|
||||
<p>When any query runs <code>kb.findNeighbors(spec, k)</code>, the ranking isn't just cosine similarity — it's <code>weighted_score = cosine × max_staffer_competence</code> over the best coordinator who ran that signature. Senior staffers' playbooks surface above juniors' on similar scenarios, even when the juniors' scenario was marginally closer in embedding space.</p>
|
||||
<p>The <code>tool_level</code> knob (full / local / basic / minimal) controls which tiers are available to a given staffer's runs. See Ch 3 for the mapping. Variance is real and measurable: 36-run demo produced a 46pt fill-rate delta between James (local tools, 93%) and Alex (minimal tools, 60%) on identical contracts.</p>
|
||||
|
||||
<h3>Auto-discovered reliable-performer labels</h3>
|
||||
<p>A second-order output of the competence path: when multiple staffers independently endorse the same worker on similar-signature playbooks, that worker accumulates cross-staffer endorsements. <code>scripts/kb_staffer_report.py</code> surfaces them — after 36 runs, Rachel D. Lewis (Welder Nashville) had 18 endorsements across 4 staffers, Angela U. Ward (Machine Op Indianapolis) 19. These are high-confidence "reliable" labels the system produced without human tagging. The UI could badge these workers on future queries; today they're visible via <code>/memory/query</code>'s <code>discovered_patterns</code> bundle.</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 10. A DAY IN THE LIFE ═══ -->
|
||||
@@ -370,9 +450,11 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
|
||||
|
||||
<div class="step"><div class="n">15:00</div><div class="body"><strong>New embeddings live.</strong> Hot-swap promotion. Searches now see all 1M new profiles. Sarah's noon query re-run would produce different top-5.</div></div>
|
||||
|
||||
<div class="step"><div class="n">17:00</div><div class="body"><strong>End-of-day retrospective.</strong> Any staffer who ran <code>tests/multi-agent/scenario.ts</code> gets <code>report.md</code> auto-generated. Workspace activity logs aggregate per staffer. <code>GET /vectors/playbook_memory/stats</code> shows the day's new entries.</div></div>
|
||||
<div class="step"><div class="n">17:00</div><div class="body"><strong>End-of-day retrospective.</strong> Any staffer who ran <code>tests/multi-agent/scenario.ts</code> gets <code>report.md</code> auto-generated. Workspace activity logs aggregate per staffer. <code>GET /vectors/playbook_memory/status</code> shows active vs retired counts. KB indexes the run (<code>kb.indexRun</code>) and the overview model synthesizes a pathway recommendation for the next matching signature. Every event outcome has already streamed to <code>lakehouse-observer.service</code> on :3800 for ERROR_ANALYZER + PLAYBOOK_BUILDER consumption.</div></div>
|
||||
|
||||
<div class="step"><div class="n">22:00</div><div class="body"><strong>Overnight trial cycle.</strong> Autotune agent continues in the background. Trial journal grows. Tomorrow morning, the system is measurably better at something it got asked about today.</div></div>
|
||||
<div class="step"><div class="n">17:15</div><div class="body"><strong>Kim fires a natural-language query from the search box.</strong> "need 3 forklift operators in Joliet by Monday" → <code>POST /memory/query</code> on the Bun MCP. Regex normalizer extracts role / city / count / deadline / intent in 0ms (no LLM call). The unified response returns playbook workers (auto-surfaced reliable performers for Joliet Forklift with citation counts), pathway recommendation from KB, prior T3 lessons for Joliet, and top staffers by competence — all in ~300ms.</div></div>
|
||||
|
||||
<div class="step"><div class="n">22:00</div><div class="body"><strong>Overnight trial cycle.</strong> Autotune agent continues in the background. Trial journal grows. KB's <code>detectErrorCorrections</code> scans today's outcomes for fail→succeed deltas on the same signature; any correction gets logged to <code>data/_kb/error_corrections.jsonl</code> with the config diff. Tomorrow morning, the system is measurably better at something it got asked about today.</div></div>
|
||||
|
||||
<h3>SMS + email drafts in the pipeline</h3>
|
||||
<p>After each sealed fill (via scenario.ts or manual <code>/log</code> flow with downstream hooks), <code>generateArtifacts</code> in the scenario runner produces: (a) one SMS per worker (TO: Name, message under 180 chars), (b) one client confirmation email. Drafts are saved to <code>sms.md</code> and <code>emails.md</code> under the scenario output dir. Ollama drafts them; the staffer reviews and sends. No auto-send; human-in-the-loop.</p>
|
||||
@@ -386,11 +468,13 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
|
||||
|
||||
<h4>Deferred — real architectural work, just not shipped yet</h4>
|
||||
<ul>
|
||||
<li><strong>Rate / margin awareness.</strong> Worker pay expectations vs contract bill rate not modeled. Requires adding <code>pay_rate</code> to workers, <code>bill_rate</code> to contracts, and a filter + warning path. Phase 20 item.</li>
|
||||
<li><strong>Push / background presence.</strong> The app requires being opened. No Slack/email/SMS push when a contract lands with a pre-ranked candidate list. Would make the "system is already thinking" claim more visible to phone-first shops.</li>
|
||||
<li><strong>Rate / margin awareness.</strong> Worker pay expectations vs contract bill rate not modeled. Requires adding <code>pay_rate</code> to workers, <code>bill_rate</code> to contracts, and a filter + warning path. Partially addressed via <code>ContractTerms.budget_per_hour_max</code> passed to T3/rescue prompts, but the match-time filter isn't wired yet.</li>
|
||||
<li><strong>Mem0-style UPDATE / DELETE / NOOP operations on playbooks.</strong> Today <code>/seed</code> only ADDs. Same <code>(operation, date)</code> pair appends a duplicate instead of refining an existing entry. Phase 26 item — cheap to add, moderate payoff.</li>
|
||||
<li><strong>Letta working-memory hot cache.</strong> Every boost query scans all active playbook entries from in-memory state. 1.9K today; cheap. Will bite somewhere north of 100K. LRU for the last-N playbooks or current-sig neighborhood deferred until that ceiling approaches.</li>
|
||||
<li><strong>Chunking cache (Phase 21 Rust port).</strong> TS primitives <code>generateContinuable</code> + <code>generateTreeSplit</code> are wired, but <code>crates/aibridge/src/{continuation.rs, tree_split.rs}</code> + <code>crates/storaged/src/chunk_cache.rs</code> remain queued. Gateway-side callers currently don't have the same protection against silent truncation that the TS test harness does.</li>
|
||||
<li><strong>Confidence calibration.</strong> Top-K is a rank, not a probability. No calibrated "85% likely to accept" score. Requires outcome-labeled training data.</li>
|
||||
<li><strong>Neural re-ranker.</strong> Phase 19 is statistical + semantic. A (query, candidate, outcome)-trained re-ranker is deferred to Phase 20+ per ADR, only if the statistical floor plateaus below usable recall.</li>
|
||||
<li><strong>playbook_memory compaction.</strong> No TTL or merge policy. Entries accumulate. At expected rate this hits 10K in a year — still tractable but warrants a policy.</li>
|
||||
<li><strong>Neural re-ranker.</strong> Phase 19 is statistical + semantic (now with geo + role prefilter, Phase 25 retirement). A (query, candidate, outcome)-trained re-ranker is deferred only if the statistical floor plateaus below usable recall — current 14× citation lift on identical inputs suggests it hasn't.</li>
|
||||
<li><strong>Observer → autotune feedback wire.</strong> Phase 24 streams scenario outcomes into <code>data/_observer/ops.jsonl</code>; autotune agent still runs on its own HNSW-trial schedule and hasn't subscribed to the outcome metric stream yet. Phase 26+ item — connects the last loop.</li>
|
||||
<li><strong>call_log cross-reference.</strong> Infrastructure present; current synthetic candidates table is too small to cross-ref. Fixes when real ATS lands.</li>
|
||||
</ul>
|
||||
|
||||
@@ -412,6 +496,6 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">Lakehouse spec · v1 2026-04-20 · maintained from <code>docs/DECISIONS.md</code> · <a href="proof">architecture live-tested</a> · <a href="console">walkthrough</a></div>
|
||||
<div class="footer">Lakehouse spec · v2 2026-04-21 · Phases 19-25 shipped (playbook boost, model matrix, continuation, KB, staffer competence, observer ingest, validity windows) · maintained from <code>docs/DECISIONS.md</code> · <a href="proof">architecture live-tested</a> · <a href="console">walkthrough</a></div>
|
||||
|
||||
</body></html>
|
||||
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase 23 staffer leaderboard + cross-staffer pattern finder.
|
||||
|
||||
Reads data/_kb/staffers.jsonl + outcomes.jsonl + signatures.jsonl and
|
||||
emits:
|
||||
- Leaderboard sorted by competence_score
|
||||
- Per-staffer breakdown (fill rate, turns, citations, rescue rate)
|
||||
- Cross-staffer common workers: names endorsed by multiple top
|
||||
staffers on similar signatures — candidates for "auto-discovered
|
||||
high-value worker" labels.
|
||||
|
||||
Run after scripts/run_staffer_demo.sh completes.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from collections import defaultdict, Counter
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
KB = ROOT / "data" / "_kb"
|
||||
PLAYBOOKS = ROOT / "tests" / "multi-agent" / "playbooks"
|
||||
|
||||
|
||||
def load_jsonl(p):
|
||||
if not p.exists():
|
||||
return []
|
||||
out = []
|
||||
for line in p.read_text().splitlines():
|
||||
if line.strip():
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
staffers = load_jsonl(KB / "staffers.jsonl")
|
||||
outcomes = load_jsonl(KB / "outcomes.jsonl")
|
||||
|
||||
if not staffers:
|
||||
print("(no staffer stats yet — run scripts/run_staffer_demo.sh first)")
|
||||
return
|
||||
|
||||
staffers.sort(key=lambda s: -s["competence_score"])
|
||||
|
||||
print("=== STAFFER LEADERBOARD ===")
|
||||
print(f"{'rank':4s} {'id':7s} {'name':16s} {'role':8s} {'mo':>4s} {'runs':>4s} {'fill':>6s} {'turns':>6s} {'cites':>6s} {'rescue':>7s} {'score':>6s}")
|
||||
print("-" * 100)
|
||||
for i, s in enumerate(staffers):
|
||||
rank_mark = "★" if i == 0 else " "
|
||||
print(f"{rank_mark}{i+1:3d} {s['id']:7s} {s['name']:16s} {s['role']:8s} {s['tenure_months']:>4d} {s['total_runs']:>4d} {s['fill_rate']*100:>5.1f}% {s['avg_turns_per_event']:>6.1f} {s['avg_citations_per_run']:>6.2f} {s['rescue_rate']*100:>6.1f}% {s['competence_score']:>6.3f}")
|
||||
print()
|
||||
|
||||
# Cross-staffer pattern — workers endorsed on the same sig_hash
|
||||
# across multiple staffers. Auto-discovered "reliable performers".
|
||||
print("=== CROSS-STAFFER WORKER OVERLAP ===")
|
||||
print("(workers endorsed on same sig_hash by ≥2 staffers)")
|
||||
worker_touches = defaultdict(lambda: {"staffers": set(), "sigs": set(), "endorsements": 0})
|
||||
for o in outcomes:
|
||||
run_dir = PLAYBOOKS / o["run_id"]
|
||||
results_file = run_dir / "results.json"
|
||||
if not results_file.exists():
|
||||
continue
|
||||
try:
|
||||
results = json.loads(results_file.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
staffer_id = o.get("staffer", {}).get("id")
|
||||
if not staffer_id:
|
||||
continue
|
||||
for r in results:
|
||||
if not r.get("ok"):
|
||||
continue
|
||||
for f in r.get("fills", []):
|
||||
name = f.get("name")
|
||||
if not name or name.startswith("Candidate "):
|
||||
continue
|
||||
key = (name, r["event"]["role"], r["event"]["city"], r["event"]["state"])
|
||||
worker_touches[key]["staffers"].add(staffer_id)
|
||||
worker_touches[key]["sigs"].add(o["sig_hash"])
|
||||
worker_touches[key]["endorsements"] += 1
|
||||
shared = sorted(
|
||||
[(k, v) for k, v in worker_touches.items() if len(v["staffers"]) >= 2],
|
||||
key=lambda x: -x[1]["endorsements"],
|
||||
)
|
||||
if shared:
|
||||
for (name, role, city, state), v in shared[:15]:
|
||||
print(f" {name:25s} {role:22s} {city:15s} {state}: {v['endorsements']} endorsements across {len(v['staffers'])} staffers")
|
||||
else:
|
||||
print(" (none yet — needs ≥2 staffers on overlapping scenarios)")
|
||||
print()
|
||||
|
||||
# Competence differential — how much does top vs bottom differ?
|
||||
if len(staffers) >= 2:
|
||||
top = staffers[0]
|
||||
bot = staffers[-1]
|
||||
print(f"=== TOP vs BOTTOM DIFFERENTIAL ===")
|
||||
print(f"{top['name']} (top) vs {bot['name']} (bottom):")
|
||||
print(f" fill rate: {top['fill_rate']*100:.1f}% vs {bot['fill_rate']*100:.1f}% (Δ {(top['fill_rate']-bot['fill_rate'])*100:+.1f}pt)")
|
||||
print(f" avg turns: {top['avg_turns_per_event']:.1f} vs {bot['avg_turns_per_event']:.1f} (Δ {top['avg_turns_per_event']-bot['avg_turns_per_event']:+.1f})")
|
||||
print(f" avg citations: {top['avg_citations_per_run']:.2f} vs {bot['avg_citations_per_run']:.2f} (Δ {top['avg_citations_per_run']-bot['avg_citations_per_run']:+.2f})")
|
||||
print(f" rescue rate: {top['rescue_rate']*100:.1f}% vs {bot['rescue_rate']*100:.1f}%")
|
||||
print(f" competence: {top['competence_score']:.3f} vs {bot['competence_score']:.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the 12 staffer-demo scenarios (4 personas × 3 contracts) with
|
||||
# cloud T3 enabled. After each run, KB indexes the outcome and
|
||||
# recomputes that staffer's competence_score. By the end, the top
|
||||
# staffer's playbooks will be surfacing first in neighbor retrieval.
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
export OLLAMA_CLOUD_KEY="$(python3 -c "import json; print(json.load(open('/root/llm_team_config.json'))['providers']['ollama_cloud']['api_key'])" 2>/dev/null || echo '')"
|
||||
|
||||
MANIFEST="tests/multi-agent/scenarios/staffer_demo/manifest.json"
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "✗ no manifest — run: bun tests/multi-agent/gen_staffer_demo.ts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
START_TS=$(date -Iseconds)
|
||||
LOG_DIR="/tmp/lakehouse_staffer_demo_$(date +%s)"
|
||||
mkdir -p "$LOG_DIR"
|
||||
echo "▶ Staffer demo start: $START_TS, logs → $LOG_DIR"
|
||||
|
||||
python3 -c "
|
||||
import json
|
||||
m = json.load(open('$MANIFEST'))
|
||||
for s in m['scenarios']:
|
||||
print(s['file'], '|', s['staffer'], '|', s['contract'])
|
||||
" | while IFS='|' read -r SCEN STAFFER CONTRACT; do
|
||||
SCEN=$(echo "$SCEN" | xargs)
|
||||
STAFFER=$(echo "$STAFFER" | xargs)
|
||||
CONTRACT=$(echo "$CONTRACT" | xargs)
|
||||
SPEC="tests/multi-agent/scenarios/staffer_demo/$SCEN"
|
||||
BASE=$(basename "$SPEC" .json)
|
||||
LOG="$LOG_DIR/${BASE}.log"
|
||||
echo " ▶ $STAFFER × $CONTRACT"
|
||||
LH_OVERVIEW_CLOUD=1 bun tests/multi-agent/scenario.ts "$SPEC" > "$LOG" 2>&1 || true
|
||||
OK=$(grep -oP '\d+/\d+ events succeeded' "$LOG" | tail -1 || echo "no-result")
|
||||
RESCUES=$(grep -c "cloud rescue requested" "$LOG" || true)
|
||||
RESCUE_OK=$(grep -c "retry outcome: ✓" "$LOG" || true)
|
||||
SIG=$(grep -oP 'KB indexed: sig=\K[a-f0-9]+' "$LOG" | tail -1 || echo "-")
|
||||
echo " → $OK; rescues=$RESCUES (${RESCUE_OK} succeeded); sig=$SIG"
|
||||
done
|
||||
|
||||
echo "▶ Staffer demo done: $(date -Iseconds)"
|
||||
echo "▶ Staffer competence leaderboard:"
|
||||
python3 scripts/kb_staffer_report.py 2>/dev/null || echo "(run scripts/kb_staffer_report.py after batch completes)"
|
||||
@@ -147,7 +147,30 @@ function genSpec(rng: () => number, id: number): any {
|
||||
if (events.length > 0) e.replaces_event = events[0].at;
|
||||
events.push(e);
|
||||
}
|
||||
return { client, date, events };
|
||||
|
||||
// Contract terms — most real staffing contracts have these. 70% of
|
||||
// generated specs carry them so KB + T3 learn to reason about budget
|
||||
// and radius trade-offs, not just geography. Distributions are
|
||||
// deliberately varied: 2 week to 45 day deadlines, $22-$38/hr caps,
|
||||
// 25-150mi local radii.
|
||||
const contract = rng() > 0.3 ? (() => {
|
||||
const deadlineDays = 10 + Math.floor(rng() * 35);
|
||||
const deadlineDate = new Date(today.getTime() + (id + deadlineDays) * 86400000)
|
||||
.toISOString().split("T")[0];
|
||||
const budgetPerHour = 22 + Math.floor(rng() * 17);
|
||||
const bonusRadius = 25 + Math.floor(rng() * 125);
|
||||
const bonusPerHour = 2 + Math.floor(rng() * 5);
|
||||
const fill: "paramount" | "preferred" = rng() > 0.4 ? "paramount" : "preferred";
|
||||
return {
|
||||
deadline: deadlineDate,
|
||||
budget_per_hour_max: budgetPerHour,
|
||||
local_bonus_per_hour: bonusPerHour,
|
||||
local_bonus_radius_mi: bonusRadius,
|
||||
fill_requirement: fill,
|
||||
};
|
||||
})() : undefined;
|
||||
|
||||
return { client, date, events, ...(contract ? { contract } : {}) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Phase 23 demo — 4 staffer personas × 3 contract scenarios each.
|
||||
// Same contracts run against different staffers to measure competence
|
||||
// differential. After the batch, findNeighbors should rank top-staffer
|
||||
// playbooks above junior-staffer playbooks for similar scenarios.
|
||||
//
|
||||
// Output: 12 spec files under tests/multi-agent/scenarios/staffer_demo/
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Per-staffer tool_level mirrors the real-world asymmetry: senior gets
|
||||
// the most powerful stack, trainee gets the least. The architectural
|
||||
// question is whether the playbook inheritance is strong enough to let
|
||||
// the trainee still produce usable outcomes when the big tools are off.
|
||||
const STAFFERS = [
|
||||
{ id: "S-001", name: "Maria Chen", tenure_months: 48, role: "senior" as const, tool_level: "full" as const },
|
||||
{ id: "S-002", name: "James Park", tenure_months: 14, role: "mid" as const, tool_level: "local" as const },
|
||||
{ id: "S-003", name: "Sam Torres", tenure_months: 4, role: "junior" as const, tool_level: "basic" as const },
|
||||
{ id: "S-004", name: "Alex Rivera", tenure_months: 1, role: "trainee" as const, tool_level: "minimal" as const },
|
||||
];
|
||||
|
||||
// Three contract shapes — one downtown assembly, one warehouse ramp,
|
||||
// one emergency recovery. Different cities to vary the sig_hash.
|
||||
const CONTRACTS = [
|
||||
{
|
||||
tag: "nashville_downtown",
|
||||
client: "Riverline Logistics — Nashville Downtown Build-Out",
|
||||
city: "Nashville", state: "TN",
|
||||
contract: {
|
||||
deadline: "2026-05-19",
|
||||
budget_total_usd: 180000,
|
||||
budget_per_hour_max: 32,
|
||||
local_bonus_per_hour: 4,
|
||||
local_bonus_radius_mi: 75,
|
||||
fill_requirement: "paramount" as const,
|
||||
},
|
||||
events: [
|
||||
{ kind: "baseline_fill", at: "07:00", role: "Welder", count: 4 },
|
||||
{ kind: "expansion", at: "08:30", role: "Packaging Operator", count: 6 },
|
||||
{ kind: "baseline_fill", at: "09:00", role: "Shipping Clerk", count: 2 },
|
||||
{ kind: "emergency", at: "13:00", role: "Welder", count: 2, deadline: "15:00" },
|
||||
{ kind: "misplacement", at: "15:30", role: "Packaging Operator", count: 1, replaces_event: "08:30" },
|
||||
],
|
||||
},
|
||||
{
|
||||
tag: "joliet_warehouse",
|
||||
client: "Midway Distribution — Joliet DC Ramp",
|
||||
city: "Joliet", state: "IL",
|
||||
contract: {
|
||||
deadline: "2026-05-12",
|
||||
budget_total_usd: 120000,
|
||||
budget_per_hour_max: 28,
|
||||
local_bonus_per_hour: 3,
|
||||
local_bonus_radius_mi: 50,
|
||||
fill_requirement: "preferred" as const,
|
||||
},
|
||||
events: [
|
||||
{ kind: "baseline_fill", at: "07:00", role: "Warehouse Associate", count: 5 },
|
||||
{ kind: "recurring", at: "10:00", role: "Forklift Operator", count: 3 },
|
||||
{ kind: "expansion", at: "12:30", role: "Picker", count: 4 },
|
||||
{ kind: "misplacement", at: "15:00", role: "Forklift Operator", count: 1, replaces_event: "10:00" },
|
||||
],
|
||||
},
|
||||
{
|
||||
tag: "indianapolis_assembly",
|
||||
client: "Pioneer Assembly — Indianapolis Plant Expansion",
|
||||
city: "Indianapolis", state: "IN",
|
||||
contract: {
|
||||
deadline: "2026-05-26",
|
||||
budget_total_usd: 220000,
|
||||
budget_per_hour_max: 30,
|
||||
local_bonus_per_hour: 5,
|
||||
local_bonus_radius_mi: 60,
|
||||
fill_requirement: "paramount" as const,
|
||||
},
|
||||
events: [
|
||||
{ kind: "baseline_fill", at: "07:30", role: "Assembler", count: 6 },
|
||||
{ kind: "recurring", at: "09:30", role: "Quality Tech", count: 2 },
|
||||
{ kind: "expansion", at: "11:00", role: "Machine Operator", count: 5 },
|
||||
{ kind: "emergency", at: "14:00", role: "Machine Operator", count: 3, deadline: "16:00" },
|
||||
{ kind: "misplacement", at: "16:00", role: "Assembler", count: 1, replaces_event: "07:30" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const outDir = "tests/multi-agent/scenarios/staffer_demo";
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const manifest: Array<{ file: string; staffer: string; contract: string; client: string }> = [];
|
||||
let day = 0;
|
||||
for (const staffer of STAFFERS) {
|
||||
for (const ct of CONTRACTS) {
|
||||
day += 1;
|
||||
const date = new Date(Date.now() + day * 86400000).toISOString().split("T")[0];
|
||||
const spec = {
|
||||
client: ct.client,
|
||||
date,
|
||||
contract: ct.contract,
|
||||
staffer,
|
||||
events: ct.events.map(e => ({
|
||||
...e,
|
||||
city: ct.city,
|
||||
state: ct.state,
|
||||
shift_start: `${e.at} ${e.at.startsWith("0") ? "AM" : "PM"}`,
|
||||
scenario_note: `Staffed by ${staffer.name} (${staffer.role}, ${staffer.tenure_months}mo). Contract deadline ${ct.contract.deadline}, fill=${ct.contract.fill_requirement}.`,
|
||||
})),
|
||||
};
|
||||
const fname = `${staffer.id}_${ct.tag}.json`;
|
||||
await writeFile(join(outDir, fname), JSON.stringify(spec, null, 2));
|
||||
manifest.push({ file: fname, staffer: staffer.name, contract: ct.tag, client: ct.client });
|
||||
}
|
||||
}
|
||||
await writeFile(
|
||||
join(outDir, "manifest.json"),
|
||||
JSON.stringify({ count: manifest.length, scenarios: manifest }, null, 2),
|
||||
);
|
||||
console.log(`✓ ${manifest.length} staffer-demo specs → ${outDir}/`);
|
||||
for (const m of manifest) console.log(` ${m.file} — ${m.staffer} × ${m.contract}`);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error("gen_staffer_demo failed:", (e as Error).message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { computeSignature, specDigest } from "./kb.ts";
|
||||
|
||||
// kb signature invariants — required so the KB's retrieval layer
|
||||
// doesn't silently drift when we add fields to ScenarioSpec.
|
||||
|
||||
test("computeSignature is stable across reorderings of unrelated fields", () => {
|
||||
const a = {
|
||||
client: "Acme Corp",
|
||||
events: [
|
||||
{ kind: "baseline_fill", role: "Welder", count: 3, city: "Toledo", state: "OH" },
|
||||
],
|
||||
};
|
||||
const b = { ...a, date: "2026-05-01", contract: { deadline: "2026-05-15" } } as any;
|
||||
const c = { ...a, staffer: { id: "S-1", name: "X", tenure_months: 10, role: "senior" } } as any;
|
||||
const sigA = computeSignature(a);
|
||||
const sigB = computeSignature(b);
|
||||
const sigC = computeSignature(c);
|
||||
expect(sigA).toBe(sigB);
|
||||
expect(sigA).toBe(sigC);
|
||||
});
|
||||
|
||||
test("computeSignature changes when role changes", () => {
|
||||
const base = { client: "Acme", events: [{ kind: "baseline_fill", role: "Welder", count: 3, city: "Toledo", state: "OH" }] };
|
||||
const swapped = { client: "Acme", events: [{ kind: "baseline_fill", role: "Electrician", count: 3, city: "Toledo", state: "OH" }] };
|
||||
expect(computeSignature(base)).not.toBe(computeSignature(swapped));
|
||||
});
|
||||
|
||||
test("computeSignature changes when city or count changes", () => {
|
||||
const base = { client: "A", events: [{ kind: "baseline_fill", role: "Welder", count: 3, city: "Toledo", state: "OH" }] };
|
||||
const cityChange = { ...base, events: [{ ...base.events[0], city: "Detroit", state: "MI" }] };
|
||||
const countChange = { ...base, events: [{ ...base.events[0], count: 5 }] };
|
||||
expect(computeSignature(base)).not.toBe(computeSignature(cityChange));
|
||||
expect(computeSignature(base)).not.toBe(computeSignature(countChange));
|
||||
});
|
||||
|
||||
test("specDigest includes each event's role + city", () => {
|
||||
const spec = {
|
||||
client: "Acme",
|
||||
events: [
|
||||
{ kind: "baseline_fill", role: "Welder", count: 3, city: "Toledo", state: "OH" },
|
||||
{ kind: "emergency", role: "Loader", count: 2, city: "Chicago", state: "IL" },
|
||||
],
|
||||
};
|
||||
const digest = specDigest(spec);
|
||||
expect(digest).toContain("Acme");
|
||||
expect(digest).toContain("Welder");
|
||||
expect(digest).toContain("Toledo,OH");
|
||||
expect(digest).toContain("Loader");
|
||||
expect(digest).toContain("Chicago,IL");
|
||||
});
|
||||
+158
-6
@@ -34,6 +34,7 @@ const OUTCOMES_FILE = "outcomes.jsonl";
|
||||
const CONFIG_SNAPSHOTS_FILE = "config_snapshots.jsonl";
|
||||
const ERROR_CORRECTIONS_FILE = "error_corrections.jsonl";
|
||||
const RECOMMENDATIONS_FILE = "pathway_recommendations.jsonl";
|
||||
const STAFFERS_FILE = "staffers.jsonl";
|
||||
|
||||
// What a playbook signature looks like — stable hash of the scenario
|
||||
// shape that ignores timestamps and specific worker IDs. Two runs with
|
||||
@@ -60,11 +61,19 @@ export interface RunOutcome {
|
||||
overview: string;
|
||||
overview_cloud: boolean;
|
||||
};
|
||||
staffer?: { // Phase 23 — who ran this scenario
|
||||
id: string;
|
||||
name: string;
|
||||
tenure_months: number;
|
||||
role: string;
|
||||
};
|
||||
ok_events: number;
|
||||
total_events: number;
|
||||
total_turns: number;
|
||||
total_gap_signals: number;
|
||||
total_citations: number;
|
||||
rescue_attempts: number; // Phase 22 item B
|
||||
rescue_successes: number;
|
||||
per_event: Array<{
|
||||
at: string;
|
||||
kind: string;
|
||||
@@ -78,6 +87,27 @@ export interface RunOutcome {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Per-staffer aggregate. Recomputed from outcomes.jsonl after every
|
||||
// run. Competence_score weights neighbor retrieval so Senior staffers'
|
||||
// playbooks rank higher for similar scenarios than Junior staffers'.
|
||||
export interface StafferStats {
|
||||
id: string;
|
||||
name: string;
|
||||
tenure_months: number;
|
||||
role: string;
|
||||
total_runs: number;
|
||||
total_events_attempted: number;
|
||||
total_events_ok: number;
|
||||
fill_rate: number; // ok / attempted
|
||||
avg_turns_per_event: number;
|
||||
avg_citations_per_run: number;
|
||||
rescue_attempts: number;
|
||||
rescue_successes: number;
|
||||
rescue_rate: number;
|
||||
competence_score: number; // 0.0 - 1.0, see formula below
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
// The AI-synthesized recommendation written back for future runs.
|
||||
export interface PathwayRecommendation {
|
||||
sig_hash: string;
|
||||
@@ -142,7 +172,7 @@ async function readJsonl<T>(file: string): Promise<T[]> {
|
||||
// embedding, appends outcome, updates or inserts signature entry.
|
||||
export async function indexRun(
|
||||
scenarioDir: string,
|
||||
spec: { client: string; date: string; events: Array<any> },
|
||||
spec: { client: string; date: string; events: Array<any>; staffer?: { id: string; name: string; tenure_months: number; role: string } },
|
||||
models: { executor: string; reviewer: string; overview: string; overview_cloud: boolean },
|
||||
elapsed_secs: number,
|
||||
): Promise<{ sig_hash: string; outcome: RunOutcome }> {
|
||||
@@ -154,16 +184,21 @@ export async function indexRun(
|
||||
// Read results.json produced by scenario.ts to build the outcome record.
|
||||
const resultsRaw = await readFile(join(scenarioDir, "results.json"), "utf8");
|
||||
const results = JSON.parse(resultsRaw) as any[];
|
||||
const rescue_attempts = results.filter(r => r.retry_remediation).length;
|
||||
const rescue_successes = results.filter(r => r.retry_remediation && r.ok).length;
|
||||
const outcome: RunOutcome = {
|
||||
sig_hash,
|
||||
run_id: scenarioDir.split("/").pop() ?? scenarioDir,
|
||||
date: spec.date,
|
||||
models,
|
||||
staffer: spec.staffer,
|
||||
ok_events: results.filter(r => r.ok).length,
|
||||
total_events: results.length,
|
||||
total_turns: results.reduce((s, r) => s + (r.turns ?? 0), 0),
|
||||
total_gap_signals: results.reduce((s, r) => s + (r.gap_signals?.length ?? 0), 0),
|
||||
total_citations: results.reduce((s, r) => s + (r.playbook_citations?.length ?? 0), 0),
|
||||
rescue_attempts,
|
||||
rescue_successes,
|
||||
per_event: results.map(r => ({
|
||||
at: r.event.at,
|
||||
kind: r.event.kind,
|
||||
@@ -178,6 +213,13 @@ export async function indexRun(
|
||||
};
|
||||
await appendFile(join(KB_DIR, OUTCOMES_FILE), JSON.stringify(outcome) + "\n");
|
||||
|
||||
// Phase 23 — recompute this staffer's aggregate + competence_score.
|
||||
// Cheap until we have thousands of runs; swap to streaming aggregate
|
||||
// when that becomes a concern.
|
||||
if (spec.staffer) {
|
||||
await recomputeStafferStats(spec.staffer.id);
|
||||
}
|
||||
|
||||
// Embed + upsert signature. Skip if embedding fails — the outcome is
|
||||
// still persisted, just without neighbor-search hookup.
|
||||
try {
|
||||
@@ -221,6 +263,72 @@ export async function indexRun(
|
||||
return { sig_hash, outcome };
|
||||
}
|
||||
|
||||
// Phase 23 — per-staffer aggregate, recomputed from scratch on each
|
||||
// run. Writes the full table to STAFFERS_FILE (not append-only; we
|
||||
// always want the current-state snapshot). Competence score is bounded
|
||||
// 0..1 and combines four dimensions:
|
||||
// fill_rate — how often the staffer completes their events
|
||||
// turn_efficiency — lower turn counts are better
|
||||
// citation_density — signals use of playbook_memory feedback
|
||||
// rescue_rate — cloud rescue successes per attempt (doesn't
|
||||
// penalize staffers who never needed one)
|
||||
// Weights: fill 0.45, turn_eff 0.20, citation 0.20, rescue 0.15.
|
||||
// Rationale: completing the job matters most; everything else is style.
|
||||
export async function recomputeStafferStats(staffer_id: string): Promise<StafferStats | null> {
|
||||
const outcomes = await readJsonl<RunOutcome>(OUTCOMES_FILE);
|
||||
const mine = outcomes.filter(o => o.staffer?.id === staffer_id);
|
||||
if (mine.length === 0) return null;
|
||||
const latest = mine[mine.length - 1].staffer!;
|
||||
const total_events_attempted = mine.reduce((s, o) => s + o.total_events, 0);
|
||||
const total_events_ok = mine.reduce((s, o) => s + o.ok_events, 0);
|
||||
const total_turns = mine.reduce((s, o) => s + o.total_turns, 0);
|
||||
const total_citations = mine.reduce((s, o) => s + o.total_citations, 0);
|
||||
const rescue_attempts = mine.reduce((s, o) => s + (o.rescue_attempts ?? 0), 0);
|
||||
const rescue_successes = mine.reduce((s, o) => s + (o.rescue_successes ?? 0), 0);
|
||||
const fill_rate = total_events_attempted > 0 ? total_events_ok / total_events_attempted : 0;
|
||||
const avg_turns_per_event = total_events_attempted > 0 ? total_turns / total_events_attempted : 0;
|
||||
const avg_citations_per_run = mine.length > 0 ? total_citations / mine.length : 0;
|
||||
const rescue_rate = rescue_attempts > 0 ? rescue_successes / rescue_attempts : 1.0; // never-rescued defaults to perfect
|
||||
// Normalize each axis to 0..1. Caps chosen from observed distributions.
|
||||
const turn_eff = avg_turns_per_event === 0 ? 0 : Math.max(0, 1 - Math.min(avg_turns_per_event / 10, 1));
|
||||
const cite_norm = Math.min(avg_citations_per_run / 3, 1);
|
||||
const competence_score = 0.45 * fill_rate
|
||||
+ 0.20 * turn_eff
|
||||
+ 0.20 * cite_norm
|
||||
+ 0.15 * rescue_rate;
|
||||
const stats: StafferStats = {
|
||||
id: staffer_id,
|
||||
name: latest.name,
|
||||
tenure_months: latest.tenure_months,
|
||||
role: latest.role,
|
||||
total_runs: mine.length,
|
||||
total_events_attempted,
|
||||
total_events_ok,
|
||||
fill_rate,
|
||||
avg_turns_per_event,
|
||||
avg_citations_per_run,
|
||||
rescue_attempts,
|
||||
rescue_successes,
|
||||
rescue_rate,
|
||||
competence_score,
|
||||
last_updated: new Date().toISOString(),
|
||||
};
|
||||
// Rewrite whole file — cheap at O(staffers) scale.
|
||||
const existing = await readJsonl<StafferStats>(STAFFERS_FILE);
|
||||
const others = existing.filter(s => s.id !== staffer_id);
|
||||
others.push(stats);
|
||||
await writeFile(
|
||||
join(KB_DIR, STAFFERS_FILE),
|
||||
others.map(s => JSON.stringify(s)).join("\n") + "\n",
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Public accessor — used by findNeighbors + the staffer report script.
|
||||
export async function loadStafferStats(): Promise<StafferStats[]> {
|
||||
return readJsonl<StafferStats>(STAFFERS_FILE);
|
||||
}
|
||||
|
||||
// Cosine similarity for neighbor lookup. Float32-in-memory is fine for
|
||||
// O(thousands) signatures; swap to vectord HNSW once the corpus grows.
|
||||
function cosine(a: number[], b: number[]): number {
|
||||
@@ -237,9 +345,18 @@ function cosine(a: number[], b: number[]): number {
|
||||
// Find the k nearest-neighbor signatures for a target spec, returning
|
||||
// neighbor sigs + their outcome history. This is what the recommender
|
||||
// feeds to the overview model.
|
||||
//
|
||||
// Phase 23 — weighted by staffer competence. The ranking score is
|
||||
// cosine_similarity * max_competence_among_this_sig's_outcomes. Top
|
||||
// staffers' playbooks surface first even if their scenario was
|
||||
// slightly less similar than a junior's. Sigs with no staffer history
|
||||
// fall back to raw similarity (competence defaults to 0.5).
|
||||
export async function findNeighbors(spec: any, k = 5): Promise<Array<{
|
||||
sig: PlaybookSignature;
|
||||
similarity: number;
|
||||
weighted_score: number;
|
||||
best_staffer_competence: number;
|
||||
best_staffer_id: string | null;
|
||||
outcomes: RunOutcome[];
|
||||
}>> {
|
||||
await ensureKb();
|
||||
@@ -256,16 +373,45 @@ export async function findNeighbors(spec: any, k = 5): Promise<Array<{
|
||||
|
||||
const sigs = await readJsonl<PlaybookSignature>(SIGNATURES_FILE);
|
||||
const outcomes = await readJsonl<RunOutcome>(OUTCOMES_FILE);
|
||||
const staffers = await readJsonl<StafferStats>(STAFFERS_FILE);
|
||||
const competenceById = new Map(staffers.map(s => [s.id, s.competence_score]));
|
||||
|
||||
// Per-sig best staffer — the highest-competence coordinator who has
|
||||
// ever run this signature. Used to weight the sig's ranking.
|
||||
function bestStafferFor(sig_hash: string): { id: string | null; competence: number } {
|
||||
const mine = outcomes.filter(o => o.sig_hash === sig_hash && o.staffer?.id);
|
||||
if (mine.length === 0) return { id: null, competence: 0.5 };
|
||||
let bestId: string | null = null;
|
||||
let bestComp = 0;
|
||||
for (const o of mine) {
|
||||
const c = competenceById.get(o.staffer!.id) ?? 0.5;
|
||||
if (c > bestComp) { bestComp = c; bestId = o.staffer!.id; }
|
||||
}
|
||||
return { id: bestId, competence: bestComp };
|
||||
}
|
||||
|
||||
const ranked = sigs
|
||||
.filter(s => s.sig_hash !== targetHash) // don't recommend against self
|
||||
.map(s => ({ sig: s, similarity: cosine(targetVec, s.embedding) }))
|
||||
.sort((a, b) => b.similarity - a.similarity)
|
||||
.map(s => {
|
||||
const similarity = cosine(targetVec, s.embedding);
|
||||
const best = bestStafferFor(s.sig_hash);
|
||||
// Weighted score: similarity multiplied by the best staffer's
|
||||
// competence (floored at 0.3 so a sig with a low-competence
|
||||
// staffer still shows up if similarity is very high).
|
||||
const weight = Math.max(best.competence, 0.3);
|
||||
return {
|
||||
sig: s,
|
||||
similarity,
|
||||
weighted_score: similarity * weight,
|
||||
best_staffer_competence: best.competence,
|
||||
best_staffer_id: best.id,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.weighted_score - a.weighted_score)
|
||||
.slice(0, k);
|
||||
|
||||
return ranked.map(r => ({
|
||||
sig: r.sig,
|
||||
similarity: r.similarity,
|
||||
...r,
|
||||
outcomes: outcomes.filter(o => o.sig_hash === r.sig.sig_hash),
|
||||
}));
|
||||
}
|
||||
@@ -342,6 +488,8 @@ export async function recommendFor(
|
||||
// Build the prompt. Include target spec digest, neighbor digests with
|
||||
// their outcome stats, and recent error corrections. Ask for
|
||||
// structured output so we can parse it.
|
||||
const staffers = await readJsonl<StafferStats>(STAFFERS_FILE);
|
||||
const stafferById = new Map(staffers.map(s => [s.id, s]));
|
||||
const neighborBlock = neighbors.map(n => {
|
||||
const best = n.outcomes.reduce((a, b) =>
|
||||
a && a.ok_events / Math.max(1, a.total_events) >= b.ok_events / Math.max(1, b.total_events) ? a : b,
|
||||
@@ -349,7 +497,11 @@ export async function recommendFor(
|
||||
const avgOk = n.outcomes.length > 0
|
||||
? (n.outcomes.reduce((s, o) => s + o.ok_events, 0) / n.outcomes.length).toFixed(1)
|
||||
: "?";
|
||||
return `- sig ${n.sig.sig_hash} sim=${n.similarity.toFixed(3)} (${n.sig.events_digest.slice(0, 100)}): ${n.outcomes.length} runs, avg ${avgOk}/${best?.total_events ?? "?"} ok; best models: exec=${best?.models.executor ?? "?"} review=${best?.models.reviewer ?? "?"}`;
|
||||
const topStaffer = n.best_staffer_id ? stafferById.get(n.best_staffer_id) : null;
|
||||
const stafferTag = topStaffer
|
||||
? ` [top staffer: ${topStaffer.name} (${topStaffer.role}, competence=${topStaffer.competence_score.toFixed(2)})]`
|
||||
: "";
|
||||
return `- sig ${n.sig.sig_hash} sim=${n.similarity.toFixed(3)} weighted=${n.weighted_score.toFixed(3)}${stafferTag}: ${n.outcomes.length} runs, avg ${avgOk}/${best?.total_events ?? "?"} ok; best models: exec=${best?.models.executor ?? "?"} review=${best?.models.reviewer ?? "?"}`;
|
||||
}).join("\n");
|
||||
|
||||
const correctionBlock = corrections.slice(-3).map(c =>
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// Unified memory query — one surface that takes any input, normalizes
|
||||
// it, and returns every memory signal the system has: playbook workers,
|
||||
// KB pathway recommendations, prior lessons, staffer competence stats,
|
||||
// and cross-staffer discovered patterns. This is the "seamlessly with
|
||||
// whatever input" answer J framed it as.
|
||||
//
|
||||
// Why a unified gateway instead of separate calls: the memory surfaces
|
||||
// are semantically related (playbook_memory workers reference runs the
|
||||
// KB tracks; prior_lessons reference staffers the stats rank). Callers
|
||||
// shouldn't have to know the topology.
|
||||
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { normalizeInput, type NormalizedInput } from "./normalize.ts";
|
||||
import { findNeighbors, loadRecommendation, loadStafferStats, computeSignature, type StafferStats, type PathwayRecommendation } from "./kb.ts";
|
||||
import { GATEWAY, SIDECAR } from "./agent.ts";
|
||||
|
||||
export interface MemoryQueryResult {
|
||||
input: NormalizedInput;
|
||||
playbook_workers: Array<{
|
||||
doc_id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
playbook_boost: number;
|
||||
playbook_citations: string[];
|
||||
}>;
|
||||
pathway_recommendation: PathwayRecommendation | null;
|
||||
neighbor_signatures: Array<{
|
||||
sig_hash: string;
|
||||
events_digest: string;
|
||||
similarity: number;
|
||||
weighted_score: number;
|
||||
best_staffer_id: string | null;
|
||||
}>;
|
||||
prior_lessons: Array<{
|
||||
date: string;
|
||||
client: string;
|
||||
cities: string;
|
||||
lesson: string;
|
||||
}>;
|
||||
top_staffers: StafferStats[];
|
||||
discovered_patterns: {
|
||||
sig_hash: string;
|
||||
top_workers: Array<{ name: string; endorsements: number }>;
|
||||
} | null;
|
||||
latency_ms: {
|
||||
normalize: number;
|
||||
playbook_search: number;
|
||||
kb_neighbors: number;
|
||||
staffer_stats: number;
|
||||
prior_lessons: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Main entry. Normalizes the input, then fans out to every memory
|
||||
// source in parallel. Each source is best-effort: a down dependency
|
||||
// just leaves its field empty rather than breaking the query.
|
||||
export async function queryMemory(raw: unknown): Promise<MemoryQueryResult> {
|
||||
const t0 = Date.now();
|
||||
|
||||
// Normalize input first — sets the tone for every downstream call.
|
||||
const tNorm = Date.now();
|
||||
const input = await normalizeInput(raw);
|
||||
const normalize_ms = Date.now() - tNorm;
|
||||
|
||||
// Synthesize a minimal scenario spec from normalized input for sig
|
||||
// computation + KB lookup. Only needed if we have at least role + city.
|
||||
const pseudoSpec = (input.role && input.city && input.state) ? {
|
||||
client: input.client ?? "(unknown)",
|
||||
events: [{
|
||||
kind: input.intent === "rescue" ? "misplacement" : "baseline_fill",
|
||||
role: input.role,
|
||||
count: input.count ?? 1,
|
||||
city: input.city,
|
||||
state: input.state,
|
||||
}],
|
||||
} : null;
|
||||
|
||||
// Fire everything that CAN fire in parallel.
|
||||
const tPb = Date.now();
|
||||
const playbookPromise: Promise<MemoryQueryResult["playbook_workers"]> =
|
||||
(input.role && input.city && input.state)
|
||||
? fetch(`${GATEWAY}/vectors/hybrid`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
index_name: "workers_500k_v1",
|
||||
sql_filter: `role = '${input.role.replace(/'/g, "''")}' AND city = '${input.city.replace(/'/g, "''")}' AND state = '${input.state}' AND CAST(availability AS DOUBLE) > 0.5`,
|
||||
question: `${input.role} ${input.city} ${input.state}`,
|
||||
top_k: 10,
|
||||
generate: false,
|
||||
use_playbook_memory: true,
|
||||
playbook_memory_k: 100,
|
||||
}),
|
||||
}).then(r => r.ok ? r.json() : { sources: [] })
|
||||
.then((d: any) => (d.sources ?? []).map((s: any) => ({
|
||||
doc_id: s.doc_id,
|
||||
name: s.chunk_text?.split("—")[0]?.trim() ?? "?",
|
||||
score: s.score ?? 0,
|
||||
playbook_boost: s.playbook_boost ?? 0,
|
||||
playbook_citations: s.playbook_citations ?? [],
|
||||
})))
|
||||
.catch(() => [])
|
||||
: Promise.resolve([]);
|
||||
|
||||
const tKb = Date.now();
|
||||
const recPromise = pseudoSpec
|
||||
? loadRecommendation(pseudoSpec).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
|
||||
const neighborsPromise = pseudoSpec
|
||||
? findNeighbors(pseudoSpec, 5).catch(() => [])
|
||||
: Promise.resolve([]);
|
||||
|
||||
const tLess = Date.now();
|
||||
const lessonsPromise: Promise<MemoryQueryResult["prior_lessons"]> =
|
||||
(input.city && input.state)
|
||||
? loadRelevantLessons(input.city, input.state).catch(() => [])
|
||||
: Promise.resolve([]);
|
||||
|
||||
const tStaff = Date.now();
|
||||
const staffersPromise = loadStafferStats().catch(() => []);
|
||||
|
||||
// Parallel await
|
||||
const [playbook_workers, pathway_recommendation, neighbors_raw, prior_lessons, staffers] =
|
||||
await Promise.all([playbookPromise, recPromise, neighborsPromise, lessonsPromise, staffersPromise]);
|
||||
|
||||
// Derived: top-k staffers by competence, from recent activity.
|
||||
const top_staffers = staffers
|
||||
.filter(s => s.total_runs > 0)
|
||||
.sort((a, b) => b.competence_score - a.competence_score)
|
||||
.slice(0, 5);
|
||||
|
||||
// Derived: discovered patterns for this sig (workers endorsed ≥ 2 staffers).
|
||||
let discovered_patterns: MemoryQueryResult["discovered_patterns"] = null;
|
||||
if (pseudoSpec) {
|
||||
const sig_hash = computeSignature(pseudoSpec);
|
||||
const workers = await collectTopEndorsedWorkers(sig_hash, input.role, input.city, input.state).catch(() => []);
|
||||
if (workers.length > 0) discovered_patterns = { sig_hash, top_workers: workers };
|
||||
}
|
||||
|
||||
return {
|
||||
input,
|
||||
playbook_workers,
|
||||
pathway_recommendation,
|
||||
neighbor_signatures: neighbors_raw.map(n => ({
|
||||
sig_hash: n.sig.sig_hash,
|
||||
events_digest: n.sig.events_digest,
|
||||
similarity: n.similarity,
|
||||
weighted_score: n.weighted_score,
|
||||
best_staffer_id: n.best_staffer_id,
|
||||
})),
|
||||
prior_lessons,
|
||||
top_staffers,
|
||||
discovered_patterns,
|
||||
latency_ms: {
|
||||
normalize: normalize_ms,
|
||||
playbook_search: Date.now() - tPb,
|
||||
kb_neighbors: Date.now() - tKb,
|
||||
staffer_stats: Date.now() - tStaff,
|
||||
prior_lessons: Date.now() - tLess,
|
||||
total: Date.now() - t0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRelevantLessons(city: string, state: string | null): Promise<MemoryQueryResult["prior_lessons"]> {
|
||||
try {
|
||||
const dir = join("data", "_playbook_lessons");
|
||||
const files = await readdir(dir).catch(() => [] as string[]);
|
||||
const out: MemoryQueryResult["prior_lessons"] = [];
|
||||
for (const f of files) {
|
||||
if (!f.endsWith(".json")) continue;
|
||||
try {
|
||||
const raw = await readFile(join(dir, f), "utf8");
|
||||
const rec = JSON.parse(raw);
|
||||
const lessonCities = (rec.cities ?? "").split(",");
|
||||
const lessonStates = (rec.states ?? "").split(",");
|
||||
if (lessonCities.includes(city) || (state && lessonStates.includes(state))) {
|
||||
out.push({
|
||||
date: rec.date,
|
||||
client: rec.client,
|
||||
cities: rec.cities,
|
||||
lesson: rec.lesson.slice(0, 500),
|
||||
});
|
||||
}
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
out.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? ""));
|
||||
return out.slice(0, 3);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function collectTopEndorsedWorkers(
|
||||
sig_hash: string,
|
||||
role: string | null,
|
||||
city: string | null,
|
||||
state: string | null,
|
||||
): Promise<Array<{ name: string; endorsements: number }>> {
|
||||
if (!role || !city || !state) return [];
|
||||
// Pull from playbook_memory — each successful fill was endorsed;
|
||||
// count by (name, role, city, state) across past playbooks.
|
||||
try {
|
||||
const resp = await fetch(`${GATEWAY}/query/sql`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sql: `SELECT operation, result FROM successful_playbooks_live
|
||||
WHERE operation LIKE 'fill: ${role.replace(/'/g, "''")} %'
|
||||
AND operation LIKE '%in ${city.replace(/'/g, "''")}, ${state}%'
|
||||
ORDER BY timestamp DESC LIMIT 50`,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data: any = await resp.json();
|
||||
// successful_playbooks_live.result has the shape
|
||||
// "N/N filled → Name A, Name B, Name C"
|
||||
// or historical "Name A | Name B | Name C". Handle both.
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of data.rows ?? []) {
|
||||
const raw = String(row.result ?? "");
|
||||
const afterArrow = raw.includes("→") ? raw.split("→")[1] : raw;
|
||||
const names = afterArrow
|
||||
.split(/[,|]/)
|
||||
.map((n: string) => n.trim())
|
||||
.filter(n => n && !/^\d+\/\d+/.test(n) && !n.toLowerCase().startsWith("filled"));
|
||||
for (const n of names) counts.set(n, (counts.get(n) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([name, endorsements]) => ({ name, endorsements }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { normalizeInputSync, normalizeInput } from "./normalize.ts";
|
||||
|
||||
test("structured FillEvent → fast path", () => {
|
||||
const n = normalizeInputSync({
|
||||
kind: "baseline_fill", role: "Welder", city: "Nashville",
|
||||
state: "TN", count: 4,
|
||||
});
|
||||
expect(n.extraction_method).toBe("structured");
|
||||
expect(n.role).toBe("Welder");
|
||||
expect(n.city).toBe("Nashville");
|
||||
expect(n.state).toBe("TN");
|
||||
expect(n.count).toBe(4);
|
||||
expect(n.confidence).toBe("high");
|
||||
expect(n.missing_fields.length).toBe(0);
|
||||
});
|
||||
|
||||
test("misplacement kind → rescue intent", () => {
|
||||
const n = normalizeInputSync({ kind: "misplacement", role: "Welder", city: "Nashville", state: "TN", count: 1 });
|
||||
expect(n.intent).toBe("rescue");
|
||||
});
|
||||
|
||||
test("structured with full state name normalizes to abbrev", () => {
|
||||
const n = normalizeInputSync({ role: "Welder", city: "Nashville", state: "Tennessee" });
|
||||
expect(n.state).toBe("TN");
|
||||
});
|
||||
|
||||
test("natural-language regex path extracts count + role + city + state", () => {
|
||||
const n = normalizeInputSync("I need 3 Welders in Nashville, TN for next week");
|
||||
expect(n.role).toBe("Welder");
|
||||
expect(n.city).toBe("Nashville");
|
||||
expect(n.state).toBe("TN");
|
||||
expect(n.count).toBe(3);
|
||||
expect(n.intent).toBe("fill");
|
||||
expect(n.extraction_method).toBe("regex");
|
||||
});
|
||||
|
||||
test("natural language with plural role + full state name", () => {
|
||||
const n = normalizeInputSync("fill 5 Forklift Operators in Chicago, Illinois");
|
||||
expect(n.role).toBe("Forklift Operator");
|
||||
expect(n.city).toBe("Chicago");
|
||||
expect(n.state).toBe("IL");
|
||||
expect(n.count).toBe(5);
|
||||
});
|
||||
|
||||
test("rescue intent keyword beats fill", () => {
|
||||
const n = normalizeInputSync("we need to rescue the Nashville welder fill, 2 workers");
|
||||
expect(n.intent).toBe("rescue");
|
||||
expect(n.count).toBe(2);
|
||||
expect(n.role).toBe("Welder");
|
||||
});
|
||||
|
||||
test("partial input reports missing fields with confidence=low or medium", () => {
|
||||
const n = normalizeInputSync("need welders");
|
||||
expect(n.role).toBe("Welder");
|
||||
expect(n.city).toBeNull();
|
||||
expect(n.state).toBeNull();
|
||||
expect(n.count).toBeNull();
|
||||
expect(n.missing_fields).toContain("city");
|
||||
expect(n.missing_fields).toContain("state");
|
||||
expect(n.confidence).toBe("low");
|
||||
});
|
||||
|
||||
test("empty object does NOT go through structured path (no role or city)", () => {
|
||||
const n = normalizeInputSync({});
|
||||
expect(n.extraction_method).toBe("regex");
|
||||
});
|
||||
|
||||
test("async normalizeInput on clean structured input matches sync", async () => {
|
||||
const input = { role: "Welder", city: "Nashville", state: "TN", count: 3 };
|
||||
const sync = normalizeInputSync(input);
|
||||
const async_ = await normalizeInput(input);
|
||||
expect(async_.role).toBe(sync.role);
|
||||
expect(async_.city).toBe(sync.city);
|
||||
expect(async_.state).toBe(sync.state);
|
||||
expect(async_.count).toBe(sync.count);
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
// Input normalizer — accepts whatever shape a caller provides (clean
|
||||
// structured spec, partial JSON, natural language, a mix) and produces
|
||||
// a canonical shape the rest of the pipeline understands. This is the
|
||||
// missing piece that lets KB + playbook_memory + overseer respond
|
||||
// "seamlessly with whatever input they're given" as J framed it.
|
||||
//
|
||||
// The pipeline already works when input is `{role, city, state, count}`;
|
||||
// what breaks today is "I need three welders in Nashville Monday" —
|
||||
// the SQL-filter extractors return None, the boost pipeline sees no
|
||||
// role/geo signal, and retrieval silently degrades to raw cosine.
|
||||
//
|
||||
// Strategy: fast path for structured input, LLM fallback only for
|
||||
// natural language. We don't want to burn an LLM call on "role=Welder
|
||||
// city=Nashville state=TN count=3" — that's wasteful.
|
||||
|
||||
import { generateContinuable } from "./agent.ts";
|
||||
|
||||
export interface NormalizedInput {
|
||||
role: string | null;
|
||||
city: string | null;
|
||||
state: string | null; // two-letter
|
||||
count: number | null;
|
||||
client: string | null;
|
||||
deadline: string | null; // ISO date or null
|
||||
intent: "fill" | "lookup" | "rescue" | "rebuild" | "unknown";
|
||||
raw_text: string; // what the caller originally sent
|
||||
confidence: "high" | "medium" | "low";
|
||||
extraction_method: "structured" | "regex" | "llm" | "mixed";
|
||||
missing_fields: string[]; // what normalizer couldn't determine
|
||||
}
|
||||
|
||||
// US state abbreviations for regex city,state extraction.
|
||||
const STATE_ABBRS = new Set([
|
||||
"AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA","HI","ID","IL","IN","IA",
|
||||
"KS","KY","LA","ME","MD","MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ",
|
||||
"NM","NY","NC","ND","OH","OK","OR","PA","RI","SC","SD","TN","TX","UT","VT",
|
||||
"VA","WA","WV","WI","WY","DC",
|
||||
]);
|
||||
|
||||
const STATE_NAMES_TO_ABBR: Record<string, string> = {
|
||||
alabama: "AL", alaska: "AK", arizona: "AZ", arkansas: "AR", california: "CA",
|
||||
colorado: "CO", connecticut: "CT", delaware: "DE", florida: "FL", georgia: "GA",
|
||||
hawaii: "HI", idaho: "ID", illinois: "IL", indiana: "IN", iowa: "IA",
|
||||
kansas: "KS", kentucky: "KY", louisiana: "LA", maine: "ME", maryland: "MD",
|
||||
massachusetts: "MA", michigan: "MI", minnesota: "MN", mississippi: "MS", missouri: "MO",
|
||||
montana: "MT", nebraska: "NE", nevada: "NV", "new hampshire": "NH", "new jersey": "NJ",
|
||||
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND",
|
||||
ohio: "OH", oklahoma: "OK", oregon: "OR", pennsylvania: "PA", "rhode island": "RI",
|
||||
"south carolina": "SC", "south dakota": "SD", tennessee: "TN", texas: "TX", utah: "UT",
|
||||
vermont: "VT", virginia: "VA", washington: "WA", "west virginia": "WV", wisconsin: "WI",
|
||||
wyoming: "WY",
|
||||
};
|
||||
|
||||
// Canonical staffing roles — the normalizer recognizes these regardless
|
||||
// of casing / plurality. Extend as domains grow.
|
||||
const KNOWN_ROLES = [
|
||||
"Warehouse Associate", "Machine Operator", "Forklift Operator", "Loader",
|
||||
"Material Handler", "Assembler", "Quality Tech", "Picker", "Packer",
|
||||
"Shipping Clerk", "Receiving Clerk", "Welder", "CNC Operator",
|
||||
"Maintenance Tech", "Electrician", "Tool & Die Maker", "Safety Coordinator",
|
||||
"Logistics Coordinator", "Packaging Operator", "Sanitation Worker",
|
||||
"Line Lead",
|
||||
];
|
||||
|
||||
// Intent keyword hints. Check in order — "rescue" beats generic "fill"
|
||||
// for "rescue the Nashville welder fill" kind of input.
|
||||
const INTENT_HINTS: Array<{ re: RegExp; intent: NormalizedInput["intent"] }> = [
|
||||
{ re: /\brescue|pivot|retry|remediat/i, intent: "rescue" },
|
||||
{ re: /\brebuild|reindex|re-?embed/i, intent: "rebuild" },
|
||||
{ re: /\blookup|find|show me|who is|what is/i, intent: "lookup" },
|
||||
{ re: /\bfill|hire|staff|need|schedule|book/i, intent: "fill" },
|
||||
];
|
||||
|
||||
// Fast structured path — the input is already a FillEvent-shaped JSON
|
||||
// or a close cousin. Returns null to fall through to regex/LLM.
|
||||
function fromStructured(raw: any): NormalizedInput | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const role = typeof raw.role === "string" ? raw.role : null;
|
||||
const city = typeof raw.city === "string" ? raw.city : null;
|
||||
const stateRaw = typeof raw.state === "string" ? raw.state : null;
|
||||
const count = typeof raw.count === "number" ? raw.count : null;
|
||||
// Only treat as structured if AT LEAST role OR city is present; a
|
||||
// bare {kind: "baseline_fill"} shouldn't go through this path.
|
||||
if (!role && !city) return null;
|
||||
return {
|
||||
role,
|
||||
city,
|
||||
state: normalizeState(stateRaw),
|
||||
count,
|
||||
client: typeof raw.client === "string" ? raw.client : null,
|
||||
deadline: typeof raw.deadline === "string" ? raw.deadline : null,
|
||||
intent: (raw.kind === "misplacement" || /rescue/i.test(raw.kind ?? "")) ? "rescue" : "fill",
|
||||
raw_text: JSON.stringify(raw),
|
||||
confidence: "high",
|
||||
extraction_method: "structured",
|
||||
missing_fields: missingFields(role, city, stateRaw, count),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeState(s: string | null | undefined): string | null {
|
||||
if (!s) return null;
|
||||
const trimmed = s.trim();
|
||||
if (STATE_ABBRS.has(trimmed.toUpperCase())) return trimmed.toUpperCase();
|
||||
const full = trimmed.toLowerCase();
|
||||
if (STATE_NAMES_TO_ABBR[full]) return STATE_NAMES_TO_ABBR[full];
|
||||
return null;
|
||||
}
|
||||
|
||||
function missingFields(role: any, city: any, state: any, count: any): string[] {
|
||||
const out: string[] = [];
|
||||
if (!role) out.push("role");
|
||||
if (!city) out.push("city");
|
||||
if (!state) out.push("state");
|
||||
if (count === null || count === undefined) out.push("count");
|
||||
return out;
|
||||
}
|
||||
|
||||
// Regex path — handles structured-ish text like "3 welders in Nashville TN".
|
||||
// Returns partial extraction; LLM path fills the rest if this leaves
|
||||
// too many missing fields.
|
||||
function fromRegex(text: string): NormalizedInput {
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
// Count: "3 welders" or "need 5" or "fill 8 safety coordinators"
|
||||
let count: number | null = null;
|
||||
const countMatch = text.match(/\b(?:need|fill|book|hire|schedule|want)\s+(\d+)\b/i)
|
||||
?? text.match(/\b(\d+)\s+(?:x\s+)?[A-Za-z]/);
|
||||
if (countMatch) count = parseInt(countMatch[1], 10);
|
||||
|
||||
// Role: longest prefix match from KNOWN_ROLES (case-insensitive).
|
||||
// Plural forms ("welders") match "Welder" via endsWith("s") strip.
|
||||
let role: string | null = null;
|
||||
for (const known of KNOWN_ROLES.sort((a, b) => b.length - a.length)) {
|
||||
const needle = known.toLowerCase();
|
||||
if (lower.includes(needle)) { role = known; break; }
|
||||
if (needle.endsWith("r") && lower.includes(needle + "s")) { role = known; break; }
|
||||
}
|
||||
|
||||
// City, state: "Nashville TN" or "Nashville, Tennessee" or "Nashville, TN".
|
||||
// City capture is 1-3 capitalized words — anything longer is usually
|
||||
// the surrounding phrase ("Forklift Operators in Chicago"). Prefer
|
||||
// "in {city}" anchor when present.
|
||||
let city: string | null = null;
|
||||
let state: string | null = null;
|
||||
const cityPat = `([A-Z][a-zA-Z.'-]+(?:\\s+[A-Z][a-zA-Z.'-]+){0,2})`;
|
||||
// First preference: "in {City}, XX" or "in {City} XX"
|
||||
const inAbbrev = text.match(new RegExp(`\\bin\\s+${cityPat},?\\s+([A-Z]{2})\\b`));
|
||||
if (inAbbrev && STATE_ABBRS.has(inAbbrev[2])) {
|
||||
city = inAbbrev[1].trim();
|
||||
state = inAbbrev[2];
|
||||
} else {
|
||||
// Bare "{City}, XX" (no "in" anchor)
|
||||
const bareAbbrev = text.match(new RegExp(`${cityPat},\\s+([A-Z]{2})\\b`));
|
||||
if (bareAbbrev && STATE_ABBRS.has(bareAbbrev[2])) {
|
||||
city = bareAbbrev[1].trim();
|
||||
state = bareAbbrev[2];
|
||||
} else {
|
||||
// "{City}, FullName" variant. Can't use case-insensitive flag
|
||||
// because it would let cityPat's [A-Z] match lowercase letters.
|
||||
// Instead try both exact-case and Title-case variants.
|
||||
for (const [full, abbr] of Object.entries(STATE_NAMES_TO_ABBR)) {
|
||||
const fullTitle = full.split(" ").map(w => w[0].toUpperCase() + w.slice(1)).join(" ");
|
||||
const variants = [full, fullTitle];
|
||||
for (const variant of variants) {
|
||||
const re = new RegExp(`\\bin\\s+${cityPat},?\\s+${variant}\\b`);
|
||||
const m = text.match(re);
|
||||
if (m) { city = m[1].trim(); state = abbr; break; }
|
||||
}
|
||||
if (city) break;
|
||||
// Bare "{City}, FullName" (no "in")
|
||||
for (const variant of variants) {
|
||||
const re = new RegExp(`${cityPat},\\s+${variant}\\b`);
|
||||
const m = text.match(re);
|
||||
if (m) { city = m[1].trim(); state = abbr; break; }
|
||||
}
|
||||
if (city) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Intent
|
||||
let intent: NormalizedInput["intent"] = "unknown";
|
||||
for (const h of INTENT_HINTS) {
|
||||
if (h.re.test(text)) { intent = h.intent; break; }
|
||||
}
|
||||
|
||||
const missing = missingFields(role, city, state, count);
|
||||
return {
|
||||
role, city, state, count,
|
||||
client: null,
|
||||
deadline: null,
|
||||
intent,
|
||||
raw_text: text,
|
||||
confidence: missing.length === 0 ? "high" : missing.length <= 2 ? "medium" : "low",
|
||||
extraction_method: "regex",
|
||||
missing_fields: missing,
|
||||
};
|
||||
}
|
||||
|
||||
// LLM path — when regex leaves too many fields missing, ask a small
|
||||
// local model to extract what's present. Uses `think:false` and a
|
||||
// strict JSON prompt so the call stays cheap.
|
||||
async function fromLLM(text: string, fallback: NormalizedInput): Promise<NormalizedInput> {
|
||||
const prompt = `Extract the staffing request shape from this text. Respond with ONLY a JSON object, no prose.
|
||||
|
||||
Text: ${text}
|
||||
|
||||
Required shape:
|
||||
{
|
||||
"role": "string | null (one of: Welder, Forklift Operator, Warehouse Associate, Machine Operator, Loader, Material Handler, Assembler, Quality Tech, Picker, Packer, Shipping Clerk, Receiving Clerk, CNC Operator, Maintenance Tech, Electrician, Tool & Die Maker, Safety Coordinator, Logistics Coordinator, Packaging Operator, Sanitation Worker, Line Lead, or null if unclear)",
|
||||
"city": "string | null (US city name, properly capitalized)",
|
||||
"state": "string | null (two-letter abbrev like TN, IL)",
|
||||
"count": "number | null (integer number of workers needed)",
|
||||
"client": "string | null (company/client name if mentioned)",
|
||||
"deadline": "string | null (ISO date YYYY-MM-DD if a specific date is mentioned, else null)",
|
||||
"intent": "string (one of: fill, lookup, rescue, rebuild, unknown)"
|
||||
}
|
||||
|
||||
Return the JSON object now:`;
|
||||
|
||||
try {
|
||||
const raw = await generateContinuable("qwen3:latest", prompt, {
|
||||
max_tokens: 400,
|
||||
shape: "json",
|
||||
think: false,
|
||||
max_continuations: 2,
|
||||
});
|
||||
const match = raw.match(/\{[\s\S]*\}/);
|
||||
if (!match) return fallback;
|
||||
const parsed = JSON.parse(match[0]);
|
||||
const role = typeof parsed.role === "string" && parsed.role !== "null" ? parsed.role : null;
|
||||
const city = typeof parsed.city === "string" && parsed.city !== "null" ? parsed.city : null;
|
||||
const state = normalizeState(parsed.state);
|
||||
const count = typeof parsed.count === "number" ? parsed.count
|
||||
: typeof parsed.count === "string" ? parseInt(parsed.count, 10) || null
|
||||
: null;
|
||||
const client = typeof parsed.client === "string" && parsed.client !== "null" ? parsed.client : null;
|
||||
const deadline = typeof parsed.deadline === "string" && parsed.deadline !== "null" ? parsed.deadline : null;
|
||||
const intent = ["fill", "lookup", "rescue", "rebuild", "unknown"].includes(parsed.intent)
|
||||
? parsed.intent as NormalizedInput["intent"]
|
||||
: "unknown";
|
||||
const missing = missingFields(role, city, state, count);
|
||||
return {
|
||||
role: role ?? fallback.role,
|
||||
city: city ?? fallback.city,
|
||||
state: state ?? fallback.state,
|
||||
count: count ?? fallback.count,
|
||||
client: client ?? fallback.client,
|
||||
deadline: deadline ?? fallback.deadline,
|
||||
intent: intent !== "unknown" ? intent : fallback.intent,
|
||||
raw_text: text,
|
||||
confidence: missing.length === 0 ? "high" : missing.length <= 2 ? "medium" : "low",
|
||||
extraction_method: "llm",
|
||||
missing_fields: missing,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level normalizer. Auto-detects input shape:
|
||||
// - Object with role/city → fast structured path
|
||||
// - String with enough regex signal → regex path
|
||||
// - Low-signal string → LLM path with regex seed
|
||||
export async function normalizeInput(raw: unknown): Promise<NormalizedInput> {
|
||||
// Structured path
|
||||
if (raw && typeof raw === "object") {
|
||||
const s = fromStructured(raw);
|
||||
if (s) return s;
|
||||
}
|
||||
// String path
|
||||
const text = typeof raw === "string" ? raw
|
||||
: raw && typeof raw === "object" ? JSON.stringify(raw)
|
||||
: String(raw);
|
||||
const reg = fromRegex(text);
|
||||
// If regex got at least role + city, trust it.
|
||||
if (reg.role && reg.city) {
|
||||
return reg;
|
||||
}
|
||||
// Otherwise try LLM fallback, using regex result as seed.
|
||||
return await fromLLM(text, reg);
|
||||
}
|
||||
|
||||
// Synchronous-only variant — returns whatever regex/structured can get
|
||||
// without an LLM call. Useful when caller can't await (e.g. a prompt
|
||||
// template).
|
||||
export function normalizeInputSync(raw: unknown): NormalizedInput {
|
||||
if (raw && typeof raw === "object") {
|
||||
const s = fromStructured(raw);
|
||||
if (s) return s;
|
||||
}
|
||||
const text = typeof raw === "string" ? raw
|
||||
: raw && typeof raw === "object" ? JSON.stringify(raw)
|
||||
: String(raw);
|
||||
return fromRegex(text);
|
||||
}
|
||||
+496
-41
@@ -35,7 +35,27 @@ import {
|
||||
reviewerPrompt,
|
||||
GATEWAY,
|
||||
} from "./agent.ts";
|
||||
import { indexRun, recommendFor, loadRecommendation, type PathwayRecommendation } from "./kb.ts";
|
||||
import { indexRun, recommendFor, loadRecommendation, snapshotConfig, computeSignature, type PathwayRecommendation } from "./kb.ts";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
// Phase 24 — observer HTTP endpoint. Scenarios POST per-event
|
||||
// outcomes here so the observer's analyzer + playbook builder see
|
||||
// them. Best-effort: if observer is down, scenario still proceeds.
|
||||
const OBSERVER_URL = process.env.LH_OBSERVER_URL ?? "http://localhost:3800";
|
||||
|
||||
async function postObserverEvent(payload: Record<string, any>): Promise<void> {
|
||||
try {
|
||||
await fetch(`${OBSERVER_URL}/event`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
} catch {
|
||||
// Observer down — silent skip. The scenario's own logs are still
|
||||
// the source of truth; observer integration is an augmentation.
|
||||
}
|
||||
}
|
||||
import { mkdir, writeFile, appendFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -79,6 +99,66 @@ const OVERVIEW_MODEL = process.env.LH_OVERVIEW_MODEL
|
||||
: (T3_TIER?.local_fallback?.model ?? "gpt-oss:20b"));
|
||||
const T3_CHECKPOINT_EVERY = Number(process.env.LH_T3_CHECKPOINT_EVERY ?? 3);
|
||||
const T3_DISABLED = process.env.LH_T3_DISABLE === "1";
|
||||
// Phase 22 item B — cloud-assisted retry on event failure. Default ON
|
||||
// when cloud T3 is enabled (LH_OVERVIEW_CLOUD=1) since that's the only
|
||||
// path where the rescue call gets a model worth asking. Disable with
|
||||
// LH_RETRY_ON_FAIL=0 to compare baseline outcomes without rescue.
|
||||
const RETRY_ON_FAIL = process.env.LH_RETRY_ON_FAIL !== "0";
|
||||
|
||||
// Phase 23 refinement — per-staffer tool_level overrides. Evaluated
|
||||
// per run in main() once we know the spec's staffer. These are
|
||||
// package-scoped mutable slots intentionally — the primary constant
|
||||
// above is the DEFAULT; main() flips them for the duration of the run
|
||||
// based on staffer.tool_level before calling anything else.
|
||||
let ACTIVE_EXECUTOR = EXECUTOR_MODEL;
|
||||
let ACTIVE_REVIEWER = REVIEWER_MODEL;
|
||||
let ACTIVE_EXECUTOR_CLOUD = false;
|
||||
let ACTIVE_REVIEWER_CLOUD = false;
|
||||
let ACTIVE_T3_DISABLED = T3_DISABLED;
|
||||
let ACTIVE_OVERVIEW_CLOUD = OVERVIEW_CLOUD;
|
||||
let ACTIVE_RETRY_ON_FAIL = RETRY_ON_FAIL;
|
||||
|
||||
function applyToolLevel(level: Staffer["tool_level"] | undefined): void {
|
||||
// Start from env defaults each time so previous staffer's overrides
|
||||
// don't leak.
|
||||
ACTIVE_EXECUTOR = EXECUTOR_MODEL;
|
||||
ACTIVE_REVIEWER = REVIEWER_MODEL;
|
||||
ACTIVE_EXECUTOR_CLOUD = false;
|
||||
ACTIVE_REVIEWER_CLOUD = false;
|
||||
ACTIVE_T3_DISABLED = T3_DISABLED;
|
||||
ACTIVE_OVERVIEW_CLOUD = OVERVIEW_CLOUD;
|
||||
ACTIVE_RETRY_ON_FAIL = RETRY_ON_FAIL;
|
||||
if (!level) return;
|
||||
switch (level) {
|
||||
case "full":
|
||||
ACTIVE_OVERVIEW_CLOUD = true;
|
||||
break;
|
||||
case "local":
|
||||
ACTIVE_OVERVIEW_CLOUD = false;
|
||||
break;
|
||||
case "basic":
|
||||
// qwen2.5 collapsed on this workload (0/14 fill). Replace with
|
||||
// cloud kimi-k2.5 — same family as k2.6 (which requires a paid
|
||||
// tier), strong at tool calling. kimi-k2.6 is targeted when the
|
||||
// subscription upgrades.
|
||||
ACTIVE_EXECUTOR = "kimi-k2.5";
|
||||
ACTIVE_EXECUTOR_CLOUD = true;
|
||||
ACTIVE_REVIEWER = "qwen3:latest"; // local reviewer stays cheap
|
||||
ACTIVE_OVERVIEW_CLOUD = false;
|
||||
ACTIVE_RETRY_ON_FAIL = false;
|
||||
break;
|
||||
case "minimal":
|
||||
// Same executor as basic but strip the overseer + rescue.
|
||||
// Proves whether playbook inheritance alone carries the load.
|
||||
ACTIVE_EXECUTOR = "kimi-k2.5";
|
||||
ACTIVE_EXECUTOR_CLOUD = true;
|
||||
ACTIVE_REVIEWER = "qwen3:latest";
|
||||
ACTIVE_T3_DISABLED = true;
|
||||
ACTIVE_OVERVIEW_CLOUD = false;
|
||||
ACTIVE_RETRY_ON_FAIL = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatcher: route T3 calls to local sidecar or Ollama Cloud depending
|
||||
// on the LH_OVERVIEW_CLOUD flag. Hot-path T1/T2 always stay local.
|
||||
@@ -90,7 +170,7 @@ async function overviewGenerate(prompt: string, opts: { temperature?: number; ma
|
||||
max_tokens: opts.max_tokens ?? 1000,
|
||||
shape: "text",
|
||||
max_continuations: 2,
|
||||
cloud: OVERVIEW_CLOUD,
|
||||
cloud: ACTIVE_OVERVIEW_CLOUD,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,10 +197,53 @@ interface FillEvent {
|
||||
replaces_event?: string; // misplacement back-ref for reporting
|
||||
}
|
||||
|
||||
// Contract terms describe the business constraints around a scenario:
|
||||
// how much can the client pay, by when, and how is a local candidate
|
||||
// bonused vs an out-of-area one. T3 checkpoint + cloud rescue pass
|
||||
// these into the overseer's prompt so pivots respect budget (not just
|
||||
// geography). Optional — legacy scenarios without a contract degrade
|
||||
// to the original "nearest-city" pivot logic.
|
||||
interface ContractTerms {
|
||||
deadline: string; // ISO date — "2026-05-05"
|
||||
budget_total_usd?: number; // optional total cap across the contract
|
||||
budget_per_hour_max?: number; // per-worker hourly cap
|
||||
local_bonus_per_hour?: number; // extra $/hr for in-radius candidates
|
||||
local_bonus_radius_mi?: number; // radius from the contract city
|
||||
fill_requirement?: "paramount" | "preferred"; // "paramount" = must fill;
|
||||
// "preferred" = best-effort
|
||||
}
|
||||
|
||||
// Phase 23 — staffer identity. When a scenario is assigned to a
|
||||
// specific coordinator, we record their trajectory so KB can
|
||||
// weight future retrieval by competence. Junior staffers inherit
|
||||
// Senior staffers' playbooks; top staffers become the training
|
||||
// signal the system auto-discovers.
|
||||
interface Staffer {
|
||||
id: string; // "S-001"
|
||||
name: string; // "Maria Chen"
|
||||
tenure_months: number;
|
||||
role: "senior" | "mid" | "junior" | "trainee";
|
||||
// Phase 23 refinement — tool_level controls which subsystems this
|
||||
// staffer's runs get to use. The mechanism always leaves
|
||||
// playbook_memory ON so inherited playbooks drive the outcome even
|
||||
// when T3 / cloud rescue / the bigger executor are disabled.
|
||||
//
|
||||
// full — qwen3.5 executor + qwen3 reviewer + cloud T3 + rescue
|
||||
// local — qwen3.5 + qwen3 + local gpt-oss:20b T3 + rescue
|
||||
// basic — qwen2.5 + qwen2.5 + local T3, no rescue
|
||||
// minimal — qwen2.5 + qwen2.5, NO T3, NO rescue. Only playbook
|
||||
// inheritance to lean on. This is the honest test of
|
||||
// whether the playbook system carries knowledge on its
|
||||
// own.
|
||||
tool_level?: "full" | "local" | "basic" | "minimal";
|
||||
}
|
||||
|
||||
interface ScenarioSpec {
|
||||
client: string;
|
||||
date: string;
|
||||
events: FillEvent[];
|
||||
contract?: ContractTerms;
|
||||
staffer?: Staffer;
|
||||
}
|
||||
|
||||
interface EventResult {
|
||||
@@ -136,6 +259,36 @@ interface EventResult {
|
||||
pool_size?: number; // sql_matches from the first hybrid_search
|
||||
playbook_citations?: string[];
|
||||
discovered_pattern?: string; // Path 2 meta-index snapshot per event
|
||||
diagnostic_log?: LogEntry[]; // SQL filters, pool sizes, drift reasons —
|
||||
// what T3 needs to diagnose supply issues
|
||||
// vs surface symptoms. Present on both
|
||||
// failed and successful events.
|
||||
// Phase 22 item B — cloud-assisted retry fields. When a first attempt
|
||||
// fails and LH_RETRY_ON_FAIL is on, T3 cloud proposes a pivot; if
|
||||
// adopted, the retry re-runs the event with the new (city, role,
|
||||
// count) and records the result in `retry_result`. original_event is
|
||||
// the pre-pivot spec, kept for the retrospective so we can show the
|
||||
// coordinator what changed.
|
||||
retry_attempt?: number;
|
||||
retry_remediation?: {
|
||||
proposed_city?: string;
|
||||
proposed_role?: string;
|
||||
proposed_count?: number;
|
||||
rationale: string;
|
||||
cloud_model: string;
|
||||
cloud_duration_secs: number;
|
||||
};
|
||||
retry_result?: Omit<EventResult, "retry_attempt" | "retry_remediation" | "retry_result" | "diagnostic_log">;
|
||||
original_event?: FillEvent; // what the event was before pivot
|
||||
}
|
||||
|
||||
interface CloudRemediation {
|
||||
retry: boolean;
|
||||
new_city?: string;
|
||||
new_state?: string;
|
||||
new_role?: string;
|
||||
new_count?: number;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
interface RosterEntry {
|
||||
@@ -341,9 +494,12 @@ async function runAgentFill(
|
||||
task: TaskSpec,
|
||||
extra_guidance: string,
|
||||
exclude_worker_ids: string[],
|
||||
sharedLog?: LogEntry[], // If provided, runAgentFill appends here too so
|
||||
// callers can diagnose AFTER a throw. Empty by
|
||||
// default — passing this in is opt-in.
|
||||
): Promise<AgentFillOutcome> {
|
||||
const t0 = Date.now();
|
||||
const log: LogEntry[] = [];
|
||||
const log: LogEntry[] = sharedLog ?? [];
|
||||
let turn = 0;
|
||||
let consecutiveDrifts = 0;
|
||||
let sealed: { fills: Fill[]; approach: string } | null = null;
|
||||
@@ -382,7 +538,7 @@ async function runAgentFill(
|
||||
// reasoning. Burning ~650 thinking tokens on a 400-token JSON was
|
||||
// exactly the bug we just solved.
|
||||
const execRaw = await generateContinuable(
|
||||
EXECUTOR_MODEL,
|
||||
ACTIVE_EXECUTOR,
|
||||
withExtras(executorPrompt(task, log)),
|
||||
{
|
||||
temperature: 0.2,
|
||||
@@ -390,8 +546,9 @@ async function runAgentFill(
|
||||
shape: "json",
|
||||
max_continuations: 3,
|
||||
think: false,
|
||||
cloud: ACTIVE_EXECUTOR_CLOUD,
|
||||
on_continuation: (n, len) =>
|
||||
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "note",
|
||||
append({ turn, role: "executor", model: ACTIVE_EXECUTOR, kind: "note",
|
||||
content: { continuation: n, combined_chars: len } }),
|
||||
},
|
||||
);
|
||||
@@ -399,11 +556,11 @@ async function runAgentFill(
|
||||
try {
|
||||
execAction = parseAction(execRaw, "executor");
|
||||
} catch (e) {
|
||||
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "error",
|
||||
append({ turn, role: "executor", model: ACTIVE_EXECUTOR, kind: "error",
|
||||
content: { message: (e as Error).message, raw: execRaw.slice(0, 300) } });
|
||||
throw e;
|
||||
}
|
||||
append({ turn, role: "executor", model: EXECUTOR_MODEL,
|
||||
append({ turn, role: "executor", model: ACTIVE_EXECUTOR,
|
||||
kind: execAction.kind as any, content: execAction });
|
||||
|
||||
if (execAction.kind === "tool_call") {
|
||||
@@ -422,7 +579,7 @@ async function runAgentFill(
|
||||
}
|
||||
}
|
||||
const trimmed = trimResult(filtered);
|
||||
append({ turn, role: "executor", model: EXECUTOR_MODEL,
|
||||
append({ turn, role: "executor", model: ACTIVE_EXECUTOR,
|
||||
kind: "tool_result", content: trimmed });
|
||||
|
||||
// Accumulate playbook citations from any hybrid result that
|
||||
@@ -435,7 +592,7 @@ async function runAgentFill(
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "tool_result",
|
||||
append({ turn, role: "executor", model: ACTIVE_EXECUTOR, kind: "tool_result",
|
||||
content: { error: (e as Error).message, tool: execAction.tool } });
|
||||
consecutiveDrifts += 1;
|
||||
if (consecutiveDrifts >= MAX_CONSECUTIVE_DRIFTS) {
|
||||
@@ -445,7 +602,7 @@ async function runAgentFill(
|
||||
}
|
||||
|
||||
const revRaw = await generateContinuable(
|
||||
REVIEWER_MODEL,
|
||||
ACTIVE_REVIEWER,
|
||||
withExtras(reviewerPrompt(task, log)),
|
||||
{
|
||||
temperature: 0.1,
|
||||
@@ -453,8 +610,9 @@ async function runAgentFill(
|
||||
shape: "json",
|
||||
max_continuations: 3,
|
||||
think: false,
|
||||
cloud: ACTIVE_REVIEWER_CLOUD,
|
||||
on_continuation: (n, len) =>
|
||||
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "note",
|
||||
append({ turn, role: "reviewer", model: ACTIVE_REVIEWER, kind: "note",
|
||||
content: { continuation: n, combined_chars: len } }),
|
||||
},
|
||||
);
|
||||
@@ -462,11 +620,11 @@ async function runAgentFill(
|
||||
try {
|
||||
revAction = parseAction(revRaw, "reviewer");
|
||||
} catch (e) {
|
||||
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "error",
|
||||
append({ turn, role: "reviewer", model: ACTIVE_REVIEWER, kind: "error",
|
||||
content: { message: (e as Error).message, raw: revRaw.slice(0, 300) } });
|
||||
throw e;
|
||||
}
|
||||
append({ turn, role: "reviewer", model: REVIEWER_MODEL,
|
||||
append({ turn, role: "reviewer", model: ACTIVE_REVIEWER,
|
||||
kind: "critique", content: revAction });
|
||||
|
||||
if (revAction.kind !== "critique") throw new Error(`reviewer emitted non-critique: ${revAction.kind}`);
|
||||
@@ -491,7 +649,7 @@ async function runAgentFill(
|
||||
throw new Error(`consensus proposed excluded worker ${f.candidate_id}`);
|
||||
}
|
||||
}
|
||||
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "consensus_done",
|
||||
append({ turn, role: "reviewer", model: ACTIVE_REVIEWER, kind: "consensus_done",
|
||||
content: { fills: execAction.fills } });
|
||||
sealed = { fills: execAction.fills, approach: execAction.rationale ?? "multi-agent hybrid" };
|
||||
}
|
||||
@@ -636,7 +794,7 @@ Body (4-6 lines max). Be specific about:
|
||||
- Any scenario flag: ${event.scenario_note ?? "(none)"}
|
||||
|
||||
Workers:
|
||||
${outcome.fills.map(f => `- ${f.name} (${f.reason.slice(0, 60)})`).join("\n")}
|
||||
${outcome.fills.map(f => `- ${f.name}${f.reason ? " (" + f.reason.slice(0, 60) + ")" : ""}`).join("\n")}
|
||||
|
||||
Respond with only the email. No commentary.`;
|
||||
|
||||
@@ -677,8 +835,14 @@ async function runEvent(event: FillEvent, ctx: ScenarioContext): Promise<EventRe
|
||||
|
||||
const gap_signals: string[] = [];
|
||||
let outcome: AgentFillOutcome;
|
||||
// Share the log so a drift-abort or tool-error throw still leaves us
|
||||
// with a diagnostic trail — T3 checkpoint needs SQL filters, pool
|
||||
// sizes, and reviewer drift notes to diagnose ROOT CAUSE (0-supply
|
||||
// cities, semantic fallback on empty SQL result) rather than surface
|
||||
// symptoms ("drift flagged").
|
||||
const sharedLog: LogEntry[] = [];
|
||||
try {
|
||||
outcome = await runAgentFill(task, guidanceFor(event, ctx), excludeIds);
|
||||
outcome = await runAgentFill(task, guidanceFor(event, ctx), excludeIds, sharedLog);
|
||||
} catch (e) {
|
||||
return {
|
||||
event,
|
||||
@@ -688,6 +852,7 @@ async function runEvent(event: FillEvent, ctx: ScenarioContext): Promise<EventRe
|
||||
duration_secs: (Date.now() - t0) / 1000,
|
||||
error: (e as Error).message,
|
||||
gap_signals: [`drift_or_tool: ${(e as Error).message}`],
|
||||
diagnostic_log: sharedLog,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -820,6 +985,7 @@ async function runEvent(event: FillEvent, ctx: ScenarioContext): Promise<EventRe
|
||||
pool_size: outcome.first_sql_matches,
|
||||
playbook_citations: outcome.playbook_citations,
|
||||
discovered_pattern,
|
||||
diagnostic_log: sharedLog,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -877,31 +1043,91 @@ interface OverviewCheckpoint {
|
||||
risk: string; // T3's named risk flag
|
||||
}
|
||||
|
||||
// Extract supply/diagnostic signals from the event's log so T3 can
|
||||
// reason about ROOT CAUSES (zero-supply cities, semantic fallback on
|
||||
// empty SQL results, repeated drift reasons) instead of surface
|
||||
// symptoms ("drift flagged").
|
||||
function extractDiagnostics(log: LogEntry[] | undefined): {
|
||||
sql_filters: string[];
|
||||
hybrid_row_counts: number[];
|
||||
sql_errors: string[];
|
||||
drift_reasons: string[];
|
||||
} {
|
||||
const sql_filters: string[] = [];
|
||||
const hybrid_row_counts: number[] = [];
|
||||
const sql_errors: string[] = [];
|
||||
const drift_reasons: string[] = [];
|
||||
if (!log) return { sql_filters, hybrid_row_counts, sql_errors, drift_reasons };
|
||||
for (const e of log) {
|
||||
if (e.kind === "tool_call" && (e.content?.tool === "hybrid_search" || e.content?.tool === "sql")) {
|
||||
const args = e.content?.args ?? {};
|
||||
if (args.sql_filter) sql_filters.push(String(args.sql_filter).slice(0, 200));
|
||||
else if (args.query) sql_filters.push(String(args.query).slice(0, 200));
|
||||
}
|
||||
if (e.kind === "tool_result") {
|
||||
const rows = e.content?.rows;
|
||||
if (typeof rows === "number") hybrid_row_counts.push(rows);
|
||||
const err = e.content?.error;
|
||||
if (typeof err === "string") sql_errors.push(err.slice(0, 200));
|
||||
}
|
||||
if (e.kind === "critique" && e.content?.verdict === "drift") {
|
||||
const note = e.content?.notes;
|
||||
if (typeof note === "string") drift_reasons.push(note.slice(0, 200));
|
||||
}
|
||||
}
|
||||
return { sql_filters, hybrid_row_counts, sql_errors, drift_reasons };
|
||||
}
|
||||
|
||||
// Render contract terms into a prompt-friendly block. Empty string when
|
||||
// no contract is attached — keeps legacy scenarios unaffected.
|
||||
function contractBlock(contract: ContractTerms | undefined): string {
|
||||
if (!contract) return "";
|
||||
const parts: string[] = [];
|
||||
parts.push(`deadline=${contract.deadline}`);
|
||||
if (contract.fill_requirement) parts.push(`fill=${contract.fill_requirement}`);
|
||||
if (contract.budget_per_hour_max) parts.push(`budget/hr max=$${contract.budget_per_hour_max}`);
|
||||
if (contract.budget_total_usd) parts.push(`total budget=$${contract.budget_total_usd}`);
|
||||
if (contract.local_bonus_per_hour) parts.push(`local bonus=+$${contract.local_bonus_per_hour}/hr`);
|
||||
if (contract.local_bonus_radius_mi) parts.push(`local radius=${contract.local_bonus_radius_mi}mi`);
|
||||
return `\nCONTRACT TERMS: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
async function runOverviewCheckpoint(
|
||||
event: FillEvent,
|
||||
result: EventResult,
|
||||
prior: EventResult[],
|
||||
contract?: ContractTerms,
|
||||
): Promise<OverviewCheckpoint | null> {
|
||||
if (T3_DISABLED) return null;
|
||||
if (ACTIVE_T3_DISABLED) return null;
|
||||
const start = Date.now();
|
||||
|
||||
const priorSummary = prior.slice(-3).map(p =>
|
||||
`- ${p.event.at} ${p.event.kind} ${p.event.role}×${p.event.count} → ${p.ok ? p.fills.length + "/" + p.event.count + " filled" : "FAIL"}; pool=${p.pool_size ?? "?"}; cites=${p.playbook_citations?.length ?? 0}`
|
||||
`- ${p.event.at} ${p.event.kind} ${p.event.role}×${p.event.count} in ${p.event.city},${p.event.state} → ${p.ok ? p.fills.length + "/" + p.event.count + " filled" : "FAIL(" + (p.error?.slice(0, 40) ?? "?") + ")"}; pool=${p.pool_size ?? "?"}; cites=${p.playbook_citations?.length ?? 0}`
|
||||
).join("\n");
|
||||
|
||||
const diag = extractDiagnostics(result.diagnostic_log);
|
||||
const diagBlock = result.ok
|
||||
? "" // only include for failures — successful events don't need the forensic detail
|
||||
: `\n\nFAILURE FORENSICS (raw signals the agent saw — use to diagnose root cause):\n`
|
||||
+ ` SQL filters attempted (first 3):\n${diag.sql_filters.slice(0, 3).map(f => " - " + f).join("\n") || " (none)"}\n`
|
||||
+ ` Row counts per tool call: ${diag.hybrid_row_counts.slice(0, 5).join(", ") || "(none)"}\n`
|
||||
+ (diag.sql_errors.length > 0 ? ` SQL errors: ${diag.sql_errors.slice(0, 2).join(" | ")}\n` : "")
|
||||
+ ` Reviewer drift reasons (first 2): ${diag.drift_reasons.slice(0, 2).join(" | ") || "(none recorded)"}\n`
|
||||
+ ` KEY CHECK: if row counts were non-zero but the reviewer said "no matches for ${event.city}", the SQL filter yielded 0 rows in the target city and vector search FALLED BACK to workers in OTHER cities. That is a ZERO-SUPPLY condition — pivot city or broaden role.`;
|
||||
|
||||
const thisOne = `This event: ${event.at} ${event.kind} ${event.role}×${event.count} in ${event.city}, ${event.state}. `
|
||||
+ `Outcome: ${result.ok ? "filled " + result.fills.length + "/" + event.count : "FAILED: " + (result.error ?? "unknown")}. `
|
||||
+ `Pool size: ${result.pool_size ?? "n/a"}. Turns: ${result.turns}. Playbook citations: ${result.playbook_citations?.length ?? 0}. `
|
||||
+ `Gap signals: ${result.gap_signals.join("; ") || "none"}.`;
|
||||
|
||||
const prompt = `You are the overview reviewer for a staffing coordinator agent system. A mid-day checkpoint has been triggered.
|
||||
|
||||
${contractBlock(contract)}
|
||||
Recent events (most recent last):
|
||||
${priorSummary || "(no prior events)"}
|
||||
|
||||
${thisOne}
|
||||
${thisOne}${diagBlock}
|
||||
|
||||
Your job: emit ONE risk flag (≤6 words) and ONE actionable hint (≤25 words) for the NEXT event. Be concrete: name the role, city, or worker class if relevant. Do not restate what happened. Think step by step, then output strictly as:
|
||||
Your job: emit ONE risk flag (≤8 words) and ONE actionable hint (≤40 words) for the NEXT event. Be concrete: name the role, city, worker class, OR a geographic pivot (e.g. "pivot to {city}, {state} ({distance}mi) — still in bonus radius"). Do not restate what happened. If the failure was zero-supply, your hint MUST propose a specific alternative city or role. If contract has local_bonus_radius_mi, prefer alternates within that radius before proposing farther pivots. Think step by step, then output strictly as:
|
||||
|
||||
RISK: <flag>
|
||||
HINT: <hint>`;
|
||||
@@ -937,12 +1163,85 @@ HINT: <hint>`;
|
||||
};
|
||||
}
|
||||
|
||||
async function runCrossDayLesson(ctx: ScenarioContext, checkpoints: OverviewCheckpoint[]): Promise<string | null> {
|
||||
if (T3_DISABLED) return null;
|
||||
// Phase 22 item B — rescue path. When an event fails, feed the
|
||||
// failure trace (SQL, pool, drift reasons, gap signals) to cloud T3
|
||||
// and ask for a concrete pivot: new city, new role, or new count.
|
||||
// Returns null if cloud can't help or flag says "impossible, don't
|
||||
// retry". Same diagnostic enrichment as the checkpoint prompt so
|
||||
// cloud reasons from raw signals, not symptoms.
|
||||
async function requestCloudRemediation(
|
||||
event: FillEvent,
|
||||
result: EventResult,
|
||||
contract?: ContractTerms,
|
||||
): Promise<{ remediation: CloudRemediation; duration_secs: number } | null> {
|
||||
if (ACTIVE_T3_DISABLED) return null;
|
||||
const start = Date.now();
|
||||
const diag = extractDiagnostics(result.diagnostic_log);
|
||||
|
||||
const eventDigest = ctx.results.map(r =>
|
||||
`- ${r.event.at} ${r.event.kind} ${r.event.role}×${r.event.count} ${r.event.city},${r.event.state} → ${r.ok ? r.fills.length + " filled" : "FAIL"}; pool=${r.pool_size ?? "?"}; turns=${r.turns}; cites=${r.playbook_citations?.length ?? 0}; gaps=${r.gap_signals.length}`
|
||||
).join("\n");
|
||||
const diagBlock = `SQL filters attempted (first 3):\n${diag.sql_filters.slice(0, 3).map(f => " - " + f).join("\n") || " (none)"}\n`
|
||||
+ `Row counts per tool call: ${diag.hybrid_row_counts.slice(0, 5).join(", ") || "(none)"}\n`
|
||||
+ (diag.sql_errors.length > 0 ? `SQL errors: ${diag.sql_errors.slice(0, 2).join(" | ")}\n` : "")
|
||||
+ `Reviewer drift reasons (first 3): ${diag.drift_reasons.slice(0, 3).join(" | ") || "(none recorded)"}\n`
|
||||
+ `Gap signals: ${result.gap_signals.join(" | ") || "none"}`;
|
||||
|
||||
const prompt = `You are the rescue agent for a staffing coordinator system. An event just FAILED. Diagnose root cause, then propose ONE concrete retry plan.
|
||||
|
||||
FAILED EVENT:
|
||||
at=${event.at} kind=${event.kind} role=${event.role} count=${event.count} city=${event.city} state=${event.state}
|
||||
outcome: ${result.error ?? "unknown failure"}
|
||||
turns used: ${result.turns}
|
||||
pool surfaced: ${result.pool_size ?? "n/a"}
|
||||
${contractBlock(contract)}
|
||||
|
||||
RAW DIAGNOSTICS (what the agent actually saw):
|
||||
${diagBlock}
|
||||
|
||||
Respond with a JSON object (NOTHING else, no prose before or after, no markdown):
|
||||
{
|
||||
"retry": true | false,
|
||||
"new_city": "string (same as original if no pivot)",
|
||||
"new_state": "XX (two-letter abbreviation)",
|
||||
"new_role": "string (same as original if no pivot)",
|
||||
"new_count": <integer (same as original if no pivot)>,
|
||||
"rationale": "2-3 sentences explaining the fix or why retry is futile"
|
||||
}
|
||||
|
||||
RULES:
|
||||
- Set retry=true only if you believe the pivot will likely succeed.
|
||||
- If the city has genuine zero supply, pivot to the NEAREST alternate city with comparable labor pool (name a specific one).
|
||||
- If the role is uniquely scarce, either broaden to a synonym role OR reduce count to something achievable.
|
||||
- If no pivot seems viable, set retry=false — wasting a retry is worse than declaring impossible.
|
||||
- Keep new_count realistic. Don't propose 5× in a city that clearly only has 2 workers.
|
||||
- new_city is the CITY name only (e.g. "Clarksville"). Put the two-letter state in new_state. Don't concat them.
|
||||
${contract ? `- CONTRACT AWARENESS: fill_requirement=${contract.fill_requirement ?? "preferred"}. ${contract.fill_requirement === "paramount" ? "MUST fill — splitting across more cities is acceptable if one city can't cover headcount." : "Best-effort — declining is OK if budget can't absorb pivot premium."} ${contract.local_bonus_radius_mi ? `Local bonus radius is ${contract.local_bonus_radius_mi}mi from ${event.city}, ${event.state}; prefer pivots WITHIN that radius even if they have slightly less supply, since those candidates earn the local bonus premium.` : ""} ${contract.budget_per_hour_max ? `Per-hour budget max is $${contract.budget_per_hour_max}.` : ""}` : ""}`;
|
||||
|
||||
let raw = "";
|
||||
try {
|
||||
raw = await overviewGenerate(prompt, { temperature: 0.2, max_tokens: 900 });
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
const duration_secs = (Date.now() - start) / 1000;
|
||||
try {
|
||||
const m = raw.match(/\{[\s\S]*\}/);
|
||||
if (!m) return null;
|
||||
const parsed = JSON.parse(m[0]) as CloudRemediation;
|
||||
if (typeof parsed.retry !== "boolean") return null;
|
||||
return { remediation: parsed, duration_secs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runCrossDayLesson(ctx: ScenarioContext, checkpoints: OverviewCheckpoint[]): Promise<string | null> {
|
||||
if (ACTIVE_T3_DISABLED) return null;
|
||||
|
||||
const eventDigest = ctx.results.map(r => {
|
||||
const diag = extractDiagnostics(r.diagnostic_log);
|
||||
const zeroSupply = !r.ok && diag.drift_reasons.some(n => /no match|no candidat|0 rows/i.test(n));
|
||||
const supplyTag = zeroSupply ? " [ZERO-SUPPLY: pivot city needed]" : "";
|
||||
return `- ${r.event.at} ${r.event.kind} ${r.event.role}×${r.event.count} ${r.event.city},${r.event.state} → ${r.ok ? r.fills.length + " filled" : "FAIL(" + (r.error?.slice(0, 40) ?? "?") + ")"}; pool=${r.pool_size ?? "?"}; turns=${r.turns}; cites=${r.playbook_citations?.length ?? 0}; gaps=${r.gap_signals.length}${supplyTag}`;
|
||||
}).join("\n");
|
||||
|
||||
const checkpointDigest = checkpoints.length > 0
|
||||
? checkpoints.map(c => `- after ${c.after_event} (${c.event_kind}): risk="${c.risk}" hint="${c.hint}"`).join("\n")
|
||||
@@ -950,7 +1249,7 @@ async function runCrossDayLesson(ctx: ScenarioContext, checkpoints: OverviewChec
|
||||
|
||||
const prompt = `You are the end-of-day lesson writer for a staffing coordinator agent system. The day is done. Distill it.
|
||||
|
||||
Client: ${ctx.spec.client} Date: ${ctx.spec.date}
|
||||
Client: ${ctx.spec.client} Date: ${ctx.spec.date}${contractBlock(ctx.spec.contract)}
|
||||
|
||||
Events that ran:
|
||||
${eventDigest}
|
||||
@@ -960,6 +1259,8 @@ ${checkpointDigest}
|
||||
|
||||
Your job: write ONE actionable lesson for future runs that face similar setups. Target audience: the agent tomorrow. Keep the lesson to 3-5 sentences. No filler, no restating. Think step by step about what pattern repeated, what to pre-fetch, or what to avoid — then write the lesson as plain prose.
|
||||
|
||||
PRIORITY: If ANY event carries the [ZERO-SUPPLY: pivot city needed] tag, your lesson MUST name the specific cities that had zero supply and propose the nearest alternate city with roughly the required role count. That is the single most valuable signal for future runs — do not bury it under generic advice. Example: "Gary, IN has no Electricians in the corpus — pivot to Chicago, IL (40mi) or Hammond, IN (10mi) for future fills."
|
||||
|
||||
LESSON:`;
|
||||
|
||||
try {
|
||||
@@ -977,7 +1278,7 @@ async function writeRetrospective(ctx: ScenarioContext): Promise<void> {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# Scenario retrospective — ${ctx.spec.client}, ${ctx.spec.date}`);
|
||||
lines.push("");
|
||||
lines.push(`Executor: \`${EXECUTOR_MODEL}\` Reviewer: \`${REVIEWER_MODEL}\` Draft: \`${DRAFT_MODEL}\` Overview(T3): \`${T3_DISABLED ? "disabled" : OVERVIEW_MODEL + (OVERVIEW_CLOUD ? " (cloud)" : "")}\``);
|
||||
lines.push(`Executor: \`${ACTIVE_EXECUTOR}\` Reviewer: \`${ACTIVE_REVIEWER}\` Draft: \`${DRAFT_MODEL}\` Overview(T3): \`${ACTIVE_T3_DISABLED ? "disabled" : OVERVIEW_MODEL + (ACTIVE_OVERVIEW_CLOUD ? " (cloud)" : "")}\``);
|
||||
lines.push(`Prior lessons loaded into executor context: **${ctx.prior_lessons.length}**${ctx.prior_lessons.length > 0 ? " (from " + ctx.prior_lessons.map(p => p.date).join(", ") + ")" : " (baseline — no prior T3 history)"}`);
|
||||
lines.push("");
|
||||
|
||||
@@ -1165,8 +1466,49 @@ async function main() {
|
||||
|
||||
const checkpoints: OverviewCheckpoint[] = [];
|
||||
|
||||
// Phase 23 refinement — per-staffer tool_level override. Fires once
|
||||
// per run. If no staffer or no tool_level, defaults hold.
|
||||
applyToolLevel(spec.staffer?.tool_level);
|
||||
|
||||
// Phase 22 — record the config snapshot each run gets. Lets the
|
||||
// error_corrections detector diff configs between fail→succeed pairs
|
||||
// and gives the KB a receipt of what was active for any given
|
||||
// outcome. Hash computed over the active model set + tool_level so
|
||||
// the same staffer running back-to-back with no config change
|
||||
// doesn't clutter the file.
|
||||
try {
|
||||
const activeModels = {
|
||||
executor: ACTIVE_EXECUTOR,
|
||||
reviewer: ACTIVE_REVIEWER,
|
||||
overview: OVERVIEW_MODEL,
|
||||
executor_cloud: String(ACTIVE_EXECUTOR_CLOUD),
|
||||
overview_cloud: String(ACTIVE_OVERVIEW_CLOUD),
|
||||
t3_disabled: String(ACTIVE_T3_DISABLED),
|
||||
tool_level: spec.staffer?.tool_level ?? "default",
|
||||
};
|
||||
const configHash = createHash("sha256")
|
||||
.update(JSON.stringify(activeModels))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
await snapshotConfig(
|
||||
configHash,
|
||||
activeModels,
|
||||
`scenario_start ${spec.client} ${spec.date} staffer=${spec.staffer?.id ?? "none"}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(` (config snapshot skipped: ${(e as Error).message})`);
|
||||
}
|
||||
|
||||
console.log(`▶ scenario: ${spec.client}, ${spec.date}, ${spec.events.length} events`);
|
||||
console.log(`▶ models: exec=${EXECUTOR_MODEL} review=${REVIEWER_MODEL} overview=${T3_DISABLED ? "disabled" : OVERVIEW_MODEL + (OVERVIEW_CLOUD ? " (cloud)" : "")}`);
|
||||
if (spec.staffer) {
|
||||
const level = spec.staffer.tool_level ?? "(default)";
|
||||
console.log(`▶ staffer: ${spec.staffer.id} ${spec.staffer.name} (${spec.staffer.role}, ${spec.staffer.tenure_months}mo, tools=${level})`);
|
||||
}
|
||||
if (spec.contract) {
|
||||
const c = spec.contract;
|
||||
console.log(`▶ contract: deadline=${c.deadline} fill=${c.fill_requirement ?? "preferred"}${c.budget_per_hour_max ? ` budget=$${c.budget_per_hour_max}/hr` : ""}${c.local_bonus_radius_mi ? ` local_radius=${c.local_bonus_radius_mi}mi+$${c.local_bonus_per_hour ?? 0}` : ""}`);
|
||||
}
|
||||
console.log(`▶ models: exec=${ACTIVE_EXECUTOR} review=${ACTIVE_REVIEWER} overview=${ACTIVE_T3_DISABLED ? "disabled" : OVERVIEW_MODEL + (ACTIVE_OVERVIEW_CLOUD ? " (cloud)" : "")}`);
|
||||
console.log(`▶ out: ${out_dir}\n`);
|
||||
|
||||
for (let i = 0; i < spec.events.length; i++) {
|
||||
@@ -1193,19 +1535,127 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runEvent(event, ctx);
|
||||
let result = await runEvent(event, ctx);
|
||||
|
||||
// Phase 22 item B — cloud rescue on failure. When an event fails
|
||||
// and LH_RETRY_ON_FAIL is on (default on when cloud T3 is on), ask
|
||||
// cloud for a concrete pivot and re-run the event with the new
|
||||
// (city, role, count). Capped at 1 retry per event to keep the
|
||||
// budget bounded and avoid infinite loops on genuinely-impossible
|
||||
// scenarios.
|
||||
if (!result.ok && ACTIVE_RETRY_ON_FAIL && !ACTIVE_T3_DISABLED) {
|
||||
console.log(` ▶ cloud rescue requested for ${event.at} ${event.kind}…`);
|
||||
const rescue = await requestCloudRemediation(event, result, spec.contract);
|
||||
if (rescue && rescue.remediation.retry) {
|
||||
const r = rescue.remediation;
|
||||
// Sanitize cloud's fields — model sometimes still packs state
|
||||
// into city ("Hammond, IN") even when prompted to split; guard
|
||||
// by stripping comma+ from new_city and harvesting the trailing
|
||||
// state if new_state is missing.
|
||||
const cityRaw = (r.new_city ?? event.city).trim();
|
||||
const cityParts = cityRaw.split(",").map(s => s.trim());
|
||||
const cityClean = cityParts[0] || event.city;
|
||||
const stateFromCity = cityParts[1]?.match(/^([A-Z]{2})/i)?.[1]?.toUpperCase() ?? "";
|
||||
const newEvent: FillEvent = {
|
||||
...event,
|
||||
city: cityClean,
|
||||
state: (r.new_state?.trim() || stateFromCity || event.state).toUpperCase(),
|
||||
role: r.new_role ?? event.role,
|
||||
count: r.new_count ?? event.count,
|
||||
scenario_note: `[cloud-rescue ${rescue.duration_secs.toFixed(1)}s] ${r.rationale}`,
|
||||
};
|
||||
const noChange = newEvent.city === event.city
|
||||
&& newEvent.role === event.role
|
||||
&& newEvent.count === event.count;
|
||||
if (noChange) {
|
||||
console.log(` (cloud rescue declined — no actual pivot proposed)`);
|
||||
} else {
|
||||
console.log(` retry: ${event.role}×${event.count} ${event.city},${event.state} → ${newEvent.role}×${newEvent.count} ${newEvent.city},${newEvent.state}`);
|
||||
const retryResult = await runEvent(newEvent, ctx);
|
||||
// Annotate original result with the retry attempt so the
|
||||
// retrospective can show both. Retry becomes THE result if it
|
||||
// succeeded; otherwise original stays the primary outcome and
|
||||
// retry is recorded alongside.
|
||||
const originalForRecord = event;
|
||||
const origTurnsDur = { turns: result.turns, duration_secs: result.duration_secs };
|
||||
const retryAnnotation = {
|
||||
retry_attempt: 1,
|
||||
retry_remediation: {
|
||||
proposed_city: r.new_city,
|
||||
proposed_role: r.new_role,
|
||||
proposed_count: r.new_count,
|
||||
rationale: r.rationale,
|
||||
cloud_model: OVERVIEW_MODEL,
|
||||
cloud_duration_secs: rescue.duration_secs,
|
||||
},
|
||||
retry_result: {
|
||||
event: retryResult.event,
|
||||
ok: retryResult.ok,
|
||||
fills: retryResult.fills,
|
||||
turns: retryResult.turns,
|
||||
duration_secs: retryResult.duration_secs,
|
||||
error: retryResult.error,
|
||||
gap_signals: retryResult.gap_signals,
|
||||
sources_first_score: retryResult.sources_first_score,
|
||||
sources_last_score: retryResult.sources_last_score,
|
||||
pool_size: retryResult.pool_size,
|
||||
playbook_citations: retryResult.playbook_citations,
|
||||
discovered_pattern: retryResult.discovered_pattern,
|
||||
},
|
||||
original_event: originalForRecord,
|
||||
};
|
||||
if (retryResult.ok) {
|
||||
// Promote retry to primary result — the coordinator cares
|
||||
// about the final outcome. Keep original_event so the
|
||||
// pivot is visible in the retrospective.
|
||||
result = { ...retryResult, ...retryAnnotation, retry_attempt: 1 };
|
||||
} else {
|
||||
// Retry also failed. Keep original as primary, annotate
|
||||
// with what was tried.
|
||||
result = { ...result, ...retryAnnotation };
|
||||
}
|
||||
console.log(` retry outcome: ${retryResult.ok ? "✓ filled " + retryResult.fills.length + "/" + newEvent.count : "✗ " + (retryResult.error?.slice(0, 80) ?? "unknown")}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` (cloud rescue: ${rescue ? "retry=false" : "unavailable"})`);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.results.push(result);
|
||||
for (const s of result.gap_signals) {
|
||||
const [category, ...rest] = s.split(":");
|
||||
ctx.gap_signals.push({ event: event.at, category: category.trim(), detail: rest.join(":").trim() });
|
||||
}
|
||||
|
||||
// Phase 24 — emit outcome to observer so its ERROR_ANALYZER and
|
||||
// PLAYBOOK_BUILDER loops see scenario events alongside MCP ops.
|
||||
// Best-effort; observer being down doesn't affect scenario flow.
|
||||
postObserverEvent({
|
||||
timestamp: new Date().toISOString(),
|
||||
endpoint: "scenario:fill",
|
||||
success: result.ok,
|
||||
duration_ms: Math.round((result.duration_secs ?? 0) * 1000),
|
||||
staffer_id: spec.staffer?.id,
|
||||
sig_hash: computeSignature(spec),
|
||||
event_kind: result.event.kind,
|
||||
role: result.event.role,
|
||||
city: result.event.city,
|
||||
state: result.event.state,
|
||||
count: result.event.count,
|
||||
error: result.error,
|
||||
rescue_attempted: !!result.retry_remediation,
|
||||
rescue_succeeded: !!result.retry_remediation && result.ok,
|
||||
output_summary: result.ok
|
||||
? `filled ${result.fills.length}/${result.event.count} in ${result.turns} turns, ${result.playbook_citations?.length ?? 0} cites`
|
||||
: `FAIL: ${(result.error ?? "unknown").slice(0, 80)}`,
|
||||
}).catch(() => {});
|
||||
|
||||
// Option B — T3 checkpoint after every misplacement, and every N-th event.
|
||||
const isLast = i === spec.events.length - 1;
|
||||
const nthHit = T3_CHECKPOINT_EVERY > 0 && ((i + 1) % T3_CHECKPOINT_EVERY === 0);
|
||||
const shouldCheckpoint = !T3_DISABLED && (event.kind === "misplacement" || nthHit || isLast);
|
||||
const shouldCheckpoint = !ACTIVE_T3_DISABLED && (event.kind === "misplacement" || nthHit || isLast);
|
||||
if (shouldCheckpoint) {
|
||||
const cp = await runOverviewCheckpoint(event, result, ctx.results.slice(0, -1));
|
||||
const cp = await runOverviewCheckpoint(event, result, ctx.results.slice(0, -1), spec.contract);
|
||||
if (cp) {
|
||||
checkpoints.push(cp);
|
||||
await appendFile(join(out_dir, "checkpoints.jsonl"), JSON.stringify(cp) + "\n");
|
||||
@@ -1224,7 +1674,7 @@ async function main() {
|
||||
// Option A — T3 cross-day lesson. One final call distills the whole run.
|
||||
// Saved to lesson.md and also seeded into playbook_memory so tomorrow's
|
||||
// agent can retrieve it on similar setups.
|
||||
if (!T3_DISABLED) {
|
||||
if (!ACTIVE_T3_DISABLED) {
|
||||
console.log(`\n▶ T3 cross-day lesson via ${OVERVIEW_MODEL}…`);
|
||||
const tLesson = Date.now();
|
||||
const lesson = await runCrossDayLesson(ctx, checkpoints);
|
||||
@@ -1261,7 +1711,7 @@ async function main() {
|
||||
events_ok: ctx.results.filter(r => r.ok).length,
|
||||
checkpoint_count: checkpoints.length,
|
||||
model: OVERVIEW_MODEL,
|
||||
cloud: OVERVIEW_CLOUD,
|
||||
cloud: ACTIVE_OVERVIEW_CLOUD,
|
||||
lesson: lesson.trim(),
|
||||
checkpoints: checkpoints.map(c => ({ after: c.after_event, risk: c.risk, hint: c.hint })),
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -1282,16 +1732,21 @@ async function main() {
|
||||
// run ends → KB updates → next run reads rec at startup.
|
||||
try {
|
||||
const elapsed = (Date.now() - runStart) / 1000;
|
||||
const { sig_hash } = await indexRun(out_dir, spec, {
|
||||
executor: EXECUTOR_MODEL,
|
||||
reviewer: REVIEWER_MODEL,
|
||||
overview: OVERVIEW_MODEL,
|
||||
overview_cloud: OVERVIEW_CLOUD,
|
||||
}, elapsed);
|
||||
const { sig_hash } = await indexRun(
|
||||
out_dir,
|
||||
{ client: spec.client, date: spec.date, events: spec.events, staffer: spec.staffer },
|
||||
{
|
||||
executor: ACTIVE_EXECUTOR,
|
||||
reviewer: ACTIVE_REVIEWER,
|
||||
overview: OVERVIEW_MODEL,
|
||||
overview_cloud: ACTIVE_OVERVIEW_CLOUD,
|
||||
},
|
||||
elapsed,
|
||||
);
|
||||
console.log(`▶ KB indexed: sig=${sig_hash} (${elapsed.toFixed(1)}s)`);
|
||||
const newRec = await recommendFor(spec, {
|
||||
overview_model: OVERVIEW_MODEL,
|
||||
cloud: OVERVIEW_CLOUD,
|
||||
cloud: ACTIVE_OVERVIEW_CLOUD,
|
||||
k: 5,
|
||||
});
|
||||
if (newRec) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"count": 20,
|
||||
"seed": 1337,
|
||||
"scenarios": [
|
||||
{
|
||||
"file": "scen_000_Great_Lakes_Mfg_Cincinnati.json",
|
||||
"client": "Great Lakes Mfg",
|
||||
"city": "Cincinnati",
|
||||
"events": 4
|
||||
},
|
||||
{
|
||||
"file": "scen_001_Parallel_Machining_Joliet.json",
|
||||
"client": "Parallel Machining",
|
||||
"city": "Joliet",
|
||||
"events": 2
|
||||
},
|
||||
{
|
||||
"file": "scen_002_Summit_Industrial_Cincinnati.json",
|
||||
"client": "Summit Industrial",
|
||||
"city": "Cincinnati",
|
||||
"events": 3
|
||||
},
|
||||
{
|
||||
"file": "scen_003_Pioneer_Assembly_Chicago.json",
|
||||
"client": "Pioneer Assembly",
|
||||
"city": "Chicago",
|
||||
"events": 1
|
||||
},
|
||||
{
|
||||
"file": "scen_004_Midway_Distribution_Columbus.json",
|
||||
"client": "Midway Distribution",
|
||||
"city": "Columbus",
|
||||
"events": 2
|
||||
},
|
||||
{
|
||||
"file": "scen_005_Apex_Warehouse_Cleveland.json",
|
||||
"client": "Apex Warehouse",
|
||||
"city": "Cleveland",
|
||||
"events": 3
|
||||
},
|
||||
{
|
||||
"file": "scen_006_Pioneer_Assembly_Flint.json",
|
||||
"client": "Pioneer Assembly",
|
||||
"city": "Flint",
|
||||
"events": 5
|
||||
},
|
||||
{
|
||||
"file": "scen_007_Riverfront_Steel_Toledo.json",
|
||||
"client": "Riverfront Steel",
|
||||
"city": "Toledo",
|
||||
"events": 3
|
||||
},
|
||||
{
|
||||
"file": "scen_008_Northland_Logistics_Indianapolis.json",
|
||||
"client": "Northland Logistics",
|
||||
"city": "Indianapolis",
|
||||
"events": 4
|
||||
},
|
||||
{
|
||||
"file": "scen_009_Parallel_Machining_Flint.json",
|
||||
"client": "Parallel Machining",
|
||||
"city": "Flint",
|
||||
"events": 3
|
||||
},
|
||||
{
|
||||
"file": "scen_010_Northland_Logistics_Chicago.json",
|
||||
"client": "Northland Logistics",
|
||||
"city": "Chicago",
|
||||
"events": 2
|
||||
},
|
||||
{
|
||||
"file": "scen_011_Heritage_Foods_Flint.json",
|
||||
"client": "Heritage Foods",
|
||||
"city": "Flint",
|
||||
"events": 3
|
||||
},
|
||||
{
|
||||
"file": "scen_012_Parallel_Machining_Kansas_City.json",
|
||||
"client": "Parallel Machining",
|
||||
"city": "Kansas City",
|
||||
"events": 3
|
||||
},
|
||||
{
|
||||
"file": "scen_013_Horizon_Supply_Flint.json",
|
||||
"client": "Horizon Supply",
|
||||
"city": "Flint",
|
||||
"events": 3
|
||||
},
|
||||
{
|
||||
"file": "scen_014_Midway_Distribution_Indianapolis.json",
|
||||
"client": "Midway Distribution",
|
||||
"city": "Indianapolis",
|
||||
"events": 4
|
||||
},
|
||||
{
|
||||
"file": "scen_015_Cornerstone_Fabrication_Kansas_City.json",
|
||||
"client": "Cornerstone Fabrication",
|
||||
"city": "Kansas City",
|
||||
"events": 4
|
||||
},
|
||||
{
|
||||
"file": "scen_016_Riverfront_Steel_Columbus.json",
|
||||
"client": "Riverfront Steel",
|
||||
"city": "Columbus",
|
||||
"events": 4
|
||||
},
|
||||
{
|
||||
"file": "scen_017_Summit_Industrial_Detroit.json",
|
||||
"client": "Summit Industrial",
|
||||
"city": "Detroit",
|
||||
"events": 2
|
||||
},
|
||||
{
|
||||
"file": "scen_018_Heritage_Foods_Cincinnati.json",
|
||||
"client": "Heritage Foods",
|
||||
"city": "Cincinnati",
|
||||
"events": 4
|
||||
},
|
||||
{
|
||||
"file": "scen_019_Midway_Distribution_Chicago.json",
|
||||
"client": "Midway Distribution",
|
||||
"city": "Chicago",
|
||||
"events": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"client": "Riverline Logistics — Nashville Downtown Build-Out",
|
||||
"date": "2026-05-05",
|
||||
"contract": {
|
||||
"deadline": "2026-05-19",
|
||||
"budget_total_usd": 180000,
|
||||
"budget_per_hour_max": 32,
|
||||
"local_bonus_per_hour": 4,
|
||||
"local_bonus_radius_mi": 75,
|
||||
"fill_requirement": "paramount"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:00",
|
||||
"role": "Welder",
|
||||
"count": 4,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "07:00 AM",
|
||||
"scenario_note": "Skilled welding for downtown structural assembly. Prefer local (in 75mi). Contract pays up to $32/hr + $4/hr local bonus. Fill is paramount — the contract is worth $180K total, broken = penalty."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "08:30",
|
||||
"role": "Packaging Operator",
|
||||
"count": 6,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "08:30 AM",
|
||||
"scenario_note": "Warehouse packaging on-site for the same project. 6 workers preferred Nashville-local; out-of-area acceptable if local pool is exhausted. Respect budget."
|
||||
},
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "09:00",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 2,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "09:00 AM",
|
||||
"scenario_note": "Administrative/shipping coordination role. Two clerks needed; tightening the load on packaging team."
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "13:00",
|
||||
"role": "Welder",
|
||||
"count": 2,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "13:00 PM",
|
||||
"deadline": "15:00",
|
||||
"scenario_note": "A subcontractor just dropped out — need 2 more Welders by 3pm. Use cloud rescue path if Nashville supply is exhausted; pivot to within-radius first (if any in 75mi), then budget-permitting out-of-area."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "15:30",
|
||||
"role": "Packaging Operator",
|
||||
"count": 1,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "15:30 PM",
|
||||
"replaces_event": "08:30",
|
||||
"scenario_note": "One packager no-showed. Single refill. Any Nashville-area candidate, budget already covered."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"client": "Great Lakes Mfg",
|
||||
"date": "2026-04-21",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "13:00",
|
||||
"role": "Material Handler",
|
||||
"count": 2,
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"shift_start": "13:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "14:30",
|
||||
"role": "Assembler",
|
||||
"count": 2,
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"shift_start": "14:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "10:30",
|
||||
"role": "Material Handler",
|
||||
"count": 3,
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"shift_start": "10:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "13:30",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 1,
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"shift_start": "13:30 AM",
|
||||
"replaces_event": "13:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"client": "Parallel Machining",
|
||||
"date": "2026-04-22",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "10:00",
|
||||
"role": "Machine Operator",
|
||||
"count": 2,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "10:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "09:30",
|
||||
"role": "Packer",
|
||||
"count": 2,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "09:30 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"client": "Summit Industrial",
|
||||
"date": "2026-04-23",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "13:30",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 1,
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"shift_start": "13:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "08:00",
|
||||
"role": "Loader",
|
||||
"count": 1,
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"shift_start": "08:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "13:00",
|
||||
"role": "Forklift Operator",
|
||||
"count": 2,
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"shift_start": "13:00 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"client": "Pioneer Assembly",
|
||||
"date": "2026-04-24",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "14:00",
|
||||
"role": "Receiving Clerk",
|
||||
"count": 3,
|
||||
"city": "Chicago",
|
||||
"state": "IL",
|
||||
"shift_start": "14:00 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"client": "Midway Distribution",
|
||||
"date": "2026-04-25",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "09:30",
|
||||
"role": "Forklift Operator",
|
||||
"count": 1,
|
||||
"city": "Columbus",
|
||||
"state": "OH",
|
||||
"shift_start": "09:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "13:00",
|
||||
"role": "Assembler",
|
||||
"count": 4,
|
||||
"city": "Columbus",
|
||||
"state": "OH",
|
||||
"shift_start": "13:00 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"client": "Apex Warehouse",
|
||||
"date": "2026-04-26",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "10:30",
|
||||
"role": "Receiving Clerk",
|
||||
"count": 1,
|
||||
"city": "Cleveland",
|
||||
"state": "OH",
|
||||
"shift_start": "10:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "15:30",
|
||||
"role": "Quality Tech",
|
||||
"count": 2,
|
||||
"city": "Cleveland",
|
||||
"state": "OH",
|
||||
"shift_start": "15:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "14:30",
|
||||
"role": "Machine Operator",
|
||||
"count": 5,
|
||||
"city": "Cleveland",
|
||||
"state": "OH",
|
||||
"shift_start": "14:30 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"client": "Pioneer Assembly",
|
||||
"date": "2026-04-27",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "13:30",
|
||||
"role": "CNC Operator",
|
||||
"count": 2,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "13:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "11:30",
|
||||
"role": "Loader",
|
||||
"count": 1,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "11:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "14:00",
|
||||
"role": "Welder",
|
||||
"count": 3,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "14:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "12:30",
|
||||
"role": "Machine Operator",
|
||||
"count": 2,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "12:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "17:00",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 1,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "17:00 AM",
|
||||
"replaces_event": "13:30"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"client": "Riverfront Steel",
|
||||
"date": "2026-04-28",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "11:00",
|
||||
"role": "Assembler",
|
||||
"count": 3,
|
||||
"city": "Toledo",
|
||||
"state": "OH",
|
||||
"shift_start": "11:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "14:00",
|
||||
"role": "Material Handler",
|
||||
"count": 1,
|
||||
"city": "Toledo",
|
||||
"state": "OH",
|
||||
"shift_start": "14:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "08:30",
|
||||
"role": "Packer",
|
||||
"count": 1,
|
||||
"city": "Toledo",
|
||||
"state": "OH",
|
||||
"shift_start": "08:30 AM",
|
||||
"replaces_event": "11:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"client": "Northland Logistics",
|
||||
"date": "2026-04-29",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "14:00",
|
||||
"role": "Quality Tech",
|
||||
"count": 1,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "14:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "12:30",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 1,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "12:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "10:00",
|
||||
"role": "Welder",
|
||||
"count": 2,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "10:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "14:30",
|
||||
"role": "Material Handler",
|
||||
"count": 1,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "14:30 AM",
|
||||
"replaces_event": "14:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"client": "Parallel Machining",
|
||||
"date": "2026-04-30",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "16:00",
|
||||
"role": "Loader",
|
||||
"count": 3,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "16:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "09:30",
|
||||
"role": "Warehouse Associate",
|
||||
"count": 2,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "09:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "12:30",
|
||||
"role": "Packer",
|
||||
"count": 3,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "12:30 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"client": "Northland Logistics",
|
||||
"date": "2026-05-01",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "15:00",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 1,
|
||||
"city": "Chicago",
|
||||
"state": "IL",
|
||||
"shift_start": "15:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "10:00",
|
||||
"role": "Warehouse Associate",
|
||||
"count": 2,
|
||||
"city": "Chicago",
|
||||
"state": "IL",
|
||||
"shift_start": "10:00 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"client": "Heritage Foods",
|
||||
"date": "2026-05-02",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "10:00",
|
||||
"role": "Receiving Clerk",
|
||||
"count": 3,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "10:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "14:30",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 4,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "14:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "08:30",
|
||||
"role": "Assembler",
|
||||
"count": 3,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "08:30 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"client": "Parallel Machining",
|
||||
"date": "2026-05-03",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "08:00",
|
||||
"role": "Warehouse Associate",
|
||||
"count": 2,
|
||||
"city": "Kansas City",
|
||||
"state": "MO",
|
||||
"shift_start": "08:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "11:30",
|
||||
"role": "Material Handler",
|
||||
"count": 2,
|
||||
"city": "Kansas City",
|
||||
"state": "MO",
|
||||
"shift_start": "11:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "09:00",
|
||||
"role": "Warehouse Associate",
|
||||
"count": 5,
|
||||
"city": "Kansas City",
|
||||
"state": "MO",
|
||||
"shift_start": "09:00 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"client": "Horizon Supply",
|
||||
"date": "2026-05-04",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "11:00",
|
||||
"role": "Forklift Operator",
|
||||
"count": 1,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "11:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "10:30",
|
||||
"role": "CNC Operator",
|
||||
"count": 1,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "10:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "16:30",
|
||||
"role": "Receiving Clerk",
|
||||
"count": 3,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "16:30 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"client": "Midway Distribution",
|
||||
"date": "2026-05-05",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "16:00",
|
||||
"role": "Assembler",
|
||||
"count": 2,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "16:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "17:30",
|
||||
"role": "Loader",
|
||||
"count": 2,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "17:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "10:30",
|
||||
"role": "Packer",
|
||||
"count": 5,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "10:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "12:00",
|
||||
"role": "Loader",
|
||||
"count": 4,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "12:00 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"client": "Cornerstone Fabrication",
|
||||
"date": "2026-05-06",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "14:00",
|
||||
"role": "Warehouse Associate",
|
||||
"count": 2,
|
||||
"city": "Kansas City",
|
||||
"state": "MO",
|
||||
"shift_start": "14:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "17:30",
|
||||
"role": "Loader",
|
||||
"count": 1,
|
||||
"city": "Kansas City",
|
||||
"state": "MO",
|
||||
"shift_start": "17:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "12:00",
|
||||
"role": "Packer",
|
||||
"count": 3,
|
||||
"city": "Kansas City",
|
||||
"state": "MO",
|
||||
"shift_start": "12:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "16:30",
|
||||
"role": "Assembler",
|
||||
"count": 1,
|
||||
"city": "Kansas City",
|
||||
"state": "MO",
|
||||
"shift_start": "16:30 AM",
|
||||
"replaces_event": "14:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"client": "Riverfront Steel",
|
||||
"date": "2026-05-07",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "12:00",
|
||||
"role": "Quality Tech",
|
||||
"count": 3,
|
||||
"city": "Columbus",
|
||||
"state": "OH",
|
||||
"shift_start": "12:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "09:30",
|
||||
"role": "Machine Operator",
|
||||
"count": 1,
|
||||
"city": "Columbus",
|
||||
"state": "OH",
|
||||
"shift_start": "09:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "17:30",
|
||||
"role": "Assembler",
|
||||
"count": 3,
|
||||
"city": "Columbus",
|
||||
"state": "OH",
|
||||
"shift_start": "17:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "15:30",
|
||||
"role": "Material Handler",
|
||||
"count": 1,
|
||||
"city": "Columbus",
|
||||
"state": "OH",
|
||||
"shift_start": "15:30 AM",
|
||||
"replaces_event": "12:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"client": "Summit Industrial",
|
||||
"date": "2026-05-08",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "13:00",
|
||||
"role": "Warehouse Associate",
|
||||
"count": 1,
|
||||
"city": "Detroit",
|
||||
"state": "MI",
|
||||
"shift_start": "13:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "10:30",
|
||||
"role": "Material Handler",
|
||||
"count": 1,
|
||||
"city": "Detroit",
|
||||
"state": "MI",
|
||||
"shift_start": "10:30 AM"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"client": "Heritage Foods",
|
||||
"date": "2026-05-09",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "12:00",
|
||||
"role": "Assembler",
|
||||
"count": 1,
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"shift_start": "12:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "17:30",
|
||||
"role": "Welder",
|
||||
"count": 1,
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"shift_start": "17:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "13:30",
|
||||
"role": "Material Handler",
|
||||
"count": 2,
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"shift_start": "13:30 AM"
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "12:00",
|
||||
"role": "Receiving Clerk",
|
||||
"count": 1,
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"shift_start": "12:00 AM",
|
||||
"replaces_event": "12:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"client": "Midway Distribution",
|
||||
"date": "2026-05-10",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "11:00",
|
||||
"role": "Machine Operator",
|
||||
"count": 2,
|
||||
"city": "Chicago",
|
||||
"state": "IL",
|
||||
"shift_start": "11:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "17:00",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 2,
|
||||
"city": "Chicago",
|
||||
"state": "IL",
|
||||
"shift_start": "17:00 AM"
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "09:30",
|
||||
"role": "Packer",
|
||||
"count": 1,
|
||||
"city": "Chicago",
|
||||
"state": "IL",
|
||||
"shift_start": "09:30 AM",
|
||||
"replaces_event": "11:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"client": "Pioneer Assembly — Indianapolis Plant Expansion",
|
||||
"date": "2026-04-24",
|
||||
"contract": {
|
||||
"deadline": "2026-05-26",
|
||||
"budget_total_usd": 220000,
|
||||
"budget_per_hour_max": 30,
|
||||
"local_bonus_per_hour": 5,
|
||||
"local_bonus_radius_mi": 60,
|
||||
"fill_requirement": "paramount"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-001",
|
||||
"name": "Maria Chen",
|
||||
"tenure_months": 48,
|
||||
"role": "senior",
|
||||
"tool_level": "full"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:30",
|
||||
"role": "Assembler",
|
||||
"count": 6,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "07:30 AM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "09:30",
|
||||
"role": "Quality Tech",
|
||||
"count": 2,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "09:30 AM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "11:00",
|
||||
"role": "Machine Operator",
|
||||
"count": 5,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "11:00 PM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "14:00",
|
||||
"role": "Machine Operator",
|
||||
"count": 3,
|
||||
"deadline": "16:00",
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "14:00 PM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "16:00",
|
||||
"role": "Assembler",
|
||||
"count": 1,
|
||||
"replaces_event": "07:30",
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "16:00 PM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"client": "Midway Distribution — Joliet DC Ramp",
|
||||
"date": "2026-04-23",
|
||||
"contract": {
|
||||
"deadline": "2026-05-12",
|
||||
"budget_total_usd": 120000,
|
||||
"budget_per_hour_max": 28,
|
||||
"local_bonus_per_hour": 3,
|
||||
"local_bonus_radius_mi": 50,
|
||||
"fill_requirement": "preferred"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-001",
|
||||
"name": "Maria Chen",
|
||||
"tenure_months": 48,
|
||||
"role": "senior",
|
||||
"tool_level": "full"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:00",
|
||||
"role": "Warehouse Associate",
|
||||
"count": 5,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "07:00 AM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "10:00",
|
||||
"role": "Forklift Operator",
|
||||
"count": 3,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "10:00 PM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "12:30",
|
||||
"role": "Picker",
|
||||
"count": 4,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "12:30 PM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "15:00",
|
||||
"role": "Forklift Operator",
|
||||
"count": 1,
|
||||
"replaces_event": "10:00",
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "15:00 PM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"client": "Riverline Logistics — Nashville Downtown Build-Out",
|
||||
"date": "2026-04-22",
|
||||
"contract": {
|
||||
"deadline": "2026-05-19",
|
||||
"budget_total_usd": 180000,
|
||||
"budget_per_hour_max": 32,
|
||||
"local_bonus_per_hour": 4,
|
||||
"local_bonus_radius_mi": 75,
|
||||
"fill_requirement": "paramount"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-001",
|
||||
"name": "Maria Chen",
|
||||
"tenure_months": 48,
|
||||
"role": "senior",
|
||||
"tool_level": "full"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:00",
|
||||
"role": "Welder",
|
||||
"count": 4,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "07:00 AM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "08:30",
|
||||
"role": "Packaging Operator",
|
||||
"count": 6,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "08:30 AM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "09:00",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 2,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "09:00 AM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "13:00",
|
||||
"role": "Welder",
|
||||
"count": 2,
|
||||
"deadline": "15:00",
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "13:00 PM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "15:30",
|
||||
"role": "Packaging Operator",
|
||||
"count": 1,
|
||||
"replaces_event": "08:30",
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "15:30 PM",
|
||||
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"client": "Pioneer Assembly — Indianapolis Plant Expansion",
|
||||
"date": "2026-04-27",
|
||||
"contract": {
|
||||
"deadline": "2026-05-26",
|
||||
"budget_total_usd": 220000,
|
||||
"budget_per_hour_max": 30,
|
||||
"local_bonus_per_hour": 5,
|
||||
"local_bonus_radius_mi": 60,
|
||||
"fill_requirement": "paramount"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-002",
|
||||
"name": "James Park",
|
||||
"tenure_months": 14,
|
||||
"role": "mid",
|
||||
"tool_level": "local"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:30",
|
||||
"role": "Assembler",
|
||||
"count": 6,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "07:30 AM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "09:30",
|
||||
"role": "Quality Tech",
|
||||
"count": 2,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "09:30 AM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "11:00",
|
||||
"role": "Machine Operator",
|
||||
"count": 5,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "11:00 PM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "14:00",
|
||||
"role": "Machine Operator",
|
||||
"count": 3,
|
||||
"deadline": "16:00",
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "14:00 PM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "16:00",
|
||||
"role": "Assembler",
|
||||
"count": 1,
|
||||
"replaces_event": "07:30",
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "16:00 PM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"client": "Midway Distribution — Joliet DC Ramp",
|
||||
"date": "2026-04-26",
|
||||
"contract": {
|
||||
"deadline": "2026-05-12",
|
||||
"budget_total_usd": 120000,
|
||||
"budget_per_hour_max": 28,
|
||||
"local_bonus_per_hour": 3,
|
||||
"local_bonus_radius_mi": 50,
|
||||
"fill_requirement": "preferred"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-002",
|
||||
"name": "James Park",
|
||||
"tenure_months": 14,
|
||||
"role": "mid",
|
||||
"tool_level": "local"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:00",
|
||||
"role": "Warehouse Associate",
|
||||
"count": 5,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "07:00 AM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "10:00",
|
||||
"role": "Forklift Operator",
|
||||
"count": 3,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "10:00 PM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "12:30",
|
||||
"role": "Picker",
|
||||
"count": 4,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "12:30 PM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "15:00",
|
||||
"role": "Forklift Operator",
|
||||
"count": 1,
|
||||
"replaces_event": "10:00",
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "15:00 PM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"client": "Riverline Logistics — Nashville Downtown Build-Out",
|
||||
"date": "2026-04-25",
|
||||
"contract": {
|
||||
"deadline": "2026-05-19",
|
||||
"budget_total_usd": 180000,
|
||||
"budget_per_hour_max": 32,
|
||||
"local_bonus_per_hour": 4,
|
||||
"local_bonus_radius_mi": 75,
|
||||
"fill_requirement": "paramount"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-002",
|
||||
"name": "James Park",
|
||||
"tenure_months": 14,
|
||||
"role": "mid",
|
||||
"tool_level": "local"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:00",
|
||||
"role": "Welder",
|
||||
"count": 4,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "07:00 AM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "08:30",
|
||||
"role": "Packaging Operator",
|
||||
"count": 6,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "08:30 AM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "09:00",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 2,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "09:00 AM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "13:00",
|
||||
"role": "Welder",
|
||||
"count": 2,
|
||||
"deadline": "15:00",
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "13:00 PM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "15:30",
|
||||
"role": "Packaging Operator",
|
||||
"count": 1,
|
||||
"replaces_event": "08:30",
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "15:30 PM",
|
||||
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"client": "Pioneer Assembly — Indianapolis Plant Expansion",
|
||||
"date": "2026-04-30",
|
||||
"contract": {
|
||||
"deadline": "2026-05-26",
|
||||
"budget_total_usd": 220000,
|
||||
"budget_per_hour_max": 30,
|
||||
"local_bonus_per_hour": 5,
|
||||
"local_bonus_radius_mi": 60,
|
||||
"fill_requirement": "paramount"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-003",
|
||||
"name": "Sam Torres",
|
||||
"tenure_months": 4,
|
||||
"role": "junior",
|
||||
"tool_level": "basic"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:30",
|
||||
"role": "Assembler",
|
||||
"count": 6,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "07:30 AM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "09:30",
|
||||
"role": "Quality Tech",
|
||||
"count": 2,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "09:30 AM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "11:00",
|
||||
"role": "Machine Operator",
|
||||
"count": 5,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "11:00 PM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "14:00",
|
||||
"role": "Machine Operator",
|
||||
"count": 3,
|
||||
"deadline": "16:00",
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "14:00 PM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "16:00",
|
||||
"role": "Assembler",
|
||||
"count": 1,
|
||||
"replaces_event": "07:30",
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "16:00 PM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"client": "Midway Distribution — Joliet DC Ramp",
|
||||
"date": "2026-04-29",
|
||||
"contract": {
|
||||
"deadline": "2026-05-12",
|
||||
"budget_total_usd": 120000,
|
||||
"budget_per_hour_max": 28,
|
||||
"local_bonus_per_hour": 3,
|
||||
"local_bonus_radius_mi": 50,
|
||||
"fill_requirement": "preferred"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-003",
|
||||
"name": "Sam Torres",
|
||||
"tenure_months": 4,
|
||||
"role": "junior",
|
||||
"tool_level": "basic"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:00",
|
||||
"role": "Warehouse Associate",
|
||||
"count": 5,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "07:00 AM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "10:00",
|
||||
"role": "Forklift Operator",
|
||||
"count": 3,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "10:00 PM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "12:30",
|
||||
"role": "Picker",
|
||||
"count": 4,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "12:30 PM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "15:00",
|
||||
"role": "Forklift Operator",
|
||||
"count": 1,
|
||||
"replaces_event": "10:00",
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "15:00 PM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"client": "Riverline Logistics — Nashville Downtown Build-Out",
|
||||
"date": "2026-04-28",
|
||||
"contract": {
|
||||
"deadline": "2026-05-19",
|
||||
"budget_total_usd": 180000,
|
||||
"budget_per_hour_max": 32,
|
||||
"local_bonus_per_hour": 4,
|
||||
"local_bonus_radius_mi": 75,
|
||||
"fill_requirement": "paramount"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-003",
|
||||
"name": "Sam Torres",
|
||||
"tenure_months": 4,
|
||||
"role": "junior",
|
||||
"tool_level": "basic"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:00",
|
||||
"role": "Welder",
|
||||
"count": 4,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "07:00 AM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "08:30",
|
||||
"role": "Packaging Operator",
|
||||
"count": 6,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "08:30 AM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "09:00",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 2,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "09:00 AM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "13:00",
|
||||
"role": "Welder",
|
||||
"count": 2,
|
||||
"deadline": "15:00",
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "13:00 PM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "15:30",
|
||||
"role": "Packaging Operator",
|
||||
"count": 1,
|
||||
"replaces_event": "08:30",
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "15:30 PM",
|
||||
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"client": "Pioneer Assembly — Indianapolis Plant Expansion",
|
||||
"date": "2026-05-03",
|
||||
"contract": {
|
||||
"deadline": "2026-05-26",
|
||||
"budget_total_usd": 220000,
|
||||
"budget_per_hour_max": 30,
|
||||
"local_bonus_per_hour": 5,
|
||||
"local_bonus_radius_mi": 60,
|
||||
"fill_requirement": "paramount"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-004",
|
||||
"name": "Alex Rivera",
|
||||
"tenure_months": 1,
|
||||
"role": "trainee",
|
||||
"tool_level": "minimal"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:30",
|
||||
"role": "Assembler",
|
||||
"count": 6,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "07:30 AM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "09:30",
|
||||
"role": "Quality Tech",
|
||||
"count": 2,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "09:30 AM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "11:00",
|
||||
"role": "Machine Operator",
|
||||
"count": 5,
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "11:00 PM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "14:00",
|
||||
"role": "Machine Operator",
|
||||
"count": 3,
|
||||
"deadline": "16:00",
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "14:00 PM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "16:00",
|
||||
"role": "Assembler",
|
||||
"count": 1,
|
||||
"replaces_event": "07:30",
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"shift_start": "16:00 PM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-26, fill=paramount."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"client": "Midway Distribution — Joliet DC Ramp",
|
||||
"date": "2026-05-02",
|
||||
"contract": {
|
||||
"deadline": "2026-05-12",
|
||||
"budget_total_usd": 120000,
|
||||
"budget_per_hour_max": 28,
|
||||
"local_bonus_per_hour": 3,
|
||||
"local_bonus_radius_mi": 50,
|
||||
"fill_requirement": "preferred"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-004",
|
||||
"name": "Alex Rivera",
|
||||
"tenure_months": 1,
|
||||
"role": "trainee",
|
||||
"tool_level": "minimal"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:00",
|
||||
"role": "Warehouse Associate",
|
||||
"count": 5,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "07:00 AM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "recurring",
|
||||
"at": "10:00",
|
||||
"role": "Forklift Operator",
|
||||
"count": 3,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "10:00 PM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "12:30",
|
||||
"role": "Picker",
|
||||
"count": 4,
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "12:30 PM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "15:00",
|
||||
"role": "Forklift Operator",
|
||||
"count": 1,
|
||||
"replaces_event": "10:00",
|
||||
"city": "Joliet",
|
||||
"state": "IL",
|
||||
"shift_start": "15:00 PM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-12, fill=preferred."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"client": "Riverline Logistics — Nashville Downtown Build-Out",
|
||||
"date": "2026-05-01",
|
||||
"contract": {
|
||||
"deadline": "2026-05-19",
|
||||
"budget_total_usd": 180000,
|
||||
"budget_per_hour_max": 32,
|
||||
"local_bonus_per_hour": 4,
|
||||
"local_bonus_radius_mi": 75,
|
||||
"fill_requirement": "paramount"
|
||||
},
|
||||
"staffer": {
|
||||
"id": "S-004",
|
||||
"name": "Alex Rivera",
|
||||
"tenure_months": 1,
|
||||
"role": "trainee",
|
||||
"tool_level": "minimal"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:00",
|
||||
"role": "Welder",
|
||||
"count": 4,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "07:00 AM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "08:30",
|
||||
"role": "Packaging Operator",
|
||||
"count": 6,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "08:30 AM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "09:00",
|
||||
"role": "Shipping Clerk",
|
||||
"count": 2,
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "09:00 AM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "13:00",
|
||||
"role": "Welder",
|
||||
"count": 2,
|
||||
"deadline": "15:00",
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "13:00 PM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "15:30",
|
||||
"role": "Packaging Operator",
|
||||
"count": 1,
|
||||
"replaces_event": "08:30",
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"shift_start": "15:30 PM",
|
||||
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-19, fill=paramount."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"count": 12,
|
||||
"scenarios": [
|
||||
{
|
||||
"file": "S-001_nashville_downtown.json",
|
||||
"staffer": "Maria Chen",
|
||||
"contract": "nashville_downtown",
|
||||
"client": "Riverline Logistics — Nashville Downtown Build-Out"
|
||||
},
|
||||
{
|
||||
"file": "S-001_joliet_warehouse.json",
|
||||
"staffer": "Maria Chen",
|
||||
"contract": "joliet_warehouse",
|
||||
"client": "Midway Distribution — Joliet DC Ramp"
|
||||
},
|
||||
{
|
||||
"file": "S-001_indianapolis_assembly.json",
|
||||
"staffer": "Maria Chen",
|
||||
"contract": "indianapolis_assembly",
|
||||
"client": "Pioneer Assembly — Indianapolis Plant Expansion"
|
||||
},
|
||||
{
|
||||
"file": "S-002_nashville_downtown.json",
|
||||
"staffer": "James Park",
|
||||
"contract": "nashville_downtown",
|
||||
"client": "Riverline Logistics — Nashville Downtown Build-Out"
|
||||
},
|
||||
{
|
||||
"file": "S-002_joliet_warehouse.json",
|
||||
"staffer": "James Park",
|
||||
"contract": "joliet_warehouse",
|
||||
"client": "Midway Distribution — Joliet DC Ramp"
|
||||
},
|
||||
{
|
||||
"file": "S-002_indianapolis_assembly.json",
|
||||
"staffer": "James Park",
|
||||
"contract": "indianapolis_assembly",
|
||||
"client": "Pioneer Assembly — Indianapolis Plant Expansion"
|
||||
},
|
||||
{
|
||||
"file": "S-003_nashville_downtown.json",
|
||||
"staffer": "Sam Torres",
|
||||
"contract": "nashville_downtown",
|
||||
"client": "Riverline Logistics — Nashville Downtown Build-Out"
|
||||
},
|
||||
{
|
||||
"file": "S-003_joliet_warehouse.json",
|
||||
"staffer": "Sam Torres",
|
||||
"contract": "joliet_warehouse",
|
||||
"client": "Midway Distribution — Joliet DC Ramp"
|
||||
},
|
||||
{
|
||||
"file": "S-003_indianapolis_assembly.json",
|
||||
"staffer": "Sam Torres",
|
||||
"contract": "indianapolis_assembly",
|
||||
"client": "Pioneer Assembly — Indianapolis Plant Expansion"
|
||||
},
|
||||
{
|
||||
"file": "S-004_nashville_downtown.json",
|
||||
"staffer": "Alex Rivera",
|
||||
"contract": "nashville_downtown",
|
||||
"client": "Riverline Logistics — Nashville Downtown Build-Out"
|
||||
},
|
||||
{
|
||||
"file": "S-004_joliet_warehouse.json",
|
||||
"staffer": "Alex Rivera",
|
||||
"contract": "joliet_warehouse",
|
||||
"client": "Midway Distribution — Joliet DC Ramp"
|
||||
},
|
||||
{
|
||||
"file": "S-004_indianapolis_assembly.json",
|
||||
"staffer": "Alex Rivera",
|
||||
"contract": "indianapolis_assembly",
|
||||
"client": "Pioneer Assembly — Indianapolis Plant Expansion"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"client": "Ironclad Industrial",
|
||||
"date": "2026-04-22",
|
||||
"events": [
|
||||
{
|
||||
"kind": "baseline_fill",
|
||||
"at": "07:00",
|
||||
"role": "Electrician",
|
||||
"count": 5,
|
||||
"city": "Gary",
|
||||
"state": "IN",
|
||||
"shift_start": "07:00 AM",
|
||||
"scenario_note": "Gary IN has ZERO Electricians in the index. Local WILL fail this. Cloud should diagnose no-supply and recommend pivoting to Chicago IL (40min drive) or relaxing to 'Maintenance Tech'."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "09:30",
|
||||
"role": "Safety Coordinator",
|
||||
"count": 8,
|
||||
"city": "Peoria",
|
||||
"state": "IL",
|
||||
"shift_start": "09:30 AM",
|
||||
"scenario_note": "Safety Coordinator is the rarest role overall (~4500 nationally). 8× in a mid-sized city with availability > 0.5 is genuinely tight. Cloud should either confirm or suggest multi-city sourcing."
|
||||
},
|
||||
{
|
||||
"kind": "emergency",
|
||||
"at": "11:45",
|
||||
"role": "Welder",
|
||||
"count": 3,
|
||||
"city": "Flint",
|
||||
"state": "MI",
|
||||
"shift_start": "12:00 PM",
|
||||
"deadline": "13:30",
|
||||
"scenario_note": "Flint MI has ZERO workers indexed — total data desert. Cloud must flag 'impossible supply' and recommend pivot (Detroit 60mi, Saginaw 40mi)."
|
||||
},
|
||||
{
|
||||
"kind": "expansion",
|
||||
"at": "14:00",
|
||||
"role": "Tool & Die Maker",
|
||||
"count": 4,
|
||||
"city": "Grand Rapids",
|
||||
"state": "MI",
|
||||
"shift_start": "14:00 PM",
|
||||
"scenario_note": "Tool & Die Maker is scarce (~9000 total). 4× in Grand Rapids, availability > 0.5 AND reliability > 0.75. Tight but solvable if playbook_memory has history; cloud should prioritize proven performers."
|
||||
},
|
||||
{
|
||||
"kind": "misplacement",
|
||||
"at": "15:30",
|
||||
"role": "Electrician",
|
||||
"count": 1,
|
||||
"city": "Gary",
|
||||
"state": "IN",
|
||||
"shift_start": "15:30 PM",
|
||||
"replaces_event": "07:00",
|
||||
"scenario_note": "Refilling 1× Electrician in Gary after a no-show. Same data desert as event 1 — cloud should recognize the repeat and recommend the SAME pivot it gave earlier, proving it learns within-run."
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user