feat(plans): הרשאות-סוכנים + dedup/merge/edit + API לתור-אישור תכניות (backend) #253

Merged
chaim merged 1 commits from worktree-plans-followups into main 2026-06-14 14:40:34 +00:00
5 changed files with 340 additions and 2 deletions
Showing only changes of commit 46bcaa8fa3 - Show all commits

View File

@@ -41,6 +41,10 @@ tools:
- mcp__legal-ai__halacha_corroboration
- mcp__legal-ai__corroboration_rebuild
- mcp__legal-ai__extract_appraiser_facts
- mcp__legal-ai__extract_plans
- mcp__legal-ai__plan_get
- mcp__legal-ai__plan_search
- mcp__legal-ai__plan_list
- mcp__legal-ai__write_interim_draft
- mcp__legal-ai__export_interim_draft
---

View File

@@ -37,6 +37,11 @@ tools:
- mcp__legal-ai__missing_precedent_create
- mcp__legal-ai__missing_precedent_list
- mcp__legal-ai__missing_precedent_close
- mcp__legal-ai__extract_plans
- mcp__legal-ai__plan_get
- mcp__legal-ai__plan_search
- mcp__legal-ai__plan_list
- mcp__legal-ai__plan_upsert
- mcp__legal-ai__workflow_status
---

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 ─────────────────────

View File

@@ -182,12 +182,25 @@ async def extract_plans_for_case(case_id: UUID) -> dict:
"status": "completed", "candidates": len(cands),
})
# Surface near-duplicates for the chair to merge manually (G10) — never
# auto-merged. A variant of an existing plan written differently won't share
# the normalized key, so flag it here instead of silently creating a dup.
plans_out = list(seen_numbers.values())
dup_hits = 0
for p in plans_out:
sims = await db.find_similar_plans(
p["plan_number"], p.get("display_name", ""), exclude_id=UUID(p["id"]),
)
p["possible_duplicates"] = sims
dup_hits += len(sims)
return {
"status": "completed",
"case_number": source_case_number,
"documents_scanned": len(by_doc),
"total_candidates": total_candidates,
"distinct_plans": len(seen_numbers),
"plans": list(seen_numbers.values()),
"distinct_plans": len(plans_out),
"possible_duplicate_hits": dup_hits,
"plans": plans_out,
"by_document": by_doc,
}

View File

