| """ |
| Example RL training loop for the BA Agent environment. |
| |
| Wraps the OpenEnv HTTP env into a Gymnasium-style adapter and runs a tiny |
| PPO-flavoured loop over an LLM policy. Intended as a starting reference |
| for users plugging the env into TRL / verl / SkyRL / custom training stacks. |
| |
| Two policy backends supported: |
| - "stub" — fixed plan (constant payloads per stage) |
| - "openrouter" — calls a chat model to produce each stage's payload |
| |
| Usage: |
| python train.py --policy stub --episodes 5 |
| python train.py --policy openrouter --model openai/gpt-4o-mini --episodes 5 |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import statistics |
| import time |
| from typing import Any, Dict, List |
|
|
| from client import BAAgentClient |
|
|
|
|
| _SYS_PROMPT_PER_STAGE = { |
| "EXTRACT": "You are extracting a structured summary of one feature. Return a short paragraph listing scope, entities, and constraints.", |
| "INTERVIEW": "Return a JSON list of 3 stakeholder Q&A pairs ([{q,a}]) covering primary actor, trigger, and exit criteria.", |
| "GRAPH": "Return a JSON object {nodes: [..], edges: [[a,b], ...]} for the entity / workflow graph.", |
| "STORY_GEN": "Return a JSON list of user stories. Each story MUST have title, description ('As a ... I want ... so that ...'), and acceptance_criteria (Given/When/Then). Cover every workflow stage in the input docs.", |
| "FINISH": "", |
| } |
|
|
|
|
| def _stub_policy(stage: str, obs: Dict[str, Any]) -> str: |
| return { |
| "EXTRACT": f"Feature scope summary for {obs.get('title','')}. Entities and constraints derived from input docs.", |
| "INTERVIEW": json.dumps([{"q": "Primary actor?", "a": "Operator"}, {"q": "Trigger?", "a": "Inbound work item"}]), |
| "GRAPH": json.dumps({"nodes": ["Actor", "Document"], "edges": [["Actor", "Document"]]}), |
| "STORY_GEN": json.dumps([ |
| {"title": "Receive item", "description": "As an operator, I want to receive a work item, so that I can process it.", |
| "acceptance_criteria": "Given inbound, When received, Then a record is created."}, |
| {"title": "Validate item", "description": "As an operator, I want validation, so that errors are caught.", |
| "acceptance_criteria": "Given a record, When validated, Then errors are flagged."}, |
| {"title": "Finalise item", "description": "As an operator, I want to finalise, so that the record is auditable.", |
| "acceptance_criteria": "Given a validated record, When finalised, Then status=Finalised."}, |
| ]), |
| "FINISH": "", |
| }[stage] |
|
|
|
|
| def _openrouter_policy(stage: str, obs: Dict[str, Any], model: str) -> str: |
| api_key = os.environ.get("OPENROUTER_API_KEY", "").strip() |
| if not api_key: |
| return _stub_policy(stage, obs) |
| from openai import OpenAI |
| client = OpenAI(api_key=api_key, base_url="https://openrouter.ai/api/v1") |
| docs = "\n\n".join(f"=== {d.get('filename','')} ===\n{(d.get('content','') or '')[:1500]}" for d in obs.get("input_documents", []) or []) |
| prompt = ( |
| f"Feature: {obs.get('title','')}\n" |
| f"Description: {obs.get('description','')[:1000]}\n\n" |
| f"Input docs:\n{docs[:6000]}\n\n" |
| f"Task: {_SYS_PROMPT_PER_STAGE[stage]}\n\n" |
| "Return ONLY the requested payload, no commentary." |
| ) |
| resp = client.chat.completions.create( |
| model=model, |
| messages=[{"role": "user", "content": prompt}], |
| temperature=0.2, |
| max_tokens=1500, |
| ) |
| return resp.choices[0].message.content.strip() |
|
|
|
|
| def run_episode(env: BAAgentClient, policy, model: str) -> Dict[str, Any]: |
| obs = env.reset() |
| total = 0.0 |
| step_rewards: List[float] = [] |
| terminal: Dict[str, Any] = {} |
| for stage in ["EXTRACT", "INTERVIEW", "GRAPH", "STORY_GEN", "FINISH"]: |
| if stage == "FINISH": |
| payload = "" |
| else: |
| payload = policy(stage, obs) if model is None else _openrouter_policy(stage, obs, model) |
| obs, r, done, meta = env.step(stage, payload) |
| total += r |
| step_rewards.append(r) |
| if done: |
| terminal = meta |
| break |
| return { |
| "task_id": obs.get("task_id"), |
| "step_rewards": [round(x, 3) for x in step_rewards], |
| "episode_reward": round(total, 4), |
| "composite_0_to_100": terminal.get("composite_0_to_100"), |
| "engine": terminal.get("engine"), |
| } |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--server", default="http://localhost:8000") |
| parser.add_argument("--policy", choices=["stub", "openrouter"], default="stub") |
| parser.add_argument("--model", default="openai/gpt-4o-mini") |
| parser.add_argument("--episodes", type=int, default=5) |
| args = parser.parse_args() |
|
|
| env = BAAgentClient(args.server) |
| if args.policy == "stub": |
| pol = _stub_policy |
| model_id = None |
| else: |
| pol = _stub_policy |
| model_id = args.model |
|
|
| print(f"Server: {args.server} Policy: {args.policy} Episodes: {args.episodes}") |
| composites: List[float] = [] |
| ep_rewards: List[float] = [] |
| for i in range(args.episodes): |
| t0 = time.time() |
| out = run_episode(env, pol, model_id) |
| dt = time.time() - t0 |
| c = out.get("composite_0_to_100") |
| composites.append(c if c is not None else 0.0) |
| ep_rewards.append(out["episode_reward"]) |
| print(f"ep {i+1:>2}/{args.episodes} task={out['task_id']:<10} total_r={out['episode_reward']:+.3f} composite={c}/100 {dt:.1f}s") |
|
|
| print() |
| print(f"Mean episode reward : {statistics.mean(ep_rewards):+.3f} ± {statistics.pstdev(ep_rewards):.3f}") |
| print(f"Mean composite : {statistics.mean(composites):.2f}/100") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|