Merge pull request 'feat(arguments): הפרדת-משיבים בצבירה לפי כתב-תשובה (#224)' (#392) from worktree-respondent-party-separation into main
This commit was merged in pull request #392.
This commit is contained in:
@@ -31,6 +31,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Allowed enum values mirror the DB CHECK constraints.
|
# Allowed enum values mirror the DB CHECK constraints.
|
||||||
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
||||||
|
# Sides that may comprise multiple distinct litigants with potentially opposing
|
||||||
|
# positions — aggregated per source pleading (party_name) rather than collapsed
|
||||||
|
# into one bucket (#224). Appellant/committee speak with a single voice.
|
||||||
|
SPLIT_PARTIES = {"respondent", "permit_applicant"}
|
||||||
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
||||||
|
|
||||||
# Hebrew labels for the prompt (Claude needs context in the same
|
# Hebrew labels for the prompt (Claude needs context in the same
|
||||||
@@ -81,7 +85,7 @@ AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום ת
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(party: str, propositions: list[dict]) -> str:
|
def _build_prompt(party: str, propositions: list[dict], party_name: str = "") -> str:
|
||||||
"""Compose the per-party aggregation prompt."""
|
"""Compose the per-party aggregation prompt."""
|
||||||
n = len(propositions)
|
n = len(propositions)
|
||||||
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
||||||
@@ -89,6 +93,11 @@ def _build_prompt(party: str, propositions: list[dict]) -> str:
|
|||||||
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
||||||
|
|
||||||
party_he = PARTY_LABELS_HE.get(party, party)
|
party_he = PARTY_LABELS_HE.get(party, party)
|
||||||
|
# For a split side (e.g. a specific respondent brief), name the brief so
|
||||||
|
# Claude aggregates only that litigant's position and does not conflate it
|
||||||
|
# with a co-respondent's separate — possibly opposing — pleading (#224).
|
||||||
|
if party_name:
|
||||||
|
party_he = f"{party_he} — {party_name}"
|
||||||
# Strip noise from propositions for the prompt — Claude only needs
|
# Strip noise from propositions for the prompt — Claude only needs
|
||||||
# the id and the text to do the grouping.
|
# the id and the text to do the grouping.
|
||||||
compact = [
|
compact = [
|
||||||
@@ -139,12 +148,17 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
async def _aggregate_party(
|
async def _aggregate_party(
|
||||||
party: str, propositions: list[dict],
|
party: str, propositions: list[dict], party_name: str = "",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Ask Claude to group one party's propositions; return normalized rows."""
|
"""Ask Claude to group one party's propositions; return normalized rows.
|
||||||
|
|
||||||
|
``party_name`` names the specific pleading when this is a split side
|
||||||
|
(respondent / permit_applicant brief), so the prompt scopes to that
|
||||||
|
litigant's position only (#224).
|
||||||
|
"""
|
||||||
if not propositions:
|
if not propositions:
|
||||||
return []
|
return []
|
||||||
prompt = _build_prompt(party, propositions)
|
prompt = _build_prompt(party, propositions, party_name=party_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
||||||
@@ -221,14 +235,25 @@ async def aggregate_claims_to_arguments(
|
|||||||
"total": 0,
|
"total": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Group propositions by party.
|
# Group propositions by party — and, for the multi-party sides (respondent /
|
||||||
by_party: dict[str, list[dict]] = {}
|
# permit_applicant), further by their source pleading so opposing joint
|
||||||
|
# briefs (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") aggregate into
|
||||||
|
# SEPARATE argument sets instead of collapsing into one "respondent" bucket
|
||||||
|
# (#224). Appellant/committee stay single-group (party_name=""). Splitting
|
||||||
|
# the large respondent set per-brief also keeps each Claude call small enough
|
||||||
|
# to succeed — a single 100+ proposition call previously returned non-JSON
|
||||||
|
# and silently dropped the whole side.
|
||||||
|
by_group: dict[tuple[str, str], list[dict]] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
party = r["party_role"]
|
party = r["party_role"]
|
||||||
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
||||||
if party not in ALLOWED_PARTIES:
|
if party not in ALLOWED_PARTIES:
|
||||||
party = "unknown"
|
party = "unknown"
|
||||||
by_party.setdefault(party, []).append(dict(r))
|
party_name = (
|
||||||
|
(r["source_document"] or "").strip()
|
||||||
|
if party in SPLIT_PARTIES else ""
|
||||||
|
)
|
||||||
|
by_group.setdefault((party, party_name), []).append(dict(r))
|
||||||
|
|
||||||
# Valid claim_ids for this case == the ids of the claims we just fetched.
|
# Valid claim_ids for this case == the ids of the claims we just fetched.
|
||||||
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate
|
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate
|
||||||
@@ -243,9 +268,11 @@ async def aggregate_claims_to_arguments(
|
|||||||
inserted = 0
|
inserted = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
for party, props in by_party.items():
|
for (party, party_name), props in by_group.items():
|
||||||
|
# Display key for the per-side summary: keep opposing briefs distinct.
|
||||||
|
group_key = f"{party}·{party_name}" if party_name else party
|
||||||
try:
|
try:
|
||||||
arguments = await _aggregate_party(party, props)
|
arguments = await _aggregate_party(party, props, party_name=party_name)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
# Most likely cause: Claude CLI not installed (running from
|
# Most likely cause: Claude CLI not installed (running from
|
||||||
# the container). Don't crash — record the gap and continue.
|
# the container). Don't crash — record the gap and continue.
|
||||||
@@ -259,11 +286,11 @@ async def aggregate_claims_to_arguments(
|
|||||||
),
|
),
|
||||||
"total": 0,
|
"total": 0,
|
||||||
}
|
}
|
||||||
errors.append(f"{party}: {msg}")
|
errors.append(f"{group_key}: {msg}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not arguments:
|
if not arguments:
|
||||||
party_counts[party] = 0
|
party_counts[group_key] = 0
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -271,12 +298,13 @@ async def aggregate_claims_to_arguments(
|
|||||||
for idx, arg in enumerate(arguments):
|
for idx, arg in enumerate(arguments):
|
||||||
arg_id = await conn.fetchval(
|
arg_id = await conn.fetchval(
|
||||||
"""INSERT INTO legal_arguments
|
"""INSERT INTO legal_arguments
|
||||||
(case_id, party, argument_index, argument_title,
|
(case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority)
|
argument_title, argument_body, legal_topic, priority)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id""",
|
RETURNING id""",
|
||||||
case_id,
|
case_id,
|
||||||
party,
|
party,
|
||||||
|
party_name,
|
||||||
idx + 1,
|
idx + 1,
|
||||||
arg["title"],
|
arg["title"],
|
||||||
arg["body"],
|
arg["body"],
|
||||||
@@ -313,7 +341,7 @@ async def aggregate_claims_to_arguments(
|
|||||||
cid, arg_id, e,
|
cid, arg_id, e,
|
||||||
)
|
)
|
||||||
inserted += 1
|
inserted += 1
|
||||||
party_counts[party] = len(arguments)
|
party_counts[group_key] = len(arguments)
|
||||||
|
|
||||||
result: dict = {
|
result: dict = {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -338,9 +366,9 @@ async def get_legal_arguments(
|
|||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
if party and party in ALLOWED_PARTIES:
|
if party and party in ALLOWED_PARTIES:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
"""SELECT id, case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority, cited_precedents,
|
argument_title, argument_body, legal_topic, priority,
|
||||||
created_at, updated_at
|
cited_precedents, created_at, updated_at
|
||||||
FROM legal_arguments
|
FROM legal_arguments
|
||||||
WHERE case_id = $1 AND party = $2
|
WHERE case_id = $1 AND party = $2
|
||||||
ORDER BY priority, argument_index""",
|
ORDER BY priority, argument_index""",
|
||||||
@@ -348,12 +376,12 @@ async def get_legal_arguments(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
"""SELECT id, case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority, cited_precedents,
|
argument_title, argument_body, legal_topic, priority,
|
||||||
created_at, updated_at
|
cited_precedents, created_at, updated_at
|
||||||
FROM legal_arguments
|
FROM legal_arguments
|
||||||
WHERE case_id = $1
|
WHERE case_id = $1
|
||||||
ORDER BY party, priority, argument_index""",
|
ORDER BY party, party_name, priority, argument_index""",
|
||||||
case_id,
|
case_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1888,6 +1888,18 @@ CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# V50 (#224): per-respondent-brief separation. The aggregator groups respondent /
|
||||||
|
# permit_applicant claims by their source pleading — each joint response brief
|
||||||
|
# (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") is one coherent litigation
|
||||||
|
# position, so opposing briefs no longer collapse into a single "respondent"
|
||||||
|
# bucket. ``party_name`` carries the brief label; appellant/committee stay ''.
|
||||||
|
SCHEMA_V50_SQL = """
|
||||||
|
ALTER TABLE legal_arguments ADD COLUMN IF NOT EXISTS party_name TEXT NOT NULL DEFAULT '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_legal_arguments_party_name
|
||||||
|
ON legal_arguments(case_id, party, party_name);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||||
# re-runs the idempotent migrations on startup; without this lock two processes
|
# re-runs the idempotent migrations on startup; without this lock two processes
|
||||||
@@ -1959,6 +1971,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V47_SQL)
|
await conn.execute(SCHEMA_V47_SQL)
|
||||||
await conn.execute(SCHEMA_V48_SQL)
|
await conn.execute(SCHEMA_V48_SQL)
|
||||||
await conn.execute(SCHEMA_V49_SQL)
|
await conn.execute(SCHEMA_V49_SQL)
|
||||||
|
await conn.execute(SCHEMA_V50_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
|
|||||||
@@ -76,11 +76,50 @@ type PartySectionProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inner body of a single party — the priority-grouping + per-argument inner
|
* Inner body of a single party. For a multi-litigant side (respondent /
|
||||||
* accordion. The party header (name / sub / count) now lives on the enclosing
|
* permit_applicant) whose arguments carry distinct ``party_name`` brief labels,
|
||||||
* party-accordion trigger (mockup 25b), so this renders the groups only.
|
* we sub-group by brief first — so opposing joint briefs (e.g. "משיבות 2-3" vs
|
||||||
|
* "משיבים 4-6") read as separate positions rather than one merged list (#224).
|
||||||
|
* Single-voice sides (one empty brief) render the priority groups directly.
|
||||||
*/
|
*/
|
||||||
function PartySection({ args }: PartySectionProps) {
|
function PartySection({ args }: PartySectionProps) {
|
||||||
|
const briefs = useMemo(() => {
|
||||||
|
const byBrief = new Map<string, LegalArgument[]>();
|
||||||
|
for (const a of args) {
|
||||||
|
const key = a.party_name?.trim() ?? "";
|
||||||
|
const list = byBrief.get(key);
|
||||||
|
if (list) list.push(a);
|
||||||
|
else byBrief.set(key, [a]);
|
||||||
|
}
|
||||||
|
return [...byBrief.entries()];
|
||||||
|
}, [args]);
|
||||||
|
|
||||||
|
// No real per-brief split (single, unnamed group) → render groups directly.
|
||||||
|
if (briefs.length <= 1) {
|
||||||
|
return <PriorityGroups args={briefs[0]?.[1] ?? args} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{briefs.map(([brief, briefArgs]) => (
|
||||||
|
<div key={brief || "—"} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 border-s-2 border-gold-deep ps-2">
|
||||||
|
<span className="text-navy text-sm font-semibold">
|
||||||
|
{brief || "ללא שיוך לכתב"}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-muted text-xs">
|
||||||
|
{briefArgs.length} טיעונים
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<PriorityGroups args={briefArgs} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Priority-grouped argument accordion for one coherent set of arguments. */
|
||||||
|
function PriorityGroups({ args }: PartySectionProps) {
|
||||||
const grouped = useMemo(() => groupByPriority(args), [args]);
|
const grouped = useMemo(() => groupByPriority(args), [args]);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export type LegalArgument = {
|
|||||||
id: string;
|
id: string;
|
||||||
case_id: string;
|
case_id: string;
|
||||||
party: LegalArgumentParty;
|
party: LegalArgumentParty;
|
||||||
|
/**
|
||||||
|
* The specific pleading this argument belongs to, for sides that may comprise
|
||||||
|
* several litigants with opposing positions (respondent / permit_applicant
|
||||||
|
* briefs). Empty for single-voice sides (appellant / committee). (#224)
|
||||||
|
*/
|
||||||
|
party_name?: string;
|
||||||
argument_index: number;
|
argument_index: number;
|
||||||
argument_title: string;
|
argument_title: string;
|
||||||
argument_body: string;
|
argument_body: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user