feat(plans): הרשאות-סוכנים + dedup/merge/edit + API לתור-אישור תכניות (backend)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

Follow-ups למרשם-התכניות (PR #252), חלק ה-backend (ללא-UI):
- סוכנים: הוספת extract_plans/plan_get/plan_search/plan_list ל-CEO ול-חוקר
  (+ plan_upsert לחוקר); plan_review נשאר אנושי בלבד (G10). .claude/agents/*.md.
- db: _plan_core_token + find_similar_plans (הצפת כפילות-וריאנט לאישור ידני, בלי
  מיזוג-אוטומטי), update_plan (עריכה+renumber, guard התנגשות→merge), merge_plans
  (איחוד aliases, מילוי-חוסר, סתירות→discrepancies, מחיקת מקור).
- plans_extractor: צירוף possible_duplicates לפלט החילוץ.
- web/app.py: GET /api/plans(+/{id},/{id}/duplicates) · POST /api/plans · PATCH
  /api/plans/{id} · POST /api/plans/{id}/review · POST /api/plans/merge; +קטגוריית
  "תכניות הממתינות לאישור" ב-/api/chair/pending.

תיקון-נתונים (DB, מחוץ ל-PR): הל/מח/250 ד' → 7.1.2002, י"פ 5045.
ה-UI (טאב /precedents + מונה /approvals) ב-PR נפרד אחרי שער Claude Design.

Invariants: G1 (נרמול בכתיבה/עריכה) · G2 · G3 · G10 (review_status + מיזוג ידני,
אישור אנושי) · INV-DM2/DM5 · INV-AH · X9 · אין בליעה שקטה (discrepancies).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 14:39:46 +00:00
parent 09ea1ee599
commit 46bcaa8fa3
5 changed files with 340 additions and 2 deletions

View File

@@ -3698,6 +3698,163 @@ async def set_plan_review_status(plan_id: UUID, status: str) -> dict | None:
return _plan_row_to_dict(row)
# words that describe a plan but aren't its identifying code — stripped for the
# comparable "core token" used in near-duplicate detection (NOT for the key).
_PLAN_DESC_WORDS = re.compile(
r"(?:תכנית|תוכנית|מתאר|מקומית|מחוזית|ארצית|מפורטת|כוללנית|מספר|מס['\"׳״]?)"
)
def _plan_core_token(s: str) -> str:
"""Best-effort comparable 'core' of a plan identifier — for near-duplicate
surfacing only, NEVER a key (bare numbers collide). Strips descriptive words
(תכנית/מתאר/מקומית/מס') and quotes, keeps the alphanumeric code:
"תכנית מתאר מקומית מס' 62""62" · "מי/820""מי/820" · "5166/ב""5166/ב".
"""
t = _normalize_plan_number(s)
t = _PLAN_DESC_WORDS.sub(" ", t)
t = t.replace('"', " ").replace("'", " ").replace("׳", " ").replace("״", " ")
return " ".join(t.split()).strip()
async def find_similar_plans(
plan_number: str,
display_name: str = "",
exclude_id: UUID | None = None,
limit: int = 10,
) -> list[dict]:
"""Surface plans that LOOK like the same scheme written differently — so the
chair can merge them (manual gate, G10). Does NOT merge. Each hit carries a
`match_reason`. The registry is small (dozens), so we scan and score in Python.
"""
num = _normalize_plan_number(plan_number)
core = _plan_core_token(plan_number) or _plan_core_token(display_name)
if not num and not core:
return []
pool = await get_pool()
rows = await pool.fetch("SELECT * FROM plans")
out: list[dict] = []
exclude = str(exclude_id) if exclude_id else None
for r in rows:
d = _plan_row_to_dict(r)
if exclude and d["id"] == exclude:
continue
cand_num = d["plan_number"]
reason = None
if num and cand_num and (num == cand_num or num in cand_num or cand_num in num):
reason = "מספר חופף"
elif num and num in (d.get("aliases") or []):
reason = "alias תואם"
elif core and core == _plan_core_token(cand_num):
reason = "קוד-ליבה זהה"
if reason:
out.append({**d, "match_reason": reason})
if len(out) >= limit:
break
return out
async def update_plan(
plan_id: UUID,
plan_number: str,
display_name: str = "",
plan_type: str = "",
gazette_date=None,
yalkut_number: str = "",
purpose: str = "",
aliases: list[str] | None = None,
) -> dict | None:
"""Edit a plan by id (chair UI). Re-normalizes plan_number (G1) and re-renders
the citation. Refuses to collide with another row's number — that's a merge, not
an edit (no silent dup). `aliases=None` keeps existing aliases.
"""
num = _normalize_plan_number(plan_number)
if not num:
raise ValueError("plan_number ריק לאחר נרמול")
gd = _coerce_plan_date(gazette_date)
display_name = (display_name or "").strip() or num
citation = format_plan_citation({
"display_name": display_name, "plan_number": num,
"gazette_date": gd, "yalkut_number": yalkut_number, "purpose": purpose,
})
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
clash = await conn.fetchrow(
"SELECT id FROM plans WHERE plan_number = $1 AND id <> $2", num, plan_id,
)
if clash:
raise ValueError(
f"מספר-תכנית {num} כבר קיים ברשומה אחרת — השתמש במיזוג (merge), לא בעריכה"
)
row = await conn.fetchrow(
"""UPDATE plans SET
plan_number = $2, display_name = $3, plan_type = $4,
gazette_date = $5, yalkut_number = $6, purpose = $7,
citation_formatted = $8,
aliases = COALESCE($9, aliases), updated_at = now()
WHERE id = $1 RETURNING *""",
plan_id, num, display_name, plan_type or "", gd,
yalkut_number or "", purpose or "", citation,
sorted(set(aliases)) if aliases is not None else None,
)
return _plan_row_to_dict(row)
async def merge_plans(source_id: UUID, target_id: UUID) -> dict:
"""Fold `source` into `target` (chair-initiated): union aliases (+ source's
number/name as aliases), fill empty target validity from source, record any
CONFLICTING validity in target.discrepancies (no silent loss), delete source.
Returns the merged target.
"""
if source_id == target_id:
raise ValueError("מקור ויעד זהים")
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
src = await conn.fetchrow("SELECT * FROM plans WHERE id = $1 FOR UPDATE", source_id)
tgt = await conn.fetchrow("SELECT * FROM plans WHERE id = $1 FOR UPDATE", target_id)
if not src or not tgt:
raise ValueError("מקור או יעד לא נמצאו")
src, tgt = dict(src), dict(tgt)
aliases = set(tgt.get("aliases") or []) | set(src.get("aliases") or [])
for a in (src["plan_number"], src["display_name"]):
if a and a not in (tgt["plan_number"], tgt["display_name"]):
aliases.add(a)
disc = tgt.get("discrepancies") or []
if isinstance(disc, str):
disc = json.loads(disc)
for field in ("gazette_date", "yalkut_number", "purpose"):
sv, tv = src[field], tgt[field]
if sv and tv and str(sv) != str(tv):
disc.append({
"field": field, "old": str(tv), "new": str(sv),
"source_case_number": src.get("source_case_number") or "",
"via": "merge",
})
new_gd = tgt["gazette_date"] or src["gazette_date"]
new_yalkut = tgt["yalkut_number"] or src["yalkut_number"]
new_purpose = tgt["purpose"] or src["purpose"]
new_type = tgt["plan_type"] or src["plan_type"]
citation = format_plan_citation({
"display_name": tgt["display_name"], "plan_number": tgt["plan_number"],
"gazette_date": new_gd, "yalkut_number": new_yalkut, "purpose": new_purpose,
})
row = await conn.fetchrow(
"""UPDATE plans SET aliases = $2, gazette_date = $3, yalkut_number = $4,
purpose = $5, plan_type = $6, citation_formatted = $7,
discrepancies = $8, updated_at = now()
WHERE id = $1 RETURNING *""",
target_id, sorted(aliases), new_gd, new_yalkut, new_purpose,
new_type, citation, json.dumps(disc, ensure_ascii=False),
)
await conn.execute("DELETE FROM plans WHERE id = $1", source_id)
return _plan_row_to_dict(row)
# ── V7: External precedent library + halachot ─────────────────────