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:
Keysat
2026-06-05 11:14:12 -05:00
parent fa2a5ce95f
commit cd3cca725c
8 changed files with 336 additions and 65 deletions
+58
View File
@@ -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: