Spaces:
Sleeping
Sleeping
| """Component 5 β Environment Core (OpenEnv Compatible). | |
| Implements the CrimeInvestigationEnv with step/reset/render interface. | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import re | |
| from typing import Any, Callable, Optional | |
| from crime_env.case_generator import generate_case | |
| from crime_env.consistency_tracker import ConsistencyTracker | |
| from crime_env.reward_calculator import RewardCalculator | |
| from crime_env.agent_prompts import build_system_prompt | |
| from crime_env.constants import ( | |
| AGENT_NAME_TO_KEY, | |
| DETECTIVE_KEY, | |
| SUSPECT_A, | |
| SUSPECT_A_KEY, | |
| SUSPECT_B, | |
| SUSPECT_B_KEY, | |
| VALID_ACCUSE_TARGETS, | |
| VALID_TARGETS, | |
| WITNESS_1, | |
| WITNESS_1_KEY, | |
| ) | |
| # ββ Action parsing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _ACTION_PATTERNS = { | |
| "ask_question": re.compile( | |
| r"ACTION:\s*ask_question\s*\|\s*TARGET:\s*(\S+)\s*\|\s*CONTENT:\s*(.+)", | |
| re.IGNORECASE, | |
| ), | |
| "request_evidence": re.compile( | |
| r"ACTION:\s*request_evidence\s*\|\s*ITEM:\s*(\S+)", | |
| re.IGNORECASE, | |
| ), | |
| "accuse": re.compile( | |
| r"ACTION:\s*accuse\s*\|\s*TARGET:\s*(\S+)", | |
| re.IGNORECASE, | |
| ), | |
| } | |
| EVIDENCE_ITEMS = {"keycard_log", "cctv_footage", "forensic_report"} | |
| # Evidence confirmation should depend on suspect-specific questioning | |
| # that is relevant to the requested evidence type. | |
| EVIDENCE_LEAD_TOPICS = { | |
| "keycard_log": {"time", "location", "was_at_scene", "alibi"}, | |
| "cctv_footage": {"location", "clothing", "time", "was_at_scene"}, | |
| "forensic_report": {"clothing", "owns_item", "location", "was_at_scene"}, | |
| } | |
| # Map evidence item names to indices in physical_evidence list | |
| EVIDENCE_INDEX_MAP = { | |
| "keycard_log": 0, | |
| "cctv_footage": 1, | |
| "forensic_report": 2, | |
| } | |
| TOPIC_KEYWORDS = { | |
| "location": [ | |
| "east wing", "server room", "vault", "parking lot", | |
| "basement archive", "rooftop terrace", "loading dock", | |
| "executive suite", "evidence locker", "maintenance corridor", | |
| "movie theater", "restaurant", | |
| ], | |
| "clothing": [ | |
| "hoodie", "trench coat", "leather jacket", | |
| "overalls", "uniform", "jeans", | |
| "windbreaker", "cargo pants", "tracksuit", | |
| "bomber jacket", "corduroy", | |
| "coat", "jacket", | |
| ], | |
| "time": [ | |
| "7:30", "8:15", "8:45", "9:00", "9:15", | |
| "9:45", "10:00", "10:30", "11:15", "11:45", | |
| "o'clock", | |
| ], | |
| "alibi": [ | |
| "playing poker", "working late", "at home", "was at", | |
| "dinner", "movie", "gym", "volunteering", "party", | |
| "sick", "attending", "helping", "hospital", | |
| "library", "studying", "babysitting", "coaching", | |
| "overtime", "laundromat", "walking", "video call", | |
| "prayer", | |
| ], | |
| "knows_associate": [ | |
| "know them", "know him", "know her", "never met", | |
| "don't know", "do not know", | |
| "know suspect", "knew", | |
| ], | |
| "was_at_scene": [ | |
| "wasn't there", "was not there", "not there", | |
| "never been", "was there", "at the scene", | |
| "nowhere near", | |
| ], | |
| "owns_item": [ | |
| "not mine", "my bag", "my phone", "my tools", | |
| "belongs to me", | |
| ], | |
| "relationship_to_victim": [ | |
| "knew the victim", "never met the victim", | |
| "victim", | |
| ], | |
| } | |
| def parse_action(action_string: str) -> dict: | |
| """Parse a detective action string into a structured dict. | |
| Returns: | |
| dict with keys: action_type, target/item, content (if applicable). | |
| Returns {action_type: "invalid"} on parse failure. | |
| """ | |
| action_string = action_string.strip() | |
| # Try ask_question | |
| m = _ACTION_PATTERNS["ask_question"].search(action_string) | |
| if m: | |
| return { | |
| "action_type": "ask_question", | |
| "target": m.group(1).strip(), | |
| "content": m.group(2).strip(), | |
| } | |
| # Try request_evidence | |
| m = _ACTION_PATTERNS["request_evidence"].search(action_string) | |
| if m: | |
| return { | |
| "action_type": "request_evidence", | |
| "item": m.group(1).strip(), | |
| } | |
| # Try accuse | |
| m = _ACTION_PATTERNS["accuse"].search(action_string) | |
| if m: | |
| return { | |
| "action_type": "accuse", | |
| "target": m.group(1).strip(), | |
| } | |
| return {"action_type": "invalid", "raw": action_string} | |
| def extract_topics_from_response(response: str) -> list[tuple[str, str]]: | |
| """Extract (topic, value) pairs from a natural language response. | |
| Uses word-boundary matching to avoid substring false positives | |
| (e.g. 'time' in 'Sometimes', 'own' in 'town'). | |
| Returns at most ONE topic per response to prevent a single statement | |
| from registering as 3 contradictions simultaneously. | |
| Returns: | |
| List of (topic, value_snippet) tuples. Max length = 1. | |
| """ | |
| response_lower = response.lower() | |
| # Find the BEST (most specific) single topic match | |
| best_match = None | |
| best_kw_len = 0 | |
| for topic, keywords in TOPIC_KEYWORDS.items(): | |
| for kw in keywords: | |
| # Use word-boundary regex to avoid substring matches | |
| pattern = r'\b' + re.escape(kw) + r'\b' | |
| m = re.search(pattern, response_lower) | |
| if m: | |
| # Prefer longer (more specific) keyword matches | |
| if len(kw) > best_kw_len: | |
| best_kw_len = len(kw) | |
| idx = m.start() | |
| start = idx | |
| end = min(len(response), idx + len(kw) + 30) | |
| value = response[start:end].strip() | |
| best_match = (topic, value) | |
| break # One match per topic, but continue checking other topics | |
| return [best_match] if best_match else [] | |
| # ββ Environment βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class CrimeInvestigationEnv: | |
| """Multi-agent crime investigation RL environment. | |
| Compatible with OpenEnv's step/reset/render interface and | |
| designed for training with HuggingFace TRL PPO. | |
| """ | |
| MAX_TURNS = 15 | |
| def __init__( | |
| self, | |
| llm_call: Optional[Callable[[str, list[dict]], str]] = None, | |
| ) -> None: | |
| """Initialize the environment. | |
| Args: | |
| llm_call: Callable(system_prompt, conversation_history) -> response. | |
| Used for NPC agents (suspects, witness). | |
| If None, a simple rule-based fallback is used. | |
| """ | |
| self.llm_call = llm_call or self._default_llm_call | |
| # Will be set on reset() | |
| self.case: Optional[dict] = None | |
| self.tracker: Optional[ConsistencyTracker] = None | |
| self.reward_calc: Optional[RewardCalculator] = None | |
| self.turn: int = 0 | |
| self.done: bool = False | |
| self.conversation_history: list[dict] = [] | |
| self.evidence_log: list[dict] = [] | |
| self.system_prompts: dict[str, str] = {} | |
| self.asked_topics: dict[str, set] = {} # {target: {topics asked}} | |
| self._pattern_penalized: set[str] = set() | |
| self._pattern_exploited_targets: set[str] = set() | |
| # Deflection tracking: stores (suspect, deflection_target) when a | |
| # suspect mentions the other suspect in their response. | |
| # Only set when the *previous action* was ask_question to a suspect. | |
| self._pending_deflection: Optional[tuple[str, str]] = None | |
| # What the last action type was (ask_question / request_evidence / etc.) | |
| self._last_action_type: Optional[str] = None | |
| # Cap anti-deflection bonus to once per suspect per episode. | |
| self._deflection_resistance_rewarded: set[str] = set() | |
| # Prevent repeated contradiction farming on the same target/topic pair. | |
| self._contradiction_rewarded_topics: set[tuple[str, str]] = set() | |
| # ββ OpenEnv Interface βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _reset_episode_tracking(self) -> None: | |
| self._pending_deflection = None | |
| self._last_action_type = None | |
| self._pattern_penalized = set() | |
| self._pattern_exploited_targets = set() | |
| self._deflection_resistance_rewarded = set() | |
| self._contradiction_rewarded_topics = set() | |
| def reset(self, case_data: Optional[dict] = None) -> dict: | |
| """Start a new episode. | |
| Returns: | |
| Initial observation for the detective agent. | |
| """ | |
| self.case = copy.deepcopy(case_data) if case_data is not None else generate_case() | |
| self.tracker = ConsistencyTracker() | |
| self.reward_calc = RewardCalculator() | |
| self.turn = 0 | |
| self.done = False | |
| self.conversation_history = [] | |
| self.evidence_log = [] | |
| self.asked_topics = {t: set() for t in VALID_TARGETS} | |
| self._reset_episode_tracking() | |
| # Build system prompts for all agents | |
| self.system_prompts = { | |
| DETECTIVE_KEY: build_system_prompt("detective", self.case), | |
| SUSPECT_A: build_system_prompt(SUSPECT_A_KEY, self.case), | |
| SUSPECT_B: build_system_prompt(SUSPECT_B_KEY, self.case), | |
| WITNESS_1: build_system_prompt("witness", self.case), | |
| } | |
| return { | |
| "role": "detective", | |
| "briefing": self.case["detective_briefing"], | |
| "turn": 0, | |
| "conversation_history": [], | |
| } | |
| def step(self, action_string: str) -> tuple[dict, float, bool, dict]: | |
| """Execute one detective action. | |
| Args: | |
| action_string: Action in the format defined in Component 4. | |
| Returns: | |
| (observation, reward, done, info) | |
| """ | |
| if self.done: | |
| raise RuntimeError("Episode is done. Call reset().") | |
| parsed = parse_action(action_string) | |
| action_type = parsed["action_type"] | |
| if action_type == "ask_question": | |
| return self._handle_ask_question(parsed) | |
| elif action_type == "request_evidence": | |
| return self._handle_request_evidence(parsed) | |
| elif action_type == "accuse": | |
| return self._handle_accuse(parsed) | |
| else: | |
| # Invalid action β treat as wasted turn | |
| reward = self._apply_invalid_turn_penalty("invalid") | |
| if self.turn >= self.MAX_TURNS: | |
| return self._handle_timeout(reward) | |
| obs = self._build_observation( | |
| message="Invalid action format. Please use the correct format." | |
| ) | |
| return obs, reward, False, {"action": "invalid"} | |
| def render(self) -> None: | |
| """Print the conversation history in a readable format.""" | |
| print("\n" + "=" * 70) | |
| print(" CRIME INVESTIGATION β CONVERSATION LOG") | |
| print("=" * 70) | |
| if self.case: | |
| print(f" Crime: {self.case['crime']}") | |
| print(f" Location: {self.case['location']}") | |
| print("-" * 70) | |
| for entry in self.conversation_history: | |
| turn = entry.get("turn", "?") | |
| speaker = entry.get("speaker", "???") | |
| content = entry.get("content", "") | |
| flags = entry.get("flags", []) | |
| print(f"\n [Turn {turn}] {speaker}:") | |
| print(f" {content}") | |
| if flags: | |
| for flag in flags: | |
| print(f" β FLAG: {flag}") | |
| print("\n" + "=" * 70) | |
| if self.done and self.reward_calc: | |
| rewards = self.reward_calc.get_rewards() | |
| print(" FINAL REWARDS:") | |
| for agent, r in rewards.items(): | |
| print(f" {agent}: {r:+.2f}") | |
| print("=" * 70 + "\n") | |
| def state(self) -> dict: | |
| """Return episode metadata (OpenEnv compatible).""" | |
| return { | |
| "turn": self.turn, | |
| "done": self.done, | |
| "max_turns": self.MAX_TURNS, | |
| "evidence_revealed": len(self.evidence_log), | |
| "contradictions_found": ( | |
| len(self.tracker.get_summary()) if self.tracker else 0 | |
| ), | |
| } | |
| # ββ Action handlers βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _handle_ask_question( | |
| self, parsed: dict | |
| ) -> tuple[dict, float, bool, dict]: | |
| target = parsed["target"] | |
| question = parsed["content"] | |
| step_reward = 0.0 | |
| info: dict[str, Any] = {"action": "ask_question", "target": target} | |
| flags: list[str] = [] | |
| # Validate target | |
| if target not in VALID_TARGETS: | |
| reward = self._apply_invalid_turn_penalty("ask_question") | |
| if self.turn >= self.MAX_TURNS: | |
| return self._handle_timeout(reward) | |
| obs = self._build_observation( | |
| message=f"Invalid target: {target}. Valid: {list(VALID_TARGETS)}" | |
| ) | |
| return obs, reward, False, {"action": "invalid_target"} | |
| step_reward += self._apply_deflection_logic(target) | |
| # Per-turn cost | |
| self.reward_calc.apply_event("per_turn_cost") | |
| step_reward += -0.3 | |
| # Check for redundant question (topic already asked to this target) | |
| question_topics = extract_topics_from_response(question) | |
| if not question_topics: | |
| # Bucket unclassified questions to prevent repetition bypasses. | |
| question_topics = [("general", question[:40])] | |
| question_topic_names = {topic for topic, _ in question_topics} | |
| previous_topics = set(self.asked_topics[target]) | |
| is_redundant = any(topic in previous_topics for topic in question_topic_names) | |
| for topic in question_topic_names: | |
| self.asked_topics[target].add(topic) | |
| if is_redundant: | |
| self.reward_calc.apply_event("redundant_question") | |
| step_reward += -0.5 | |
| flags.append("redundant_question") | |
| # Add question to conversation history | |
| self.conversation_history.append( | |
| { | |
| "turn": self.turn, | |
| "speaker": "Detective", | |
| "content": f"[To {target}] {question}", | |
| "flags": list(flags), | |
| } | |
| ) | |
| # Get NPC response | |
| system_prompt = self.system_prompts.get(target, "") | |
| # Use full history to avoid artificial contradictions from short memory cap | |
| recent_history = list(self.conversation_history) | |
| response = self.llm_call(system_prompt, recent_history) | |
| response_lower = response.lower() | |
| # Extract topics from response and run consistency checks | |
| response_topics = extract_topics_from_response(response) | |
| response_flags: list[str] = [] | |
| step_reward += self._analyse_response_topics( | |
| target=target, | |
| response_topics=response_topics, | |
| question_topic_names=question_topic_names, | |
| previous_topics=previous_topics, | |
| response_flags=response_flags, | |
| ) | |
| # Check for witness bias-driven false implication | |
| if target == WITNESS_1: | |
| self._check_witness_bias(response_lower, response_flags) | |
| # Add response to conversation history | |
| self.conversation_history.append( | |
| { | |
| "turn": self.turn, | |
| "speaker": target, | |
| "content": response, | |
| "flags": response_flags, | |
| } | |
| ) | |
| # ββ Track deflection attempt ββββββββββββββββββββββββββββββββββββ | |
| # Only track deflection when current action is ask_question | |
| # to a suspect, and their response mentions the other suspect. | |
| if target in (SUSPECT_A, SUSPECT_B): | |
| other = SUSPECT_B if target == SUSPECT_A else SUSPECT_A | |
| if ( | |
| other.lower().replace("_", " ") in response_lower | |
| or other.lower() in response_lower | |
| ): | |
| self._pending_deflection = (target, other) | |
| info["deflection_target"] = other | |
| else: | |
| self._pending_deflection = None | |
| else: | |
| self._pending_deflection = None | |
| self._last_action_type = "ask_question" | |
| self.turn += 1 | |
| if self.turn >= self.MAX_TURNS: | |
| return self._handle_timeout(step_reward) | |
| obs = self._build_observation() | |
| info["flags"] = response_flags | |
| return obs, step_reward, False, info | |
| def _apply_deflection_logic(self, target: str) -> float: | |
| """Apply deflection rewards/penalties from the previous turn.""" | |
| step_delta = 0.0 | |
| if ( | |
| self._pending_deflection is not None | |
| and self._last_action_type == "ask_question" | |
| ): | |
| deflector, deflection_target = self._pending_deflection | |
| if target == deflection_target: | |
| deflector_key = AGENT_NAME_TO_KEY.get(deflector, deflector.lower()) | |
| self.reward_calc.apply_event( | |
| "successful_deflection", | |
| agent=deflector_key, | |
| ) | |
| elif ( | |
| target == deflector | |
| and deflector not in self._deflection_resistance_rewarded | |
| ): | |
| # Reward resisting deflection only when the detective stays | |
| # on the same suspect, and only once per deflector. | |
| self._deflection_resistance_rewarded.add(deflector) | |
| step_delta += self.reward_calc.apply_event("deflection_resistance") | |
| self._pending_deflection = None | |
| return step_delta | |
| def _analyse_response_topics( | |
| self, | |
| target: str, | |
| response_topics: list[tuple[str, str]], | |
| question_topic_names: set[str], | |
| previous_topics: set[str], | |
| response_flags: list[str], | |
| ) -> float: | |
| """Analyze response claims for contradictions, lies, and prior-pattern hits.""" | |
| step_delta = 0.0 | |
| agent_key = AGENT_NAME_TO_KEY.get(target, target.lower()) | |
| for topic, value in response_topics: | |
| claim_result = self.tracker.record_claim(target, topic, value, self.turn) | |
| if claim_result["contradicted"]: | |
| response_flags.append( | |
| f"CONTRADICTION on '{topic}': " | |
| f"previously said '{claim_result['old_value'][:50]}', " | |
| f"now says '{claim_result['new_value'][:50]}'" | |
| ) | |
| intentional_probe = ( | |
| topic in question_topic_names and topic in previous_topics | |
| ) | |
| contradiction_key = (target, topic) | |
| if ( | |
| intentional_probe | |
| and contradiction_key not in self._contradiction_rewarded_topics | |
| ): | |
| self._contradiction_rewarded_topics.add(contradiction_key) | |
| self.reward_calc.apply_event("contradiction_exposed") | |
| step_delta += 2.0 | |
| if target in (SUSPECT_A, SUSPECT_B): | |
| self.reward_calc.apply_event( | |
| "suspect_self_contradiction", | |
| agent=agent_key, | |
| ) | |
| elif target == WITNESS_1: | |
| self.reward_calc.apply_event("witness_self_contradiction") | |
| visible_evidence = list(self.evidence_log) | |
| if visible_evidence: | |
| ev_result = self.tracker.check_against_evidence( | |
| target, topic, value, visible_evidence | |
| ) | |
| if ev_result["confirmed_lie"]: | |
| response_flags.append( | |
| f"LIE CONFIRMED by evidence on '{topic}'" | |
| ) | |
| if target in (SUSPECT_A, SUSPECT_B): | |
| self.reward_calc.apply_event( | |
| "confirmed_lie_vs_evidence", | |
| agent=agent_key, | |
| ) | |
| step_delta += 1.0 | |
| prior_result = self.tracker.check_prior_pattern( | |
| target, topic, value, self.case["prior_history"] | |
| ) | |
| if prior_result["pattern_match"]: | |
| response_flags.append( | |
| f"PRIOR PATTERN MATCH on '{topic}'" | |
| ) | |
| has_documented_history = bool( | |
| self.case["prior_history"] | |
| .get(target, {}) | |
| .get("past_lies_on_record", []) | |
| ) | |
| topic_aligned_probe = topic in question_topic_names | |
| if has_documented_history and topic_aligned_probe: | |
| if target not in self._pattern_exploited_targets: | |
| self._pattern_exploited_targets.add(target) | |
| self.reward_calc.apply_event("prior_pattern_exploited") | |
| step_delta += 1.5 | |
| if ( | |
| target == self.case["criminal"] | |
| and target not in self._pattern_penalized | |
| ): | |
| self._pattern_penalized.add(target) | |
| self.reward_calc.apply_event( | |
| "prior_pattern_repeated", | |
| agent=agent_key, | |
| ) | |
| return step_delta | |
| def _apply_invalid_turn_penalty(self, action_type: str) -> float: | |
| """Apply consistent malformed-action accounting across all handlers.""" | |
| self.turn += 1 | |
| self.reward_calc.apply_event("per_turn_cost") | |
| self._last_action_type = action_type | |
| self._pending_deflection = None | |
| return -0.3 | |
| def _check_witness_bias(self, response_lower: str, response_flags: list[str]) -> None: | |
| """Flag likely witness-bias implication of the innocent suspect.""" | |
| witness_knowledge = self.case["agent_knowledge"][WITNESS_1_KEY] | |
| if not witness_knowledge.get("bias_target"): | |
| return | |
| bias_target = witness_knowledge["bias_target"] | |
| # Only fire bias penalty if the bias target is the INNOCENT suspect. | |
| if bias_target.lower() not in response_lower or bias_target == self.case["criminal"]: | |
| return | |
| bias_strength = witness_knowledge.get("bias_strength", 0) | |
| if bias_strength <= 0.2: | |
| return | |
| scene_location = self.case["location"].lower() | |
| bias_name = bias_target.lower() | |
| implication_patterns = [ | |
| f"saw {bias_name}", | |
| f"{bias_name} was", | |
| f"{bias_name} at", | |
| f"{bias_name} near", | |
| f"{bias_name} did it", | |
| f"{bias_name} committed", | |
| ] | |
| certainty_markers = [ | |
| "definitely", "certain", "for sure", "i'm sure", "it was", | |
| ] | |
| uncertainty_markers = [ | |
| "not sure", "might", "maybe", "could", "possibly", | |
| "can't be certain", "uncertain", | |
| ] | |
| implies_innocent = any(p in response_lower for p in implication_patterns) | |
| mentions_scene = ( | |
| scene_location in response_lower | |
| or "scene" in response_lower | |
| or "there" in response_lower | |
| ) | |
| is_certain = any(m in response_lower for m in certainty_markers) | |
| is_uncertain = any(m in response_lower for m in uncertainty_markers) | |
| if implies_innocent and mentions_scene and (is_certain or "saw" in response_lower) and not is_uncertain: | |
| self.reward_calc.apply_event("bias_driven_false_implication") | |
| response_flags.append("POSSIBLE BIAS-DRIVEN STATEMENT") | |
| def _handle_request_evidence( | |
| self, parsed: dict | |
| ) -> tuple[dict, float, bool, dict]: | |
| item = parsed["item"].lower() | |
| step_reward = 0.0 | |
| info: dict[str, Any] = {"action": "request_evidence", "item": item} | |
| # Evidence request clears any pending deflection β it's not a follow-up | |
| self._pending_deflection = None | |
| self._last_action_type = "request_evidence" | |
| self.reward_calc.apply_event("per_turn_cost") | |
| step_reward += -0.3 | |
| self.turn += 1 | |
| if item not in EVIDENCE_ITEMS: | |
| if self.turn >= self.MAX_TURNS: | |
| return self._handle_timeout(step_reward) | |
| obs = self._build_observation( | |
| message=f"Unknown evidence item: {item}. " | |
| f"Available: {list(EVIDENCE_ITEMS)}" | |
| ) | |
| return obs, step_reward, False, info | |
| # Check if this evidence was already revealed (prevent reward hacking) | |
| already_revealed = any( | |
| e.get("name") == item for e in self.evidence_log | |
| ) | |
| if already_revealed: | |
| if self.turn >= self.MAX_TURNS: | |
| return self._handle_timeout(step_reward) | |
| obs = self._build_observation( | |
| message=f"Evidence '{item}' has already been revealed." | |
| ) | |
| return obs, step_reward, False, info | |
| # Reveal the evidence | |
| idx = EVIDENCE_INDEX_MAP.get(item, 0) | |
| evidence = copy.deepcopy(self.case["physical_evidence"][idx]) | |
| evidence["visible_to_detective"] = True | |
| self.case["physical_evidence"][idx]["visible_to_detective"] = True | |
| self.evidence_log.append(evidence) | |
| # Check if it confirms an existing lead | |
| points_to = evidence.get("points_to", "unknown") | |
| if points_to != "unknown": | |
| # Only grant reward if the detective asked evidence-relevant, | |
| # non-general questions to the implicated suspect. | |
| if points_to in VALID_TARGETS: | |
| asked = self.asked_topics.get(points_to, set()) | |
| lead_topics = EVIDENCE_LEAD_TOPICS.get(item, set()) | |
| has_meaningful_lead = any( | |
| topic != "general" and topic in lead_topics | |
| for topic in asked | |
| ) | |
| else: | |
| has_meaningful_lead = False | |
| if has_meaningful_lead: | |
| self.reward_calc.apply_event("evidence_request_confirms_lead") | |
| step_reward += 1.0 | |
| info["confirms_lead"] = points_to | |
| # IMPORTANT: Do NOT reveal points_to to the detective β they must reason | |
| self.conversation_history.append( | |
| { | |
| "turn": self.turn - 1, | |
| "speaker": "Evidence", | |
| "content": f"[{item}] {evidence['description']}", | |
| "flags": [], | |
| } | |
| ) | |
| if self.turn >= self.MAX_TURNS: | |
| return self._handle_timeout(step_reward) | |
| obs = self._build_observation( | |
| message=f"Evidence revealed: {evidence['description']}" | |
| ) | |
| info["evidence"] = evidence | |
| return obs, step_reward, False, info | |
| def _handle_accuse( | |
| self, parsed: dict | |
| ) -> tuple[dict, float, bool, dict]: | |
| target = parsed["target"] | |
| if target not in VALID_ACCUSE_TARGETS: | |
| reward = self._apply_invalid_turn_penalty("accuse") | |
| if self.turn >= self.MAX_TURNS: | |
| return self._handle_timeout(reward) | |
| obs = self._build_observation( | |
| message=( | |
| f"Invalid accusation target: {target}. " | |
| f"Valid: {list(VALID_ACCUSE_TARGETS)}" | |
| ) | |
| ) | |
| return obs, reward, False, { | |
| "action": "invalid_accuse_target", | |
| "target": target, | |
| } | |
| self.done = True | |
| criminal = self.case["criminal"] | |
| correct = target == criminal | |
| info: dict[str, Any] = { | |
| "action": "accuse", | |
| "target": target, | |
| "correct": correct, | |
| "actual_criminal": criminal, | |
| } | |
| # Check if witness bias was active | |
| witness_knowledge = self.case["agent_knowledge"][WITNESS_1_KEY] | |
| bias_active = ( | |
| witness_knowledge.get("bias_target") is not None | |
| and witness_knowledge.get("bias_strength", 0) > 0.2 | |
| ) | |
| if correct: | |
| # Correct accusation | |
| self.reward_calc.apply_event("correct_accusation") | |
| # Criminal caught | |
| criminal_key = AGENT_NAME_TO_KEY.get(criminal, criminal.lower()) | |
| self.reward_calc.apply_event( | |
| "correctly_caught", agent=criminal_key | |
| ) | |
| # Witness helped correctly | |
| self.reward_calc.apply_event("helped_correct_accusation") | |
| else: | |
| # Wrong accusation | |
| self.reward_calc.apply_event( | |
| "wrong_accusation", bias_active=bias_active | |
| ) | |
| # The real criminal escapes | |
| criminal_key = AGENT_NAME_TO_KEY.get(criminal, criminal.lower()) | |
| self.reward_calc.apply_event( | |
| "escaped_not_accused", agent=criminal_key | |
| ) | |
| # Innocent suspect was wrongly accused | |
| innocent_key = AGENT_NAME_TO_KEY.get(target, target.lower()) | |
| self.reward_calc.apply_event( | |
| "wrongly_accused_innocent", agent=innocent_key | |
| ) | |
| # Witness led to wrong accusation | |
| self.reward_calc.apply_event("led_to_wrong_accusation") | |
| self.conversation_history.append( | |
| { | |
| "turn": self.turn, | |
| "speaker": "Detective", | |
| "content": f"ACCUSATION: {target}", | |
| "flags": [ | |
| "β CORRECT" if correct else "β WRONG", | |
| ], | |
| } | |
| ) | |
| # Bug 1: Return only the terminal delta, not cumulative total | |
| if correct: | |
| terminal_delta = 10.0 | |
| else: | |
| terminal_delta = -8.0 if not bias_active else -10.0 | |
| obs = self._build_observation( | |
| message=( | |
| f"Accusation: {target}. " | |
| f"{'CORRECT!' if correct else 'WRONG!'} " | |
| f"The criminal was {criminal}." | |
| ) | |
| ) | |
| return obs, terminal_delta, True, info | |
| def _handle_timeout(self, step_reward: float = 0.0) -> tuple[dict, float, bool, dict]: | |
| """Handle the case where 15 turns pass with no accusation.""" | |
| self.done = True | |
| self.reward_calc.apply_event("timeout_no_accusation") | |
| # Criminal escapes | |
| criminal = self.case["criminal"] | |
| criminal_key = AGENT_NAME_TO_KEY.get(criminal, criminal.lower()) | |
| self.reward_calc.apply_event( | |
| "escaped_not_accused", agent=criminal_key | |
| ) | |
| self.conversation_history.append( | |
| { | |
| "turn": self.turn, | |
| "speaker": "System", | |
| "content": "TIMEOUT β No accusation made. Case goes cold.", | |
| "flags": ["β° TIMEOUT"], | |
| } | |
| ) | |
| # Bug 3: Return terminal delta (-3.0) instead of cumulative total | |
| timeout_penalty = -3.0 | |
| obs = self._build_observation( | |
| message="Case timed out. No accusation was made within 15 turns." | |
| ) | |
| return obs, step_reward + timeout_penalty, True, {"action": "timeout"} | |
| # ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _build_observation(self, message: str = "") -> dict: | |
| """Build the observation dict for the detective.""" | |
| return { | |
| "role": "detective", | |
| "briefing": self.case["detective_briefing"], | |
| "turn": self.turn, | |
| "conversation_history": list(self.conversation_history), | |
| # Bug 10: Deep copy evidence dicts to prevent mutation of case state | |
| "evidence_log": copy.deepcopy(self.evidence_log), | |
| "message": message, | |
| } | |
| def _default_llm_call( | |
| system_prompt: str, conversation_history: list[dict] | |
| ) -> str: | |
| """Rule-based fallback for NPC agents. | |
| Uses the system prompt to identify the agent and provide | |
| role-appropriate responses that stay consistent. | |
| Works with either suspect as the criminal. | |
| """ | |
| # Determine role from system prompt | |
| # Bug 5: Use 'FAKE ALIBI' to identify guilty suspect instead of 'committed' | |
| # (detective prompt contains 'committed' too, causing misidentification) | |
| is_suspect = "Suspect_A" in system_prompt or "Suspect_B" in system_prompt | |
| is_guilty = "FAKE ALIBI:" in system_prompt | |
| is_innocent = "REAL ALIBI:" in system_prompt | |
| is_witness = ("Witness_1" in system_prompt or "Witness" in system_prompt) and not is_suspect | |
| if is_suspect and is_guilty: | |
| # Extract alibi from the prompt | |
| alibi = "at a family gathering" | |
| if "FAKE ALIBI:" in system_prompt: | |
| alibi_line = system_prompt.split("FAKE ALIBI:")[1].split("\n")[0].strip() | |
| if alibi_line: | |
| alibi = alibi_line | |
| return ( | |
| f"I was nowhere near the scene. {alibi}. " | |
| "Check with anyone who was there β they'll confirm. " | |
| "I don't see why you're asking me about this." | |
| ) | |
| elif is_suspect and is_innocent: | |
| # Extract alibi from the prompt | |
| alibi = "working late at the office" | |
| if "REAL ALIBI:" in system_prompt: | |
| alibi_line = system_prompt.split("REAL ALIBI:")[1].split("\n")[0].strip() | |
| if alibi_line: | |
| alibi = alibi_line | |
| return ( | |
| f"I can tell you exactly where I was β {alibi}. " | |
| "I have receipts and witnesses to prove it. " | |
| "I had absolutely nothing to do with this." | |
| ) | |
| elif is_witness: | |
| # Extract what the witness saw from the prompt | |
| saw = "someone in dark clothing near the area" | |
| if "WHAT YOU SAW:" in system_prompt: | |
| saw_line = system_prompt.split("WHAT YOU SAW:")[1].split("\n")[0].strip() | |
| if saw_line: | |
| saw = saw_line | |
| return ( | |
| f"I saw {saw}. " | |
| "They were moving quickly and seemed nervous. " | |
| "I didn't get a clear look at their face." | |
| ) | |
| else: | |
| return "I don't have anything else to add." | |