#2: Per-client worker blacklists
New endpoints:
- POST /clients/:client/blacklist { worker_id, name?, reason? }
- GET /clients/:client/blacklist → { client, entries }
- DELETE /clients/:client/blacklist/:worker_id → { removed, total }
Bun /search accepts optional `client` field. When present, loads that
client's blacklist and appends `AND worker_id NOT IN (...)` to the
SQL filter. Zero-cost if unused; clean trust-break avoidance when a
client has previously flagged a worker.
Persistence: mcp-server/data/client_blacklists.json, synchronous
writes via Bun.write. Scale target is hundreds of entries per client
tops — JSON is fine until we hit 10K+ per client.
Verified: worker_id 9326 (Carmen Green) blacklisted for AcmeCorp,
same Chicago Electrician search with client=AcmeCorp returns 196
sql_matches vs 197 without — exactly one excluded.
This commit is contained in:
parent
4aea71d213
commit
cdd12a1438
@ -382,6 +382,44 @@ async function main() {
|
||||
}
|
||||
|
||||
// Tool: hybrid search
|
||||
// ─── Client blacklists (feature #2) ───────────────────────────
|
||||
// Per-client worker exclusion list. A worker blacklisted for
|
||||
// client X is hidden from /search and /match when the caller
|
||||
// passes `client: "X"`. Persisted to local JSON so it survives
|
||||
// Bun restarts. This is a trust-critical feature — if the
|
||||
// system recommends a worker the client already flagged, the
|
||||
// system's credibility is gone.
|
||||
if (url.pathname.startsWith("/clients/") && url.pathname.includes("/blacklist")) {
|
||||
const m = url.pathname.match(/^\/clients\/([^\/]+)\/blacklist\/?(.*)$/);
|
||||
if (m) {
|
||||
const client = decodeURIComponent(m[1]);
|
||||
const suffix = m[2]; // empty, or a worker_id to delete
|
||||
|
||||
if (req.method === "GET") {
|
||||
const list = await loadClientBlacklist(client);
|
||||
return ok({ client, entries: list });
|
||||
}
|
||||
if (req.method === "POST" && !suffix) {
|
||||
const b = await json();
|
||||
if (!b.worker_id) return err("worker_id required", 400);
|
||||
const entry = {
|
||||
worker_id: String(b.worker_id),
|
||||
name: b.name || "",
|
||||
reason: b.reason || "",
|
||||
added_at: new Date().toISOString(),
|
||||
};
|
||||
const list = await addToClientBlacklist(client, entry);
|
||||
return ok({ client, added: entry, total: list.length });
|
||||
}
|
||||
if (req.method === "DELETE" && suffix) {
|
||||
const worker_id = decodeURIComponent(suffix);
|
||||
const { removed, total } = await removeFromClientBlacklist(client, worker_id);
|
||||
return ok({ client, removed, total });
|
||||
}
|
||||
return err(`unsupported method ${req.method} for blacklist`, 405);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/search") {
|
||||
const b = await json();
|
||||
// Availability soft-filter: if the caller didn't constrain
|
||||
@ -394,6 +432,16 @@ async function main() {
|
||||
if (!optOut && filter && !/availability/i.test(filter)) {
|
||||
filter = `(${filter}) AND CAST(availability AS DOUBLE) > 0.5`;
|
||||
}
|
||||
// Client blacklist filter: if caller passes `client`, exclude
|
||||
// worker_ids that client has flagged. One SQL expression
|
||||
// added, no extra round-trip needed by the caller.
|
||||
if (b.client && filter) {
|
||||
const bl = await loadClientBlacklist(String(b.client));
|
||||
const ids = bl.map(e => e.worker_id).filter(x => /^\d+$/.test(x));
|
||||
if (ids.length > 0) {
|
||||
filter = `(${filter}) AND worker_id NOT IN (${ids.join(",")})`;
|
||||
}
|
||||
}
|
||||
return ok(await api("POST", "/vectors/hybrid", {
|
||||
question: b.question, index_name: b.index || "workers_500k_v1",
|
||||
sql_filter: filter, filter_dataset: b.dataset || "ethereal_workers",
|
||||
@ -1692,6 +1740,52 @@ const SCENARIOS = [
|
||||
|
||||
function pick<T>(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||
|
||||
// ─── Client-blacklist persistence (feature #2) ──────────────────────────
|
||||
// Simple JSON file under mcp-server/data/. Synchronous writes are fine
|
||||
// at the expected rate (a handful of blacklist adds per day).
|
||||
const BLACKLIST_PATH = `${import.meta.dir}/data/client_blacklists.json`;
|
||||
|
||||
interface BlacklistEntry {
|
||||
worker_id: string;
|
||||
name: string;
|
||||
reason: string;
|
||||
added_at: string;
|
||||
}
|
||||
|
||||
async function loadAllBlacklists(): Promise<Record<string, BlacklistEntry[]>> {
|
||||
try {
|
||||
const f = Bun.file(BLACKLIST_PATH);
|
||||
if (!(await f.exists())) return {};
|
||||
return await f.json() as Record<string, BlacklistEntry[]>;
|
||||
} catch { return {}; }
|
||||
}
|
||||
async function saveAllBlacklists(all: Record<string, BlacklistEntry[]>): Promise<void> {
|
||||
await Bun.write(BLACKLIST_PATH, JSON.stringify(all, null, 2));
|
||||
}
|
||||
async function loadClientBlacklist(client: string): Promise<BlacklistEntry[]> {
|
||||
const all = await loadAllBlacklists();
|
||||
return all[client] || [];
|
||||
}
|
||||
async function addToClientBlacklist(client: string, entry: BlacklistEntry): Promise<BlacklistEntry[]> {
|
||||
const all = await loadAllBlacklists();
|
||||
const list = all[client] || [];
|
||||
// De-dupe: same worker_id replaces prior entry with fresher reason.
|
||||
const filtered = list.filter(e => e.worker_id !== entry.worker_id);
|
||||
filtered.push(entry);
|
||||
all[client] = filtered;
|
||||
await saveAllBlacklists(all);
|
||||
return filtered;
|
||||
}
|
||||
async function removeFromClientBlacklist(client: string, worker_id: string): Promise<{ removed: boolean; total: number }> {
|
||||
const all = await loadAllBlacklists();
|
||||
const list = all[client] || [];
|
||||
const filtered = list.filter(e => e.worker_id !== worker_id);
|
||||
const removed = filtered.length < list.length;
|
||||
all[client] = filtered;
|
||||
await saveAllBlacklists(all);
|
||||
return { removed, total: filtered.length };
|
||||
}
|
||||
|
||||
// Seed playbook_memory from a filled contract so the next hybrid query
|
||||
// ranks against it. Used by both runWeekSimulation (per-day) and the /log
|
||||
// endpoint (per manual logging). Fail-soft — seeding is best-effort.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user