Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-07-21 08:46:24

0001 #!/usr/bin/env python
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 # Per delegate call; 4 chunk calls must fit the agent handler's 1800s budget.
0061 CALL_TIMEOUT = 420
0062 # Bump with ANY change to SYSTEM_PROMPT or the request/catalog presentation:
0063 # a changed matcher is a changed question, and every questionnaire earns one
0064 # deliberate re-pass against it.
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'          # tag carries no beams — cannot verify
0129     if not req or 'x' not in req:
0130         return 'unverified'          # request states no beams — cannot verify
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         # Event-driven rescan: skip unless the request content, the task
0287         # catalog, or the matcher prompt changed since this questionnaire
0288         # was last scanned. Re-asking an unchanged question harvests LLM
0289         # variance, not information.
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         # Stamp every scanned questionnaire — matched or not — with what it
0397         # was scanned against, so it stays quiet until an input changes.
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         # The one live line for the whole run; its record page lists every
0412         # new match (multi-line message renders in the record's pre block).
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())