Merge pull request 'feat: תיקון-ציטוט בדלי-החילוץ + קישור-לתור מדף-פרט (#133 follow-ups)' (#230) from worktree-halacha-quote-fix-and-detail-link into main
This commit was merged in pull request #230.
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
“{h.supporting_quote}”
|
“{h.supporting_quote}”
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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, "הלכה לא נמצאה")
|
||||||
|
|||||||
Reference in New Issue
Block a user