Merge pull request 'feat: תיקון-ציטוט בדלי-החילוץ + קישור-לתור מדף-פרט (#133 follow-ups)' (#230) from worktree-halacha-quote-fix-and-detail-link into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m32s
G12 Leak-Guard / leak-guard (push) Successful in 5s

This commit was merged in pull request #230.
This commit is contained in:
2026-06-12 09:04:01 +00:00
6 changed files with 96 additions and 25 deletions

View File

@@ -4658,12 +4658,40 @@ async def update_halacha(
reasoning_summary: str | None = None, reasoning_summary: str | None = None,
subject_tags: list[str] | None = None, subject_tags: list[str] | None = None,
practice_areas: list[str] | None = None, practice_areas: list[str] | None = None,
supporting_quote: str | None = None,
) -> dict | None: ) -> dict | None:
"""Update a halacha — used by the chair to approve/reject/edit.""" """Update a halacha — used by the chair to approve/reject/edit.
#133 follow-up — extraction repair: when ``supporting_quote`` is edited it is
RE-VERIFIED against the stored source text (deterministic; no re-OCR / no LLM —
see feedback_no_reocr_retrofit) and ``quote_verified`` + the ``quote_unverified``
quality flag are synced. A chair who pastes the correct quote from the source
clears the flag, and the item leaves the 'fix' bucket."""
pool = await get_pool() pool = await get_pool()
set_parts: list[str] = [] set_parts: list[str] = []
params: list = [halacha_id] params: list = [halacha_id]
idx = 2 idx = 2
if supporting_quote is not None:
set_parts.append(f"supporting_quote = ${idx}")
params.append(supporting_quote)
idx += 1
# re-verify against the stored full_text, sync quote_verified + the flag
src = await pool.fetchrow(
"SELECT cl.full_text, h.quality_flags FROM halachot h "
"JOIN case_law cl ON cl.id = h.case_law_id WHERE h.id = $1", halacha_id)
if src is not None:
from legal_mcp.services.halacha_extractor import _verify_quote
verified = _verify_quote(supporting_quote, src["full_text"] or "")
set_parts.append(f"quote_verified = ${idx}")
params.append(verified)
idx += 1
flags = [f for f in (src["quality_flags"] or [])
if f != halacha_quality.FLAG_QUOTE_UNVERIFIED]
if not verified:
flags.append(halacha_quality.FLAG_QUOTE_UNVERIFIED)
set_parts.append(f"quality_flags = ${idx}")
params.append(flags)
idx += 1
if review_status is not None: if review_status is not None:
set_parts.append(f"review_status = ${idx}") set_parts.append(f"review_status = ${idx}")
params.append(review_status) params.append(review_status)

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
@@ -52,10 +53,22 @@ function IncomingPill() {
return <CountPill n={data?.by_status?.open ?? 0} tone="info" />; return <CountPill n={data?.by_status?.open ?? 0} tone="info" />;
} }
const PRECEDENT_TABS = new Set(["library", "search", "review", "incoming", "stats"]);
export default function PrecedentsPage() { export default function PrecedentsPage() {
// Controlled so a deep link like /precedents?tab=review (e.g. from a pending
// halacha on a precedent-detail page, #133) lands on the right tab. Read after
// mount to avoid an SSR/CSR mismatch and the useSearchParams Suspense rule.
const [tab, setTab] = useState("library");
useEffect(() => {
// read post-mount (not lazy init) to avoid an SSR/CSR hydration mismatch
const t = new URLSearchParams(window.location.search).get("tab");
// eslint-disable-next-line react-hooks/set-state-in-effect
if (t && PRECEDENT_TABS.has(t)) setTab(t);
}, []);
return ( return (
<AppShell> <AppShell>
<Tabs defaultValue="library" dir="rtl"> <Tabs value={tab} onValueChange={setTab} dir="rtl">
<section className="space-y-6"> <section className="space-y-6">
<header className="space-y-3"> <header className="space-y-3">
<nav className="text-[0.78rem] text-ink-muted"> <nav className="text-[0.78rem] text-ink-muted">

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Check, X, RotateCcw } from "lucide-react"; import Link from "next/link";
import { Check, X, RotateCcw, ArrowLeft } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -250,23 +251,19 @@ export function ExtractedHalachotSection({ halachot }: { halachot: Halacha[] })
</Button> </Button>
</> </>
)} )}
{/* #133 — the chair approves in the unified queue, not here:
a pending halacha links to the queue instead of duplicating the
approve/reject card (single gate, INV-IA/G10). */}
{h.review_status === "pending_review" && ( {h.review_status === "pending_review" && (
<> <Link
<Button size="sm" variant="ghost" disabled={update.isPending} href="/precedents?tab=review"
onClick={() => { className="inline-flex items-center gap-1.5 text-[0.78rem] font-semibold
if (window.confirm("לדחות הלכה זו?")) setStatus(h, "rejected"); text-gold-deep bg-gold-wash border border-gold/40 rounded-md px-3 py-1.5
}} hover:bg-gold-wash/70 transition-colors"
className="text-danger hover:text-danger hover:bg-danger-bg"> >
<X className="w-3.5 h-3.5 me-1" /> עבור לתור הלכות
דחה <ArrowLeft className="w-3.5 h-3.5" />
</Button> </Link>
<Button size="sm" disabled={update.isPending}
onClick={() => setStatus(h, "approved")}
className="bg-gold text-navy hover:bg-gold-deep">
<Check className="w-3.5 h-3.5 me-1" />
אשר
</Button>
</>
)} )}
</div> </div>
</li> </li>

