File indexing completed on 2026-07-21 08:46:24
0001
0002 """Match production-request questionnaires to catalog tasks (LLM-assisted).
0003
0004 The questionnaire is free text (generator, process, beams, purpose as the
0005 requester wrote them); the catalog side is composed task names built from
0006 tag codes. The delegate model is handed the COMPLETE tag map inline —
0007 every physics/evgen/simu/reco tag with its real content — plus the task
0008 catalog and a batch of requests in one call, so it never has to (and is
0009 forbidden to) guess what a code means: tag codes are opaque sequential
0010 ids, and inferring anything from their numerals is explicitly banned.
0011 That rule exists because the first run, given only composed names, matched
0012 on digit coincidences ("e9 ≈ 9 GeV") — a delegate starved of facts
0013 manufactures them.
0014
0015 Deterministic guards bound the model: proposed names must resolve against
0016 the actual catalog, existing matches are never modified or re-proposed
0017 (additive only), and confidence gates status — high/medium land as
0018 accepted (removable in the UI), low lands as suggested (visible on the
0019 request page, never counted).
0020
0021 Each new match logs a questionnaire_match_found action-stream event
0022 (normal, recorded but not live). The live channel gets exactly one line
0023 per run with new matches — a questionnaire_new_matches summary event
0024 whose record page lists every new match with confidence and reason. The
0025 run summary is printed as JSON for the calling agent handler.
0026
0027 Rescanning is event-driven, never habitual: an LLM's second answer to the
0028 same question differs from its first, so re-asking unchanged questions
0029 harvests variance — noise in the live stream, misleading reporting, wasted
0030 tokens. Each questionnaire carries a stamp of what it was last scanned
0031 against (data['automatch_scan']: prompt version, task-catalog high-water
0032 id, request content hash) and is asked again only when one of those inputs
0033 changed — the request was edited, the catalog grew, or the matcher prompt
0034 was revised (bump PROMPT_VERSION with any prompt change; that triggers one
0035 deliberate re-pass). A night with no changes makes no LLM call and emits
0036 no events. --all forces a full rescan; --dry-run proposes without writing;
0037 --limit N bounds the scan (testing). Requests are batched CHUNK per call —
0038 one model, educated once with the map, serves the whole batch.
0039
0040 The delegate runs through the Claude Code CLI (``claude -p``) under the
0041 account's subscription login — never the metered API. The API key is
0042 stripped from the call environment so a misconfigured CLI cannot fall back
0043 to per-token billing: this automated path exhausted the monthly API budget
0044 once (2026-07-15) and must not be able to again.
0045 """
0046 import argparse
0047 import hashlib
0048 import json
0049 import os
0050 import shutil
0051 import subprocess
0052 import sys
0053
0054 MODEL = os.environ.get('EPICPROD_MATCHER_MODEL', 'claude-opus-4-8')
0055 STATE_KEY = 'epicprod_automatch_last_task_id'
0056 MATCHED_BY = 'automatch'
0057 CHUNK = 25
0058 CLAUDE_BIN = os.environ.get('EPICPROD_MATCHER_CLAUDE') or (
0059 shutil.which('claude') or os.path.expanduser('~/.local/bin/claude'))
0060
0061 CALL_TIMEOUT = 420
0062
0063
0064
0065 PROMPT_VERSION = 2
0066 SCAN_STAMP_KEY = 'automatch_scan'
0067
0068 SYSTEM_PROMPT = """\
0069 You match ePIC production requests to the production tasks that realize them.
0070 Requests are free text from physicists (generator, process, beam energies,
0071 purpose). Tasks are named by composed identifiers built from tag codes; the
0072 TAG MAP below defines what every code actually is — generator, physics
0073 process, Q2 range, final state, beams.
0074
0075 HARD RULE: tag codes (pNNNN, eN, sN, rN) are opaque sequential identifiers.
0076 Their numeric values carry NO physics meaning — never infer beam energy,
0077 process, or anything else from the numerals or their adjacency. A code
0078 means ONLY what the tag map says it means.
0079
0080 HARD RULE: beam energies and species must match EXACTLY per the tag map.
0081 9x100 never matches 10x100; ep never matches en; eAu never matches eHe3.
0082 If the requested beams do not exist in the catalog, return no match for
0083 that request — never propose the nearest beam.
0084
0085 One request often maps to several tasks (beam-energy variants, campaign
0086 re-runs). Match only when generator, process, and beams genuinely
0087 correspond per the map — an empty result is better than a forced match."""
0088
0089
0090 def scan_content_hash(q):
0091 """Hash of the request fields the matcher reads — a change means the
0092 question changed and the questionnaire earns a rescan."""
0093 basis = '|'.join([q.description or '', q.repository or '', q.nevents or ''])
0094 return hashlib.sha256(basis.encode('utf-8')).hexdigest()[:16]
0095
0096
0097 def norm_energy(value):
0098 """'10' == '10.0' == 10; unparseable -> ''."""
0099 try:
0100 f = float(str(value).strip())
0101 except (TypeError, ValueError):
0102 return ''
0103 return str(int(f)) if f == int(f) else str(f)
0104
0105
0106 def task_beams(task):
0107 """(electron, hadron, species) from the task's physics tag — DB truth."""
0108 ds = task.dataset
0109 p = (ds.physics_tag.parameters
0110 if ds is not None and ds.physics_tag_id else {}) or {}
0111 return (norm_energy(p.get('beam_energy_electron')),
0112 norm_energy(p.get('beam_energy_hadron')),
0113 str(p.get('beam_species') or '').strip().lower())
0114
0115
0116 def beam_verdict(match, task):
0117 """Deterministic beam gate: 'ok', 'reject', or 'unverified'.
0118
0119 The model states the request's beams; the task's beams come from its
0120 physics tag. Exact equality is required — the prompt alone does not
0121 hold this line (observed proposing cross-beam matches at medium
0122 confidence while confessing the mismatch in its own reason).
0123 """
0124 t_e, t_h, t_species = task_beams(task)
0125 req = str(match.get('request_beams') or '').strip().lower()
0126 req_species = str(match.get('request_species') or '').strip().lower()
0127 if not (t_e and t_h):
0128 return 'unverified'
0129 if not req or 'x' not in req:
0130 return 'unverified'
0131 r_e, _, r_h = req.partition('x')
0132 if (norm_energy(r_e), norm_energy(r_h)) != (t_e, t_h):
0133 return 'reject'
0134 if req_species and t_species and req_species != t_species:
0135 return 'reject'
0136 return 'ok'
0137
0138
0139 def compact_params(params):
0140 if not isinstance(params, dict) or not params:
0141 return ''
0142 return ', '.join(f"{k}={v}" for k, v in sorted(params.items())
0143 if v not in (None, '', [], {}))
0144
0145
0146 def build_tag_map():
0147 from pcs.models import EvgenTag, PhysicsTag, RecoTag, SimuTag
0148 lines = ["TAG MAP — the only source of meaning for tag codes:",
0149 "", "Physics tags (p):"]
0150 for t in PhysicsTag.objects.select_related('category').order_by('tag_number'):
0151 cat = t.category.name if t.category else ''
0152 detail = compact_params(t.parameters) or t.description
0153 lines.append(f"{t.tag_label}: {cat} | {detail}")
0154 lines.append("")
0155 lines.append("Event-generator tags (e):")
0156 for t in EvgenTag.objects.order_by('tag_number'):
0157 detail = t.description or compact_params(t.parameters)
0158 lines.append(f"{t.tag_label}: {detail}")
0159 lines.append("")
0160 lines.append("Simulation tags (s):")
0161 for t in SimuTag.objects.order_by('tag_number'):
0162 lines.append(f"{t.tag_label}: {t.description or compact_params(t.parameters)}")
0163 lines.append("")
0164 lines.append("Reconstruction tags (r):")
0165 for t in RecoTag.objects.order_by('tag_number'):
0166 lines.append(f"{t.tag_label}: {t.description or compact_params(t.parameters)}")
0167 return "\n".join(lines)
0168
0169
0170 def build_catalog(tasks):
0171 lines = ["TASK CATALOG (composed name | dataset | sample | status | campaign):"]
0172 for task in tasks:
0173 display = task.composed_name or task.name
0174 if not display:
0175 continue
0176 ds = task.dataset
0177 dataset_name = (ds.dataset_name or '') if ds else ''
0178 sample = (ds.sample_name or '') if ds else ''
0179 campaign = task.campaign.name if task.campaign else ''
0180 lines.append(f"{display} | {dataset_name} | {sample} | {task.status} | {campaign}")
0181 return "\n".join(lines)
0182
0183
0184 def build_requests_block(chunk, existing_names_by_id):
0185 lines = ["PRODUCTION REQUESTS to match:"]
0186 for q in chunk:
0187 lines.append("")
0188 lines.append(f"REQUEST id={q.pk}")
0189 lines.append(f"submitted: {q.submitted_at.date().isoformat()}")
0190 lines.append(f"description: {q.description.strip() or '(none)'}")
0191 lines.append(f"repository: {q.repository.strip() or '(none)'}")
0192 lines.append(f"events requested: {q.nevents.strip() or '(none)'}")
0193 already = existing_names_by_id.get(q.pk) or set()
0194 if already:
0195 lines.append("already matched (do not repeat): "
0196 + '; '.join(sorted(already)))
0197 lines.append(
0198 "\nReturn ONLY a JSON array, no prose, one entry per request that has "
0199 "matches (omit requests with none):\n"
0200 '[{"request_id": <id>, "matches": [{"task_name": "<exact composed '
0201 'name from the catalog>", "confidence": "high|medium|low", '
0202 '"request_beams": "<electron>x<hadron> exactly as the request states '
0203 'them, or \\"\\" if the request does not state beams", '
0204 '"request_species": "<beam species the request states (ep, en, eAu, '
0205 'eHe3, ...), or \\"\\">", '
0206 '"reason": "<one short sentence citing the map facts>"}]}]\n'
0207 "high = certainly this request; medium = very likely; low = plausible "
0208 "but uncertain.")
0209 return "\n".join(lines)
0210
0211
0212 def call_delegate(prompt):
0213 """One subscription-billed delegate call via the Claude Code CLI.
0214
0215 Tools and setting sources are disabled — this is a pure text
0216 completion, and the account's Claude Code hooks/config must not fire.
0217 A non-zero exit or timeout raises: the script fails loudly and the
0218 agent handler logs the error to the action stream.
0219 """
0220 env = {k: v for k, v in os.environ.items()
0221 if k not in ('ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN')}
0222 cmd = [CLAUDE_BIN, '-p', '--model', MODEL,
0223 '--system-prompt', SYSTEM_PROMPT,
0224 '--tools', '', '--setting-sources', '']
0225 p = subprocess.run(cmd, input=prompt, capture_output=True, text=True,
0226 timeout=CALL_TIMEOUT, env=env)
0227 if p.returncode != 0:
0228 raise RuntimeError(
0229 f"claude -p failed rc={p.returncode}: {(p.stderr or '')[:500]}")
0230 return p.stdout or ''
0231
0232
0233 def parse_batch(text):
0234 start, end = text.find('['), text.rfind(']')
0235 if start < 0 or end <= start:
0236 return []
0237 try:
0238 parsed = json.loads(text[start:end + 1])
0239 except json.JSONDecodeError:
0240 return []
0241 return [e for e in parsed if isinstance(e, dict) and e.get('request_id')]
0242
0243
0244 def main():
0245 parser = argparse.ArgumentParser()
0246 parser.add_argument("--created-by", default="automatch")
0247 parser.add_argument("--all", action="store_true",
0248 help="rescan every questionnaire")
0249 parser.add_argument("--limit", type=int, default=0,
0250 help="scan at most N questionnaires (testing)")
0251 parser.add_argument("--dry-run", action="store_true",
0252 help="propose matches without writing anything")
0253 args = parser.parse_args()
0254
0255 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "swf_monitor_project.settings")
0256 import django
0257 django.setup()
0258 from django.utils import timezone
0259 from monitor_app.epicprod_logging import log_epicprod_action
0260 from monitor_app.models import PersistentState
0261 from pcs.models import ProdTask, Questionnaire
0262 from pcs.services import rebuild_questionnaire_match_cache
0263
0264 tasks = list(ProdTask.objects
0265 .select_related('dataset__physics_tag', 'campaign')
0266 .order_by('id'))
0267 by_name = {}
0268 max_task_id = 0
0269 for task in tasks:
0270 max_task_id = max(max_task_id, task.pk)
0271 if task.composed_name:
0272 by_name[task.composed_name] = task
0273 if task.name:
0274 by_name[task.name] = task
0275
0276 tag_map = build_tag_map()
0277 catalog = build_catalog(tasks)
0278
0279 scan = []
0280 existing_by_id = {}
0281 names_by_id = {}
0282 skipped_unchanged = 0
0283 for q in Questionnaire.objects.all().order_by('id'):
0284 existing = [m for m in ((q.data or {}).get('prod_matches') or [])
0285 if isinstance(m, dict)]
0286
0287
0288
0289
0290 stamp = (q.data or {}).get(SCAN_STAMP_KEY) or {}
0291 unchanged = (
0292 stamp.get('prompt_version') == PROMPT_VERSION
0293 and int(stamp.get('task_high_water') or 0) >= max_task_id
0294 and stamp.get('content_hash') == scan_content_hash(q)
0295 )
0296 if unchanged and not args.all:
0297 skipped_unchanged += 1
0298 continue
0299 scan.append(q)
0300 existing_by_id[q.pk] = existing
0301 names_by_id[q.pk] = {m.get('task_name') for m in existing
0302 if m.get('task_name')}
0303 if args.limit > 0:
0304 scan = scan[:args.limit]
0305
0306 summary = {"scanned": len(scan), "skipped_unchanged": skipped_unchanged,
0307 "llm_calls": 0, "new_matches": 0,
0308 "accepted": 0, "suggested": 0, "unknown_names": 0,
0309 "beam_rejected": 0, "beam_unverified_demoted": 0,
0310 "model": MODEL, "transport": "claude-cli-subscription",
0311 "prompt_version": PROMPT_VERSION,
0312 "dry_run": bool(args.dry_run)}
0313 run_added = []
0314
0315 for i in range(0, len(scan), CHUNK):
0316 chunk = scan[i:i + CHUNK]
0317 by_pk = {q.pk: q for q in chunk}
0318 prompt = "\n\n".join([tag_map, catalog,
0319 build_requests_block(chunk, names_by_id)])
0320 reply = call_delegate(prompt)
0321 summary["llm_calls"] += 1
0322
0323 for entry in parse_batch(reply):
0324 q = by_pk.get(int(entry['request_id'])) if str(
0325 entry['request_id']).isdigit() else None
0326 if q is None:
0327 continue
0328 existing = existing_by_id[q.pk]
0329 existing_ids = {m.get('task_id') for m in existing}
0330 added = []
0331 for match in entry.get('matches') or []:
0332 if not isinstance(match, dict):
0333 continue
0334 task = by_name.get(str(match.get('task_name') or '').strip())
0335 if task is None:
0336 summary["unknown_names"] += 1
0337 continue
0338 if task.pk in existing_ids:
0339 continue
0340 verdict = beam_verdict(match, task)
0341 if verdict == 'reject':
0342 summary["beam_rejected"] += 1
0343 print(f"BEAM REJECT: request #{q.pk} -> "
0344 f"{match.get('task_name')} request_beams="
0345 f"{match.get('request_beams')!r} species="
0346 f"{match.get('request_species')!r}", file=sys.stderr)
0347 continue
0348 confidence = str(match.get('confidence') or '').strip().lower()
0349 if confidence not in ('high', 'medium', 'low'):
0350 confidence = 'low'
0351 status = ('accepted' if confidence in ('high', 'medium')
0352 else 'suggested')
0353 if verdict == 'unverified' and status == 'accepted':
0354 summary["beam_unverified_demoted"] += 1
0355 status = 'suggested'
0356 record = {
0357 'task_id': task.pk,
0358 'task_name': task.composed_name or task.name,
0359 'legacy_name': task.name,
0360 'confidence': confidence,
0361 'status': status,
0362 'request_beams': str(match.get('request_beams') or '').strip(),
0363 'reason': str(match.get('reason') or '').strip()[:300],
0364 'matched_by': MATCHED_BY,
0365 'matched_at': timezone.now().isoformat(),
0366 }
0367 existing.append(record)
0368 existing_ids.add(task.pk)
0369 added.append(record)
0370 summary["new_matches"] += 1
0371 summary["accepted" if status == 'accepted' else "suggested"] += 1
0372
0373 if added and not args.dry_run:
0374 data = dict(q.data or {})
0375 data['prod_matches'] = existing
0376 q.data = data
0377 q.save(update_fields=['data', 'updated_at'])
0378 run_added.extend((q.pk, record) for record in added)
0379 for record in added:
0380 log_epicprod_action(
0381 'ops-agent', 'questionnaire_match_found',
0382 subject_type='campaign_task',
0383 subject_key=record['task_name'],
0384 username=args.created_by,
0385 sublevel='normal', live_default=False,
0386 questionnaire=q.pk, confidence=record['confidence'],
0387 match_status=record['status'],
0388 summary=f"request #{q.pk} ({record['confidence']}): "
0389 f"{record['reason'][:120]}")
0390 elif added:
0391 for record in added:
0392 print(f"DRY RUN: request #{q.pk} -> {record['task_name']} "
0393 f"({record['confidence']}, {record['status']}): "
0394 f"{record['reason']}", file=sys.stderr)
0395
0396
0397
0398 if not args.dry_run:
0399 for q in chunk:
0400 data = dict(q.data or {})
0401 data[SCAN_STAMP_KEY] = {
0402 'prompt_version': PROMPT_VERSION,
0403 'task_high_water': max_task_id,
0404 'content_hash': scan_content_hash(q),
0405 'scanned_at': timezone.now().isoformat(),
0406 }
0407 q.data = data
0408 q.save(update_fields=['data', 'updated_at'])
0409
0410 if run_added:
0411
0412
0413 one_line = (f"{summary['new_matches']} new matches "
0414 f"({summary['accepted']} accepted, "
0415 f"{summary['suggested']} suggested) "
0416 f"from {summary['scanned']} requests scanned")
0417 detail = [one_line] + [
0418 f"request #{qpk} -> {r['task_name']} "
0419 f"({r['confidence']}, {r['status']}): {r['reason']}"
0420 for qpk, r in run_added]
0421 log_epicprod_action(
0422 'ops-agent', 'questionnaire_new_matches',
0423 username=args.created_by,
0424 sublevel='normal', live_default=True,
0425 message="\n".join(detail), summary=one_line,
0426 requests_matched=len({qpk for qpk, _ in run_added}))
0427
0428 if not args.dry_run:
0429 PersistentState.update_state({STATE_KEY: max_task_id})
0430 if summary["new_matches"]:
0431 rebuild_questionnaire_match_cache(updated_by=args.created_by)
0432
0433 print(json.dumps(summary, sort_keys=True))
0434 return 0
0435
0436
0437 if __name__ == "__main__":
0438 raise SystemExit(main())