feat(ui): interactive decision-block viewer + inline editor on case page
Adds a new "ההחלטה" tab to the case detail page showing all 12 decision
blocks with rendered markdown content and inline editing that saves back
to the DB via two new FastAPI endpoints.
Backend (web/app.py):
- GET /api/cases/{n}/decision-blocks — returns all 12 blocks (empty
ones included) merged from BLOCK_CONFIG + decision_blocks table.
Exposes source_of_truth ("docx"|"blocks") and active_draft_path.
- PUT /api/cases/{n}/decision-blocks/{block_id} — inline save via
block_writer.save_block_content; warns (does not block) when an
active DOCX draft exists.
Frontend:
- src/lib/api/decision-blocks.ts — typed hooks (useDecisionBlocks,
useSaveBlock) following the cases.ts hand-written-module pattern.
- src/components/cases/decision-blocks-panel.tsx — accordion of 12
blocks; view mode renders Markdown component; edit mode is a textarea
with on-blur save (derived from ChairEditor pattern, setState-during-
render for re-sync to avoid effect cascade).
- BLOCK_LABELS in feedback.ts extended from 7 → 12 blocks.
- cases/[caseNumber]/page.tsx — new "ההחלטה" tab wired to the panel.
No DB migration required — decision_blocks + active_draft_path exist.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import { StatusGuide } from "@/components/cases/status-guide";
|
||||
import { StatusChanger } from "@/components/cases/status-changer";
|
||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||
@@ -81,6 +82,9 @@ export default function CaseDetailPage({
|
||||
<TabsTrigger value="arguments">
|
||||
טיעונים
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="decision">
|
||||
ההחלטה
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drafts">
|
||||
טיוטות והערות
|
||||
</TabsTrigger>
|
||||
@@ -147,6 +151,10 @@ export default function CaseDetailPage({
|
||||
<LegalArgumentsPanel caseNumber={caseNumber} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="decision" className="mt-5">
|
||||
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="mt-5">
|
||||
<DraftsPanel
|
||||
caseNumber={caseNumber}
|
||||
|
||||
240
web-ui/src/components/cases/decision-blocks-panel.tsx
Normal file
240
web-ui/src/components/cases/decision-blocks-panel.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import {
|
||||
useDecisionBlocks,
|
||||
useSaveBlock,
|
||||
type DecisionBlock,
|
||||
type BlockStatus,
|
||||
} from "@/lib/api/decision-blocks";
|
||||
import { BLOCK_LABELS } from "@/lib/api/feedback";
|
||||
import { AlertTriangle, Pencil, FileText } from "lucide-react";
|
||||
|
||||
/* ── status badge styling ─────────────────────────────── */
|
||||
|
||||
const STATUS_LABELS: Record<BlockStatus, string> = {
|
||||
empty: "ריק",
|
||||
draft: "טיוטה",
|
||||
review: "בבדיקה",
|
||||
final: "סופי",
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<BlockStatus, string> = {
|
||||
empty: "bg-rule-soft text-ink-muted border-rule",
|
||||
draft: "bg-gold/10 text-gold-deep border-gold/30",
|
||||
review: "bg-blue-50 text-blue-700 border-blue-200",
|
||||
final: "bg-success-bg text-success border-success/40",
|
||||
};
|
||||
|
||||
function blockLabel(b: DecisionBlock): string {
|
||||
return BLOCK_LABELS[b.block_id] ?? b.title ?? b.block_id;
|
||||
}
|
||||
|
||||
/* ── Main panel ───────────────────────────────────────── */
|
||||
|
||||
export function DecisionBlocksPanel({ caseNumber }: { caseNumber: string }) {
|
||||
const { data, isLoading, error } = useDecisionBlocks(caseNumber);
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-ink-muted">טוען...</p>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-danger">
|
||||
שגיאה בטעינת תוכן ההחלטה: {error.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const written = data.blocks.filter((b) => b.word_count > 0).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── Source-of-truth warning ── */}
|
||||
{data.source_of_truth === "docx" && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
|
||||
<AlertTriangle className="w-5 h-5 text-gold-deep shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-gold-deep leading-relaxed">
|
||||
קיים קובץ DOCX מתוקן המשמש כמקור האמת לתיק זה. עריכת בלוקים כאן
|
||||
נשמרת ב-DB אך <strong>לא</strong> תעדכן את ה-DOCX עד הפקת טיוטה
|
||||
מחדש.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Header line ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-navy text-base">תוכן ההחלטה לפי בלוקים</h3>
|
||||
<span className="text-xs text-ink-muted tabular-nums">
|
||||
{written}/12 בלוקים נכתבו
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!data.has_decision && (
|
||||
<p className="text-sm text-ink-muted">
|
||||
טרם נכתבו בלוקים לתיק זה. ניתן להתחיל לכתוב בכל בלוק להלן — הכתיבה
|
||||
תיצור את ההחלטה אוטומטית.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── 12 blocks ── */}
|
||||
<Accordion type="multiple" className="space-y-2">
|
||||
{data.blocks.map((block) => (
|
||||
<AccordionItem
|
||||
key={block.block_id}
|
||||
value={block.block_id}
|
||||
className="rounded-lg border border-rule bg-surface px-4"
|
||||
>
|
||||
<AccordionTrigger className="hover:no-underline py-3">
|
||||
<div className="flex items-center gap-2 flex-wrap text-start">
|
||||
<span className="text-sm font-medium text-navy">
|
||||
{blockLabel(block)}
|
||||
</span>
|
||||
<Badge
|
||||
className={`text-[0.65rem] border ${STATUS_CLASSES[block.status]}`}
|
||||
>
|
||||
{STATUS_LABELS[block.status]}
|
||||
</Badge>
|
||||
{block.word_count > 0 && (
|
||||
<span className="text-[0.7rem] text-ink-muted tabular-nums">
|
||||
{block.word_count} מילים
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<BlockEditor caseNumber={caseNumber} block={block} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Per-block view / edit ────────────────────────────── */
|
||||
|
||||
type SaveState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "saving" }
|
||||
| { kind: "saved"; at: Date }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
function BlockEditor({
|
||||
caseNumber,
|
||||
block,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
block: DecisionBlock;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(block.content);
|
||||
const [state, setState] = useState<SaveState>({ kind: "idle" });
|
||||
/* The last content known to be persisted — used to skip no-op saves. */
|
||||
const [baseline, setBaseline] = useState(block.content);
|
||||
const save = useSaveBlock(caseNumber);
|
||||
|
||||
/* Re-sync when the upstream query refetches (e.g. after another save) while
|
||||
* not actively editing. Adjusting state during render — the documented React
|
||||
* pattern for derived-from-props — avoids a setState-in-effect cascade. */
|
||||
if (!editing && block.content !== baseline) {
|
||||
setBaseline(block.content);
|
||||
setValue(block.content);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const next = value;
|
||||
if (next === baseline) return;
|
||||
setState({ kind: "saving" });
|
||||
try {
|
||||
await save.mutateAsync({ blockId: block.block_id, content: next });
|
||||
setBaseline(next);
|
||||
setState({ kind: "saved", at: new Date() });
|
||||
} catch (e) {
|
||||
setState({
|
||||
kind: "error",
|
||||
message: e instanceof Error ? e.message : "שגיאה בשמירה",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{block.content.trim() ? (
|
||||
<Markdown content={block.content} />
|
||||
) : (
|
||||
<p className="text-sm text-ink-muted italic">בלוק ריק.</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 me-1.5" />
|
||||
ערוך
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[0.72rem] text-ink-muted flex items-center gap-1">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
עריכת תוכן הבלוק (Markdown) — נשמר בעת יציאה מהשדה
|
||||
</span>
|
||||
<SaveIndicator state={state} />
|
||||
</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
rows={12}
|
||||
dir="rtl"
|
||||
placeholder="כתוב כאן את תוכן הבלוק. הטקסט נשמר אוטומטית כשעוזבים את השדה."
|
||||
className="w-full resize-y rounded border border-rule bg-parchment px-3 py-2 text-sm leading-relaxed text-ink shadow-inner focus:border-gold focus:outline-none focus:ring-2 focus:ring-gold/30"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-ink-muted"
|
||||
disabled={save.isPending}
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
סיום עריכה
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveIndicator({ state }: { state: SaveState }) {
|
||||
if (state.kind === "idle") return null;
|
||||
if (state.kind === "saving") {
|
||||
return <span className="text-[0.72rem] text-ink-muted">⏳ שומר…</span>;
|
||||
}
|
||||
if (state.kind === "saved") {
|
||||
const time = state.at.toLocaleTimeString("he-IL", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return <span className="text-[0.72rem] text-success">✓ נשמר {time}</span>;
|
||||
}
|
||||
return <span className="text-[0.72rem] text-danger">⚠ {state.message}</span>;
|
||||
}
|
||||
75
web-ui/src/lib/api/decision-blocks.ts
Normal file
75
web-ui/src/lib/api/decision-blocks.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Decision-blocks domain hooks.
|
||||
*
|
||||
* The 12-block decision content lives in the `decision_blocks` table and was
|
||||
* previously only reachable via DOCX export. These hooks back the interactive
|
||||
* block viewer/editor on the case page:
|
||||
* GET /api/cases/{n}/decision-blocks → all 12 blocks (empty included)
|
||||
* PUT /api/cases/{n}/decision-blocks/{block} → save inline-edited content
|
||||
*
|
||||
* The endpoints aren't declared with response models on the FastAPI side, so
|
||||
* we maintain local types here (same convention as cases.ts).
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type BlockStatus = "empty" | "draft" | "review" | "final";
|
||||
|
||||
export type DecisionBlock = {
|
||||
block_id: string;
|
||||
block_index: number;
|
||||
title: string;
|
||||
content: string;
|
||||
word_count: number;
|
||||
status: BlockStatus;
|
||||
generation_type: string;
|
||||
model_used: string;
|
||||
/** ISO timestamp; null when the block was never written */
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type DecisionBlocksResponse = {
|
||||
case_number: string;
|
||||
has_decision: boolean;
|
||||
decision_id: string | null;
|
||||
active_draft_path: string | null;
|
||||
source_of_truth: "docx" | "blocks";
|
||||
blocks: DecisionBlock[];
|
||||
};
|
||||
|
||||
export type SaveBlockResponse = {
|
||||
block: DecisionBlock;
|
||||
active_draft_warning: boolean;
|
||||
};
|
||||
|
||||
export const decisionBlocksKeys = {
|
||||
all: ["decision-blocks"] as const,
|
||||
case: (caseNumber: string) =>
|
||||
[...decisionBlocksKeys.all, caseNumber] as const,
|
||||
};
|
||||
|
||||
export function useDecisionBlocks(caseNumber: string) {
|
||||
return useQuery({
|
||||
queryKey: decisionBlocksKeys.case(caseNumber),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<DecisionBlocksResponse>(
|
||||
`/api/cases/${caseNumber}/decision-blocks`,
|
||||
{ signal },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveBlock(caseNumber: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ blockId, content }: { blockId: string; content: string }) =>
|
||||
apiRequest<SaveBlockResponse>(
|
||||
`/api/cases/${caseNumber}/decision-blocks/${blockId}`,
|
||||
{ method: "PUT", body: { content } },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: decisionBlocksKeys.case(caseNumber) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -125,6 +125,10 @@ export const CATEGORY_COLORS: Record<FeedbackCategory, string> = {
|
||||
|
||||
/** Block ID labels */
|
||||
export const BLOCK_LABELS: Record<string, string> = {
|
||||
"block-alef": "א — כותרת מוסדית",
|
||||
"block-bet": "ב — הרכב הוועדה",
|
||||
"block-gimel": "ג — צדדים",
|
||||
"block-dalet": "ד — החלטה",
|
||||
"block-he": "ה — פתיחה",
|
||||
"block-vav": "ו — רקע עובדתי",
|
||||
"block-zayin": "ז — טענות הצדדים",
|
||||
@@ -132,4 +136,5 @@ export const BLOCK_LABELS: Record<string, string> = {
|
||||
"block-tet": "ט — תכניות חלות",
|
||||
"block-yod": "י — דיון והכרעה",
|
||||
"block-yod-alef": "יא — סיכום",
|
||||
"block-yod-bet": "יב — חתימות",
|
||||
};
|
||||
|
||||
117
web/app.py
117
web/app.py
@@ -2575,6 +2575,123 @@ async def api_run_qa(case_number: str):
|
||||
return {"passed": all_passed, "checks": checks, "status": new_status}
|
||||
|
||||
|
||||
# ── Decision blocks — interactive view + inline edit ──
|
||||
|
||||
|
||||
class BlockUpdateRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
def _serialize_block(row: dict, cfg: dict) -> dict:
|
||||
"""Merge a decision_blocks DB row with its BLOCK_CONFIG skeleton."""
|
||||
updated = row.get("updated_at") if row else None
|
||||
return {
|
||||
"block_id": cfg["block_id"],
|
||||
"block_index": cfg["index"],
|
||||
# Prefer the DB title (may be hand-edited); fall back to the canonical config title.
|
||||
"title": (row.get("title") if row and row.get("title") else cfg["title"]),
|
||||
"content": (row.get("content") if row else "") or "",
|
||||
"word_count": (row.get("word_count") if row else 0) or 0,
|
||||
"status": (row.get("status") if row else "empty") or "empty",
|
||||
"generation_type": (row.get("generation_type") if row else cfg["gen_type"]),
|
||||
"model_used": (row.get("model_used") if row else cfg["model"]),
|
||||
"updated_at": updated.isoformat() if updated else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/decision-blocks")
|
||||
async def api_get_decision_blocks(case_number: str):
|
||||
"""Return all 12 decision blocks as JSON (empty blocks included).
|
||||
|
||||
Read path for the interactive block viewer — content lives in
|
||||
decision_blocks but was previously only reachable via DOCX export.
|
||||
"""
|
||||
from legal_mcp.services.block_writer import BLOCK_CONFIG
|
||||
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
|
||||
case_id = UUID(case["id"])
|
||||
# Canonical skeleton, ordered by block_index. Carries block_id into each cfg.
|
||||
skeleton = [
|
||||
{**cfg, "block_id": bid}
|
||||
for bid, cfg in sorted(BLOCK_CONFIG.items(), key=lambda kv: kv[1]["index"])
|
||||
]
|
||||
|
||||
decision = await db.get_decision_by_case(case_id)
|
||||
active_draft_path = await db.get_active_draft_path(case_id)
|
||||
by_id: dict[str, dict] = {}
|
||||
decision_id = None
|
||||
if decision:
|
||||
decision_id = decision["id"]
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT block_id, block_index, title, content, word_count, status,
|
||||
generation_type, model_used, updated_at
|
||||
FROM decision_blocks WHERE decision_id = $1""",
|
||||
UUID(decision_id),
|
||||
)
|
||||
by_id = {r["block_id"]: dict(r) for r in rows}
|
||||
|
||||
blocks = [_serialize_block(by_id.get(cfg["block_id"]), cfg) for cfg in skeleton]
|
||||
|
||||
return {
|
||||
"case_number": case["case_number"],
|
||||
"has_decision": decision is not None,
|
||||
"decision_id": decision_id,
|
||||
"active_draft_path": active_draft_path,
|
||||
"source_of_truth": "docx" if active_draft_path else "blocks",
|
||||
"blocks": blocks,
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/cases/{case_number}/decision-blocks/{block_id}")
|
||||
async def api_update_decision_block(
|
||||
case_number: str, block_id: str, req: BlockUpdateRequest
|
||||
):
|
||||
"""Save inline-edited content for a single decision block.
|
||||
|
||||
Writes to decision_blocks (upsert, status='draft') and rebuilds the
|
||||
on-disk decision.md. Creates a decision row if none exists yet.
|
||||
"""
|
||||
from legal_mcp.services import block_writer
|
||||
from legal_mcp.services.block_writer import BLOCK_CONFIG
|
||||
|
||||
if block_id not in BLOCK_CONFIG:
|
||||
raise HTTPException(404, f"בלוק לא ידוע: {block_id}")
|
||||
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
|
||||
case_id = UUID(case["id"])
|
||||
active_draft_path = await db.get_active_draft_path(case_id)
|
||||
if active_draft_path:
|
||||
logger.warning(
|
||||
"Inline block edit on %s/%s while active_draft_path is set (%s) — "
|
||||
"DB and DOCX may diverge.",
|
||||
case_number, block_id, active_draft_path,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await block_writer.save_block_content(case_id, block_id, req.content)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
cfg = {**BLOCK_CONFIG[block_id], "block_id": block_id}
|
||||
block = _serialize_block(
|
||||
{
|
||||
**result,
|
||||
"status": "draft",
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
},
|
||||
cfg,
|
||||
)
|
||||
return {"block": block, "active_draft_warning": bool(active_draft_path)}
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/learn")
|
||||
async def api_learn(case_number: str):
|
||||
"""Trigger learning loop — compare draft to final version."""
|
||||
|
||||
Reference in New Issue
Block a user