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

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