| """Roll keyword_triager + memory_aware policies; emit (prompt, completion) JSONL for SFT. |
| |
| Usage: |
| python scripts/build_sft_traces.py --out data/sft_traces.jsonl --scenarios E0_quiet_day E1_release_week E2_spam_wave |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import random |
| import sys |
| from pathlib import Path |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) |
|
|
| from eval.policies import build_policy |
| from server.opsguard_environment import OpsguardEnvironment |
|
|
|
|
| SYSTEM_PROMPT = """You triage GitHub issues for an open-source repository. Each step you see ONE issue or PR. You must take ONE action. |
| |
| Available actions (emit STRICT JSON, single line): |
| {"action_type":"label","target_issue_id":N,"label":"<name>","reasoning":"..."} |
| {"action_type":"close_spam","target_issue_id":N,"reasoning":"..."} |
| {"action_type":"request_info","target_issue_id":N,"comment_body":"...","reasoning":"..."} |
| {"action_type":"link_duplicate","target_issue_id":N,"duplicate_of_id":M,"reasoning":"..."} |
| {"action_type":"assign","target_issue_id":N,"assignee_login":"...","reasoning":"..."} |
| {"action_type":"comment","target_issue_id":N,"comment_body":"...","reasoning":"..."} |
| {"action_type":"merge_pr","target_issue_id":N,"reasoning":"..."} |
| {"action_type":"query_history","query":"...","reasoning":"..."} |
| {"action_type":"wait"} |
| |
| Goal: maximize action_match (matching the maintainer's real action) and spam_resistance (close_spam on synthetic spam, do NOT label/merge/assign spam). Avoid repeated actions on the same issue. Use query_history sparingly. |
| """ |
|
|
|
|
| def format_observation(obs) -> str: |
| payload = { |
| "scenario": obs.scenario_id, |
| "step": obs.step, |
| "step_budget": obs.step_budget, |
| "queue_position": obs.queue_position, |
| "queue_total": obs.queue_total, |
| "feedback": obs.feedback, |
| "memory_hits": obs.memory_hits, |
| "recent_actions": obs.recent_actions, |
| } |
| if obs.current_issue is not None: |
| ci = obs.current_issue |
| payload["current_issue"] = { |
| "issue_id": ci.issue_id, |
| "number": ci.number, |
| "title": ci.title, |
| "body": ci.body[:1200], |
| "is_pr": ci.is_pr, |
| "author_login": ci.author_login, |
| "author_pr_count": ci.author_pr_count, |
| "author_account_age_days": ci.author_account_age_days, |
| "available_labels": ci.available_labels[:25], |
| "comments_preview": ci.comments_preview, |
| } |
| return json.dumps(payload, ensure_ascii=False) |
|
|
|
|
| def _build_reasoning_text(action, obs) -> str: |
| at = action.action_type.value if hasattr(action.action_type, "value") else str(action.action_type) |
| ci = obs.current_issue |
| rationale = action.reasoning or "applying triage heuristic" |
| parts = [] |
| if ci is not None: |
| parts.append( |
| f"Issue #{ci.number} by {ci.author_login} " |
| f"(prior PRs={ci.author_pr_count}, account_age_days={ci.author_account_age_days})." |
| ) |
| if ci.is_pr and ci.pr_diff_preview: |
| parts.append(f"PR diff preview indicates: {ci.pr_diff_preview[:160]}.") |
| if ci.pr_dependency_changes: |
| parts.append(f"Dependency changes: {ci.pr_dependency_changes[:2]}.") |
| parts.append(f"Selecting action={at} because: {rationale}.") |
| return " ".join(parts) |
|
|
|
|
| def _format_cot_completion(action, obs) -> str: |
| reasoning = _build_reasoning_text(action, obs) |
| comp = action.model_dump(mode="json", exclude_none=True) |
| return f"REASONING: {reasoning}\nACTION: {json.dumps(comp, ensure_ascii=False)}" |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--out", default="data/sft_traces.jsonl") |
| ap.add_argument("--scenarios", nargs="*", |
| default=["E0_normal_intake", "E1_typosquat_wave", "E2_social_eng_buildup", "E3_compromised_maintainer"]) |
| ap.add_argument("--seeds", nargs="*", type=int, default=[0, 1, 2]) |
| ap.add_argument("--policies", nargs="*", default=["keyword_security_triager", "memory_aware"]) |
| ap.add_argument("--cot-ratio", type=float, default=0.5, |
| help="Fraction of pairs emitted in REASONING+ACTION format (R2Vul-style).") |
| ap.add_argument("--seed", type=int, default=17) |
| args = ap.parse_args() |
|
|
| out = Path(args.out) |
| out.parent.mkdir(parents=True, exist_ok=True) |
| rng = random.Random(args.seed) |
|
|
| n_pairs = 0 |
| n_cot = 0 |
| n_json = 0 |
| with open(out, "w", encoding="utf-8") as f: |
| for pname in args.policies: |
| policy = build_policy(pname) |
| for sid in args.scenarios: |
| for seed in args.seeds: |
| env = OpsguardEnvironment() |
| obs = env.reset(scenario_id=sid, seed=seed) |
| while not obs.done and obs.step < env._episode.scenario.step_budget: |
| action = policy(obs) |
| obs_text = format_observation(obs) |
| prompt = SYSTEM_PROMPT + "\n\nOBSERVATION:\n" + obs_text + "\n\nReturn ONE action as strict JSON." |
| if rng.random() < args.cot_ratio: |
| completion = _format_cot_completion(action, obs) |
| fmt = "reasoning_action" |
| n_cot += 1 |
| else: |
| comp = action.model_dump(mode="json", exclude_none=True) |
| completion = json.dumps(comp, ensure_ascii=False) |
| fmt = "json_only" |
| n_json += 1 |
| f.write(json.dumps({ |
| "prompt": prompt, |
| "completion": completion, |
| "format": fmt, |
| "policy": pname, "scenario": sid, "seed": seed, |
| }) + "\n") |
| n_pairs += 1 |
| obs = env.step(action) |
| print(f"DONE: wrote {n_pairs} (prompt, completion) pairs to {out} " |
| f"[reasoning_action={n_cot}, json_only={n_json}]") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|