Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/agents/tools/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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 });
Expand Down
65 changes: 60 additions & 5 deletions src/lib/contextual-ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<boolean> {
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 {
Expand Down Expand Up @@ -217,7 +269,10 @@ export async function classifyCandidate(
): Promise<ClassificationResult> {
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.
Expand Down
81 changes: 61 additions & 20 deletions src/lib/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -520,6 +532,28 @@ export async function syncMercury(env: Env, sql: NeonQueryFunction<false, false>
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<string[]> {
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.
*/
Expand All @@ -530,35 +564,42 @@ export async function syncCourtDocket(env: Env, sql: NeonQueryFunction<false, fa
const token = await env.COMMAND_KV.get('scrape:service_token');
if (!token) return 0;

// Arias v. Bianchi case number
const result = await scrape.scrapeCourtDocket('2024D007847', token);
if (!result?.success) {
console.error('[cron:court_docket] scrape failed:', result?.error);
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');
return 0;
}

let synced = 0;

if (result.data?.entries) {
for (const entry of result.data.entries) {
for (const caseNumber of trackedCases) {
const result = await scrape.scrapeCourtDocket(caseNumber, token);
if (!result?.success) {
console.error(`[cron:court_docket] scrape failed for ${caseNumber}:`, result?.error);
continue;
}

if (result.data?.entries) {
for (const entry of result.data.entries) {
await sql`
INSERT INTO cc_legal_deadlines (case_number, deadline_type, deadline_date, description, source)
VALUES (${caseNumber}, ${entry.type || 'court_entry'}, ${entry.date || null}, ${entry.description || ''}, 'court_docket_scrape')
ON CONFLICT DO NOTHING
`;
synced++;
}
}

if (result.data?.nextHearing) {
await sql`
INSERT INTO cc_legal_deadlines (case_number, deadline_type, deadline_date, description, source)
VALUES ('2024D007847', ${entry.type || 'court_entry'}, ${entry.date || null}, ${entry.description || ''}, 'court_docket_scrape')
VALUES (${caseNumber}, 'hearing', ${result.data.nextHearing}, 'Next court hearing', 'court_docket_scrape')
ON CONFLICT DO NOTHING
`;
synced++;
}
}

if (result.data?.nextHearing) {
await sql`
INSERT INTO cc_legal_deadlines (case_number, deadline_type, deadline_date, description, source)
VALUES ('2024D007847', 'hearing', ${result.data.nextHearing}, 'Next court hearing', 'court_docket_scrape')
ON CONFLICT DO NOTHING
`;
synced++;
}

return synced;
}

Expand Down
6 changes: 5 additions & 1 deletion src/lib/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,12 @@ export const submitEvidenceSchema = z.object({

// ── Bridge: Scrape ───────────────────────────────────────────

// caseNumber is REQUIRED. It was optional, and the route defaulted the missing
// value to a live matter -- exactly the "no defaulting to the active case"
// prohibition in legal-operating-defaults §8. A docket scrape with no matter
// named is a bad request, not an invitation to guess.
export const courtDocketScrapeSchema = z.object({
caseNumber: z.string().max(50).optional(),
caseNumber: z.string().min(1).max(50),
});

// ── Bridge: Plaid ────────────────────────────────────────────
Expand Down
3 changes: 2 additions & 1 deletion src/routes/bridge/scrape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ scrapeRoutes.post('/court-docket', async (c) => {

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);

Expand Down
2 changes: 1 addition & 1 deletion ui/src/pages/Legal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function Legal() {
<input
value={form.case_ref}
onChange={(e) => 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"
/>
</div>
Expand Down
4 changes: 3 additions & 1 deletion ui/src/pages/LitigationAssistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export function LitigationAssistant() {
const [step, setStep] = useState<Step>('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');

Expand Down
Loading