feat(plans): הרשאות-סוכנים + dedup/merge/edit + API לתור-אישור תכניות (backend)
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:
159
web/app.py
159
web/app.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user