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:
2026-06-06 09:36:51 +00:00
parent 6dd125c491
commit c35e0e50ed
5 changed files with 445 additions and 0 deletions

View File

@@ -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."""