J asked directly: "did we implement our memory findings so that our
knowledge base and our configuration playbook [work] seamlessly with
whatever input they're given?" Honest answer tonight was "one of five
findings shipped, normalizer is the blocker." This closes that gap.
NORMALIZER (tests/multi-agent/normalize.ts):
Accepts structured JSON, natural language, or mixed. Returns canonical
NormalizedInput { role, city, state, count, client, deadline, intent,
confidence, extraction_method, missing_fields } for any downstream
consumer.
Three-tier path:
1. Structured fast-path — already-shaped input skips LLM
2. Regex path — "need 3 welders in Nashville, TN" parses without LLM.
City/state parser tightened to 1-3 capitalized words + "in {city}"
anchor preference + case-exact full-state-name variants to prevent
"Forklift Operators in Chicago" being captured as the city name
3. LLM fallback — qwen3 local with think:false + 400 max_tokens for
inputs the regex can't handle
Unit tests (tests/multi-agent/normalize.test.ts): 9/9 pass. Covers
structured fast-path, misplacement→rescue intent, state-name→abbrev
conversion, regex extraction from natural language, plural role +
full state name edge case, rescue intent keyword precedence, partial
input reporting missing fields, empty object fallthrough, async/sync
parity on clean inputs.
UNIFIED MEMORY QUERY (tests/multi-agent/memory_query.ts):
One function, five parallel fan-outs, one bundle returned:
- playbook_workers — hybrid_search via gateway with use_playbook_memory
- pathway_recommendation — KB recommender for this sig
- neighbor_signatures — K-NN sigs weighted by staffer competence
- prior_lessons — T3 overseer lessons filtered by city/state
- top_staffers — competence-sorted leaderboard
- discovered_patterns — top workers endorsed across past playbooks
for this (role, city, state)
- latency_ms — per-source + total
Every branch is best-effort: one source down doesn't break the bundle.
HTTP ENDPOINT (mcp-server/index.ts):
POST /memory/query with body {input: <anything>} → MemoryQueryResult
Returns the same shape the TS function does. Typed with types.ts for
future UI consumption.
VERIFIED:
curl POST /memory/query with structured {role,city,state,count}
→ extraction_method=structured, 10 playbook workers, top score 0.878
curl POST /memory/query with "I need 3 welders in Nashville, TN"
→ extraction_method=regex (no LLM call), 319ms total, 8 endorsements
for Lauren Gomez auto-discovered as top Nashville Welder
Honest remaining gaps (documented for next phase):
- Mem0 ADD/UPDATE/DELETE/NOOP — we still only ADD + mark_failed
- Zep validity windows — playbook entries have timestamps but no
retirement semantic
- Letta working-memory / hot cache — every query scans all 1560
playbook entries
- Memory profiles / scoped queries — global pool, no per-staffer
private subsets
2 of 5 findings now shipped (multi-strategy retrieval in Rust, input
normalization + unified query in TS). The remaining 3 are architectural
additions queued as Phase 25 items — validity windows first since it's
the most load-bearing for long-running systems.
78 lines
2.8 KiB
TypeScript
78 lines
2.8 KiB
TypeScript
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);
|
|
});
|