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 { StatusChanger } from "@/components/cases/status-changer";
|
||||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
import { DraftsPanel } from "@/components/cases/drafts-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 { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||||
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||||
@@ -81,6 +82,9 @@ export default function CaseDetailPage({
|
|||||||
<TabsTrigger value="arguments">
|
<TabsTrigger value="arguments">
|
||||||
טיעונים
|
טיעונים
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="decision">
|
||||||
|
ההחלטה
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="drafts">
|
<TabsTrigger value="drafts">
|
||||||
טיוטות והערות
|
טיוטות והערות
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -147,6 +151,10 @@ export default function CaseDetailPage({
|
|||||||
<LegalArgumentsPanel caseNumber={caseNumber} />
|
<LegalArgumentsPanel caseNumber={caseNumber} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="decision" className="mt-5">
|
||||||
|
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="drafts" className="mt-5">
|
<TabsContent value="drafts" className="mt-5">
|
||||||
<DraftsPanel
|
<DraftsPanel
|
||||||
caseNumber={caseNumber}
|
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 */
|
/** Block ID labels */
|
||||||
export const BLOCK_LABELS: Record<string, string> = {
|
export const BLOCK_LABELS: Record<string, string> = {
|
||||||
|
"block-alef": "א — כותרת מוסדית",
|
||||||
|
"block-bet": "ב — הרכב הוועדה",
|
||||||
|
"block-gimel": "ג — צדדים",
|
||||||
|
"block-dalet": "ד — החלטה",
|
||||||
"block-he": "ה — פתיחה",
|
"block-he": "ה — פתיחה",
|
||||||
"block-vav": "ו — רקע עובדתי",
|
"block-vav": "ו — רקע עובדתי",
|
||||||
"block-zayin": "ז — טענות הצדדים",
|
"block-zayin": "ז — טענות הצדדים",
|
||||||
@@ -132,4 +136,5 @@ export const BLOCK_LABELS: Record<string, string> = {
|
|||||||
"block-tet": "ט — תכניות חלות",
|
"block-tet": "ט — תכניות חלות",
|
||||||
"block-yod": "י — דיון והכרעה",
|
"block-yod": "י — דיון והכרעה",
|
||||||
"block-yod-alef": "יא — סיכום",
|
"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}
|
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")
|
@app.post("/api/cases/{case_number}/learn")
|
||||||
async def api_learn(case_number: str):
|
async def api_learn(case_number: str):
|
||||||
"""Trigger learning loop — compare draft to final version."""
|
"""Trigger learning loop — compare draft to final version."""
|
||||||
|
|||||||
Reference in New Issue
Block a user