Merge pull request 'feat(ui): interactive decision-block viewer + inline editor on case page' (#57) from feat/decision-blocks-viewer into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 4m15s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 4m15s
feat(ui): interactive decision-block viewer + inline editor on case page (#57)
This commit was merged in pull request #57.
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