Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
640db8c63c | ||
|
|
138592dc56 | ||
|
|
e0a843d1a5 | ||
|
|
3fb3a60da4 | ||
|
|
52561d10d3 | ||
|
|
b95dd86556 | ||
|
|
137aed64fb | ||
|
|
ad0edbe29c | ||
|
|
5e89407939 | ||
|
|
6b71c8e9b2 | ||
|
|
a7fc8e2256 | ||
|
|
c21b261877 | ||
|
|
a663698571 | ||
|
|
330cb90f99 | ||
|
|
9c1400d738 | ||
|
|
0c4868c191 | ||
|
|
6e7ca1830e | ||
|
|
03d723e7e6 | ||
|
|
e4ae5b646e | ||
|
|
0ff091c173 | ||
|
|
af3856b103 | ||
|
|
a117ae8b38 | ||
|
|
6287558493 | ||
|
|
23eb04a145 | ||
|
|
468798c9ac | ||
|
|
76bfa2c8d7 | ||
|
|
05f2e42c45 | ||
|
|
bb1b471c67 | ||
|
|
2595d48535 | ||
|
|
1f56630d5d | ||
|
|
cdd12a1438 | ||
|
|
4aea71d213 | ||
|
|
72ee8f006f | ||
|
|
99ab0fe623 | ||
|
|
5c39c74fe4 | ||
|
|
f8e8d25b5f | ||
|
|
1274ab2cb3 | ||
|
|
95c26f04f8 | ||
|
|
20b0289aa9 |
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"_description": "Lakehouse model matrix — authoritative routing for all agent tiers. Local models do the heavy lifting; cloud models are consulted sparingly for overview, strategic, and gatekeeper decisions. Read by tests/multi-agent/scenario.ts and mcp-server/index.ts.",
|
||||
"version": 1,
|
||||
"updated": "2026-04-21",
|
||||
"providers": {
|
||||
"ollama_local": {
|
||||
"base_url": "http://localhost:11434",
|
||||
"key_env": null
|
||||
},
|
||||
"ollama_cloud": {
|
||||
"base_url": "https://ollama.com",
|
||||
"key_env": "OLLAMA_CLOUD_KEY",
|
||||
"key_source": "/root/llm_team_config.json → providers.ollama_cloud.api_key",
|
||||
"rate_budget": {
|
||||
"calls_per_hour": 200,
|
||||
"calls_per_day": 2000,
|
||||
"notes": "Paid tier — generous. Policy: keep overview calls ≤ 3/scenario, strategic ≤ 10/day, gatekeeper ≤ 5/day."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tiers": {
|
||||
"t1_hot": {
|
||||
"purpose": "Per tool call — SQL generation, hybrid_search, sql(). Runs 50-200 times per scenario. Latency-sensitive.",
|
||||
"kind": "local_fast",
|
||||
"primary": { "model": "qwen3.5:latest", "provider": "ollama_local", "context_window": 262144 },
|
||||
"fallback": { "model": "qwen2.5:latest", "provider": "ollama_local", "context_window": 32768 },
|
||||
"max_tokens": 1000,
|
||||
"temperature": 0.3,
|
||||
"never_route_cloud": true,
|
||||
"context_budget": {
|
||||
"system_prompt_cap": 4000,
|
||||
"prior_context_cap": 6000,
|
||||
"tool_results_cap": 8000,
|
||||
"safety_margin": 2000,
|
||||
"overflow_policy": "summarize_oldest_tool_results_via_t3"
|
||||
},
|
||||
"rationale": "qwen3.5:latest is a 9.7B thinking model with 262K context that emits clean JSON (verified 2026-04-21 after mistral A/B wipeout). Thinking budget requires max_tokens ≥ 400; we hold 1000 to keep propose_done payloads intact. qwen2.5 stays as fallback."
|
||||
},
|
||||
"t2_review": {
|
||||
"purpose": "Per step consensus — executor ↔ reviewer loop critique. 5-14 calls per event.",
|
||||
"kind": "local_balanced",
|
||||
"primary": { "model": "qwen3:latest", "provider": "ollama_local", "context_window": 40960 },
|
||||
"fallback": { "model": "qwen2.5:latest", "provider": "ollama_local", "context_window": 32768 },
|
||||
"max_tokens": 800,
|
||||
"temperature": 0.3,
|
||||
"never_route_cloud": true,
|
||||
"context_budget": {
|
||||
"system_prompt_cap": 2000,
|
||||
"recent_turns_cap": 4000,
|
||||
"safety_margin": 1000
|
||||
},
|
||||
"rationale": "Reviewer only needs to detect schema violations and drift — a 7B model is sufficient."
|
||||
},
|
||||
"t3_overview": {
|
||||
"purpose": "Mid-day checkpoint after every misplacement + every Nth event, and cross-day lesson. 1-3 calls per scenario.",
|
||||
"kind": "thinking_cloud",
|
||||
"primary": { "model": "gpt-oss:120b", "provider": "ollama_cloud", "context_window": 131072 },
|
||||
"local_fallback": { "model": "gpt-oss:20b", "provider": "ollama_local", "context_window": 131072 },
|
||||
"max_tokens": 900,
|
||||
"temperature": 0.2,
|
||||
"cloud_budget_per_scenario": 5,
|
||||
"env_flag": "LH_OVERVIEW_CLOUD=1",
|
||||
"context_budget": {
|
||||
"event_digest_cap": 30000,
|
||||
"checkpoint_cap": 8000,
|
||||
"lesson_corpus_cap": 40000,
|
||||
"safety_margin": 8000,
|
||||
"overflow_policy": "chunk_lessons_via_cosine_topk"
|
||||
},
|
||||
"rationale": "Same prompt family as local 20b (gpt-oss series) — prompts port directly. 120b is faster via cloud than 20b local in practice, and lessons are noticeably more specific."
|
||||
},
|
||||
"t4_strategic": {
|
||||
"purpose": "Daily playbook board re-ranking, weekly gap audit, pattern discovery across accumulated playbooks. 1-10 calls per day.",
|
||||
"kind": "thinking_cloud_large",
|
||||
"primary": { "model": "kimi-k2.6", "provider": "ollama_cloud", "context_window": 200000 },
|
||||
"secondary": { "model": "qwen3.5:397b", "provider": "ollama_cloud", "context_window": 131072 },
|
||||
"fallback": { "model": "glm-4.7", "provider": "ollama_cloud", "context_window": 131072 },
|
||||
"local_fallback": { "model": "gpt-oss:20b", "provider": "ollama_local", "context_window": 131072 },
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0.2,
|
||||
"cloud_budget_per_day": 10,
|
||||
"context_budget": {
|
||||
"playbook_corpus_cap": 80000,
|
||||
"pattern_history_cap": 20000,
|
||||
"safety_margin": 16000,
|
||||
"overflow_policy": "two_pass_map_reduce"
|
||||
},
|
||||
"rationale": "J named qwen3.5 specifically. GLM-4.7 is a promising alternate for debate phase. Runs after all scenarios complete for the day."
|
||||
},
|
||||
"t5_gatekeeper": {
|
||||
"purpose": "MUST route here: architecture changes, new client onboarding, schema migrations, playbook retirements, index rebuilds, autotune config changes.",
|
||||
"kind": "thinking_cloud_deepest",
|
||||
"primary": { "model": "kimi-k2-thinking", "provider": "ollama_cloud", "context_window": 200000 },
|
||||
"fallback": { "model": "deepseek-v3.1:671b", "provider": "ollama_cloud", "context_window": 131072 },
|
||||
"secondary_fallback": { "model": "qwen3.5:397b", "provider": "ollama_cloud", "context_window": 131072 },
|
||||
"local_fallback": { "model": "gpt-oss:20b", "provider": "ollama_local", "context_window": 131072 },
|
||||
"max_tokens": 4000,
|
||||
"temperature": 0.1,
|
||||
"cloud_budget_per_day": 5,
|
||||
"audit_log": true,
|
||||
"context_budget": {
|
||||
"decision_doc_cap": 50000,
|
||||
"evidence_bundle_cap": 100000,
|
||||
"prior_gatekeeper_decisions_cap": 20000,
|
||||
"safety_margin": 20000,
|
||||
"overflow_policy": "escalate_to_kimi_k2_1t_or_split_decision"
|
||||
},
|
||||
"rationale": "Highest-stakes decisions — reasoning depth matters more than latency. Audit log so J can always see what the gatekeeper was asked and what it answered. No human approval required today; escalate later if mis-decisions show up."
|
||||
}
|
||||
},
|
||||
|
||||
"embeddings": {
|
||||
"_description": "Vector embedding models for hybrid search + playbook_memory similarity. Always local — embedding calls are too high-volume for cloud. qwen3-embedding is the new primary (2026-04-21); nomic-embed-text-v2-moe is the MoE alternative for corpus-scale embedding; original nomic-embed-text stays as legacy fallback while vectord indexes are being re-embedded.",
|
||||
"primary": { "model": "qwen3-embedding", "provider": "ollama_local", "dim": 1024, "context_window": 32768 },
|
||||
"alternate": { "model": "nomic-embed-text-v2-moe", "provider": "ollama_local", "dim": 768, "context_window": 2048, "notes": "MoE variant — better for long documents, slower per-call, roughly matches nomic-embed-text on short text." },
|
||||
"legacy": { "model": "nomic-embed-text:latest", "provider": "ollama_local", "dim": 768, "context_window": 8192, "notes": "Used by existing vectord indexes. Do NOT switch live until indexes are rebuilt, or hybrid search recall will crater." },
|
||||
"migration_plan": "Re-embed workers_500k (and all production indexes) with qwen3-embedding, keep the old index under a _v1 suffix for rollback, flip the profile pointer only after recall benchmarks match or exceed legacy. Touches vectord::agent + autotune."
|
||||
},
|
||||
|
||||
"context_management": {
|
||||
"_description": "Rule zero: NEVER call a model with more tokens than its context_window minus safety_margin. Every call goes through the budget checker first. If over budget → chunk, summarize, or escalate. This is the stability floor.",
|
||||
"token_estimator": {
|
||||
"method": "chars_div_4",
|
||||
"note": "Rough, biased safe by ~15%. For production, swap to tiktoken or the provider's tokenizer endpoint."
|
||||
},
|
||||
"overflow_policies": {
|
||||
"summarize_oldest_tool_results_via_t3": {
|
||||
"when": "T1 conversation history + tool results exceed context_budget.tool_results_cap",
|
||||
"how": "Send oldest N tool results to T3 with prompt 'summarize these into 500 tokens that preserve what the executor needs to know'; replace them with the summary in the running conversation."
|
||||
},
|
||||
"chunk_lessons_via_cosine_topk": {
|
||||
"when": "lesson corpus in data/_playbook_lessons/*.json exceeds lesson_corpus_cap",
|
||||
"how": "Embed the current scenario spec, cosine-rank all lessons, take top-K until budget exhausted. Fall back to date-sorted if embeddings unavailable."
|
||||
},
|
||||
"two_pass_map_reduce": {
|
||||
"when": "T4 playbook corpus exceeds playbook_corpus_cap",
|
||||
"how": "Pass 1: chunk playbooks into ≤30K token shards, run primary model on each shard to emit 'shard summary'. Pass 2: feed all summaries to primary model for global synthesis. Logged as two audit entries."
|
||||
},
|
||||
"escalate_to_kimi_k2_1t_or_split_decision": {
|
||||
"when": "T5 decision evidence exceeds decision_doc_cap + evidence_bundle_cap",
|
||||
"how": "Prefer kimi-k2:1t which has 1M context. If still over, split decision into sub-decisions (e.g. 'retire playbooks by city' instead of 'retire playbooks globally') and loop."
|
||||
}
|
||||
},
|
||||
"chunking_cache": {
|
||||
"_description": "Precomputed shards of the playbook corpus, indexed by (corpus_version, shard_id). Avoids re-chunking on every T4 run.",
|
||||
"location": "data/_chunk_cache/",
|
||||
"invalidation": "Key includes corpus hash. When playbook_memory changes, the hash changes, the cache misses, and chunks regenerate.",
|
||||
"implementation_status": "SPEC — next sprint."
|
||||
},
|
||||
"implementation_status": "context_window + context_budget fields WIRED in config. Enforcement helper NOT yet wired in agent.ts. Next: add estimateTokens() + budgetCheck() helpers, route all generate() calls through them, emit a metric when overflow policy fires."
|
||||
},
|
||||
"experimental_rotation": {
|
||||
"enabled": false,
|
||||
"purpose": "Sample newer models on a schedule to collect comparison data without rate-limit risk.",
|
||||
"candidates": [
|
||||
{ "model": "minimax-m2.7", "notes": "Newer minimax; unknown output stability" },
|
||||
{ "model": "glm-5", "notes": "GLM next-gen; larger context" },
|
||||
{ "model": "glm-5.1", "notes": "Incremental on GLM-5" },
|
||||
{ "model": "qwen3-next:80b", "notes": "Qwen's experimental successor; smaller than 3.5" },
|
||||
{ "model": "qwen3-coder-next", "notes": "Coder-optimized — good for SQL gen T1 experiments" },
|
||||
{ "model": "deepseek-v3.2", "notes": "Smaller deepseek; reasoning/coding" },
|
||||
{ "model": "nemotron-3-super", "notes": "NVIDIA 230B; general" },
|
||||
{ "model": "cogito-2.1:671b", "notes": "671B general" },
|
||||
{ "model": "mistral-large-3:675b", "notes": "Mistral's flagship; good T3 candidate" }
|
||||
],
|
||||
"rotation": "weekly",
|
||||
"sample_rate": 0.1,
|
||||
"apply_to_tier": "t4_strategic",
|
||||
"notes": "When enabled, T4 routes 10% of calls to a rotating experimental model. Log comparison in /data/_model_eval/ — if the experimental consistently beats primary across 3 rotations, promote it to primary."
|
||||
},
|
||||
"playbook_versioning": {
|
||||
"enabled": true,
|
||||
"purpose": "A playbook can work, then break when architecture changes. Versioning lets us pin which change retired which playbook.",
|
||||
"dataset": "playbook_memory",
|
||||
"schema_additions": {
|
||||
"version": "integer — auto-increment per operation",
|
||||
"parent_id": "string — previous version entry_id for same operation (null for v1)",
|
||||
"retired_at": "timestamp — set when success_rate drops or architecture changes",
|
||||
"retirement_reason": "string — e.g. 'schema_migration:workers_500k 2026-05-03'",
|
||||
"architecture_snapshot": "object — crate versions, index name, schema fingerprint at seed time"
|
||||
},
|
||||
"retire_triggers": [
|
||||
"success_rate < 0.3 over last 20 citations",
|
||||
"schema_fingerprint mismatch detected at retrieval time",
|
||||
"architecture change event emitted by ingestd/vectord",
|
||||
"T5 gatekeeper explicitly retires via /vectors/playbook_memory/retire"
|
||||
],
|
||||
"read_back_policy": "retrieval returns only non-retired versions. History endpoint /vectors/playbook_memory/history/{operation} returns the full chain.",
|
||||
"ui_surface": "mcp-server to render a diff view: side-by-side of playbook versions with a timeline of what changed and when.",
|
||||
"implementation_status": "SPEC — not yet wired. Target: next sprint. Touches gateway + catalogd + mcp-server."
|
||||
},
|
||||
"matrix_index_hybrid_search_note": "Phase 22 candidate: elevate the hybrid_search T1 tool to consult T3 when a pool returns <3 matches OR when the same (role, city) combo has failed N times in 24h. Consult result is a reformulated sql_filter the executor retries with. Keeps T1 fast on the happy path, escalates to T3 only on low-recall signals."
|
||||
}
|
||||
@@ -47,11 +47,15 @@ const STATE_KEY: &str = "_playbook_memory/state.json";
|
||||
pub const MAX_BOOST_PER_WORKER: f32 = 0.25;
|
||||
|
||||
/// Default number of past playbooks to consider when ranking the current
|
||||
/// operation. Bumped 5 → 25 on 2026-04-20 because at >100 entries in
|
||||
/// memory the old default missed too many relevant playbooks — boost
|
||||
/// silently failed even when the seeded workers were ideal matches.
|
||||
/// 25 is brute-force-cheap (sub-ms) and covers most live operator memory.
|
||||
pub const DEFAULT_TOP_K_PLAYBOOKS: usize = 25;
|
||||
/// operation. Bumped 25 → 100 on 2026-04-20 (second revision) after
|
||||
/// direct measurement showed cosine similarities cluster in a narrow band
|
||||
/// (0.55-0.67) across all playbooks regardless of geo — the embedding
|
||||
/// model doesn't discriminate city/role strongly enough. k=25 missed
|
||||
/// relevant Toledo Welder playbooks even when they existed; k=100
|
||||
/// includes them comfortably. Brute-force remains sub-ms at this size.
|
||||
/// Deeper fix: filter playbooks by (target_city, target_state) in the
|
||||
/// request before similarity ranking — deferred.
|
||||
pub const DEFAULT_TOP_K_PLAYBOOKS: usize = 100;
|
||||
|
||||
/// Half-life of a playbook's contribution to boost, in days. A playbook
|
||||
/// 30 days old contributes half what a fresh one would; 60 days old, a
|
||||
@@ -81,6 +85,46 @@ 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.
|
||||
/// Tracked per (city, state, name) so a single worker's failures on
|
||||
/// Toledo Welder contracts don't penalize the same name in Chicago.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FailureRecord {
|
||||
pub city: String,
|
||||
pub state: String,
|
||||
pub name: String,
|
||||
pub reason: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// Persisted / in-memory state.
|
||||
@@ -90,6 +134,11 @@ struct PlaybookMemoryState {
|
||||
/// Unix epoch millis when the last rebuild completed. Caller can
|
||||
/// use this to gate "stale > N hours → trigger rebuild" behavior.
|
||||
last_rebuilt_at: i64,
|
||||
/// Failed-fill records. Path 1 negative signal — every entry here
|
||||
/// dampens the positive boost for its (city, state, name) key by
|
||||
/// half per failure count, so three failures zero the boost.
|
||||
#[serde(default)]
|
||||
failures: Vec<FailureRecord>,
|
||||
}
|
||||
|
||||
/// Per-worker boost payload. `citations` lets the response layer show
|
||||
@@ -100,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 {
|
||||
@@ -113,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> {
|
||||
@@ -127,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)
|
||||
}
|
||||
@@ -143,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 {
|
||||
@@ -154,6 +412,29 @@ impl PlaybookMemory {
|
||||
self.state.read().await.entries.clone()
|
||||
}
|
||||
|
||||
/// Record failure(s). Each added `FailureRecord` is an additional
|
||||
/// penalty against that worker's positive boost for the same geo.
|
||||
pub async fn mark_failures(&self, new_failures: Vec<FailureRecord>) -> Result<usize, String> {
|
||||
if new_failures.is_empty() { return Ok(0); }
|
||||
let added = new_failures.len();
|
||||
let mut s = self.state.write().await;
|
||||
s.failures.extend(new_failures);
|
||||
drop(s);
|
||||
self.persist().await?;
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
/// Count failures per (city, state, name) key. Used by compute_boost_for
|
||||
/// to dampen positive boost.
|
||||
pub async fn failure_counts(&self) -> HashMap<(String, String, String), usize> {
|
||||
let s = self.state.read().await;
|
||||
let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
|
||||
for f in &s.failures {
|
||||
*counts.entry((f.city.clone(), f.state.clone(), f.name.clone())).or_insert(0) += 1;
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
/// Given an operation's embedding, find the top-K most similar past
|
||||
/// playbooks (by cosine similarity) and return a per-worker boost map
|
||||
/// keyed by (city, state, name). Worker is matched by the tuple so a
|
||||
@@ -170,15 +451,142 @@ impl PlaybookMemory {
|
||||
top_k_playbooks: usize,
|
||||
base_weight: f32,
|
||||
) -> HashMap<(String, String, String), BoostEntry> {
|
||||
let entries = self.state.read().await.entries.clone();
|
||||
self.compute_boost_for_filtered(query_embedding, top_k_playbooks, base_weight, None).await
|
||||
}
|
||||
|
||||
// Brute-force cosine. Empty / missing embeddings just skip.
|
||||
let mut scored: Vec<(f32, &PlaybookEntry)> = entries
|
||||
/// Same as `compute_boost_for` but only considers playbooks whose
|
||||
/// (city, state) matches the caller's target. This is the honest
|
||||
/// fix for the "boosts=170 matched=0" pathology: globally-ranked
|
||||
/// semantic neighbors include playbooks from every city the query
|
||||
/// could never reach via its SQL filter. When the caller knows the
|
||||
/// target geo, restricting here collapses noise and raises the
|
||||
/// endorsed-worker hit rate. Pass None for the original behavior.
|
||||
///
|
||||
/// 2026-04-21 — added after a corpus-density batch of 25 runs
|
||||
/// showed only 6/40 successful (role, city) combos ever triggered
|
||||
/// a citation on subsequent runs. Diagnostic logging proved the
|
||||
/// boost map had 170 keys but the 50-candidate pool matched 0.
|
||||
pub async fn compute_boost_for_filtered(
|
||||
&self,
|
||||
query_embedding: &[f32],
|
||||
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();
|
||||
// Build failure map once before dropping the lock so we don't
|
||||
// hold the read lock across the full scoring loop.
|
||||
let mut failure_counts: HashMap<(String, String, String), usize> = HashMap::new();
|
||||
for f in &state.failures {
|
||||
*failure_counts.entry((f.city.clone(), f.state.clone(), f.name.clone())).or_insert(0) += 1;
|
||||
}
|
||||
drop(state);
|
||||
|
||||
// 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_map(|e| e.embedding.as_ref().map(|v| (cosine(query_embedding, v), e)))
|
||||
.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
|
||||
})
|
||||
.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();
|
||||
@@ -205,11 +613,17 @@ impl PlaybookMemory {
|
||||
let per_worker = similarity * base_weight * decay / (n_workers as f32);
|
||||
for name in &pb.endorsed_names {
|
||||
let key = (city.clone(), state.clone(), name.clone());
|
||||
// Path 1 negative signal — each recorded failure halves
|
||||
// this worker's contribution. Three failures → 0.125x.
|
||||
// Five failures → ~0.03x (effectively zero). Caps at
|
||||
// 20 before we clamp to avoid degen cases.
|
||||
let fail_count = failure_counts.get(&key).copied().unwrap_or(0).min(20);
|
||||
let penalty = 0.5_f32.powi(fail_count as i32);
|
||||
let entry = boosts.entry(key).or_insert(BoostEntry {
|
||||
boost: 0.0,
|
||||
citations: Vec::new(),
|
||||
});
|
||||
entry.boost = (entry.boost + per_worker).min(MAX_BOOST_PER_WORKER);
|
||||
entry.boost = (entry.boost + per_worker * penalty).min(MAX_BOOST_PER_WORKER);
|
||||
if !entry.citations.contains(&pb.playbook_id) {
|
||||
entry.citations.push(pb.playbook_id.clone());
|
||||
}
|
||||
@@ -633,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();
|
||||
@@ -811,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 {
|
||||
@@ -823,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,9 @@ pub fn router(state: VectorState) -> Router {
|
||||
.route("/playbook_memory/seed", post(seed_playbook_memory))
|
||||
.route("/playbook_memory/persist_sql", post(persist_playbook_memory_sql))
|
||||
.route("/playbook_memory/patterns", post(discover_playbook_patterns))
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -802,22 +805,65 @@ 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, 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(&qv, boost_k, 0.5).await;
|
||||
let boosts = state.playbook_memory
|
||||
.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;
|
||||
|
||||
// Diagnostics for Phase 19 boost pipeline. Logged so item 3
|
||||
// investigation has ground truth:
|
||||
// - boosts.len(): how many (city,state,name) keys surfaced for
|
||||
// this query (0 = playbook_memory found nothing semantically
|
||||
// similar to the question).
|
||||
// - parsed: how many candidate chunks parsed cleanly into
|
||||
// (name,city,state) via parse_worker_chunk.
|
||||
// - matched: how many parsed keys matched an entry in boosts.
|
||||
// 2026-04-21 — 20-scenario batch showed 34/40 ok combos never
|
||||
// got a citation. These counters pin whether the gap is on the
|
||||
// SIMILARITY side (boosts empty) or the MATCH side (parsed vs
|
||||
// boosted keys mismatch — e.g. name format drift).
|
||||
let mut parsed_count = 0usize;
|
||||
let mut matched_count = 0usize;
|
||||
for src in sources.iter_mut() {
|
||||
// Parse "{Name} — {Role} in {City}, {State}. …" chunk. Being
|
||||
// defensive: chunks from other datasets may not follow this
|
||||
// exact shape, so absent fields just skip the boost.
|
||||
if let Some((name, city, state)) = parse_worker_chunk(&src.chunk_text) {
|
||||
parsed_count += 1;
|
||||
let key = (city, state, name);
|
||||
if let Some(entry) = boosts.get(&key) {
|
||||
src.score += entry.boost;
|
||||
src.playbook_boost = entry.boost;
|
||||
src.playbook_citations = entry.citations.clone();
|
||||
matched_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"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.
|
||||
sources.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
// Finally trim to the caller's requested top_k — we pulled fetch_k
|
||||
@@ -2058,6 +2104,56 @@ 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)> {
|
||||
let city = grab_eq_value(sql_filter, "city")?;
|
||||
let state = grab_eq_value(sql_filter, "state")?;
|
||||
Some((city, state))
|
||||
}
|
||||
|
||||
fn parse_worker_chunk(chunk: &str) -> Option<(String, String, String)> {
|
||||
// "Name — Role in City, ST. …" → split on "—" then " in " then ","
|
||||
let (name_part, rest) = chunk.split_once('—')?;
|
||||
@@ -2091,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
|
||||
@@ -2115,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: {}",
|
||||
@@ -2166,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);
|
||||
} else {
|
||||
current = vec![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,
|
||||
})))
|
||||
}
|
||||
if let Err(e) = state.playbook_memory.set_entries(current).await {
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, format!("upsert: {e}"))),
|
||||
}
|
||||
} else {
|
||||
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!({ "playbook_id": pid, "entries_after": state.playbook_memory.entry_count().await })))
|
||||
Ok(Json(serde_json::json!({
|
||||
"outcome": { "mode": "replaced", "playbook_id": pid },
|
||||
"entries_after": state.playbook_memory.entry_count().await,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
async fn rebuild_playbook_memory(
|
||||
@@ -2242,6 +2377,47 @@ async fn discover_playbook_patterns(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MarkFailedRequest {
|
||||
/// Operation text, same shape as seed: "fill: Role xN in City, ST"
|
||||
operation: String,
|
||||
/// Names of workers who didn't deliver on the fill.
|
||||
failed_names: Vec<String>,
|
||||
/// Short reason (no-show, fired, unreliable). Stored verbatim.
|
||||
#[serde(default)]
|
||||
reason: String,
|
||||
}
|
||||
|
||||
async fn mark_playbook_failed(
|
||||
State(state): State<VectorState>,
|
||||
Json(req): Json<MarkFailedRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// Parse city + state from the operation — mirrors seed's parser.
|
||||
let after_in = req.operation.split(" in ").nth(1).unwrap_or("");
|
||||
let mut parts = after_in.splitn(2, ',');
|
||||
let city = parts.next().map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
let state_ = parts.next().map(|s|
|
||||
s.trim().chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>()
|
||||
).filter(|s| !s.is_empty());
|
||||
let (Some(city), Some(state_code)) = (city, state_) else {
|
||||
return Err((StatusCode::BAD_REQUEST,
|
||||
"operation must match 'fill: Role xN in City, ST' shape".into()));
|
||||
};
|
||||
|
||||
let ts = chrono::Utc::now().to_rfc3339();
|
||||
let records: Vec<playbook_memory::FailureRecord> = req.failed_names.iter()
|
||||
.map(|n| playbook_memory::FailureRecord {
|
||||
city: city.clone(), state: state_code.clone(), name: n.clone(),
|
||||
reason: req.reason.clone(), timestamp: ts.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
match state.playbook_memory.mark_failures(records).await {
|
||||
Ok(added) => Ok(Json(serde_json::json!({ "added": added, "city": city, "state": state_code }))),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn playbook_memory_stats(
|
||||
State(state): State<VectorState>,
|
||||
) -> impl IntoResponse {
|
||||
@@ -2260,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>,
|
||||
@@ -2351,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.**
|
||||
|
||||
+186
-4
@@ -1,8 +1,8 @@
|
||||
# PRD: Lakehouse — Rust-First Substrate for Versioned Knowledge Stores
|
||||
|
||||
**Status:** Active — Phases 0-18 shipped; hybrid SQL+Vector search operational; IVF_PQ recall tuned to 0.97; 10K real staffing profiles ingested from Ethereal; autonomous agent + staffing simulation verified
|
||||
**Status:** Active — Phases 0-18 shipped; hybrid SQL+Vector search operational; IVF_PQ recall tuned to 1.000 at p50 ≈ 7.4ms via `nprobes`+`refine`; autonomous agent rotates across full index portfolio; cron-scheduled ingest; eval federation complete
|
||||
**Created:** 2026-03-27
|
||||
**Last updated:** 2026-04-17 — hybrid search closes the SQL+vector gap; quality eval surfaced and fixed real bugs
|
||||
**Last updated:** 2026-04-20 — portfolio-wide autotune, real cron, evals federation, bucket-migrate, IVF_PQ recall 0.805 → 1.000
|
||||
**Owner:** J
|
||||
|
||||
---
|
||||
@@ -126,6 +126,15 @@ User question → gateway
|
||||
9. **Every reader gets its own profile.** Human operators, AI agents, and local models are all clients of the same substrate. Each has a named profile with its own bucket, vector indexes, trial history, and dataset bindings. Profiles are a first-class architectural concept, not a tenancy afterthought. (Phase 17)
|
||||
10. **Trials are data, not logs.** Every index build is a trial with measurable metrics. The trial journal IS the agent's memory for how to tune itself. Stored as write-once batched JSONL per the ADR-018 append-log pattern.
|
||||
11. **Operational failures are findable in one HTTP call.** The bucket error journal, trial journal, and audit log all expose `/storage/errors`, `/hnsw/trials`, `/access/audit` with structured filter + aggregation. No `grep` archaeology to answer "what broke?"
|
||||
12. **Playbooks feed the index, not just the log.** A completed playbook isn't just a record of what worked — it's a signal that shapes future rankings. Every `successful_playbooks` row contributes to the playbook-memory vector index, so semantically-similar future operations re-rank toward workers that have actually succeeded in comparable fills. This is the "system gets smarter over time" dimension that distinguishes this substrate from a static search engine. (Phase 19)
|
||||
|
||||
---
|
||||
|
||||
## Vision drift acknowledged (2026-04-20)
|
||||
|
||||
The system as shipped through Phase 18 is a **hybrid SQL+vector search engine with a playbook log**. The original pitch (and the "staffing AI co-pilot" framing) implied a **meta-index that learns from playbooks over time** — hot-swap profiles weren't just routing, they were knowledge generations that compounded. That learning loop was never built; playbooks were write-only. Phase 19 closes that gap explicitly.
|
||||
|
||||
The feedback signal is **statistical + semantic**, not neural. No model training — the index reads the playbook journal, computes operation-similarity, and boosts endorsed workers at query time. Rebuildable from `successful_playbooks` alone, same as every other derived index.
|
||||
|
||||
---
|
||||
|
||||
@@ -349,12 +358,185 @@ The question raised 2026-04-16 after J's LLMS3 knowledge base identified Lance a
|
||||
|
||||
Per-profile `vector_backend: Parquet | Lance` becomes part of Phase 17 (model profiles). See ADR-019 for the full scorecard and caveats.
|
||||
|
||||
### Phase 19+: Further horizon
|
||||
### Phase 19: Playbook memory (meta-index) — the feedback loop
|
||||
|
||||
Make successful playbooks actually improve future searches. Today `successful_playbooks` is a write-only log; future-you looks at it and thinks "cool, we filled Toledo welders once" — but the index has no idea it happened, so the next Toledo-welder search ranks the same as if none of those fills had existed. Phase 19 closes the loop.
|
||||
|
||||
| Step | Deliverable | Gate |
|
||||
|---|---|---|
|
||||
| 19.1 | Embed every `successful_playbooks` row — operation + approach + context → one chunk per playbook | A new dataset `playbook_memory` appears in catalog with N rows = row count of `successful_playbooks` |
|
||||
| 19.2 | Vector index on `playbook_memory` (HNSW or Lance — whichever `agent-parquet` profile uses) | `/vectors/search` against `playbook_memory` returns semantically similar past playbooks |
|
||||
| 19.3 | Endorsement extraction: each playbook row has `fills[]` (worker_ids it succeeded with). Parse them out at ingest time and store in a sidecar `playbook_endorsements` Parquet keyed by playbook_id | `SELECT * FROM playbook_endorsements WHERE playbook_id = 'X'` returns the worker_ids |
|
||||
| 19.4 | `/vectors/hybrid` gains opt-in `use_playbook_memory: bool`. When true: after hybrid ranks candidates, find top-K similar past playbooks (semantic search over `playbook_memory`), extract endorsed worker_ids, add a bounded boost to candidates in the endorsed set, re-rank | A search where the "right" worker is known from a prior playbook ranks higher with the flag than without |
|
||||
| 19.5 | Write-through from multi-agent orchestrator: when two agents seal a playbook, it appends to `successful_playbooks` AND triggers a refresh of `playbook_memory` (via existing Phase C stale-mark path). Next query sees the new signal. | Run the orchestrator → inspect `playbook_memory` → see a new row. Run the same query before/after → ranking differs. |
|
||||
| 19.6 | Ceiling-aware boost: cap the per-worker boost so one popular worker can't dominate future searches. Boost decays with time (optional) so stale playbooks matter less. | Synthetic test: 100 playbooks all filled with the same worker_id; the 101st search still returns a mix, not just that one worker |
|
||||
|
||||
**Gate:** Run a real search before and after a new successful playbook lands. The endorsed workers from similar past operations rank higher in the second call. Demonstrable with a diff of the two result sets.
|
||||
|
||||
**Why this is the right version of "meta-index":** The alternative — training a neural re-ranker on (query, candidate, outcome) triples — is a weeks-long ML story and requires labeled outcome data we don't really have. The statistical-semantic version here is rebuildable from the existing playbook journal, explainable ("boosted because of similar playbooks X, Y, Z"), and invalidatable (delete a playbook → boost goes away on next rebuild). It gets 80% of the payoff at 10% of the cost. Neural re-ranking stays as a future option if it bites.
|
||||
|
||||
**Non-goals for this phase:**
|
||||
- Neural training / fine-tuning. Statistical feedback only.
|
||||
- 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 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 — `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 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
|
||||
|
||||
**Why this is a phase and not an optimization:** bumping `max_tokens` until a response stops truncating is a tourniquet — J called this out explicitly. As playbooks accumulate into the hundreds and responses grow, eventually SOME request will exceed SOME model's window, and we can't solve it by raising a number. The stable answer is two primitives that let us handle arbitrary-size work without losing context: a scratchpad that glues multi-call responses together, and a tree split that shards oversized inputs and reduces them back.
|
||||
|
||||
**Two primitives (WIRED 2026-04-21 in `tests/multi-agent/agent.ts`):**
|
||||
|
||||
1. **`generateContinuable()`** — handles OUTPUT overflow. Calls the model; checks structural completeness (for JSON: matched braces + JSON.parse success; for text: non-empty). If incomplete, calls again with "continue from here" + the partial response as scratchpad. Up to `max_continuations` times. No `max_tokens` tuning needed — if thinking ate the initial budget, continuation picks up the slack.
|
||||
|
||||
2. **`generateTreeSplit()`** — handles INPUT overflow. Caller passes an array of shards (semantic chunks of the corpus). For each shard: map call with running scratchpad digest. Final reduce call produces the answer. Scratchpad truncates oldest content if it approaches its own budget. If a single shard still overflows, `assertContextBudget` throws — caller must re-shard at finer granularity, NOT silently truncate.
|
||||
|
||||
**Guarantees:**
|
||||
1. No agent call can silently truncate. Either it completes, continues, or throws with numbers.
|
||||
2. No corpus is too big — `generateTreeSplit` handles any size the caller can shard.
|
||||
3. Scratchpad is the glue between multi-call responses; context is never lost, only compacted.
|
||||
4. Token estimation uses `chars / 4` (biased safe ~15%) until we wire the provider's tokenizer.
|
||||
|
||||
**What lives where now:**
|
||||
- `agent.ts::estimateTokens()` + `assertContextBudget()` + `generateContinuable()` + `generateTreeSplit()` — WIRED
|
||||
- `scenario.ts` executor + reviewer + overviewGenerate calls — migrated to `generateContinuable`
|
||||
- `config/models.json` — context_window + context_budget + overflow_policies per tier (declarative)
|
||||
|
||||
**Next sprint (Rust side, so gateway tools share it):**
|
||||
- `crates/aibridge/src/continuation.rs` — port of `generateContinuable`
|
||||
- `crates/aibridge/src/tree_split.rs` — port of `generateTreeSplit`
|
||||
- `crates/storaged/src/chunk_cache.rs` — precomputed shards keyed by corpus hash (avoid re-chunking on every T4 run)
|
||||
- `/metrics` counter: `context_continuations_total{model,shape,succeeded}`
|
||||
|
||||
**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.
|
||||
|
||||
**Files (`data/_kb/`):**
|
||||
- `signatures.jsonl` — (sig_hash, embedding[], first_seen, last_seen, run_count). Sig = stable hash of the sequence of (kind, role, count, city, state) across events.
|
||||
- `outcomes.jsonl` — per-run record: {sig, run_id, models, ok/total, turns, citations, per-event summary, elapsed}.
|
||||
- `pathway_recommendations.jsonl` — AI-synthesized for next run: {confidence, rationale, top_models, budget_hints, pathway_notes, neighbors_consulted}.
|
||||
- `error_corrections.jsonl` — detected fail→succeed pairs on same sig, diff of what changed.
|
||||
- `config_snapshots.jsonl` — history of models.json changes + why.
|
||||
|
||||
**Cycle (event-driven, not wall-clock):**
|
||||
1. Scenario ends → `kb.indexRun()` extracts signature, embeds spec digest, appends outcome.
|
||||
2. `kb.recommendFor(nextSpec)` finds k-NN signatures via cosine, feeds their outcome history + recent error corrections to the overview model, writes a structured recommendation.
|
||||
3. Next scenario starts → `kb.loadRecommendation(spec)` pulls the newest rec for this sig, injects `pathway_notes` into `guidanceFor()` alongside prior lessons.
|
||||
|
||||
**Why file-based for MVP:** Phase 19 playbook_memory is already a catalogd dataset. KB is a separate meta-layer; keep it file-based first to iterate without a gateway schema migration. Rust port (and promotion to vectord-indexed corpus for neighbor search at scale) lands once shape stabilizes — mirrors how Phase 21 primitives were TS-first → Rust next sprint.
|
||||
|
||||
**What the overview model gets asked:**
|
||||
- Target scenario digest
|
||||
- Top-k neighbor signatures with avg ok rate, best model combo per neighbor
|
||||
- Recent error corrections (sig, before/after model set)
|
||||
|
||||
**What it outputs (JSON-constrained):**
|
||||
- confidence (high/medium/low)
|
||||
- rationale (2-3 sentences)
|
||||
- top_models {executor, reviewer, overview}
|
||||
- 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. `snapshotConfig()` wired to fire at every scenario start — active model set + tool_level + cloud flags hashed and appended to `config_snapshots.jsonl`.
|
||||
|
||||
**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
|
||||
|
||||
- PDF OCR for scanned documents (Tesseract integration)
|
||||
- Specialized fine-tuned models per domain (staffing matcher, resume parser)
|
||||
- Video/audio transcript ingest + multimodal embeddings
|
||||
- Neural re-ranker over (query, candidate, outcome) triples — only if Phase 19's statistical feedback plateaus below usable recall
|
||||
- True distributed query (DataFusion multi-node) — only if single-machine ceilings bite
|
||||
- Playbook versioning (version + parent_id + retired_at) — touches gateway + catalogd + mcp-server
|
||||
- Playbook board (6-phase deep_analysis applied to playbook ranking)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Lakehouse — Alerts</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
a{color:#58a6ff;text-decoration:none}
|
||||
a:hover{color:#79c0ff}
|
||||
|
||||
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
|
||||
.bar h1{font-size:14px;font-weight:600;color:#e6edf3;letter-spacing:-0.2px}
|
||||
.bar nav{display:flex;gap:2px}
|
||||
.bar nav a{font-size:12px;color:#545d68;padding:6px 14px;border-radius:6px;transition:all 0.15s}
|
||||
.bar nav a:hover{color:#e6edf3;background:#161b22}
|
||||
.bar nav a.active{color:#e6edf3;background:#1c2333}
|
||||
|
||||
.wrap{max-width:900px;margin:0 auto;padding:28px 20px 60px}
|
||||
|
||||
h2{color:#e6edf3;font-size:20px;font-weight:700;letter-spacing:-0.3px;margin-bottom:4px}
|
||||
.lede{color:#8b949e;font-size:13px;margin-bottom:18px;line-height:1.6;max-width:680px}
|
||||
|
||||
.card{background:#0d1117;border:1px solid #171d27;border-radius:12px;padding:18px;margin:10px 0}
|
||||
|
||||
.row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid #171d27}
|
||||
.row:last-child{border-bottom:none}
|
||||
.row .k{color:#8b949e;font-size:12px}
|
||||
.row .v{color:#e6edf3;font-weight:500;font-size:13px;font-family:ui-monospace,Menlo,monospace}
|
||||
|
||||
label{display:block;color:#8b949e;font-size:12px;margin-bottom:6px;margin-top:12px}
|
||||
label:first-child{margin-top:0}
|
||||
input[type=text],input[type=number],input[type=url]{width:100%;padding:10px 14px;background:#161b22;border:1px solid #21262d;border-radius:8px;color:#e6edf3;font-size:13px;outline:none;font-family:ui-monospace,Menlo,monospace}
|
||||
input:focus{border-color:#388bfd}
|
||||
.switch{display:flex;align-items:center;gap:8px;margin-top:4px}
|
||||
.switch input{width:auto;accent-color:#1f6feb}
|
||||
.switch span{color:#c9d1d9;font-size:13px}
|
||||
|
||||
.btn{padding:10px 18px;background:#1f6feb;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:600;cursor:pointer;margin-right:8px;margin-top:14px}
|
||||
.btn:hover{background:#388bfd}
|
||||
.btn:disabled{opacity:0.4;cursor:wait}
|
||||
.btn.ghost{background:transparent;border:1px solid #21262d;color:#c9d1d9}
|
||||
.btn.ghost:hover{background:#161b22}
|
||||
.btn.green{background:#2ea043}
|
||||
.btn.green:hover{background:#3fb950}
|
||||
|
||||
.entry{background:#161b22;border-radius:6px;padding:10px 14px;margin-bottom:6px;font-size:12px;font-family:ui-monospace,Menlo,monospace;white-space:pre-wrap;color:#c9d1d9;line-height:1.6}
|
||||
.entry .at{color:#545d68;font-size:10px;display:block;margin-bottom:4px}
|
||||
|
||||
.tip{color:#8b949e;font-size:12px;line-height:1.7;padding:10px 14px;border-left:2px solid #21262d;margin:10px 0}
|
||||
.tip strong{color:#c9d1d9}
|
||||
.tip code{background:#161b22;padding:1px 5px;border-radius:3px;font-family:ui-monospace,Menlo,monospace;font-size:11px;color:#79c0ff}
|
||||
|
||||
.result{padding:14px;border-radius:8px;margin-top:10px;font-size:12px;line-height:1.6}
|
||||
.result.ok{background:#0d2818;border:1px solid #2ea04360;color:#86efac}
|
||||
.result.err{background:#3a1a1a;border:1px solid #f8514960;color:#fca5a5}
|
||||
.result.dim{background:#161b22;border:1px solid #21262d;color:#8b949e}
|
||||
|
||||
.chan{display:inline-block;padding:3px 10px;border-radius:10px;font-size:10px;font-weight:600;margin-right:6px;letter-spacing:0.3px}
|
||||
.chan.console{background:#1a2744;color:#79c0ff}
|
||||
.chan.file{background:#1a3a2a;color:#3fb950}
|
||||
.chan.webhook{background:#2a1a3a;color:#bc8cff}
|
||||
|
||||
.footer{border-top:1px solid #171d27;padding:20px;text-align:center;color:#3d444d;font-size:11px}
|
||||
|
||||
@media(max-width:720px){
|
||||
.wrap{padding:20px 12px 40px}
|
||||
.bar nav{display:none}
|
||||
}
|
||||
</style></head>
|
||||
<body>
|
||||
|
||||
<div class="bar">
|
||||
<h1>Lakehouse — Alerts</h1>
|
||||
<nav>
|
||||
<a href=".">Dashboard</a>
|
||||
<a href="console">Walkthrough</a>
|
||||
<a href="proof">Architecture</a>
|
||||
<a href="spec">Spec</a>
|
||||
<a href="onboard">Onboard</a>
|
||||
<a href="alerts" class="active">Alerts</a>
|
||||
<a href="workspaces">Workspaces</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<h2>Push alerts — make the system find you</h2>
|
||||
<div class="lede">The daemon runs in the background and dispatches a concise digest whenever something worth notifying changes: a role escalates to tight or critical, a new staffing deadline falls within your warn window, or the playbook memory compounds meaningfully. Phone-first shops don't open dashboards — they need the system to reach out.</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color:#e6edf3;font-size:15px;margin-bottom:10px">Current status</h3>
|
||||
<div id="status"><div style="color:#8b949e">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color:#e6edf3;font-size:15px;margin-bottom:10px">Configuration</h3>
|
||||
<form id="cfg-form">
|
||||
<label class="switch"><input type="checkbox" id="enabled"> <span>Daemon enabled — fire digests on the interval</span></label>
|
||||
<label>Check interval (minutes)</label>
|
||||
<input type="number" id="interval" min="1" max="1440" value="15">
|
||||
<label>Deadline warn window (days)</label>
|
||||
<input type="number" id="deadline_warn_days" min="1" max="90" value="7">
|
||||
<label>Webhook URL (Slack, Discord, or any POST-accepting endpoint — optional)</label>
|
||||
<input type="url" id="webhook_url" placeholder="https://hooks.slack.com/services/... or https://discord.com/api/webhooks/...">
|
||||
<label>Webhook label (your name for this channel)</label>
|
||||
<input type="text" id="webhook_label" placeholder="e.g. #staffing-ops">
|
||||
<div>
|
||||
<button type="button" class="btn" id="save-btn">Save</button>
|
||||
<button type="button" class="btn green" id="fire-btn">Fire a test digest now</button>
|
||||
</div>
|
||||
<div id="save-result"></div>
|
||||
</form>
|
||||
<div class="tip">
|
||||
<strong>Webhook behavior.</strong> The daemon POSTs JSON <code>{text, digest}</code> to your webhook URL.
|
||||
Slack and Discord both accept this shape if the URL is an incoming-webhook. The <code>text</code>
|
||||
field is pre-formatted human-readable; the <code>digest</code> field is structured for a bot to parse.
|
||||
Interval changes take effect on next server restart.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color:#e6edf3;font-size:15px;margin-bottom:10px">Recent digests</h3>
|
||||
<div id="recent"><div style="color:#8b949e">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer">Lakehouse · alerts · daemon log: <code>mcp-server/data/notifications.jsonl</code></div>
|
||||
|
||||
<script>
|
||||
var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
|
||||
var A=location.origin+P;
|
||||
|
||||
function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!==undefined)e.textContent=String(x);return e}
|
||||
function apiGet(p){return fetch(A+p).then(function(r){return r.json()})}
|
||||
function apiPost(p,b){return fetch(A+p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}).then(function(r){return r.json()})}
|
||||
|
||||
function loadStatus(){
|
||||
apiGet('/alerts/config').then(function(r){
|
||||
var host=document.getElementById('status');host.textContent='';
|
||||
var cfg=r.config||{};
|
||||
var st=r.state||{};
|
||||
var rows=[
|
||||
['Daemon',cfg.enabled?'enabled':'disabled'],
|
||||
['Interval',(cfg.interval_minutes||'?')+' minutes'],
|
||||
['Deadline warn window',(cfg.deadline_warn_days||7)+' days'],
|
||||
['Webhook',cfg.webhook_url?(cfg.webhook_label||'configured'):'none'],
|
||||
['Last run',st.last_run_at?new Date(st.last_run_at).toLocaleString():'never'],
|
||||
];
|
||||
rows.forEach(function(r){
|
||||
var row=el('div','row');
|
||||
row.appendChild(el('span','k',r[0]));
|
||||
row.appendChild(el('span','v',r[1]));
|
||||
host.appendChild(row);
|
||||
});
|
||||
// Populate form
|
||||
document.getElementById('enabled').checked=!!cfg.enabled;
|
||||
document.getElementById('interval').value=cfg.interval_minutes||15;
|
||||
document.getElementById('deadline_warn_days').value=cfg.deadline_warn_days||7;
|
||||
document.getElementById('webhook_url').value=cfg.webhook_url||'';
|
||||
document.getElementById('webhook_label').value=cfg.webhook_label||'';
|
||||
}).catch(function(e){
|
||||
var host=document.getElementById('status');host.textContent='';
|
||||
host.appendChild(el('div',null,'status unavailable: '+(e.message||e)));
|
||||
});
|
||||
}
|
||||
|
||||
function loadRecent(){
|
||||
apiGet('/alerts/recent').then(function(r){
|
||||
var host=document.getElementById('recent');host.textContent='';
|
||||
var entries=r.entries||[];
|
||||
if(entries.length===0){
|
||||
host.appendChild(el('div',null,'No digests yet. The daemon fires every '+(document.getElementById('interval').value||15)+' minutes and only dispatches when something changes. Click "Fire a test digest now" to force one.'));
|
||||
host.firstChild.style.color='#8b949e';
|
||||
return;
|
||||
}
|
||||
entries.forEach(function(e){
|
||||
var card=el('div','entry');
|
||||
if(e.at) card.appendChild(el('span','at',new Date(e.at).toLocaleString()));
|
||||
card.appendChild(document.createTextNode(e.text||JSON.stringify(e.digest)));
|
||||
host.appendChild(card);
|
||||
});
|
||||
}).catch(function(e){
|
||||
var host=document.getElementById('recent');host.textContent='';
|
||||
host.appendChild(el('div',null,'recent digests unavailable: '+(e.message||e)));
|
||||
});
|
||||
}
|
||||
|
||||
function save(){
|
||||
var btn=document.getElementById('save-btn');
|
||||
btn.disabled=true;
|
||||
var body={
|
||||
enabled:document.getElementById('enabled').checked,
|
||||
interval_minutes:Number(document.getElementById('interval').value)||15,
|
||||
deadline_warn_days:Number(document.getElementById('deadline_warn_days').value)||7,
|
||||
webhook_url:document.getElementById('webhook_url').value.trim(),
|
||||
webhook_label:document.getElementById('webhook_label').value.trim(),
|
||||
};
|
||||
apiPost('/alerts/config',body).then(function(r){
|
||||
btn.disabled=false;
|
||||
var out=document.getElementById('save-result');out.textContent='';
|
||||
if(r.saved){
|
||||
var ok=el('div','result ok',r.note||'saved.');out.appendChild(ok);
|
||||
loadStatus();
|
||||
} else {
|
||||
out.appendChild(el('div','result err',JSON.stringify(r)));
|
||||
}
|
||||
}).catch(function(e){
|
||||
btn.disabled=false;
|
||||
var out=document.getElementById('save-result');out.textContent='';
|
||||
out.appendChild(el('div','result err','save failed: '+(e.message||e)));
|
||||
});
|
||||
}
|
||||
|
||||
function fire(){
|
||||
var btn=document.getElementById('fire-btn');
|
||||
btn.disabled=true;btn.textContent='Firing…';
|
||||
apiPost('/alerts/fire',{}).then(function(r){
|
||||
btn.disabled=false;btn.textContent='Fire a test digest now';
|
||||
var out=document.getElementById('save-result');out.textContent='';
|
||||
if(!r.fired){
|
||||
out.appendChild(el('div','result dim',r.reason||'nothing to dispatch'));
|
||||
} else {
|
||||
var ok=el('div','result ok');
|
||||
ok.appendChild(document.createTextNode('Dispatched to: '));
|
||||
(r.channels||[]).forEach(function(c){
|
||||
var chip=el('span','chan '+c,c);ok.appendChild(chip);
|
||||
});
|
||||
if(r.errors&&r.errors.length){
|
||||
ok.appendChild(document.createElement('br'));
|
||||
ok.appendChild(document.createTextNode('errors: '+r.errors.join(' | ')));
|
||||
}
|
||||
out.appendChild(ok);
|
||||
setTimeout(loadRecent,500);
|
||||
}
|
||||
}).catch(function(e){
|
||||
btn.disabled=false;btn.textContent='Fire a test digest now';
|
||||
var out=document.getElementById('save-result');out.textContent='';
|
||||
out.appendChild(el('div','result err','fire failed: '+(e.message||e)));
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded',function(){
|
||||
loadStatus();loadRecent();
|
||||
document.getElementById('save-btn').addEventListener('click',save);
|
||||
document.getElementById('fire-btn').addEventListener('click',fire);
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
+431
-380
@@ -1,419 +1,470 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Staffing Intelligence Console</title>
|
||||
<title>Lakehouse — What Your Staffing System Would Do</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,system-ui,sans-serif;background:#06090f;color:#c9d1d9;font-size:14px;line-height:1.5}
|
||||
.bar{background:#0d1117;padding:14px 20px;border-bottom:1px solid #1b2130;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px}
|
||||
.bar h1{font-size:15px;font-weight:600;color:#f0f6fc;letter-spacing:0.5px}
|
||||
.bar .rt{font-size:11px;color:#484f58}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:20px 16px}
|
||||
.brief-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px;margin-bottom:24px}
|
||||
.bcard{background:#0d1117;border:1px solid #1b2130;border-radius:10px;padding:18px;position:relative;overflow:hidden}
|
||||
.bcard .label{font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:#484f58;margin-bottom:8px}
|
||||
.bcard .headline{font-size:16px;font-weight:700;color:#f0f6fc;margin-bottom:4px}
|
||||
.bcard .sub{font-size:12px;color:#8b949e;margin-bottom:12px}
|
||||
.bcard .big{font-size:36px;font-weight:800;line-height:1}
|
||||
.bcard.pulse{border-left:3px solid #58a6ff}
|
||||
.bcard.bench{border-left:3px solid #d29922}
|
||||
.bcard.gems{border-left:3px solid #3fb950}
|
||||
.bcard.risk{border-left:3px solid #f85149}
|
||||
.bcard.talent{border-left:3px solid #bc8cff}
|
||||
.bcard.supply{border-left:3px solid #79c0ff}
|
||||
.arch-row{display:flex;gap:4px;flex-wrap:wrap;margin-top:10px}
|
||||
.arch-pill{padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500}
|
||||
.bench-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px}
|
||||
.bench-row .st{width:24px;font-weight:600;color:#f0f6fc}
|
||||
.bench-bar{flex:1;height:14px;background:#161b22;border-radius:4px;overflow:hidden;position:relative}
|
||||
.bench-fill{height:100%;border-radius:4px;transition:width 0.8s}
|
||||
.bench-row .pct{width:40px;text-align:right;font-size:11px;font-weight:600}
|
||||
.wk{display:flex;align-items:center;gap:10px;padding:8px;background:#161b22;border-radius:6px;margin-bottom:4px}
|
||||
.wk .av{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;color:#f0f6fc;flex-shrink:0}
|
||||
.wk .info{flex:1;min-width:0}
|
||||
.wk .nm{font-weight:600;color:#f0f6fc;font-size:13px}
|
||||
.wk .det{color:#8b949e;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.wk .badge{padding:2px 8px;border-radius:8px;font-size:10px;font-weight:600;flex-shrink:0}
|
||||
.prompts{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.prompt-btn{padding:8px 16px;background:#161b22;border:1px solid #1b2130;border-radius:20px;color:#8b949e;font-size:12px;cursor:pointer;transition:all 0.2s}
|
||||
.prompt-btn:hover{border-color:#58a6ff;color:#58a6ff;background:#0d1525}
|
||||
.chat-box{background:#0d1117;border:1px solid #1b2130;border-radius:12px;overflow:hidden;margin-bottom:16px}
|
||||
.chat-messages{min-height:60px;max-height:600px;overflow-y:auto;padding:16px}
|
||||
.msg{margin-bottom:16px;animation:fadeIn 0.3s ease}
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||||
.msg.user{text-align:right}
|
||||
.msg.user .bubble{display:inline-block;background:#1f3d68;color:#c9d6ff;padding:10px 16px;border-radius:16px 16px 4px 16px;max-width:70%;text-align:left;font-size:13px}
|
||||
.msg.system .bubble{background:#161b22;border:1px solid #1b2130;border-radius:4px 16px 16px 16px;padding:16px;font-size:13px}
|
||||
.msg .meta{font-size:10px;color:#484f58;margin-top:6px}
|
||||
.msg .steps{margin-bottom:10px}
|
||||
.msg .step{font-size:11px;color:#58a6ff;padding:2px 0}
|
||||
.msg .summary{font-size:14px;font-weight:600;color:#f0f6fc;margin-bottom:8px}
|
||||
.msg .note{font-size:11px;color:#8b949e;font-style:italic;margin-top:8px;padding:8px;background:#0d1117;border-radius:6px}
|
||||
.result-card{background:#0d1117;border:1px solid #1b2130;border-radius:8px;padding:12px;margin-bottom:6px}
|
||||
.result-card .rc-name{font-weight:600;color:#f0f6fc;font-size:13px}
|
||||
.result-card .rc-detail{font-size:11px;color:#8b949e;margin-top:2px}
|
||||
.result-card .rc-score{font-size:10px;color:#58a6ff;margin-top:4px}
|
||||
.result-card .rc-skills{display:flex;gap:4px;flex-wrap:wrap;margin-top:6px}
|
||||
.result-card .rc-skill{padding:2px 8px;background:#1a2744;color:#58a6ff;border-radius:8px;font-size:10px}
|
||||
.result-card .rc-cert{padding:2px 8px;background:#1a3a2a;color:#3fb950;border-radius:8px;font-size:10px}
|
||||
.source-card{background:#1a1a00;border:1px solid #854d0e;border-radius:8px;padding:12px;margin-bottom:10px}
|
||||
.whatif-severity{display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:700;margin-left:8px}
|
||||
.input-bar{display:flex;gap:8px;padding:12px 16px;border-top:1px solid #1b2130;background:#0d1117}
|
||||
.input-bar input{flex:1;padding:12px 16px;background:#06090f;border:1px solid #1b2130;border-radius:10px;color:#f0f6fc;font-size:14px;outline:none}
|
||||
.input-bar input:focus{border-color:#58a6ff}
|
||||
.input-bar button{padding:12px 20px;background:#58a6ff;border:none;border-radius:10px;color:#0d1117;font-weight:700;font-size:14px;cursor:pointer}
|
||||
.input-bar button:hover{background:#79c0ff}
|
||||
.input-bar button:disabled{opacity:0.4;cursor:default}
|
||||
.thinking{display:flex;align-items:center;gap:8px;color:#58a6ff;font-size:12px;padding:12px}
|
||||
.dots span{animation:blink 1.4s infinite both;margin:0 1px}
|
||||
.dots span:nth-child(2){animation-delay:0.2s}
|
||||
.dots span:nth-child(3){animation-delay:0.4s}
|
||||
@keyframes blink{0%,80%,100%{opacity:0.2}40%{opacity:1}}
|
||||
.supply-row{display:flex;justify-content:space-between;align-items:center;padding:5px 8px;font-size:12px;border-bottom:1px solid #0d1117}
|
||||
.supply-row:last-child{border-bottom:none}
|
||||
.supply-role{color:#f0f6fc;font-weight:500}
|
||||
.supply-nums{color:#8b949e;font-size:11px}
|
||||
.ft{text-align:center;padding:16px;color:#2d333b;font-size:10px}
|
||||
@media(max-width:768px){.brief-grid{grid-template-columns:1fr}.msg.user .bubble{max-width:90%}}
|
||||
</style></head><body>
|
||||
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
a{color:#58a6ff;text-decoration:none}
|
||||
a:hover{color:#79c0ff}
|
||||
|
||||
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
|
||||
.bar h1{font-size:14px;font-weight:600;color:#e6edf3;letter-spacing:-0.2px}
|
||||
.bar nav{display:flex;gap:2px}
|
||||
.bar nav a{font-size:12px;color:#545d68;padding:6px 14px;border-radius:6px;transition:all 0.15s}
|
||||
.bar nav a:hover{color:#e6edf3;background:#161b22}
|
||||
.bar nav a.active{color:#e6edf3;background:#1c2333}
|
||||
.bar .rt{font-size:11px;color:#545d68}
|
||||
|
||||
.wrap{max-width:1040px;margin:0 auto;padding:28px 20px 60px}
|
||||
|
||||
.chapter{margin-bottom:48px}
|
||||
.chapter .num{color:#545d68;font-size:11px;font-weight:600;letter-spacing:1.6px;text-transform:uppercase;margin-bottom:6px}
|
||||
.chapter h2{color:#e6edf3;font-size:24px;font-weight:700;letter-spacing:-0.4px;margin-bottom:8px;line-height:1.2}
|
||||
.chapter .lede{color:#8b949e;font-size:14px;margin-bottom:18px;max-width:680px;line-height:1.6}
|
||||
|
||||
.card{background:#0d1117;border:1px solid #171d27;border-radius:12px;padding:20px;margin-bottom:12px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px}
|
||||
|
||||
.stat-lg{padding:18px 20px}
|
||||
.stat-lg .n{font-size:30px;font-weight:800;color:#e6edf3;letter-spacing:-1px;line-height:1}
|
||||
.stat-lg .l{font-size:10px;color:#545d68;text-transform:uppercase;letter-spacing:1.2px;margin-top:8px;font-weight:600}
|
||||
.stat-lg .sub{font-size:12px;color:#8b949e;margin-top:4px}
|
||||
|
||||
.pill{display:inline-block;padding:3px 10px;border-radius:10px;font-size:10px;font-weight:600;letter-spacing:0.3px;margin-right:6px}
|
||||
.pill.real{background:#0d2818;color:#3fb950;border:1px solid #2ea04380}
|
||||
.pill.synth{background:#1a1a2e;color:#bc8cff;border:1px solid #5530988c}
|
||||
.pill.flow{background:#0d2340;color:#58a6ff;border:1px solid #1f6feb80}
|
||||
|
||||
.row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 14px;background:#0d1117;border:1px solid #171d27;border-radius:8px;margin-bottom:6px;font-size:13px}
|
||||
.row:hover{border-color:#21262d}
|
||||
.row .title{color:#e6edf3;font-weight:500}
|
||||
.row .meta{color:#8b949e;font-size:11px;margin-top:2px}
|
||||
.row .val{color:#58a6ff;font-weight:600;white-space:nowrap}
|
||||
|
||||
details{background:#0d1117;border:1px solid #171d27;border-radius:8px;padding:10px 14px;margin-bottom:6px}
|
||||
details summary{cursor:pointer;font-size:13px;color:#e6edf3;font-weight:500;list-style:none}
|
||||
details summary::-webkit-details-marker{display:none}
|
||||
details .body{padding-top:10px;font-size:12px;color:#8b949e}
|
||||
|
||||
.accent-l{border-left:3px solid #2ea043}
|
||||
.accent-b{border-left:3px solid #1f6feb}
|
||||
.accent-a{border-left:3px solid #bc8cff}
|
||||
.accent-w{border-left:3px solid #d29922}
|
||||
|
||||
.worker{display:flex;align-items:center;gap:10px;padding:8px 10px;background:#161b22;border-radius:6px;margin-bottom:4px;font-size:12px}
|
||||
.worker .av{width:28px;height:28px;border-radius:6px;background:#1a2744;display:flex;align-items:center;justify-content:center;font-weight:600;color:#e6edf3;font-size:10px;flex-shrink:0}
|
||||
.worker .info{flex:1;min-width:0}
|
||||
.worker .nm{color:#e6edf3;font-weight:500}
|
||||
.worker .why{color:#545d68;font-size:11px;margin-top:1px}
|
||||
.worker .score{color:#58a6ff;font-size:11px;font-weight:600;white-space:nowrap}
|
||||
|
||||
.mem-chip{background:#0d2818;border:1px solid #2ea04360;border-radius:6px;padding:8px 12px;font-size:11px;color:#86efac;line-height:1.5;margin-top:6px}
|
||||
.mem-chip .l{color:#3fb950;font-weight:600;margin-right:6px}
|
||||
|
||||
.boost-chip{display:inline-block;margin-left:6px;padding:2px 7px;border-radius:9px;font-size:9px;font-weight:600;background:#0d2818;border:1px solid #2ea043;color:#3fb950;vertical-align:middle}
|
||||
|
||||
.try-box{background:#0d1117;border:1px solid #171d27;border-radius:12px;padding:16px}
|
||||
.try-box input{width:100%;padding:12px 16px;background:#161b22;border:1px solid #21262d;border-radius:8px;color:#e6edf3;font-size:13px;outline:none;margin-bottom:10px}
|
||||
.try-box input:focus{border-color:#388bfd}
|
||||
.try-box button{padding:10px 20px;background:#1f6feb;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:600;cursor:pointer}
|
||||
.try-box button:hover{background:#388bfd}
|
||||
.try-box button:disabled{opacity:0.5;cursor:wait}
|
||||
|
||||
.footer{border-top:1px solid #171d27;padding:20px;text-align:center;color:#3d444d;font-size:11px}
|
||||
|
||||
.loading{color:#484f58;font-style:italic;padding:20px 0;text-align:center}
|
||||
.err{color:#f85149;font-size:12px;padding:10px}
|
||||
|
||||
.narr{color:#8b949e;font-size:13px;line-height:1.7;margin:10px 0;padding:10px 14px;border-left:2px solid #21262d}
|
||||
.narr strong{color:#c9d1d9;font-weight:600}
|
||||
|
||||
.step-label{color:#58a6ff;font-size:12px;margin-top:12px;font-weight:600}
|
||||
.step-body{color:#c9d1d9;font-size:13px;margin-top:2px}
|
||||
|
||||
@media(max-width:720px){
|
||||
.wrap{padding:20px 12px 40px}
|
||||
.chapter h2{font-size:20px}
|
||||
.bar nav{display:none}
|
||||
}
|
||||
</style></head>
|
||||
<body>
|
||||
|
||||
<div class="bar">
|
||||
<h1>Staffing Intelligence Console</h1>
|
||||
<div class="rt" id="brief-time">Analyzing 500,000 profiles...</div>
|
||||
<h1>Lakehouse — What Your Staffing System Would Do</h1>
|
||||
<nav>
|
||||
<a href=".">Dashboard</a>
|
||||
<a href="console" class="active">Walkthrough</a>
|
||||
<a href="proof">Architecture</a>
|
||||
<a href="spec">Spec</a>
|
||||
<a href="onboard">Onboard</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
<a href="workspaces">Workspaces</a>
|
||||
</nav>
|
||||
<div class="rt" id="hdr-time">Reading live state…</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="brief" class="brief-grid"><div style="grid-column:1/-1;text-align:center;padding:40px;color:#484f58">Scanning your entire workforce...</div></div>
|
||||
<div class="prompts" id="prompts"></div>
|
||||
<div class="chat-box">
|
||||
<div class="chat-messages" id="chat"></div>
|
||||
<div class="input-bar">
|
||||
<input type="text" id="q" placeholder="Ask anything about your workforce..." onkeydown="if(event.key==='Enter'&&!event.shiftKey)send()">
|
||||
<button id="send-btn" onclick="send()">Send</button>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 1</div>
|
||||
<h2>Right now, this system is already thinking</h2>
|
||||
<div class="lede">Before you touched anything, it pulled real Chicago building-permit data, measured demand, checked your bench, and began flagging roles that need attention. This isn't theoretical — open your browser network tab and watch the fetches land.</div>
|
||||
<div class="grid" id="ch1-stats"><div class="loading">Fetching live state…</div></div>
|
||||
<div class="narr" id="ch1-narr"></div>
|
||||
</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 2</div>
|
||||
<h2>The demand signal is real, not made up</h2>
|
||||
<div class="lede">Chicago's Department of Buildings publishes every permit they issue. Below are the largest categories of construction filed in the last 30 days. If a staffer doesn't believe our numbers, they can verify at <a href="https://data.cityofchicago.org/Buildings/Building-Permits/ydr8-5enu" target="_blank" rel="noopener">data.cityofchicago.org</a>.</div>
|
||||
<div id="ch2-permits"><div class="loading">Loading permit feed…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 3</div>
|
||||
<h2>Where your own data would live</h2>
|
||||
<div class="lede">The system stores data in labeled catalogs. Purple pills = synthetic stand-ins you'd swap for your real ATS/CRM/call-log exports. Blue pills = data the system generates about itself (playbooks, audit trails). Nothing else in the pipeline changes — only the source.</div>
|
||||
<div id="ch3-datasets"><div class="loading">Enumerating catalog…</div></div>
|
||||
<div class="narr">
|
||||
<strong>The swap path.</strong> workers_500k → your ATS export (same schema shape). candidates → your CRM. call_log → your phone system's CDR. timesheets → your payroll export. Once ingested, every behavior you see on the dashboard applies to your real data. No re-training. No replatform.
|
||||
</div>
|
||||
</div>
|
||||
<div class="ft">Powered by Lakehouse — Hybrid SQL + Vector Search across 500,000 embedded worker profiles</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 4</div>
|
||||
<h2>Watch the system rank candidates in real time</h2>
|
||||
<div class="lede">This takes the most recent Chicago permit, derives the staffing need, pulls ranked candidates from the bench, and shows you why each one ranked. Everything below loaded in about 3 seconds against the live system.</div>
|
||||
<div id="ch4-demo"><div class="loading">Running demo query…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 5</div>
|
||||
<h2>Every action compounds — the CRM-killer</h2>
|
||||
<div class="lede">A CRM stores. This system compounds. Every successful fill, every no-show, every phone call becomes a re-ranking signal on the next query. Below is the live playbook memory state. The number grows as the app gets used.</div>
|
||||
<div id="ch5-memory"><div class="loading">Reading playbook memory…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 6</div>
|
||||
<h2>Try it yourself</h2>
|
||||
<div class="lede">Type any staffing question. The system picks the right search path (smart-parse, semantic discovery, analytics), shows what it understood, and returns ranked results with memory signal.</div>
|
||||
<div class="try-box">
|
||||
<input type="text" id="try-q" placeholder="e.g. reliable forklift operators in Chicago with OSHA certs" onkeydown="if(event.key==='Enter')runTry()">
|
||||
<button id="try-btn" onclick="runTry()">Ask</button>
|
||||
<div id="try-out" style="margin-top:16px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer">Lakehouse · demo instance · <a href="proof">architecture & benchmarks</a></div>
|
||||
|
||||
<script>
|
||||
var P=location.pathname.replace(/\/console\/?$/,'');
|
||||
var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
|
||||
var A=location.origin+P;
|
||||
var AC=['#1a2744','#1a3a2a','#2a1a3a','#3a2a1a','#1a3a3a','#2a2a1a','#3a1a2a','#1a2a3a'];
|
||||
var briefData=null;
|
||||
|
||||
// DOM helpers — all dynamic content goes through these. No innerHTML
|
||||
// anywhere in the script; every API-derived string passes through
|
||||
// textContent so no injection path regardless of upstream data.
|
||||
function el(tag, cls, text){
|
||||
var e=document.createElement(tag);
|
||||
if(cls) e.className=cls;
|
||||
if(text!==undefined && text!==null) e.textContent=String(text);
|
||||
return e;
|
||||
}
|
||||
function setAttrs(e, obj){
|
||||
for(var k in obj) if(Object.prototype.hasOwnProperty.call(obj,k)) e.setAttribute(k,obj[k]);
|
||||
return e;
|
||||
}
|
||||
|
||||
function api(path, body){
|
||||
return fetch(A+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})}).then(function(r){return r.json()})
|
||||
return fetch(A+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})}).then(function(r){return r.json()});
|
||||
}
|
||||
function apiGet(path){
|
||||
return fetch(A+path).then(function(r){return r.json()});
|
||||
}
|
||||
|
||||
function el(tag,css,text){var e=document.createElement(tag);if(css)e.style.cssText=css;if(text)e.textContent=text;return e}
|
||||
function div(cls,text){var d=document.createElement('div');if(cls)d.className=cls;if(text)d.textContent=text;return d}
|
||||
|
||||
window.addEventListener('load',loadBrief);
|
||||
|
||||
function loadBrief(){
|
||||
api('/intelligence/brief').then(function(d){
|
||||
briefData=d;
|
||||
var container=document.getElementById('brief');
|
||||
container.textContent='';
|
||||
document.getElementById('brief-time').textContent='Analyzed '+(d.pool.total||0).toLocaleString()+' profiles in '+(d.duration_ms||0)+'ms';
|
||||
var pool=d.pool;
|
||||
var relPct=pool.total?Math.round(pool.reliable/pool.total*100):0;
|
||||
var elitePct=pool.total?Math.round(pool.elite/pool.total*100):0;
|
||||
|
||||
// Card 1: Workforce Pulse
|
||||
var c1=makeCard('pulse','WORKFORCE PULSE','Your A-Team',pool.reliable.toLocaleString()+' workers with 80%+ reliability — '+relPct+'% of your pool');
|
||||
var bigRow=el('div','display:flex;gap:20px;margin-bottom:12px');
|
||||
addBigNum(bigRow,pool.total.toLocaleString(),'Total Workers','#f0f6fc');
|
||||
addBigNum(bigRow,relPct+'%','Reliable','#3fb950');
|
||||
addBigNum(bigRow,elitePct+'%','Elite (90%+)','#58a6ff');
|
||||
c1.appendChild(bigRow);
|
||||
if(d.archetypes&&d.archetypes.length){
|
||||
var ar=div('arch-row');
|
||||
var archColors={reliable:'#3fb950',communicator:'#58a6ff',flexible:'#d29922',leader:'#bc8cff',specialist:'#f0883e',improving:'#79c0ff',erratic:'#f85149',silent:'#484f58'};
|
||||
d.archetypes.forEach(function(a){
|
||||
var pct=pool.total?Math.round(a.cnt/pool.total*100):0;
|
||||
var color=archColors[a.archetype]||'#484f58';
|
||||
var pill=el('span','background:'+color+'20;color:'+color+';border:1px solid '+color+'40;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500',a.archetype+' '+pct+'%');
|
||||
pill.className='arch-pill';ar.appendChild(pill);
|
||||
window.addEventListener('load',function(){
|
||||
loadChapter1();
|
||||
loadChapter2();
|
||||
loadChapter3();
|
||||
loadChapter4();
|
||||
loadChapter5();
|
||||
});
|
||||
c1.appendChild(ar);
|
||||
}
|
||||
container.appendChild(c1);
|
||||
|
||||
// Card 2: Geographic Bench
|
||||
var c2=makeCard('bench','GEOGRAPHIC BENCH','Where You\'re Strong — and Thin','Reliable worker density by state');
|
||||
var sorted=(d.bench||[]).slice().sort(function(a,b){return (b.total?b.reliable/b.total:0)-(a.total?a.reliable/a.total:0)});
|
||||
sorted.forEach(function(s){
|
||||
var pct=s.total?Math.round(s.reliable/s.total*100):0;
|
||||
var color=pct>45?'#3fb950':pct>35?'#d29922':'#f85149';
|
||||
var row=div('bench-row');
|
||||
row.appendChild(el('span','width:24px;font-weight:600;color:#f0f6fc',s.state));
|
||||
var bar=el('div','flex:1;height:14px;background:#161b22;border-radius:4px;overflow:hidden');
|
||||
bar.appendChild(el('div','height:100%;border-radius:4px;background:'+color+';width:'+pct+'%;transition:width 0.8s'));
|
||||
row.appendChild(bar);
|
||||
row.appendChild(el('span','width:40px;text-align:right;font-size:11px;font-weight:600;color:'+color,pct+'%'));
|
||||
row.appendChild(el('span','font-size:10px;color:#484f58;width:60px',s.total.toLocaleString()));
|
||||
c2.appendChild(row);
|
||||
});
|
||||
var weakest=sorted[sorted.length-1];
|
||||
if(weakest){
|
||||
var wpct=weakest.total?Math.round(weakest.reliable/weakest.total*100):0;
|
||||
c2.appendChild(makeCallout('Warning: '+weakest.state+' is your thinnest bench at '+wpct+'% reliable. If a client there ramps up, you may struggle to fill.','#1a1500','#854d0e'));
|
||||
}
|
||||
container.appendChild(c2);
|
||||
// ─── Chapter 1 ────────────────────────────────────────────
|
||||
function loadChapter1(){
|
||||
Promise.all([
|
||||
api('/intelligence/staffing_forecast',{}).catch(function(){return {}}),
|
||||
apiGet('/api/vectors/playbook_memory/stats').catch(function(){return {}}),
|
||||
apiGet('/api/catalog/datasets').catch(function(){return []}),
|
||||
]).then(function(all){
|
||||
var f=all[0]||{}, mem=all[1]||{}, ds=all[2]||[];
|
||||
var totalRows=0;
|
||||
if(Array.isArray(ds)) ds.forEach(function(d){totalRows+=(d.row_count||0)});
|
||||
var host=document.getElementById('ch1-stats');host.textContent='';
|
||||
addStat(host,'$'+(f.total_cost||0).toLocaleString('en-US',{maximumFractionDigits:0}),'Construction pipeline',(f.permit_count||0)+' permits last 30 days','accent-b');
|
||||
addStat(host,(f.total_estimated_workers||0).toLocaleString(),'Predicted worker demand','split across '+((f.forecast||[]).length)+' roles','accent-w');
|
||||
addStat(host,(totalRows||0).toLocaleString(),'Rows under management',(Array.isArray(ds)?ds.length:0)+' datasets · real + synthetic','accent-a');
|
||||
addStat(host,(mem.entries||0).toLocaleString(),'Playbooks remembered',(mem.total_names_endorsed||0)+' endorsed worker-tags','accent-l');
|
||||
|
||||
// Card 3: Comeback Watch
|
||||
if(d.gems&&d.gems.length){
|
||||
var c3=makeCard('gems','COMEBACK WATCH',pool.improving.toLocaleString()+' Workers Improving','These workers crossed 80% reliability — they earned a second look');
|
||||
d.gems.forEach(function(w){
|
||||
addWorkerMini(c3,w.name,w.role+' · '+w.city+', '+w.state,'Reliability: '+Math.round(w.rel*100)+'% rising','#79c0ff','improving');
|
||||
});
|
||||
c3.appendChild(makeCallout('These workers were previously below threshold. The system detected their improvement automatically — no staffer had to track it.','#0d261a','#238636'));
|
||||
container.appendChild(c3);
|
||||
var narrEl=document.getElementById('ch1-narr');
|
||||
narrEl.textContent='';
|
||||
var critical=(f.critical_roles||0), tight=(f.tight_roles||0);
|
||||
if(critical>0){
|
||||
narrEl.appendChild(document.createTextNode('The system has already flagged '));
|
||||
narrEl.appendChild(el('strong',null,critical+' role'+(critical!==1?'s':'')+' at critical coverage'));
|
||||
narrEl.appendChild(document.createTextNode(' — supply is below demand. A CRM would surface this only if someone ran the report. This system surfaces it before you log in.'));
|
||||
} else if(tight>0){
|
||||
narrEl.appendChild(document.createTextNode('The system flagged '));
|
||||
narrEl.appendChild(el('strong',null,tight+' role'+(tight!==1?'s':'')+' as tight'));
|
||||
narrEl.appendChild(document.createTextNode(' — watch for supply compression. Pre-emptive signal, no query required.'));
|
||||
} else {
|
||||
narrEl.textContent='No coverage gaps right now — bench exceeds predicted demand across all roles. The system is tracking this continuously; check back tomorrow and the numbers will have moved.';
|
||||
}
|
||||
|
||||
// Card 4: Risk Watch
|
||||
if(d.risks&&d.risks.length){
|
||||
var c4=makeCard('risk','RISK WATCH',pool.erratic.toLocaleString()+' Erratic + '+pool.silent_cnt.toLocaleString()+' Silent Workers','Low reliability + behavioral flags — review placement priority');
|
||||
d.risks.forEach(function(w){
|
||||
addWorkerMini(c4,w.name,w.role+' · '+w.city+', '+w.state,'Reliability: '+Math.round(w.rel*100)+'% · Responsiveness: '+Math.round(w.resp*100)+'%','#f85149',w.archetype);
|
||||
});
|
||||
c4.appendChild(makeCallout('Not firing — deprioritizing. Every failed placement costs time and client trust. The system flags these automatically so you don\'t learn the hard way.','#2d0d0d','#7f1d1d'));
|
||||
container.appendChild(c4);
|
||||
}
|
||||
|
||||
// Card 5: Ready & Waiting
|
||||
if(d.untapped&&d.untapped.length){
|
||||
var c5=makeCard('talent','READY & WAITING','Available + Reliable — Call These First','85%+ reliability and actively available right now');
|
||||
d.untapped.forEach(function(w){
|
||||
addWorkerMini(c5,w.name,w.role+' · '+w.city+', '+w.state,'Available: '+Math.round(w.avail*100)+'% · Reliable: '+Math.round(w.rel*100)+'%','#bc8cff','ready');
|
||||
});
|
||||
container.appendChild(c5);
|
||||
}
|
||||
|
||||
// Card 6: Role Supply
|
||||
if(d.supply&&d.supply.length){
|
||||
var c6=makeCard('supply','ROLE SUPPLY','Workers by Role','Roles with high supply but low availability may indicate scheduling problems');
|
||||
d.supply.slice(0,10).forEach(function(r){
|
||||
var row=div('supply-row');
|
||||
row.appendChild(el('span','color:#f0f6fc;font-weight:500',r.role));
|
||||
var nums=el('span','color:#8b949e;font-size:11px');
|
||||
nums.appendChild(el('strong','color:#f0f6fc',r.supply.toLocaleString()));
|
||||
nums.appendChild(document.createTextNode(' total · '));
|
||||
nums.appendChild(el('strong','color:#f0f6fc',r.available.toLocaleString()));
|
||||
nums.appendChild(document.createTextNode(' available · rel '+Math.round(r.avg_rel*100)+'%'));
|
||||
row.appendChild(nums);c6.appendChild(row);
|
||||
});
|
||||
container.appendChild(c6);
|
||||
}
|
||||
setupPrompts(d);
|
||||
}).catch(function(e){
|
||||
document.getElementById('brief').textContent='Failed to load brief: '+e.message;
|
||||
var h=document.getElementById('ch1-stats');h.textContent='';h.appendChild(el('div','err','Live state unavailable: '+(e.message||e)));
|
||||
});
|
||||
document.getElementById('hdr-time').textContent='Snapshot · '+new Date().toLocaleTimeString();
|
||||
}
|
||||
function addStat(host,n,l,sub,cls){
|
||||
var d=el('div','card stat-lg '+(cls||''));
|
||||
d.appendChild(el('div','n',n));
|
||||
d.appendChild(el('div','l',l));
|
||||
d.appendChild(el('div','sub',sub||''));
|
||||
host.appendChild(d);
|
||||
}
|
||||
|
||||
// ─── Chapter 2 ────────────────────────────────────────────
|
||||
function loadChapter2(){
|
||||
api('/intelligence/market',{}).then(function(r){
|
||||
var host=document.getElementById('ch2-permits');host.textContent='';
|
||||
if(r.error){host.appendChild(el('div','err','Permit feed error: '+r.error));return}
|
||||
var byType=r.by_type||[];
|
||||
if(byType.length===0){host.appendChild(el('div','narr','No permits returned by the Chicago API right now.'));return}
|
||||
byType.slice(0,8).forEach(function(t){
|
||||
var det=document.createElement('details');
|
||||
var summary=document.createElement('summary');
|
||||
summary.appendChild(el('span','pill real','LIVE · DATA.CITYOFCHICAGO.ORG'));
|
||||
summary.appendChild(document.createTextNode(' '));
|
||||
var wt=el('strong',null,t.work_type||'General construction');
|
||||
summary.appendChild(wt);
|
||||
summary.appendChild(document.createTextNode(' · '+(t.permits||0)+' permits · $'+(t.total_cost||0).toLocaleString('en-US',{maximumFractionDigits:0})+' · est '+(t.estimated_workers||0)+' workers needed'));
|
||||
det.appendChild(summary);
|
||||
var body=el('div','body');
|
||||
body.appendChild(document.createTextNode('Likely roles: '+((t.needed_roles||[]).join(', ')||'—')+'. '));
|
||||
body.appendChild(document.createTextNode('At industry heuristic (~$150K per worker), this work type alone implies '));
|
||||
body.appendChild(el('strong',null,(t.estimated_workers||0)+' staffing opportunities'));
|
||||
body.appendChild(document.createTextNode(' over the next 30-60 days.'));
|
||||
det.appendChild(body);
|
||||
host.appendChild(det);
|
||||
});
|
||||
}).catch(function(e){
|
||||
var h=document.getElementById('ch2-permits');h.textContent='';h.appendChild(el('div','err',e.message||String(e)));
|
||||
});
|
||||
}
|
||||
|
||||
function makeCard(cls,label,headline,sub){
|
||||
var d=div('bcard '+cls);
|
||||
d.appendChild(div('label',label));d.appendChild(div('headline',headline));d.appendChild(div('sub',sub));return d;
|
||||
}
|
||||
function addBigNum(parent,num,label,color){
|
||||
var d=el('div','');
|
||||
var n=el('div','font-size:36px;font-weight:800;line-height:1;color:'+color,num);
|
||||
d.appendChild(n);d.appendChild(el('div','font-size:10px;color:#484f58;margin-top:2px',label));parent.appendChild(d);
|
||||
}
|
||||
function addWorkerMini(parent,name,detail,extra,color,badge){
|
||||
var w=div('wk');
|
||||
var av=el('div','width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;color:#f0f6fc;flex-shrink:0;background:'+AC[Math.floor(Math.random()*AC.length)]);
|
||||
av.textContent=(name||'').split(' ').map(function(n){return(n[0]||'').toUpperCase()}).slice(0,2).join('');
|
||||
var info=div('info');
|
||||
info.appendChild(div('nm',name));info.appendChild(div('det',detail));
|
||||
if(extra){var ex=div('det');ex.style.color='#58a6ff';ex.textContent=extra;info.appendChild(ex)}
|
||||
w.appendChild(av);w.appendChild(info);
|
||||
if(badge){var b=el('span','padding:2px 8px;border-radius:8px;font-size:10px;font-weight:600;background:'+(color||'#484f58')+'20;color:'+(color||'#484f58'),badge);w.appendChild(b)}
|
||||
parent.appendChild(w);
|
||||
}
|
||||
function makeCallout(text,bg,border){
|
||||
return el('div','font-size:11px;color:#8b949e;margin-top:8px;padding:8px 10px;background:'+(bg||'#0d1117')+';border:1px solid '+(border||'#1b2130')+';border-radius:6px;font-style:italic',text);
|
||||
}
|
||||
|
||||
function setupPrompts(data){
|
||||
var container=document.getElementById('prompts');container.textContent='';
|
||||
var prompts=[];
|
||||
if(data.gems&&data.gems[0])prompts.push('Find someone like '+data.gems[0].name+' but in OH');
|
||||
prompts.push('Who could handle industrial electrical work?');
|
||||
prompts.push('What if we lose our top 5 forklift operators?');
|
||||
prompts.push('Which workers should we stop placing?');
|
||||
if(data.supply&&data.supply.length>2){
|
||||
var under=data.supply.slice().sort(function(a,b){return (a.available/a.supply)-(b.available/b.supply)})[0];
|
||||
if(under)prompts.push('Show me available '+under.role+'s');
|
||||
}
|
||||
prompts.push('Find me a warehouse worker available today near Nashville');
|
||||
prompts.push('Find bilingual workers with leadership skills');
|
||||
prompts.forEach(function(p){
|
||||
var btn=el('button','',p);btn.className='prompt-btn';
|
||||
btn.onclick=function(){document.getElementById('q').value=p;send()};
|
||||
container.appendChild(btn);
|
||||
// ─── Chapter 3 ────────────────────────────────────────────
|
||||
function loadChapter3(){
|
||||
apiGet('/api/catalog/datasets').then(function(ds){
|
||||
var host=document.getElementById('ch3-datasets');host.textContent='';
|
||||
if(!Array.isArray(ds)){host.appendChild(el('div','err','No datasets returned.'));return}
|
||||
var bigOnes=ds.filter(function(d){return (d.row_count||0)>=500}).sort(function(a,b){return (b.row_count||0)-(a.row_count||0)});
|
||||
var playbookKeys=['successful_playbooks','playbook_memory','kb_'];
|
||||
bigOnes.slice(0,12).forEach(function(d){
|
||||
var row=el('div','row');
|
||||
var left=document.createElement('div');left.style.flex='1';left.style.minWidth='0';
|
||||
var t=el('div','title');
|
||||
var pillCls='synth', pillLabel='SWAP FOR YOUR DATA';
|
||||
if(playbookKeys.some(function(k){return (d.name||'').indexOf(k)>=0})){pillCls='flow';pillLabel='SYSTEM-GENERATED'}
|
||||
else if((d.name||'').indexOf('threat_')===0){pillCls='flow';pillLabel='SYSTEM-GENERATED'}
|
||||
t.appendChild(el('span','pill '+pillCls,pillLabel));
|
||||
t.appendChild(document.createTextNode(' '+(d.name||'(unnamed)')));
|
||||
var m=el('div','meta',(d.columns?d.columns.length+' columns · ':'')+(d.objects?d.objects.length+' parquet file'+(d.objects.length!==1?'s':''):''));
|
||||
left.appendChild(t);left.appendChild(m);
|
||||
var v=el('div','val',(d.row_count||0).toLocaleString()+' rows');
|
||||
row.appendChild(left);row.appendChild(v);host.appendChild(row);
|
||||
});
|
||||
}).catch(function(e){
|
||||
var h=document.getElementById('ch3-datasets');h.textContent='';h.appendChild(el('div','err','Catalog unavailable: '+(e.message||e)));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Chat ───
|
||||
function send(){
|
||||
var input=document.getElementById('q');
|
||||
var q=input.value.trim();if(!q)return;
|
||||
input.value='';
|
||||
var chat=document.getElementById('chat');
|
||||
var userMsg=div('msg user');var bubble=div('bubble',q);userMsg.appendChild(bubble);chat.appendChild(userMsg);
|
||||
var thinking=el('div','display:flex;align-items:center;gap:8px;color:#58a6ff;font-size:12px;padding:12px');
|
||||
thinking.appendChild(document.createTextNode('Querying 500K profiles'));
|
||||
var dots=el('span','');dots.className='dots';
|
||||
dots.appendChild(el('span','','.'));dots.appendChild(el('span','','.'));dots.appendChild(el('span','','.'));
|
||||
thinking.appendChild(dots);chat.appendChild(thinking);chat.scrollTop=chat.scrollHeight;
|
||||
document.getElementById('send-btn').disabled=true;
|
||||
// ─── Chapter 4 ────────────────────────────────────────────
|
||||
function loadChapter4(){
|
||||
api('/intelligence/permit_contracts',{}).then(function(r){
|
||||
var host=document.getElementById('ch4-demo');host.textContent='';
|
||||
if(!r.contracts||r.contracts.length===0){host.appendChild(el('div','loading','No contracts returned.'));return}
|
||||
var c=r.contracts[0];
|
||||
var p=c.permit||{}, prop=c.proposed||{}, tl=c.timeline||{};
|
||||
var card=el('div','card accent-b');
|
||||
var title=el('div',null,'$'+(p.cost||0).toLocaleString()+' · '+(p.work_type||''));
|
||||
title.style.cssText='font-size:15px;font-weight:600;color:#e6edf3';
|
||||
card.appendChild(title);
|
||||
var addr=el('div',null,(p.address||'')+' · Chicago, IL · filed '+(p.issue_date||''));
|
||||
addr.style.cssText='color:#8b949e;font-size:12px;margin-top:2px';
|
||||
card.appendChild(addr);
|
||||
|
||||
card.appendChild(el('div','step-label','STEP 1 · Derive staffing need'));
|
||||
var s1=el('div','step-body');
|
||||
s1.appendChild(document.createTextNode('Industry heuristic: ~1 worker per $150K of permit cost, capped 2-8. Resulting contract: '));
|
||||
s1.appendChild(el('strong',null,(prop.count||0)+'× '+(prop.role||'')));
|
||||
s1.appendChild(document.createTextNode(' in Chicago, IL.'));
|
||||
card.appendChild(s1);
|
||||
|
||||
card.appendChild(el('div','step-label','STEP 2 · Narrow via SQL ('+((prop.pool_size||0).toLocaleString())+' candidates match)'));
|
||||
card.appendChild(el('div','step-body',"role = '"+(prop.role||'')+"' AND state = 'IL' AND city = 'Chicago' AND CAST(availability AS DOUBLE) > 0.5"));
|
||||
|
||||
card.appendChild(el('div','step-label','STEP 3 · Rank via semantic match + playbook boost'));
|
||||
host.appendChild(card);
|
||||
|
||||
var list=document.createElement('div');list.style.marginTop='6px';
|
||||
(prop.candidates||[]).slice(0,5).forEach(function(cand,i){
|
||||
var w=el('div','worker');
|
||||
var initials=(cand.name||'?').split(' ').map(function(s){return (s[0]||'').toUpperCase()}).join('').substring(0,2);
|
||||
w.appendChild(el('div','av',initials));
|
||||
var info=el('div','info');
|
||||
var nm=el('div','nm',cand.name||cand.doc_id||'?');
|
||||
if((cand.playbook_boost||0)>0){
|
||||
var ncit=(cand.playbook_citations||[]).length;
|
||||
nm.appendChild(el('span','boost-chip','Endorsed · '+ncit+' past fill'+(ncit!==1?'s':'')));
|
||||
}
|
||||
info.appendChild(nm);
|
||||
var why=cand.doc_id+' · '+(cand.playbook_boost>0?'boosted +'+cand.playbook_boost.toFixed(3)+' by memory · ':'')+'semantic score '+(cand.score||0).toFixed(3);
|
||||
info.appendChild(el('div','why',why));
|
||||
w.appendChild(info);
|
||||
w.appendChild(el('div','score','#'+(i+1)));
|
||||
list.appendChild(w);
|
||||
});
|
||||
card.appendChild(list);
|
||||
|
||||
if(c.discovered_pattern||c.pattern_matched>0){
|
||||
var mem=el('div','mem-chip');
|
||||
mem.appendChild(el('span','l','MEMORY ('+(c.pattern_matched||0)+' past playbooks):'));
|
||||
mem.appendChild(document.createTextNode(' '+(c.discovered_pattern||'memory is sparse for this role+geo')));
|
||||
card.appendChild(mem);
|
||||
}
|
||||
|
||||
if(tl.days_to_deadline!==undefined){
|
||||
var tm=document.createElement('div');
|
||||
tm.style.cssText='margin-top:12px;padding-top:10px;border-top:1px solid #171d27;font-size:12px;color:#8b949e';
|
||||
tm.appendChild(el('strong',null,'Timeline. '));
|
||||
tm.childNodes[0].style && (tm.childNodes[0].style.color='#c9d1d9');
|
||||
var dd=tl.days_to_deadline;
|
||||
var when=(dd<=0?Math.abs(dd)+' days overdue':dd+' days out');
|
||||
tm.appendChild(document.createTextNode('Staffing window opens '+(tl.staffing_window_opens||'')+' ('+when+'). Construction starts '+(tl.estimated_construction_start||'')+'. Urgency: '));
|
||||
tm.appendChild(el('strong',null,(tl.urgency||'').toUpperCase()));
|
||||
tm.appendChild(document.createTextNode('.'));
|
||||
card.appendChild(tm);
|
||||
}
|
||||
|
||||
var note=el('div','narr');
|
||||
note.appendChild(el('strong',null,'This is the CRM gap. '));
|
||||
note.appendChild(document.createTextNode('A CRM would tell you the permit exists. It would NOT narrow to matching candidates, would NOT boost past-successful workers, would NOT flag patterns across similar past fills, would NOT count down to the deadline. All that happens here, pre-computed, before the staffer clicks anything.'));
|
||||
host.appendChild(note);
|
||||
}).catch(function(e){
|
||||
var h=document.getElementById('ch4-demo');h.textContent='';h.appendChild(el('div','err','Demo failed: '+(e.message||e)));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Chapter 5 ────────────────────────────────────────────
|
||||
function loadChapter5(){
|
||||
apiGet('/api/vectors/playbook_memory/stats').then(function(mem){
|
||||
var host=document.getElementById('ch5-memory');host.textContent='';
|
||||
var card=el('div','card accent-l');
|
||||
var big=el('div',null,(mem.entries||0).toLocaleString());
|
||||
big.style.cssText='font-size:30px;font-weight:800;color:#e6edf3;line-height:1';
|
||||
card.appendChild(big);
|
||||
var lb=el('div',null,'Playbooks in memory');
|
||||
lb.style.cssText='font-size:10px;color:#545d68;text-transform:uppercase;letter-spacing:1.2px;margin-top:8px;font-weight:600';
|
||||
card.appendChild(lb);
|
||||
var sub=el('div',null,(mem.total_names_endorsed||0).toLocaleString()+' endorsed worker-tags · each one boosts future matching queries');
|
||||
sub.style.cssText='font-size:12px;color:#8b949e;margin-top:4px';
|
||||
card.appendChild(sub);
|
||||
host.appendChild(card);
|
||||
|
||||
if(mem.sample&&mem.sample.length){
|
||||
var hdr=document.createElement('div');
|
||||
hdr.style.cssText='color:#545d68;font-size:11px;text-transform:uppercase;letter-spacing:1.4px;font-weight:600;margin:14px 0 8px';
|
||||
hdr.textContent='Sample playbooks held in memory right now';
|
||||
host.appendChild(hdr);
|
||||
mem.sample.slice(0,5).forEach(function(pb){
|
||||
var row=el('div','row');
|
||||
var left=document.createElement('div');left.style.flex='1';left.style.minWidth='0';
|
||||
left.appendChild(el('div','title',pb.operation||'(no operation)'));
|
||||
left.appendChild(el('div','meta',(pb.city||'')+', '+(pb.state||'')+' · endorsed: '+((pb.endorsed||[]).slice(0,3).join(', ')||'—')));
|
||||
row.appendChild(left);host.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
var narr=el('div','narr');
|
||||
narr.appendChild(el('strong',null,'What this means for a staffer. '));
|
||||
narr.appendChild(document.createTextNode('When you log a successful fill, this counter grows by one. Next time someone searches for a similar role+city, the system finds your past pick and boosts them to the top. Over months, the system becomes a record of who has actually worked out — not just who is on the roster.'));
|
||||
host.appendChild(narr);
|
||||
}).catch(function(e){
|
||||
var h=document.getElementById('ch5-memory');h.textContent='';h.appendChild(el('div','err','Memory unavailable: '+(e.message||e)));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Chapter 6 ────────────────────────────────────────────
|
||||
function runTry(){
|
||||
var q=document.getElementById('try-q').value.trim();if(!q)return;
|
||||
var btn=document.getElementById('try-btn'),out=document.getElementById('try-out');
|
||||
btn.disabled=true;btn.textContent='Thinking…';out.textContent='';out.appendChild(el('div','loading','Running…'));
|
||||
api('/intelligence/chat',{message:q}).then(function(d){
|
||||
thinking.remove();document.getElementById('send-btn').disabled=false;
|
||||
var msg=div('msg system');var bbl=div('bubble');
|
||||
|
||||
// Steps
|
||||
if(d.queries_run&&d.queries_run.length){
|
||||
var steps=div('steps');
|
||||
d.queries_run.forEach(function(s){steps.appendChild(el('div','font-size:11px;color:#58a6ff;padding:2px 0','→ '+s))});
|
||||
bbl.appendChild(steps);
|
||||
}
|
||||
bbl.appendChild(div('summary',d.summary||''));
|
||||
|
||||
// Type-specific rendering
|
||||
if(d.type==='similar'&&d.source){
|
||||
var sc=div('source-card');
|
||||
sc.appendChild(el('div','font-size:10px;color:#d29922;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px','Source Worker'));
|
||||
sc.appendChild(el('div','font-weight:700;color:#f0f6fc;font-size:14px',d.source.name));
|
||||
sc.appendChild(el('div','font-size:12px;color:#d29922',d.source.role+' · '+d.source.city+', '+d.source.state+' · Reliability: '+Math.round(d.source.rel*100)+'%'));
|
||||
sc.appendChild(el('div','font-size:11px;color:#8b949e;margin-top:4px','Skills: '+(d.source.skills||'none')+' · Archetype: '+(d.source.archetype||'unknown')));
|
||||
bbl.appendChild(sc);
|
||||
renderResults(bbl,d.results);
|
||||
if(d.sql_matches)bbl.appendChild(el('div','font-size:10px;color:#484f58;margin-top:6px','Filtered pool: '+d.sql_matches.toLocaleString()+' workers'));
|
||||
}
|
||||
else if(d.type==='discovery'){
|
||||
renderResults(bbl,d.results);
|
||||
if(d.note)bbl.appendChild(el('div','font-size:11px;color:#8b949e;font-style:italic;margin-top:8px;padding:8px;background:#0d1117;border-radius:6px',d.note));
|
||||
}
|
||||
else if(d.type==='whatif'){
|
||||
var sevColors={HIGH:['#7f1d1d','#fca5a5'],MEDIUM:['#854d0e','#fcd34d'],LOW:['#238636','#86efac']};
|
||||
var sc2=sevColors[d.risk_level]||sevColors.MEDIUM;
|
||||
var sev=el('span','display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:700;margin-left:8px;background:'+sc2[0]+';color:'+sc2[1],'RISK: '+d.risk_level);
|
||||
bbl.querySelector('.summary').appendChild(sev);
|
||||
|
||||
bbl.appendChild(el('div','font-size:11px;color:#f85149;margin:8px 0 4px;font-weight:600','Workers you\'d lose:'));
|
||||
(d.lost||[]).forEach(function(w){
|
||||
var rc=div('result-card');rc.style.borderLeft='3px solid #f85149';
|
||||
rc.appendChild(el('div','font-weight:600;color:#f0f6fc;font-size:13px',w.name));
|
||||
rc.appendChild(el('div','font-size:11px;color:#8b949e;margin-top:2px',w.role+' · '+w.city+', '+w.state+' · Reliability: '+Math.round(w.rel*100)+'%'));
|
||||
bbl.appendChild(rc);
|
||||
});
|
||||
bbl.appendChild(el('div','font-size:11px;color:#3fb950;margin:12px 0 4px;font-weight:600','Remaining bench: '+d.reliable_remaining+' reliable workers across '+(d.bench||[]).length+' states ('+d.total_in_role+' total in role)'));
|
||||
(d.bench||[]).forEach(function(b){
|
||||
var row=el('div','display:flex;justify-content:space-between;padding:4px 8px;font-size:12px');
|
||||
row.appendChild(el('span','color:#f0f6fc',b.state+' — '+b.total+' total'));
|
||||
row.appendChild(el('span','color:'+(b.reliable>5?'#3fb950':'#f85149'),b.reliable+' reliable'));
|
||||
bbl.appendChild(row);
|
||||
});
|
||||
}
|
||||
else if(d.type==='risk'){
|
||||
(d.results||[]).forEach(function(w){
|
||||
var rc=div('result-card');rc.style.borderLeft='3px solid #f85149';
|
||||
rc.appendChild(el('div','font-weight:600;color:#f0f6fc;font-size:13px',w.name));
|
||||
rc.appendChild(el('div','font-size:11px;color:#8b949e;margin-top:2px',w.role+' · '+w.city+', '+w.state));
|
||||
rc.appendChild(el('div','font-size:10px;color:#f85149;margin-top:4px','Reliability: '+Math.round(w.rel*100)+'% · Responsiveness: '+Math.round(w.resp*100)+'% · Compliance: '+Math.round(w.compl*100)+'% · '+w.archetype));
|
||||
bbl.appendChild(rc);
|
||||
});
|
||||
if(d.total_flagged)bbl.appendChild(el('div','font-size:10px;color:#484f58;margin-top:6px',d.total_flagged.toLocaleString()+' total workers match this risk profile'));
|
||||
}
|
||||
else if(d.type==='smart_search'){
|
||||
// Show what the system understood
|
||||
btn.disabled=false;btn.textContent='Ask';out.textContent='';
|
||||
var card=el('div','card accent-b');
|
||||
if(d.understood&&d.understood.length){
|
||||
var tags=el('div','display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px');
|
||||
var tags=document.createElement('div');tags.style.cssText='display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px';
|
||||
d.understood.forEach(function(u){
|
||||
tags.appendChild(el('span','padding:3px 10px;border-radius:10px;font-size:11px;background:#1a274420;color:#58a6ff;border:1px solid #1a274480',u));
|
||||
var tg=el('span',null,u);
|
||||
tg.style.cssText='padding:3px 10px;border-radius:10px;font-size:11px;background:#1a274420;color:#58a6ff;border:1px solid #1a274480';
|
||||
tags.appendChild(tg);
|
||||
});
|
||||
bbl.appendChild(tags);
|
||||
card.appendChild(tags);
|
||||
}
|
||||
// Show SQL results with zip codes, availability, skills
|
||||
if(d.sql_results&&d.sql_results.length){
|
||||
d.sql_results.forEach(function(w){
|
||||
var rc=div('result-card');
|
||||
rc.appendChild(el('div','font-weight:600;color:#f0f6fc;font-size:14px',w.name));
|
||||
var locParts=[w.role,w.city+', '+w.state];
|
||||
if(w.zip)locParts.push('ZIP: '+w.zip);
|
||||
rc.appendChild(el('div','font-size:12px;color:#8b949e;margin-top:2px',locParts.join(' · ')));
|
||||
// Metrics row
|
||||
var metrics=[];
|
||||
if(w.avail!==undefined)metrics.push('Available: '+Math.round(w.avail*100)+'%');
|
||||
if(w.rel!==undefined)metrics.push('Reliable: '+Math.round(w.rel*100)+'%');
|
||||
if(w.archetype)metrics.push(w.archetype);
|
||||
if(metrics.length)rc.appendChild(el('div','font-size:11px;color:#58a6ff;margin-top:4px',metrics.join(' · ')));
|
||||
// Skills + certs
|
||||
if(w.skills||w.certifications){
|
||||
var tagRow=el('div','display:flex;gap:4px;flex-wrap:wrap;margin-top:6px');
|
||||
if(w.skills)(w.skills||'').split(',').forEach(function(s){s=s.trim();if(s)tagRow.appendChild(el('span','padding:2px 8px;background:#1a2744;color:#58a6ff;border-radius:8px;font-size:10px',s))});
|
||||
if(w.certifications)(w.certifications||'').split(',').forEach(function(c){c=c.trim();if(c&&c!=='none')tagRow.appendChild(el('span','padding:2px 8px;background:#1a3a2a;color:#3fb950;border-radius:8px;font-size:10px',c))});
|
||||
rc.appendChild(tagRow);
|
||||
}
|
||||
bbl.appendChild(rc);
|
||||
});
|
||||
}
|
||||
// Also show vector results if different
|
||||
if(d.vector_results&&d.vector_results.length&&(!d.sql_results||!d.sql_results.length)){
|
||||
renderResults(bbl,d.vector_results);
|
||||
}
|
||||
}
|
||||
else if(d.type==='search'||d.type==='answer'){
|
||||
renderResults(bbl,d.results||d.sources||[]);
|
||||
}
|
||||
else if(d.type==='error'){
|
||||
bbl.appendChild(el('div','color:#f85149;font-size:13px',d.summary));
|
||||
var sum=el('div',null,d.summary||'(no summary)');
|
||||
sum.style.cssText='color:#c9d1d9;font-size:13px;margin-bottom:10px';
|
||||
card.appendChild(sum);
|
||||
|
||||
if(d.pattern_playbooks_matched>0&&d.discovered_pattern){
|
||||
var mem=el('div','mem-chip');
|
||||
mem.appendChild(el('span','l','MEMORY ('+d.pattern_playbooks_matched+' playbooks):'));
|
||||
mem.appendChild(document.createTextNode(' '+d.discovered_pattern));
|
||||
card.appendChild(mem);
|
||||
}
|
||||
|
||||
if(d.duration_ms){
|
||||
var meta=div('meta');
|
||||
meta.appendChild(el('span','margin-right:12px',d.duration_ms+'ms'));
|
||||
if(d.sql_matches)meta.appendChild(el('span','',d.sql_matches.toLocaleString()+' records scanned'));
|
||||
bbl.appendChild(meta);
|
||||
var workers=d.sql_results||d.vector_results||d.results||[];
|
||||
workers.slice(0,5).forEach(function(w,i){
|
||||
var row=el('div','worker');
|
||||
var nm=w.name||(w.text||'').split('—')[0].trim()||w.doc_id||'?';
|
||||
var initials=nm.split(' ').map(function(s){return (s[0]||'').toUpperCase()}).join('').substring(0,2);
|
||||
row.appendChild(el('div','av',initials));
|
||||
var info=el('div','info');
|
||||
var n=el('div','nm',nm);
|
||||
if((w.playbook_boost||0)>0){
|
||||
n.appendChild(el('span','boost-chip','Endorsed · '+((w.playbook_citations||[]).length||'?')+' past fill(s)'));
|
||||
}
|
||||
msg.appendChild(bbl);chat.appendChild(msg);chat.scrollTop=chat.scrollHeight;
|
||||
info.appendChild(n);
|
||||
var bits=[];
|
||||
if(w.role) bits.push(w.role);
|
||||
if(w.city&&w.state) bits.push(w.city+', '+w.state);
|
||||
if(w.rel!==undefined) bits.push('reliability '+Math.round(w.rel*100)+'%');
|
||||
if(w.avail!==undefined) bits.push('availability '+Math.round(w.avail*100)+'%');
|
||||
info.appendChild(el('div','why',bits.join(' · ')||'AI semantic match'));
|
||||
row.appendChild(info);
|
||||
row.appendChild(el('div','score','#'+(i+1)));
|
||||
card.appendChild(row);
|
||||
});
|
||||
var meta=el('div',null,(d.duration_ms||0)+'ms · type='+(d.type||'smart_search'));
|
||||
meta.style.cssText='color:#545d68;font-size:11px;margin-top:10px';
|
||||
card.appendChild(meta);
|
||||
out.appendChild(card);
|
||||
}).catch(function(e){
|
||||
thinking.remove();document.getElementById('send-btn').disabled=false;
|
||||
var msg=div('msg system');var bbl=div('bubble');bbl.style.borderColor='#f85149';
|
||||
bbl.appendChild(el('div','color:#f85149','Error: '+e.message));
|
||||
msg.appendChild(bbl);chat.appendChild(msg);
|
||||
btn.disabled=false;btn.textContent='Ask';
|
||||
out.textContent='';out.appendChild(el('div','err',e.message||String(e)));
|
||||
});
|
||||
}
|
||||
|
||||
function renderResults(parent,results){
|
||||
if(!results||!results.length)return;
|
||||
results.forEach(function(r){
|
||||
var text=r.text||r.chunk_text||'';
|
||||
var parts=text.split(/\u2014|\u2013|\u002D{2,}/);
|
||||
var name=parts[0]?parts[0].trim():(r.name||r.doc_id||'Worker');
|
||||
var rest=parts[1]?parts[1].trim():'';
|
||||
var roleM=rest.match(/^(.+?)\s+in\s+(.+?)\./);
|
||||
var skillM=rest.match(/Skills:\s*([^.]+)/);
|
||||
var certM=rest.match(/Cert(?:s|ifications)?:\s*([^.]+)/);
|
||||
var relM=rest.match(/Reliability:\s*([\d.]+)/);
|
||||
var archM=rest.match(/Archetype:\s*(\w+)/);
|
||||
|
||||
var rc=div('result-card');
|
||||
rc.appendChild(el('div','font-weight:600;color:#f0f6fc;font-size:13px',name));
|
||||
if(roleM)rc.appendChild(el('div','font-size:11px;color:#8b949e;margin-top:2px',roleM[1]+' · '+roleM[2]));
|
||||
var scoreParts=[];
|
||||
if(r.score)scoreParts.push('Match: '+Math.round(r.score*100)+'%');
|
||||
if(relM)scoreParts.push('Reliability: '+Math.round(relM[1]*100)+'%');
|
||||
if(archM)scoreParts.push(archM[1]);
|
||||
if(scoreParts.length)rc.appendChild(el('div','font-size:10px;color:#58a6ff;margin-top:4px',scoreParts.join(' · ')));
|
||||
|
||||
if(skillM||certM){
|
||||
var tags=el('div','display:flex;gap:4px;flex-wrap:wrap;margin-top:6px');
|
||||
if(skillM)skillM[1].split(/[|,]/).forEach(function(s){if(s.trim()){tags.appendChild(el('span','padding:2px 8px;background:#1a2744;color:#58a6ff;border-radius:8px;font-size:10px',s.trim()))}});
|
||||
if(certM)certM[1].split(/[|,]/).forEach(function(c){if(c.trim()&&c.trim()!=='none'){tags.appendChild(el('span','padding:2px 8px;background:#1a3a2a;color:#3fb950;border-radius:8px;font-size:10px',c.trim()))}});
|
||||
rc.appendChild(tags);
|
||||
}
|
||||
parent.appendChild(rc);
|
||||
});
|
||||
}
|
||||
</script></body></html>
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
+1122
-515
File diff suppressed because it is too large
Load Diff
+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)}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Lakehouse — Connect Your Data</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
a{color:#58a6ff;text-decoration:none}
|
||||
a:hover{color:#79c0ff}
|
||||
|
||||
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
|
||||
.bar h1{font-size:14px;font-weight:600;color:#e6edf3;letter-spacing:-0.2px}
|
||||
.bar nav{display:flex;gap:2px}
|
||||
.bar nav a{font-size:12px;color:#545d68;padding:6px 14px;border-radius:6px;transition:all 0.15s}
|
||||
.bar nav a:hover{color:#e6edf3;background:#161b22}
|
||||
.bar nav a.active{color:#e6edf3;background:#1c2333}
|
||||
.bar .rt{font-size:11px;color:#545d68}
|
||||
|
||||
.wrap{max-width:1040px;margin:0 auto;padding:28px 20px 60px}
|
||||
|
||||
.step{margin-bottom:36px;padding-left:44px;position:relative}
|
||||
.step:before{content:attr(data-n);position:absolute;left:0;top:-2px;width:30px;height:30px;border-radius:15px;background:#0d1117;border:1px solid #21262d;color:#58a6ff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center}
|
||||
.step.active:before{background:#1f6feb;color:#fff;border-color:#1f6feb}
|
||||
.step.done:before{content:"✓";background:#0d2818;color:#3fb950;border-color:#2ea043}
|
||||
.step h2{color:#e6edf3;font-size:18px;font-weight:700;margin-bottom:6px;letter-spacing:-0.2px}
|
||||
.step .lede{color:#8b949e;font-size:13px;margin-bottom:14px;line-height:1.6}
|
||||
|
||||
.card{background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:18px;margin:8px 0}
|
||||
|
||||
.btn{padding:10px 20px;background:#1f6feb;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block}
|
||||
.btn:hover{background:#388bfd}
|
||||
.btn:disabled{opacity:0.4;cursor:not-allowed}
|
||||
.btn.ghost{background:transparent;border:1px solid #21262d;color:#c9d1d9}
|
||||
.btn.ghost:hover{background:#161b22;border-color:#30363d}
|
||||
.btn.green{background:#2ea043}
|
||||
.btn.green:hover{background:#3fb950}
|
||||
|
||||
.drop{border:2px dashed #30363d;border-radius:10px;padding:28px;text-align:center;transition:all 0.15s;cursor:pointer}
|
||||
.drop:hover{border-color:#58a6ff;background:#0d1117}
|
||||
.drop.active{border-color:#2ea043;background:#0d2818}
|
||||
.drop input[type=file]{display:none}
|
||||
.drop .title{color:#e6edf3;font-weight:600;margin-bottom:4px}
|
||||
.drop .sub{color:#545d68;font-size:12px}
|
||||
|
||||
.actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:10px}
|
||||
|
||||
input[type=text]{padding:10px 14px;background:#161b22;border:1px solid #21262d;border-radius:8px;color:#e6edf3;font-size:13px;outline:none;width:100%}
|
||||
input[type=text]:focus{border-color:#388bfd}
|
||||
|
||||
table.preview{width:100%;border-collapse:collapse;font-size:11px;margin-top:8px;font-family:ui-monospace,Menlo,monospace}
|
||||
table.preview th{text-align:left;padding:8px 10px;color:#8b949e;background:#0d1117;border-bottom:1px solid #21262d;font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:0.8px}
|
||||
table.preview td{padding:6px 10px;border-bottom:1px solid #171d27;color:#c9d1d9;white-space:nowrap;max-width:180px;overflow:hidden;text-overflow:ellipsis}
|
||||
table.preview tr:hover td{background:#0d1117}
|
||||
|
||||
.col-list{display:flex;flex-direction:column;gap:6px;margin-top:6px}
|
||||
.col{display:flex;align-items:center;gap:8px;padding:8px 12px;background:#161b22;border-radius:6px;font-size:12px}
|
||||
.col .name{color:#e6edf3;font-weight:500;min-width:140px}
|
||||
.col .type{color:#58a6ff;font-size:11px;font-family:ui-monospace,Menlo,monospace;min-width:60px}
|
||||
.col .pii{padding:2px 8px;border-radius:9px;font-size:9px;font-weight:600;letter-spacing:0.3px}
|
||||
.pii.email{background:#2d1b00;color:#d29922;border:1px solid #854d0e}
|
||||
.pii.phone{background:#2d1b00;color:#d29922;border:1px solid #854d0e}
|
||||
.pii.ssn{background:#3a1a1a;color:#f85149;border:1px solid #7f1d1d}
|
||||
.pii.addr{background:#2d1b00;color:#d29922;border:1px solid #854d0e}
|
||||
.col .sample{color:#8b949e;font-size:11px;font-family:ui-monospace,Menlo,monospace;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
.note{color:#8b949e;font-size:12px;line-height:1.7;padding:10px 14px;border-left:2px solid #21262d;margin:10px 0}
|
||||
.note strong{color:#c9d1d9}
|
||||
|
||||
.result{padding:16px;border-radius:10px;margin:10px 0}
|
||||
.result.ok{background:#0d2818;border:1px solid #2ea04360;color:#86efac}
|
||||
.result.err{background:#3a1a1a;border:1px solid #f8514960;color:#fca5a5}
|
||||
.result .big{font-size:16px;font-weight:700;margin-bottom:4px}
|
||||
.result .detail{font-size:12px}
|
||||
|
||||
.spin{display:inline-block;width:14px;height:14px;border:2px solid #30363d;border-top-color:#58a6ff;border-radius:50%;animation:spin 0.7s linear infinite;vertical-align:middle;margin-right:6px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
.footer{border-top:1px solid #171d27;padding:20px;text-align:center;color:#3d444d;font-size:11px}
|
||||
|
||||
@media(max-width:720px){
|
||||
.wrap{padding:20px 12px 40px}
|
||||
.step{padding-left:36px}
|
||||
.step:before{width:26px;height:26px;font-size:12px}
|
||||
.bar nav{display:none}
|
||||
}
|
||||
</style></head>
|
||||
<body>
|
||||
|
||||
<div class="bar">
|
||||
<h1>Lakehouse — Connect Your Data</h1>
|
||||
<nav>
|
||||
<a href=".">Dashboard</a>
|
||||
<a href="console">Walkthrough</a>
|
||||
<a href="proof">Architecture</a>
|
||||
<a href="spec">Spec</a>
|
||||
<a href="onboard" class="active">Onboard</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
<a href="workspaces">Workspaces</a>
|
||||
</nav>
|
||||
<div class="rt">30 minutes from CSV to live search</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<div style="margin-bottom:24px">
|
||||
<h1 style="color:#e6edf3;font-size:26px;font-weight:700;letter-spacing:-0.5px;margin-bottom:8px">Ingest any staffing CSV in three steps</h1>
|
||||
<p style="color:#8b949e;font-size:14px;line-height:1.6;max-width:720px">
|
||||
Upload your ATS export, your worker roster, or any CSV with a <code>name</code> column.
|
||||
The wizard auto-detects columns, flags PII, previews the first rows, then ingests
|
||||
as a queryable Parquet dataset. Everything that follows — hybrid search,
|
||||
playbook ranking, pattern discovery — works against your data automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="step active" data-n="1" id="step-1">
|
||||
<h2>Pick a file</h2>
|
||||
<div class="lede">Drag a CSV in, pick from disk, or use the sample roster to see the flow without any real data.</div>
|
||||
<label class="drop" id="drop">
|
||||
<input type="file" id="file" accept=".csv,text/csv">
|
||||
<div class="title">Drop a CSV here or click to choose</div>
|
||||
<div class="sub">Max 50MB · UTF-8 · comma-separated with a header row</div>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="samples/staffing_roster_sample.csv" download>Download sample roster (25 workers)</a>
|
||||
<button class="btn ghost" id="use-sample" type="button">Use the sample as my data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="step" data-n="2" id="step-2" style="display:none">
|
||||
<h2>Review what we detected</h2>
|
||||
<div class="lede">
|
||||
Columns auto-typed. PII columns flagged. First rows previewed. Nothing is written to the system yet — this is a read-only dry-run.
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="color:#545d68;font-size:11px;text-transform:uppercase;letter-spacing:1.2px;font-weight:600;margin-bottom:10px">Columns detected</div>
|
||||
<div class="col-list" id="col-list"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="color:#545d68;font-size:11px;text-transform:uppercase;letter-spacing:1.2px;font-weight:600;margin-bottom:10px">First rows</div>
|
||||
<div style="overflow-x:auto"><table class="preview" id="preview-table"></table></div>
|
||||
<div style="color:#545d68;font-size:11px;margin-top:8px" id="row-count"></div>
|
||||
</div>
|
||||
<div class="note">
|
||||
<strong>What happens next.</strong> On commit, the file is sent to <code>/ingest/file</code> — the same
|
||||
endpoint every other ingest path uses. The Rust gateway writes it to object storage as
|
||||
Parquet, computes a schema fingerprint, registers it in the catalog, and auto-detects PII
|
||||
columns server-side. Re-uploading the same file is a no-op (deduplicated by content hash).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="step" data-n="3" id="step-3" style="display:none">
|
||||
<h2>Name it and commit</h2>
|
||||
<div class="lede">Give the dataset a queryable name. This becomes the table you can <code>SELECT * FROM</code> immediately after commit.</div>
|
||||
<div class="card">
|
||||
<label style="display:block;color:#8b949e;font-size:12px;margin-bottom:6px">Dataset name</label>
|
||||
<input type="text" id="dataset-name" placeholder="e.g. acme_staffing_roster" value="client_workers">
|
||||
<div style="color:#545d68;font-size:11px;margin-top:6px">
|
||||
Use lowercase + underscores. Once committed: queryable via <code>/query/sql</code>,
|
||||
searchable via <code>/search</code>, indexable via <code>/vectors/index</code>.
|
||||
</div>
|
||||
<div class="actions" style="margin-top:14px">
|
||||
<button class="btn green" id="commit-btn" type="button">Commit dataset</button>
|
||||
<button class="btn ghost" id="back-btn" type="button">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="commit-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Post-commit next steps (hidden until success) -->
|
||||
<div class="step" data-n="4" id="step-4" style="display:none">
|
||||
<h2>Your dataset is live</h2>
|
||||
<div class="lede">From here, the rest of the system applies to your data with zero additional setup.</div>
|
||||
<div class="card">
|
||||
<div style="color:#e6edf3;font-size:14px;font-weight:600;margin-bottom:12px">What you can do right now</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="padding:10px 14px;background:#161b22;border-radius:6px;font-size:12px"><strong style="color:#e6edf3">Query via SQL.</strong> <code>POST /query/sql</code> with <code>SELECT * FROM <span id="ds-name-1">your_dataset</span> LIMIT 10</code>.</div>
|
||||
<div style="padding:10px 14px;background:#161b22;border-radius:6px;font-size:12px"><strong style="color:#e6edf3">Build a vector index.</strong> <code>POST /vectors/index</code> with <code>{"dataset":"<span id="ds-name-2">your_dataset</span>","text_column":"skills"}</code>. Embeddings stream in; queryable progressively.</div>
|
||||
<div style="padding:10px 14px;background:#161b22;border-radius:6px;font-size:12px"><strong style="color:#e6edf3">Search via the dashboard.</strong> Open the <a href=".">dashboard</a> and the "Search all workers" box. Results will come from your data.</div>
|
||||
<div style="padding:10px 14px;background:#161b22;border-radius:6px;font-size:12px"><strong style="color:#e6edf3">Track with playbook memory.</strong> Every Call/SMS/No-show click on a worker card trains the system on your data.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer">Lakehouse · onboarding wizard · <a href="spec">full architecture spec</a></div>
|
||||
|
||||
<script>
|
||||
var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
|
||||
var A=location.origin+P;
|
||||
|
||||
var parsedFile=null; // { name, rows, columns, sample }
|
||||
var rawBlob=null;
|
||||
|
||||
function el(tag,cls,text){
|
||||
var e=document.createElement(tag);
|
||||
if(cls) e.className=cls;
|
||||
if(text!==undefined && text!==null) e.textContent=String(text);
|
||||
return e;
|
||||
}
|
||||
|
||||
function setStep(n){
|
||||
for(var i=1;i<=4;i++){
|
||||
var s=document.getElementById('step-'+i);
|
||||
if(!s) continue;
|
||||
s.classList.remove('active','done');
|
||||
if(i<n) s.classList.add('done');
|
||||
if(i===n) { s.classList.add('active'); s.style.display='block'; }
|
||||
else if(i>n) s.style.display='none';
|
||||
else s.style.display='block';
|
||||
}
|
||||
}
|
||||
|
||||
// Basic CSV parser. Handles quoted fields with commas + escaped quotes.
|
||||
function parseCSV(text){
|
||||
var lines=[], i=0, cur='', row=[], inQ=false;
|
||||
while(i<text.length){
|
||||
var c=text[i];
|
||||
if(inQ){
|
||||
if(c==='"' && text[i+1]==='"'){ cur+='"'; i+=2; continue; }
|
||||
if(c==='"'){ inQ=false; i++; continue; }
|
||||
cur+=c; i++;
|
||||
} else {
|
||||
if(c==='"'){ inQ=true; i++; continue; }
|
||||
if(c===','){ row.push(cur); cur=''; i++; continue; }
|
||||
if(c==='\n'){ row.push(cur); lines.push(row); row=[]; cur=''; i++; continue; }
|
||||
if(c==='\r'){ i++; continue; }
|
||||
cur+=c; i++;
|
||||
}
|
||||
}
|
||||
if(cur.length>0||row.length>0){ row.push(cur); lines.push(row); }
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Type + PII inference from a sample of values for a column.
|
||||
function inferColumn(name, values){
|
||||
var nonEmpty=values.filter(function(v){return v!=null && v!==''});
|
||||
var n=nonEmpty.length;
|
||||
var lname=(name||'').toLowerCase();
|
||||
|
||||
// PII signals by name and sample content
|
||||
var pii=null;
|
||||
if(/email|e-mail|mail_address/.test(lname)) pii='email';
|
||||
else if(/phone|mobile|cell|tel/.test(lname)) pii='phone';
|
||||
else if(/ssn|social|tax_id/.test(lname)) pii='ssn';
|
||||
else if(/address|street|zip|postal/.test(lname)) pii='addr';
|
||||
// Content-based PII scan on sample
|
||||
if(!pii){
|
||||
var emailRe=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
var phoneRe=/^[\s\-\(\)\+]*\d[\s\-\(\)\+\d]{6,}$/;
|
||||
var emailHits=nonEmpty.filter(function(v){return emailRe.test(v)}).length;
|
||||
var phoneHits=nonEmpty.filter(function(v){return phoneRe.test(v)}).length;
|
||||
if(emailHits>n*0.5) pii='email';
|
||||
else if(phoneHits>n*0.5) pii='phone';
|
||||
}
|
||||
|
||||
// Type inference
|
||||
var type='text';
|
||||
if(n>0){
|
||||
var intHits=nonEmpty.filter(function(v){return /^-?\d+$/.test(v)}).length;
|
||||
var numHits=nonEmpty.filter(function(v){return /^-?\d+(\.\d+)?$/.test(v)}).length;
|
||||
if(intHits===n) type='int';
|
||||
else if(numHits===n) type='decimal';
|
||||
else if(nonEmpty.every(function(v){return /^\d{4}-\d{2}-\d{2}/.test(v)})) type='date';
|
||||
}
|
||||
|
||||
return { name: name, type: type, pii: pii, sample: nonEmpty.slice(0,3).join(' · ') || '(empty)' };
|
||||
}
|
||||
|
||||
function readFile(file){
|
||||
rawBlob=file;
|
||||
var reader=new FileReader();
|
||||
reader.onload=function(e){
|
||||
var text=String(e.target.result||'');
|
||||
var lines=parseCSV(text);
|
||||
if(lines.length<2){
|
||||
alert('CSV is empty or has no data rows.');
|
||||
return;
|
||||
}
|
||||
var header=lines[0].map(function(h){return String(h||'').trim()});
|
||||
var rows=lines.slice(1).filter(function(r){return r.length>0 && r.some(function(c){return c!=null&&c!==''})});
|
||||
|
||||
// Build columns with inference
|
||||
var columns=header.map(function(h,idx){
|
||||
var colValues=rows.map(function(r){return r[idx]});
|
||||
return inferColumn(h, colValues.slice(0,50)); // sample first 50 for perf
|
||||
});
|
||||
|
||||
parsedFile={name: file.name, rowCount: rows.length, header: header, rows: rows, columns: columns};
|
||||
renderPreview();
|
||||
setStep(2);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function renderPreview(){
|
||||
var colList=document.getElementById('col-list'); colList.textContent='';
|
||||
parsedFile.columns.forEach(function(c){
|
||||
var row=el('div','col');
|
||||
row.appendChild(el('span','name',c.name));
|
||||
row.appendChild(el('span','type',c.type));
|
||||
if(c.pii){
|
||||
var chip=el('span','pii '+c.pii,c.pii.toUpperCase());
|
||||
row.appendChild(chip);
|
||||
}
|
||||
row.appendChild(el('span','sample',c.sample));
|
||||
colList.appendChild(row);
|
||||
});
|
||||
|
||||
var table=document.getElementById('preview-table');table.textContent='';
|
||||
var thead=document.createElement('thead');
|
||||
var htr=document.createElement('tr');
|
||||
parsedFile.header.forEach(function(h){ htr.appendChild(el('th',null,h)) });
|
||||
thead.appendChild(htr);table.appendChild(thead);
|
||||
var tbody=document.createElement('tbody');
|
||||
parsedFile.rows.slice(0,5).forEach(function(r){
|
||||
var tr=document.createElement('tr');
|
||||
parsedFile.header.forEach(function(_,idx){
|
||||
tr.appendChild(el('td',null,r[idx]||''));
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
|
||||
document.getElementById('row-count').textContent=parsedFile.rowCount.toLocaleString()+' data rows · '+parsedFile.header.length+' columns · source: '+parsedFile.name;
|
||||
|
||||
// Auto-advance button: a Next button at the bottom of step-2
|
||||
var step2=document.getElementById('step-2');
|
||||
var existing=document.getElementById('step-2-next');
|
||||
if(!existing){
|
||||
var wrap=el('div','actions');wrap.id='step-2-next';
|
||||
var next=el('button','btn','Continue to commit');next.type='button';
|
||||
next.onclick=function(){ setStep(3); };
|
||||
var reset=el('button','btn ghost','Pick a different file');reset.type='button';
|
||||
reset.onclick=function(){ parsedFile=null; rawBlob=null; document.getElementById('file').value=''; setStep(1); };
|
||||
wrap.appendChild(next);wrap.appendChild(reset);
|
||||
step2.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function commit(){
|
||||
var dsName=(document.getElementById('dataset-name').value||'').trim();
|
||||
if(!dsName){ alert('Dataset name required.'); return; }
|
||||
if(!/^[a-z][a-z0-9_]*$/.test(dsName)){ alert('Dataset name: lowercase letters, numbers, underscores only; start with a letter.'); return; }
|
||||
if(!rawBlob){ alert('No file loaded.'); return; }
|
||||
|
||||
var btn=document.getElementById('commit-btn');
|
||||
btn.disabled=true;btn.textContent='';
|
||||
var spin=el('span','spin');btn.appendChild(spin);
|
||||
btn.appendChild(document.createTextNode('Ingesting…'));
|
||||
|
||||
var form=new FormData();
|
||||
form.append('file', rawBlob, parsedFile.name);
|
||||
|
||||
fetch(A+'/onboard/ingest?name='+encodeURIComponent(dsName), {
|
||||
method: 'POST', body: form,
|
||||
}).then(function(r){
|
||||
return r.text().then(function(body){ return {status:r.status, body:body}; });
|
||||
}).then(function(res){
|
||||
btn.disabled=false;btn.textContent='Commit dataset';
|
||||
var out=document.getElementById('commit-result');out.textContent='';
|
||||
if(res.status>=200 && res.status<300){
|
||||
var ok=el('div','result ok');
|
||||
ok.appendChild(el('div','big','Dataset "'+dsName+'" is live.'));
|
||||
ok.appendChild(el('div','detail',parsedFile.rowCount.toLocaleString()+' rows · '+parsedFile.header.length+' columns · registered in catalog'));
|
||||
out.appendChild(ok);
|
||||
document.getElementById('ds-name-1').textContent=dsName;
|
||||
document.getElementById('ds-name-2').textContent=dsName;
|
||||
setStep(4);
|
||||
} else {
|
||||
var err=el('div','result err');
|
||||
err.appendChild(el('div','big','Ingest failed ('+res.status+')'));
|
||||
err.appendChild(el('div','detail',res.body.slice(0,400)));
|
||||
out.appendChild(err);
|
||||
}
|
||||
}).catch(function(e){
|
||||
btn.disabled=false;btn.textContent='Commit dataset';
|
||||
var out=document.getElementById('commit-result');out.textContent='';
|
||||
var err=el('div','result err');
|
||||
err.appendChild(el('div','big','Network error'));
|
||||
err.appendChild(el('div','detail',e.message||String(e)));
|
||||
out.appendChild(err);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded',function(){
|
||||
var fileInput=document.getElementById('file');
|
||||
var drop=document.getElementById('drop');
|
||||
fileInput.addEventListener('change',function(e){
|
||||
if(e.target.files.length) readFile(e.target.files[0]);
|
||||
});
|
||||
drop.addEventListener('dragover',function(e){e.preventDefault();drop.classList.add('active')});
|
||||
drop.addEventListener('dragleave',function(){drop.classList.remove('active')});
|
||||
drop.addEventListener('drop',function(e){
|
||||
e.preventDefault();drop.classList.remove('active');
|
||||
if(e.dataTransfer.files.length) readFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
|
||||
document.getElementById('use-sample').addEventListener('click',function(){
|
||||
// Fetch the sample CSV and feed it through the same pipeline
|
||||
fetch(A+'/samples/staffing_roster_sample.csv').then(function(r){return r.blob()}).then(function(blob){
|
||||
var file=new File([blob],'staffing_roster_sample.csv',{type:'text/csv'});
|
||||
readFile(file);
|
||||
}).catch(function(e){ alert('Sample unavailable: '+e.message); });
|
||||
});
|
||||
|
||||
document.getElementById('commit-btn').addEventListener('click',commit);
|
||||
document.getElementById('back-btn').addEventListener('click',function(){ setStep(2); });
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,457 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Lakehouse — Architecture & Reproduction</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
a{color:#58a6ff;text-decoration:none}
|
||||
a:hover{color:#79c0ff}
|
||||
|
||||
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
|
||||
.bar h1{font-size:14px;font-weight:600;color:#e6edf3;letter-spacing:-0.2px}
|
||||
.bar nav{display:flex;gap:2px}
|
||||
.bar nav a{font-size:12px;color:#545d68;padding:6px 14px;border-radius:6px;transition:all 0.15s}
|
||||
.bar nav a:hover{color:#e6edf3;background:#161b22}
|
||||
.bar nav a.active{color:#e6edf3;background:#1c2333}
|
||||
.bar .rt{font-size:11px;color:#545d68}
|
||||
|
||||
.wrap{max-width:1040px;margin:0 auto;padding:28px 20px 60px}
|
||||
|
||||
.chapter{margin-bottom:48px}
|
||||
.chapter .num{color:#545d68;font-size:11px;font-weight:600;letter-spacing:1.6px;text-transform:uppercase;margin-bottom:6px}
|
||||
.chapter h2{color:#e6edf3;font-size:24px;font-weight:700;letter-spacing:-0.4px;margin-bottom:8px;line-height:1.2}
|
||||
.chapter .lede{color:#8b949e;font-size:14px;margin-bottom:18px;max-width:680px;line-height:1.6}
|
||||
.chapter h3{color:#e6edf3;font-size:16px;font-weight:600;margin:18px 0 6px}
|
||||
|
||||
.card{background:#0d1117;border:1px solid #171d27;border-radius:12px;padding:20px;margin-bottom:12px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
|
||||
|
||||
.stat-lg{padding:18px 20px}
|
||||
.stat-lg .n{font-size:26px;font-weight:800;color:#e6edf3;letter-spacing:-1px;line-height:1}
|
||||
.stat-lg .l{font-size:10px;color:#545d68;text-transform:uppercase;letter-spacing:1.2px;margin-top:8px;font-weight:600}
|
||||
.stat-lg .sub{font-size:12px;color:#8b949e;margin-top:4px}
|
||||
|
||||
.accent-l{border-left:3px solid #2ea043}
|
||||
.accent-b{border-left:3px solid #1f6feb}
|
||||
.accent-a{border-left:3px solid #bc8cff}
|
||||
.accent-w{border-left:3px solid #d29922}
|
||||
.accent-r{border-left:3px solid #f85149}
|
||||
|
||||
.row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 14px;background:#0d1117;border:1px solid #171d27;border-radius:8px;margin-bottom:6px;font-size:13px}
|
||||
.row:hover{border-color:#21262d}
|
||||
.row .title{color:#e6edf3;font-weight:500}
|
||||
.row .meta{color:#8b949e;font-size:11px;margin-top:2px;font-family:ui-monospace,Menlo,monospace}
|
||||
.row .val{color:#58a6ff;font-weight:600;white-space:nowrap;font-family:ui-monospace,Menlo,monospace}
|
||||
.row.pass{border-left:3px solid #2ea043}
|
||||
.row.fail{border-left:3px solid #f85149}
|
||||
|
||||
table.plain{width:100%;border-collapse:collapse;font-size:12px;margin-top:8px}
|
||||
table.plain th{text-align:left;padding:8px 12px;color:#545d68;font-weight:600;text-transform:uppercase;font-size:10px;letter-spacing:0.8px;border-bottom:1px solid #171d27}
|
||||
table.plain td{padding:8px 12px;border-bottom:1px solid #171d27;color:#c9d1d9;font-family:ui-monospace,Menlo,monospace}
|
||||
table.plain tr:hover td{background:#0d1117}
|
||||
|
||||
code{background:#161b22;color:#e6edf3;padding:2px 6px;border-radius:4px;font-family:ui-monospace,Menlo,monospace;font-size:12px}
|
||||
pre{background:#161b22;border:1px solid #171d27;border-radius:8px;padding:14px 16px;overflow-x:auto;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:#c9d1d9;line-height:1.5;margin:8px 0}
|
||||
|
||||
.narr{color:#8b949e;font-size:13px;line-height:1.7;margin:10px 0;padding:10px 14px;border-left:2px solid #21262d}
|
||||
.narr strong{color:#c9d1d9;font-weight:600}
|
||||
|
||||
.ref{color:#545d68;font-size:11px;margin-top:6px;font-family:ui-monospace,Menlo,monospace}
|
||||
.ref strong{color:#79c0ff;font-weight:600}
|
||||
|
||||
.math{background:#0d1117;border:1px solid #171d27;border-radius:8px;padding:14px 16px;font-family:ui-monospace,Menlo,monospace;font-size:13px;color:#e6edf3;margin:8px 0}
|
||||
.math .c{color:#8b949e}
|
||||
|
||||
.footer{border-top:1px solid #171d27;padding:20px;text-align:center;color:#3d444d;font-size:11px}
|
||||
|
||||
.loading{color:#484f58;font-style:italic;padding:20px 0;text-align:center}
|
||||
.err{color:#f85149;font-size:12px;padding:10px}
|
||||
|
||||
@media(max-width:720px){
|
||||
.wrap{padding:20px 12px 40px}
|
||||
.chapter h2{font-size:20px}
|
||||
.bar nav{display:none}
|
||||
}
|
||||
</style></head>
|
||||
<body>
|
||||
|
||||
<div class="bar">
|
||||
<h1>Lakehouse — Architecture & Reproduction</h1>
|
||||
<nav>
|
||||
<a href=".">Dashboard</a>
|
||||
<a href="console">Walkthrough</a>
|
||||
<a href="proof" class="active">Architecture</a>
|
||||
<a href="spec">Spec</a>
|
||||
<a href="onboard">Onboard</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
<a href="workspaces">Workspaces</a>
|
||||
</nav>
|
||||
<div class="rt" id="hdr-time">Running live tests…</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 1</div>
|
||||
<h2>Receipts, not promises</h2>
|
||||
<div class="lede">Every test below ran live against the real gateway when you loaded this page. Sub-100ms SQL on multi-million-row Parquet, hybrid search with playbook boost applied. No fixtures. If a test fails, you'll see ✗.</div>
|
||||
<div id="ch1-tests"><div class="loading">Running tests…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 2</div>
|
||||
<h2>Architecture — 13 crates, one object store, one local AI runtime</h2>
|
||||
<div class="lede">Request flows top to bottom. Every node is independently swappable. Every line is a real HTTP or gRPC hop that you can trace with <code>tcpdump</code>.</div>
|
||||
<div class="card accent-b">
|
||||
<pre> HTTP :3100 + gRPC :3101
|
||||
│
|
||||
┌───────▼───────┐
|
||||
│ gateway │ Rust · Axum · routing, CORS, auth, tools
|
||||
└───────┬───────┘
|
||||
┌────────────┬───────────┼───────────┬────────────┐
|
||||
│ │ │ │ │
|
||||
┌────▼───┐ ┌────▼───┐ ┌────▼───┐ ┌────▼───┐ ┌────▼───┐
|
||||
│catalog │ │ query │ │ vector │ │ ingest │ │aibridge│
|
||||
│ d │ │ d │ │ d │ │ d │ │ │
|
||||
└────┬───┘ └────┬───┘ └────┬───┘ └────┬───┘ └────┬───┘
|
||||
│ │ │ │ │
|
||||
└────────────┴───────────┼───────────┴────────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ object storage │ Parquet files (local / S3)
|
||||
└─────────────────┘
|
||||
▲
|
||||
│
|
||||
┌───────┴────────┐
|
||||
│ Python sidecar │ FastAPI → Ollama
|
||||
│ (aibridge) │ local models only
|
||||
└────────────────┘</pre>
|
||||
</div>
|
||||
<h3>Per-crate responsibility</h3>
|
||||
<table class="plain">
|
||||
<thead><tr><th>Crate</th><th>Role</th><th>Path</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>shared</td><td>Types, errors, Arrow helpers, PII detection, secrets provider</td><td>crates/shared/</td></tr>
|
||||
<tr><td>storaged</td><td>object_store I/O, BucketRegistry (multi-bucket), AppendLog, ErrorJournal</td><td>crates/storaged/</td></tr>
|
||||
<tr><td>catalogd</td><td>Metadata authority — manifests, views, tombstones, profiles, schema fingerprints</td><td>crates/catalogd/</td></tr>
|
||||
<tr><td>queryd</td><td>DataFusion SQL engine, MemTable cache, delta merge-on-read, compaction</td><td>crates/queryd/</td></tr>
|
||||
<tr><td>ingestd</td><td>CSV/JSON/PDF(+OCR)/Postgres/MySQL ingest, cron schedules, auto-PII</td><td>crates/ingestd/</td></tr>
|
||||
<tr><td>vectord</td><td>Embeddings as Parquet, HNSW, trial system, autotune agent, playbook_memory</td><td>crates/vectord/</td></tr>
|
||||
<tr><td>vectord-lance</td><td>Firewall crate — Lance 4.0 + Arrow 57 isolated from main Arrow 55</td><td>crates/vectord-lance/</td></tr>
|
||||
<tr><td>journald</td><td>Append-only mutation event log for time-travel & audit</td><td>crates/journald/</td></tr>
|
||||
<tr><td>aibridge</td><td>Rust↔Python sidecar, Ollama HTTP client, VRAM introspection</td><td>crates/aibridge/</td></tr>
|
||||
<tr><td>gateway</td><td>Axum HTTP :3100 + gRPC :3101, middleware, tools registry</td><td>crates/gateway/</td></tr>
|
||||
<tr><td>ui</td><td>Dioxus WASM internal developer UI</td><td>crates/ui/</td></tr>
|
||||
<tr><td>mcp-server</td><td>Bun TypeScript recruiter-facing app (this server)</td><td>mcp-server/</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="ref"><strong>Source:</strong> git.agentview.dev/profit/lakehouse · <strong>ADRs:</strong> docs/DECISIONS.md (currently 20 records)</div>
|
||||
</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 3</div>
|
||||
<h2>Dual-agent recursive consensus loop</h2>
|
||||
<div class="lede">The system we use to execute staffing fills is a dual-agent recursive protocol. Two agents with distinct roles iterate against a shared log until one of three terminal states is reached. It is deterministic in structure, stochastic in content, and verifiable through the per-run log artifact.</div>
|
||||
<h3>Agents and protocol</h3>
|
||||
<div class="card accent-a">
|
||||
<pre> task in
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ EXECUTOR (mistral:latest) │
|
||||
│ ──────────────────────────────────────────────────────── │
|
||||
│ input: task spec + shared log + seen-candidates ledger │
|
||||
│ output: one JSON action per turn │
|
||||
│ · {kind:"plan",steps:[…]} │
|
||||
│ · {kind:"tool_call",tool,args,rationale} │
|
||||
│ · {kind:"propose_done",fills:[N of N]} │
|
||||
└───────────┬───────────────────────────────┬───────────────┘
|
||||
│ tool_call │ propose_done
|
||||
▼ │
|
||||
┌──────────────────────────┐ │
|
||||
│ TOOL DISPATCH │ │
|
||||
│ hybrid_search / sql │ │
|
||||
│ (against live gateway) │ │
|
||||
└──────────┬───────────────┘ │
|
||||
│ result (trimmed, exclusions) │
|
||||
▼ ▼
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ REVIEWER (qwen2.5:latest) │
|
||||
│ ──────────────────────────────────────────────────────── │
|
||||
│ input: task spec + shared log (including tool result) │
|
||||
│ output: {kind:"critique",verdict:"continue|drift| │
|
||||
│ approve_done",notes} │
|
||||
└───────────┬───────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────┴─────┐
|
||||
▼ ▼ ▼
|
||||
continue drift approve_done + propose_done ⟹ SEAL
|
||||
(next turn) (cap ≈ 3 →
|
||||
hard abort)
|
||||
</pre>
|
||||
</div>
|
||||
<div class="ref"><strong>Code:</strong> tests/multi-agent/agent.ts (protocol + prompts) · tests/multi-agent/orchestrator.ts (run loop) · tests/multi-agent/scenario.ts (5-event warehouse week)</div>
|
||||
|
||||
<h3>Why "dual" — role specialization</h3>
|
||||
<div class="narr">
|
||||
<strong>The executor is an optimist.</strong> Its job is to produce progress: pull candidates, verify SQL, propose consensus. It's instructed to be decisive.
|
||||
<br><br>
|
||||
<strong>The reviewer is a pessimist.</strong> Its job is to catch drift: proposals that don't match the task's geography, fill count, or role. It's authorized to stop the loop.
|
||||
<br><br>
|
||||
This adversarial separation is cheaper and more deterministic than asking a single model to self-critique. The reviewer has a hard rule: on the turn after a <code>propose_done</code>, it MUST emit either <code>approve_done</code> or <code>drift</code> — it cannot stall with <code>continue</code>.
|
||||
</div>
|
||||
|
||||
<h3>Why "parallel" — orchestrator can fan out</h3>
|
||||
<div class="narr">
|
||||
<strong>Independent pairs run concurrently.</strong> <code>tests/multi-agent/run_e2e_rated.ts</code> runs two task-specific agent pairs via <code>Promise.all</code>. Ollama serializes inference at the model level, so "parallel" is concurrent orchestration — but the substrate (gateway, queryd, vectord) handles concurrent requests cleanly. Verified in the scenario harness: two contracts sealing simultaneously.
|
||||
</div>
|
||||
|
||||
<h3>Why "recursive" — each seal feeds the next</h3>
|
||||
<div class="narr">
|
||||
<strong>Consensus does not end at the sealed playbook.</strong> Every sealed playbook is persisted to <code>playbook_memory</code> via <code>POST /vectors/playbook_memory/seed</code>. The next hybrid search for a semantically similar operation consults that memory via <code>compute_boost_for(query_embedding, top_k, base_weight)</code> and re-ranks the candidate pool. The system builds on itself turn over turn, playbook over playbook.
|
||||
</div>
|
||||
|
||||
<h3>Termination guarantees</h3>
|
||||
<div class="math">
|
||||
<span class="c">// three paths out, every run has one of these:</span><br>
|
||||
sealed = executor.propose_done ∧ reviewer.approve_done ∧ fills.count == target<br>
|
||||
abort = consecutive_tool_errors ≥ MAX_TOOL_ERRORS (3) <span class="c">// executor can't form a valid call</span><br>
|
||||
abort = consecutive_drifts ≥ MAX_CONSECUTIVE_DRIFTS (3) <span class="c">// reviewer keeps flagging</span><br>
|
||||
abort = turn > MAX_TURNS (12) <span class="c">// no consensus reached in window</span>
|
||||
</div>
|
||||
<div class="narr">Every abort dumps the full log to <code>tests/multi-agent/playbooks/<id>-FAILED.json</code> for forensic review. No consensus is ever implicit.</div>
|
||||
</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 4</div>
|
||||
<h2>Playbook memory — the compounding feedback loop</h2>
|
||||
<div class="lede">A CRM stores events. This system turns events into re-ranking signal. Every sealed playbook endorses specific (worker, city, state) tuples. Every failure penalizes them. Every similar future query inherits the signal through cosine similarity.</div>
|
||||
|
||||
<h3>Seed shape</h3>
|
||||
<div class="math">
|
||||
PlaybookEntry {<br>
|
||||
playbook_id, <span class="c">// pb-seed-<sha8></span><br>
|
||||
operation, <span class="c">// "fill: Welder x2 in Toledo, OH"</span><br>
|
||||
approach, context, <span class="c">// short canonical — long strings dilute embedding</span><br>
|
||||
timestamp, <span class="c">// RFC3339</span><br>
|
||||
endorsed_names[], <span class="c">// validated against workers_500k for city+state</span><br>
|
||||
city, state, <span class="c">// parsed from operation</span><br>
|
||||
embedding <span class="c">// 768-d nomic-embed-text of text shape</span><br>
|
||||
}
|
||||
</div>
|
||||
<div class="ref"><strong>Code:</strong> crates/vectord/src/playbook_memory.rs (PlaybookEntry, FailureRecord, PlaybookMemoryState)</div>
|
||||
|
||||
<h3>Boost math (positive + decay + negative)</h3>
|
||||
<div class="math">
|
||||
<span class="c">// For each playbook pb among top-K most cosine-similar:</span><br>
|
||||
<span class="c">// given query embedding qv, constant base_weight, n_workers = |pb.endorsed_names|</span><br>
|
||||
<br>
|
||||
similarity = cosine(qv, pb.embedding) <span class="c">// skip if ≤ 0.05</span><br>
|
||||
age_days = (now - pb.timestamp) / 86_400 seconds<br>
|
||||
decay = e<sup>-age_days / 30</sup> <span class="c">// half-life = 30 days</span><br>
|
||||
<br>
|
||||
<span class="c">// For each endorsed worker in pb:</span><br>
|
||||
key = (pb.city, pb.state, name)<br>
|
||||
fail_count = failures[key] <span class="c">// # times this worker was marked no-show for same geo</span><br>
|
||||
penalty = 0.5<sup>min(fail_count, 20)</sup><br>
|
||||
<br>
|
||||
per_worker = similarity × base_weight × decay × penalty / n_workers<br>
|
||||
boost[key] = min(boost[key] + per_worker, MAX_BOOST_PER_WORKER)<br>
|
||||
<br>
|
||||
<span class="c">// MAX_BOOST_PER_WORKER = 0.25 — cap stops one popular worker from always winning</span>
|
||||
</div>
|
||||
<div class="ref"><strong>Code:</strong> crates/vectord/src/playbook_memory.rs::compute_boost_for · constants: MAX_BOOST_PER_WORKER, DEFAULT_TOP_K_PLAYBOOKS, BOOST_HALF_LIFE_DAYS</div>
|
||||
|
||||
<h3>Application at query time</h3>
|
||||
<div class="math">
|
||||
<span class="c">// In /vectors/hybrid handler (crates/vectord/src/service.rs):</span><br>
|
||||
1. SQL filter narrows workers_500k to geo/role/availability<br>
|
||||
2. Vector index returns top_k × 5 candidates by cosine to question<br>
|
||||
3. compute_boost_for(qv, k=200) returns boost map<br>
|
||||
4. For each candidate: parse (name, city, state) from chunk, look up boost, add to score<br>
|
||||
5. Re-sort sources by boosted score<br>
|
||||
6. Truncate to requested top_k, return with playbook_boost and playbook_citations
|
||||
</div>
|
||||
<div class="narr">
|
||||
<strong>Why k=200.</strong> Direct measurement showed cosine similarity clusters in the 0.55-0.67 band across all playbooks regardless of geo (nomic-embed-text has narrow discrimination on this kind of structured operation text). A k of 25 silently missed geo-matched playbooks. k=200 is the measured floor for reliably catching compounding. Brute-force over 200 × 768-d is sub-ms even on this hardware.
|
||||
</div>
|
||||
<div class="ref"><strong>Evidence:</strong> Chicago Electrician compounding test 2026-04-20 — Carmen Green, Anna Patel, Fatima Wilson went from rank >5 / boost 0 / 0 citations (run 0, no seed) to rank 1/2/3 / boost +0.250 (capped) / 3 citations each (run 3, after 3 identical seeds). Each seed increments citations; total boost caps at 0.25/worker.</div>
|
||||
|
||||
<h3>Write-through to SQL</h3>
|
||||
<div class="narr">
|
||||
<strong>successful_playbooks_live</strong> is a DataFusion-queryable Parquet surface maintained by <code>POST /vectors/playbook_memory/persist_sql</code>. Every <code>/log</code> from the recruiter UI triggers seed → persist_sql. The in-memory store and the SQL surface stay synchronized (full snapshot on each persist, safe because memory is source of truth).
|
||||
</div>
|
||||
<div class="ref"><strong>Code:</strong> crates/vectord/src/playbook_memory.rs::persist_to_sql · catalog-registered under "successful_playbooks_live"</div>
|
||||
|
||||
<h3>Pattern discovery (Path 2 — meta-index)</h3>
|
||||
<div class="narr">
|
||||
<strong>Beyond "who was endorsed."</strong> <code>POST /vectors/playbook_memory/patterns</code> takes a query, finds top-K similar past playbooks, pulls each endorsed worker's full workers_500k profile, and aggregates shared traits: recurring certifications, skill frequencies, modal archetype, reliability distribution. Returns a <code>discovered_pattern</code> string showing operator-actionable signal the user didn't explicitly query for.
|
||||
</div>
|
||||
<div class="ref"><strong>Code:</strong> crates/vectord/src/playbook_memory.rs::discover_patterns · <strong>Surfaces:</strong> /vectors/playbook_memory/patterns endpoint, /intelligence/chat response, /intelligence/permit_contracts cards</div>
|
||||
</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 5</div>
|
||||
<h2>Key architectural choices — what was picked and why</h2>
|
||||
<div class="lede">Each choice is documented in <code>docs/DECISIONS.md</code> (Architecture Decision Records). If you dispute any of these, the ADR names the alternatives we rejected and the measurement that drove the call.</div>
|
||||
<div class="card">
|
||||
<div class="row accent-b">
|
||||
<div style="flex:1"><div class="title">ADR-001 · Object storage as source of truth</div><div class="meta">No traditional database. All data is Parquet on S3-compatible object storage. Eliminates DB operational overhead; every engine can read Parquet.</div></div>
|
||||
</div>
|
||||
<div class="row accent-b">
|
||||
<div style="flex:1"><div class="title">ADR-008 · Embeddings stored as Parquet, not a vector DB</div><div class="meta">Keeps all data in one portable format. No Pinecone/Weaviate/Qdrant lock-in. Trade-off: brute-force search up to ~100K; HNSW beyond.</div></div>
|
||||
</div>
|
||||
<div class="row accent-l">
|
||||
<div style="flex:1"><div class="title">ADR-012 · Append-only event journal — never destroy evidence</div><div class="meta">Every mutation is appended. Compliance, audit, AI-decision forensics. Impossible to retrofit; easy to add now.</div></div>
|
||||
</div>
|
||||
<div class="row accent-a">
|
||||
<div style="flex:1"><div class="title">ADR-015 · Tool registry before raw SQL for agents</div><div class="meta">Named, governed, audited actions for agents. Permission checks, rate limits, parameter validation. MCP-compatible.</div></div>
|
||||
</div>
|
||||
<div class="row accent-w">
|
||||
<div style="flex:1"><div class="title">ADR-019 · Hybrid Parquet+HNSW ⊕ Lance vector backend</div><div class="meta">Parquet+HNSW primary (2.55× faster search at 100K). Lance secondary for index-build speed (14× faster), random fetch (112× faster), append (structural). Per-profile <code>vector_backend: Parquet | Lance</code>.</div></div>
|
||||
</div>
|
||||
<div class="row accent-r">
|
||||
<div style="flex:1"><div class="title">ADR-020 · Idempotent register() with schema-fingerprint gate</div><div class="meta">Same (name, fingerprint) reuses manifest. Different fingerprint = 409 Conflict. Prevents silent duplicate manifests. Cleanup run collapsed 374 → 31 datasets.</div></div>
|
||||
</div>
|
||||
<div class="row accent-l">
|
||||
<div style="flex:1"><div class="title">Phase 19 design note · Statistical + semantic, not neural</div><div class="meta">Meta-index is cosine similarity + endorsement aggregation. No model training. Rebuildable from <code>successful_playbooks</code> alone. Neural re-ranker deferred to Phase 20+ only if statistical floor plateaus.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 6</div>
|
||||
<h2>Measured at scale, on this machine</h2>
|
||||
<div class="lede">Hardware: i9 + 128GB RAM + Nvidia A4000 16GB VRAM. Numbers below are from <em>this</em> running instance. Refresh the page and they'll recompute.</div>
|
||||
<div class="grid" id="ch6-scale"><div class="loading">Loading scale data…</div></div>
|
||||
<div id="ch6-recall" style="margin-top:10px"></div>
|
||||
</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 7</div>
|
||||
<h2>Verify or dispute — reproduce it yourself</h2>
|
||||
<div class="lede">Every claim below is a curl away from falsification.</div>
|
||||
<div class="card">
|
||||
<div class="narr"><strong>Health.</strong> Should return <code>lakehouse ok</code>.</div>
|
||||
<pre>curl http://localhost:3100/health</pre>
|
||||
<div class="narr"><strong>Any SQL on multi-million-row Parquet.</strong> Sub-100ms typical.</div>
|
||||
<pre>curl -s -X POST http://localhost:3100/query/sql \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"sql":"SELECT role, COUNT(*) FROM workers_500k WHERE state=\"IL\" GROUP BY role LIMIT 5"}'</pre>
|
||||
<div class="narr"><strong>Hybrid search with playbook boost.</strong> The whole Phase 19 feedback loop in one request.</div>
|
||||
<pre>curl -s -X POST http://localhost:3100/vectors/hybrid \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"index_name":"workers_500k_v1",
|
||||
"sql_filter":"role = '\''Forklift Operator'\'' AND city = '\''Chicago'\'' AND CAST(availability AS DOUBLE) > 0.5",
|
||||
"question":"reliable forklift operator",
|
||||
"top_k":5,"use_playbook_memory":true,"playbook_memory_k":200}'</pre>
|
||||
<div class="narr"><strong>Playbook memory stats.</strong> Count + endorsed names + sample.</div>
|
||||
<pre>curl http://localhost:3100/vectors/playbook_memory/stats</pre>
|
||||
<div class="narr"><strong>Pattern discovery.</strong> What do past similar fills have in common?</div>
|
||||
<pre>curl -s -X POST http://localhost:3100/vectors/playbook_memory/patterns \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"query":"Forklift Operator in Chicago, IL","top_k_playbooks":25,"min_trait_frequency":0.3}'</pre>
|
||||
<div class="narr"><strong>Run the dual-agent scenario yourself.</strong> All 5 events, real fills, real artifacts.</div>
|
||||
<pre>cd /home/profit/lakehouse
|
||||
bun run tests/multi-agent/scenario.ts
|
||||
# Output: tests/multi-agent/playbooks/scenario-<timestamp>/report.md</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chapter">
|
||||
<div class="num">Chapter 8</div>
|
||||
<h2>What we are <em>not</em> claiming</h2>
|
||||
<div class="lede">Every impressive-sounding number comes with a footnote. Here are the honest limits.</div>
|
||||
<div class="card">
|
||||
<div class="row accent-a"><div style="flex:1"><div class="title">workers_500k is synthetic.</div><div class="meta">Real client ATS export replaces this table. Schema is deliberately identical to a production ATS.</div></div></div>
|
||||
<div class="row accent-a"><div style="flex:1"><div class="title">candidates table has 1,000 rows.</div><div class="meta">Intentionally small for demo. call_log references higher candidate_ids that don't cross-reference — this is a dataset alignment issue, not a pipeline issue.</div></div></div>
|
||||
<div class="row accent-b"><div style="flex:1"><div class="title">Chicago permit data is real.</div><div class="meta">Pulled live from data.cityofchicago.org/resource/ydr8-5enu.json (Socrata API). Not synthetic. Not cached.</div></div></div>
|
||||
<div class="row accent-l"><div style="flex:1"><div class="title">Playbook memory is seeded from demo runs.</div><div class="meta">The pipeline that seeds it is identical to what a live recruiter would trigger via /log. Same code path.</div></div></div>
|
||||
<div class="row accent-w"><div style="flex:1"><div class="title">Local 7B models (mistral, qwen2.5) are imperfect.</div><div class="meta">They occasionally malform tool calls or drop fields. Multi-agent scenarios seal roughly 40-80% in one run. Larger models or constrained decoding would improve this. Not a substrate problem.</div></div></div>
|
||||
<div class="row accent-r"><div style="flex:1"><div class="title">No rate/margin awareness yet.</div><div class="meta">Worker pay expectations vs contract bill rates are not modeled. Flagged as a Phase 20 item; no architectural blocker.</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer">Lakehouse · Architecture page regenerates tests on every load · <a href="console">walkthrough</a> · <a href=".">dashboard</a></div>
|
||||
|
||||
<script>
|
||||
var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
|
||||
var A=location.origin+P;
|
||||
|
||||
function el(tag, cls, text){
|
||||
var e=document.createElement(tag);
|
||||
if(cls) e.className=cls;
|
||||
if(text!==undefined && text!==null) e.textContent=String(text);
|
||||
return e;
|
||||
}
|
||||
|
||||
function apiPost(path, body){
|
||||
return fetch(A+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})}).then(function(r){return r.json()});
|
||||
}
|
||||
|
||||
window.addEventListener('load',function(){
|
||||
loadLiveSections();
|
||||
});
|
||||
|
||||
function loadLiveSections(){
|
||||
apiPost('/proof.json',{}).then(function(r){
|
||||
var host1=document.getElementById('ch1-tests');host1.textContent='';
|
||||
var tests=r.tests||[];
|
||||
tests.forEach(function(t){
|
||||
var row=el('div','row '+(t.pass?'pass':'fail'));
|
||||
var left=document.createElement('div');left.style.flex='1';left.style.minWidth='0';
|
||||
var title=el('div','title',(t.pass?'✓ ':'✗ ')+(t.name||'(unnamed)'));
|
||||
var resultStr='';
|
||||
if(typeof t.result==='object' && t.result) resultStr=JSON.stringify(t.result);
|
||||
else if(t.result!==undefined && t.result!==null) resultStr=String(t.result);
|
||||
var meta=el('div','meta',resultStr);
|
||||
left.appendChild(title);left.appendChild(meta);
|
||||
row.appendChild(left);
|
||||
row.appendChild(el('div','val',(t.ms||0)+' ms'));
|
||||
host1.appendChild(row);
|
||||
});
|
||||
|
||||
var host6=document.getElementById('ch6-scale');host6.textContent='';
|
||||
var scale=r.scale||{};
|
||||
addStat(host6,(scale.total_rows||0).toLocaleString(),'Rows under management','across '+(scale.datasets||0)+' datasets','accent-b');
|
||||
addStat(host6,(scale.total_chunks||0).toLocaleString(),'Vector chunks indexed','across '+(scale.indexes||0)+' HNSW + Lance indexes','accent-a');
|
||||
var gpu=r.gpu||{};
|
||||
var vramStr=(gpu.vram_used_mib!==undefined?gpu.vram_used_mib+' / '+gpu.vram_total_mib+' MiB':'—');
|
||||
addStat(host6,vramStr,'GPU VRAM',(gpu.name||'A4000'),'accent-w');
|
||||
if(r.recall){
|
||||
var reco=r.recall||{};
|
||||
var hnswStr=(reco.hnsw!==undefined?(reco.hnsw*100).toFixed(0)+'%':'—');
|
||||
addStat(host6,hnswStr,'HNSW recall','measured · '+(reco.note||''),'accent-l');
|
||||
}
|
||||
|
||||
var host6r=document.getElementById('ch6-recall');host6r.textContent='';
|
||||
if(r.lance_10m){
|
||||
var narr=el('div','narr');
|
||||
narr.appendChild(el('strong',null,'Scale headroom: '));
|
||||
narr.appendChild(document.createTextNode(
|
||||
'Lance backend tested at '+r.lance_10m.vectors.toLocaleString()+' vectors, '
|
||||
+r.lance_10m.disk_gb+' GB on disk, '+r.lance_10m.search_p50_ms+'ms p50. '
|
||||
+(r.lance_10m.note||'')
|
||||
));
|
||||
host6r.appendChild(narr);
|
||||
}
|
||||
|
||||
document.getElementById('hdr-time').textContent='Generated · '+(r.generated?new Date(r.generated).toLocaleTimeString():'live');
|
||||
}).catch(function(e){
|
||||
var host1=document.getElementById('ch1-tests');host1.textContent='';
|
||||
host1.appendChild(el('div','err','Live tests failed: '+(e.message||e)+'. Server may be offline.'));
|
||||
});
|
||||
}
|
||||
|
||||
function addStat(host,n,l,sub,cls){
|
||||
var d=el('div','card stat-lg '+(cls||''));
|
||||
d.appendChild(el('div','n',n));
|
||||
d.appendChild(el('div','l',l));
|
||||
d.appendChild(el('div','sub',sub||''));
|
||||
host.appendChild(d);
|
||||
}
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,26 @@
|
||||
worker_id,name,role,city,state,email,phone,skills,certifications,availability,reliability,archetype
|
||||
W-1001,Sarah Johnson,Forklift Operator,Chicago,IL,sarah.johnson@example.com,(312) 555-0101,pallet jack|hazmat|loading dock,OSHA-10|Forklift,0.92,0.88,reliable
|
||||
W-1002,Michael Chen,Welder,Toledo,OH,m.chen@example.com,(419) 555-0234,TIG|MIG|blueprint reading|grinder,OSHA-10|AWS D1.1,0.71,0.94,specialist
|
||||
W-1003,Maria Rodriguez,Warehouse Associate,Chicago,IL,maria.r@example.com,(312) 555-0311,inventory|RF scanner|pick-to-light,OSHA-10,0.85,0.80,reliable
|
||||
W-1004,David Park,Machine Operator,Detroit,MI,dpark@example.com,(313) 555-0412,CNC|gauge R&R|lean manufacturing,OSHA-10|First Aid,0.60,0.91,specialist
|
||||
W-1005,Jennifer Lopez,Loader,Chicago,IL,j.lopez@example.com,(312) 555-0501,loading dock|team lead|cold storage,OSHA-10|Forklift,0.95,0.76,communicator
|
||||
W-1006,Robert Williams,Maintenance Tech,Milwaukee,WI,rwilliams@example.com,(414) 555-0612,electrical|PLC|hydraulics|CMMS,OSHA-10|LOTO,0.45,0.93,specialist
|
||||
W-1007,Amanda Taylor,Quality Tech,Toledo,OH,a.taylor@example.com,(419) 555-0701,ISO 9001|calibration|root cause analysis,OSHA-10|Six Sigma Green,0.80,0.87,leader
|
||||
W-1008,Carlos Mendoza,Electrician,Chicago,IL,c.mendoza@example.com,(312) 555-0808,conduit|motor controls|troubleshooting,OSHA-30|NEC,0.88,0.96,specialist
|
||||
W-1009,Kim Nguyen,Forklift Operator,Detroit,MI,k.nguyen@example.com,(313) 555-0915,pallet jack|shipping|team lead,OSHA-10|Forklift,0.73,0.82,flexible
|
||||
W-1010,James O'Brien,Welder,Milwaukee,WI,jobrien@example.com,(414) 555-1021,TIG|pipe welding|confined space,OSHA-10|AWS D1.1,0.90,0.79,reliable
|
||||
W-1011,Priya Patel,Production Worker,Chicago,IL,priya.p@example.com,(312) 555-1118,line work|first article|labeling,OSHA-10,0.66,0.85,reliable
|
||||
W-1012,Thomas Anderson,Material Handler,Toledo,OH,tanderson@example.com,(419) 555-1225,RF scanner|pallet jack|receiving,OSHA-10|Forklift,0.82,0.77,flexible
|
||||
W-1013,Lisa Nakamura,Warehouse Associate,Detroit,MI,l.nakamura@example.com,(313) 555-1312,inventory|Excel|packaging,OSHA-10,0.77,0.89,communicator
|
||||
W-1014,Brandon Moore,Machine Operator,Milwaukee,WI,b.moore@example.com,(414) 555-1405,CNC|SPC|conveyor ops,OSHA-10,0.56,0.94,specialist
|
||||
W-1015,Emily Zhang,Assembler,Chicago,IL,emily.z@example.com,(312) 555-1502,assembly|gauge R&R|line lead,OSHA-10|Six Sigma Yellow,0.91,0.81,leader
|
||||
W-1016,Marcus Johnson,Forklift Operator,Toledo,OH,mjohnson@example.com,(419) 555-1609,pallet jack|hazmat|overhead crane,OSHA-10|Forklift|Hazmat,0.68,0.88,reliable
|
||||
W-1017,Anita Brooks,Line Lead,Detroit,MI,a.brooks@example.com,(313) 555-1715,team lead|training|SPC,OSHA-30|Six Sigma Green,0.52,0.97,leader
|
||||
W-1018,Dmitri Volkov,Welder,Chicago,IL,d.volkov@example.com,(312) 555-1820,TIG|MIG|blueprint reading,OSHA-10|AWS D1.1|Confined Space,0.84,0.83,specialist
|
||||
W-1019,Rachel Kim,Loader,Milwaukee,WI,r.kim@example.com,(414) 555-1911,loading dock|first aid|bilingual,OSHA-10|First Aid,0.79,0.84,communicator
|
||||
W-1020,Samuel Park,Maintenance Tech,Toledo,OH,spark@example.com,(419) 555-2012,electrical|PLC|troubleshooting|CMMS,OSHA-10|LOTO,0.70,0.92,specialist
|
||||
W-1021,Jordan Williams,Quality Tech,Detroit,MI,j.williams@example.com,(313) 555-2117,ISO 9001|calibration|SPC,OSHA-10|Six Sigma Green,0.83,0.86,leader
|
||||
W-1022,Natalia Soto,Warehouse Associate,Chicago,IL,n.soto@example.com,(312) 555-2221,RF scanner|inventory|team lead,OSHA-10,0.89,0.90,communicator
|
||||
W-1023,Henry Chen,Machine Operator,Milwaukee,WI,h.chen@example.com,(414) 555-2318,CNC|conveyor ops|root cause,OSHA-10,0.64,0.93,specialist
|
||||
W-1024,Ava Martinez,Forklift Operator,Toledo,OH,a.martinez@example.com,(419) 555-2411,pallet jack|bilingual|cold storage,OSHA-10|Forklift,0.87,0.80,flexible
|
||||
W-1025,Tyler Rodriguez,Production Worker,Chicago,IL,t.rodriguez@example.com,(312) 555-2515,line work|packaging|quality inspection,OSHA-10,0.74,0.86,reliable
|
||||
|
+386
-16
@@ -103,8 +103,12 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
|
||||
<h1>Staffing Co-Pilot</h1>
|
||||
<nav>
|
||||
<a href="." class="active">Dashboard</a>
|
||||
<a href="console">Intelligence Console</a>
|
||||
<a href="console">Walkthrough</a>
|
||||
<a href="proof">Architecture</a>
|
||||
<a href="spec">Spec</a>
|
||||
<a href="onboard">Onboard</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
<a href="workspaces">Workspaces</a>
|
||||
</nav>
|
||||
<div class="rt" id="status">Loading...</div>
|
||||
</div>
|
||||
@@ -112,6 +116,22 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
|
||||
|
||||
<div id="main"><div class="ld">Analyzing contracts and workers...</div></div>
|
||||
|
||||
<div class="section" id="staffing-forecast-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">Staffing Forecast — Next 30 Days</span>
|
||||
<span class="section-meta">Permits → predicted demand · Bench supply · Days to staffing deadline</span>
|
||||
</div>
|
||||
<div id="staffing-forecast"><div class="ld">Loading forecast...</div></div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="live-contracts-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">Live Contracts — Chicago Permits → Proposed Fills</span>
|
||||
<span class="section-meta" id="live-contracts-meta">Real public permit data + worker bench + past playbook patterns</span>
|
||||
</div>
|
||||
<div id="live-contracts"><div class="ld">Loading live contracts...</div></div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="market-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">Market Intelligence</span>
|
||||
@@ -131,7 +151,7 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">Worker Search</span>
|
||||
<span class="section-meta">Natural language · 500K profiles</span>
|
||||
<span class="section-meta" id="worker-search-meta">Natural language search</span>
|
||||
</div>
|
||||
<details class="sa" open><summary>Search all workers</summary><div class="inner">
|
||||
<input type="text" id="sq" placeholder="Try: reliable forklift operator available in Nashville" onkeydown="if(event.key==='Enter')doSearch()">
|
||||
@@ -140,19 +160,209 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
|
||||
<button class="sbtn" onclick="doSearch()">Find Workers</button><div id="sresults"></div></div></details>
|
||||
</div>
|
||||
|
||||
<div class="ft">Staffing Co-Pilot · Hybrid SQL + Vector Search · 500K embedded profiles · <a href="console">Console</a> · <a href="proof">Architecture</a></div>
|
||||
<div class="ft" id="footer">Staffing Co-Pilot · Hybrid SQL + Vector Search · loading scale… · <a href="console">Console</a> · <a href="proof">Architecture</a></div>
|
||||
</div>
|
||||
<script>
|
||||
var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
|
||||
var A=location.origin+P;
|
||||
var AC=['#1a2744','#1a3a2a','#2a1a3a','#3a2a1a','#1a3a3a','#2a2a1a'];
|
||||
var lastQuery='';
|
||||
window.addEventListener('load',function(){loadDay();loadMarket();loadLearning()});
|
||||
window.addEventListener('load',function(){loadSystemSummary();loadDay();loadStaffingForecast();loadLiveContracts();loadMarket();loadLearning()});
|
||||
|
||||
function loadSystemSummary(){
|
||||
api('/system/summary',{}).then(function(s){
|
||||
if(!s) return;
|
||||
var totalRows=(s.total_rows||0).toLocaleString();
|
||||
var workers=(s.workers_500k_rows||0).toLocaleString();
|
||||
var chunks=(s.total_chunks||0).toLocaleString();
|
||||
var ds=s.datasets||0;
|
||||
var meta1=document.getElementById('live-contracts-meta');
|
||||
if(meta1) meta1.textContent='Real public permit data + '+workers+' worker bench + past playbook patterns';
|
||||
var meta2=document.getElementById('worker-search-meta');
|
||||
if(meta2) meta2.textContent='Natural language · '+workers+' profiles across '+ds+' datasets';
|
||||
var foot=document.getElementById('footer');
|
||||
if(foot){
|
||||
foot.textContent='';
|
||||
foot.appendChild(document.createTextNode('Staffing Co-Pilot · Hybrid SQL + Vector Search · '+totalRows+' rows across '+ds+' datasets · '+chunks+' vector chunks · '));
|
||||
var a1=document.createElement('a');a1.href='console';a1.textContent='Console';foot.appendChild(a1);
|
||||
foot.appendChild(document.createTextNode(' · '));
|
||||
var a2=document.createElement('a');a2.href='proof';a2.textContent='Architecture';foot.appendChild(a2);
|
||||
}
|
||||
// Also update the collapsible search box label if not yet populated
|
||||
var sum=document.querySelector('.sa summary');
|
||||
if(sum&&/Search all\s*\d*\s*workers/.test(sum.textContent)){
|
||||
sum.textContent='Search all '+workers+' workers';
|
||||
}
|
||||
}).catch(function(){/* non-fatal */});
|
||||
}
|
||||
|
||||
function loadStaffingForecast(){
|
||||
api('/intelligence/staffing_forecast',{}).then(function(r){
|
||||
var el=document.getElementById('staffing-forecast');el.textContent='';
|
||||
if(!r||!r.forecast){el.textContent='Forecast unavailable.';return}
|
||||
// Header summary
|
||||
var hdr=document.createElement('div');hdr.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:14px;margin-bottom:10px;display:flex;justify-content:space-between;gap:16px';
|
||||
var left=document.createElement('div');
|
||||
var big=document.createElement('div');big.style.cssText='font-size:22px;font-weight:700;color:#e6edf3;letter-spacing:-0.5px';
|
||||
big.textContent='$'+(r.total_cost||0).toLocaleString('en-US',{maximumFractionDigits:0})+' in construction coming';
|
||||
var sub=document.createElement('div');sub.style.cssText='color:#8b949e;font-size:12px;margin-top:4px';
|
||||
sub.textContent=r.permit_count+' permits filed last 30 days · ~'+r.total_estimated_workers+' workers needed across roles';
|
||||
left.appendChild(big);left.appendChild(sub);hdr.appendChild(left);
|
||||
var right=document.createElement('div');right.style.cssText='text-align:right;font-size:11px';
|
||||
if(r.critical_roles>0){
|
||||
var c=document.createElement('div');c.style.cssText='color:#f85149;font-weight:700';
|
||||
c.textContent=r.critical_roles+' CRITICAL role'+(r.critical_roles!==1?'s':'')+' — supply gap';right.appendChild(c);
|
||||
} else if(r.tight_roles>0){
|
||||
var t=document.createElement('div');t.style.cssText='color:#d29922;font-weight:700';
|
||||
t.textContent=r.tight_roles+' tight role'+(r.tight_roles!==1?'s':'');right.appendChild(t);
|
||||
} else {
|
||||
var g=document.createElement('div');g.style.cssText='color:#3fb950;font-weight:600';g.textContent='bench covers predicted demand';right.appendChild(g);
|
||||
}
|
||||
var when=document.createElement('div');when.style.cssText='color:#545d68;margin-top:2px';
|
||||
when.textContent='updated '+((r.duration_ms||0)/1000).toFixed(1)+'s ago';right.appendChild(when);
|
||||
hdr.appendChild(right);el.appendChild(hdr);
|
||||
// Per-role rows
|
||||
r.forecast.forEach(function(f){
|
||||
var row=document.createElement('div');
|
||||
var riskColor={critical:'#f85149',tight:'#d29922',watch:'#d29922',ok:'#3fb950'}[f.risk]||'#8b949e';
|
||||
row.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:8px;padding:10px 14px;margin-bottom:6px;border-left:3px solid '+riskColor+';display:flex;justify-content:space-between;gap:12px;font-size:12px;align-items:center';
|
||||
var l=document.createElement('div');
|
||||
var n=document.createElement('div');n.style.cssText='color:#e6edf3;font-weight:600;font-size:13px';
|
||||
n.textContent=f.role;
|
||||
var d=document.createElement('div');d.style.cssText='color:#8b949e;font-size:11px;margin-top:2px';
|
||||
d.textContent=f.demand_permits+' permit'+(f.demand_permits!==1?'s':'')+' · est '+f.demand_workers+' workers · earliest staffing deadline '+f.earliest_staffing_deadline;
|
||||
l.appendChild(n);l.appendChild(d);row.appendChild(l);
|
||||
var r2=document.createElement('div');r2.style.cssText='text-align:right;white-space:nowrap';
|
||||
var cov=document.createElement('div');cov.style.cssText='color:'+riskColor+';font-weight:700;font-size:13px';
|
||||
cov.textContent=f.bench_available.toLocaleString()+' / '+f.demand_workers+' available ('+f.coverage_pct+'%)';
|
||||
var days=document.createElement('div');days.style.cssText='color:'+(f.days_to_deadline<=0?'#f85149':f.days_to_deadline<=7?'#d29922':'#8b949e')+';font-size:11px;margin-top:2px';
|
||||
days.textContent=f.days_to_deadline<=0?(Math.abs(f.days_to_deadline)+'d overdue'):(f.days_to_deadline+'d to deadline');
|
||||
r2.appendChild(cov);r2.appendChild(days);row.appendChild(r2);
|
||||
el.appendChild(row);
|
||||
});
|
||||
}).catch(function(e){
|
||||
document.getElementById('staffing-forecast').textContent='Forecast error: '+(e.message||e);
|
||||
});
|
||||
}
|
||||
|
||||
function api(path,body){
|
||||
return fetch(A+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(function(r){return r.json()})
|
||||
}
|
||||
|
||||
// Mirror of the Bun-side pay-rate formula so client-side renderers
|
||||
// (main search, modal) can show a rate when they only have worker
|
||||
// fields, not a pre-enriched hybrid source. Keep in sync with
|
||||
// impliedPayRate() in mcp-server/index.ts.
|
||||
var ROLE_BASE_PAY={Electrician:28,Welder:26,'Machine Operator':24,'Maintenance Tech':26,
|
||||
'Forklift Operator':20,Loader:17,'Warehouse Associate':17,'Material Handler':18,
|
||||
'Production Worker':18,'Quality Tech':23,'Line Lead':22,Assembler:18,'Shipping Clerk':19};
|
||||
function computeImpliedPayRate(role,rel,archetype){
|
||||
var base=ROLE_BASE_PAY[role||'']||19;
|
||||
var r=typeof rel==='string'?parseFloat(rel):(rel||0.5);
|
||||
var relBump=(isFinite(r)?r:0.5)*4;
|
||||
var a=(archetype||'').toLowerCase();
|
||||
var archBump=a==='specialist'?4:a==='leader'?3:a==='reliable'?1:0;
|
||||
return Math.round((base+relBump+archBump)*100)/100;
|
||||
}
|
||||
|
||||
function loadLiveContracts(){
|
||||
// Pair live Chicago permits with our 500K worker bench and the
|
||||
// meta-index discovered patterns for each role+geo. This is the
|
||||
// "real external data meets synthetic playbook learning" card set.
|
||||
api('/intelligence/permit_contracts',{}).then(function(r){
|
||||
var el=document.getElementById('live-contracts');el.textContent='';
|
||||
if(!r||!r.contracts||r.contracts.length===0){
|
||||
el.textContent='No permits returned.';return;
|
||||
}
|
||||
r.contracts.forEach(function(c){
|
||||
var p=c.permit||{}, prop=c.proposed||{}, tl=c.timeline||{};
|
||||
var urg=tl.urgency||'scheduled';
|
||||
var borderColor={overdue:'#f85149',urgent:'#d29922',soon:'#388bfd',scheduled:'#2ea043'}[urg]||'#388bfd';
|
||||
var card=document.createElement('div');card.className='insight info';
|
||||
card.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:16px;margin-bottom:10px;border-left:3px solid '+borderColor;
|
||||
// Header — permit
|
||||
var hdr=document.createElement('div');hdr.style.cssText='display:flex;justify-content:space-between;margin-bottom:8px;gap:12px';
|
||||
var left=document.createElement('div');
|
||||
var title=document.createElement('div');title.style.cssText='font-weight:600;color:#e6edf3;font-size:14px';
|
||||
title.textContent='$'+(p.cost||0).toLocaleString()+' · '+(p.work_type||'');
|
||||
var addr=document.createElement('div');addr.style.cssText='color:#8b949e;font-size:12px;margin-top:2px';
|
||||
addr.textContent=(p.address||'')+' · Chicago, IL · filed '+(p.issue_date||'');
|
||||
// Timeline chip
|
||||
if(tl.days_to_deadline!==undefined){
|
||||
var tmline=document.createElement('div');tmline.style.cssText='color:'+borderColor+';font-size:11px;font-weight:600;margin-top:4px';
|
||||
var urgLabel={overdue:'OVERDUE',urgent:'URGENT',soon:'SOON',scheduled:'SCHEDULED'}[urg]||'SCHEDULED';
|
||||
var dd=tl.days_to_deadline;
|
||||
var txt=urgLabel+' · staffing window opens '+(tl.staffing_window_opens||'')+' ('+(dd<=0?Math.abs(dd)+'d overdue':dd+'d to deadline')+') · construction est '+(tl.estimated_construction_start||'');
|
||||
tmline.textContent=txt;addr.appendChild(document.createElement('br'));left.appendChild(tmline);
|
||||
}
|
||||
left.appendChild(title);left.appendChild(addr);
|
||||
var right=document.createElement('div');right.style.cssText='color:#58a6ff;font-size:12px;font-weight:600;text-align:right;white-space:nowrap';
|
||||
right.textContent=prop.count+'× '+prop.role;
|
||||
var sub=document.createElement('div');sub.style.cssText='color:#545d68;font-size:10px;text-align:right';
|
||||
sub.textContent='pool: '+(prop.pool_size||'?').toLocaleString()+' available';
|
||||
right.appendChild(sub);
|
||||
// Rate awareness: show implied bill rate per contract
|
||||
if(c.implied_bill_rate){
|
||||
var rate=document.createElement('div');
|
||||
rate.style.cssText='color:#d29922;font-size:10px;text-align:right;margin-top:3px';
|
||||
rate.textContent='bill rate: $'+c.implied_bill_rate.toFixed(2)+'/hr';
|
||||
right.appendChild(rate);
|
||||
}
|
||||
hdr.appendChild(left);hdr.appendChild(right);card.appendChild(hdr);
|
||||
// Description
|
||||
if(p.description){
|
||||
var desc=document.createElement('div');desc.style.cssText='color:#94a3b8;font-size:11px;margin-bottom:10px;line-height:1.5';
|
||||
desc.textContent=p.description;card.appendChild(desc);
|
||||
}
|
||||
// Pattern (meta-index) chip
|
||||
if(c.discovered_pattern){
|
||||
var pat=document.createElement('div');pat.style.cssText='background:#0d2818;border:1px solid #2ea04360;border-radius:6px;padding:8px 12px;margin-bottom:10px;font-size:11px;color:#86efac;line-height:1.5';
|
||||
var plabel=document.createElement('span');plabel.style.cssText='color:#3fb950;font-weight:600;margin-right:6px';
|
||||
plabel.textContent='MEMORY ('+c.pattern_matched+' playbooks):';
|
||||
pat.appendChild(plabel);
|
||||
pat.appendChild(document.createTextNode(' '+c.discovered_pattern));
|
||||
card.appendChild(pat);
|
||||
}
|
||||
// Candidates
|
||||
var cands=prop.candidates||[];
|
||||
cands.slice(0,3).forEach(function(cand,i){
|
||||
var row=document.createElement('div');row.style.cssText='display:flex;align-items:center;gap:10px;padding:6px 10px;background:#161b22;border-radius:6px;margin-bottom:4px;font-size:12px';
|
||||
var av=document.createElement('div');av.style.cssText='width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:10px;color:#e6edf3;background:'+AC[i%AC.length];
|
||||
av.textContent=(cand.name||'?').split(' ').map(function(n){return (n[0]||'').toUpperCase()}).join('').substring(0,2);
|
||||
var info=document.createElement('div');info.style.cssText='flex:1;min-width:0';
|
||||
var nm=document.createElement('div');nm.style.cssText='color:#e6edf3;font-weight:500';nm.textContent=cand.name||cand.doc_id;
|
||||
if((cand.playbook_boost||0)>0){
|
||||
var chip=document.createElement('span');chip.style.cssText='margin-left:8px;padding:2px 7px;border-radius:9px;font-size:9px;font-weight:600;background:#0d2818;border:1px solid #2ea043;color:#3fb950;vertical-align:middle';
|
||||
chip.textContent='Endorsed · '+(cand.playbook_citations||[]).length+' playbook'+((cand.playbook_citations||[]).length===1?'':'s');
|
||||
nm.appendChild(chip);
|
||||
}
|
||||
// Rate warning chip when worker's pay exceeds the contract's bill rate
|
||||
if(cand.over_bill_rate){
|
||||
var warn=document.createElement('span');warn.style.cssText='margin-left:6px;padding:2px 7px;border-radius:9px;font-size:9px;font-weight:600;background:#3a1a1a;border:1px solid #f85149;color:#fca5a5;vertical-align:middle';
|
||||
warn.textContent='Over bill rate';
|
||||
warn.title='Worker\'s implied pay rate ($'+(cand.implied_pay_rate||0).toFixed(2)+'/hr) exceeds contract bill rate ($'+(c.implied_bill_rate||0).toFixed(2)+'/hr) — margin at risk';
|
||||
nm.appendChild(warn);
|
||||
}
|
||||
var sub2=document.createElement('div');sub2.style.cssText='color:#545d68;font-size:10px';
|
||||
var subText=cand.doc_id+' · score '+(cand.score||0).toFixed(3);
|
||||
if(cand.implied_pay_rate) subText+=' · pay $'+cand.implied_pay_rate.toFixed(2)+'/hr';
|
||||
sub2.textContent=subText;
|
||||
info.appendChild(nm);info.appendChild(sub2);
|
||||
row.appendChild(av);row.appendChild(info);
|
||||
card.appendChild(row);
|
||||
});
|
||||
if(cands.length>3){
|
||||
var more=document.createElement('div');more.style.cssText='font-size:10px;color:#58a6ff;padding:4px 10px;margin-top:2px';
|
||||
more.textContent='+ '+(cands.length-3)+' more candidates available';
|
||||
card.appendChild(more);
|
||||
}
|
||||
el.appendChild(card);
|
||||
});
|
||||
}).catch(function(e){
|
||||
document.getElementById('live-contracts').textContent='Error loading: '+e.message;
|
||||
});
|
||||
}
|
||||
|
||||
function loadDay(){
|
||||
// Step 1: run simulation + get real worker count + populate dropdowns from actual data
|
||||
Promise.all([
|
||||
@@ -539,11 +749,84 @@ function showProfile(workerData){
|
||||
body.appendChild(srcBox);
|
||||
}
|
||||
|
||||
// Call history — recruiter-facing institutional memory from call_log.
|
||||
// Queries for prior contact with this specific worker (by name
|
||||
// cross-ref). Fails soft: if no rows, shows "no recent contact" which
|
||||
// is itself a useful signal (or an honest tell about data sparsity).
|
||||
addSection(body,'Recent Contact','Last phone outreach logged in call_log');
|
||||
var callBox=document.createElement('div');
|
||||
callBox.style.cssText='background:#0d1117;border-radius:8px;padding:14px;margin-bottom:20px;font-size:12px;color:#8b949e;line-height:1.6';
|
||||
callBox.textContent='Checking call log...';body.appendChild(callBox);
|
||||
var nameLitC=(workerData.nm||'').replace(/'/g,"''");
|
||||
var callSQL="SELECT cl.timestamp, cl.recruiter, cl.duration_seconds, cl.disposition "
|
||||
+"FROM call_log cl JOIN candidates c ON c.candidate_id = cl.candidate_id "
|
||||
+"WHERE CONCAT(c.first_name, ' ', c.last_name) = '"+nameLitC+"' "
|
||||
+"ORDER BY cl.timestamp DESC LIMIT 3";
|
||||
api('/sql',{sql:callSQL}).then(function(r){
|
||||
callBox.textContent='';
|
||||
var rows=(r&&r.rows)||[];
|
||||
if(rows.length===0){
|
||||
callBox.textContent='No recent call logged for '+(workerData.nm||'this worker')+'. Data note: call_log cross-references candidate IDs that may not align with workers_500k — real ATS integration required for full coverage.';
|
||||
callBox.style.color='#484f58';return;
|
||||
}
|
||||
rows.forEach(function(c){
|
||||
var row=document.createElement('div');row.style.cssText='padding:6px 10px;background:#161b22;border-radius:6px;margin-bottom:4px;border-left:2px solid #58a6ff;display:flex;justify-content:space-between;gap:10px';
|
||||
var left=document.createElement('div');
|
||||
var ts=(c.timestamp||'').substring(0,10);
|
||||
var dur=Math.round((c.duration_seconds||0)/60);
|
||||
var l1=document.createElement('div');l1.style.cssText='color:#e6edf3;font-weight:500;font-size:12px';
|
||||
l1.textContent=ts+(c.recruiter?' · by '+c.recruiter:'');left.appendChild(l1);
|
||||
var l2=document.createElement('div');l2.style.cssText='color:#8b949e;font-size:10px';
|
||||
l2.textContent=(c.disposition||'?').replace(/_/g,' ')+(dur?' · '+dur+' min':'');left.appendChild(l2);
|
||||
row.appendChild(left);callBox.appendChild(row);
|
||||
});
|
||||
}).catch(function(){callBox.textContent='(call log unavailable)';callBox.style.color='#484f58'});
|
||||
|
||||
// Past playbook history — Phase 19 institutional memory surfaced on
|
||||
// the worker's own profile. Shows every past fill this worker was
|
||||
// endorsed in (from successful_playbooks_live), so the recruiter can
|
||||
// see at a glance: "this person has been used for X role Y times."
|
||||
addSection(body,'Past Playbooks','Where this worker has been endorsed before');
|
||||
var histBox=document.createElement('div');histBox.id='hist-'+(workerData.nm||'anon').replace(/\s/g,'-');
|
||||
histBox.style.cssText='background:#0d1117;border-radius:8px;padding:14px;margin-bottom:20px;font-size:12px;color:#8b949e;line-height:1.6';
|
||||
histBox.textContent='Loading history...';body.appendChild(histBox);
|
||||
var city=(workerData.loc||'').split(',')[0].trim();
|
||||
var state=(workerData.loc||'').split(',').pop().trim();
|
||||
var nameLit=(workerData.nm||'').replace(/'/g,"''");
|
||||
var sqlQ="SELECT operation, approach, context, timestamp FROM successful_playbooks_live "
|
||||
+"WHERE result LIKE '%"+nameLit+"%' ORDER BY timestamp DESC LIMIT 8";
|
||||
api('/sql',{sql:sqlQ}).then(function(r){
|
||||
histBox.textContent='';
|
||||
var rows=(r&&r.rows)||[];
|
||||
if(rows.length===0){
|
||||
histBox.textContent='No prior playbooks for '+(workerData.nm||'this worker')+' yet. First placement builds the first entry.';
|
||||
histBox.style.color='#484f58';return;
|
||||
}
|
||||
var hdr2=document.createElement('div');hdr2.style.cssText='color:#3fb950;font-weight:600;margin-bottom:8px;font-size:11px';
|
||||
hdr2.textContent=rows.length+' past endorsement'+(rows.length!==1?'s':'');
|
||||
histBox.appendChild(hdr2);
|
||||
rows.forEach(function(pb){
|
||||
var row=document.createElement('div');row.style.cssText='padding:6px 10px;background:#161b22;border-radius:6px;margin-bottom:4px;border-left:2px solid #2ea043';
|
||||
var op=document.createElement('div');op.style.cssText='color:#e6edf3;font-weight:500;font-size:12px';
|
||||
op.textContent=pb.operation||'(unknown op)';row.appendChild(op);
|
||||
var meta=document.createElement('div');meta.style.cssText='color:#8b949e;font-size:10px;margin-top:2px';
|
||||
var ts=(pb.timestamp||'').substring(0,10);
|
||||
meta.textContent=ts+' · '+(pb.approach||'').slice(0,40)+(pb.context?' · '+pb.context.slice(0,30):'');
|
||||
row.appendChild(meta);histBox.appendChild(row);
|
||||
});
|
||||
}).catch(function(){
|
||||
histBox.textContent='(history unavailable)';histBox.style.color='#484f58';
|
||||
});
|
||||
|
||||
// Actions
|
||||
var acts=document.createElement('div');acts.style.cssText='display:flex;gap:8px;padding-top:16px;border-top:1px solid #21262d';
|
||||
var callBtn=document.createElement('button');callBtn.style.cssText='flex:1;padding:12px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;border:none;background:#1f3d68;color:#58a6ff';callBtn.textContent='Call';
|
||||
callBtn.onclick=function(){logAction(workerData,'call',callBtn)};
|
||||
var smsBtn=document.createElement('button');smsBtn.style.cssText='flex:1;padding:12px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;border:none;background:#0d261a;color:#3fb950';smsBtn.textContent='Send SMS';
|
||||
acts.appendChild(callBtn);acts.appendChild(smsBtn);body.appendChild(acts);
|
||||
smsBtn.onclick=function(){logAction(workerData,'sms',smsBtn)};
|
||||
var noshowBtn=document.createElement('button');noshowBtn.style.cssText='flex:1;padding:12px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;border:none;background:#3a1a1a;color:#f85149';noshowBtn.textContent='No-show';
|
||||
noshowBtn.onclick=function(){logAction(workerData,'failure',noshowBtn)};
|
||||
acts.appendChild(callBtn);acts.appendChild(smsBtn);acts.appendChild(noshowBtn);body.appendChild(acts);
|
||||
|
||||
modal.appendChild(body);overlay.appendChild(modal);document.body.appendChild(overlay);
|
||||
}
|
||||
@@ -580,14 +863,35 @@ function addWorkerInsight(parent,name,detail,why,idx,highlight){
|
||||
var info=document.createElement('div');info.className='info';
|
||||
var nm=document.createElement('div');nm.className='nm';nm.textContent=name;
|
||||
// Phase 19: when a past playbook endorsed this worker, show a green chip
|
||||
// next to the name. Hover reveals the citation IDs.
|
||||
// next to the name. Hover reveals a NARRATIVE of past endorsements
|
||||
// derived from successful_playbooks_live — "filled X in Y on date" —
|
||||
// rather than opaque pb-seed-xxx ids. Recruiters need stories, not
|
||||
// citation keys. Lazy-loaded per card on first render.
|
||||
if(boostInfo && boostInfo.boost > 0){
|
||||
var chip=document.createElement('span');
|
||||
chip.style.cssText='display:inline-block;margin-left:8px;padding:2px 7px;border-radius:9px;font-size:10px;font-weight:600;background:#0d2818;border:1px solid #2ea043;color:#3fb950;vertical-align:middle';
|
||||
chip.style.cssText='display:inline-block;margin-left:8px;padding:2px 7px;border-radius:9px;font-size:10px;font-weight:600;background:#0d2818;border:1px solid #2ea043;color:#3fb950;vertical-align:middle;cursor:help';
|
||||
var n=(boostInfo.citations && boostInfo.citations.length) || 0;
|
||||
chip.textContent='Endorsed · '+n+' playbook'+(n!==1?'s':'');
|
||||
chip.title='Boosted by past playbooks: '+(boostInfo.citations||[]).join(', ');
|
||||
chip.title='Loading past playbooks for '+name+'...';
|
||||
nm.appendChild(chip);
|
||||
// Fetch narrative for this worker lazily
|
||||
var safeName = (name||'').replace(/'/g,"''");
|
||||
var narrativeSQL = "SELECT operation, result, timestamp FROM successful_playbooks_live "
|
||||
+ "WHERE result LIKE '%"+safeName+"%' ORDER BY timestamp DESC LIMIT 5";
|
||||
api('/sql',{sql:narrativeSQL}).then(function(r){
|
||||
var rows=(r&&r.rows)||[];
|
||||
if(rows.length===0){
|
||||
chip.title=name+' — endorsed in '+n+' playbook'+(n!==1?'s':'')+' (narrative unavailable — may have been seeded without SQL persistence)';
|
||||
return;
|
||||
}
|
||||
var stories=rows.map(function(pb){
|
||||
var d=(pb.timestamp||'').substring(0,10);
|
||||
return '• '+(pb.operation||'?').replace(/^fill:\s*/,'')+' ('+d+')';
|
||||
});
|
||||
chip.title=name+' — past endorsements:\n'+stories.join('\n');
|
||||
}).catch(function(){
|
||||
chip.title=name+' — endorsed in '+n+' playbook'+(n!==1?'s':'');
|
||||
});
|
||||
}
|
||||
var dt=document.createElement('div');dt.className='detail';dt.textContent=detail;
|
||||
info.appendChild(nm);info.appendChild(dt);
|
||||
@@ -595,10 +899,16 @@ function addWorkerInsight(parent,name,detail,why,idx,highlight){
|
||||
w.appendChild(info);
|
||||
var acts=document.createElement('div');acts.className='acts';
|
||||
var call=document.createElement('button');call.className='ibtn call';call.textContent='Call';
|
||||
call.onclick=function(e){e.stopPropagation();logSelection(workerDataRef)};
|
||||
call.onclick=function(e){e.stopPropagation();logAction(workerDataRef,'call',call)};
|
||||
var sms=document.createElement('button');sms.className='ibtn sms';sms.textContent='SMS';
|
||||
sms.onclick=function(e){e.stopPropagation();logSelection(workerDataRef)};
|
||||
acts.appendChild(call);acts.appendChild(sms);w.appendChild(acts);
|
||||
sms.onclick=function(e){e.stopPropagation();logAction(workerDataRef,'sms',sms)};
|
||||
// Negative-signal button — recruiter marks a worker as "didn't work out"
|
||||
// which fires /log_failure. Each such mark dampens that worker's
|
||||
// future boost in the same geo by 0.5^n.
|
||||
var noshow=document.createElement('button');noshow.className='ibtn';noshow.textContent='No-show';
|
||||
noshow.style.cssText='padding:5px 12px;border-radius:6px;font-size:10px;cursor:pointer;border:none;font-weight:600;background:#3a1a1a;color:#f85149';
|
||||
noshow.onclick=function(e){e.stopPropagation();logAction(workerDataRef,'failure',noshow)};
|
||||
acts.appendChild(call);acts.appendChild(sms);acts.appendChild(noshow);w.appendChild(acts);
|
||||
parent.appendChild(w);
|
||||
}
|
||||
|
||||
@@ -644,6 +954,32 @@ function doSearch(){
|
||||
var h=document.createElement('div');h.style.cssText='color:#8b949e;font-size:12px;margin-bottom:10px';
|
||||
h.textContent=(d.sql_matches?d.sql_matches.toLocaleString()+' workers matched — ':'')+'showing best results ('+(d.duration_ms||0)+'ms)';
|
||||
out.appendChild(h);
|
||||
// Meta-index signal — ALWAYS render when the system has any memory,
|
||||
// even if no trait crossed threshold. Silence here would have
|
||||
// recruiters assume "no signal" when the reality is "threshold
|
||||
// filtered it out" or "memory is sparse for this geo." Trust
|
||||
// depends on the system being honest about what it doesn't know.
|
||||
if(d.pattern_playbooks_matched > 0 || d.discovered_pattern){
|
||||
var mem=document.createElement('div');
|
||||
mem.style.cssText='background:#0d2818;border:1px solid #2ea04360;border-radius:6px;padding:8px 12px;margin-bottom:10px;font-size:11px;color:#86efac;line-height:1.5';
|
||||
var label=document.createElement('span');label.style.cssText='color:#3fb950;font-weight:600;margin-right:6px';
|
||||
label.textContent='MEMORY ('+(d.pattern_playbooks_matched||0)+' playbook'+(d.pattern_playbooks_matched===1?'':'s')+'):';
|
||||
mem.appendChild(label);
|
||||
var pattern = d.discovered_pattern || '';
|
||||
if(!pattern || pattern.indexOf('No similar')>=0 || pattern.indexOf('0 workers')>=0){
|
||||
mem.appendChild(document.createTextNode(' memory is sparse for this role+geo — no trait crossed threshold. Will accumulate as fills land.'));
|
||||
mem.style.color='#6ca885';
|
||||
} else {
|
||||
mem.appendChild(document.createTextNode(' '+pattern));
|
||||
}
|
||||
out.appendChild(mem);
|
||||
} else {
|
||||
// Zero playbooks matched — be explicit
|
||||
var mem0=document.createElement('div');
|
||||
mem0.style.cssText='background:#161b22;border:1px solid #21262d;border-radius:6px;padding:6px 12px;margin-bottom:10px;font-size:11px;color:#6e7681';
|
||||
mem0.textContent='MEMORY: no similar past playbooks yet — first fill of this kind will seed it.';
|
||||
out.appendChild(mem0);
|
||||
}
|
||||
// Render results based on type
|
||||
var workers=d.sql_results||[];
|
||||
if(workers.length){
|
||||
@@ -656,6 +992,10 @@ function doSearch(){
|
||||
var why='Reliability: '+Math.round((w.rel||0)*100)+'%';
|
||||
if(w.avail)why+=' · Available: '+Math.round(w.avail*100)+'%';
|
||||
if(w.archetype)why+=' · '+w.archetype;
|
||||
// Derive and show implied pay rate client-side so the main search
|
||||
// surface matches the live-contracts cards. Same formula as Bun.
|
||||
var rate=computeImpliedPayRate(w.role,w.rel,w.archetype);
|
||||
if(rate) why+=' · pay $'+rate.toFixed(2)+'/hr';
|
||||
addWorkerInsight(out,w.name,detail.join(' · '),why,i,null,wd);
|
||||
});
|
||||
} else {
|
||||
@@ -781,12 +1121,42 @@ function loadMarket(){
|
||||
}
|
||||
|
||||
// ─── Learning Loop ───
|
||||
function logSelection(workerData){
|
||||
if(!lastQuery||!workerData)return;
|
||||
fetch(A+'/intelligence/learn',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({query:lastQuery,worker_name:workerData.nm,worker_id:workerData.nm,worker_role:workerData.role,worker_state:(workerData.loc||'').split(',').pop().trim(),worker_city:(workerData.loc||'').split(',')[0].trim(),filters:document.getElementById('sst').value+' '+document.getElementById('srl').value})
|
||||
}).then(function(){loadLearning()}).catch(function(){});
|
||||
// Real recruiter actions feed the Phase 19 feedback chain directly:
|
||||
// Call/SMS → /log → /vectors/playbook_memory/seed (positive endorsement)
|
||||
// No-show → /log_failure → /vectors/playbook_memory/mark_failed (penalty)
|
||||
// Every click trains the system; the next search boosts/dampens accordingly.
|
||||
function logAction(workerData, kind, btnEl){
|
||||
if(!workerData)return;
|
||||
var role=workerData.role||'Worker';
|
||||
var city=(workerData.loc||'').split(',')[0].trim();
|
||||
var state=(workerData.loc||'').split(',').pop().trim();
|
||||
if(!city||!state){flashBtn(btnEl,'no geo');return;}
|
||||
var op='fill: '+role+' x1 in '+city+', '+state;
|
||||
if(kind==='failure'){
|
||||
fetch(A+'/log_failure',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({operation:op,failed_names:[workerData.nm],reason:'marked no-show via UI'})
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
flashBtn(btnEl, d&&d.marked?'Flagged':'Ghost');
|
||||
loadLearning();
|
||||
}).catch(function(){flashBtn(btnEl,'err')});
|
||||
} else {
|
||||
fetch(A+'/log',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({operation:op,approach:kind+' from UI',
|
||||
result:'1/1 filled → '+workerData.nm,
|
||||
context:'client=ui query='+(lastQuery||'(direct)').slice(0,40)})
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
flashBtn(btnEl, d&&d.seeded?'Logged':'Ghost');
|
||||
loadLearning();
|
||||
}).catch(function(){flashBtn(btnEl,'err')});
|
||||
}
|
||||
}
|
||||
function flashBtn(btn,label){
|
||||
if(!btn)return;
|
||||
var old=btn.textContent;btn.textContent=label;btn.disabled=true;
|
||||
setTimeout(function(){btn.textContent=old;btn.disabled=false},1400);
|
||||
}
|
||||
// Back-compat shim — any legacy caller still pointing at logSelection.
|
||||
function logSelection(workerData){ logAction(workerData, 'call', null); }
|
||||
|
||||
function loadLearning(){
|
||||
api('/intelligence/activity',{}).then(function(d){
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Lakehouse — Technical Specification</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:#58a6ff;text-decoration:none}
|
||||
a:hover{color:#79c0ff}
|
||||
|
||||
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
|
||||
.bar h1{font-size:14px;font-weight:600;color:#e6edf3;letter-spacing:-0.2px}
|
||||
.bar nav{display:flex;gap:2px}
|
||||
.bar nav a{font-size:12px;color:#545d68;padding:6px 14px;border-radius:6px;transition:all 0.15s}
|
||||
.bar nav a:hover{color:#e6edf3;background:#161b22}
|
||||
.bar nav a.active{color:#e6edf3;background:#1c2333}
|
||||
.bar .rt{font-size:11px;color:#545d68}
|
||||
|
||||
.layout{display:grid;grid-template-columns:220px 1fr;gap:0;max-width:1200px;margin:0 auto}
|
||||
.toc{position:sticky;top:72px;align-self:start;padding:24px 12px;border-right:1px solid #171d27;height:calc(100vh - 72px);overflow-y:auto}
|
||||
.toc .hdr{color:#545d68;font-size:10px;text-transform:uppercase;letter-spacing:1.4px;font-weight:600;margin-bottom:10px;padding-left:8px}
|
||||
.toc a{display:block;color:#8b949e;font-size:12px;padding:6px 10px;border-radius:6px;margin-bottom:2px;line-height:1.4}
|
||||
.toc a:hover{color:#e6edf3;background:#161b22}
|
||||
|
||||
.wrap{padding:28px 24px 60px;min-width:0}
|
||||
|
||||
.chapter{margin-bottom:48px;scroll-margin-top:72px}
|
||||
.chapter .num{color:#545d68;font-size:11px;font-weight:600;letter-spacing:1.6px;text-transform:uppercase;margin-bottom:6px}
|
||||
.chapter h2{color:#e6edf3;font-size:24px;font-weight:700;letter-spacing:-0.4px;margin-bottom:8px;line-height:1.2}
|
||||
.chapter h3{color:#e6edf3;font-size:16px;font-weight:600;margin:22px 0 8px}
|
||||
.chapter h4{color:#c9d1d9;font-size:14px;font-weight:600;margin:16px 0 6px}
|
||||
.chapter .lede{color:#8b949e;font-size:14px;margin-bottom:18px;max-width:760px;line-height:1.7}
|
||||
.chapter p{color:#b0b8c4;margin-bottom:12px;max-width:800px}
|
||||
.chapter ul{color:#b0b8c4;margin:8px 0 16px 24px}
|
||||
.chapter li{margin-bottom:6px;line-height:1.7}
|
||||
.chapter strong{color:#e6edf3;font-weight:600}
|
||||
|
||||
.card{background:#0d1117;border:1px solid #171d27;border-radius:12px;padding:20px;margin:12px 0}
|
||||
.accent-l{border-left:3px solid #2ea043}
|
||||
.accent-b{border-left:3px solid #1f6feb}
|
||||
.accent-a{border-left:3px solid #bc8cff}
|
||||
.accent-w{border-left:3px solid #d29922}
|
||||
.accent-r{border-left:3px solid #f85149}
|
||||
|
||||
code{background:#161b22;color:#e6edf3;padding:2px 6px;border-radius:4px;font-family:ui-monospace,Menlo,monospace;font-size:12px}
|
||||
pre{background:#161b22;border:1px solid #171d27;border-radius:8px;padding:14px 16px;overflow-x:auto;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:#c9d1d9;line-height:1.5;margin:8px 0}
|
||||
|
||||
table.plain{width:100%;border-collapse:collapse;font-size:12px;margin:10px 0}
|
||||
table.plain th{text-align:left;padding:8px 12px;color:#545d68;font-weight:600;text-transform:uppercase;font-size:10px;letter-spacing:0.8px;border-bottom:1px solid #171d27;background:#0d1117}
|
||||
table.plain td{padding:8px 12px;border-bottom:1px solid #171d27;color:#c9d1d9;vertical-align:top}
|
||||
table.plain td.mono{font-family:ui-monospace,Menlo,monospace}
|
||||
table.plain tr:hover td{background:#0d1117}
|
||||
|
||||
.narr{color:#8b949e;font-size:13px;line-height:1.7;margin:10px 0;padding:10px 14px;border-left:2px solid #21262d}
|
||||
.narr strong{color:#c9d1d9}
|
||||
|
||||
.ref{color:#545d68;font-size:11px;margin-top:6px;font-family:ui-monospace,Menlo,monospace}
|
||||
.ref strong{color:#79c0ff;font-weight:600}
|
||||
|
||||
.step{display:flex;gap:14px;margin-bottom:14px;padding:12px 16px;background:#0d1117;border:1px solid #171d27;border-radius:8px}
|
||||
.step .n{color:#58a6ff;font-weight:700;font-size:18px;flex-shrink:0;min-width:30px}
|
||||
.step .body{color:#b0b8c4;font-size:13px;line-height:1.7}
|
||||
.step .body strong{color:#e6edf3}
|
||||
|
||||
.footer{border-top:1px solid #171d27;padding:20px;text-align:center;color:#3d444d;font-size:11px}
|
||||
|
||||
@media(max-width:900px){
|
||||
.layout{grid-template-columns:1fr}
|
||||
.toc{display:none}
|
||||
.wrap{padding:20px 14px 40px}
|
||||
.bar nav{display:none}
|
||||
}
|
||||
</style></head>
|
||||
<body>
|
||||
|
||||
<div class="bar">
|
||||
<h1>Lakehouse — Technical Specification</h1>
|
||||
<nav>
|
||||
<a href=".">Dashboard</a>
|
||||
<a href="console">Walkthrough</a>
|
||||
<a href="proof">Architecture</a>
|
||||
<a href="spec" class="active">Spec</a>
|
||||
<a href="onboard">Onboard</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
<a href="workspaces">Workspaces</a>
|
||||
</nav>
|
||||
<div class="rt">v1 · 2026-04-20</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="toc">
|
||||
<div class="hdr">Contents</div>
|
||||
<a href="#ch1">1. Repository layout</a>
|
||||
<a href="#ch2">2. Data ingest pipeline</a>
|
||||
<a href="#ch3">3. Measurement & indexing</a>
|
||||
<a href="#ch4">4. Contract inference</a>
|
||||
<a href="#ch5">5. What a CRM can't do</a>
|
||||
<a href="#ch6">6. How it gets better over time</a>
|
||||
<a href="#ch7">7. Scale story — 20 staffers, 300 contracts, a surge</a>
|
||||
<a href="#ch8">8. Error surfaces & recovery</a>
|
||||
<a href="#ch9">9. Per-staffer context</a>
|
||||
<a href="#ch10">10. A day in the life</a>
|
||||
<a href="#ch11">11. Known limits & non-goals</a>
|
||||
</aside>
|
||||
<div class="wrap">
|
||||
|
||||
<!-- ═══ 1. REPO LAYOUT ═══ -->
|
||||
<div class="chapter" id="ch1">
|
||||
<div class="num">Chapter 1</div>
|
||||
<h2>Repository layout</h2>
|
||||
<div class="lede">What lives where. Every folder below has a single, bounded responsibility. A maintainer reading this should know — in under ten minutes — which crate owns a failing behavior.</div>
|
||||
<table class="plain">
|
||||
<thead><tr><th>Path</th><th>Owns</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="mono">crates/shared/</td><td>Types, errors, Arrow helpers, schema fingerprints, PII detection, secrets provider. Every other crate depends on this.</td></tr>
|
||||
<tr><td class="mono">crates/storaged/</td><td>Raw object I/O. <code>BucketRegistry</code> (multi-bucket, rescue-aware), <code>AppendLog</code> (write-once batched append), <code>ErrorJournal</code> (bucket op failures). ADR-017 (federation), ADR-018 (append pattern).</td></tr>
|
||||
<tr><td class="mono">crates/catalogd/</td><td>Metadata authority. Dataset manifests, schema fingerprints (ADR-020), tombstones (soft delete), AI-safe views, model profiles (Phase 17). In-memory index persisted as Parquet on storage.</td></tr>
|
||||
<tr><td class="mono">crates/queryd/</td><td>SQL engine. DataFusion over Parquet + MemTable cache + delta merge-on-read + compaction. Registers every bucket as an object_store so SQL can join across them.</td></tr>
|
||||
<tr><td class="mono">crates/ingestd/</td><td>Data on-ramp. CSV / JSON / PDF (+OCR via Tesseract) / Postgres streaming / MySQL streaming / inbox watcher / cron schedules. Every ingest path auto-tags PII (emails, phones, SSNs, addresses), records lineage, and marks embeddings stale.</td></tr>
|
||||
<tr><td class="mono">crates/vectord/</td><td>The vector + learning surface. Embeddings stored as Parquet (ADR-008), HNSW index (Phase 15), trial system (autotune), promotion registry (Phase 16), playbook_memory (Phase 19). Core feedback loop lives here.</td></tr>
|
||||
<tr><td class="mono">crates/vectord-lance/</td><td>Firewall crate. Lance 4.0 + Arrow 57, isolated from the main Arrow-55 workspace. Provides secondary vector backend for large-scale, random-access, and append-heavy workloads (ADR-019).</td></tr>
|
||||
<tr><td class="mono">crates/journald/</td><td>Append-only mutation event log (ADR-012). Every insert/update/delete writes here — who, when, what, old/new value. Never mutated. Foundation for time-travel + compliance audit.</td></tr>
|
||||
<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/* /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, <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>
|
||||
|
||||
<!-- ═══ 2. INGEST PIPELINE ═══ -->
|
||||
<div class="chapter" id="ch2">
|
||||
<div class="num">Chapter 2</div>
|
||||
<h2>Data ingest pipeline</h2>
|
||||
<div class="lede">How staffing data gets into the system — whether from a CSV drop, an ATS export, a Postgres replica, or a PDF resume. Every path ends at the same place: a registered dataset with known schema, known lineage, known sensitivity.</div>
|
||||
|
||||
<div class="step"><div class="n">1</div><div class="body"><strong>Source arrives.</strong> Four shapes: (a) file upload via <code>POST /ingest/file</code>, (b) inbox watcher (drops in <code>./inbox/</code> → auto-ingested in under 15s), (c) Postgres or MySQL streaming connector (<code>POST /ingest/db</code> with DSN), (d) scheduled ingest via <code>ingestd::schedule</code> with cron.</div></div>
|
||||
|
||||
<div class="step"><div class="n">2</div><div class="body"><strong>Parse + normalize.</strong> CSV parser infers types per column; defaults to <code>String</code> on ambiguity (ADR-010 — better to ingest everything than reject on type mismatch). JSON parser flattens nested objects. PDF extractor uses <code>lopdf</code> first; falls back to Tesseract OCR for scanned/image PDFs. Output is always an Arrow <code>RecordBatch</code>.</div></div>
|
||||
|
||||
<div class="step"><div class="n">3</div><div class="body"><strong>Auto-detect PII.</strong> <code>shared::pii</code> scans column values and names. Identifies emails, phone numbers, SSNs, salaries, street addresses, medical terms. Tags columns with <code>sensitivity: PII | PHI | Financial | Internal | Public</code> (Phase 10 catalog v2).</div></div>
|
||||
|
||||
<div class="step"><div class="n">4</div><div class="body"><strong>Deduplicate by content hash.</strong> Every uploaded file's SHA-256 is checked against the catalog's seen-hash log. Re-ingesting the same file is a no-op (ADR invariant #5).</div></div>
|
||||
|
||||
<div class="step"><div class="n">5</div><div class="body"><strong>Write Parquet to object storage.</strong> <code>arrow_helpers::record_batch_to_parquet</code> → <code>storaged::ops::put</code> → file lands under <code>data/datasets/<name>.parquet</code> (or bucket-scoped via <code>BucketRegistry</code>). Schema fingerprint computed.</div></div>
|
||||
|
||||
<div class="step"><div class="n">6</div><div class="body"><strong>Register in catalog.</strong> <code>catalogd::Registry::register(name, fingerprint, objects)</code> — idempotent on (name, fingerprint). Same name + same fingerprint = reuse manifest, bump updated_at. Same name + different fingerprint = <code>409 Conflict</code> (ADR-020 — prevents silent schema drift). New name = create new manifest with owner, lineage, freshness SLA, column metadata, PII tags.</div></div>
|
||||
|
||||
<div class="step"><div class="n">7</div><div class="body"><strong>Mark embeddings stale.</strong> If the dataset already has a vector index, the new rows mean that index is now behind. <code>Registry::mark_embeddings_stale</code> flips a flag; <code>POST /vectors/refresh/<dataset></code> runs an incremental re-embed (only new rows, not the whole corpus).</div></div>
|
||||
|
||||
<div class="step"><div class="n">8</div><div class="body"><strong>Queryable immediately.</strong> <code>queryd::context</code> picks up the new manifest on next query. Hot-cache warms on first hit. Delta merge-on-read means updates land without rewriting the base Parquet.</div></div>
|
||||
|
||||
<div class="ref"><strong>Code:</strong> crates/ingestd/src/{service.rs, csv.rs, json.rs, pdf.rs, pg_stream.rs, my_stream.rs, schedule.rs}</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 3. MEASUREMENT & INDEXING ═══ -->
|
||||
<div class="chapter" id="ch3">
|
||||
<div class="num">Chapter 3</div>
|
||||
<h2>Measurement & indexing</h2>
|
||||
<div class="lede">Once data is in, the system describes it rigorously and builds fast-access indexes over the parts that will be queried. Every measurement is deterministic, versioned, and visible via HTTP.</div>
|
||||
|
||||
<h3>What gets measured per dataset</h3>
|
||||
<ul>
|
||||
<li><strong>Row count</strong> (from parquet footer, not a SELECT COUNT). O(1).</li>
|
||||
<li><strong>Schema fingerprint</strong> — SHA-256 over (column_name, type, nullability, sort) tuples. Drives ADR-020 idempotent register.</li>
|
||||
<li><strong>Owner / sensitivity / freshness SLA</strong> — catalog v2 metadata. PII auto-detected; owner assigned on ingest.</li>
|
||||
<li><strong>Lineage</strong> — source_system → ingest_job → dataset. Who put this here, when, from what.</li>
|
||||
<li><strong>Last embedded at</strong> — when the vector index covering this dataset was last refreshed. Drives stale-detection.</li>
|
||||
</ul>
|
||||
|
||||
<h3>How vector indexes are built</h3>
|
||||
<p>Two backends, chosen per profile (ADR-019):</p>
|
||||
<table class="plain">
|
||||
<thead><tr><th></th><th>HNSW over Parquet (primary)</th><th>Lance (secondary)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Storage</td><td>Embeddings as Parquet columns (<code>doc_id, chunk_text, vector</code>)</td><td>Lance native dataset</td></tr>
|
||||
<tr><td>Index</td><td>HNSW in RAM, serialized sidecar</td><td>IVF_PQ on disk</td></tr>
|
||||
<tr><td>Build time (100K × 768d)</td><td>~230s</td><td>~16s (14× faster)</td></tr>
|
||||
<tr><td>Search p50 (100K)</td><td>~873μs</td><td>~7.4ms at recall 1.0</td></tr>
|
||||
<tr><td>Append</td><td>Rewrite required</td><td>Structural (0.08s for 100 rows)</td></tr>
|
||||
<tr><td>Random fetch by doc_id</td><td>Full scan</td><td>~311μs (112× faster)</td></tr>
|
||||
<tr><td>RAM ceiling</td><td>~5M vectors</td><td>Scales past RAM — disk-resident</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Autotune</h3>
|
||||
<p>The <code>vectord::agent</code> background task runs continuously. Per index, it proposes HNSW configurations (<code>ef_construction × ef_search</code>), executes a trial against a stored eval set, journals the result as JSONL, and — if recall beats the min_recall gate (0.9) and latency wins the Pareto test — promotes the new config atomically via <code>promotion_registry</code>. No downtime. Rollback in milliseconds.</p>
|
||||
|
||||
<h3>Per-profile / per-staffer indexing</h3>
|
||||
<p>Model profiles (Phase 17) are not routing strings — they are named scopes. Each profile has <code>bound_datasets[]</code>, <code>hnsw_config</code>, <code>vector_backend</code>, and <code>bucket</code>. When a staffer activates a profile:</p>
|
||||
<ul>
|
||||
<li>EmbeddingCache warms for bound indexes only</li>
|
||||
<li>HNSW is rebuilt with the profile's config (if different from current)</li>
|
||||
<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>
|
||||
|
||||
<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 ═══ -->
|
||||
<div class="chapter" id="ch4">
|
||||
<div class="num">Chapter 4</div>
|
||||
<h2>Contract inference from external signal</h2>
|
||||
<div class="lede">Most CRMs wait for a contract to land. This system watches upstream demand and pre-builds the ranking before the contract lands.</div>
|
||||
|
||||
<p>The concrete example running on <code>devop.live/lakehouse</code> is Chicago Department of Buildings permit data (public Socrata API). Every permit is a signal that construction — and therefore staffing — is coming.</p>
|
||||
|
||||
<h3>Flow</h3>
|
||||
<div class="step"><div class="n">1</div><div class="body"><strong>Fetch.</strong> <code>/intelligence/market</code> and <code>/intelligence/permit_contracts</code> hit <code>data.cityofchicago.org/resource/ydr8-5enu.json</code> live. No caching of permit data — every page load is fresh.</div></div>
|
||||
<div class="step"><div class="n">2</div><div class="body"><strong>Map work_type → role.</strong> Industry dictionary: "Electrical Work" → "Electrician", "Masonry Work" → "Production Worker", "Mechanical Work" → "Maintenance Tech", etc.</div></div>
|
||||
<div class="step"><div class="n">3</div><div class="body"><strong>Derive worker count.</strong> Heuristic: ~1 worker per $150K of permit cost, capped 2-8 per contract for staffing realism. Operator-configurable when real client history is available.</div></div>
|
||||
<div class="step"><div class="n">4</div><div class="body"><strong>Derive timeline.</strong> Permit issued → construction starts ~45 days later → staffing window opens ~14 days before construction. Classifies each permit as <code>overdue</code>, <code>urgent</code>, <code>soon</code>, <code>scheduled</code>.</div></div>
|
||||
<div class="step"><div class="n">5</div><div class="body"><strong>Run hybrid search against the bench.</strong> For each derived contract, <code>POST /vectors/hybrid</code> with <code>sql_filter</code> on role+state+city+availability, <code>use_playbook_memory: true</code>, <code>playbook_memory_k: 200</code>. Returns top-5 candidates with boost + citations.</div></div>
|
||||
<div class="step"><div class="n">6</div><div class="body"><strong>Query the meta-index.</strong> <code>POST /vectors/playbook_memory/patterns</code> aggregates traits across similar past playbooks — recurring certs, skills, archetype, reliability distribution. Surfaces signal the operator didn't query for.</div></div>
|
||||
<div class="step"><div class="n">7</div><div class="body"><strong>Render on the dashboard.</strong> Each card shows permit + derived contract + top 3 candidates with memory chips + discovered pattern + urgency. All of this pre-computed before any staffer opens the UI.</div></div>
|
||||
|
||||
<h3>Coverage forecast</h3>
|
||||
<p><code>/intelligence/staffing_forecast</code> aggregates the last 30 days of permits into predicted role-level demand, joins against the IL bench supply, computes coverage %, and classifies each role as <code>critical</code> / <code>tight</code> / <code>watch</code> / <code>ok</code>. The dashboard's top panel renders this — staffers see supply gaps before they query.</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 5. WHAT A CRM CAN'T DO ═══ -->
|
||||
<div class="chapter" id="ch5">
|
||||
<div class="num">Chapter 5</div>
|
||||
<h2>What a CRM can't do (and why)</h2>
|
||||
<div class="lede">A CRM stores. This system infers, predicts, re-ranks, and compounds. The six capabilities below are load-bearing — missing any of them is the gap between "software that logs calls" and "software that makes the next call better."</div>
|
||||
|
||||
<div class="card accent-b">
|
||||
<table class="plain">
|
||||
<thead><tr><th>Capability</th><th>CRM</th><th>This system</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Store candidate records</td><td>Yes</td><td>Yes (workers_500k, candidates)</td></tr>
|
||||
<tr><td>Search by structured field</td><td>Yes</td><td>Yes (DataFusion SQL, sub-100ms on 3M rows)</td></tr>
|
||||
<tr><td>Search by semantic meaning</td><td>No</td><td>Yes (HNSW + nomic-embed-text)</td></tr>
|
||||
<tr><td>Combine SQL filter + semantic rank</td><td>No</td><td>Yes (<code>/vectors/hybrid</code>)</td></tr>
|
||||
<tr><td>Boost workers based on past success</td><td>No</td><td>Yes (Phase 19 playbook_memory)</td></tr>
|
||||
<tr><td>Penalize workers based on past failure</td><td>No</td><td>Yes (<code>/log_failure</code> + <code>0.5<sup>n</sup></code> penalty)</td></tr>
|
||||
<tr><td>Surface traits across past fills</td><td>No</td><td>Yes (<code>/vectors/playbook_memory/patterns</code>)</td></tr>
|
||||
<tr><td>Predict staffing demand from external data</td><td>No</td><td>Yes (Chicago permit feed + 30-day rolling forecast)</td></tr>
|
||||
<tr><td>Count down to staffing deadline per contract</td><td>No</td><td>Yes (permit issue_date + heuristic timeline)</td></tr>
|
||||
<tr><td>Explain why each candidate ranked</td><td>No</td><td>Yes (boost chip + narrative citations + memory pattern)</td></tr>
|
||||
<tr><td>Improve ranking from operator actions</td><td>No</td><td>Yes (every Call/SMS/No-show click → re-rank signal)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 6. HOW IT GETS BETTER ═══ -->
|
||||
<div class="chapter" id="ch6">
|
||||
<div class="num">Chapter 6</div>
|
||||
<h2>How it gets better over time</h2>
|
||||
<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 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><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 ═══ -->
|
||||
<div class="chapter" id="ch7">
|
||||
<div class="num">Chapter 7</div>
|
||||
<h2>Scale story — 20 staffers, 300 contracts, a surge</h2>
|
||||
<div class="lede">What happens when the demo-level load becomes the production-level load, and midday a client pushes 20 more contracts plus a 1M-row ATS delta. Honest: some of this is architectural headroom, not measured scale. The designed behaviors are below.</div>
|
||||
|
||||
<h3>20 concurrent staffers</h3>
|
||||
<p><strong>Axum is async.</strong> The gateway handles concurrent requests on Tokio with work-stealing. No per-request thread. Tested at 10 parallel queries in 82ms total on this hardware.</p>
|
||||
<p><strong>Per-staffer profile isolation.</strong> Each staffer activates their own profile (Phase 17) or workspace (Phase 8.5). Profile scopes their search to bound datasets. Workspace carries their in-progress contracts across sessions.</p>
|
||||
<p><strong>Per-client blacklists.</strong> Auto-applied when the caller passes <code>client: "X"</code> on <code>/search</code>. Staffer A filling for Acme never sees Acme's flagged workers. Staffer B filling for MidState sees them normally.</p>
|
||||
|
||||
<h3>300 active contracts</h3>
|
||||
<p><strong>SQL on <code>job_orders</code> is cheap.</strong> 300 rows is nothing — a scan is microseconds.</p>
|
||||
<p><strong>Workspace per contract.</strong> Each contract gets its own workspace with saved searches, shortlists, activity log. Zero-copy handoff between staffers (pointer swap, not data copy).</p>
|
||||
<p><strong>Forecast remains coherent.</strong> <code>/intelligence/staffing_forecast</code> aggregates 30-day permit data regardless of contract count. The bench supply query (<code>GROUP BY role</code> over workers_500k) is a single sub-second SQL.</p>
|
||||
|
||||
<h3>Midday surge: +20 contracts, +1M profiles</h3>
|
||||
<p>The delta arrives at 12:30. Here's what happens in the following minutes:</p>
|
||||
<div class="step"><div class="n">1</div><div class="body"><strong>+20 contracts via /ingest/db or /ingest/file.</strong> Parsed, schema-checked, Parquet-written, catalog-registered. No queries blocked — register holds a write lock across the manifest write only.</div></div>
|
||||
<div class="step"><div class="n">2</div><div class="body"><strong>+1M worker profiles arrives as delta to workers_500k.</strong> Append-log pattern (ADR-018) means the new rows write to a fresh batch file — base Parquet is NOT rewritten. Queries against workers_500k immediately merge-on-read the new batches.</div></div>
|
||||
<div class="step"><div class="n">3</div><div class="body"><strong>Embeddings marked stale.</strong> The vector index for workers_500k_v1 now has 1M rows it hasn't seen. <code>mark_embeddings_stale</code> flips the flag.</div></div>
|
||||
<div class="step"><div class="n">4</div><div class="body"><strong>Incremental refresh fires.</strong> <code>POST /vectors/refresh/workers_500k</code> reads only the new rows (diff against existing embeddings), embeds them in batches of 64 via Ollama, writes delta embedding Parquet. Measured on threat_intel: 34 new rows in 970ms (6× faster than full re-embed).</div></div>
|
||||
<div class="step"><div class="n">5</div><div class="body"><strong>Search degrades gracefully.</strong> During the refresh, searches against workers_500k_v1 still work — they serve from the old embeddings. Brute-force cosine over new-rows-without-embeddings is allowed but costs more. HNSW rebuild happens after all embeds complete.</div></div>
|
||||
<div class="step"><div class="n">6</div><div class="body"><strong>Hot-swap promotion.</strong> When the new index is ready, <code>promotion_registry</code> atomically flips the active pointer. Next search hits the new config. Rollback stays available.</div></div>
|
||||
<div class="step"><div class="n">7</div><div class="body"><strong>Autotune re-enters the loop.</strong> The agent queue picks up a <code>DatasetAppended</code> trigger and schedules a fresh HNSW trial cycle against the expanded index.</div></div>
|
||||
|
||||
<h3>Known pain points at this scale</h3>
|
||||
<ul>
|
||||
<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> 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>
|
||||
|
||||
<!-- ═══ 8. ERROR SURFACES ═══ -->
|
||||
<div class="chapter" id="ch8">
|
||||
<div class="num">Chapter 8</div>
|
||||
<h2>Error surfaces & recovery</h2>
|
||||
<div class="lede">Every failure mode has a named surface, a structured response, and a recovery path. No silent failures.</div>
|
||||
|
||||
<table class="plain">
|
||||
<thead><tr><th>Failure mode</th><th>Surface / response</th><th>Recovery</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Ingest receives file with schema mismatch vs existing dataset</td><td><code>409 Conflict</code> with both fingerprints named (ADR-020)</td><td>Re-ingest under a new name, or migrate the existing via Phase 14 schema evolution</td></tr>
|
||||
<tr><td>Bucket unreachable on write</td><td>Hard 503, error journaled to <code>primary://_errors/bucket_errors/</code></td><td><code>GET /storage/errors</code> lists failures; <code>GET /storage/bucket-health</code> shows per-bucket status</td></tr>
|
||||
<tr><td>Bucket unreachable on read</td><td>Rescue bucket fallback, <code>X-Lakehouse-Rescue-Used: true</code> header on response</td><td>Response still succeeds; operator sees rescue flag</td></tr>
|
||||
<tr><td>/log receives name that doesn't exist in workers_500k</td><td>Seed is SKIPPED; response includes <code>rejected_ghost_names: [...]</code> and a note</td><td>Operator sees exactly which names were rejected and why</td></tr>
|
||||
<tr><td>Dual-agent executor malforms tool call</td><td>Result appended to log with <code>error</code> field; counter increments</td><td>After 3 consecutive: abort with full log dump at <code>tests/multi-agent/playbooks/<id>-FAILED.json</code></td></tr>
|
||||
<tr><td>Dual-agent drifts from target</td><td>Reviewer verdict = <code>drift</code>, counter increments</td><td>After 3 consecutive drifts: abort with full log</td></tr>
|
||||
<tr><td>Hybrid search finds zero candidates</td><td>Returns empty <code>sources[]</code> + <code>sql_matches: 0</code></td><td>Gap signal captured by scenario runner; operator prompted to broaden filter</td></tr>
|
||||
<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>
|
||||
|
||||
<!-- ═══ 9. PER-STAFFER CONTEXT ═══ -->
|
||||
<div class="chapter" id="ch9">
|
||||
<div class="num">Chapter 9</div>
|
||||
<h2>Per-staffer context</h2>
|
||||
<div class="lede">Twenty staffers don't see the same UI state. Each one's session is shaped by their active profile, their workspaces, their assigned contracts, and their client's blacklists.</div>
|
||||
|
||||
<h3>Active profile (Phase 17)</h3>
|
||||
<p>Scopes every search. A <code>staffing-recruiter</code> profile bound to <code>workers_500k</code> sees only that dataset. A <code>security-analyst</code> profile bound to <code>threat_intel</code> cannot see worker data. <code>GET /vectors/profile/<id>/audit</code> records every tool invocation by model identity.</p>
|
||||
|
||||
<h3>Workspace (Phase 8.5)</h3>
|
||||
<p>Per-contract state. Each workspace has daily/weekly/monthly tiers, saved searches, shortlists, activity logs. Survives across sessions. Instant zero-copy handoff between staffers — pointer swap, not data copy. Persisted to object storage, rebuilt on startup.</p>
|
||||
|
||||
<h3>Client blacklist</h3>
|
||||
<p>Per-client worker exclusion. Populated via <code>POST /clients/:client/blacklist</code>. Auto-applied when the caller passes <code>client: "X"</code> on <code>/search</code>. JSON-backed; would move to catalog table under real client load.</p>
|
||||
|
||||
<h3>Audit trail</h3>
|
||||
<p>Phase 12 tool registry logs every governed-action invocation (who called what, with what args, when, outcome). <code>GET /tools/audit</code> queryable. Phase 13 access control layers on top — role-based field masking, query audit log.</p>
|
||||
|
||||
<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 ═══ -->
|
||||
<div class="chapter" id="ch10">
|
||||
<div class="num">Chapter 10</div>
|
||||
<h2>A day in the life — from morning brief to EOD retrospective</h2>
|
||||
<div class="lede">Concrete operator timeline. Every step touches a real endpoint that exists today.</div>
|
||||
|
||||
<div class="step"><div class="n">07:00</div><div class="body"><strong>Overnight housekeeping.</strong> Scheduled ingest runs — the configured cron picks up the client's latest ATS CSV delta, runs it through the pipeline in Ch2, marks workers_500k embeddings stale. Autotune agent promotes any Pareto-winner HNSW configs from overnight trials.</div></div>
|
||||
|
||||
<div class="step"><div class="n">07:30</div><div class="body"><strong>Embedding refresh.</strong> Background job re-embeds the new rows. Old index keeps serving. Hot-swap promotes when done.</div></div>
|
||||
|
||||
<div class="step"><div class="n">08:00</div><div class="body"><strong>Sarah (staffer) opens devop.live/lakehouse.</strong> Page loads in ~3s. Forecast panel shows: "$275M construction coming, 4 tight roles this week." Live Contracts section shows 6 Chicago permits with proposed fills + boost chips + pattern signals.</div></div>
|
||||
|
||||
<div class="step"><div class="n">08:15</div><div class="body"><strong>Sarah drills into a $5M permit.</strong> Top candidate card: Carmen Green, Endorsed · 3 playbooks chip, boost +0.166, pattern line reads "leader archetype · 47% OSHA-10." Sarah hovers the chip — narrative tooltip: "filled Welder x2 in Toledo (2026-04-15), Welder x1 in Toledo (2026-04-18)."</div></div>
|
||||
|
||||
<div class="step"><div class="n">08:30</div><div class="body"><strong>Sarah calls Carmen.</strong> Clicks Call button → <code>/log</code> fires → <code>playbook_memory.seed</code> → <code>persist_sql</code> → successful_playbooks_live grows by one. Button flashes "Logged" for 1.4s. No modal, no form, no second click.</div></div>
|
||||
|
||||
<div class="step"><div class="n">09:00</div><div class="body"><strong>Kim (another staffer) opens the same UI.</strong> Her profile loads. Her workspaces show her own contracts. She searches "reliable forklift Chicago" — MEMORY chip shows the pattern discovered across Sarah's morning work AND prior fills. Carmen, already logged by Sarah, shows up with an updated citation count.</div></div>
|
||||
|
||||
<div class="step"><div class="n">12:30</div><div class="body"><strong>Client pushes 20 new contracts + 1M ATS delta.</strong> Ch7 scale flow fires. Ingest in seconds; embedding refresh kicks off as a background job. Searches continue against old embeddings.</div></div>
|
||||
|
||||
<div class="step"><div class="n">14:00</div><div class="body"><strong>Emergency: worker Dave no-showed.</strong> Sarah clicks No-show button on Dave's card → <code>/log_failure</code> → <code>mark_failed</code> records a penalty. Next similar query dampens Dave's boost by 0.5. Sarah continues the refill — the refill excludes Dave and the 2 others already booked for this shift.</div></div>
|
||||
|
||||
<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/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">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>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 11. LIMITS & NON-GOALS ═══ -->
|
||||
<div class="chapter" id="ch11">
|
||||
<div class="num">Chapter 11</div>
|
||||
<h2>Known limits & non-goals</h2>
|
||||
<div class="lede">Honesty is a feature. Everything below is either deferred or explicitly out of scope.</div>
|
||||
|
||||
<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. 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 (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>
|
||||
|
||||
<h4>Non-goals — explicitly out of scope</h4>
|
||||
<ul>
|
||||
<li><strong>Cloud deployment.</strong> Local-first by design. Works offline after setup.</li>
|
||||
<li><strong>Full ACID transactions.</strong> Single-writer model is sufficient; Delta Lake-grade MVCC is deliberately not attempted.</li>
|
||||
<li><strong>Real-time streaming / CDC.</strong> Batch ingest is the model. Scheduled refresh, not transactional replication.</li>
|
||||
<li><strong>Replacing the CRM.</strong> This is the analytical + AI layer <em>behind</em> the CRM. Operational CRUD stays with the existing system.</li>
|
||||
<li><strong>Custom file formats.</strong> Parquet for datasets, sidecar indexes for vectors. No proprietary formats (ADR-008, ADR-018 reaffirm).</li>
|
||||
<li><strong>Hard multi-tenant isolation.</strong> Profiles and federation provide soft isolation. Adversarial multi-tenant is not a goal — this system assumes a single-trust operator.</li>
|
||||
</ul>
|
||||
|
||||
<div class="narr">
|
||||
<strong>Overall bet.</strong> The substrate is conservative: Parquet + DataFusion + HNSW + Ollama + object storage. Every layer is replaceable, open, auditable. The intelligence layer (playbook_memory, patterns, autotune) is statistical, not neural — cheaper, explainable, rebuildable from the journal alone. If the statistical floor plateaus below what a real client needs, Phase 20+ adds neural re-rank on top. We don't make that call until measurement demands it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</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>
|
||||
@@ -0,0 +1,487 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Lakehouse — Workspaces</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
a{color:#58a6ff;text-decoration:none}
|
||||
a:hover{color:#79c0ff}
|
||||
|
||||
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
|
||||
.bar h1{font-size:14px;font-weight:600;color:#e6edf3;letter-spacing:-0.2px}
|
||||
.bar nav{display:flex;gap:2px;flex-wrap:wrap}
|
||||
.bar nav a{font-size:12px;color:#545d68;padding:6px 14px;border-radius:6px;transition:all 0.15s}
|
||||
.bar nav a:hover{color:#e6edf3;background:#161b22}
|
||||
.bar nav a.active{color:#e6edf3;background:#1c2333}
|
||||
|
||||
.wrap{max-width:1200px;margin:0 auto;padding:24px 20px 60px}
|
||||
|
||||
h2{color:#e6edf3;font-size:22px;font-weight:700;letter-spacing:-0.3px;margin-bottom:4px}
|
||||
.lede{color:#8b949e;font-size:13px;margin-bottom:18px;line-height:1.6;max-width:720px}
|
||||
|
||||
.layout{display:grid;grid-template-columns:320px 1fr;gap:16px}
|
||||
|
||||
.list{display:flex;flex-direction:column;gap:8px;max-height:calc(100vh - 200px);overflow-y:auto;padding-right:4px}
|
||||
.ws-card{background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:12px 14px;cursor:pointer;transition:all 0.15s}
|
||||
.ws-card:hover{border-color:#30363d;background:#161b22}
|
||||
.ws-card.active{border-color:#1f6feb;background:#0d1d38}
|
||||
.ws-card .name{color:#e6edf3;font-weight:600;font-size:13px;margin-bottom:4px;line-height:1.4}
|
||||
.ws-card .meta{color:#8b949e;font-size:11px}
|
||||
.ws-card .tier{display:inline-block;padding:1px 7px;border-radius:8px;font-size:9px;font-weight:600;margin-right:6px;letter-spacing:0.3px}
|
||||
.tier.daily{background:#2d1b00;color:#d29922;border:1px solid #854d0e}
|
||||
.tier.weekly{background:#0d2340;color:#58a6ff;border:1px solid #1f6feb80}
|
||||
.tier.monthly{background:#1e1047;color:#a78bfa;border:1px solid #5b21b680}
|
||||
.tier.pinned{background:#0d2818;color:#3fb950;border:1px solid #2ea04380}
|
||||
|
||||
.detail{background:#0d1117;border:1px solid #171d27;border-radius:12px;padding:18px;min-height:400px}
|
||||
|
||||
.section{margin-bottom:20px}
|
||||
.section-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
|
||||
.section-hdr .title{color:#545d68;font-size:11px;text-transform:uppercase;letter-spacing:1.2px;font-weight:600}
|
||||
.section-hdr .count{color:#8b949e;font-size:11px}
|
||||
|
||||
.entry{background:#161b22;border-radius:6px;padding:10px 12px;margin-bottom:6px;font-size:12px}
|
||||
.entry .title{color:#e6edf3;font-weight:500;font-size:13px}
|
||||
.entry .meta{color:#8b949e;font-size:11px;margin-top:2px}
|
||||
.entry .sub{color:#545d68;font-size:11px;margin-top:2px;font-family:ui-monospace,Menlo,monospace}
|
||||
|
||||
.empty{color:#484f58;font-size:12px;font-style:italic;padding:10px 12px}
|
||||
|
||||
.btn{padding:8px 14px;background:#1f6feb;border:none;border-radius:6px;color:#fff;font-size:12px;font-weight:600;cursor:pointer}
|
||||
.btn:hover{background:#388bfd}
|
||||
.btn:disabled{opacity:0.4;cursor:not-allowed}
|
||||
.btn.ghost{background:transparent;border:1px solid #21262d;color:#c9d1d9}
|
||||
.btn.ghost:hover{background:#161b22}
|
||||
.btn.green{background:#2ea043}
|
||||
.btn.green:hover{background:#3fb950}
|
||||
.btn.sm{padding:4px 10px;font-size:11px}
|
||||
|
||||
.form{display:flex;flex-direction:column;gap:10px}
|
||||
.form label{color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;margin-bottom:-6px}
|
||||
.form input,.form textarea,.form select{padding:8px 12px;background:#161b22;border:1px solid #21262d;border-radius:6px;color:#e6edf3;font-size:13px;outline:none;font-family:inherit}
|
||||
.form input:focus,.form textarea:focus,.form select:focus{border-color:#388bfd}
|
||||
.form textarea{resize:vertical;min-height:60px;font-family:inherit}
|
||||
.form-row{display:flex;gap:10px}
|
||||
.form-row>*{flex:1}
|
||||
|
||||
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:100;display:none;align-items:center;justify-content:center;padding:20px}
|
||||
.modal-backdrop.show{display:flex}
|
||||
.modal{background:#0d1117;border:1px solid #30363d;border-radius:12px;padding:22px;max-width:520px;width:100%;max-height:90vh;overflow-y:auto}
|
||||
.modal h3{color:#e6edf3;font-size:17px;font-weight:700;margin-bottom:12px}
|
||||
|
||||
.hdr-block{padding-bottom:14px;border-bottom:1px solid #171d27;margin-bottom:16px}
|
||||
.hdr-block .wname{color:#e6edf3;font-size:18px;font-weight:700;margin-bottom:4px}
|
||||
.hdr-block .wmeta{color:#8b949e;font-size:12px}
|
||||
.hdr-block .desc{color:#c9d1d9;font-size:13px;margin-top:8px;line-height:1.6}
|
||||
|
||||
.prev-owners{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
|
||||
.prev-owners .chip{padding:2px 8px;border-radius:10px;font-size:10px;background:#161b22;color:#8b949e;border:1px solid #21262d}
|
||||
|
||||
.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
|
||||
|
||||
.flash{padding:10px 14px;border-radius:6px;margin-top:10px;font-size:12px}
|
||||
.flash.ok{background:#0d2818;color:#86efac;border:1px solid #2ea04360}
|
||||
.flash.err{background:#3a1a1a;color:#fca5a5;border:1px solid #f8514960}
|
||||
|
||||
.activity{max-height:240px;overflow-y:auto}
|
||||
.activity .entry{border-left:2px solid #2ea043}
|
||||
|
||||
.footer{border-top:1px solid #171d27;padding:20px;text-align:center;color:#3d444d;font-size:11px}
|
||||
|
||||
@media(max-width:900px){
|
||||
.layout{grid-template-columns:1fr}
|
||||
.list{max-height:none}
|
||||
.bar nav{font-size:10px}
|
||||
}
|
||||
</style></head>
|
||||
<body>
|
||||
|
||||
<div class="bar">
|
||||
<h1>Lakehouse — Workspaces</h1>
|
||||
<nav>
|
||||
<a href=".">Dashboard</a>
|
||||
<a href="console">Walkthrough</a>
|
||||
<a href="proof">Architecture</a>
|
||||
<a href="spec">Spec</a>
|
||||
<a href="onboard">Onboard</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
<a href="workspaces" class="active">Workspaces</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<div style="margin-bottom:18px">
|
||||
<h2>Per-contract workspaces</h2>
|
||||
<div class="lede">
|
||||
Each contract gets its own workspace: saved searches, shortlisted candidates, activity log, tier (daily / weekly / monthly / pinned). Persisted across sessions. One-click zero-copy handoff between staffers — Sarah hands off to Kim, Kim opens the workspace and sees everything Sarah saw. This is the layer that keeps institutional memory per-contract, not per-person.
|
||||
</div>
|
||||
<button class="btn" id="new-ws-btn">New workspace</button>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<div class="list" id="ws-list"><div class="empty">Loading workspaces…</div></div>
|
||||
<div class="detail" id="ws-detail">
|
||||
<div class="empty">Select a workspace from the list, or create one.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div class="modal-backdrop" id="modal-new">
|
||||
<div class="modal">
|
||||
<h3>New workspace</h3>
|
||||
<form class="form" id="form-new" onsubmit="return false">
|
||||
<label>Contract / fill name</label>
|
||||
<input type="text" id="nw-name" placeholder="e.g. Riverfront Steel — Week 17 fills" required>
|
||||
<label>Description</label>
|
||||
<textarea id="nw-desc" placeholder="What's this contract about?"></textarea>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>Tier</label>
|
||||
<select id="nw-tier">
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly" selected>Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="pinned">Pinned</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Owner (staffer)</label>
|
||||
<input type="text" id="nw-owner" placeholder="sarah" value="sarah">
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn green" id="nw-create">Create</button>
|
||||
<button type="button" class="btn ghost" data-close="modal-new">Cancel</button>
|
||||
</div>
|
||||
<div id="nw-result"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="modal-handoff">
|
||||
<div class="modal">
|
||||
<h3>Hand off to another staffer</h3>
|
||||
<div style="color:#8b949e;font-size:12px;margin-bottom:14px;line-height:1.6">
|
||||
Transfers ownership of this workspace. The prior owner is preserved in <code>previous_owners</code> for the audit trail.
|
||||
The recipient sees all saved searches, shortlist entries, and activity from the prior owner — no copy, no migration.
|
||||
</div>
|
||||
<form class="form" onsubmit="return false">
|
||||
<label>New owner</label>
|
||||
<input type="text" id="ho-owner" placeholder="kim">
|
||||
<label>Reason</label>
|
||||
<input type="text" id="ho-reason" placeholder="e.g. end of shift, taking over for Sarah">
|
||||
<div class="actions">
|
||||
<button type="button" class="btn green" id="ho-submit">Hand off</button>
|
||||
<button type="button" class="btn ghost" data-close="modal-handoff">Cancel</button>
|
||||
</div>
|
||||
<div id="ho-result"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="modal-shortlist">
|
||||
<div class="modal">
|
||||
<h3>Shortlist a candidate</h3>
|
||||
<form class="form" onsubmit="return false">
|
||||
<label>Dataset</label>
|
||||
<input type="text" id="sl-dataset" placeholder="workers_500k" value="workers_500k">
|
||||
<label>Worker ID</label>
|
||||
<input type="text" id="sl-record" placeholder="e.g. 4661 or W500K-4661">
|
||||
<label>Notes</label>
|
||||
<textarea id="sl-notes" placeholder="Why this candidate — skills, past fills, availability window"></textarea>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn green" id="sl-submit">Add to shortlist</button>
|
||||
<button type="button" class="btn ghost" data-close="modal-shortlist">Cancel</button>
|
||||
</div>
|
||||
<div id="sl-result"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="modal-search">
|
||||
<div class="modal">
|
||||
<h3>Save a search</h3>
|
||||
<form class="form" onsubmit="return false">
|
||||
<label>Search name</label>
|
||||
<input type="text" id="sv-name" placeholder="e.g. Available Toledo welders with OSHA">
|
||||
<label>SQL (will run against the catalog via /query/sql)</label>
|
||||
<textarea id="sv-sql" placeholder="SELECT * FROM workers_500k WHERE role='Welder' AND city='Toledo' AND state='OH' AND CAST(availability AS DOUBLE) > 0.5 LIMIT 20"></textarea>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn green" id="sv-submit">Save search</button>
|
||||
<button type="button" class="btn ghost" data-close="modal-search">Cancel</button>
|
||||
</div>
|
||||
<div id="sv-result"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="modal-activity">
|
||||
<div class="modal">
|
||||
<h3>Log activity</h3>
|
||||
<form class="form" onsubmit="return false">
|
||||
<label>Action</label>
|
||||
<input type="text" id="act-action" placeholder="e.g. called, emailed, shortlisted, rejected">
|
||||
<label>Detail</label>
|
||||
<textarea id="act-detail" placeholder="What happened and what's the next step"></textarea>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn green" id="act-submit">Log activity</button>
|
||||
<button type="button" class="btn ghost" data-close="modal-activity">Cancel</button>
|
||||
</div>
|
||||
<div id="act-result"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">Lakehouse · workspaces · per-contract state, zero-copy handoff (Phase 8.5)</div>
|
||||
|
||||
<script>
|
||||
var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
|
||||
var A=location.origin+P;
|
||||
|
||||
var state={list:[], current:null, currentAgent:'sarah'};
|
||||
|
||||
function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!==undefined&&x!==null)e.textContent=String(x);return e}
|
||||
|
||||
function apiGet(p){
|
||||
return fetch(A+'/api'+p).then(function(r){return r.ok?r.json():r.text().then(function(b){throw new Error('HTTP '+r.status+': '+b)})});
|
||||
}
|
||||
function apiPost(p,body){
|
||||
return fetch(A+'/api'+p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})}).then(function(r){
|
||||
return r.text().then(function(t){
|
||||
if(!r.ok) throw new Error('HTTP '+r.status+': '+t.slice(0,200));
|
||||
try{return JSON.parse(t)}catch(e){return {raw:t}}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showModal(id){ document.getElementById(id).classList.add('show') }
|
||||
function hideModal(id){ document.getElementById(id).classList.remove('show') }
|
||||
|
||||
function loadList(preselectId){
|
||||
apiGet('/workspaces').then(function(arr){
|
||||
if(!Array.isArray(arr)) arr=[];
|
||||
state.list=arr;
|
||||
renderList();
|
||||
if(preselectId){ openWorkspace(preselectId); }
|
||||
else if(arr.length>0 && !state.current){ openWorkspace(arr[0].id); }
|
||||
}).catch(function(e){
|
||||
var host=document.getElementById('ws-list');host.textContent='';
|
||||
host.appendChild(el('div','empty','Failed to load: '+e.message));
|
||||
});
|
||||
}
|
||||
|
||||
function renderList(){
|
||||
var host=document.getElementById('ws-list');host.textContent='';
|
||||
if(state.list.length===0){
|
||||
host.appendChild(el('div','empty','No workspaces yet. Create one to get started.'));
|
||||
return;
|
||||
}
|
||||
state.list.slice().sort(function(a,b){
|
||||
return (b.updated_at||'').localeCompare(a.updated_at||'');
|
||||
}).forEach(function(w){
|
||||
var card=el('div','ws-card'+(state.current&&state.current.id===w.id?' active':''));
|
||||
card.onclick=function(){ openWorkspace(w.id); };
|
||||
card.appendChild(el('div','name',w.name||'(unnamed)'));
|
||||
var meta=el('div','meta');
|
||||
meta.appendChild(el('span','tier '+(w.tier||'weekly'),(w.tier||'weekly').toUpperCase()));
|
||||
meta.appendChild(document.createTextNode((w.owner||'?')+' · '+((w.shortlist||[]).length)+' shortlisted · '+((w.activity||[]).length)+' events'));
|
||||
card.appendChild(meta);
|
||||
host.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function openWorkspace(id){
|
||||
apiGet('/workspaces/'+encodeURIComponent(id)).then(function(w){
|
||||
if(!w||w.error){ throw new Error(w&&w.error?w.error:'not found'); }
|
||||
state.current=w;
|
||||
renderList();
|
||||
renderDetail();
|
||||
}).catch(function(e){
|
||||
var host=document.getElementById('ws-detail');host.textContent='';
|
||||
host.appendChild(el('div','empty','Failed to load: '+e.message));
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetail(){
|
||||
var host=document.getElementById('ws-detail');host.textContent='';
|
||||
var w=state.current;
|
||||
if(!w){ host.appendChild(el('div','empty','Select a workspace from the list, or create one.')); return; }
|
||||
|
||||
// Header
|
||||
var hdr=el('div','hdr-block');
|
||||
hdr.appendChild(el('div','wname',w.name||'(unnamed)'));
|
||||
var meta=el('div','wmeta');
|
||||
meta.appendChild(el('span','tier '+(w.tier||'weekly'),(w.tier||'weekly').toUpperCase()));
|
||||
meta.appendChild(document.createTextNode(' owner: '+(w.owner||'?')+' · created '+(w.created_at||'').slice(0,10)+' · updated '+(w.updated_at||'').slice(0,10)));
|
||||
hdr.appendChild(meta);
|
||||
if(w.description){ hdr.appendChild(el('div','desc',w.description)); }
|
||||
if((w.previous_owners||[]).length>0){
|
||||
var pos=el('div','prev-owners');
|
||||
pos.appendChild(el('span','chip','Previous owners:'));
|
||||
w.previous_owners.forEach(function(p){ pos.appendChild(el('span','chip',p)); });
|
||||
hdr.appendChild(pos);
|
||||
}
|
||||
// Action buttons
|
||||
var actions=el('div','actions');
|
||||
var handoffBtn=el('button','btn','Hand off');
|
||||
handoffBtn.onclick=function(){ showModal('modal-handoff'); };
|
||||
actions.appendChild(handoffBtn);
|
||||
var slBtn=el('button','btn ghost','Shortlist candidate');
|
||||
slBtn.onclick=function(){ showModal('modal-shortlist'); };
|
||||
actions.appendChild(slBtn);
|
||||
var svBtn=el('button','btn ghost','Save search');
|
||||
svBtn.onclick=function(){ showModal('modal-search'); };
|
||||
actions.appendChild(svBtn);
|
||||
var actBtn=el('button','btn ghost','Log activity');
|
||||
actBtn.onclick=function(){ showModal('modal-activity'); };
|
||||
actions.appendChild(actBtn);
|
||||
hdr.appendChild(actions);
|
||||
host.appendChild(hdr);
|
||||
|
||||
// Shortlist
|
||||
host.appendChild(sectionHeader('Shortlist',(w.shortlist||[]).length+' candidates'));
|
||||
if((w.shortlist||[]).length===0){
|
||||
host.appendChild(el('div','empty','No candidates shortlisted yet.'));
|
||||
} else {
|
||||
w.shortlist.forEach(function(sl){
|
||||
var e=el('div','entry');
|
||||
e.appendChild(el('div','title',(sl.dataset||'?')+' · '+(sl.record_id||'?')));
|
||||
if(sl.notes) e.appendChild(el('div','meta',sl.notes));
|
||||
e.appendChild(el('div','sub','by '+(sl.by_agent||sl.agent||'?')+' · '+(sl.at||sl.added_at||'').slice(0,16).replace('T',' ')));
|
||||
host.appendChild(e);
|
||||
});
|
||||
}
|
||||
|
||||
// Saved searches
|
||||
host.appendChild(sectionHeader('Saved searches',(w.saved_searches||[]).length+' queries'));
|
||||
if((w.saved_searches||[]).length===0){
|
||||
host.appendChild(el('div','empty','No saved searches yet.'));
|
||||
} else {
|
||||
w.saved_searches.forEach(function(sv){
|
||||
var e=el('div','entry');
|
||||
e.appendChild(el('div','title',sv.name||'(unnamed)'));
|
||||
if(sv.sql) e.appendChild(el('div','sub',sv.sql));
|
||||
e.appendChild(el('div','sub','by '+(sv.by_agent||sv.agent||'?')+' · '+(sv.at||sv.added_at||'').slice(0,16).replace('T',' ')));
|
||||
host.appendChild(e);
|
||||
});
|
||||
}
|
||||
|
||||
// Activity
|
||||
host.appendChild(sectionHeader('Activity',(w.activity||[]).length+' events'));
|
||||
if((w.activity||[]).length===0){
|
||||
host.appendChild(el('div','empty','No activity logged yet.'));
|
||||
} else {
|
||||
var act=el('div','activity');
|
||||
w.activity.slice().reverse().forEach(function(a){
|
||||
var e=el('div','entry');
|
||||
e.appendChild(el('div','title',a.action||'(no action)'));
|
||||
if(a.detail) e.appendChild(el('div','meta',a.detail));
|
||||
e.appendChild(el('div','sub','by '+(a.by_agent||a.agent||'?')+' · '+(a.at||'').slice(0,16).replace('T',' ')));
|
||||
act.appendChild(e);
|
||||
});
|
||||
host.appendChild(act);
|
||||
}
|
||||
}
|
||||
|
||||
function sectionHeader(title,count){
|
||||
var s=el('div','section-hdr');
|
||||
s.appendChild(el('span','title',title));
|
||||
s.appendChild(el('span','count',count));
|
||||
return s;
|
||||
}
|
||||
|
||||
function flash(id,text,kind){
|
||||
var box=document.getElementById(id);
|
||||
box.textContent='';
|
||||
box.appendChild(el('div','flash '+(kind||'ok'),text));
|
||||
}
|
||||
|
||||
function createWorkspace(){
|
||||
var name=document.getElementById('nw-name').value.trim();
|
||||
var desc=document.getElementById('nw-desc').value.trim();
|
||||
var tier=document.getElementById('nw-tier').value;
|
||||
var owner=document.getElementById('nw-owner').value.trim()||'sarah';
|
||||
if(!name){ flash('nw-result','Name is required','err');return; }
|
||||
apiPost('/workspaces/create',{name:name,description:desc,owner:owner,tier:tier}).then(function(r){
|
||||
state.currentAgent=owner;
|
||||
hideModal('modal-new');
|
||||
document.getElementById('nw-name').value='';
|
||||
document.getElementById('nw-desc').value='';
|
||||
loadList(r.id);
|
||||
}).catch(function(e){ flash('nw-result',e.message,'err'); });
|
||||
}
|
||||
|
||||
function submitHandoff(){
|
||||
var w=state.current;if(!w)return;
|
||||
var to=document.getElementById('ho-owner').value.trim();
|
||||
var reason=document.getElementById('ho-reason').value.trim();
|
||||
if(!to){ flash('ho-result','New owner required','err');return; }
|
||||
apiPost('/workspaces/'+encodeURIComponent(w.id)+'/handoff',{to_agent:to,reason:reason}).then(function(){
|
||||
state.currentAgent=to;
|
||||
hideModal('modal-handoff');
|
||||
loadList(w.id);
|
||||
}).catch(function(e){ flash('ho-result',e.message,'err'); });
|
||||
}
|
||||
|
||||
function submitShortlist(){
|
||||
var w=state.current;if(!w)return;
|
||||
var dataset=document.getElementById('sl-dataset').value.trim()||'workers_500k';
|
||||
var record=document.getElementById('sl-record').value.trim();
|
||||
var notes=document.getElementById('sl-notes').value.trim();
|
||||
if(!record){ flash('sl-result','Worker ID required','err');return; }
|
||||
apiPost('/workspaces/'+encodeURIComponent(w.id)+'/shortlist',{dataset:dataset,record_id:record,notes:notes,agent:state.currentAgent}).then(function(){
|
||||
hideModal('modal-shortlist');
|
||||
document.getElementById('sl-record').value='';
|
||||
document.getElementById('sl-notes').value='';
|
||||
loadList(w.id);
|
||||
}).catch(function(e){ flash('sl-result',e.message,'err'); });
|
||||
}
|
||||
|
||||
function submitSaveSearch(){
|
||||
var w=state.current;if(!w)return;
|
||||
var name=document.getElementById('sv-name').value.trim();
|
||||
var sql=document.getElementById('sv-sql').value.trim();
|
||||
if(!name||!sql){ flash('sv-result','Name and SQL are required','err');return; }
|
||||
apiPost('/workspaces/'+encodeURIComponent(w.id)+'/search',{name:name,sql:sql,agent:state.currentAgent}).then(function(){
|
||||
hideModal('modal-search');
|
||||
document.getElementById('sv-name').value='';
|
||||
document.getElementById('sv-sql').value='';
|
||||
loadList(w.id);
|
||||
}).catch(function(e){ flash('sv-result',e.message,'err'); });
|
||||
}
|
||||
|
||||
function submitActivity(){
|
||||
var w=state.current;if(!w)return;
|
||||
var action=document.getElementById('act-action').value.trim();
|
||||
var detail=document.getElementById('act-detail').value.trim();
|
||||
if(!action){ flash('act-result','Action required','err');return; }
|
||||
apiPost('/workspaces/'+encodeURIComponent(w.id)+'/activity',{action:action,detail:detail,agent:state.currentAgent}).then(function(){
|
||||
hideModal('modal-activity');
|
||||
document.getElementById('act-action').value='';
|
||||
document.getElementById('act-detail').value='';
|
||||
loadList(w.id);
|
||||
}).catch(function(e){ flash('act-result',e.message,'err'); });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded',function(){
|
||||
document.getElementById('new-ws-btn').addEventListener('click',function(){ showModal('modal-new'); });
|
||||
document.getElementById('nw-create').addEventListener('click',createWorkspace);
|
||||
document.getElementById('ho-submit').addEventListener('click',submitHandoff);
|
||||
document.getElementById('sl-submit').addEventListener('click',submitShortlist);
|
||||
document.getElementById('sv-submit').addEventListener('click',submitSaveSearch);
|
||||
document.getElementById('act-submit').addEventListener('click',submitActivity);
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-close]'),function(b){
|
||||
b.addEventListener('click',function(){ hideModal(b.getAttribute('data-close')); });
|
||||
});
|
||||
// Click-outside-to-close
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.modal-backdrop'),function(m){
|
||||
m.addEventListener('click',function(e){ if(e.target===m) m.classList.remove('show'); });
|
||||
});
|
||||
loadList();
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# A/B test of T3 overseer: does it actually make subsequent runs better?
|
||||
# Chains Run B (T3 seed) → Run C (T3 + read-back) → Run D (T3 cloud).
|
||||
# Run A is assumed already complete (launched separately). Aggregates
|
||||
# metrics at the end into ab_scorecard.json.
|
||||
|
||||
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'])")"
|
||||
|
||||
echo "▶ A/B test start at $(date -Iseconds)"
|
||||
echo "▶ prior lessons dir: $(ls data/_playbook_lessons 2>/dev/null | wc -l) files"
|
||||
|
||||
# Run B — T3 enabled local, no prior lessons should exist yet
|
||||
echo "──── RUN B: T3 local, seeds first lesson ────"
|
||||
bun tests/multi-agent/scenario.ts > /tmp/lakehouse_ab_B.log 2>&1 || true
|
||||
echo " B exit=$?"
|
||||
ls data/_playbook_lessons/*.json 2>/dev/null | head -5
|
||||
|
||||
# Run C — T3 enabled local, B's lesson should load
|
||||
echo "──── RUN C: T3 local, reads B's lesson ────"
|
||||
bun tests/multi-agent/scenario.ts > /tmp/lakehouse_ab_C.log 2>&1 || true
|
||||
echo " C exit=$?"
|
||||
|
||||
# Run D — T3 enabled CLOUD (gpt-oss:120b), reads B+C lessons
|
||||
echo "──── RUN D: T3 cloud, reads B+C lessons ────"
|
||||
LH_OVERVIEW_CLOUD=1 bun tests/multi-agent/scenario.ts > /tmp/lakehouse_ab_D.log 2>&1 || true
|
||||
echo " D exit=$?"
|
||||
|
||||
echo "▶ all runs done at $(date -Iseconds)"
|
||||
echo "▶ scorecard:"
|
||||
ls -1dt tests/multi-agent/playbooks/scenario-* | head -4 | tac | python3 -c "
|
||||
import sys, os, json
|
||||
|
||||
runs = [l.strip() for l in sys.stdin if l.strip()]
|
||||
labels = ['A(no-T3)','B(T3-seed)','C(T3-read)','D(T3-cloud)']
|
||||
# Prepend Run A: most recent BEFORE the ab_t3_test kicked off is Run A
|
||||
# (launched separately). But we only picked up the most recent 4 runs.
|
||||
# Actually: ab_t3_test runs B/C/D, so recent 3 = B,C,D. Run A is the one
|
||||
# BEFORE those — find it separately.
|
||||
# Reread to include Run A:
|
||||
import subprocess
|
||||
all_runs = subprocess.check_output(['bash','-c','ls -1dt tests/multi-agent/playbooks/scenario-* | head -8']).decode().strip().split('\n')
|
||||
# The 4 most recent are D, C, B, A (reverse chronological).
|
||||
top4 = list(reversed(all_runs[:4])) # oldest first → A,B,C,D
|
||||
rows = []
|
||||
for i, path in enumerate(top4):
|
||||
try:
|
||||
results = json.load(open(os.path.join(path, 'results.json')))
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
ok = sum(1 for r in results if r.get('ok'))
|
||||
turns = sum(r.get('turns', 0) for r in results)
|
||||
gaps = sum(len(r.get('gap_signals', [])) for r in results)
|
||||
cites = sum(len(r.get('playbook_citations') or []) for r in results)
|
||||
prior = []
|
||||
try:
|
||||
prior = json.load(open(os.path.join(path, 'prior_lessons.json')))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
rows.append({
|
||||
'label': labels[i] if i < len(labels) else f'run{i}',
|
||||
'path': path,
|
||||
'ok_events': ok,
|
||||
'total_events': len(results),
|
||||
'total_turns': turns,
|
||||
'total_gaps': gaps,
|
||||
'total_citations': cites,
|
||||
'prior_lessons_loaded': len(prior),
|
||||
})
|
||||
|
||||
scorecard = {'generated_at': __import__('datetime').datetime.utcnow().isoformat()+'Z', 'runs': rows}
|
||||
open('tests/multi-agent/playbooks/ab_scorecard.json','w').write(json.dumps(scorecard, indent=2))
|
||||
print(json.dumps(scorecard, indent=2))
|
||||
"
|
||||
echo "▶ saved: tests/multi-agent/playbooks/ab_scorecard.json"
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Aggregate KB state for item 3 decision.
|
||||
|
||||
Reads data/_kb/*.jsonl and tests/multi-agent/playbooks/*/results.json
|
||||
to answer:
|
||||
- How many distinct signatures exist?
|
||||
- Total runs, avg ok rate, avg citations per event?
|
||||
- Which (role, city) combos have NEVER gotten a citation?
|
||||
- Recommender confidence progression (cold → medium → high)?
|
||||
- Mean turn count trend across runs (proxy for efficiency).
|
||||
|
||||
Run after `scripts/run_kb_batch.sh` completes. Writes a markdown
|
||||
summary to tests/multi-agent/playbooks/kb_measurement.md and prints
|
||||
to stdout.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
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():
|
||||
sigs = load_jsonl(KB / "signatures.jsonl")
|
||||
outcomes = load_jsonl(KB / "outcomes.jsonl")
|
||||
recs = load_jsonl(KB / "pathway_recommendations.jsonl")
|
||||
corrections = load_jsonl(KB / "error_corrections.jsonl")
|
||||
|
||||
# --- Basic counts ---
|
||||
print(f"Signatures: {len(sigs)}")
|
||||
print(f"Outcomes: {len(outcomes)}")
|
||||
print(f"Recommendations: {len(recs)}")
|
||||
print(f"Error corrections: {len(corrections)}")
|
||||
print()
|
||||
|
||||
# --- Recommender confidence progression ---
|
||||
conf_counts = Counter(r.get("confidence", "?") for r in recs)
|
||||
print(f"Recommender confidence distribution:")
|
||||
for c in ("high", "medium", "low"):
|
||||
print(f" {c:8s}: {conf_counts.get(c, 0)}")
|
||||
print()
|
||||
|
||||
# Time-ordered confidence
|
||||
recs_sorted = sorted(recs, key=lambda r: r.get("generated_at", ""))
|
||||
neighbor_counts = [len(r.get("neighbors_consulted", [])) for r in recs_sorted]
|
||||
if neighbor_counts:
|
||||
print(f"Neighbors consulted over time (first → last):")
|
||||
print(f" first 3: {neighbor_counts[:3]}")
|
||||
print(f" last 3: {neighbor_counts[-3:]}")
|
||||
print(f" max: {max(neighbor_counts)}")
|
||||
print()
|
||||
|
||||
# --- Fill rate + citation density per run ---
|
||||
if outcomes:
|
||||
total_ok = sum(o["ok_events"] for o in outcomes)
|
||||
total_events = sum(o["total_events"] for o in outcomes)
|
||||
total_cites = sum(o.get("total_citations", 0) for o in outcomes)
|
||||
total_turns = sum(o.get("total_turns", 0) for o in outcomes)
|
||||
print(f"Fill rate: {total_ok}/{total_events} = {100*total_ok/max(1,total_events):.1f}%")
|
||||
print(f"Avg citations per run: {total_cites/len(outcomes):.2f}")
|
||||
print(f"Avg turns per run: {total_turns/len(outcomes):.1f}")
|
||||
print()
|
||||
|
||||
# First 5 runs vs last 5 — does it get better?
|
||||
sorted_out = sorted(outcomes, key=lambda o: o.get("created_at", ""))
|
||||
if len(sorted_out) >= 10:
|
||||
first = sorted_out[:5]
|
||||
last = sorted_out[-5:]
|
||||
fok = sum(o["ok_events"] for o in first) / sum(o["total_events"] for o in first)
|
||||
lok = sum(o["ok_events"] for o in last) / sum(o["total_events"] for o in last)
|
||||
fcit = sum(o.get("total_citations", 0) for o in first) / 5
|
||||
lcit = sum(o.get("total_citations", 0) for o in last) / 5
|
||||
print(f"First 5 runs ok rate: {100*fok:.1f}% avg cites: {fcit:.2f}")
|
||||
print(f"Last 5 runs ok rate: {100*lok:.1f}% avg cites: {lcit:.2f}")
|
||||
print()
|
||||
|
||||
# --- Per-(role, city) citation coverage ---
|
||||
cite_by_combo = Counter()
|
||||
combo_attempts = Counter()
|
||||
for o in outcomes:
|
||||
for ev in o.get("per_event", []):
|
||||
key = (ev.get("role", "?"), "?") # city not in per_event summary
|
||||
combo_attempts[key] += 1
|
||||
# Read the playbook dirs for full event detail (has city)
|
||||
cites_by_role_city = defaultdict(lambda: {"attempts": 0, "citations": 0, "ok": 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
|
||||
for r in results:
|
||||
e = r.get("event", {})
|
||||
key = (e.get("role"), e.get("city"), e.get("state"))
|
||||
cites_by_role_city[key]["attempts"] += 1
|
||||
cites_by_role_city[key]["citations"] += len(r.get("playbook_citations") or [])
|
||||
if r.get("ok"):
|
||||
cites_by_role_city[key]["ok"] += 1
|
||||
|
||||
combos_with_cites = [(k, v) for k, v in cites_by_role_city.items() if v["citations"] > 0]
|
||||
combos_zero_cites = [(k, v) for k, v in cites_by_role_city.items() if v["citations"] == 0 and v["ok"] > 0]
|
||||
print(f"(role, city, state) combos with any citation: {len(combos_with_cites)}")
|
||||
print(f"(role, city, state) combos with ok fills but 0 cites: {len(combos_zero_cites)}")
|
||||
print()
|
||||
if combos_with_cites:
|
||||
print("Top 10 combos by citation count:")
|
||||
for (role, city, state), v in sorted(combos_with_cites, key=lambda x: -x[1]["citations"])[:10]:
|
||||
print(f" {role:25s} {city:15s} {state}: {v['citations']} cites across {v['attempts']} attempts ({v['ok']} ok)")
|
||||
print()
|
||||
|
||||
# --- Write markdown report ---
|
||||
lines = ["# KB Measurement Report", ""]
|
||||
lines.append(f"Generated from {len(outcomes)} runs across {len(sigs)} distinct signatures.")
|
||||
lines.append("")
|
||||
lines.append("## Recommender confidence")
|
||||
for c in ("high", "medium", "low"):
|
||||
lines.append(f"- {c}: {conf_counts.get(c, 0)}")
|
||||
lines.append("")
|
||||
lines.append("## Overall fill + citation")
|
||||
if outcomes:
|
||||
lines.append(f"- Fill rate: **{total_ok}/{total_events}** ({100*total_ok/max(1,total_events):.1f}%)")
|
||||
lines.append(f"- Avg citations per run: **{total_cites/len(outcomes):.2f}**")
|
||||
lines.append(f"- Avg turns per run: {total_turns/len(outcomes):.1f}")
|
||||
lines.append("")
|
||||
lines.append("## Citation coverage by (role, city, state)")
|
||||
lines.append(f"- Combos with ≥1 citation: {len(combos_with_cites)}")
|
||||
lines.append(f"- Combos with ok fills but 0 citations: {len(combos_zero_cites)}")
|
||||
lines.append("")
|
||||
lines.append("## Item 3 decision signal")
|
||||
if combos_zero_cites:
|
||||
lines.append("Non-zero: there are **combos that succeeded but never triggered playbook_memory boost**. Candidates for item 3 investigation:")
|
||||
for (role, city, state), v in combos_zero_cites[:5]:
|
||||
lines.append(f"- {role} in {city}, {state}: {v['ok']}/{v['attempts']} ok, 0 cites")
|
||||
else:
|
||||
lines.append("All ok combos got at least some citation firing. Boost mechanism is healthy; raising the cap may help but isn't forced.")
|
||||
lines.append("")
|
||||
out = PLAYBOOKS / "kb_measurement.md"
|
||||
out.write_text("\n".join(lines))
|
||||
print(f"✓ markdown report → {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run all generated scenarios sequentially to populate the KB.
|
||||
# Reads tests/multi-agent/scenarios/manifest.json and feeds each file
|
||||
# to scenario.ts. Each scenario indexes into data/_kb/ automatically
|
||||
# via the end-of-run hook. Exit code: 0 if all scenarios completed
|
||||
# (event failures are NOT failures for the batch — we want the KB to
|
||||
# record both successes AND failures).
|
||||
|
||||
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/manifest.json"
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "✗ no manifest at $MANIFEST — run: bun tests/multi-agent/gen_scenarios.ts <N>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
START_TS=$(date -Iseconds)
|
||||
LOG_DIR="/tmp/lakehouse_kb_batch_$(date +%s)"
|
||||
mkdir -p "$LOG_DIR"
|
||||
echo "▶ KB batch start: $START_TS, logs → $LOG_DIR"
|
||||
|
||||
python3 -c "
|
||||
import json
|
||||
m = json.load(open('$MANIFEST'))
|
||||
for s in m['scenarios']:
|
||||
print(s['file'])
|
||||
" | while read -r SCEN; do
|
||||
SPEC="tests/multi-agent/scenarios/$SCEN"
|
||||
BASE=$(basename "$SPEC" .json)
|
||||
LOG="$LOG_DIR/${BASE}.log"
|
||||
echo " ▶ $SCEN"
|
||||
bun tests/multi-agent/scenario.ts "$SPEC" > "$LOG" 2>&1 || true
|
||||
OK=$(grep -oP '\d+/\d+ events succeeded' "$LOG" | tail -1 || echo "no-result")
|
||||
SIG=$(grep -oP 'KB indexed: sig=\K[a-f0-9]+' "$LOG" | tail -1 || echo "-")
|
||||
echo " → $OK; sig=$SIG"
|
||||
done
|
||||
|
||||
echo "▶ KB batch done: $(date -Iseconds)"
|
||||
echo "▶ KB state:"
|
||||
wc -l data/_kb/*.jsonl 2>/dev/null || true
|
||||
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)"
|
||||
@@ -16,6 +16,10 @@ class GenerateRequest(BaseModel):
|
||||
system: str | None = None
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 2048
|
||||
# think=false disables hidden reasoning blocks on thinking models
|
||||
# (qwen3, qwen3.5, gpt-oss). Required for hot-path JSON emitters
|
||||
# that need the whole token budget for the visible response.
|
||||
think: bool | None = None
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
@@ -40,6 +44,8 @@ async def generate(req: GenerateRequest):
|
||||
}
|
||||
if req.system:
|
||||
payload["system"] = req.system
|
||||
if req.think is not None:
|
||||
payload["think"] = req.think
|
||||
|
||||
async with client() as c:
|
||||
resp = await c.post("/api/generate", json=payload)
|
||||
|
||||
+366
-19
@@ -9,6 +9,272 @@
|
||||
export const GATEWAY = "http://localhost:3100";
|
||||
export const SIDECAR = "http://localhost:3200";
|
||||
|
||||
// Ollama Cloud — used for the T3 overview tier when LH_OVERVIEW_CLOUD=1.
|
||||
// Same /api/generate surface as local Ollama; just needs the bearer key.
|
||||
// Default base and key are read from env so secrets never land in git.
|
||||
export const OLLAMA_CLOUD_URL = process.env.OLLAMA_CLOUD_URL ?? "https://ollama.com";
|
||||
export const OLLAMA_CLOUD_KEY = process.env.OLLAMA_CLOUD_KEY ?? "";
|
||||
|
||||
// Rough token estimator — chars/4 biased safe by ~15%. Swap to a real
|
||||
// tokenizer (tiktoken or provider endpoint) once Phase 21 lands. Good
|
||||
// enough to stop the "context silently truncated" failure mode today.
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Scratchpad + tree-split continuation
|
||||
// ============================================================
|
||||
//
|
||||
// Core problem: when a prompt OR response would exceed a model's window
|
||||
// — e.g. 200 playbooks pasted in, or a long propose_done payload — we
|
||||
// cannot just raise max_tokens forever. That's the tourniquet approach
|
||||
// J explicitly rejected. The right answer is: split into sub-calls
|
||||
// glued by a persistent scratchpad, so no context is ever lost.
|
||||
//
|
||||
// Two primitives:
|
||||
//
|
||||
// 1. generateContinuable(): for OUTPUT overflow. If the model returns
|
||||
// a structurally-incomplete JSON (unmatched braces, truncated
|
||||
// mid-value), auto-continue with the partial as scratchpad until
|
||||
// the JSON parses or max_continuations is hit.
|
||||
//
|
||||
// 2. generateTreeSplit(): for INPUT overflow. If prompt + system +
|
||||
// max_tokens exceeds window, shard the input at semantic boundaries,
|
||||
// run each shard through the model with a running scratchpad digest,
|
||||
// then run a final reduce pass to combine. This is map-reduce with
|
||||
// glue.
|
||||
//
|
||||
// Both are tier-agnostic — they wrap generate() and generateCloud().
|
||||
|
||||
// Check structural completeness of a response. For non-JSON responses
|
||||
// (lesson text, checkpoint hints) we treat "non-empty" as complete.
|
||||
// For JSON-shaped responses (executor/reviewer actions) we check that
|
||||
// braces match AND that JSON.parse succeeds on the first {...} block.
|
||||
function isStructurallyComplete(text: string, shape: "json" | "text"): boolean {
|
||||
if (!text || !text.trim()) return false;
|
||||
if (shape === "text") return true;
|
||||
// Strip ``` fences
|
||||
let s = text.trim();
|
||||
if (s.startsWith("```")) s = s.replace(/^```(?:json)?\n?/, "").replace(/```$/, "").trim();
|
||||
const start = s.indexOf("{");
|
||||
const end = s.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) return false;
|
||||
const slice = s.slice(start, end + 1);
|
||||
// Balance check — cheaper than JSON.parse and catches truncated nests
|
||||
let depth = 0, inStr = false, esc = false;
|
||||
for (const c of slice) {
|
||||
if (esc) { esc = false; continue; }
|
||||
if (c === "\\") { esc = true; continue; }
|
||||
if (c === '"') { inStr = !inStr; continue; }
|
||||
if (inStr) continue;
|
||||
if (c === "{") depth++;
|
||||
else if (c === "}") depth--;
|
||||
}
|
||||
if (depth !== 0) return false;
|
||||
try { JSON.parse(slice); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// Continue a truncated response. We do NOT ask the model to start over —
|
||||
// we ask it to continue from exactly where it stopped. The partial goes
|
||||
// in as scratchpad so it knows what's already committed.
|
||||
async function continueResponse(
|
||||
model: string,
|
||||
originalPrompt: string,
|
||||
partial: string,
|
||||
opts: { max_tokens: number; temperature: number; system?: string; cloud: boolean; think?: boolean },
|
||||
): Promise<string> {
|
||||
const continuationPrompt = `${originalPrompt}
|
||||
|
||||
PARTIAL RESPONSE SO FAR (continue from here — do NOT restart, do NOT repeat what's already there, emit ONLY the remaining tokens to close the structure):
|
||||
${partial}`;
|
||||
const fn = opts.cloud ? generateCloud : generate;
|
||||
const rest = await fn(model, continuationPrompt, {
|
||||
max_tokens: opts.max_tokens,
|
||||
temperature: opts.temperature,
|
||||
system: opts.system,
|
||||
bypass_budget: true, // the caller already checked; continuation doesn't double-count
|
||||
think: opts.think,
|
||||
});
|
||||
return partial + rest;
|
||||
}
|
||||
|
||||
// Output-overflow handler. Handles two distinct failure modes:
|
||||
// (a) EMPTY response — thinking model ate the whole budget. Retry the
|
||||
// ORIGINAL prompt with 2x the budget (geometric backoff up to cap).
|
||||
// (b) TRUNCATED non-empty — model got most of the way there but hit
|
||||
// max_tokens before closing. Continue with the partial as
|
||||
// scratchpad.
|
||||
// The two modes need different repair: (a) needs more budget, not
|
||||
// continuation from ""; (b) needs scratchpad-glued continuation.
|
||||
export async function generateContinuable(
|
||||
model: string,
|
||||
prompt: string,
|
||||
opts: {
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
system?: string;
|
||||
shape?: "json" | "text";
|
||||
max_continuations?: number;
|
||||
cloud?: boolean;
|
||||
think?: boolean;
|
||||
on_continuation?: (n: number, combined_len: number) => void;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const shape = opts.shape ?? "json";
|
||||
const initialMax = opts.max_tokens ?? 800;
|
||||
const maxConts = opts.max_continuations ?? 3;
|
||||
const cloud = opts.cloud ?? false;
|
||||
const budgetCap = 8000; // don't geometric-backoff forever
|
||||
const fn = cloud ? generateCloud : generate;
|
||||
|
||||
let combined = "";
|
||||
let currentMax = initialMax;
|
||||
|
||||
// Initial call + empty-response backoff loop.
|
||||
for (let retry = 0; retry < 3; retry++) {
|
||||
const out = await fn(model, prompt, {
|
||||
max_tokens: currentMax,
|
||||
temperature: opts.temperature,
|
||||
system: opts.system,
|
||||
think: opts.think,
|
||||
});
|
||||
if (out.trim().length > 0) { combined = out; break; }
|
||||
// Empty — thinking model ate the budget. Double it and retry.
|
||||
if (opts.on_continuation) opts.on_continuation(retry + 1, 0);
|
||||
currentMax = Math.min(currentMax * 2, budgetCap);
|
||||
}
|
||||
|
||||
// Structural completion loop (continuation from partial).
|
||||
for (let i = 0; i < maxConts; i++) {
|
||||
if (isStructurallyComplete(combined, shape)) return combined;
|
||||
if (opts.on_continuation) opts.on_continuation(i + 1, combined.length);
|
||||
combined = await continueResponse(model, prompt, combined, {
|
||||
max_tokens: Math.min(currentMax, budgetCap),
|
||||
temperature: opts.temperature ?? 0.3,
|
||||
system: opts.system,
|
||||
cloud,
|
||||
think: opts.think,
|
||||
});
|
||||
}
|
||||
// Last-resort: return combined even if incomplete; caller's parser
|
||||
// will throw with the raw text for forensics rather than silently
|
||||
// truncating.
|
||||
return combined;
|
||||
}
|
||||
|
||||
// Input-overflow handler. When the input corpus exceeds the window,
|
||||
// shard → map → reduce with a running scratchpad digest.
|
||||
//
|
||||
// shards: array of input chunks the caller already split at semantic
|
||||
// boundaries (paragraphs, records, playbook entries).
|
||||
// map_prompt: fn taking (shard, running_scratchpad) → prompt for a
|
||||
// single map call. Must fit within window.
|
||||
// reduce_prompt: fn taking (combined_scratchpad) → final prompt. Also
|
||||
// must fit window; if the scratchpad itself overflows, this triggers
|
||||
// a recursive tree-split.
|
||||
//
|
||||
// Result: the reduce call's response.
|
||||
export async function generateTreeSplit(
|
||||
model: string,
|
||||
shards: string[],
|
||||
map_prompt: (shard: string, scratchpad: string) => string,
|
||||
reduce_prompt: (scratchpad: string) => string,
|
||||
opts: {
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
system?: string;
|
||||
cloud?: boolean;
|
||||
on_shard?: (i: number, total: number) => void;
|
||||
scratchpad_budget?: number;
|
||||
} = {},
|
||||
): Promise<{ response: string; scratchpad: string; shards_processed: number }> {
|
||||
const cloud = opts.cloud ?? false;
|
||||
const scratchpadBudget = opts.scratchpad_budget ?? 6000;
|
||||
let scratchpad = "";
|
||||
|
||||
for (let i = 0; i < shards.length; i++) {
|
||||
if (opts.on_shard) opts.on_shard(i + 1, shards.length);
|
||||
const shardPrompt = map_prompt(shards[i], scratchpad);
|
||||
// If the per-shard prompt alone exceeds window, the caller sharded
|
||||
// too coarsely — bubble up rather than silently truncating.
|
||||
assertContextBudget(model, shardPrompt, {
|
||||
system: opts.system,
|
||||
max_tokens: opts.max_tokens,
|
||||
bypass: false,
|
||||
});
|
||||
const shardOut = await generateContinuable(model, shardPrompt, {
|
||||
max_tokens: opts.max_tokens ?? 800,
|
||||
temperature: opts.temperature,
|
||||
system: opts.system,
|
||||
shape: "text",
|
||||
cloud,
|
||||
});
|
||||
// Append to scratchpad; truncate oldest if over budget.
|
||||
scratchpad = (scratchpad + `\n— shard ${i + 1}/${shards.length} digest —\n` + shardOut.trim()).slice(-scratchpadBudget * 4);
|
||||
}
|
||||
|
||||
const reducePrompt = reduce_prompt(scratchpad);
|
||||
assertContextBudget(model, reducePrompt, {
|
||||
system: opts.system,
|
||||
max_tokens: opts.max_tokens,
|
||||
bypass: false,
|
||||
});
|
||||
const response = await generateContinuable(model, reducePrompt, {
|
||||
max_tokens: opts.max_tokens ?? 1500,
|
||||
temperature: opts.temperature,
|
||||
system: opts.system,
|
||||
shape: "text",
|
||||
cloud,
|
||||
});
|
||||
return { response, scratchpad, shards_processed: shards.length };
|
||||
}
|
||||
|
||||
// Known context windows — matches crates/../config/models.json. Kept in
|
||||
// code as a fallback so the test harness doesn't crash if the config is
|
||||
// missing. Production path should read from models.json.
|
||||
export const CONTEXT_WINDOWS: Record<string, number> = {
|
||||
"mistral:latest": 32768,
|
||||
"qwen2.5:latest": 32768,
|
||||
"qwen3:latest": 40960,
|
||||
"qwen3.5:latest": 262144, // local 9.7B — new executor
|
||||
"qwen3-embedding": 32768, // local embedding model
|
||||
"nomic-embed-text-v2-moe": 2048, // local embedding, MoE
|
||||
"gpt-oss:20b": 131072,
|
||||
"gpt-oss:120b": 131072,
|
||||
"qwen3.5:397b": 131072,
|
||||
"kimi-k2-thinking": 200000,
|
||||
"kimi-k2.6": 200000, // cloud — new T4 candidate
|
||||
"kimi-k2:1t": 1048576,
|
||||
"deepseek-v3.1:671b": 131072,
|
||||
"glm-4.7": 131072,
|
||||
};
|
||||
const DEFAULT_CONTEXT_WINDOW = 32768;
|
||||
const DEFAULT_SAFETY_MARGIN = 2000;
|
||||
|
||||
// Fail LOUDLY if a prompt would blow the model's context. The whole
|
||||
// point of Phase 21 is to stop silent truncation — so we throw with the
|
||||
// numbers. Callers that expect to handle overflow should chunk BEFORE
|
||||
// calling; they can also set bypass: true to opt out (T5 gatekeeper
|
||||
// handles its own overflow policy).
|
||||
export function assertContextBudget(
|
||||
model: string,
|
||||
prompt: string,
|
||||
opts: { system?: string; max_tokens?: number; safety_margin?: number; bypass?: boolean } = {}
|
||||
): { estimated: number; window: number; remaining: number } {
|
||||
const window = CONTEXT_WINDOWS[model] ?? DEFAULT_CONTEXT_WINDOW;
|
||||
const safety = opts.safety_margin ?? DEFAULT_SAFETY_MARGIN;
|
||||
const estimated = estimateTokens(prompt) + estimateTokens(opts.system ?? "") + (opts.max_tokens ?? 800);
|
||||
const remaining = window - estimated - safety;
|
||||
if (remaining < 0 && !opts.bypass) {
|
||||
throw new Error(
|
||||
`context overflow: model=${model} est=${estimated}t window=${window}t safety=${safety}t over=${-remaining}t. ` +
|
||||
`Chunk the prompt (see config/models.json overflow_policies) or set bypass:true if you know the risk.`
|
||||
);
|
||||
}
|
||||
return { estimated, window, remaining };
|
||||
}
|
||||
|
||||
// --- Shared types ---
|
||||
|
||||
export type Role = "executor" | "reviewer";
|
||||
@@ -35,6 +301,7 @@ export interface LogEntry {
|
||||
| "critique"
|
||||
| "propose_done"
|
||||
| "consensus_done"
|
||||
| "note"
|
||||
| "error";
|
||||
content: any;
|
||||
}
|
||||
@@ -51,7 +318,11 @@ export type Action =
|
||||
export interface Fill {
|
||||
candidate_id: string;
|
||||
name: string;
|
||||
reason: string;
|
||||
reason?: string; // optional — the schema used to require it; nothing
|
||||
// downstream consumed it, and qwen3.5 would emit
|
||||
// 100-150 char justifications per fill that blew
|
||||
// the JSON budget on 4+ fills. Kept optional so
|
||||
// models that still emit it don't break parsing.
|
||||
}
|
||||
|
||||
// --- HTTP helpers (fail-fast) ---
|
||||
@@ -81,7 +352,10 @@ export async function callTool(tool: string, args: Record<string, any>): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function hybridSearch(sql_filter: string, question: string, k = 10): Promise<any> {
|
||||
// Default k=20 is a floor, not a ceiling — executor prompt instructs
|
||||
// models to scale k to 5× target_count (cap 80) so multi-fill events
|
||||
// get a meaningfully deep pool to rank within.
|
||||
export async function hybridSearch(sql_filter: string, question: string, k = 20): Promise<any> {
|
||||
return http("POST", `${GATEWAY}/vectors/hybrid`, { sql_filter, question, k });
|
||||
}
|
||||
|
||||
@@ -95,7 +369,14 @@ export async function generate(model: string, prompt: string, opts: {
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
system?: string;
|
||||
bypass_budget?: boolean;
|
||||
think?: boolean;
|
||||
} = {}): Promise<string> {
|
||||
assertContextBudget(model, prompt, {
|
||||
system: opts.system,
|
||||
max_tokens: opts.max_tokens,
|
||||
bypass: opts.bypass_budget,
|
||||
});
|
||||
const body: Record<string, any> = {
|
||||
model,
|
||||
prompt,
|
||||
@@ -103,11 +384,62 @@ export async function generate(model: string, prompt: string, opts: {
|
||||
max_tokens: opts.max_tokens ?? 800,
|
||||
};
|
||||
if (opts.system) body.system = opts.system;
|
||||
if (opts.think !== undefined) body.think = opts.think;
|
||||
const r = await http<any>("POST", `${SIDECAR}/generate`, body);
|
||||
const text = r.text ?? "";
|
||||
if (!text || typeof text !== "string") {
|
||||
throw new Error(`generate returned empty text from ${model}: ${JSON.stringify(r).slice(0, 200)}`);
|
||||
const text = typeof r.text === "string" ? r.text : "";
|
||||
// Do NOT throw on empty. Thinking models (gpt-oss, qwen3.5) burn the
|
||||
// max_tokens budget on hidden reasoning and emit "" when budget was
|
||||
// too tight. generateContinuable detects empty + continues with more
|
||||
// budget. Callers that expected non-empty can check themselves.
|
||||
return text;
|
||||
}
|
||||
|
||||
// Cloud generate — hits Ollama Cloud directly with the bearer key. Same
|
||||
// /api/generate shape as local Ollama; `thinking` field (for gpt-oss:Nb)
|
||||
// is discarded, only `response` is returned. Caller should budget
|
||||
// num_predict ≥ 400 so thinking-model reasoning has room before the
|
||||
// visible response starts.
|
||||
export async function generateCloud(model: string, prompt: string, opts: {
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
system?: string;
|
||||
bypass_budget?: boolean;
|
||||
think?: boolean;
|
||||
} = {}): Promise<string> {
|
||||
if (!OLLAMA_CLOUD_KEY) {
|
||||
throw new Error("OLLAMA_CLOUD_KEY not set; cannot reach Ollama Cloud");
|
||||
}
|
||||
assertContextBudget(model, prompt, {
|
||||
system: opts.system,
|
||||
max_tokens: opts.max_tokens,
|
||||
bypass: opts.bypass_budget,
|
||||
});
|
||||
const body: Record<string, any> = {
|
||||
model,
|
||||
prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: opts.temperature ?? 0.3,
|
||||
num_predict: Math.max(opts.max_tokens ?? 800, 400),
|
||||
},
|
||||
};
|
||||
if (opts.system) body.system = opts.system;
|
||||
if (opts.think !== undefined) body.think = opts.think;
|
||||
const resp = await fetch(`${OLLAMA_CLOUD_URL}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${OLLAMA_CLOUD_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Ollama Cloud ${resp.status}: ${await resp.text().catch(() => "?")}`);
|
||||
}
|
||||
const data: any = await resp.json();
|
||||
const text = typeof data.response === "string" ? data.response : "";
|
||||
// Same non-throw policy as local generate() — empty text is a valid
|
||||
// signal that thinking ate the budget. Let generateContinuable retry.
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -121,9 +453,13 @@ Available tools (each takes a JSON "args" object):
|
||||
→ Canonical production tool for fill tasks. Always use this FIRST.
|
||||
→ Example args:
|
||||
{"index_name":"workers_500k_v1",
|
||||
"sql_filter":"LOWER(role) LIKE '%weld%' AND city = 'Toledo' AND state = 'OH' AND availability > 0.5",
|
||||
"question":"reliable welder with OSHA certs",
|
||||
"k":10}
|
||||
"sql_filter":"role = 'Forklift Operator' AND city = 'Toledo' AND state = 'OH' AND CAST(availability AS DOUBLE) > 0.5",
|
||||
"question":"reliable forklift operator Toledo",
|
||||
"k":40}
|
||||
→ k should scale with target_count: roughly 5× the number of fills
|
||||
needed, floor 20, cap 80. For 1-2 fills use k=20. For 5 fills use
|
||||
k=40. A deep pool lets the ranker discriminate across a larger
|
||||
candidate set; k=10 was too tight for multi-fill events.
|
||||
|
||||
- sql(query: string)
|
||||
→ Raw read-only SELECT. Use for verification (confirm a worker exists,
|
||||
@@ -133,17 +469,19 @@ Available tools (each takes a JSON "args" object):
|
||||
responsiveness, engagement, communications, compliance, availability,
|
||||
resume_text.
|
||||
→ Example args:
|
||||
{"query":"SELECT worker_id, name, role, city, state, availability FROM workers_500k WHERE worker_id = 'W123456'"}
|
||||
{"query":"SELECT worker_id, name, role, city, state FROM workers_500k WHERE worker_id = 40123"}
|
||||
|
||||
Rules:
|
||||
- hybrid_search returns sources[] each with {doc_id, chunk_text, score, sql_verified}.
|
||||
- hybrid_search returns sources[] each with {doc_id, chunk_text, score,
|
||||
sql_verified, playbook_boost, playbook_citations}.
|
||||
- **ID mapping:** vector doc_ids look like "W500K-7995" (prefix + number).
|
||||
The SQL worker_id is an INTEGER. To go from doc_id to SQL, strip the
|
||||
"W500K-" prefix and cast:
|
||||
SELECT ... FROM workers_500k WHERE worker_id = CAST(SUBSTR('W500K-7995', 7) AS BIGINT)
|
||||
or more simply: WHERE worker_id = 7995.
|
||||
- Names are NOT unique. Always identify by worker_id, never by name alone.
|
||||
- Return EXACTLY ONE JSON object per turn. No prose outside the JSON.
|
||||
The SQL worker_id is an INTEGER. Use WHERE worker_id = 7995 directly.
|
||||
- Names are NOT unique. Always identify by worker_id.
|
||||
- availability and reliability are stored as text; ALWAYS cast as
|
||||
DOUBLE in filters: CAST(availability AS DOUBLE) > 0.5.
|
||||
- Narrative words from the guidance ("shift", "recurring", "expansion",
|
||||
"emergency") are NOT columns. Only use columns listed above.
|
||||
- Return EXACTLY ONE JSON object per turn. No markdown fences, no prose.
|
||||
`;
|
||||
|
||||
// Smart per-kind summary so agents see the substance of each prior turn
|
||||
@@ -253,8 +591,8 @@ Your next action MUST be a JSON object matching one of these shapes:
|
||||
— use on turn 1 to outline your approach. Steps must be concrete.
|
||||
{"kind":"tool_call","tool":"...","args":{...},"rationale":"why"}
|
||||
— call a tool and see its result next turn.
|
||||
{"kind":"propose_done","fills":[{"candidate_id":"...","name":"First Last","reason":"why them"}],"rationale":"..."}
|
||||
— propose you've met the target. fills MUST have EXACTLY ${task.target_count} entries — count twice before emitting.
|
||||
{"kind":"propose_done","fills":[{"candidate_id":"...","name":"First Last"}],"rationale":"..."}
|
||||
— propose you've met the target. fills MUST have EXACTLY ${task.target_count} entries — count twice before emitting. Each fill is ONLY {candidate_id, name} — no reason field, no scores, no commentary.
|
||||
|
||||
Strategy tip: once "CANDIDATES SURFACED SO FAR" has ≥ ${task.target_count} entries in ${task.target_city}, ${task.target_state} matching ${task.target_role}, verify ONE via the sql tool (to satisfy the reviewer's SQL-verification criterion) and then propose_done with the top ${task.target_count}. Don't keep re-searching.
|
||||
|
||||
@@ -321,7 +659,12 @@ export function parseAction(raw: string, role: Role): Action {
|
||||
if (start < 0 || end <= start) {
|
||||
throw new Error(`no JSON object in ${role} response: ${raw.slice(0, 300)}`);
|
||||
}
|
||||
const json = s.slice(start, end + 1);
|
||||
let json = s.slice(start, end + 1);
|
||||
// Soft-tolerate common model mistakes: stray `)` before closing brace
|
||||
// (qwen2.5 does this on tool_call), trailing commas, etc. Fix the
|
||||
// cheapest ones that are unambiguous.
|
||||
json = json.replace(/\)\s*\}/g, "}"); // "...)}" → "...}"
|
||||
json = json.replace(/,(\s*[}\]])/g, "$1"); // trailing comma before } or ]
|
||||
let obj: any;
|
||||
try {
|
||||
obj = JSON.parse(json);
|
||||
@@ -333,6 +676,10 @@ export function parseAction(raw: string, role: Role): Action {
|
||||
if (obj.kind === "plan" && Array.isArray(obj.steps)) return obj as Action;
|
||||
if (obj.kind === "tool_call" && typeof obj.tool === "string" && typeof obj.args === "object") return obj as Action;
|
||||
if (obj.kind === "propose_done" && Array.isArray(obj.fills)) return obj as Action;
|
||||
// Tolerance: some model outputs put a stray closing paren or
|
||||
// trailing garbage after the main object. If the kind looks
|
||||
// recognizable but shape doesn't match, bubble a cleaner error so
|
||||
// the orchestrator's soft-fail path doesn't swallow it.
|
||||
throw new Error(`executor returned unexpected shape: ${JSON.stringify(obj).slice(0, 200)}`);
|
||||
} else {
|
||||
// Normalize: some models (qwen2.5, mistral) emit the verdict AS the
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Scenario generator for Phase 22 KB corpus-building.
|
||||
//
|
||||
// Emits N unique ScenarioSpec JSON files under
|
||||
// tests/multi-agent/scenarios/ covering:
|
||||
// - different clients (so sig varies even when events match)
|
||||
// - different city/state combos actually present in workers_500k
|
||||
// - varied event sequences (baseline/recurring/expansion/emergency/misplacement)
|
||||
// - varied role mixes from the industrial staffing taxonomy
|
||||
//
|
||||
// Each scenario spec is written as scen_NN_CLIENT_CITY.json and can be
|
||||
// fed to scenario.ts as argv[2]. A sibling run_batch.sh runs them all
|
||||
// sequentially so the KB populates overnight.
|
||||
//
|
||||
// Determinism: the RNG seed is argv[2] (defaulting to 42) so repeat
|
||||
// invocations produce identical specs.
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Deterministic PRNG — mulberry32, same as many test harnesses. Stable
|
||||
// across bun versions; not cryptographic.
|
||||
function mulberry32(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s + 0x6D2B79F5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// Cities known to exist in workers_500k, chosen to avoid false-empty
|
||||
// searches. All Midwest because that's the target persona's geography.
|
||||
const CITIES: Array<{ city: string; state: string }> = [
|
||||
{ city: "Toledo", state: "OH" },
|
||||
{ city: "Cleveland", state: "OH" },
|
||||
{ city: "Columbus", state: "OH" },
|
||||
{ city: "Cincinnati", state: "OH" },
|
||||
{ city: "Akron", state: "OH" },
|
||||
{ city: "Detroit", state: "MI" },
|
||||
{ city: "Grand Rapids", state: "MI" },
|
||||
{ city: "Flint", state: "MI" },
|
||||
{ city: "Indianapolis", state: "IN" },
|
||||
{ city: "Fort Wayne", state: "IN" },
|
||||
{ city: "Gary", state: "IN" },
|
||||
{ city: "Chicago", state: "IL" },
|
||||
{ city: "Joliet", state: "IL" },
|
||||
{ city: "Rockford", state: "IL" },
|
||||
{ city: "Milwaukee", state: "WI" },
|
||||
{ city: "Madison", state: "WI" },
|
||||
{ city: "Louisville", state: "KY" },
|
||||
{ city: "Lexington", state: "KY" },
|
||||
{ city: "Kansas City", state: "MO" },
|
||||
{ city: "St. Louis", state: "MO" },
|
||||
];
|
||||
|
||||
// Industrial staffing role taxonomy. Weighted so common roles appear
|
||||
// more often (realistic distribution).
|
||||
const ROLES: Array<{ role: string; weight: number }> = [
|
||||
{ role: "Warehouse Associate", weight: 5 },
|
||||
{ role: "Machine Operator", weight: 4 },
|
||||
{ role: "Forklift Operator", weight: 4 },
|
||||
{ role: "Loader", weight: 3 },
|
||||
{ role: "Material Handler", weight: 3 },
|
||||
{ role: "Assembler", weight: 3 },
|
||||
{ role: "Quality Tech", weight: 2 },
|
||||
{ role: "Picker", weight: 3 },
|
||||
{ role: "Packer", weight: 3 },
|
||||
{ role: "Shipping Clerk", weight: 2 },
|
||||
{ role: "Receiving Clerk", weight: 2 },
|
||||
{ role: "Welder", weight: 2 },
|
||||
{ role: "CNC Operator", weight: 2 },
|
||||
{ role: "Maintenance Tech", weight: 1 },
|
||||
];
|
||||
|
||||
const CLIENTS = [
|
||||
"Riverfront Steel", "Northland Logistics", "Great Lakes Mfg",
|
||||
"Midway Distribution", "Pioneer Assembly", "Cornerstone Fabrication",
|
||||
"Horizon Supply", "Keystone Plastics", "Apex Warehouse",
|
||||
"Heritage Foods", "Summit Industrial", "Vanguard Components",
|
||||
"Centennial Packaging", "Parallel Machining", "Beacon Freight",
|
||||
];
|
||||
|
||||
function pickWeighted<T extends { weight: number }>(rng: () => number, items: T[]): T {
|
||||
const total = items.reduce((s, x) => s + x.weight, 0);
|
||||
let r = rng() * total;
|
||||
for (const x of items) { r -= x.weight; if (r <= 0) return x; }
|
||||
return items[items.length - 1];
|
||||
}
|
||||
|
||||
function pick<T>(rng: () => number, items: T[]): T {
|
||||
return items[Math.floor(rng() * items.length)];
|
||||
}
|
||||
|
||||
// Event shape templates. Each scenario picks 3-6 of these at random.
|
||||
// Multi-fill counts skew low to make the harness quicker; 5+ fill
|
||||
// events are the hardest and should be rarer in a corpus run.
|
||||
type EventKind = "baseline_fill" | "recurring" | "expansion" | "emergency" | "misplacement";
|
||||
|
||||
function makeEvent(
|
||||
rng: () => number,
|
||||
kind: EventKind,
|
||||
at: string,
|
||||
city: string,
|
||||
state: string,
|
||||
): any {
|
||||
const { role } = pickWeighted(rng, ROLES);
|
||||
const count = kind === "misplacement" ? 1
|
||||
: kind === "expansion" ? 2 + Math.floor(rng() * 4) // 2-5
|
||||
: kind === "baseline_fill" ? 1 + Math.floor(rng() * 3) // 1-3
|
||||
: kind === "recurring" ? 1 + Math.floor(rng() * 2) // 1-2
|
||||
: /* emergency */ 2 + Math.floor(rng() * 3); // 2-4
|
||||
const hour = 8 + Math.floor(rng() * 10);
|
||||
const min = Math.random() > 0.5 ? 0 : 30;
|
||||
const at_real = `${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
|
||||
return {
|
||||
kind,
|
||||
at: at_real,
|
||||
role,
|
||||
count,
|
||||
city,
|
||||
state,
|
||||
shift_start: `${at_real.replace(":", ":")} AM`,
|
||||
};
|
||||
}
|
||||
|
||||
function genSpec(rng: () => number, id: number): any {
|
||||
const client = pick(rng, CLIENTS);
|
||||
const { city, state } = pick(rng, CITIES);
|
||||
const today = new Date();
|
||||
const date = new Date(today.getTime() + id * 86400000)
|
||||
.toISOString().split("T")[0];
|
||||
|
||||
// Scenario shape mix — 60% pure fill (baseline+recurring+expansion),
|
||||
// 40% mixed (add emergency and/or misplacement).
|
||||
const includeEmergency = rng() > 0.6;
|
||||
const includeMisplacement = rng() > 0.6;
|
||||
const events: any[] = [];
|
||||
// always at least one baseline
|
||||
events.push(makeEvent(rng, "baseline_fill", "08:00", city, state));
|
||||
if (rng() > 0.3) events.push(makeEvent(rng, "recurring", "10:30", city, state));
|
||||
if (rng() > 0.5) events.push(makeEvent(rng, "expansion", "12:15", city, state));
|
||||
if (includeEmergency) events.push(makeEvent(rng, "emergency", "14:00", city, state));
|
||||
if (includeMisplacement) {
|
||||
const e = makeEvent(rng, "misplacement", "15:45", city, state);
|
||||
if (events.length > 0) e.replaces_event = events[0].at;
|
||||
events.push(e);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const n = Number(process.argv[2] ?? 20);
|
||||
const seed = Number(process.argv[3] ?? 42);
|
||||
const rng = mulberry32(seed);
|
||||
const outDir = "tests/multi-agent/scenarios";
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const manifest: Array<{ file: string; client: string; city: string; events: number }> = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const spec = genSpec(rng, i);
|
||||
const cityLabel = spec.events[0].city.replace(/\s+/g, "_");
|
||||
const fname = `scen_${String(i).padStart(3, "0")}_${spec.client.replace(/\s+/g, "_")}_${cityLabel}.json`;
|
||||
await writeFile(join(outDir, fname), JSON.stringify(spec, null, 2));
|
||||
manifest.push({
|
||||
file: fname,
|
||||
client: spec.client,
|
||||
city: spec.events[0].city,
|
||||
events: spec.events.length,
|
||||
});
|
||||
}
|
||||
await writeFile(
|
||||
join(outDir, "manifest.json"),
|
||||
JSON.stringify({ count: n, seed, scenarios: manifest }, null, 2),
|
||||
);
|
||||
console.log(`✓ generated ${n} scenarios → ${outDir}/ (seed=${seed})`);
|
||||
for (const m of manifest.slice(0, 5)) {
|
||||
console.log(` ${m.file} — ${m.client} (${m.city}), ${m.events} events`);
|
||||
}
|
||||
if (manifest.length > 5) console.log(` ... +${manifest.length - 5} more`);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error("gen_scenarios failed:", (e as Error).message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,600 @@
|
||||
// Phase 22 — Internal Knowledge Library.
|
||||
//
|
||||
// Sits on top of the successful-playbook store. Tracks which
|
||||
// configurations produced which outcomes for which playbook
|
||||
// signatures, detects fail→succeed error corrections across runs,
|
||||
// and emits pathway recommendations that the NEXT scenario reads
|
||||
// at startup.
|
||||
//
|
||||
// Event-driven cycle (not wall-clock polling):
|
||||
// scenario ends → kb.indexRun() → kb.recommendFor(nextSpec) → next
|
||||
// scenario reads top rec and applies.
|
||||
//
|
||||
// File layout under data/_kb/:
|
||||
// signatures.jsonl — (sig_hash, embedding[], first_seen, last_seen, run_count)
|
||||
// outcomes.jsonl — append-only per-run: {sig, run_id, models, pathway, ok_events, elapsed_s, errors[]}
|
||||
// config_snapshots.jsonl — config/models.json hash + env at each ingest
|
||||
// error_corrections.jsonl — fail→succeed deltas
|
||||
// pathway_recommendations.jsonl — AI-synthesized suggestions
|
||||
//
|
||||
// Why file-based: Phase 19 playbook_memory is already in the
|
||||
// catalogd+vectord stack. This is a separate meta-layer — keeping
|
||||
// it file-based first lets us iterate quickly without a gateway
|
||||
// schema migration. Rust port lands once the shape stabilizes
|
||||
// (mirrors how Phase 21 primitives are TS-first → Rust next sprint).
|
||||
|
||||
import { mkdir, readFile, writeFile, appendFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { SIDECAR, generateContinuable } from "./agent.ts";
|
||||
|
||||
const KB_DIR = "data/_kb";
|
||||
const SIGNATURES_FILE = "signatures.jsonl";
|
||||
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
|
||||
// the same sequence of (kind, role, count, city, state) get the same
|
||||
// hash. This is the retrieval key for the KB.
|
||||
export interface PlaybookSignature {
|
||||
sig_hash: string;
|
||||
client: string;
|
||||
events_digest: string; // human-readable event summary
|
||||
embedding: number[]; // sidecar embed, for nearest-neighbor
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
run_count: number;
|
||||
}
|
||||
|
||||
// What we record after a run completes.
|
||||
export interface RunOutcome {
|
||||
sig_hash: string;
|
||||
run_id: string; // scenario dir basename
|
||||
date: string;
|
||||
models: {
|
||||
executor: string;
|
||||
reviewer: string;
|
||||
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;
|
||||
role: string;
|
||||
count: number;
|
||||
ok: boolean;
|
||||
turns: number;
|
||||
error: string | null;
|
||||
}>;
|
||||
elapsed_secs: number;
|
||||
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;
|
||||
generated_at: string;
|
||||
generated_by_model: string;
|
||||
confidence: "high" | "medium" | "low";
|
||||
rationale: string; // prose from the recommender
|
||||
top_models: { // suggested tier assignments
|
||||
executor?: string;
|
||||
reviewer?: string;
|
||||
overview?: string;
|
||||
};
|
||||
budget_hints: { // suggested max_tokens / think flags
|
||||
executor_max_tokens?: number;
|
||||
reviewer_max_tokens?: number;
|
||||
executor_think?: boolean;
|
||||
};
|
||||
pathway_notes: string; // "pre-fetch cert data", "use buffer of 3+"
|
||||
neighbors_consulted: string[]; // sig_hashes of the playbooks that shaped this rec
|
||||
}
|
||||
|
||||
// Compute a stable hash from the scenario spec. Two runs with the same
|
||||
// events list (ignoring SMS drafts / timestamps / exclude lists) get
|
||||
// the same hash.
|
||||
export function computeSignature(spec: {
|
||||
client: string;
|
||||
events: Array<{ kind: string; role: string; count: number; city: string; state: string }>;
|
||||
}): string {
|
||||
const canonical = JSON.stringify({
|
||||
client: spec.client,
|
||||
events: spec.events.map(e => ({
|
||||
kind: e.kind, role: e.role, count: e.count, city: e.city, state: e.state,
|
||||
})),
|
||||
});
|
||||
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
// Human-readable digest of the spec, used in recommender prompts.
|
||||
export function specDigest(spec: {
|
||||
client: string;
|
||||
events: Array<{ kind: string; role: string; count: number; city: string; state: string }>;
|
||||
}): string {
|
||||
return `${spec.client}: ` + spec.events.map(e =>
|
||||
`${e.kind}/${e.role}×${e.count} in ${e.city},${e.state}`
|
||||
).join(" | ");
|
||||
}
|
||||
|
||||
async function ensureKb(): Promise<void> {
|
||||
await mkdir(KB_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function readJsonl<T>(file: string): Promise<T[]> {
|
||||
try {
|
||||
const raw = await readFile(join(KB_DIR, file), "utf8");
|
||||
return raw.split("\n").filter(l => l.trim()).map(l => JSON.parse(l) as T);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Index a completed scenario run. Extracts signature, computes
|
||||
// embedding, appends outcome, updates or inserts signature entry.
|
||||
export async function indexRun(
|
||||
scenarioDir: string,
|
||||
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 }> {
|
||||
await ensureKb();
|
||||
|
||||
const sig_hash = computeSignature(spec);
|
||||
const digest = specDigest(spec);
|
||||
|
||||
// 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,
|
||||
role: r.event.role,
|
||||
count: r.event.count,
|
||||
ok: r.ok,
|
||||
turns: r.turns ?? 0,
|
||||
error: r.error ?? null,
|
||||
})),
|
||||
elapsed_secs,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
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 {
|
||||
const resp = await fetch(`${SIDECAR}/embed`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ texts: [digest] }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data: any = await resp.json();
|
||||
const embedding: number[] = data.embeddings?.[0] ?? [];
|
||||
const existing = await readJsonl<PlaybookSignature>(SIGNATURES_FILE);
|
||||
const now = new Date().toISOString();
|
||||
const found = existing.find(s => s.sig_hash === sig_hash);
|
||||
if (found) {
|
||||
found.last_seen = now;
|
||||
found.run_count += 1;
|
||||
} else {
|
||||
existing.push({
|
||||
sig_hash,
|
||||
client: spec.client,
|
||||
events_digest: digest,
|
||||
embedding,
|
||||
first_seen: now,
|
||||
last_seen: now,
|
||||
run_count: 1,
|
||||
});
|
||||
}
|
||||
// Rewrite the whole file — cheap while KB is small; swap to
|
||||
// append-with-periodic-compact when row count exceeds ~10k.
|
||||
await writeFile(
|
||||
join(KB_DIR, SIGNATURES_FILE),
|
||||
existing.map(s => JSON.stringify(s)).join("\n") + "\n",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Signature indexing failed — outcome is still captured. Next
|
||||
// indexRun retries.
|
||||
}
|
||||
|
||||
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 {
|
||||
if (a.length === 0 || a.length !== b.length) return 0;
|
||||
let dot = 0, na = 0, nb = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
na += a[i] * a[i];
|
||||
nb += b[i] * b[i];
|
||||
}
|
||||
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-12);
|
||||
}
|
||||
|
||||
// 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();
|
||||
const digest = specDigest(spec);
|
||||
const targetHash = computeSignature(spec);
|
||||
const resp = await fetch(`${SIDECAR}/embed`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ texts: [digest] }),
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data: any = await resp.json();
|
||||
const targetVec: number[] = data.embeddings?.[0] ?? [];
|
||||
|
||||
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 => {
|
||||
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 => ({
|
||||
...r,
|
||||
outcomes: outcomes.filter(o => o.sig_hash === r.sig.sig_hash),
|
||||
}));
|
||||
}
|
||||
|
||||
// Detect fail→succeed pairs within outcomes for the same signature.
|
||||
// Diff the config/models between them; that's the correction.
|
||||
export async function detectErrorCorrections(): Promise<Array<{
|
||||
sig_hash: string;
|
||||
before: RunOutcome;
|
||||
after: RunOutcome;
|
||||
models_changed: Record<string, { from: string; to: string }>;
|
||||
}>> {
|
||||
const outcomes = await readJsonl<RunOutcome>(OUTCOMES_FILE);
|
||||
const bySig = new Map<string, RunOutcome[]>();
|
||||
for (const o of outcomes) {
|
||||
const arr = bySig.get(o.sig_hash) ?? [];
|
||||
arr.push(o);
|
||||
bySig.set(o.sig_hash, arr);
|
||||
}
|
||||
const corrections: any[] = [];
|
||||
for (const [sig, runs] of bySig) {
|
||||
runs.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
||||
for (let i = 1; i < runs.length; i++) {
|
||||
const prev = runs[i - 1];
|
||||
const cur = runs[i];
|
||||
if (prev.ok_events < prev.total_events && cur.ok_events > prev.ok_events) {
|
||||
const changed: Record<string, { from: string; to: string }> = {};
|
||||
for (const k of ["executor", "reviewer", "overview"] as const) {
|
||||
if (prev.models[k] !== cur.models[k]) {
|
||||
changed[k] = { from: prev.models[k], to: cur.models[k] };
|
||||
}
|
||||
}
|
||||
corrections.push({ sig_hash: sig, before: prev, after: cur, models_changed: changed });
|
||||
}
|
||||
}
|
||||
}
|
||||
return corrections;
|
||||
}
|
||||
|
||||
// Generate a pathway recommendation for a target spec. Reads k-NN
|
||||
// signatures + their outcome history, asks an overview model to
|
||||
// synthesize "best path forward", writes to recommendations file.
|
||||
export async function recommendFor(
|
||||
spec: any,
|
||||
opts: { overview_model?: string; cloud?: boolean; k?: number } = {},
|
||||
): Promise<PathwayRecommendation | null> {
|
||||
await ensureKb();
|
||||
const k = opts.k ?? 5;
|
||||
const overviewModel = opts.overview_model ?? "gpt-oss:20b";
|
||||
const cloud = opts.cloud ?? false;
|
||||
|
||||
const neighbors = await findNeighbors(spec, k);
|
||||
const corrections = await detectErrorCorrections();
|
||||
const targetHash = computeSignature(spec);
|
||||
|
||||
if (neighbors.length === 0) {
|
||||
// Cold start — no history. Write a minimal rec so next run still
|
||||
// gets "we looked" instead of a missing file.
|
||||
const rec: PathwayRecommendation = {
|
||||
sig_hash: targetHash,
|
||||
generated_at: new Date().toISOString(),
|
||||
generated_by_model: overviewModel,
|
||||
confidence: "low",
|
||||
rationale: "No prior runs in KB — first time this signature is seen. Use default model matrix.",
|
||||
top_models: {},
|
||||
budget_hints: {},
|
||||
pathway_notes: "No neighbor history to draw on. Proceed with current config; KB will have data after this run.",
|
||||
neighbors_consulted: [],
|
||||
};
|
||||
await appendFile(join(KB_DIR, RECOMMENDATIONS_FILE), JSON.stringify(rec) + "\n");
|
||||
return rec;
|
||||
}
|
||||
|
||||
// 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,
|
||||
n.outcomes[0]);
|
||||
const avgOk = n.outcomes.length > 0
|
||||
? (n.outcomes.reduce((s, o) => s + o.ok_events, 0) / n.outcomes.length).toFixed(1)
|
||||
: "?";
|
||||
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 =>
|
||||
`- sig ${c.sig_hash}: ${c.before.ok_events}/${c.before.total_events} → ${c.after.ok_events}/${c.after.total_events}; changed=${JSON.stringify(c.models_changed)}`
|
||||
).join("\n") || "(no corrections observed yet)";
|
||||
|
||||
const prompt = `You are the pathway recommender for a staffing coordinator agent system. A new scenario is about to run. Your job: use history of similar runs to recommend the best configuration.
|
||||
|
||||
TARGET SCENARIO:
|
||||
${specDigest(spec)}
|
||||
|
||||
${k} NEAREST-NEIGHBOR SIGNATURES FROM HISTORY (by cosine similarity):
|
||||
${neighborBlock}
|
||||
|
||||
RECENT ERROR CORRECTIONS (fail → succeed deltas on same signature):
|
||||
${correctionBlock}
|
||||
|
||||
Your output MUST be a valid JSON object with this shape (and nothing else — no prose before or after):
|
||||
{
|
||||
"confidence": "high" | "medium" | "low",
|
||||
"rationale": "2-3 sentence explanation of what pattern you saw",
|
||||
"top_models": {"executor": "...", "reviewer": "...", "overview": "..."},
|
||||
"budget_hints": {"executor_max_tokens": 800, "reviewer_max_tokens": 600, "executor_think": false},
|
||||
"pathway_notes": "concrete pre-run advice — what to pre-fetch, what to avoid, buffer sizes"
|
||||
}
|
||||
|
||||
Respond with ONLY the JSON object.`;
|
||||
|
||||
let raw = "";
|
||||
try {
|
||||
raw = await generateContinuable(overviewModel, prompt, {
|
||||
max_tokens: 1200,
|
||||
shape: "json",
|
||||
cloud,
|
||||
max_continuations: 2,
|
||||
});
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
const m = raw.match(/\{[\s\S]*\}/);
|
||||
parsed = m ? JSON.parse(m[0]) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (!parsed) return null;
|
||||
|
||||
const rec: PathwayRecommendation = {
|
||||
sig_hash: targetHash,
|
||||
generated_at: new Date().toISOString(),
|
||||
generated_by_model: overviewModel,
|
||||
confidence: parsed.confidence ?? "low",
|
||||
rationale: String(parsed.rationale ?? "").slice(0, 1000),
|
||||
top_models: parsed.top_models ?? {},
|
||||
budget_hints: parsed.budget_hints ?? {},
|
||||
pathway_notes: String(parsed.pathway_notes ?? "").slice(0, 2000),
|
||||
neighbors_consulted: neighbors.map(n => n.sig.sig_hash),
|
||||
};
|
||||
await appendFile(join(KB_DIR, RECOMMENDATIONS_FILE), JSON.stringify(rec) + "\n");
|
||||
return rec;
|
||||
}
|
||||
|
||||
// Read-back at scenario start: find the newest recommendation for this
|
||||
// exact sig_hash (or nearest neighbor of it). Returns null if nothing
|
||||
// applicable found.
|
||||
export async function loadRecommendation(spec: any): Promise<PathwayRecommendation | null> {
|
||||
await ensureKb();
|
||||
const targetHash = computeSignature(spec);
|
||||
const recs = await readJsonl<PathwayRecommendation>(RECOMMENDATIONS_FILE);
|
||||
const matching = recs.filter(r => r.sig_hash === targetHash);
|
||||
if (matching.length === 0) return null;
|
||||
matching.sort((a, b) => b.generated_at.localeCompare(a.generated_at));
|
||||
return matching[0];
|
||||
}
|
||||
|
||||
// Record a config snapshot. Called whenever models.json or the active
|
||||
// model assignments change, so error-correction diffs have history to
|
||||
// reference beyond the outcomes file.
|
||||
export async function snapshotConfig(
|
||||
models_json_hash: string,
|
||||
active_models: Record<string, string>,
|
||||
why: string,
|
||||
): Promise<void> {
|
||||
await ensureKb();
|
||||
await appendFile(
|
||||
join(KB_DIR, CONFIG_SNAPSHOTS_FILE),
|
||||
JSON.stringify({
|
||||
at: new Date().toISOString(),
|
||||
models_json_hash,
|
||||
active_models,
|
||||
why,
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,8 @@ import {
|
||||
callTool,
|
||||
} from "./agent.ts";
|
||||
|
||||
const EXECUTOR_MODEL = "mistral:latest";
|
||||
const REVIEWER_MODEL = "qwen2.5:latest";
|
||||
const EXECUTOR_MODEL = "qwen3.5:latest";
|
||||
const REVIEWER_MODEL = "qwen3:latest";
|
||||
const VERIFIER_MODEL = "qwen2.5:latest";
|
||||
const PROFILE_ID = "staffing-recruiter";
|
||||
const INDEX_NAME = "workers_500k_v1";
|
||||
@@ -175,7 +175,7 @@ async function buildPhase(task: TaskSpec, prefix: string): Promise<BuildResult>
|
||||
while (turn < MAX_TURNS && !sealed) {
|
||||
turn += 1;
|
||||
|
||||
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), { temperature: 0.2, max_tokens: 600 });
|
||||
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), { temperature: 0.2, max_tokens: 1200 });
|
||||
const execAction = parseAction(execRaw, "executor");
|
||||
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: execAction.kind as any, content: execAction });
|
||||
|
||||
@@ -192,7 +192,7 @@ async function buildPhase(task: TaskSpec, prefix: string): Promise<BuildResult>
|
||||
}
|
||||
}
|
||||
|
||||
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), { temperature: 0.1, max_tokens: 400 });
|
||||
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), { temperature: 0.1, max_tokens: 1000 });
|
||||
const revAction = parseAction(revRaw, "reviewer");
|
||||
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "critique", content: revAction });
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const EXECUTOR_MODEL = "mistral:latest";
|
||||
const REVIEWER_MODEL = "qwen2.5:latest";
|
||||
const EXECUTOR_MODEL = "qwen3.5:latest";
|
||||
const REVIEWER_MODEL = "qwen3:latest";
|
||||
const MAX_TURNS = 12; // executor turns; reviewer gets one per
|
||||
const MAX_CONSECUTIVE_DRIFTS = 3; // drift-cycle blown → give up
|
||||
|
||||
@@ -148,7 +148,7 @@ async function main() {
|
||||
// --- EXECUTOR TURN ---
|
||||
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), {
|
||||
temperature: 0.2,
|
||||
max_tokens: 600,
|
||||
max_tokens: 1200,
|
||||
});
|
||||
let execAction: Action;
|
||||
try {
|
||||
@@ -191,7 +191,7 @@ async function main() {
|
||||
// --- REVIEWER TURN ---
|
||||
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), {
|
||||
temperature: 0.1,
|
||||
max_tokens: 400,
|
||||
max_tokens: 1000,
|
||||
});
|
||||
let revAction: Action;
|
||||
try {
|
||||
|
||||
@@ -32,8 +32,8 @@ import {
|
||||
callTool,
|
||||
} from "./agent.ts";
|
||||
|
||||
const EXECUTOR_MODEL = "mistral:latest";
|
||||
const REVIEWER_MODEL = "qwen2.5:latest";
|
||||
const EXECUTOR_MODEL = "qwen3.5:latest";
|
||||
const REVIEWER_MODEL = "qwen3:latest";
|
||||
const MAX_TURNS = 12;
|
||||
const MAX_CONSECUTIVE_DRIFTS = 3;
|
||||
const INDEX_NAME = "workers_500k_v1";
|
||||
@@ -75,7 +75,7 @@ async function runOrchestrator(task: TaskSpec, prefix: string): Promise<RunResul
|
||||
turn += 1;
|
||||
|
||||
// Executor
|
||||
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), { temperature: 0.2, max_tokens: 600 });
|
||||
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), { temperature: 0.2, max_tokens: 1200 });
|
||||
const execAction = parseAction(execRaw, "executor");
|
||||
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: execAction.kind as any, content: execAction });
|
||||
|
||||
@@ -95,7 +95,7 @@ async function runOrchestrator(task: TaskSpec, prefix: string): Promise<RunResul
|
||||
}
|
||||
|
||||
// Reviewer
|
||||
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), { temperature: 0.1, max_tokens: 400 });
|
||||
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), { temperature: 0.1, max_tokens: 1000 });
|
||||
const revAction = parseAction(revRaw, "reviewer");
|
||||
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "critique", content: revAction });
|
||||
|
||||
|
||||
+982
-32
File diff suppressed because it is too large
Load Diff
@@ -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