feat: Stage A finalizers + #35/#36/#37 — critical-gap closure
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:
2026-05-26 08:34:40 +00:00
parent af651d0135
commit f3cc9ca9d4
33 changed files with 4588 additions and 37 deletions

View File

@@ -0,0 +1,222 @@
"use client";
import { useMemo } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
PARTY_LABELS_HE,
PRIORITY_LABELS_HE,
PRIORITY_ORDER,
useAggregateArguments,
useLegalArguments,
type LegalArgument,
type LegalArgumentParty,
type LegalArgumentPriority,
} from "@/lib/api/legal-arguments";
import { toast } from "sonner";
import { Loader2, RefreshCw, Sparkles } from "lucide-react";
const PRIORITY_BADGE_TONE: Record<LegalArgumentPriority, string> = {
threshold: "bg-danger-bg/60 text-danger-strong border-danger/40",
substantive: "bg-gold-soft/50 text-navy border-gold/40",
procedural: "bg-rule-soft text-ink border-rule",
relief: "bg-emerald-50 text-emerald-900 border-emerald-200",
};
function groupByPriority(
args: LegalArgument[],
): Record<LegalArgumentPriority, LegalArgument[]> {
const out: Record<LegalArgumentPriority, LegalArgument[]> = {
threshold: [],
substantive: [],
procedural: [],
relief: [],
};
for (const a of args) {
(out[a.priority] ?? out.substantive).push(a);
}
for (const key of PRIORITY_ORDER) {
out[key].sort((x, y) => x.argument_index - y.argument_index);
}
return out;
}
type PartySectionProps = {
party: LegalArgumentParty;
args: LegalArgument[];
};
function PartySection({ party, args }: PartySectionProps) {
const grouped = useMemo(() => groupByPriority(args), [args]);
return (
<div className="space-y-3">
<div className="flex items-baseline justify-between border-b border-rule pb-2">
<h3 className="text-navy text-base font-semibold">
{PARTY_LABELS_HE[party] ?? party}
</h3>
<span className="text-ink-muted text-xs">
{args.length} טיעונים
</span>
</div>
{PRIORITY_ORDER.map((priority) => {
const list = grouped[priority];
if (!list?.length) return null;
return (
<div key={priority} className="space-y-1">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={`${PRIORITY_BADGE_TONE[priority]} text-xs`}
>
{PRIORITY_LABELS_HE[priority]}
</Badge>
<span className="text-ink-muted text-xs">
{list.length} טיעונים
</span>
</div>
<Accordion type="multiple" className="rounded-md border border-rule bg-surface">
{list.map((arg) => (
<AccordionItem key={arg.id} value={arg.id} className="px-3">
<AccordionTrigger className="text-start">
<div className="flex flex-1 flex-col items-start gap-1">
<span className="text-navy text-sm font-medium leading-tight">
{arg.argument_index}. {arg.argument_title}
</span>
{arg.legal_topic && (
<span className="text-ink-muted text-xs">
{arg.legal_topic}
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2 px-1">
<p className="text-ink leading-relaxed whitespace-pre-line">
{arg.argument_body}
</p>
{arg.supporting_claims.length > 0 && (
<p className="text-ink-muted text-xs">
מסתמך על {arg.supporting_claims.length} פרופוזיציות
גולמיות.
</p>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
})}
</div>
);
}
type LegalArgumentsPanelProps = {
caseNumber: string;
};
export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
const { data, isPending, isError, error } = useLegalArguments(caseNumber);
const aggregate = useAggregateArguments(caseNumber);
const parties = useMemo<LegalArgumentParty[]>(() => {
if (!data?.by_party) return [];
const order: LegalArgumentParty[] = [
"appellant",
"respondent",
"committee",
"permit_applicant",
"unknown",
];
return order.filter((p) => (data.by_party[p]?.length ?? 0) > 0);
}, [data]);
const handleAggregate = (force: boolean) => {
aggregate.mutate(force, {
onSuccess: () => {
toast.success(
force
? "הופעלה חזרה חישוב טיעונים (force). יסתיים תוך דקה."
: "הופעל חישוב טיעונים. רענן בעוד דקה.",
);
},
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
});
};
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-navy text-base font-semibold">
טיעונים משפטיים
</h2>
<p className="text-ink-muted text-xs mt-0.5">
טיעונים מאוגדים מתוך הפרופוזיציות הגולמיות, מקובצים לפי צד וקדימות.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={aggregate.isPending}
onClick={() => handleAggregate(false)}
>
{aggregate.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
) : (
<Sparkles className="w-3.5 h-3.5 me-1.5" />
)}
חשב טיעונים
</Button>
<Button
variant="ghost"
size="sm"
disabled={aggregate.isPending || !data?.total}
onClick={() => handleAggregate(true)}
title="חישוב מחדש (מוחק טיעונים קיימים)"
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{isPending ? (
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : isError ? (
<p className="text-danger text-sm">
שגיאה בטעינת טיעונים: {(error as Error).message}
</p>
) : !data?.total ? (
<p className="text-ink-muted text-sm">
אין טיעונים מאוגדים עדיין. לחץ "חשב טיעונים" כדי להריץ את ה-aggregator.
</p>
) : (
<div className="space-y-6">
{parties.map((party) => (
<PartySection
key={party}
party={party}
args={data.by_party[party] ?? []}
/>
))}
</div>
)}
</CardContent>
</Card>
);
}