Spaces:
Running
Running
| """ | |
| Live diagnostic for the LLM Fraudster against Ollama. | |
| Goal | |
| ---- | |
| Prove that the LLM Fraudster (1) receives the right per-turn observation | |
| (including the new ``my_proposal_signals`` ledger), (2) produces a | |
| *schema-valid* JSON action, and (3) reacts to the Investigator in | |
| subsequent turns — *without* writing a permanent test that needs Ollama | |
| to be up in CI. | |
| What it does | |
| ~~~~~~~~~~~~ | |
| For each task tier (``task_1``, ``task_2``, ``task_3``): | |
| 1. Spin up an in-process ``RefereeEnvironment`` (no WebSocket). | |
| 2. Build the real Fraudster observation. | |
| 3. Call Ollama once via ``LLMFraudster._call_llm_once`` (no retries). | |
| 4. Print the SYSTEM prompt size, the USER prompt, the RAW response, and | |
| the PARSED action (or schema error). | |
| 5. Step the env with that action so the next turn's observation reflects | |
| the real consequence (queue grows, proposals_remaining decrements). | |
| 6. After the Fraudster's first turn, run one scripted Investigator turn so | |
| the Fraudster's *next* turn observation contains a verdict — proves | |
| the reactive feedback loop is wired end-to-end. | |
| 7. Repeat steps 2-5 for the second Fraudster turn. | |
| Run it | |
| ~~~~~~ | |
| :: | |
| # ollama serve must be running with llama3.1:8b pulled | |
| python -m counterfeint.diagnostics.verify_fraudster | |
| You can override the model + endpoint via env vars: | |
| MODEL_NAME=llama3.1:8b | |
| API_BASE_URL=http://localhost:11434/v1 | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import sys | |
| import textwrap | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional | |
| if __package__ in {None, ""}: | |
| # Allow `python counterfeint/diagnostics/verify_fraudster.py` as well | |
| # as `python -m counterfeint.diagnostics.verify_fraudster`. | |
| _REPO_ROOT = Path(__file__).resolve().parent.parent.parent | |
| if str(_REPO_ROOT) not in sys.path: | |
| sys.path.insert(0, str(_REPO_ROOT)) | |
| from counterfeint.agents import LLMFraudster | |
| from counterfeint.agents.prompts import FRAUDSTER_SYSTEM_PROMPT | |
| from counterfeint.models import AdReviewAction, FraudsterAction | |
| from counterfeint.scripted import ScriptedInvestigator | |
| from counterfeint.server.referee import RefereeEnvironment | |
| _OLLAMA_BASE = os.getenv("API_BASE_URL", "http://localhost:11434/v1") | |
| _OLLAMA_MODEL = os.getenv("MODEL_NAME", "llama3.1:8b") | |
| _TURNS_PER_TASK = 2 | |
| def _hr(title: str = "") -> None: | |
| bar = "=" * 78 | |
| if title: | |
| print(f"\n{bar}\n{title}\n{bar}") | |
| else: | |
| print(bar) | |
| def _wrap(text: str, indent: str = " ") -> str: | |
| return textwrap.indent(text, indent) | |
| def _build_fraudster() -> LLMFraudster: | |
| """Construct the Fraudster pointed at Ollama with retries=0 for clarity.""" | |
| return LLMFraudster( | |
| model_name=_OLLAMA_MODEL, | |
| api_base_url=_OLLAMA_BASE, | |
| api_key="ollama", | |
| temperature=0.2, | |
| max_tokens=384, | |
| timeout_s=60.0, | |
| retries=0, | |
| ) | |
| def _summarise_obs(obs_dict: Dict[str, Any]) -> str: | |
| """One-line pre-flight summary of what the Fraudster is about to see.""" | |
| queue = obs_dict.get("current_queue", []) or [] | |
| verdicts = obs_dict.get("prior_verdicts", []) or [] | |
| targets = obs_dict.get("investigation_targets_used", {}) or {} | |
| return ( | |
| f"round={obs_dict.get('round_number')} " | |
| f"proposals_remaining={obs_dict.get('proposals_remaining')} " | |
| f"actions_left={obs_dict.get('actions_left_this_turn')} " | |
| f"queue_size={len(queue)} " | |
| f"prior_verdicts={len(verdicts)} " | |
| f"targets_pulled_on={len(targets)} ads" | |
| ) | |
| def _try_one_turn( | |
| fraudster: LLMFraudster, | |
| env: RefereeEnvironment, | |
| *, | |
| turn_label: str, | |
| ) -> Optional[Any]: | |
| """ | |
| Run ONE Fraudster turn end-to-end and dump the full prompt+response | |
| chain. Returns the parsed FraudsterAction (or None on schema failure). | |
| """ | |
| obs = env.build_fraudster_observation() | |
| obs_dict = obs.model_dump() | |
| print(f"\n--- {turn_label} | {_summarise_obs(obs_dict)} ---") | |
| user_prompt = fraudster._build_user_prompt(obs_dict) | |
| print("\n[USER PROMPT sent to LLM]") | |
| print(_wrap(user_prompt)) | |
| messages = [ | |
| {"role": "system", "content": fraudster.system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| try: | |
| raw = fraudster._call_llm_once(messages) | |
| except Exception as exc: | |
| print(f"\n[ERROR] LLM call raised {type(exc).__name__}: {exc}") | |
| return None | |
| print("\n[RAW LLM RESPONSE]") | |
| print(_wrap(raw if raw.strip() else "(empty string)")) | |
| try: | |
| action = fraudster._parse_and_validate(raw) | |
| except Exception as exc: | |
| print(f"\n[SCHEMA FAIL] {type(exc).__name__}: {exc}") | |
| return None | |
| print("\n[PARSED ACTION (schema-valid)]") | |
| print(_wrap(json.dumps(action.model_dump(exclude_none=True), indent=2))) | |
| try: | |
| env.step_as_fraudster(action) | |
| print( | |
| f"\n[ENV ACK] phase={env.phase} " | |
| f"round={env.state.round_number} " | |
| f"proposals_used={env.state.proposals_used}" | |
| ) | |
| except Exception as exc: | |
| print(f"\n[ENV REJECTED ACTION] {type(exc).__name__}: {exc}") | |
| return action | |
| def _drain_investigator( | |
| env: RefereeEnvironment, *, max_actions: int = 3 | |
| ) -> List[str]: | |
| """ | |
| Hand control to a scripted Investigator for up to ``max_actions`` | |
| actions so the Fraudster's NEXT turn observation will contain a real | |
| verdict / investigation target. Returns a compact log for printing. | |
| """ | |
| inv = ScriptedInvestigator() | |
| log: List[str] = [] | |
| actions_taken = 0 | |
| while env.phase == "investigator_turn" and actions_taken < max_actions: | |
| obs = env.build_investigator_observation().model_dump() | |
| action = inv.act(obs) | |
| env.step_as_investigator(action) | |
| actions_taken += 1 | |
| if action.action_type == "verdict": | |
| log.append( | |
| f"verdict({action.ad_id}={action.verdict}@{action.confidence})" | |
| ) | |
| elif action.action_type == "investigate": | |
| log.append(f"investigate({action.ad_id}/{action.investigation_target})") | |
| else: | |
| log.append(action.action_type) | |
| return log | |
| def verify_task(task_id: str, seed: int = 42) -> Dict[str, Any]: | |
| _hr(f"TASK = {task_id} (seed={seed}, model={_OLLAMA_MODEL})") | |
| env = RefereeEnvironment() | |
| env.reset_match(task_id=task_id, seed=seed) | |
| print( | |
| f"max_rounds={env.state.max_rounds} " | |
| f"max_proposals={env.state.max_proposals} " | |
| f"allowed_categories={env.build_fraudster_observation().allowed_categories}" | |
| ) | |
| fraudster = _build_fraudster() | |
| schema_fails = 0 | |
| actions_seen: List[str] = [] | |
| for turn_idx in range(_TURNS_PER_TASK): | |
| if env.phase != "fraudster_turn": | |
| print(f"[skip] turn {turn_idx + 1}: phase is {env.phase}, stopping.") | |
| break | |
| action = _try_one_turn( | |
| fraudster, env, turn_label=f"FRAUDSTER TURN {turn_idx + 1}" | |
| ) | |
| if action is None: | |
| schema_fails += 1 | |
| else: | |
| actions_seen.append(action.action_type) | |
| if env.phase == "investigator_turn": | |
| inv_log = _drain_investigator(env, max_actions=3) | |
| print(f"\n[Investigator played] {' | '.join(inv_log) or '(no-op)'}") | |
| if env.phase in ("audit_phase", "done"): | |
| print(f"[Episode advanced to {env.phase}; stopping diagnostic.]") | |
| break | |
| return { | |
| "task_id": task_id, | |
| "schema_fails": schema_fails, | |
| "calls": fraudster.call_count, | |
| "fallback_count_via_act": fraudster.fallback_count, | |
| "actions_seen": actions_seen, | |
| "last_error": fraudster.last_error, | |
| } | |
| def verify_reactive_path(task_id: str = "task_2", seed: int = 7) -> Dict[str, Any]: | |
| """ | |
| Force the rejection-feedback path: Fraudster proposes → Investigator | |
| rejects that ad → Fraudster turn 2 sees ``prior_verdicts`` containing a | |
| rejection of its own proposal. We then check whether the LLM picks a | |
| different category / softer copy on the follow-up. | |
| This is the single most important behavioural check before we trust | |
| the Fraudster as a frozen training opponent — without a reactive | |
| Fraudster the trained Investigator's reward saturates trivially. | |
| """ | |
| _hr(f"REACTIVE PROBE — task={task_id} seed={seed}") | |
| env = RefereeEnvironment() | |
| env.reset_match( | |
| task_id=task_id, | |
| seed=seed, | |
| max_investigator_actions_per_turn=2, | |
| max_fraudster_actions_per_turn=2, | |
| ) | |
| fraudster = _build_fraudster() | |
| print("\n[Stage 1] One LLM-driven proposal, then forced end_turn.") | |
| obs1 = env.build_fraudster_observation().model_dump() | |
| user_prompt_1 = fraudster._build_user_prompt(obs1) | |
| messages_1 = [ | |
| {"role": "system", "content": fraudster.system_prompt}, | |
| {"role": "user", "content": user_prompt_1}, | |
| ] | |
| raw_1 = fraudster._call_llm_once(messages_1) | |
| try: | |
| action_1 = fraudster._parse_and_validate(raw_1) | |
| except Exception as exc: | |
| print(f"[SCHEMA FAIL on Stage 1] {exc}") | |
| return {"task_id": task_id, "stage_1_schema_fail": True} | |
| if action_1.action_type != "propose_ad": | |
| print( | |
| f"[skip-reactive] Fraudster opened with {action_1.action_type!r}, " | |
| "not propose_ad; reactive probe needs at least one proposal." | |
| ) | |
| return {"task_id": task_id, "skipped": True, "opening": action_1.action_type} | |
| env.step_as_fraudster(action_1) | |
| proposed_ad = env._proposal_slot_to_ad_id.get(0) | |
| print( | |
| f" - Fraudster proposed: category={action_1.category!r} " | |
| f"-> ad_id={proposed_ad}" | |
| ) | |
| if env.phase == "fraudster_turn": | |
| env.step_as_fraudster(FraudsterAction(action_type="end_turn")) | |
| print(f" - phase after forced end_turn: {env.phase}") | |
| print("\n[Stage 2] Force-reject the Fraudster's ad to populate prior_verdicts.") | |
| if proposed_ad and env.phase == "investigator_turn": | |
| try: | |
| env.step_as_investigator( | |
| AdReviewAction( | |
| action_type="verdict", | |
| ad_id=proposed_ad, | |
| verdict="reject", | |
| confidence=0.85, | |
| rationale=( | |
| "Forced rejection for reactive probe: " | |
| "soft urgency markers and unverifiable claims." | |
| ), | |
| ) | |
| ) | |
| print(f" - Investigator rejected {proposed_ad}.") | |
| except Exception as exc: | |
| print(f" - Investigator probe step failed: {exc}") | |
| safety = 0 | |
| while env.phase == "investigator_turn" and safety < 6: | |
| drained = _drain_investigator(env, max_actions=2) | |
| if drained: | |
| print(f" - Investigator drain: {' | '.join(drained)}") | |
| safety += 1 | |
| if env.phase != "fraudster_turn": | |
| print(f" - Could not return to fraudster_turn (phase={env.phase}); abort.") | |
| return {"task_id": task_id, "phase_after_drain": env.phase} | |
| print("\n[Stage 3] Fraudster's reactive turn (sees its own ad rejected).") | |
| obs2 = env.build_fraudster_observation().model_dump() | |
| print( | |
| f" - prior_verdicts now contains {len(obs2.get('prior_verdicts', []))} " | |
| f"item(s); first = {obs2.get('prior_verdicts', [None])[:1]}" | |
| ) | |
| user_prompt_2 = fraudster._build_user_prompt(obs2) | |
| print("\n[USER PROMPT — turn 2]") | |
| print(_wrap(user_prompt_2)) | |
| messages_2 = [ | |
| {"role": "system", "content": fraudster.system_prompt}, | |
| {"role": "user", "content": user_prompt_2}, | |
| ] | |
| raw_2 = fraudster._call_llm_once(messages_2) | |
| print("\n[RAW LLM RESPONSE — turn 2]") | |
| print(_wrap(raw_2 if raw_2.strip() else "(empty)")) | |
| try: | |
| action_2 = fraudster._parse_and_validate(raw_2) | |
| except Exception as exc: | |
| print(f"\n[SCHEMA FAIL on Stage 3] {exc}") | |
| return {"task_id": task_id, "stage_3_schema_fail": True} | |
| print("\n[PARSED ACTION — turn 2]") | |
| print(_wrap(json.dumps(action_2.model_dump(exclude_none=True), indent=2))) | |
| adapted = ( | |
| action_2.action_type == "modify_pending_ad" | |
| or ( | |
| action_2.category is not None | |
| and action_2.category != action_1.category | |
| ) | |
| ) | |
| print( | |
| f"\n >>> Adapted to rejection? {adapted} " | |
| f"(turn1 cat={action_1.category!r}, turn2 cat={action_2.category!r}, " | |
| f"action={action_2.action_type})" | |
| ) | |
| return { | |
| "task_id": task_id, | |
| "turn1_category": action_1.category, | |
| "turn2_category": action_2.category, | |
| "turn2_action_type": action_2.action_type, | |
| "adapted": adapted, | |
| "schema_fails": 0, | |
| } | |
| def main() -> int: | |
| _hr("CounterFeint Fraudster live diagnostic (Ollama)") | |
| print(f"endpoint = {_OLLAMA_BASE}") | |
| print(f"model = {_OLLAMA_MODEL}") | |
| print(f"system_prompt_chars = {len(FRAUDSTER_SYSTEM_PROMPT)}") | |
| print(f"turns_per_task = {_TURNS_PER_TASK}") | |
| summaries: List[Dict[str, Any]] = [] | |
| for task_id in ("task_1", "task_2", "task_3"): | |
| try: | |
| summaries.append(verify_task(task_id)) | |
| except Exception as exc: | |
| print(f"\n[FATAL] {task_id}: {type(exc).__name__}: {exc}") | |
| summaries.append( | |
| {"task_id": task_id, "schema_fails": -1, "error": str(exc)} | |
| ) | |
| reactive_summary: Dict[str, Any] | |
| try: | |
| reactive_summary = verify_reactive_path() | |
| except Exception as exc: | |
| print(f"\n[FATAL] reactive probe: {type(exc).__name__}: {exc}") | |
| reactive_summary = {"reactive": False, "error": str(exc)} | |
| _hr("SUMMARY") | |
| for s in summaries: | |
| print(json.dumps(s, indent=2)) | |
| print("\n[reactive probe]") | |
| print(json.dumps(reactive_summary, indent=2)) | |
| total_fails = sum(max(0, s.get("schema_fails", 0)) for s in summaries) | |
| if total_fails: | |
| print(f"\nResult: {total_fails} schema failure(s) across all tasks.") | |
| return 1 | |
| print("\nResult: all turns produced schema-valid Fraudster actions.") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |