""" Baseline Inference Script for API Contract Validator Environment. Runs an LLM agent against all tasks and produces scores. Uses the OpenAI client for all LLM calls. Environment variables: API_BASE_URL — API endpoint (default: HF router) MODEL_NAME — Model identifier (default: Qwen2.5-72B-Instruct) HF_TOKEN — Hugging Face / API key (no default) LOCAL_IMAGE_NAME — Docker image name when using from_docker_image() STDOUT format follows the hackathon specification exactly: [START] task= env= model= [STEP] step= action= reward=<0.00> done= error= [END] success= steps= rewards= """ import asyncio import json import os import textwrap from pathlib import Path from typing import Any, Dict, List, Optional # Load .env file (if present) before reading any os.getenv values. # .env is gitignored — keeps HF_TOKEN out of source control. try: from dotenv import load_dotenv _ENV_FILE = Path(__file__).resolve().parent / ".env" if _ENV_FILE.exists(): load_dotenv(_ENV_FILE) except ImportError: pass # python-dotenv not installed — fall back to OS env vars only from openai import OpenAI from client import ValidatorEnv from models import ValidatorAction # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME") HF_TOKEN = os.getenv("HF_TOKEN") API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1") MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct") SCORES_OUT_PATH = os.getenv("SCORES_OUT_PATH") # e.g. baseline_scores.json BENCHMARK = "api_contract_validator" PHASE1_TASKS = [ "find_type_mismatches", "validate_nested_objects", "detect_breaking_changes", "validate_response_schema", "validate_cross_field_constraints", "validate_auth_request", ] PHASE2_TASKS = ["trace_downstream_blast_radius"] PHASE3_TASKS = ["propose_backward_compat_fix"] CASCADE_TASKS = ["multi_service_cascade_fix"] TASKS = PHASE1_TASKS + PHASE2_TASKS + PHASE3_TASKS + CASCADE_TASKS MAX_STEPS_PER_TASK = { "find_type_mismatches": 10, "validate_nested_objects": 15, "detect_breaking_changes": 20, "validate_response_schema": 25, "validate_cross_field_constraints": 18, "validate_auth_request": 14, "trace_downstream_blast_radius": 20, "propose_backward_compat_fix": 25, "multi_service_cascade_fix": 40, } MAX_CONSECUTIVE_FAILURES = 3 # stop retrying same field after this many -0.3 rewards TEMPERATURE = float(os.getenv("TEMPERATURE", "0.2")) MAX_TOKENS = int(os.getenv("MAX_TOKENS", "1024")) SUCCESS_SCORE_THRESHOLD = float(os.getenv("SUCCESS_SCORE_THRESHOLD", "0.3")) # --------------------------------------------------------------------------- # Structured stdout logging # --------------------------------------------------------------------------- def log_start(task: str, env: str, model: str) -> None: print(f"[START] task={task} env={env} model={model}", flush=True) def log_step( step: int, action: str, reward: float, done: bool, error: Optional[str] ) -> None: error_val = error if error else "null" done_val = str(done).lower() print( f"[STEP] step={step} action={action} reward={reward:.2f} " f"done={done_val} error={error_val}", flush=True, ) def log_end( success: bool, steps: int, score: float, rewards: List[float] ) -> None: rewards_str = ",".join(f"{r:.2f}" for r in rewards) print( f"[END] success={str(success).lower()} steps={steps} score={score:.2f} rewards={rewards_str}", flush=True, ) # --------------------------------------------------------------------------- # Prompt construction # --------------------------------------------------------------------------- SYSTEM_PROMPT_PHASE1 = textwrap.dedent("""\ You are an expert API contract validator. You will be given an OpenAPI \ specification and an API payload. Your job is to find ALL violations in the \ payload that do not conform to the spec. Each turn you must respond with EXACTLY one JSON object (no markdown, no \ explanation outside the JSON): { "action_type": "report_violation", "field_path": "", "violation_type": "", "description": "", "suggested_fix": "" } STRICT RULES: 1. Report ONE violation per turn. Be systematic — check every field. 2. field_path must be ONLY the path. NEVER put ':violation_type' inside field_path. 3. Paths: dot-notation 'customer.email', arrays 'items[1].quantity', breaking changes 'POST /path.field'. 4. violation_type choices: - type_mismatch: wrong data type (string vs integer, etc.) - missing_required: required field absent from payload - invalid_enum: value not in the allowed enum list - format_error: value violates format/pattern/min/max constraint - breaking_change: API v1→v2 change that breaks existing clients - cross_field_constraint: arithmetic/date/conditional rule across multiple fields 5. Do NOT repeat a violation already in 'Violations found so far'. 6. If last feedback was 'False positive' or negative reward, that field is WRONG — move to a different field. 7. When you have reported all violations, set field_path='DONE'. 8. You may set field_path='HINT' for a location clue at -0.5 reward cost. """) SYSTEM_PROMPT_PHASE2 = textwrap.dedent("""\ You are an enterprise API impact analyst. A producer microservice has made \ a breaking change to its API. You will see: * the breaking change (`violation`) * a list of consumer services with the fields each consumer depends on Your job: identify EVERY downstream service whose contract is broken by \ the change. Submit a SINGLE action listing all affected consumers. Respond with EXACTLY one JSON object: { "action_type": "trace_impact", "affected_services": ["ServiceA", "ServiceB", ...], "reasoning": "" } STRICT RULES: 1. Include a service ONLY if its declared `fields_consumed` overlaps with the \ field affected by the breaking change. 2. For enum-narrowing changes, a consumer is affected only if it emits one \ of the removed values. 3. Do NOT include services that consume unrelated fields — false flags are \ penalised heavily. 4. Service names are case-insensitive but must match those in the consumer list. """) SYSTEM_PROMPT_PHASE3 = textwrap.dedent("""\ You are a senior platform engineer designing a backward-compatible \ migration. You see a breaking change and a list of consumer specs. Your job: propose a fix (a `spec_patch`) that lets every consumer keep \ working without redeploying. Respond with EXACTLY one JSON object: { "action_type": "propose_fix", "fix_strategy": "", "spec_patch": { ... }, "rationale": "" } Strategy contracts (the patch must contain these keys): * field_alias → spec_patch.aliases = { "": "", ... } * version_bump → spec_patch.versions = ["v1.0", "v2.0"] (must keep both) * deprecation_window → spec_patch.deprecated_fields = [""] OR spec_patch.deprecated_enum_values = [""] * dual_write → spec_patch.emit_fields = ["", ""] * consumer_patch → spec_patch.consumers_to_migrate = [] STRICT RULES: 1. Pick the strategy that fits the change. For enum narrowing, prefer \ consumer_patch or version_bump (aliasing cannot restore enum values). 2. The patch must apply for EVERY affected consumer, not just one. 3. If the proposal fails, refine the patch and try again — do not repeat \ the same failing patch. """) SYSTEM_PROMPT_CASCADE = SYSTEM_PROMPT_PHASE2 + "\n\n" + SYSTEM_PROMPT_PHASE3 + ( "\n\nThe episode begins in Phase 2 (trace_impact). After every consumer " "is correctly traced, the environment switches to Phase 3 (propose_fix)." ) def _system_prompt_for_phase(phase: str, task_name: str) -> str: if task_name in CASCADE_TASKS: return SYSTEM_PROMPT_CASCADE if phase == "tracing" or task_name in PHASE2_TASKS: return SYSTEM_PROMPT_PHASE2 if phase == "fix_proposal" or task_name in PHASE3_TASKS: return SYSTEM_PROMPT_PHASE3 return SYSTEM_PROMPT_PHASE1 def build_user_prompt( observation: Dict[str, Any], step: int, history: List[str], ) -> str: """Build a phase-aware user prompt from the current observation.""" phase = observation.get("phase", "detection") task_name = observation.get("task_name", "") history_block = "\n".join(history[-5:]) if history else "None" if phase == "tracing" or task_name in PHASE2_TASKS: graph = observation.get("service_graph", {}) return textwrap.dedent(f"""\ Step: {step} Task: {task_name} Phase: 2 — Impact Tracing Instructions: {observation.get('task_description', '')} Breaking change: {json.dumps(graph.get('violation', observation.get('detected_violation', {})), indent=2)} Consumers (each declares fields_consumed): {json.dumps(graph.get('consumers', []), indent=2)} Last feedback: {observation.get('feedback', '')} Previous steps: {history_block} Respond with a single JSON object containing action_type='trace_impact'. """) if phase == "fix_proposal" or task_name in PHASE3_TASKS: violation = observation.get("detected_violation", {}) consumer_specs = observation.get("consumer_specs", {}) last_results = observation.get("fix_validation_results", {}) return textwrap.dedent(f"""\ Step: {step} Task: {task_name} Phase: 3 — Fix & Verify Instructions: {observation.get('task_description', '')} Breaking change to fix: {json.dumps(violation, indent=2)} Consumer specs (every consumer must keep working): {json.dumps(consumer_specs, indent=2)} Last fix attempt result: {json.dumps(last_results, indent=2) if last_results else 'None'} Last feedback: {observation.get('feedback', '')} Previous steps: {history_block} Respond with a single JSON object containing action_type='propose_fix'. """) # Default: Phase 1 — Detection violations_found = observation.get("violations_found", []) if violations_found: found_lines = [ f" - {v['field_path']}: {v['violation_type']}" for v in violations_found ] found_summary = "\n".join(found_lines) else: found_summary = "None yet" return textwrap.dedent(f"""\ Step: {step} Task: {task_name} Phase: 1 — Detection Instructions: {observation.get('task_description', '')} API Specification: {json.dumps(observation.get('api_spec', {}), indent=2)} Payload to validate: {json.dumps(observation.get('payload', {}), indent=2)} Violations found so far: {found_summary} Violations remaining: {observation.get('violations_remaining', '?')} Last feedback: {observation.get('feedback', '')} Previous steps: {history_block} Respond with a single JSON object for the next violation (or DONE). """) # --------------------------------------------------------------------------- # LLM interaction # --------------------------------------------------------------------------- _VALID_VIOLATION_TYPES = { "type_mismatch", "missing_required", "invalid_enum", "format_error", "extra_field", "breaking_change", "cross_field_constraint", } def parse_llm_response(text: str) -> Dict[str, Any]: """Parse the LLM response into an action dict for any phase. Handles markdown fences and infers the action_type when the model omits it but includes phase-specific fields. """ cleaned = text.strip() if cleaned.startswith("```"): lines = cleaned.split("\n") lines = [l for l in lines if not l.strip().startswith("```")] cleaned = "\n".join(lines).strip() try: data = json.loads(cleaned) except json.JSONDecodeError: upper = cleaned.upper() if "DONE" in upper: return {"action_type": "report_violation", "field_path": "DONE", "violation_type": "", "description": "", "suggested_fix": ""} return {"action_type": "report_violation", "field_path": "DONE", "violation_type": "unknown", "description": f"Failed to parse: {cleaned[:100]}", "suggested_fix": ""} action_type = str(data.get("action_type", "")).strip() # Infer action_type when omitted if not action_type: if "affected_services" in data: action_type = "trace_impact" elif "fix_strategy" in data or "spec_patch" in data: action_type = "propose_fix" else: action_type = "report_violation" if action_type == "trace_impact": services = data.get("affected_services") or [] if not isinstance(services, list): services = [str(services)] return { "action_type": "trace_impact", "affected_services": [str(s) for s in services], "reasoning": str(data.get("reasoning", "")), } if action_type in ("propose_fix", "validate_fix"): patch = data.get("spec_patch") or {} if not isinstance(patch, dict): patch = {} return { "action_type": action_type, "fix_strategy": str(data.get("fix_strategy", "")), "spec_patch": patch, "rationale": str(data.get("rationale", "")), } # Default: Phase 1 — report_violation field_path = str(data.get("field_path", "DONE")) violation_type = str(data.get("violation_type", "unknown")) if ":" in field_path: parts = field_path.split(":") if any(vt in parts[-1].lower() for vt in _VALID_VIOLATION_TYPES): field_path = parts[0].strip() return { "action_type": "report_violation", "field_path": field_path, "violation_type": violation_type, "description": str(data.get("description", "")), "suggested_fix": str(data.get("suggested_fix", "")), } def _build_action(action_data: Dict[str, Any]) -> ValidatorAction: """Materialise a ValidatorAction from a parsed-LLM dict.""" at = action_data.get("action_type", "report_violation") if at == "trace_impact": return ValidatorAction( action_type="trace_impact", affected_services=action_data.get("affected_services", []), reasoning=action_data.get("reasoning", ""), ) if at in ("propose_fix", "validate_fix"): return ValidatorAction( action_type=at, fix_strategy=action_data.get("fix_strategy", ""), spec_patch=action_data.get("spec_patch", {}), rationale=action_data.get("rationale", ""), ) return ValidatorAction( action_type="report_violation", field_path=action_data.get("field_path", "DONE"), violation_type=action_data.get("violation_type", ""), description=action_data.get("description", ""), suggested_fix=action_data.get("suggested_fix", ""), ) def _action_summary(action_data: Dict[str, Any]) -> str: at = action_data.get("action_type", "report_violation") if at == "trace_impact": return f"trace_impact:{','.join(action_data.get('affected_services', []))}" if at in ("propose_fix", "validate_fix"): return f"{at}:{action_data.get('fix_strategy','')}" return ( f"{action_data.get('field_path','')}:" f"{action_data.get('violation_type','')}" ) def query_llm( client: OpenAI, observation: Dict[str, Any], step: int, history: List[str], ) -> Dict[str, Any]: """Send the current observation to the LLM and return a parsed action.""" phase = observation.get("phase", "detection") task_name = observation.get("task_name", "") user_prompt = build_user_prompt(observation, step, history) system_prompt = _system_prompt_for_phase(phase, task_name) try: completion = client.chat.completions.create( model=MODEL_NAME, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], temperature=TEMPERATURE, max_tokens=MAX_TOKENS, stream=False, ) raw_text = (completion.choices[0].message.content or "").strip() return parse_llm_response(raw_text) except Exception as exc: print(f"[DEBUG] LLM request failed: {exc}", flush=True) # Safe fallback action for any phase if phase == "tracing" or task_name in PHASE2_TASKS: return {"action_type": "trace_impact", "affected_services": [], "reasoning": f"LLM error: {exc}"} if phase == "fix_proposal" or task_name in PHASE3_TASKS: return {"action_type": "propose_fix", "fix_strategy": "", "spec_patch": {}, "rationale": f"LLM error: {exc}"} return {"action_type": "report_violation", "field_path": "DONE", "violation_type": "", "description": f"LLM error: {exc}", "suggested_fix": ""} # --------------------------------------------------------------------------- # Main loop # --------------------------------------------------------------------------- async def run_single_task( client: OpenAI, env: ValidatorEnv, task_name: str, ) -> Dict[str, Any]: """Run a single task episode and emit structured logs. Returns a dict with the per-task summary so the caller can aggregate a baseline_scores.json or trained_scores.json file. """ max_steps = MAX_STEPS_PER_TASK.get(task_name, 15) history: List[str] = [] rewards: List[float] = [] steps_taken = 0 score = 0.01 success = False consecutive_failures = 0 last_failed_path = "" is_phase1_task = task_name in PHASE1_TASKS log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME) try: result = await env.reset(task_name=task_name) obs_dict = ( result.observation.model_dump() if hasattr(result.observation, "model_dump") else result.observation.__dict__ ) for step in range(1, max_steps + 1): if result.done: break # Phase 1 only: trigger HINT after repeated failures on same field if is_phase1_task and consecutive_failures >= MAX_CONSECUTIVE_FAILURES: action_data = { "action_type": "report_violation", "field_path": "HINT", "violation_type": "", "description": "", "suggested_fix": "", } consecutive_failures = 0 last_failed_path = "" else: action_data = query_llm(client, obs_dict, step, history) action = _build_action(action_data) result = await env.step(action) obs_dict = ( result.observation.model_dump() if hasattr(result.observation, "model_dump") else result.observation.__dict__ ) reward = result.reward or 0.0 done = result.done rewards.append(reward) steps_taken = step action_str = _action_summary(action_data) log_step(step=step, action=action_str, reward=reward, done=done, error=None) # Phase 1 only: track stuck-on-same-field if ( is_phase1_task and reward < 0 and action_data.get("field_path") not in ("DONE", "HINT") ): fp = action_data.get("field_path", "") if fp == last_failed_path: consecutive_failures += 1 else: consecutive_failures = 1 last_failed_path = fp else: consecutive_failures = 0 last_failed_path = "" history.append( f"Step {step}: {action_str} → reward {reward:+.2f}" ) if done: break # Final score: trust env-side score (most accurate); fall back to # Phase 1 heuristic for back-compat with older Phase 1 evaluators. if is_phase1_task: if rewards: correct_count = sum(1 for r in rewards if r >= 1.0) total_violations = obs_dict.get("violations_remaining", 0) + len( obs_dict.get("violations_found", []) ) score = ( correct_count / total_violations if total_violations > 0 else 0.0 ) else: # Phase 2/3 score is already computed by the environment. try: state = await env.state() score = getattr(state, "score", None) or 0.01 except Exception: score = 0.01 score = min(max(score, 0.01), 0.99) success = score >= SUCCESS_SCORE_THRESHOLD finally: log_end(success=success, steps=steps_taken, score=score, rewards=rewards) return { "task": task_name, "score": round(score, 4), "steps": steps_taken, "success": success, "rewards": [round(r, 4) for r in rewards], } async def main() -> None: """Run the inference agent against all tasks and optionally save scores.""" from datetime import datetime, timezone openai_client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN) if LOCAL_IMAGE_NAME: env = await ValidatorEnv.from_docker_image(LOCAL_IMAGE_NAME) else: env_url = os.getenv("ENV_BASE_URL", "http://localhost:7860") env = ValidatorEnv(base_url=env_url) results: List[Dict[str, Any]] = [] try: for task_name in TASKS: res = await run_single_task(openai_client, env, task_name) results.append(res) finally: try: await env.close() except Exception as exc: print(f"[DEBUG] env.close() error: {exc}", flush=True) if SCORES_OUT_PATH: scores_obj = { "model": MODEL_NAME, "benchmark": BENCHMARK, "date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "scores": {r["task"]: r["score"] for r in results}, "details": results, } with open(SCORES_OUT_PATH, "w", encoding="utf-8") as fh: json.dump(scores_obj, fh, indent=2) print(f"[INFO] wrote {SCORES_OUT_PATH}", flush=True) if __name__ == "__main__": asyncio.run(main())