diff --git a/mcp-server/src/legal_mcp/services/research_md.py b/mcp-server/src/legal_mcp/services/research_md.py new file mode 100644 index 0000000..d95635e --- /dev/null +++ b/mcp-server/src/legal_mcp/services/research_md.py @@ -0,0 +1,355 @@ +"""Parser for analysis-and-research.md produced by the legal-analyst agent. + +Extracts the structured content (threshold claims, issues, sections) into +a JSON-serializable dict for UI rendering, and supports atomic in-place +updates of the "עמדת ועדת הערר" (chair position) field in each subsection. + +The parser is intentionally tolerant: the file format is under active +development, so we extract what we find rather than enforcing a strict +schema. Missing sections return empty/None values. +""" + +from __future__ import annotations + +import os +import re +from datetime import datetime +from pathlib import Path +from typing import Any + +# Placeholder strings — any of these means "not yet filled" +CHAIR_POSITION_PLACEHOLDERS = ( + "[ימולא ע\"י יו\"ר הוועדה]", + "[ימולא ע'י יו'ר הוועדה]", + "[ימולא על ידי יו\"ר הוועדה]", + "[לא מולא]", + "[טרם מולא]", +) + +CHAIR_POSITION_LABEL = "עמדת ועדת הערר" + +# Matches "## N. title" or "## title" for main sections +MAIN_SECTION_RE = re.compile(r"^##\s+(\d+)\.?\s+(.+?)$", re.MULTILINE) + +# Matches "### title" for subsections (threshold claims, issues) +SUBSECTION_RE = re.compile(r"^###\s+(.+?)$", re.MULTILINE) + +# Matches "**LABEL:**" field markers — handles both inline and block variants: +# "**עמדת המבקשת:** Some text on same line" +# "**שאלות משפטיות:**\n1. First question" +# The label itself must not contain ** or newlines. +FIELD_LABEL_RE = re.compile(r"^\*\*([^\n*]+?):\*\*[ \t]*", re.MULTILINE) + +# Matches the case number in the H1 +CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILINE) + +# Matches the date line +DATE_RE = re.compile(r"^תאריך:\s*(.+?)\s*$", re.MULTILINE) + + +def _is_placeholder(text: str) -> bool: + """Check if a field value is one of the placeholder strings (empty).""" + stripped = text.strip() + if not stripped: + return True + for ph in CHAIR_POSITION_PLACEHOLDERS: + if ph in stripped: + return True + return False + + +def _normalize_chair_position(text: str) -> str: + """Return empty string for placeholders, otherwise the text.""" + if _is_placeholder(text): + return "" + return text.strip() + + +def _split_main_sections(content: str) -> list[tuple[str, str, str]]: + """Split content into (number, title, body) tuples for each H2 section. + + Handles both numbered (## 1. title) and unnumbered (## title) H2s. + Body is everything up to the next H2. + """ + # Find all H2 positions + h2_positions = [] + for m in re.finditer(r"^##\s+(.+?)$", content, re.MULTILINE): + title = m.group(1).strip() + num_match = re.match(r"^(\d+)\.?\s+(.+)", title) + if num_match: + number = num_match.group(1) + title = num_match.group(2).strip() + else: + number = "" + h2_positions.append((m.start(), m.end(), number, title)) + + sections = [] + for i, (_start, end, number, title) in enumerate(h2_positions): + next_start = h2_positions[i + 1][0] if i + 1 < len(h2_positions) else len(content) + body = content[end:next_start].strip() + sections.append((number, title, body)) + return sections + + +def _split_subsections(body: str) -> list[tuple[str, str]]: + """Split a section body by H3 subsections. + + Returns list of (title, content) — content is everything until next H3. + Leading text before first H3 is discarded at this level. + """ + h3_positions = [] + for m in re.finditer(r"^###\s+(.+?)$", body, re.MULTILINE): + h3_positions.append((m.start(), m.end(), m.group(1).strip())) + + if not h3_positions: + return [] + + subs = [] + for i, (_start, end, title) in enumerate(h3_positions): + next_start = h3_positions[i + 1][0] if i + 1 < len(h3_positions) else len(body) + content = body[end:next_start].strip() + # Strip trailing horizontal rule "---" + content = re.sub(r"\s*---\s*$", "", content).strip() + subs.append((title, content)) + return subs + + +def _extract_fields(text: str) -> list[dict]: + """Extract bold-label fields from a subsection body. + + Returns list of {"label": str, "content": str} in document order. + A field runs from its "**LABEL:**" marker until the next one (or EOS). + """ + matches = list(FIELD_LABEL_RE.finditer(text)) + if not matches: + return [] + + fields = [] + for i, m in enumerate(matches): + label = m.group(1).strip() + content_start = m.end() + content_end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + content = text[content_start:content_end].strip() + # Strip trailing horizontal rule + content = re.sub(r"\s*---\s*$", "", content).strip() + fields.append({"label": label, "content": content}) + return fields + + +def _build_subsection_dict( + title: str, body: str, id_prefix: str, number: int +) -> dict: + """Build a structured dict for a threshold claim or issue subsection. + + - id: stable identifier used by update endpoint (e.g. 'threshold_1') + - title: the H3 title + - number: 1-based ordinal + - fields: ordered list of {label, content} pairs + - chair_position: extracted separately for UI editing (normalized empty) + """ + fields = _extract_fields(body) + + # Split title at ": " for cleaner display + display_title = title + if ": " in title: + parts = title.split(": ", 1) + display_title = parts[1] if len(parts) > 1 else title + + chair_position = "" + regular_fields = [] + for f in fields: + if f["label"] == CHAIR_POSITION_LABEL: + chair_position = _normalize_chair_position(f["content"]) + else: + regular_fields.append(f) + + return { + "id": f"{id_prefix}_{number}", + "number": number, + "title": display_title, + "raw_title": title, + "fields": regular_fields, + "chair_position": chair_position, + } + + +def parse(file_path: Path) -> dict[str, Any]: + """Parse analysis-and-research.md into a structured dict. + + Returns a dict with header info, plain-text sections, threshold_claims[], + issues[], and conclusions. Tolerant to missing sections. + """ + content = file_path.read_text(encoding="utf-8") + + # Header info from H1 and date line + case_match = CASE_NUMBER_RE.search(content) + case_number = case_match.group(1) if case_match else "" + date_match = DATE_RE.search(content) + date_str = date_match.group(1) if date_match else "" + + stat = file_path.stat() + mtime_iso = datetime.fromtimestamp(stat.st_mtime).isoformat() + + result: dict[str, Any] = { + "header": { + "case_number": case_number, + "date": date_str, + "file_path": str(file_path), + "file_size": stat.st_size, + "modified_at": mtime_iso, + }, + "represented_party": "", + "procedural_background": "", + "agreed_facts": "", + "disputed_facts": "", + "threshold_claims": [], + "issues": [], + "conclusions": "", + "other_sections": [], + } + + sections = _split_main_sections(content) + + for number, title, body in sections: + title_norm = title.strip() + + if "צד מיוצג" in title_norm: + result["represented_party"] = body + elif "רקע דיוני" in title_norm: + result["procedural_background"] = body + elif "עובדות מוסכמות" in title_norm: + result["agreed_facts"] = body + elif "עובדות שנויות במחלוקת" in title_norm or "שנויות" in title_norm: + result["disputed_facts"] = body + elif "טענות סף" in title_norm or "טענות הסף" in title_norm: + subs = _split_subsections(body) + for i, (sub_title, sub_body) in enumerate(subs, start=1): + result["threshold_claims"].append( + _build_subsection_dict(sub_title, sub_body, "threshold", i) + ) + elif "סוגיות להכרעה" in title_norm or "סוגיות" in title_norm: + subs = _split_subsections(body) + for i, (sub_title, sub_body) in enumerate(subs, start=1): + result["issues"].append( + _build_subsection_dict(sub_title, sub_body, "issue", i) + ) + elif "מסקנות" in title_norm or "סיכום" in title_norm: + result["conclusions"] = body + else: + # Unknown section — keep as-is for display + result["other_sections"].append( + {"number": number, "title": title_norm, "body": body} + ) + + return result + + +# ── Chair position in-place update ─────────────────────────────── + + +def _find_subsection_by_id( + content: str, section_id: str +) -> tuple[int, int, str] | None: + """Locate a subsection's body range in the raw content. + + Given section_id like 'threshold_2' or 'issue_3', walks the file + structure and returns (body_start, body_end, body_text) for that + subsection. Returns None if not found. + """ + parts = section_id.split("_") + if len(parts) != 2: + return None + kind, idx_str = parts + try: + target_idx = int(idx_str) + except ValueError: + return None + + if kind == "threshold": + main_keywords = ("טענות סף", "טענות הסף") + elif kind == "issue": + main_keywords = ("סוגיות להכרעה", "סוגיות") + else: + return None + + # Find the main section that contains threshold claims or issues + sections_iter = list(re.finditer(r"^##\s+(.+?)$", content, re.MULTILINE)) + for i, m in enumerate(sections_iter): + title = m.group(1).strip() + if not any(kw in title for kw in main_keywords): + continue + + body_start = m.end() + body_end = ( + sections_iter[i + 1].start() if i + 1 < len(sections_iter) else len(content) + ) + section_body = content[body_start:body_end] + + # Find H3 subsections within + h3s = list(re.finditer(r"^###\s+.+?$", section_body, re.MULTILINE)) + if target_idx < 1 or target_idx > len(h3s): + return None + + sub_start_rel = h3s[target_idx - 1].end() + sub_end_rel = ( + h3s[target_idx].start() if target_idx < len(h3s) else len(section_body) + ) + + abs_start = body_start + sub_start_rel + abs_end = body_start + sub_end_rel + return abs_start, abs_end, content[abs_start:abs_end] + + return None + + +def update_chair_position( + file_path: Path, section_id: str, new_text: str +) -> dict[str, Any]: + """Atomically update the chair_position field of one subsection. + + Writes to a temporary file then renames into place (atomic on Linux). + Returns {"saved": bool, "section_id": ..., "preview": ...}. + Raises FileNotFoundError or ValueError on error. + """ + if not file_path.exists(): + raise FileNotFoundError(str(file_path)) + + content = file_path.read_text(encoding="utf-8") + found = _find_subsection_by_id(content, section_id) + if not found: + raise ValueError(f"section {section_id} not found") + + _abs_start, _abs_end, subsection_body = found + + # Find the "**עמדת ועדת הערר:**" label within this subsection + label_pattern = re.compile( + r"(\*\*" + re.escape(CHAIR_POSITION_LABEL) + r":\*\*)\s*\n?([^*]*?)(?=\n\*\*|\n##|\n---|\Z)", + re.DOTALL, + ) + m = label_pattern.search(subsection_body) + if not m: + # Label not present — append it at the end of the subsection + # (just before the trailing --- if any) + new_block = f"\n\n**{CHAIR_POSITION_LABEL}:**\n{new_text.strip()}\n" + new_subsection = subsection_body.rstrip() + new_block + new_content = content[:_abs_start] + new_subsection + content[_abs_end:] + else: + # Replace the existing content of the chair_position field + replacement = f"{m.group(1)}\n{new_text.strip() if new_text.strip() else CHAIR_POSITION_PLACEHOLDERS[0]}\n" + new_subsection = ( + subsection_body[: m.start()] + replacement + subsection_body[m.end():] + ) + new_content = content[:_abs_start] + new_subsection + content[_abs_end:] + + # Atomic write + tmp_path = file_path.with_suffix(file_path.suffix + ".tmp") + tmp_path.write_text(new_content, encoding="utf-8") + os.replace(tmp_path, file_path) + + preview = new_text.strip()[:120] + return { + "saved": True, + "section_id": section_id, + "preview": preview, + "timestamp": datetime.now().isoformat(), + } diff --git a/web/app.py b/web/app.py index 8f50532..dc47bec 100644 --- a/web/app.py +++ b/web/app.py @@ -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 ── diff --git a/web/static/index.html b/web/static/index.html index b894e70..065f7e1 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -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 { + + +
@@ -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 = `
שגיאה בטעינת הניתוח: ${esc(e.message)}
`; + } +} + +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 ` +
+
${esc(title)}
+
${esc(content.trim())}
+
+ `; + }; + + 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(` +
+
+ טענות סף + ${data.threshold_claims.length} +
+ ${data.threshold_claims.map((tc, i) => renderSubsection(tc, i === 0)).join('')} +
+ `); + } + + // Issues + if (data.issues && data.issues.length) { + parts.push(` +
+
+ סוגיות להכרעה + ${data.issues.length} +
+ ${data.issues.map((iss, i) => renderSubsection(iss, false)).join('')} +
+ `); + } + + // 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('') || '
הקובץ ריק
'; +} + +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 => ` +
+
${esc(f.label)}
+
${esc(f.content)}
+
+ `).join(''); + + const textareaContent = esc(item.chair_position || ''); + + return ` +
+ + ${item.number} + ${esc(item.title)} + ${esc(statusText)} + +
+ ${fieldsHtml} +
+
+ עמדת ועדת הערר + +
+ +
+
+
+ `; +} + +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;