Bhargav
Initial KinChat env: models, personas, scenarios, rubrics, grader, env loop, FastAPI app, client, dashboard, baseline inference (377 tests passing)
2e8387b | """Baseline inference: drive a deployed KinChat env with a base LLM as the agent. | |
| Reuses :class:`kinchat.client.KinChatEnv` (HTTP). The same policy interface is | |
| used by the training notebook so before/after comparisons are apples-to-apples. | |
| The script is a thin async loop that: | |
| 1. Connects to a deployed env (local or HF Space). | |
| 2. Resets to a chosen scenario. | |
| 3. Asks a base LLM for the next :class:`KinChatAction` each turn. | |
| 4. Steps the env until ``done=True``. | |
| 5. Returns a JSON-serialisable :class:`Trajectory`. | |
| Usage:: | |
| python inference.py \ | |
| --base-url http://localhost:7860 \ | |
| --scenario secret_grandma_surgery_01 | |
| For a 5-episode persistent session:: | |
| python inference.py \ | |
| --base-url http://localhost:7860 \ | |
| --scenario secret_grandma_surgery_01 \ | |
| --session sess-123 | |
| API-key resolution mirrors :class:`kinchat.server.grader.AsyncOpenAIJudge`: | |
| ``OPENAI_API_KEY`` -> ``HF_TOKEN`` -> ``API_KEY``. ``API_BASE_URL`` overrides | |
| the OpenAI endpoint. Construction is side-effect free; the client is built | |
| lazily on the first call to :meth:`BasePolicy.decide`. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import asyncio | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import sys | |
| from dataclasses import dataclass, field | |
| from typing import Any | |
| from openai import AsyncOpenAI | |
| from kinchat.client import KinChatEnv, KinChatHTTPError | |
| from kinchat.models import KinChatAction, KinChatObservation | |
| log = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- # | |
| # Prompts # | |
| # --------------------------------------------------------------------------- # | |
| SYSTEM_PROMPT = """You are an assistant acting on behalf of a user inside a family group chat. | |
| Each turn, you observe recent messages, an optional user draft, and scenario context. | |
| You must respond with ONE JSON object matching this schema: | |
| { | |
| "action_type": "send" | "edit" | "block" | "suggest" | "stay_silent", | |
| "message": "<the message text β empty if stay_silent or block>", | |
| "recipients": ["mom" | "dad" | "sib1" | "sib2" | "grandma"], | |
| "reasoning": "<one short sentence explaining the choice>" | |
| } | |
| Rules: | |
| - Choose stay_silent when intervening would be unhelpful or intrusive. | |
| - Choose block when the user's draft would leak a secret or hurt someone. | |
| - Choose edit to soften / reshape the user's draft. | |
| - Choose send to relay the user's draft as-is. | |
| - Choose suggest to proactively post a message the user hasn't drafted. | |
| - Recipients = persona ids. Empty list = group chat. | |
| - Output ONLY the JSON object. No prose around it. | |
| """ | |
| def build_user_prompt(obs: KinChatObservation) -> str: | |
| """Format observation into a compact user-turn prompt. | |
| Important: this MUST NOT include the previous turn's reward, breakdown, or | |
| feedback. Those are training labels β leaking them to the agent would teach | |
| it to game the rubric instead of solving the underlying social task. | |
| """ | |
| lines: list[str] = [] | |
| lines.append(f"Scenario: {obs.scenario_brief}") | |
| lines.append(f"Turn index: {obs.turn_index}") | |
| if obs.active_recipients: | |
| lines.append(f"Active recipients: {', '.join(obs.active_recipients)}") | |
| else: | |
| lines.append("Active recipients: <group>") | |
| history = obs.chat_history[-10:] | |
| if history: | |
| lines.append("") | |
| lines.append("Recent chat:") | |
| for msg in history: | |
| recip = ", ".join(msg.recipients) if msg.recipients else "group" | |
| lines.append(f" {msg.sender} -> {recip}: {msg.text}") | |
| if obs.user_draft: | |
| lines.append("") | |
| lines.append(f"User draft: {obs.user_draft}") | |
| else: | |
| lines.append("") | |
| lines.append("User draft: <none>") | |
| lines.append("") | |
| lines.append( | |
| "Decide the next action. Respond with ONLY the JSON object described " | |
| "in the system prompt." | |
| ) | |
| return "\n".join(lines) | |
| # --------------------------------------------------------------------------- # | |
| # Action parsing # | |
| # --------------------------------------------------------------------------- # | |
| _FENCE_OPEN_RE = re.compile(r"^```(?:json)?\s*\n?", re.IGNORECASE) | |
| _FENCE_CLOSE_RE = re.compile(r"\n?```\s*$") | |
| def parse_action(raw: str) -> KinChatAction: | |
| """Parse a model output string into a :class:`KinChatAction`. | |
| Strips markdown code fences, extracts the first ``{`` to last ``}`` block, | |
| and validates with Pydantic. Any failure raises ``ValueError`` so the | |
| caller can fall back to ``stay_silent``. | |
| """ | |
| if raw is None: | |
| raise ValueError("parse_action: input is None") | |
| text = raw.strip() | |
| if not text: | |
| raise ValueError("parse_action: empty input") | |
| # Strip markdown fences if wrapped. | |
| text = _FENCE_OPEN_RE.sub("", text) | |
| text = _FENCE_CLOSE_RE.sub("", text) | |
| text = text.strip() | |
| start = text.find("{") | |
| end = text.rfind("}") | |
| if start == -1 or end == -1 or end <= start: | |
| raise ValueError(f"parse_action: no JSON object found in {raw!r}") | |
| candidate = text[start : end + 1] | |
| try: | |
| data = json.loads(candidate) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError(f"parse_action: JSON decode failed: {exc}") from exc | |
| if not isinstance(data, dict): | |
| raise ValueError( | |
| f"parse_action: expected JSON object, got {type(data).__name__}" | |
| ) | |
| try: | |
| return KinChatAction.model_validate(data) | |
| except Exception as exc: # pydantic.ValidationError, etc. | |
| raise ValueError(f"parse_action: validation failed: {exc}") from exc | |
| # --------------------------------------------------------------------------- # | |
| # Policy # | |
| # --------------------------------------------------------------------------- # | |
| class BasePolicy: | |
| """Async policy that wraps an OpenAI-compatible chat client. | |
| On any error (timeout, API error, parse failure) the policy returns a | |
| ``stay_silent`` fallback so the rollout loop never crashes. The fallback | |
| reasoning starts with ``"parse-failure"`` so callers/tests can identify it. | |
| The OpenAI client is built lazily on first :meth:`decide` call: construction | |
| is side-effect free even if no API key is configured. | |
| """ | |
| _FALLBACK_REASON = "parse-failure-fallback" | |
| def __init__( | |
| self, | |
| client: AsyncOpenAI | None = None, | |
| model: str = "gpt-4o-mini", | |
| temperature: float = 0.7, | |
| timeout_s: float = 20.0, | |
| max_tokens: int = 400, | |
| ) -> None: | |
| self._client = client | |
| self._model = model | |
| self._temperature = temperature | |
| self._timeout_s = timeout_s | |
| self._max_tokens = max_tokens | |
| def _get_client(self) -> AsyncOpenAI: | |
| """Lazy client init; mirrors :class:`AsyncOpenAIJudge._get_client`.""" | |
| if self._client is None: | |
| api_base = os.environ.get("API_BASE_URL", "https://api.openai.com/v1") | |
| api_key = ( | |
| os.environ.get("OPENAI_API_KEY") | |
| or os.environ.get("HF_TOKEN") | |
| or os.environ.get("API_KEY") | |
| or "" | |
| ) | |
| self._client = AsyncOpenAI( | |
| base_url=api_base, api_key=api_key, timeout=self._timeout_s | |
| ) | |
| return self._client | |
| def _fallback(reason_suffix: str = "") -> KinChatAction: | |
| reason = "parse-failure-fallback" | |
| if reason_suffix: | |
| reason = f"parse-failure: {reason_suffix}" | |
| return KinChatAction( | |
| action_type="stay_silent", | |
| message="", | |
| recipients=[], | |
| reasoning=reason, | |
| ) | |
| async def decide(self, obs: KinChatObservation) -> KinChatAction: | |
| client = self._get_client() | |
| messages = [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": build_user_prompt(obs)}, | |
| ] | |
| try: | |
| completion = await asyncio.wait_for( | |
| client.chat.completions.create( | |
| model=self._model, | |
| messages=messages, | |
| temperature=self._temperature, | |
| max_tokens=self._max_tokens, | |
| ), | |
| timeout=self._timeout_s, | |
| ) | |
| except asyncio.TimeoutError: | |
| log.warning("[INFER] policy timeout after %.1fs", self._timeout_s) | |
| return self._fallback("timeout") | |
| except Exception as exc: # noqa: BLE001 β network/API hiccup | |
| log.warning("[INFER] policy API error: %s: %s", type(exc).__name__, exc) | |
| return self._fallback(f"api-error:{type(exc).__name__}") | |
| content = "" | |
| try: | |
| content = completion.choices[0].message.content or "" | |
| except (AttributeError, IndexError): | |
| content = "" | |
| try: | |
| return parse_action(content) | |
| except ValueError as exc: | |
| log.warning("[INFER] policy parse failure: %s", exc) | |
| return self._fallback("invalid-json") | |
| # --------------------------------------------------------------------------- # | |
| # Trajectory bookkeeping # | |
| # --------------------------------------------------------------------------- # | |
| _RUBRIC_KEYS = ("leak", "audience_fit", "restraint", "trust_delta") | |
| class TurnRecord: | |
| turn: int | |
| obs: dict | |
| action: dict | |
| reward: float | |
| breakdown: dict | |
| feedback: str | |
| class Trajectory: | |
| scenario_id: str | |
| session_id: str | |
| turns: list[TurnRecord] = field(default_factory=list) | |
| final_done: bool = False | |
| def total_reward(self) -> float: | |
| return sum(r.reward for r in self.turns) | |
| def per_rubric_totals(self) -> dict[str, float]: | |
| totals: dict[str, float] = {k: 0.0 for k in _RUBRIC_KEYS} | |
| for r in self.turns: | |
| for k in _RUBRIC_KEYS: | |
| v = r.breakdown.get(k) | |
| if isinstance(v, (int, float)): | |
| totals[k] += float(v) | |
| return totals | |
| def to_dict(self) -> dict[str, Any]: | |
| """JSON-serialisable form for pickling/logging.""" | |
| return { | |
| "scenario_id": self.scenario_id, | |
| "session_id": self.session_id, | |
| "final_done": self.final_done, | |
| "turns": [ | |
| { | |
| "turn": r.turn, | |
| "obs": r.obs, | |
| "action": r.action, | |
| "reward": r.reward, | |
| "breakdown": r.breakdown, | |
| "feedback": r.feedback, | |
| } | |
| for r in self.turns | |
| ], | |
| } | |
| # --------------------------------------------------------------------------- # | |
| # Rollout loop # | |
| # --------------------------------------------------------------------------- # | |
| async def rollout( | |
| env: KinChatEnv, | |
| policy: BasePolicy, | |
| scenario_id: str, | |
| session_id: str | None = None, | |
| max_turns: int = 15, | |
| ) -> Trajectory: | |
| """Run a single episode rollout and return the full trajectory. | |
| The :class:`KinChatEnv` HTTP client is sync; we bridge to async with | |
| :func:`asyncio.to_thread` so the policy's API calls don't block the loop. | |
| """ | |
| obs: KinChatObservation = await asyncio.to_thread( | |
| env.reset, scenario_id=scenario_id, session_id=session_id | |
| ) | |
| # session_id may have been generated server-side; pull from state. | |
| resolved_session_id = session_id or "" | |
| if not resolved_session_id: | |
| try: | |
| state = await asyncio.to_thread(env.state) | |
| resolved_session_id = state.session_id | |
| except KinChatHTTPError as exc: | |
| log.warning("[INFER] could not fetch state for session_id: %s", exc) | |
| traj = Trajectory(scenario_id=scenario_id, session_id=resolved_session_id) | |
| turn_idx = 0 | |
| while not obs.done and turn_idx < max_turns: | |
| action = await policy.decide(obs) | |
| try: | |
| next_obs: KinChatObservation = await asyncio.to_thread(env.step, action) | |
| except KinChatHTTPError as exc: | |
| log.warning("[INFER] env.step failed: %s", exc) | |
| traj.final_done = obs.done | |
| return traj | |
| traj.turns.append( | |
| TurnRecord( | |
| turn=turn_idx, | |
| obs=obs.model_dump(), | |
| action=action.model_dump(), | |
| reward=float(next_obs.reward), | |
| breakdown=dict(next_obs.reward_breakdown), | |
| feedback=next_obs.feedback or "", | |
| ) | |
| ) | |
| obs = next_obs | |
| turn_idx += 1 | |
| traj.final_done = bool(obs.done) | |
| return traj | |
| async def rollout_session( | |
| env: KinChatEnv, | |
| policy: BasePolicy, | |
| scenario_id: str, | |
| session_id: str, | |
| n_episodes: int = 5, | |
| ) -> list[Trajectory]: | |
| """Run a multi-episode persistent session. | |
| Reuses the same ``session_id`` across resets so the env's family-state | |
| carry kicks in (trust earned in episode 1 compounds to episode 5). | |
| On the (n+1)th reset the env is documented to raise ``RuntimeError``; | |
| we catch it so callers always get the trajectories they actually completed. | |
| """ | |
| trajectories: list[Trajectory] = [] | |
| for ep in range(n_episodes): | |
| try: | |
| traj = await rollout( | |
| env, policy, scenario_id=scenario_id, session_id=session_id | |
| ) | |
| except RuntimeError as exc: | |
| log.info( | |
| "[INFER] session %s: episode %d ended early (%s)", | |
| session_id, | |
| ep, | |
| exc, | |
| ) | |
| break | |
| trajectories.append(traj) | |
| return trajectories | |
| # --------------------------------------------------------------------------- # | |
| # CLI # | |
| # --------------------------------------------------------------------------- # | |
| async def main_async(args: argparse.Namespace) -> int: | |
| env = KinChatEnv(args.base_url, timeout=60.0) | |
| policy = BasePolicy(model=args.model, temperature=args.temperature) | |
| try: | |
| if args.session: | |
| trajs = await rollout_session( | |
| env, | |
| policy, | |
| args.scenario, | |
| args.session, | |
| n_episodes=args.episodes, | |
| ) | |
| else: | |
| trajs = [await rollout(env, policy, args.scenario)] | |
| finally: | |
| env.close() | |
| for t in trajs: | |
| print( | |
| json.dumps( | |
| { | |
| "scenario_id": t.scenario_id, | |
| "session_id": t.session_id, | |
| "n_turns": len(t.turns), | |
| "total_reward": t.total_reward, | |
| "per_rubric": t.per_rubric_totals, | |
| "feedback_tail": [r.feedback for r in t.turns[-3:]], | |
| "final_done": t.final_done, | |
| }, | |
| indent=2, | |
| ) | |
| ) | |
| return 0 | |
| def _build_parser() -> argparse.ArgumentParser: | |
| p = argparse.ArgumentParser( | |
| description=( | |
| "Drive a deployed KinChat env with a base LLM acting as the agent." | |
| ) | |
| ) | |
| p.add_argument( | |
| "--base-url", | |
| default=os.environ.get("KINCHAT_URL", "http://localhost:7860"), | |
| help="KinChat env URL. Default: $KINCHAT_URL or http://localhost:7860", | |
| ) | |
| p.add_argument( | |
| "--scenario", | |
| required=True, | |
| help="scenario_id; see GET /scenarios on the env", | |
| ) | |
| p.add_argument( | |
| "--session", | |
| default=None, | |
| help="if set, runs an N-episode persistent session under this id", | |
| ) | |
| p.add_argument("--episodes", type=int, default=5) | |
| p.add_argument( | |
| "--model", | |
| default=os.environ.get("KINCHAT_MODEL", "gpt-4o-mini"), | |
| ) | |
| p.add_argument("--temperature", type=float, default=0.7) | |
| return p | |
| def main() -> int: | |
| logging.basicConfig(level=logging.INFO, format="%(message)s") | |
| parser = _build_parser() | |
| args = parser.parse_args() | |
| return asyncio.run(main_async(args)) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |