feat(learning): FU-2 UI — התלבטות-הפאנל במסך-אישור היו"ר (#133) #220
@@ -4466,6 +4466,7 @@ async def list_halachot(
|
|||||||
order_by_priority: bool = False,
|
order_by_priority: bool = False,
|
||||||
cluster: bool = False,
|
cluster: bool = False,
|
||||||
include_equivalents: bool = False,
|
include_equivalents: bool = False,
|
||||||
|
include_panel_round: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""List halachot with optional triage controls (#84).
|
"""List halachot with optional triage controls (#84).
|
||||||
|
|
||||||
@@ -4549,9 +4550,47 @@ async def list_halachot(
|
|||||||
await _annotate_clusters(pool, out)
|
await _annotate_clusters(pool, out)
|
||||||
if include_equivalents and out:
|
if include_equivalents and out:
|
||||||
await _annotate_equivalents(pool, out)
|
await _annotate_equivalents(pool, out)
|
||||||
|
if include_panel_round and out:
|
||||||
|
await _annotate_panel_rounds(pool, out)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _annotate_panel_rounds(pool, out: list[dict]) -> None:
|
||||||
|
"""Attach the LATEST 3-judge panel round to each row (#133/FU-2), display-only.
|
||||||
|
|
||||||
|
Surfaces the panel's deliberation — each lineage's vote + rationale and the
|
||||||
|
derived verdict — inside the chair's review card, so her decision (the gold
|
||||||
|
label the loop learns from) is informed by *why* the judges split. Reads the
|
||||||
|
capture table halacha_panel_rounds; never affects review_status (INV-G10)."""
|
||||||
|
ids = [d["id"] for d in out]
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT DISTINCT ON (halacha_id) halacha_id, question, verdict, "
|
||||||
|
"applied_action, round_ts, claude_vote, claude_reason, deepseek_vote, "
|
||||||
|
"deepseek_reason, gemini_vote, gemini_reason "
|
||||||
|
"FROM halacha_panel_rounds WHERE halacha_id = ANY($1::uuid[]) "
|
||||||
|
"ORDER BY halacha_id, round_ts DESC",
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
by_id = {
|
||||||
|
str(r["halacha_id"]): {
|
||||||
|
"question": r["question"],
|
||||||
|
"verdict": r["verdict"],
|
||||||
|
"applied_action": r["applied_action"],
|
||||||
|
"round_ts": r["round_ts"].isoformat() if r["round_ts"] else None,
|
||||||
|
"judges": [
|
||||||
|
{"model": "claude", "vote": r["claude_vote"], "reason": r["claude_reason"]},
|
||||||
|
{"model": "deepseek", "vote": r["deepseek_vote"], "reason": r["deepseek_reason"]},
|
||||||
|
{"model": "gemini", "vote": r["gemini_vote"], "reason": r["gemini_reason"]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
}
|
||||||
|
for d in out:
|
||||||
|
pr = by_id.get(str(d["id"]))
|
||||||
|
if pr:
|
||||||
|
d["panel_round"] = pr
|
||||||
|
|
||||||
|
|
||||||
async def _annotate_clusters(pool, out: list[dict]) -> None:
|
async def _annotate_clusters(pool, out: list[dict]) -> None:
|
||||||
"""Add cluster_id + cluster_size to each row (#84.2), display-only.
|
"""Add cluster_id + cluster_size to each row (#84.2), display-only.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw } from "lucide-react";
|
import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw, Info } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -43,6 +43,85 @@ function cleanCitation(s: string | null | undefined): string {
|
|||||||
|
|
||||||
type EditState = { rule_statement: string; reasoning_summary: string };
|
type EditState = { rule_statement: string; reasoning_summary: string };
|
||||||
|
|
||||||
|
// ─── Panel deliberation (#133/FU-2) ───────────────────────────────────────────
|
||||||
|
// Surfaces the 3-judge panel's vote+rationale inside the chair's review card so
|
||||||
|
// her decision — the gold label the active-learning loop learns from — is informed
|
||||||
|
// by WHY the panel split. Display-only; never affects review_status (INV-G10).
|
||||||
|
|
||||||
|
const JUDGE_META: Record<string, { name: string; lineage: string }> = {
|
||||||
|
claude: { name: "Claude", lineage: "Anthropic" },
|
||||||
|
deepseek: { name: "DeepSeek", lineage: "DeepSeek" },
|
||||||
|
gemini: { name: "Gemini", lineage: "Google" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const VERDICT_META: Record<string, { label: string; cls: string }> = {
|
||||||
|
unanimous_yes: { label: "פה-אחד", cls: "bg-success text-white" },
|
||||||
|
unanimous_no: { label: "פה-אחד נגד", cls: "bg-danger text-white" },
|
||||||
|
split: { label: "פיצול", cls: "bg-warn text-white" },
|
||||||
|
incomplete: { label: "חלקי", cls: "bg-ink-muted text-white" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function PanelDeliberation({ round }: { round: NonNullable<Halacha["panel_round"]> }) {
|
||||||
|
const isEntail = round.question === "entailed";
|
||||||
|
const yesLabel = isEntail ? "נתמך" : "לשמירה";
|
||||||
|
const noLabel = isEntail ? "הכלל חורג" : "לפסילה";
|
||||||
|
const question = isEntail
|
||||||
|
? "האם הציטוט תומך בכלל ואינו מרחיב מעבר לו?"
|
||||||
|
: "האם זו הלכה בת-הכללה לשמירה?";
|
||||||
|
const votes = round.judges.map((j) => j.vote).filter((v): v is boolean => v !== null);
|
||||||
|
const yes = votes.filter(Boolean).length;
|
||||||
|
const no = votes.length - yes;
|
||||||
|
const v = VERDICT_META[round.verdict] ?? VERDICT_META.incomplete;
|
||||||
|
const ratio = round.verdict === "split" ? ` ${Math.max(yes, no)}:${Math.min(yes, no)}` : "";
|
||||||
|
// The strict-vs-gist explainer only makes sense on a split over entailment.
|
||||||
|
const showHint = round.verdict === "split" && isEntail;
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-rule overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap bg-navy text-parchment px-3 py-2">
|
||||||
|
<span className="text-[0.72rem] font-bold">התלבטות הפאנל</span>
|
||||||
|
<span className={`rounded-full text-[0.62rem] font-bold px-2 py-0.5 ${v.cls}`}>
|
||||||
|
{v.label}{ratio}
|
||||||
|
</span>
|
||||||
|
<span className="ms-auto text-[0.66rem] text-parchment/70" dir="rtl">
|
||||||
|
השאלה: ״{question}״
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{round.judges.map((j) => {
|
||||||
|
const yesVote = j.vote === true;
|
||||||
|
const noVote = j.vote === false;
|
||||||
|
const meta = JUDGE_META[j.model] ?? { name: j.model, lineage: "" };
|
||||||
|
return (
|
||||||
|
<div key={j.model}
|
||||||
|
className="grid grid-cols-[5.5rem_6.5rem_1fr] gap-2 items-start px-3 py-2 border-t border-rule-soft">
|
||||||
|
<div className="text-[0.72rem] font-semibold text-navy leading-tight">
|
||||||
|
{meta.name}
|
||||||
|
<span className="block text-[0.6rem] text-ink-muted font-medium">{meta.lineage}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex items-center justify-self-start rounded-md text-[0.66rem] font-semibold px-2 py-0.5 whitespace-nowrap ${
|
||||||
|
yesVote ? "bg-success-bg text-success"
|
||||||
|
: noVote ? "bg-danger-bg text-danger"
|
||||||
|
: "bg-rule-soft text-ink-muted"
|
||||||
|
}`}>
|
||||||
|
{j.vote === null ? "— ללא" : yesVote ? `✓ ${yesLabel}` : `✗ ${noLabel}`}
|
||||||
|
</span>
|
||||||
|
<p className="text-[0.72rem] text-ink-soft leading-snug" dir="rtl">{j.reason || "—"}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showHint && (
|
||||||
|
<div className="flex gap-2 items-start bg-info-bg text-[0.68rem] leading-relaxed px-3 py-2 border-t border-rule-soft" dir="rtl">
|
||||||
|
<Info className="w-3.5 h-3.5 mt-0.5 shrink-0 text-info" />
|
||||||
|
<p>
|
||||||
|
<b className="text-info">שורש המחלוקת:</b>{" "}
|
||||||
|
סטנדרט קפדני (כל פרט בכלל חייב להופיע בציטוט) מול סטנדרט-תמצית (הגרעין המשפטי נתמך).
|
||||||
|
הכרעתך קובעת איזה סטנדרט הפאנל יאמץ.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Pending-queue card (full interactions) ───────────────────────────────────
|
// ─── Pending-queue card (full interactions) ───────────────────────────────────
|
||||||
|
|
||||||
function HalachaCard({
|
function HalachaCard({
|
||||||
@@ -158,6 +237,8 @@ function HalachaCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{h.panel_round && <PanelDeliberation round={h.panel_round} />}
|
||||||
|
|
||||||
{(editing || h.reasoning_summary) && (
|
{(editing || h.reasoning_summary) && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[0.7rem] text-ink-muted mb-1">תמצית ההיגיון</div>
|
<div className="text-[0.7rem] text-ink-muted mb-1">תמצית ההיגיון</div>
|
||||||
|
|||||||
@@ -111,6 +111,21 @@ export type Halacha = {
|
|||||||
rule_statement: string;
|
rule_statement: string;
|
||||||
cosine: number | null;
|
cosine: number | null;
|
||||||
}[];
|
}[];
|
||||||
|
/* #133/FU-2 — the latest 3-judge panel deliberation (present only when fetched
|
||||||
|
* with include_panel_round). Surfaced in the review card so the chair sees WHY
|
||||||
|
* the panel split before she decides; her decision is the gold label the
|
||||||
|
* active-learning loop learns from. Capture-only — does not affect review_status. */
|
||||||
|
panel_round?: {
|
||||||
|
question: "keep" | "entailed";
|
||||||
|
verdict: "unanimous_yes" | "unanimous_no" | "split" | "incomplete";
|
||||||
|
applied_action: string;
|
||||||
|
round_ts: string | null;
|
||||||
|
judges: {
|
||||||
|
model: "claude" | "deepseek" | "gemini";
|
||||||
|
vote: boolean | null;
|
||||||
|
reason: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RelatedCase = {
|
export type RelatedCase = {
|
||||||
@@ -597,9 +612,11 @@ export function useHalachotPending(
|
|||||||
) {
|
) {
|
||||||
const { limit = 200, needsFix = false } = opts;
|
const { limit = 200, needsFix = false } = opts;
|
||||||
const qs = needsFix
|
const qs = needsFix
|
||||||
? `review_status=pending_review&exclude_low_quality=false&limit=${limit}`
|
? `review_status=pending_review&exclude_low_quality=false`
|
||||||
|
+ `&include_panel_round=true&limit=${limit}`
|
||||||
: `review_status=pending_review&exclude_low_quality=true`
|
: `review_status=pending_review&exclude_low_quality=true`
|
||||||
+ `&order_by_priority=true&cluster=true&include_equivalents=true&limit=${limit}`;
|
+ `&order_by_priority=true&cluster=true&include_equivalents=true`
|
||||||
|
+ `&include_panel_round=true&limit=${limit}`;
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [...libraryKeys.halachotPending(), needsFix ? "needsfix" : "clean"],
|
queryKey: [...libraryKeys.halachotPending(), needsFix ? "needsfix" : "clean"],
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
|
|||||||
10
web/app.py
10
web/app.py
@@ -7123,12 +7123,15 @@ async def halachot_list(
|
|||||||
order_by_priority: bool = False,
|
order_by_priority: bool = False,
|
||||||
cluster: bool = False,
|
cluster: bool = False,
|
||||||
include_equivalents: bool = False,
|
include_equivalents: bool = False,
|
||||||
|
include_panel_round: bool = False,
|
||||||
):
|
):
|
||||||
"""List halachot. ``exclude_low_quality`` hides flagged items (#84.1),
|
"""List halachot. ``exclude_low_quality`` hides flagged items (#84.1),
|
||||||
``order_by_priority`` switches to the active-learning order (#84.3),
|
``order_by_priority`` switches to the active-learning order (#84.3),
|
||||||
``cluster`` annotates near-duplicate groups for one-card review (#84.2), and
|
``cluster`` annotates near-duplicate groups for one-card review (#84.2),
|
||||||
``include_equivalents`` attaches cross-precedent parallel-authority links. All
|
``include_equivalents`` attaches cross-precedent parallel-authority links, and
|
||||||
default off so existing callers are unaffected; the review queue opts in."""
|
``include_panel_round`` attaches the latest 3-judge panel deliberation so the
|
||||||
|
chair sees why the panel split (#133/FU-2). All default off so existing callers
|
||||||
|
are unaffected; the review queue opts in."""
|
||||||
cid: UUID | None = None
|
cid: UUID | None = None
|
||||||
if case_law_id:
|
if case_law_id:
|
||||||
try:
|
try:
|
||||||
@@ -7144,6 +7147,7 @@ async def halachot_list(
|
|||||||
order_by_priority=order_by_priority,
|
order_by_priority=order_by_priority,
|
||||||
cluster=cluster,
|
cluster=cluster,
|
||||||
include_equivalents=include_equivalents,
|
include_equivalents=include_equivalents,
|
||||||
|
include_panel_round=include_panel_round,
|
||||||
)
|
)
|
||||||
return {"items": rows, "count": len(rows)}
|
return {"items": rows, "count": len(rows)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user