feat: Stage A finalizers + #35/#36/#37 — critical-gap closure
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Four parallel sub-agents closed the remaining critical gaps from the 26/05 Stage A/B sprint. Each block independently tested; aggregated here. ## #30/#31 finalizers (sub-agent A) * Auto-derive practice_area in case_create from case_number prefix (1xxx→rishuy_uvniya, 8xxx→betterment_levy, 9xxx→compensation_197); default for CaseCreateRequest is now "" (the DB constraint catches any stray "appeals_committee"). * practice_area.py: derive_subtype now handles axis-B domain values (rishuy_uvniya/betterment_levy/compensation_197) without parsing the case number; new helper derive_domain_practice_area(). * Halacha re-extraction verified unnecessary — all 6 reclassified records already had is_binding=false and approved halachot. * Regression tests: 6 cases in tests/test_corpus_constraints.py covering practice_area enum, internal-committee chair/district, external-upload arar prefix, MCP guard. * UI: district input → Select dropdown (7 districts) in precedent-edit-sheet.tsx, preserving legacy free-text values. ## #37 בל"מ subtypes (sub-agent B) * 3 new appeal_subtypes: extension_request_{building_permit, betterment_levy,compensation}. APPEALS_COMMITTEE_SUBTYPES extended, SUBTYPES_BY_AREA mappings added. * New helpers: is_blam_subject(), is_blam_subtype(), derive_subtype_with_blam(case_number, subject, practice_area). case_create now uses it to auto-detect "בקשה להארכת מועד" subjects. * 3 methodology templates under docs/methodology/extension-request-*.md. * paperclip_client.py mapping updated for the 3 new subtypes (extension_request_building_permit→CMP, the other two→CMPA). * Frontend: bilingual "בל"מ" badge + filter dropdown on cases list + detail header; appeal-type-bars collapseBlam() merges בל"מ into its parent domain for aggregate bars. * Wizard auto-detects בל"מ from subject during case creation. * 3 Berlinger cases (1017/1018/1019-03-26) migrated to appeal_subtype=extension_request_building_permit via psql. ## #35 missing_precedents feature (sub-agent C) * Schema V13: missing_precedents table (citation, case_id, party, legal_topic, status, linked_case_law_id, claim_quote, ...) + FK constraints + 3 indexes. Applied via psql + idempotent migration. * 6 db.py service functions, 3 MCP tools, 6 FastAPI endpoints (POST/GET/PATCH/DELETE/upload — upload routes by citation prefix to ingest_internal_decision or ingest_precedent). * Next.js page /missing-precedents with 5 status tabs + filters + sidebar badge counter + detail drawer with metadata edit + smart upload form that switches fields per committee/court. * Bootstrap: 7 rows imported from the JSON file (3 citations × cases, all status=closed with linked_case_law_id). * legal-researcher.md: new §2ב.5 with missing_precedent_create usage + dedup semantics + tool grant. ## #36 legal_arguments aggregation (sub-agent D) * Schema V14: legal_arguments + legal_argument_propositions M:M. Applied via psql. * New service argument_aggregator.py with two functions — aggregate_claims_to_arguments() (Claude CLI / claude_session) and get_legal_arguments(). Graceful llm_unavailable handling when CLI is missing (containers). * 2 MCP tools + 2 API endpoints (POST .../aggregate-arguments as BackgroundTask, GET .../legal-arguments). * Frontend: shadcn Accordion + new legal-arguments-panel.tsx with hierarchical (party → priority badge → arguments) display, "טיעונים" tab on the case page, "חשב/חשב מחדש" buttons. * scripts/backfill_legal_arguments.py + SCRIPTS.md entry — dry-run found 8 candidate cases including 1017/1018/1019. ## Open follow-ups (intentionally deferred) * npm run api:types in web-ui (CLAUDE.md flow) — recommended before the next UI commit; not required for backend deployment. * Run backfill_legal_arguments.py --apply once the container picks up the new aggregator service. * webhook on missing-precedents upload-close to Paperclip (optional). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
111
web-ui/src/lib/api/legal-arguments.ts
Normal file
111
web-ui/src/lib/api/legal-arguments.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Legal Arguments domain — aggregated propositions (claim de-dup).
|
||||
*
|
||||
* Each raw "claim" is an extracted proposition from a litigation brief;
|
||||
* the LLM-driven aggregator groups them by party into 6-12 distinct
|
||||
* legal arguments. These hooks expose the read + trigger endpoints.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type LegalArgumentParty =
|
||||
| "appellant"
|
||||
| "respondent"
|
||||
| "committee"
|
||||
| "permit_applicant"
|
||||
| "unknown";
|
||||
|
||||
export type LegalArgumentPriority =
|
||||
| "threshold"
|
||||
| "substantive"
|
||||
| "procedural"
|
||||
| "relief";
|
||||
|
||||
export type LegalArgument = {
|
||||
id: string;
|
||||
case_id: string;
|
||||
party: LegalArgumentParty;
|
||||
argument_index: number;
|
||||
argument_title: string;
|
||||
argument_body: string;
|
||||
legal_topic: string | null;
|
||||
priority: LegalArgumentPriority;
|
||||
cited_precedents?: string[] | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
supporting_claims: string[];
|
||||
};
|
||||
|
||||
export type LegalArgumentsResponse = {
|
||||
case_number: string;
|
||||
total: number;
|
||||
by_party: Partial<Record<LegalArgumentParty, LegalArgument[]>>;
|
||||
arguments: LegalArgument[];
|
||||
};
|
||||
|
||||
export const legalArgumentsKeys = {
|
||||
all: ["legal-arguments"] as const,
|
||||
byCase: (caseNumber: string) =>
|
||||
[...legalArgumentsKeys.all, caseNumber] as const,
|
||||
};
|
||||
|
||||
export function useLegalArguments(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: legalArgumentsKeys.byCase(caseNumber ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<LegalArgumentsResponse>(
|
||||
`/api/cases/${caseNumber}/legal-arguments`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: Boolean(caseNumber),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export type AggregateArgumentsResult = {
|
||||
status: "started" | string;
|
||||
case_number: string;
|
||||
force: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function useAggregateArguments(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (force: boolean = false) =>
|
||||
apiRequest<AggregateArgumentsResult>(
|
||||
`/api/cases/${caseNumber}/aggregate-arguments${force ? "?force=true" : ""}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
if (caseNumber) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: legalArgumentsKeys.byCase(caseNumber),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const PARTY_LABELS_HE: Record<LegalArgumentParty, string> = {
|
||||
appellant: "עוררים",
|
||||
respondent: "משיבים",
|
||||
committee: "ועדה מקומית",
|
||||
permit_applicant: "מבקשי היתר",
|
||||
unknown: "צד לא מזוהה",
|
||||
};
|
||||
|
||||
export const PRIORITY_LABELS_HE: Record<LegalArgumentPriority, string> = {
|
||||
threshold: "סף",
|
||||
substantive: "מהותי",
|
||||
procedural: "פגם הליך",
|
||||
relief: "סעד",
|
||||
};
|
||||
|
||||
export const PRIORITY_ORDER: LegalArgumentPriority[] = [
|
||||
"threshold",
|
||||
"substantive",
|
||||
"procedural",
|
||||
"relief",
|
||||
];
|
||||
277
web-ui/src/lib/api/missing-precedents.ts
Normal file
277
web-ui/src/lib/api/missing-precedents.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Missing precedents — citations the parties brought up but that aren't
|
||||
* yet in the corpus.
|
||||
*
|
||||
* Lifecycle: 'open' → researcher logs gap → chair uploads decision via
|
||||
* the dialog → POST /upload routes to internal_decision_upload (ערר/בל"מ)
|
||||
* or precedent_library_upload (court rulings), then status flips to
|
||||
* 'closed' with linked_case_law_id set.
|
||||
*
|
||||
* Endpoints touched:
|
||||
* - POST /api/missing-precedents create (JSON body)
|
||||
* - GET /api/missing-precedents?status=open list (filters)
|
||||
* - GET /api/missing-precedents/{id} detail
|
||||
* - PATCH /api/missing-precedents/{id} metadata edit
|
||||
* - DELETE /api/missing-precedents/{id} remove
|
||||
* - POST /api/missing-precedents/{id}/upload multipart upload + close
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ApiError, apiRequest } from "./client";
|
||||
|
||||
export type CitedByParty =
|
||||
| "appellant"
|
||||
| "respondent"
|
||||
| "committee"
|
||||
| "permit_applicant"
|
||||
| "unknown";
|
||||
|
||||
export type MissingPrecedentStatus =
|
||||
| "open"
|
||||
| "uploaded"
|
||||
| "closed"
|
||||
| "irrelevant";
|
||||
|
||||
export type MissingPrecedent = {
|
||||
id: string;
|
||||
citation: string;
|
||||
case_name: string | null;
|
||||
cited_in_case_id: string | null;
|
||||
cited_in_case_number: string | null; // joined
|
||||
cited_in_document_id: string | null;
|
||||
cited_by_party: CitedByParty | null;
|
||||
cited_by_party_name: string | null;
|
||||
legal_topic: string | null;
|
||||
legal_issue: string | null;
|
||||
claim_quote: string | null;
|
||||
status: MissingPrecedentStatus;
|
||||
linked_case_law_id: string | null;
|
||||
linked_case_law_number: string | null;
|
||||
linked_case_law_name: string | null;
|
||||
closed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type MissingPrecedentListResponse = {
|
||||
items: MissingPrecedent[];
|
||||
count: number;
|
||||
by_status: Partial<Record<MissingPrecedentStatus, number>>;
|
||||
total_open: number;
|
||||
};
|
||||
|
||||
export type MissingPrecedentCreateInput = {
|
||||
citation: string;
|
||||
case_number?: string;
|
||||
cited_in_document_id?: string;
|
||||
cited_by_party?: CitedByParty;
|
||||
cited_by_party_name?: string;
|
||||
legal_topic?: string;
|
||||
legal_issue?: string;
|
||||
claim_quote?: string;
|
||||
case_name?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type MissingPrecedentPatch = Partial<{
|
||||
legal_topic: string;
|
||||
legal_issue: string;
|
||||
notes: string;
|
||||
cited_by_party: CitedByParty;
|
||||
cited_by_party_name: string;
|
||||
case_name: string;
|
||||
status: MissingPrecedentStatus;
|
||||
citation: string;
|
||||
claim_quote: string;
|
||||
}>;
|
||||
|
||||
export type MissingPrecedentFilters = {
|
||||
status?: MissingPrecedentStatus | "";
|
||||
caseNumber?: string;
|
||||
caseId?: string;
|
||||
legalTopic?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export const missingPrecedentKeys = {
|
||||
all: ["missing-precedents"] as const,
|
||||
list: (filters: MissingPrecedentFilters) =>
|
||||
[...missingPrecedentKeys.all, "list", filters] as const,
|
||||
detail: (id: string) => [...missingPrecedentKeys.all, "detail", id] as const,
|
||||
};
|
||||
|
||||
export function useMissingPrecedents(filters: MissingPrecedentFilters = {}) {
|
||||
return useQuery({
|
||||
queryKey: missingPrecedentKeys.list(filters),
|
||||
queryFn: ({ signal }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (filters.status) p.set("status", filters.status);
|
||||
if (filters.caseNumber) p.set("case_number", filters.caseNumber);
|
||||
if (filters.caseId) p.set("case_id", filters.caseId);
|
||||
if (filters.legalTopic) p.set("legal_topic", filters.legalTopic);
|
||||
if (filters.limit) p.set("limit", String(filters.limit));
|
||||
const qs = p.toString();
|
||||
return apiRequest<MissingPrecedentListResponse>(
|
||||
`/api/missing-precedents${qs ? `?${qs}` : ""}`,
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Counter for the sidebar / nav badge — open rows only. */
|
||||
export function useMissingPrecedentsOpenCount() {
|
||||
return useQuery({
|
||||
queryKey: [...missingPrecedentKeys.all, "open-count"] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<MissingPrecedentListResponse>(
|
||||
"/api/missing-precedents?status=open&limit=1",
|
||||
{ signal },
|
||||
),
|
||||
staleTime: 30_000,
|
||||
select: (data) => data.total_open,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMissingPrecedent(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: missingPrecedentKeys.detail(id ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<MissingPrecedent>(
|
||||
`/api/missing-precedents/${encodeURIComponent(id!)}`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: Boolean(id),
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateMissingPrecedent() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: MissingPrecedentCreateInput) =>
|
||||
apiRequest<MissingPrecedent>("/api/missing-precedents", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMissingPrecedent() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, patch }: { id: string; patch: MissingPrecedentPatch }) =>
|
||||
apiRequest<MissingPrecedent>(
|
||||
`/api/missing-precedents/${encodeURIComponent(id)}`,
|
||||
{ method: "PATCH", body: patch },
|
||||
),
|
||||
onSuccess: (_, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) });
|
||||
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMissingPrecedent() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiRequest<{ deleted: boolean }>(
|
||||
`/api/missing-precedents/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type MissingPrecedentUploadInput = {
|
||||
id: string;
|
||||
file: File;
|
||||
case_number?: string;
|
||||
chair_name?: string;
|
||||
district?: string;
|
||||
case_name?: string;
|
||||
court?: string;
|
||||
decision_date?: string;
|
||||
practice_area?: string;
|
||||
appeal_subtype?: string;
|
||||
subject_tags?: string[];
|
||||
is_binding?: boolean;
|
||||
headnote?: string;
|
||||
summary?: string;
|
||||
precedent_level?: string;
|
||||
source_type?: string;
|
||||
};
|
||||
|
||||
export function useUploadMissingPrecedent() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: MissingPrecedentUploadInput) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", input.file);
|
||||
if (input.case_number) fd.append("case_number", input.case_number);
|
||||
if (input.chair_name) fd.append("chair_name", input.chair_name);
|
||||
if (input.district) fd.append("district", input.district);
|
||||
if (input.case_name) fd.append("case_name", input.case_name);
|
||||
if (input.court) fd.append("court", input.court);
|
||||
if (input.decision_date) fd.append("decision_date", input.decision_date);
|
||||
if (input.practice_area) fd.append("practice_area", input.practice_area);
|
||||
if (input.appeal_subtype) fd.append("appeal_subtype", input.appeal_subtype);
|
||||
if (input.subject_tags && input.subject_tags.length) {
|
||||
fd.append("subject_tags", JSON.stringify(input.subject_tags));
|
||||
}
|
||||
fd.append("is_binding", String(input.is_binding ?? true));
|
||||
if (input.headnote) fd.append("headnote", input.headnote);
|
||||
if (input.summary) fd.append("summary", input.summary);
|
||||
if (input.precedent_level)
|
||||
fd.append("precedent_level", input.precedent_level);
|
||||
if (input.source_type) fd.append("source_type", input.source_type);
|
||||
|
||||
const res = await fetch(
|
||||
`/api/missing-precedents/${encodeURIComponent(input.id)}/upload`,
|
||||
{ method: "POST", body: fd },
|
||||
);
|
||||
const parsed = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
`Upload failed with ${res.status}`,
|
||||
res.status,
|
||||
parsed,
|
||||
);
|
||||
}
|
||||
return parsed as {
|
||||
missing_precedent: MissingPrecedent;
|
||||
case_law_id: string;
|
||||
route: "internal_committee" | "external_upload";
|
||||
};
|
||||
},
|
||||
onSuccess: (_, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) });
|
||||
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
||||
qc.invalidateQueries({ queryKey: ["precedent-library"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Hebrew labels for display. */
|
||||
export const CITED_BY_PARTY_LABELS: Record<CitedByParty, string> = {
|
||||
appellant: "עורר",
|
||||
respondent: "משיב",
|
||||
committee: "ועדה",
|
||||
permit_applicant: "מבקש היתר",
|
||||
unknown: "לא ידוע",
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<MissingPrecedentStatus, string> = {
|
||||
open: "פתוח",
|
||||
uploaded: "הועלה",
|
||||
closed: "נסגר",
|
||||
irrelevant: "לא רלוונטי",
|
||||
};
|
||||
Reference in New Issue
Block a user