diff --git a/src/agents/tools/storage.ts b/src/agents/tools/storage.ts index 1c988fd..1e1e79d 100644 --- a/src/agents/tools/storage.ts +++ b/src/agents/tools/storage.ts @@ -43,7 +43,7 @@ export function createStorageTools() { description: 'Search documents in ChittyStorage by entity, filename, or content hash. Returns matching documents with metadata.', inputSchema: z.object({ query: z.string().describe('Search query — entity slug, filename, or SHA-256 hash'), - entity: z.string().optional().describe('Filter by entity slug (e.g., "arias-v-bianchi")'), + entity: z.string().optional().describe('Filter by entity slug (e.g., "smith-v-jones")'), limit: z.number().min(1).max(50).optional().describe('Max results (default 20)'), }), execute: async ({ query, entity, limit }) => { @@ -68,7 +68,7 @@ export function createStorageTools() { list_entity_documents: tool({ description: 'List all documents stored for a specific entity.', inputSchema: z.object({ - entity: z.string().describe('Entity slug (e.g., "arias-v-bianchi", "it-can-be-llc")'), + entity: z.string().describe('Entity slug (e.g., "smith-v-jones", "example-holdings-llc")'), }), execute: async ({ entity }) => { return callStorageMcp('storage_entity', { entity }); diff --git a/src/lib/contextual-ingest.ts b/src/lib/contextual-ingest.ts index c466ce1..00f2828 100644 --- a/src/lib/contextual-ingest.ts +++ b/src/lib/contextual-ingest.ts @@ -10,7 +10,7 @@ * ChittyLedger-Messaging / delicate-moon — read-only). Candidates are * messages carrying an `amount` entity plus a payee-like entity * (person/org). `date` entities give a due date; `legal_document` and - * case-ref entities (#287 / #239 / 2024D007847) drive sensitivity. + * case-ref entities (court case numbers and configured matter refs) drive sensitivity. * 2. Route/classify via chittyrouter. We attempt its intelligentRoute surface * (POST /process); the deployed edge currently does not expose it * (404) and /agents/triage/classify returns a deterministic stub, so we @@ -47,9 +47,61 @@ import { contextualTasksClient } from './integrations'; const ROUTER_MODEL = '@cf/meta/llama-3.1-8b-instruct-fast'; // Case refs / patterns that force legalink sensitivity + privileged intent. -// @canon: chittyview-projection-spine — divorce 2024D007847 work product is -// privileged even though it is business-relevant. -const LEGAL_CASE_PATTERN = /2024D007847|#?\s?287\b|#?\s?239\b|arias\s+v\.?\s+bianchi/i; +// @canon: chittyview-projection-spine — litigation work product is privileged +// even when it is business-relevant. +// +// This matches court-case-number SHAPES, not one matter. Keying privilege +// classification to a single hardcoded case meant every OTHER matter's work +// product was silently classified non-sensitive — a §9 privilege risk, not a +// tidiness problem. The shapes below are a superset of what the literal matched. +// +// Non-docket refs (unit numbers, party names, internal slugs) cannot be derived +// from a shape and are read from KV `legal:case_refs` instead. See +// matchesConfiguredCaseRef(). +const LEGAL_CASE_PATTERN = + /\b(\d{4}\s?D\s?\d{6}|\d{2}\s?M\d\s?\d{6}|\d{4}-\d{2}-\d{5}|\d{2}-?[A-Z]{1,3}-?\d{4,6})\b/i; + +/** + * Additional matter references that have no derivable shape — unit numbers, + * party names, internal slugs. Stored as a JSON array of strings in KV + * `legal:case_refs`. + * + * These are matter data and never belong in source. If the key is unset the + * classifier falls back to shapes and keywords alone, which is NARROWER than + * the old hardcoded behaviour for non-docket refs — so it warns rather than + * failing silently. + */ +// Bounded cache. Workers reuse globals across requests for the isolate's +// lifetime, so an unbounded cache would freeze the first result forever: +// seeding legal:case_refs afterwards would never take effect, and a later +// change would never propagate. A read FAILURE is never cached as "configured +// empty" -- doing so would silently and permanently downgrade sensitivity +// classification for every subsequent request in that isolate. +const CASE_REFS_TTL_MS = 60_000; +let configuredRefsCache: string[] | null = null; +let configuredRefsFetchedAt = 0; + +async function matchesConfiguredCaseRef(env: Env, text: string): Promise { + const stale = Date.now() - configuredRefsFetchedAt > CASE_REFS_TTL_MS; + if (configuredRefsCache === null || stale) { + try { + const raw = await env.COMMAND_KV.get('legal:case_refs'); + const parsed = raw ? (JSON.parse(raw) as string[]).filter((r) => typeof r === 'string' && r) : []; + configuredRefsCache = parsed; + configuredRefsFetchedAt = Date.now(); + if (parsed.length === 0) { + console.warn('[contextual-ingest] KV legal:case_refs unset — non-docket matter refs will not force legal sensitivity'); + } + } catch (err) { + // Do NOT cache the failure. Keep any previously-good value and retry on + // the next call rather than locking in an empty configuration. + console.error('[contextual-ingest] legal:case_refs read/parse failed:', err); + if (configuredRefsCache === null) return false; + } + } + const lower = text.toLowerCase(); + return configuredRefsCache.some((ref) => lower.includes(ref.toLowerCase())); +} const LEGAL_KEYWORD_PATTERN = /\b(lawsuit|subpoena|court|hearing|motion|deposition|notice of motion|debt[-\s]?collection|collection agency)\b/i; export interface ContextualCandidate { @@ -217,7 +269,10 @@ export async function classifyCandidate( ): Promise { const text = `${cand.body_text}`.slice(0, 1500); const legalSignal = - cand.has_legal_doc || LEGAL_CASE_PATTERN.test(text) || LEGAL_KEYWORD_PATTERN.test(text); + cand.has_legal_doc || + LEGAL_CASE_PATTERN.test(text) || + (await matchesConfiguredCaseRef(env, text)) || + LEGAL_KEYWORD_PATTERN.test(text); // 1) chittyrouter intelligentRoute (POST /process). Returns ai.analysis with // category/priority/urgency_score when available. diff --git a/src/lib/cron.ts b/src/lib/cron.ts index dfafe44..9173ba7 100644 --- a/src/lib/cron.ts +++ b/src/lib/cron.ts @@ -210,10 +210,22 @@ export async function runCronSync( try { // Enqueue via dispatcher for retry + fan-out const chittyId = await env.COMMAND_KV.get('default:chitty_id') || undefined; - await enqueueJob(sql, 'court_docket', { case_number: '2024D007847' }, { - chittyId, - cronSource: 'court_docket', - }, env); + const trackedCases = await getTrackedCases(env); + if (trackedCases.length === 0) { + console.warn('[cron:court_docket] no cases configured in KV legal:tracked_cases — nothing to sync'); + } + // Per-case isolation: one failing enqueue must not abandon the rest of + // the batch, nor skip processQueue for jobs that did enqueue. + for (const caseNumber of trackedCases) { + try { + await enqueueJob(sql, 'court_docket', { case_number: caseNumber }, { + chittyId, + cronSource: 'court_docket', + }, env); + } catch (err) { + console.error(`[cron:court_docket] enqueue failed for ${caseNumber}:`, err); + } + } const queueResult = await processQueue(sql, env, ctx); recordsSynced += queueResult.succeeded; console.log(`[cron:court_docket] dispatcher: ${queueResult.succeeded} succeeded, ${queueResult.failed} failed`); @@ -520,6 +532,28 @@ export async function syncMercury(env: Env, sql: NeonQueryFunction return recordsSynced; } +/** + * Matters tracked by the docket cron, read from KV `legal:tracked_cases` + * (a JSON array of case numbers). + * + * This is config, never a literal. A hardcoded case number made every scheduled + * run operate on one operator's live matter and made the job unusable for any + * other -- the "no defaulting to the active case" prohibition in + * legal-operating-defaults §8. Unset config means the cron does nothing and says + * so; it never guesses a matter. + */ +async function getTrackedCases(env: Env): Promise { + try { + const raw = await env.COMMAND_KV.get('legal:tracked_cases'); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((c): c is string => typeof c === 'string' && c.length > 0) : []; + } catch (err) { + console.error('[cron:court_docket] legal:tracked_cases is not valid JSON:', err); + return []; + } +} + /** * Scrape court docket via ChittyScrape and insert new deadlines. */ @@ -530,35 +564,42 @@ export async function syncCourtDocket(env: Env, sql: NeonQueryFunction { const parsed = courtDocketScrapeSchema.safeParse(await c.req.json()); if (!parsed.success) return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400); - const targetCase = parsed.data.caseNumber || '2024D007847'; + // No fallback. The schema now requires caseNumber; an absent matter is a 400. + const targetCase = parsed.data.caseNumber; const result = await scrape.scrapeCourtDocket(targetCase, token); diff --git a/ui/src/pages/Legal.tsx b/ui/src/pages/Legal.tsx index e084755..86cdbf9 100644 --- a/ui/src/pages/Legal.tsx +++ b/ui/src/pages/Legal.tsx @@ -101,7 +101,7 @@ export function Legal() { setForm(f => ({ ...f, case_ref: e.target.value }))} - placeholder="e.g. 2024D007847" + placeholder="e.g. 2019D001234" className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-200 text-card-text text-sm focus:outline-none focus:ring-2 focus:ring-chitty-500/50" /> diff --git a/ui/src/pages/LitigationAssistant.tsx b/ui/src/pages/LitigationAssistant.tsx index f6cb85b..c6c9c7d 100644 --- a/ui/src/pages/LitigationAssistant.tsx +++ b/ui/src/pages/LitigationAssistant.tsx @@ -29,7 +29,9 @@ export function LitigationAssistant() { const [step, setStep] = useState('idle'); const [rawNotes, setRawNotes] = useState(''); const [property, setProperty] = useState('550 W Surf St, Unit 504, Chicago, IL'); - const [caseNumber, setCaseNumber] = useState('2024D007847'); + // No prefill. A live matter number as the default made every user of this + // page start inside someone else's case (legal-operating-defaults §8). + const [caseNumber, setCaseNumber] = useState(''); const [focus, setFocus] = useState(FOCUS_OPTIONS[0]); const [recipient, setRecipient] = useState('Robert Alexander');