Phase 1: dual approval default, web-UI index jobs + merge review queue, thesis v2
- Dual sign-off is now the default (thesis_required_approvals defaults to 2).
- Entity-merge review queue (migration 0003): the fuzzy/Qwen tier no longer
auto-merges — it writes CANDIDATES (entity_merge_candidates) with a same/different
suggestion + confidence + reason for a human to approve (merge) or reject (keep
separate). entity_merge.py applies/rejects (durable via entity_merges, soft-delete,
repoint links+edges); decided pairs aren't re-surfaced.
- entity_jobs.py: UI-triggered background index jobs (rebuild/update/find-duplicates)
as subprocesses with a one-at-a-time lock; status in /api/system/status.
- server.py: /api/index/{rebuild,update}, /api/entities/find-duplicates,
/api/entities/merge-candidates [+ /{id} decide] — admin-gated.
- docs/thesis-seed-v2.md: concrete, plain-English rewrite per Grant's feedback.
Backend verified end-to-end on synthetic data (candidate gen -> approve/reject).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,16 @@ try:
|
||||
except Exception:
|
||||
thesis_review = None
|
||||
|
||||
# Phase-1: entity-merge review + UI-triggered index jobs (guarded).
|
||||
try:
|
||||
import entity_merge # type: ignore
|
||||
except Exception:
|
||||
entity_merge = None
|
||||
try:
|
||||
import entity_jobs # type: ignore
|
||||
except Exception:
|
||||
entity_jobs = None
|
||||
|
||||
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -1749,6 +1759,10 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
if re.match(r'^/api/thesis/[^/]+/canonical$', path):
|
||||
return self.handle_get_canonical_thesis(user, path.split('/')[-2])
|
||||
|
||||
# ─── Entity-merge review queue ───
|
||||
if path == '/api/entities/merge-candidates':
|
||||
return self.handle_list_merge_candidates(user, params)
|
||||
|
||||
self.send_error_json("Not found", 404)
|
||||
|
||||
def do_POST(self):
|
||||
@@ -1817,6 +1831,16 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
if re.match(r'^/api/thesis/versions/[^/]+/review$', path):
|
||||
return self.handle_thesis_review(user, path.split('/')[-2], body)
|
||||
|
||||
# ─── UI-triggered index jobs + entity-merge decisions (Phase 1) ───
|
||||
if path == '/api/index/rebuild':
|
||||
return self.handle_index_job(user, 'rebuild_index')
|
||||
if path == '/api/index/update':
|
||||
return self.handle_index_job(user, 'update_index')
|
||||
if path == '/api/entities/find-duplicates':
|
||||
return self.handle_index_job(user, 'find_duplicates')
|
||||
if re.match(r'^/api/entities/merge-candidates/[^/]+$', path):
|
||||
return self.handle_decide_merge_candidate(user, path.split('/')[-1], body)
|
||||
|
||||
self.send_error_json("Not found", 404)
|
||||
|
||||
def do_PUT(self):
|
||||
@@ -3462,9 +3486,43 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
"SELECT ts, actor_type, actor_id, action FROM interaction_log ORDER BY ts DESC LIMIT 12")]
|
||||
except Exception:
|
||||
out['recent_activity'] = []
|
||||
try:
|
||||
out['pending_merge_candidates'] = conn.execute(
|
||||
"SELECT COUNT(*) FROM entity_merge_candidates WHERE status='pending'").fetchone()[0]
|
||||
except Exception:
|
||||
out['pending_merge_candidates'] = None
|
||||
out['index_job'] = entity_jobs.get_status() if entity_jobs else None
|
||||
conn.close()
|
||||
self.send_json({"data": out})
|
||||
|
||||
# ─── UI-triggered index jobs + entity-merge review (Phase 1) ───
|
||||
def handle_index_job(self, user, kind):
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if entity_jobs is None:
|
||||
return self.send_error_json("Jobs unavailable", 503)
|
||||
res = entity_jobs.start(kind, DB_PATH)
|
||||
if res.get('error'):
|
||||
return self.send_error_json(res['error'], 409)
|
||||
return self.send_json({"data": res})
|
||||
|
||||
def handle_list_merge_candidates(self, user, params):
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if entity_merge is None:
|
||||
return self.send_error_json("Unavailable", 503)
|
||||
return self.send_json(entity_merge.list_candidates(DB_PATH, params.get('status', 'pending')))
|
||||
|
||||
def handle_decide_merge_candidate(self, user, candidate_id, body):
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
if entity_merge is None:
|
||||
return self.send_error_json("Unavailable", 503)
|
||||
res = entity_merge.decide(DB_PATH, candidate_id, (body or {}).get('decision'), user['user_id'])
|
||||
if res.get('error'):
|
||||
return self.send_error_json(res['error'], 400)
|
||||
return self.send_json({"data": res})
|
||||
|
||||
# ─── Architect thesis (Phase 1) ───
|
||||
def handle_list_thesis_lines(self, user):
|
||||
if thesis_review is None:
|
||||
|
||||
Reference in New Issue
Block a user