View File

@@ -42,7 +42,11 @@ function cleanCitation(s: string | null | undefined): string {
return s.replace(/[--]/g, "").trim(); return s.replace(/[--]/g, "").trim();
} }
type EditState = { rule_statement: string; reasoning_summary: string }; type EditState = {
rule_statement: string;
reasoning_summary: string;
supporting_quote: string; // #133 — editing this re-verifies vs the source
};
// ─── Panel deliberation (#133/FU-2) ─────────────────────────────────────────── // ─── Panel deliberation (#133/FU-2) ───────────────────────────────────────────
// Surfaces the 3-judge panel's vote+rationale inside the chair's review card so // Surfaces the 3-judge panel's vote+rationale inside the chair's review card so
@@ -143,6 +147,7 @@ function HalachaCard({
const [draft, setDraft] = useState<EditState>({ const [draft, setDraft] = useState<EditState>({
rule_statement: h.rule_statement, rule_statement: h.rule_statement,
reasoning_summary: h.reasoning_summary, reasoning_summary: h.reasoning_summary,
supporting_quote: h.supporting_quote,
}); });
useEffect(() => { useEffect(() => {
@@ -150,11 +155,21 @@ function HalachaCard({
setDraft({ setDraft({
rule_statement: h.rule_statement, rule_statement: h.rule_statement,
reasoning_summary: h.reasoning_summary, reasoning_summary: h.reasoning_summary,
supporting_quote: h.supporting_quote,
}); });
}, [h.id, h.rule_statement, h.reasoning_summary]); }, [h.id, h.rule_statement, h.reasoning_summary, h.supporting_quote]);
const onSubmitEdit = async () => { const onSubmitEdit = async () => {
await onSave(draft); // Only send the quote when it actually changed — that triggers server-side
// re-verification + flag sync (#133); unchanged edits keep the old behavior.
const patch: Partial<EditState> = {
rule_statement: draft.rule_statement,
reasoning_summary: draft.reasoning_summary,
};
if (draft.supporting_quote !== h.supporting_quote) {
patch.supporting_quote = draft.supporting_quote;
}
await onSave(patch);
setEditing(false); setEditing(false);
}; };
@@ -232,9 +247,22 @@ function HalachaCard({
</div> </div>
<div> <div>
<div className="text-[0.7rem] text-ink-muted mb-1">ציטוט תומך</div> <div className="text-[0.7rem] text-ink-muted mb-1">ציטוט תומך</div>
{editing ? (
<>
<Textarea
value={draft.supporting_quote} rows={4} dir="rtl"
onChange={(e) => setDraft({ ...draft, supporting_quote: e.target.value })}
className="bg-gold-wash/50 border-gold/30"
/>
<p className="text-[0.66rem] text-info mt-1 leading-snug">
השמירה מאמתת את הציטוט מול טקסט-המקור; אם תואם הדגל ״ציטוט לא-מאומת״ יוסר.
</p>
</>
) : (
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl"> <blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
&ldquo;{h.supporting_quote}&rdquo; &ldquo;{h.supporting_quote}&rdquo;
</blockquote> </blockquote>
)}
</div> </div>
</div> </div>

View File

@@ -650,6 +650,9 @@ export type HalachaPatch = Partial<{
reasoning_summary: string; reasoning_summary: string;
subject_tags: string[]; subject_tags: string[];
practice_areas: string[]; practice_areas: string[];
// #133 — editing the quote re-verifies it against the source server-side and
// clears/sets the quote_unverified flag (extraction repair).
supporting_quote: string;
}>; }>;
export function useUpdateHalacha() { export function useUpdateHalacha() {

View File

@@ -5960,6 +5960,7 @@ class HalachaUpdateRequest(BaseModel):
reasoning_summary: str | None = None reasoning_summary: str | None = None
subject_tags: list[str] | None = None subject_tags: list[str] | None = None
practice_areas: list[str] | None = None practice_areas: list[str] | None = None
supporting_quote: str | None = None # #133 — edited quote → re-verify + sync flag
class HalachaBatchReviewRequest(BaseModel): class HalachaBatchReviewRequest(BaseModel):
@@ -7253,6 +7254,7 @@ async def halacha_update(halacha_id: str, req: HalachaUpdateRequest):
reasoning_summary=req.reasoning_summary, reasoning_summary=req.reasoning_summary,
subject_tags=req.subject_tags, subject_tags=req.subject_tags,
practice_areas=req.practice_areas, practice_areas=req.practice_areas,
supporting_quote=req.supporting_quote,
) )
if not row: if not row:
raise HTTPException(404, "הלכה לא נמצאה") raise HTTPException(404, "הלכה לא נמצאה")