fix(research): עמדת ועדת הערר בטענות-סף נשמרה אך לא נקראה בחזרה
הקורא והכותב של 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>
This commit is contained in:
@@ -43,8 +43,18 @@ 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"
|
||||
# and both the bare and the list-item form, because the analyst agent writes
|
||||
# threshold claims as a bullet list ("- **עמדת ועדת הערר:**") while it writes
|
||||
# issues bare. Group 1 is the list marker (or None), group 2 is the label.
|
||||
# The label itself must not contain ** or newlines.
|
||||
FIELD_LABEL_RE = re.compile(r"^\*\*([^\n*]+?):\*\*[ \t]*", re.MULTILINE)
|
||||
FIELD_LABEL_RE = re.compile(
|
||||
r"^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)?\*\*([^\n*]+?):\*\*[ \t]*",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# Terminators that end a field's content even without a following field label:
|
||||
# a heading, or a horizontal rule closing the subsection.
|
||||
FIELD_TERMINATOR_RE = re.compile(r"^(?:#{2,}[ \t]|[ \t]*---[ \t]*$)", re.MULTILINE)
|
||||
|
||||
# Matches the case number in the H1
|
||||
CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILINE)
|
||||
@@ -53,6 +63,17 @@ CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILIN
|
||||
DATE_RE = re.compile(r"^תאריך:\s*(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _is_chair_label(label: str) -> bool:
|
||||
"""Is this field label the chair-position field?
|
||||
|
||||
Matches on prefix, not equality, because the analyst sometimes decorates the
|
||||
label with a parenthetical — "עמדת ועדת הערר (הכוונת יו"ר 24.6)". Requiring
|
||||
equality made the reader treat those as ordinary fields and the writer append
|
||||
a second, duplicate block instead of updating the existing one.
|
||||
"""
|
||||
return label.strip().startswith(CHAIR_POSITION_LABEL)
|
||||
|
||||
|
||||
def _is_placeholder(text: str) -> bool:
|
||||
"""Check if a field value is one of the placeholder strings (empty)."""
|
||||
stripped = text.strip()
|
||||
@@ -135,7 +156,7 @@ def _extract_fields(text: str) -> list[dict]:
|
||||
|
||||
fields = []
|
||||
for i, m in enumerate(matches):
|
||||
label = m.group(1).strip()
|
||||
label = m.group(2).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()
|
||||
@@ -164,11 +185,17 @@ def _build_subsection_dict(
|
||||
parts = title.split(": ", 1)
|
||||
display_title = parts[1] if len(parts) > 1 else title
|
||||
|
||||
# Only the *first* chair-position field is the editable one — the same one
|
||||
# update_chair_position writes to. A subsection that carries more than one
|
||||
# (e.g. an H3 covering "סוגיות 4–6") keeps the extras as ordinary fields
|
||||
# rather than silently reading back a different field than the one saved.
|
||||
chair_position = ""
|
||||
chair_seen = False
|
||||
regular_fields = []
|
||||
for f in fields:
|
||||
if f["label"] == CHAIR_POSITION_LABEL:
|
||||
if not chair_seen and _is_chair_label(f["label"]):
|
||||
chair_position = _normalize_chair_position(f["content"])
|
||||
chair_seen = True
|
||||
else:
|
||||
regular_fields.append(f)
|
||||
|
||||
@@ -311,6 +338,46 @@ def _find_subsection_by_id(
|
||||
return None
|
||||
|
||||
|
||||
def _split_trailing_rule(body: str) -> tuple[str, str]:
|
||||
"""Split a subsection body into (content, trailing "---" separator).
|
||||
|
||||
Returns ("<body>", "") when the subsection has no closing rule.
|
||||
"""
|
||||
m = re.search(r"\n[ \t]*---[ \t]*\s*\Z", body)
|
||||
if not m:
|
||||
return body, ""
|
||||
return body[: m.start()], body[m.start() :]
|
||||
|
||||
|
||||
def _chair_field_span(body: str) -> tuple[int, int] | None:
|
||||
"""Locate the chair-position field's *content* range in a subsection body.
|
||||
|
||||
Returns (content_start, content_end) — the slice the chair's text occupies,
|
||||
excluding the "**LABEL:**" marker itself so the marker line (and any list
|
||||
bullet in front of it) survives an update untouched. Returns None when the
|
||||
subsection has no chair-position field yet.
|
||||
|
||||
Field boundaries come from FIELD_LABEL_RE — the same definition the reader
|
||||
uses — so what update writes is exactly what parse reads back (G2: one
|
||||
definition of a field, not two that can drift).
|
||||
"""
|
||||
matches = list(FIELD_LABEL_RE.finditer(body))
|
||||
for i, m in enumerate(matches):
|
||||
if not _is_chair_label(m.group(2)):
|
||||
continue
|
||||
content_start = m.end()
|
||||
if i + 1 < len(matches):
|
||||
content_end = matches[i + 1].start()
|
||||
else:
|
||||
content_end = len(body)
|
||||
# A heading or closing rule ends the field even without a next label.
|
||||
term = FIELD_TERMINATOR_RE.search(body, content_start, content_end)
|
||||
if term:
|
||||
content_end = term.start()
|
||||
return content_start, content_end
|
||||
return None
|
||||
|
||||
|
||||
def update_chair_position(
|
||||
file_path: Path, section_id: str, new_text: str
|
||||
) -> dict[str, Any]:
|
||||
@@ -330,40 +397,62 @@ def update_chair_position(
|
||||
|
||||
_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:]
|
||||
span = _chair_field_span(subsection_body)
|
||||
body_text = new_text.strip() or CHAIR_POSITION_PLACEHOLDERS[0]
|
||||
|
||||
if span is None:
|
||||
# Label not present — append it at the end of the subsection,
|
||||
# before a trailing horizontal rule if there is one.
|
||||
head, tail = _split_trailing_rule(subsection_body)
|
||||
new_block = f"\n\n**{CHAIR_POSITION_LABEL}:**\n{body_text}\n"
|
||||
new_subsection = head.rstrip() + new_block + tail
|
||||
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"
|
||||
# Replace the existing content of the chair_position field, keeping the
|
||||
# label line exactly as written (including any list marker) so the file
|
||||
# structure the analyst produced is preserved.
|
||||
content_start, content_end = span
|
||||
new_subsection = (
|
||||
subsection_body[: m.start()] + replacement + subsection_body[m.end():]
|
||||
subsection_body[:content_start].rstrip("\r\n \t")
|
||||
+ f"\n{body_text}\n"
|
||||
+ subsection_body[content_end:]
|
||||
)
|
||||
new_content = content[:_abs_start] + new_subsection + content[_abs_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") # noqa: STG1 — atomic .tmp; in-place edit, S3 re-sync in Phase-2 read-wiring
|
||||
os.replace(tmp_path, file_path)
|
||||
|
||||
preview = new_text.strip()[:120]
|
||||
# Read-after-write: a position the parser cannot read back is not saved,
|
||||
# however cleanly the write itself succeeded. Reporting success here is what
|
||||
# let a whole class of format drift hide behind a green "נשמר" in the UI.
|
||||
stored = _stored_chair_position(file_path, section_id)
|
||||
expected = _normalize_chair_position(new_text)
|
||||
if stored != expected:
|
||||
raise RuntimeError(
|
||||
f"העמדה נכתבה ל-{file_path.name} אך לא נקראה בחזרה עבור {section_id} "
|
||||
f"— ככל הנראה מבנה השדה בקובץ חורג מהתבנית הצפויה"
|
||||
)
|
||||
|
||||
return {
|
||||
"saved": True,
|
||||
"section_id": section_id,
|
||||
"preview": preview,
|
||||
"position": stored,
|
||||
"preview": stored[:120],
|
||||
"timestamp": datetime.now(IL_TZ).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _stored_chair_position(file_path: Path, section_id: str) -> str:
|
||||
"""Re-parse the file and return the chair position now stored for a section."""
|
||||
parsed = parse(file_path)
|
||||
for item in parsed.get("threshold_claims", []) + parsed.get("issues", []):
|
||||
if item["id"] == section_id:
|
||||
return item.get("chair_position", "") or ""
|
||||
return ""
|
||||
|
||||
|
||||
# ── Chair directions extraction (for downstream agents) ─────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user