Research analysis cards with inline chair-position editor

New feature on case view: the analysis-and-research.md produced by the
legal-analyst agent is now rendered as structured cards in the UI,
with inline editing of "עמדת ועדת הערר" that writes directly back to
the markdown file (atomic rename).

Backend (research_md.py):
- parse(Path) → dict with header, prose sections, threshold_claims[],
  issues[], conclusions, other_sections
- Tolerant field extractor handles both block ("**LABEL:**\ncontent")
  and inline ("**LABEL:** content") variants
- Detects [ימולא ע"י יו"ר הוועדה] placeholder → empty chair_position
- update_chair_position(path, section_id, text) locates the exact
  subsection by ordinal, replaces or appends the chair field, writes
  atomically via temp file + os.replace
- Section IDs: threshold_N / issue_N (1-based)

Endpoints:
- GET /api/cases/{n}/research/analysis — returns parsed JSON or 404
- PATCH /api/cases/{n}/research/analysis/chair-position — {section_id, position}

Frontend (#page-case):
- New card "ניתוח משפטי ומחקר" below local-files card
- Prose sections as justified text panels (background + gold border)
- Threshold claims and issues as collapsible <details> items with
  gold right-border on open, numbered pills
- Each item shows all extracted fields with label above content
- Chair position editor: gold-wash background, 📝 icon label, textarea
  with placeholder prompt
- onblur → PATCH with save indicator:  שומר → ✓ נשמר HH:MM → fade
- Status pill next to each item title: "ממתין לעמדה" / "✓ עמדה נקבעה"
- First threshold claim opens by default, rest closed
- Card hidden entirely when no analysis file exists (404)

Tested against real file: case 1033-25 with 3 threshold claims and
6 issues, all chair positions correctly empty, update writes only the
targeted section, atomic rewrite preserves all other content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 12:47:36 +00:00
parent ffa089e1df
commit 753fe0d57d
3 changed files with 798 additions and 1 deletions

View File

@@ -28,7 +28,7 @@ from pydantic import BaseModel
import asyncpg
from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor, processor, proofreader
from legal_mcp.services import chunker, db, embeddings, extractor, processor, proofreader, research_md
from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools
# Import integration clients (same directory)
@@ -1578,6 +1578,51 @@ async def api_read_local_file(case_number: str, folder: str, filename: str):
return FileResponse(path, media_type="text/plain; charset=utf-8", filename=filename)
# ── Research analysis (analysis-and-research.md) — parse + edit ────
def _research_file_path(case_number: str) -> Path:
"""Resolve analysis-and-research.md path for a case."""
case_dir = config.find_case_dir(case_number)
return case_dir / "documents" / "research" / "analysis-and-research.md"
@app.get("/api/cases/{case_number}/research/analysis")
async def api_research_analysis(case_number: str):
"""Return parsed structure of analysis-and-research.md for UI rendering."""
path = _research_file_path(case_number)
if not path.exists():
raise HTTPException(404, "טרם בוצע ניתוח משפטי לתיק זה")
try:
return research_md.parse(path)
except Exception as e:
logger.exception("Failed to parse %s", path)
raise HTTPException(500, f"שגיאה בעיבוד הקובץ: {e}")
class ChairPositionRequest(BaseModel):
section_id: str
position: str = ""
@app.patch("/api/cases/{case_number}/research/analysis/chair-position")
async def api_research_chair_position(case_number: str, req: ChairPositionRequest):
"""Update the chair_position field of a specific subsection, writing
directly to analysis-and-research.md (atomic rename)."""
path = _research_file_path(case_number)
if not path.exists():
raise HTTPException(404, "הקובץ לא נמצא")
if not re.match(r"^(threshold|issue)_\d+$", req.section_id):
raise HTTPException(400, "section_id לא תקין")
try:
return research_md.update_chair_position(path, req.section_id, req.position)
except ValueError as e:
raise HTTPException(404, str(e))
except Exception as e:
logger.exception("Failed to update chair position")
raise HTTPException(500, f"שגיאה בשמירה: {e}")
# ── Exports API — drafts, versions, download, upload, mark-final ──

View File

@@ -636,6 +636,202 @@ header nav a.active::after {
.doc-item .doc-icon { color: var(--color-gold); }
.doc-item .doc-name { flex: 1; color: var(--color-ink); font-weight: 500; }
.doc-item .doc-status { font-size: 0.78em; color: var(--color-ink-light); }
/* ── Research Analysis Cards ───────────────────────── */
.research-section {
margin-bottom: var(--space-6);
}
.research-section:last-child { margin-bottom: 0; }
.research-section-title {
font-family: var(--font-body);
font-size: 1.05em;
font-weight: 700;
color: var(--color-navy);
padding: var(--space-3) var(--space-4);
background: var(--color-parchment);
border-right: 4px solid var(--color-gold);
border-radius: var(--radius) 0 0 var(--radius);
margin-bottom: var(--space-4);
display: flex;
justify-content: space-between;
align-items: center;
}
.research-section-title .count-pill {
background: var(--color-gold);
color: var(--color-parchment);
padding: 2px 12px;
border-radius: var(--radius-pill);
font-size: 0.78em;
font-weight: 600;
}
.research-prose {
padding: var(--space-4) var(--space-5);
background: var(--color-parchment);
border: 1px solid var(--color-rule-soft);
border-radius: var(--radius);
font-size: 0.92em;
line-height: var(--leading-body);
color: var(--color-ink);
white-space: pre-wrap;
text-align: justify;
text-justify: inter-word;
}
.research-item {
border: 1px solid var(--color-rule-soft);
border-radius: var(--radius-lg);
background: var(--color-surface);
margin-bottom: var(--space-3);
overflow: hidden;
transition: box-shadow var(--t);
}
.research-item[open] {
box-shadow: var(--shadow-sm);
border-color: var(--color-gold);
}
.research-item summary {
padding: var(--space-4) var(--space-5);
cursor: pointer;
font-weight: 600;
color: var(--color-navy);
font-size: 0.98em;
display: flex;
align-items: center;
gap: var(--space-3);
list-style: none;
transition: background var(--t-fast);
}
.research-item summary::-webkit-details-marker { display: none; }
.research-item summary::before {
content: '▸';
color: var(--color-gold);
font-size: 1.1em;
transition: transform var(--t);
flex-shrink: 0;
}
.research-item[open] summary::before { transform: rotate(90deg); }
.research-item summary:hover { background: var(--color-parchment); }
.research-item-number {
display: inline-block;
background: var(--color-gold-wash);
color: var(--color-gold-deep);
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: 0.78em;
font-weight: 700;
flex-shrink: 0;
}
.research-item-title {
flex: 1;
line-height: 1.4;
}
.research-item-status {
flex-shrink: 0;
font-size: 0.76em;
color: var(--color-ink-muted);
font-weight: 400;
}
.research-item-status.filled {
color: var(--color-success);
font-weight: 600;
}
.research-item-body {
padding: 0 var(--space-5) var(--space-5);
border-top: 1px solid var(--color-rule-soft);
}
.research-field {
margin-top: var(--space-4);
}
.research-field-label {
font-size: 0.78em;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-ink-muted);
font-weight: 700;
margin-bottom: var(--space-2);
padding-bottom: 4px;
border-bottom: 1px solid var(--color-rule-soft);
}
.research-field-content {
font-size: 0.92em;
line-height: var(--leading-body);
color: var(--color-ink);
white-space: pre-wrap;
text-align: justify;
text-justify: inter-word;
}
/* Chair position editor — the highlight feature */
.chair-editor {
margin-top: var(--space-5);
padding: var(--space-4) var(--space-5);
background: var(--color-gold-wash);
border: 2px solid var(--color-gold);
border-radius: var(--radius-md);
position: relative;
}
.chair-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.chair-editor-label {
font-family: var(--font-body);
font-size: 0.88em;
font-weight: 700;
color: var(--color-navy);
display: flex;
align-items: center;
gap: 6px;
}
.chair-editor-label::before {
content: '📝';
font-size: 1.1em;
}
.chair-editor textarea {
width: 100%;
min-height: 90px;
padding: 12px 14px;
font-family: var(--font-body);
font-size: 0.95em;
line-height: var(--leading-body);
color: var(--color-ink);
background: var(--color-surface);
border: 1px solid var(--color-rule);
border-radius: var(--radius);
resize: vertical;
direction: rtl;
text-align: justify;
text-justify: inter-word;
}
.chair-editor textarea:focus {
outline: none;
border-color: var(--color-gold);
box-shadow: 0 0 0 3px rgba(169, 125, 58, 0.15);
}
.chair-editor textarea::placeholder {
color: var(--color-ink-light);
font-style: italic;
}
.save-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78em;
font-weight: 600;
min-height: 1.2em;
transition: opacity var(--t);
}
.save-indicator.saving { color: var(--color-ink-muted); }
.save-indicator.saved { color: var(--color-success); animation: saveFlash 0.4s var(--ease-out); }
.save-indicator.error { color: var(--color-danger); }
@keyframes saveFlash {
0% { transform: scale(0.9); opacity: 0; }
50% { transform: scale(1.05); }
100% { transform: scale(1); opacity: 1; }
}
.doc-status.completed { color: var(--color-success); font-weight: 700; font-size: 1em; }
.doc-status.processing { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--color-gold); border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
.doc-status.pending { color: #ccc; }
@@ -1905,6 +2101,17 @@ kbd {
</div>
</div>
<!-- Research Analysis Cards (parsed analysis-and-research.md) -->
<div class="card" id="caseResearchCard" style="display:none">
<div class="card-header">
<span>ניתוח משפטי ומחקר — תצוגה מובנית</span>
<span id="caseResearchMeta" style="float:left;font-size:0.82em;color:var(--color-ink-muted);font-weight:400;font-family:var(--font-body)"></span>
</div>
<div class="card-body" id="caseResearchBody">
<div class="empty">טוען...</div>
</div>
</div>
<!-- Exports / Drafts -->
<div class="card">
<div class="card-header">
@@ -2721,6 +2928,7 @@ async function loadCaseView(caseNumber) {
setupCaseUpload(caseNumber);
loadLocalFiles(caseNumber);
loadResearchAnalysis(caseNumber);
loadExports(caseNumber);
setupExportUpload(caseNumber);
}
@@ -2762,6 +2970,195 @@ async function loadLocalFiles(caseNumber) {
}
}
// ── Research Analysis Cards (analysis-and-research.md) ─────
let _currentResearchCase = null;
async function loadResearchAnalysis(caseNumber) {
_currentResearchCase = caseNumber;
const card = document.getElementById('caseResearchCard');
const body = document.getElementById('caseResearchBody');
const meta = document.getElementById('caseResearchMeta');
try {
const res = await fetch(API + '/cases/' + encodeURIComponent(caseNumber) + '/research/analysis');
if (res.status === 404) {
card.style.display = 'none';
return;
}
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
card.style.display = '';
renderResearchCards(data, body, meta);
} catch (e) {
card.style.display = '';
body.innerHTML = `<div class="empty">שגיאה בטעינת הניתוח: ${esc(e.message)}</div>`;
}
}
function renderResearchCards(data, container, metaEl) {
const h = data.header || {};
if (metaEl) {
const date = h.date || '';
const modDate = h.modified_at ? new Date(h.modified_at).toLocaleDateString('he-IL') : '';
metaEl.textContent = (date ? `תאריך ניתוח: ${date}` : '') +
(modDate ? ` · עודכן: ${modDate}` : '');
}
const parts = [];
// Intro/prose sections
const proseSection = (title, content) => {
if (!content || !content.trim()) return '';
return `
<div class="research-section">
<div class="research-section-title">${esc(title)}</div>
<div class="research-prose">${esc(content.trim())}</div>
</div>
`;
};
parts.push(proseSection('צד מיוצג', data.represented_party));
parts.push(proseSection('רקע דיוני', data.procedural_background));
parts.push(proseSection('עובדות מוסכמות', data.agreed_facts));
parts.push(proseSection('עובדות שנויות במחלוקת', data.disputed_facts));
// Threshold claims
if (data.threshold_claims && data.threshold_claims.length) {
parts.push(`
<div class="research-section">
<div class="research-section-title">
<span>טענות סף</span>
<span class="count-pill">${data.threshold_claims.length}</span>
</div>
${data.threshold_claims.map((tc, i) => renderSubsection(tc, i === 0)).join('')}
</div>
`);
}
// Issues
if (data.issues && data.issues.length) {
parts.push(`
<div class="research-section">
<div class="research-section-title">
<span>סוגיות להכרעה</span>
<span class="count-pill">${data.issues.length}</span>
</div>
${data.issues.map((iss, i) => renderSubsection(iss, false)).join('')}
</div>
`);
}
// Conclusions
parts.push(proseSection('מסקנות', data.conclusions));
// Other sections (if any unexpected)
if (data.other_sections && data.other_sections.length) {
data.other_sections.forEach(s => {
parts.push(proseSection(s.title, s.body));
});
}
container.innerHTML = parts.join('') || '<div class="empty">הקובץ ריק</div>';
}
function renderSubsection(item, openByDefault) {
const isFilled = item.chair_position && item.chair_position.trim().length > 0;
const statusClass = isFilled ? 'filled' : '';
const statusText = isFilled ? '✓ עמדה נקבעה' : 'ממתין לעמדה';
const fieldsHtml = (item.fields || []).map(f => `
<div class="research-field">
<div class="research-field-label">${esc(f.label)}</div>
<div class="research-field-content">${esc(f.content)}</div>
</div>
`).join('');
const textareaContent = esc(item.chair_position || '');
return `
<details class="research-item" ${openByDefault ? 'open' : ''} data-section-id="${esc(item.id)}">
<summary>
<span class="research-item-number">${item.number}</span>
<span class="research-item-title">${esc(item.title)}</span>
<span class="research-item-status ${statusClass}" data-status>${esc(statusText)}</span>
</summary>
<div class="research-item-body">
${fieldsHtml}
<div class="chair-editor">
<div class="chair-editor-header">
<span class="chair-editor-label">עמדת ועדת הערר</span>
<span class="save-indicator" data-save-indicator></span>
</div>
<textarea
data-chair-editor
data-section-id="${esc(item.id)}"
placeholder="כתבי כאן את עמדתך לגבי סוגיה זו. הטקסט נשמר אוטומטית כשעוזבת את השדה."
onblur="saveChairPosition(this)">${textareaContent}</textarea>
</div>
</div>
</details>
`;
}
async function saveChairPosition(textarea) {
const sectionId = textarea.dataset.sectionId;
const caseNumber = _currentResearchCase;
if (!sectionId || !caseNumber) return;
const item = textarea.closest('.research-item');
const indicator = item.querySelector('[data-save-indicator]');
const statusEl = item.querySelector('[data-status]');
const newText = textarea.value.trim();
// Track the last-saved value on the element to avoid redundant saves
if (textarea._lastSaved === undefined) textarea._lastSaved = textarea.defaultValue;
if (newText === textarea._lastSaved) return;
indicator.className = 'save-indicator saving';
indicator.textContent = '⏳ שומר...';
try {
const res = await fetch(
API + '/cases/' + encodeURIComponent(caseNumber) + '/research/analysis/chair-position',
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section_id: sectionId, position: newText }),
}
);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
textarea._lastSaved = newText;
indicator.className = 'save-indicator saved';
const timeStr = new Date().toLocaleTimeString('he-IL', { hour: '2-digit', minute: '2-digit' });
indicator.textContent = `✓ נשמר ${timeStr}`;
// Update status pill
if (statusEl) {
if (newText) {
statusEl.className = 'research-item-status filled';
statusEl.textContent = '✓ עמדה נקבעה';
} else {
statusEl.className = 'research-item-status';
statusEl.textContent = 'ממתין לעמדה';
}
}
// Fade the saved indicator after 3 seconds
setTimeout(() => {
if (indicator.className.includes('saved')) {
indicator.style.opacity = '0.5';
}
}, 3000);
indicator.style.opacity = '1';
} catch (e) {
indicator.className = 'save-indicator error';
indicator.textContent = '✗ שגיאה — ' + (e.message || '').substring(0, 60);
}
}
async function retryDoc(caseNumber, docId) {
const btn = event.target;
btn.disabled = true;