@@ -5774,6 +5774,22 @@ async def api_chair_pending():
"sample": [{"text": r["cn"], "source": r["name"]} for r in se_sample],
})
# 6) תכניות הממתינות לאישור (מרשם-התכניות, V38 / G10) — נראות בבלוק ט רק לאחר אישור
pl_count = await conn.fetchval(
"SELECT count(*) FROM plans WHERE review_status='pending_review'")
pl_oldest = await conn.fetchval(
"SELECT min(created_at) FROM plans WHERE review_status='pending_review'")
pl_sample = await conn.fetch(
"SELECT coalesce(display_name, plan_number) AS name, coalesce(purpose,'') AS purpose "
"FROM plans WHERE review_status='pending_review' ORDER BY created_at ASC LIMIT 5")
categories.append({
"key": "plans", "label": "תכניות הממתינות לאישור",
"description": "תכניות שחולצו אוטומטית מהחלטות — מצוטטות בבלוק ט רק לאחר אישורך.",
"count": pl_count, "severity": "medium" if pl_count else "ok",
"href": "/precedents", "oldest_at": pl_oldest.isoformat() if pl_oldest else None,
"sample": [{"text": r["name"], "source": (r["purpose"] or "")[:80]} for r in pl_sample],
})
total_pending = sum(c["count"] for c in categories)
return {
"total_pending": total_pending,
@@ -7436,6 +7452,149 @@ async def halacha_batch_review(req: HalachaBatchReviewRequest):
return {"updated": updated}
# ── Planning-schemes registry (V38) — מרשם-התכניות ─────────────────
# Chair review queue + manual add/edit/merge for the canonical plans registry.
# LLM-extracted rows arrive pending_review; only approved validity feeds block-tet
# (INV-DM5/G10). Variant duplicates are surfaced, never auto-merged (chair decides).
_ALLOWED_PLAN_STATUS = {"pending_review", "approved", "rejected"}
class PlanUpsertRequest(BaseModel):
plan_number: str
display_name: str = ""
plan_type: str = ""
gazette_date: str = "" # ISO YYYY-MM-DD (empty = unknown)
yalkut_number: str = ""
purpose: str = ""
review_status: str = "approved" # manual chair add → approved by default
aliases: list[str] = []
class PlanEditRequest(BaseModel):
plan_number: str
display_name: str = ""
plan_type: str = ""
gazette_date: str = ""
yalkut_number: str = ""
purpose: str = ""
aliases: list[str] | None = None
class PlanReviewRequest(BaseModel):
review_status: str
class PlanMergeRequest(BaseModel):
source_id: str
target_id: str
@app.get("/api/plans")
async def plans_list(review_status: str = "", q: str = "", limit: int = 500):
"""List the plans registry; filter by review_status (queue) or fuzzy q (search)."""
if review_status and review_status not in _ALLOWED_PLAN_STATUS:
raise HTTPException(400, "review_status לא תקין")
if q:
rows = await db.search_plans(q, limit)
else:
rows = await db.list_plans(review_status, limit)
return {"items": rows, "count": len(rows)}
@app.get("/api/plans/{plan_id}/duplicates")
async def plan_duplicates(plan_id: str):
"""Near-duplicate candidates for a plan — for the chair to merge (G10, no auto-merge)."""
try:
pid = UUID(plan_id)
except ValueError:
raise HTTPException(400, "plan_id לא תקין")
plan = await db.get_plan_by_id(pid)
if not plan:
raise HTTPException(404, "תכנית לא נמצאה")
sims = await db.find_similar_plans(
plan["plan_number"], plan.get("display_name", ""), exclude_id=pid,
)
return {"items": sims, "count": len(sims)}
@app.get("/api/plans/{plan_id}")
async def plan_get(plan_id: str):
try:
pid = UUID(plan_id)
except ValueError:
raise HTTPException(400, "plan_id לא תקין")
plan = await db.get_plan_by_id(pid)
if not plan:
raise HTTPException(404, "תכנית לא נמצאה")
return plan
@app.post("/api/plans")
async def plan_create(req: PlanUpsertRequest):
"""Manual chair add/upsert (idempotent on normalized plan_number)."""
if req.review_status not in _ALLOWED_PLAN_STATUS:
raise HTTPException(400, "review_status לא תקין")
try:
return await db.upsert_plan(
plan_number=req.plan_number, display_name=req.display_name,
aliases=req.aliases, plan_type=req.plan_type,
gazette_date=req.gazette_date or None, yalkut_number=req.yalkut_number,
purpose=req.purpose, review_status=req.review_status,
model_used="chair_manual",
)
except ValueError as e:
raise HTTPException(400, str(e))
@app.patch("/api/plans/{plan_id}")
async def plan_edit(plan_id: str, req: PlanEditRequest):
"""Edit/fix a plan by id (chair). Refuses a number collision (→ merge instead)."""
try:
pid = UUID(plan_id)
except ValueError:
raise HTTPException(400, "plan_id לא תקין")
try:
row = await db.update_plan(
pid, plan_number=req.plan_number, display_name=req.display_name,
plan_type=req.plan_type, gazette_date=req.gazette_date or None,
yalkut_number=req.yalkut_number, purpose=req.purpose, aliases=req.aliases,
)
except ValueError as e:
raise HTTPException(400, str(e))
if not row:
raise HTTPException(404, "תכנית לא נמצאה")
return row
@app.post("/api/plans/{plan_id}/review")
async def plan_review(plan_id: str, req: PlanReviewRequest):
"""Chair gate (G10): approve / reject / reset."""
if req.review_status not in _ALLOWED_PLAN_STATUS:
raise HTTPException(400, "review_status לא תקין")
try:
pid = UUID(plan_id)
except ValueError:
raise HTTPException(400, "plan_id לא תקין")
row = await db.set_plan_review_status(pid, req.review_status)
if not row:
raise HTTPException(404, "תכנית לא נמצאה")
return row
@app.post("/api/plans/merge")
async def plan_merge(req: PlanMergeRequest):
"""Fold source plan into target (chair-initiated dedup); source is deleted."""
try:
sid, tid = UUID(req.source_id), UUID(req.target_id)
except ValueError:
raise HTTPException(400, "מזהה לא תקין")
try:
return await db.merge_plans(sid, tid)
except ValueError as e:
raise HTTPException(400, str(e))
# ── Missing Precedents (TaskMaster #35) ────────────────────────────
# Track citations from party briefs that aren't yet in the precedent
# corpus. Researcher logs gaps; chair closes them by uploading the