הקורא והכותב של analysis-and-research.md לא הסכימו על מהו שדה: FIELD_LABEL_RE דרש `**כותרת:**` בתחילת שורה, בעוד הכותב התאים את התווית בכל מקום בשורה. האנליסט כותב טענות-סף כרשימה (`- **עמדת ועדת הערר:**`) ואת הסוגיות בתחילת שורה — ולכן שמירה בטענת-סף הצליחה (200 + "✓ נשמר"), אבל הקורא לא ראה את השדה כלל והעמדה נעלמה ברענון. גם יתר שדות הטענה (טענה/תשובה/שאלה משפטית) היו בלתי-נראים באותן טענות. - הקורא מקבל תווית עם סמן-רשימה אופציונלי, כמו הכותב. - הכותב עובר להשתמש באותה הגדרת-גבולות של הקורא (_chair_field_span) במקום regex משלו — `[^*]*?` הישן גם קטע עמדה שהכילה `**הדגשה**`. התווית והסמן נשמרים כפי שהם, וכך גם `---` הסוגר. - תווית מעוטרת (`עמדת ועדת הערר (הכוונת יו"ר 24.6)`) מזוהה בהתאמת-רישא; קודם הכותב הוסיף בלוק כפול במקום לעדכן. - שדה כפול באותו H3 (`### סוגיות 4–6`): הקורא לקח את האחרון והכותב את הראשון. שניהם לוקחים עכשיו את הראשון. - read-after-write: שמירה שהפרסר לא קורא בחזרה מדווחת כשגיאה במקום "נשמר" ירוק, וה-UI שומר בקאש את מה שהשרת קרא — לא את מה ששלח. - תבנית האנליסט (§5) קיבלה שלד מפורש לטענות-סף, זהה לזה של הסוגיות, כדי שקבצים חדשים לא ייווצרו במבנה החורג (נרמול-במקור, G1). הרצת round-trip על כל הקורפוס: 24 מתוך 141 תת-סעיפים נכשלו לפני התיקון (1017, 1019, 1027, 1033, 1043, 1069, 8124) — 0 אחריו. העמדות שכבר נשמרו בקבצים הקיימים חוזרות להיקרא בלי מיגרציה. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
238 lines
9.5 KiB
Python
238 lines
9.5 KiB
Python
"""Chair-position round-trip in analysis-and-research.md.
|
||
|
||
Regression cover for the reader/writer asymmetry that made "עמדת ועדת הערר"
|
||
appear to save on threshold claims and then vanish on refresh: the writer
|
||
matched the label anywhere on a line, the reader only at the start of one, and
|
||
the analyst agent writes threshold claims as a bullet list
|
||
("- **עמדת ועדת הערר:**") while it writes issues flush-left.
|
||
|
||
The invariant these tests pin down: whatever update_chair_position writes,
|
||
parse() must read back — for every label form that appears in the corpus.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from legal_mcp.services import research_md
|
||
|
||
|
||
BULLETED = """# ניתוח משפטי — ערר 1069-04-26
|
||
תאריך: 2026-08-02
|
||
|
||
## 5. טענות סף
|
||
|
||
### ס-1 — אי-מילוי תנאי ההפקדה
|
||
- **טענה (claim):** התכנית אושרה מבלי שמולאו תנאי ההפקדה.
|
||
- **שאלה משפטית:** האם הסטת התנאים פוגמת בחוקיות?
|
||
- **עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
|
||
|
||
### ס-2 — זכות עמידה
|
||
- **טענה (claim):** לעוררים אין אינטרס מוגן.
|
||
- **עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
|
||
|
||
## 6. סוגיות להכרעה
|
||
|
||
### סוגיה 1: סמכות לפי 62א(א)(11)
|
||
|
||
**ניתוח:**
|
||
- הכלל החל: ...
|
||
|
||
**עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
|
||
|
||
---
|
||
"""
|
||
|
||
|
||
@pytest.fixture()
|
||
def analysis_file(tmp_path: Path) -> Path:
|
||
path = tmp_path / "analysis-and-research.md"
|
||
path.write_text(BULLETED, encoding="utf-8")
|
||
return path
|
||
|
||
|
||
def _positions(path: Path) -> dict[str, str]:
|
||
parsed = research_md.parse(path)
|
||
items = parsed["threshold_claims"] + parsed["issues"]
|
||
return {item["id"]: item["chair_position"] for item in items}
|
||
|
||
|
||
# ── the reported bug ────────────────────────────────────────────
|
||
|
||
|
||
def test_bulleted_threshold_position_survives_a_reload(analysis_file: Path) -> None:
|
||
"""The bug as chaim hit it: saved on a threshold claim, gone on refresh."""
|
||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדתנו היא שהטענה נדחית.")
|
||
|
||
assert _positions(analysis_file)["threshold_1"] == "עמדתנו היא שהטענה נדחית."
|
||
|
||
|
||
def test_flush_left_issue_position_still_round_trips(analysis_file: Path) -> None:
|
||
"""The surface that already worked must keep working."""
|
||
research_md.update_chair_position(analysis_file, "issue_1", "יש לקבל את הערר בנקודה זו.")
|
||
|
||
assert _positions(analysis_file)["issue_1"] == "יש לקבל את הערר בנקודה זו."
|
||
|
||
|
||
def test_bulleted_threshold_fields_are_parsed_at_all(analysis_file: Path) -> None:
|
||
"""Bulleted labels were invisible to the reader — the whole claim looked empty."""
|
||
threshold = research_md.parse(analysis_file)["threshold_claims"]
|
||
|
||
labels = [f["label"] for f in threshold[0]["fields"]]
|
||
assert "טענה (claim)" in labels
|
||
assert "שאלה משפטית" in labels
|
||
# the chair field is surfaced separately, never as a regular field
|
||
assert not any(research_md._is_chair_label(label) for label in labels)
|
||
|
||
|
||
# ── writer/reader agreement on content boundaries ───────────────
|
||
|
||
|
||
def test_position_containing_markdown_is_not_truncated(analysis_file: Path) -> None:
|
||
"""The old writer stopped the field at the first '*' and swallowed the rest."""
|
||
text = 'העמדה כוללת **הדגשה** וגם קו --- באמצע\nושורה שנייה.'
|
||
research_md.update_chair_position(analysis_file, "issue_1", text)
|
||
|
||
assert _positions(analysis_file)["issue_1"] == text
|
||
|
||
|
||
def test_update_does_not_touch_sibling_subsections(analysis_file: Path) -> None:
|
||
research_md.update_chair_position(analysis_file, "threshold_1", "ראשונה")
|
||
research_md.update_chair_position(analysis_file, "threshold_2", "שנייה")
|
||
|
||
positions = _positions(analysis_file)
|
||
assert positions["threshold_1"] == "ראשונה"
|
||
assert positions["threshold_2"] == "שנייה"
|
||
assert positions["issue_1"] == ""
|
||
|
||
|
||
def test_closing_rule_and_bullet_marker_are_preserved(analysis_file: Path) -> None:
|
||
"""An update must not restructure the file the analyst produced."""
|
||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||
research_md.update_chair_position(analysis_file, "issue_1", "עמדה")
|
||
content = analysis_file.read_text(encoding="utf-8")
|
||
|
||
assert "- **עמדת ועדת הערר:**" in content
|
||
assert content.rstrip().endswith("---")
|
||
|
||
|
||
def test_clearing_a_position_restores_the_placeholder(analysis_file: Path) -> None:
|
||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||
research_md.update_chair_position(analysis_file, "threshold_1", "")
|
||
|
||
assert _positions(analysis_file)["threshold_1"] == ""
|
||
assert research_md.CHAIR_POSITION_PLACEHOLDERS[0] in analysis_file.read_text(
|
||
encoding="utf-8"
|
||
)
|
||
|
||
|
||
def test_repeated_saves_do_not_accumulate_blocks(analysis_file: Path) -> None:
|
||
for text in ("ראשון", "שני", "שלישי"):
|
||
research_md.update_chair_position(analysis_file, "threshold_1", text)
|
||
|
||
content = analysis_file.read_text(encoding="utf-8")
|
||
assert content.count(f"**{research_md.CHAIR_POSITION_LABEL}:**") == 3
|
||
assert _positions(analysis_file)["threshold_1"] == "שלישי"
|
||
|
||
|
||
# ── decorated and duplicated labels ─────────────────────────────
|
||
|
||
|
||
def test_decorated_label_is_updated_not_duplicated(tmp_path: Path) -> None:
|
||
"""'עמדת ועדת הערר (הכוונת יו"ר 24.6)' appears in the corpus (8125-09-24)."""
|
||
path = tmp_path / "a.md"
|
||
path.write_text(
|
||
"## 6. סוגיות להכרעה\n\n"
|
||
"### סוגיה 1: כותרת\n\n"
|
||
'**עמדת ועדת הערר (הכוונת יו"ר 24.6):** [ימולא ע"י יו"ר הוועדה]\n',
|
||
encoding="utf-8",
|
||
)
|
||
|
||
research_md.update_chair_position(path, "issue_1", "עמדה מעודכנת")
|
||
|
||
content = path.read_text(encoding="utf-8")
|
||
assert content.count(research_md.CHAIR_POSITION_LABEL) == 1
|
||
assert _positions(path)["issue_1"] == "עמדה מעודכנת"
|
||
|
||
|
||
def test_duplicate_labels_read_back_the_one_that_was_written(tmp_path: Path) -> None:
|
||
"""An H3 covering several issues (1033-02-25) must not read a sibling's text."""
|
||
path = tmp_path / "a.md"
|
||
path.write_text(
|
||
"## 6. סוגיות להכרעה\n\n"
|
||
"### סוגיות 4–6: מקובצות\n\n"
|
||
"**עמדת ועדת הערר:** ראשונה\n\n"
|
||
"**עמדת ועדת הערר:** אחרונה\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
research_md.update_chair_position(path, "issue_1", "העמדה הנכונה")
|
||
|
||
assert _positions(path)["issue_1"] == "העמדה הנכונה"
|
||
|
||
|
||
# ── read-after-write guard ──────────────────────────────────────
|
||
|
||
|
||
def test_update_returns_the_persisted_value(analysis_file: Path) -> None:
|
||
result = research_md.update_chair_position(analysis_file, "threshold_1", " עמדה ")
|
||
|
||
assert result["saved"] is True
|
||
assert result["position"] == "עמדה"
|
||
|
||
|
||
def test_a_write_the_parser_cannot_read_back_is_reported_as_failure(
|
||
analysis_file: Path, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Regression guard: the bug hid behind a success response for months."""
|
||
monkeypatch.setattr(
|
||
research_md, "_stored_chair_position", lambda *_args: "משהו אחר"
|
||
)
|
||
|
||
with pytest.raises(RuntimeError, match="לא נקראה בחזרה"):
|
||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||
|
||
|
||
# ── downstream consumers see the recovered positions ────────────
|
||
|
||
|
||
def test_chair_directions_count_bulleted_positions(analysis_file: Path) -> None:
|
||
"""extract_chair_directions feeds legal-writer; it under-reported before."""
|
||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||
|
||
directions = research_md.extract_chair_directions(analysis_file)
|
||
assert directions["filled_count"] == 1
|
||
assert directions["status"] == "partial"
|
||
filled = [t for t in directions["threshold_claims"] if t["direction"]]
|
||
assert [t["id"] for t in filled] == ["threshold_1"]
|
||
|
||
|
||
# ── the real corpus ─────────────────────────────────────────────
|
||
|
||
|
||
CORPUS = sorted(
|
||
Path("/home/chaim/legal-ai/data/cases").glob(
|
||
"*/documents/research/analysis-and-research.md"
|
||
)
|
||
)
|
||
|
||
|
||
@pytest.mark.skipif(not CORPUS, reason="case corpus not present on this host")
|
||
@pytest.mark.parametrize("source", CORPUS, ids=lambda p: p.parts[-4])
|
||
def test_every_corpus_subsection_round_trips(source: Path, tmp_path: Path) -> None:
|
||
sentinel = "עמדת-בדיקה — **הדגשה** ו---קו\nושורה שנייה."
|
||
before = _positions(source)
|
||
|
||
for section_id in before:
|
||
working = tmp_path / f"{section_id}.md"
|
||
working.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
|
||
|
||
research_md.update_chair_position(working, section_id, sentinel)
|
||
after = _positions(working)
|
||
|
||
assert after[section_id] == sentinel, f"{source.parts[-4]} {section_id}"
|
||
untouched = {k: v for k, v in after.items() if k != section_id}
|
||
assert untouched == {k: v for k, v in before.items() if k != section_id}
|