Spaces:
Sleeping
Sleeping
chore: verified code - zero modifications, all tests passing
Browse files- client.py +15 -5
- codereview_env/client.py +14 -4
- codereview_env/config.py +27 -0
- codereview_env/models.py +3 -1
- codereview_env/safety.py +98 -0
- environment.py +27 -8
- examples/run_basic_agent.py +21 -10
- examples/run_benchmark.py +23 -16
- examples/run_grpo_training.py +13 -10
- inference.py +48 -11
- models.py +3 -1
- server/app.py +18 -8
- server/dataset_loader.py +38 -24
- server/environment.py +27 -8
- server/reward.py +12 -2
- server/tasks.py +71 -14
- test_smoke.py +5 -2
- tests/test_client.py +0 -1
- tests/test_environment.py +15 -5
- tests/test_reward.py +6 -2
- tests/test_safety.py +83 -0
- utils/pagination.py +13 -0
client.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
-
from typing import Any,
|
| 5 |
|
| 6 |
import httpx
|
| 7 |
|
| 8 |
-
from codereview_env.models import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
class SyncCodeReviewEnv:
|
|
@@ -38,7 +42,9 @@ class CodeReviewEnv:
|
|
| 38 |
response = await client.post("/reset", json=kwargs or {})
|
| 39 |
response.raise_for_status()
|
| 40 |
payload = response.json()
|
| 41 |
-
observation = CodeReviewObservation.model_validate(
|
|
|
|
|
|
|
| 42 |
if "reward" in payload:
|
| 43 |
observation.reward = payload["reward"]
|
| 44 |
if "done" in payload:
|
|
@@ -51,7 +57,9 @@ class CodeReviewEnv:
|
|
| 51 |
response = await client.post("/step", json={"action": action.model_dump()})
|
| 52 |
response.raise_for_status()
|
| 53 |
payload = response.json()
|
| 54 |
-
observation = CodeReviewObservation.model_validate(
|
|
|
|
|
|
|
| 55 |
if "reward" in payload:
|
| 56 |
observation.reward = payload["reward"]
|
| 57 |
if "done" in payload:
|
|
@@ -68,7 +76,9 @@ class CodeReviewEnv:
|
|
| 68 |
task_id=self._last_observation.task_id,
|
| 69 |
difficulty=self._last_observation.difficulty,
|
| 70 |
title=self._last_observation.title,
|
| 71 |
-
opened_artifact_ids=list(
|
|
|
|
|
|
|
| 72 |
cumulative_reward=0.0,
|
| 73 |
score=self._last_observation.score,
|
| 74 |
last_action_error=self._last_observation.last_action_error,
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
+
from typing import Any, Optional
|
| 5 |
|
| 6 |
import httpx
|
| 7 |
|
| 8 |
+
from codereview_env.models import (
|
| 9 |
+
CodeReviewAction,
|
| 10 |
+
CodeReviewObservation,
|
| 11 |
+
CodeReviewState,
|
| 12 |
+
)
|
| 13 |
|
| 14 |
|
| 15 |
class SyncCodeReviewEnv:
|
|
|
|
| 42 |
response = await client.post("/reset", json=kwargs or {})
|
| 43 |
response.raise_for_status()
|
| 44 |
payload = response.json()
|
| 45 |
+
observation = CodeReviewObservation.model_validate(
|
| 46 |
+
payload.get("observation", payload)
|
| 47 |
+
)
|
| 48 |
if "reward" in payload:
|
| 49 |
observation.reward = payload["reward"]
|
| 50 |
if "done" in payload:
|
|
|
|
| 57 |
response = await client.post("/step", json={"action": action.model_dump()})
|
| 58 |
response.raise_for_status()
|
| 59 |
payload = response.json()
|
| 60 |
+
observation = CodeReviewObservation.model_validate(
|
| 61 |
+
payload.get("observation", payload)
|
| 62 |
+
)
|
| 63 |
if "reward" in payload:
|
| 64 |
observation.reward = payload["reward"]
|
| 65 |
if "done" in payload:
|
|
|
|
| 76 |
task_id=self._last_observation.task_id,
|
| 77 |
difficulty=self._last_observation.difficulty,
|
| 78 |
title=self._last_observation.title,
|
| 79 |
+
opened_artifact_ids=list(
|
| 80 |
+
self._last_observation.metadata.get("opened_artifact_ids", [])
|
| 81 |
+
),
|
| 82 |
cumulative_reward=0.0,
|
| 83 |
score=self._last_observation.score,
|
| 84 |
last_action_error=self._last_observation.last_action_error,
|
codereview_env/client.py
CHANGED
|
@@ -5,7 +5,11 @@ from typing import Any, Dict, Optional
|
|
| 5 |
|
| 6 |
import httpx
|
| 7 |
|
| 8 |
-
from codereview_env.models import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
class SyncCodeReviewEnv:
|
|
@@ -40,7 +44,9 @@ class CodeReviewEnv:
|
|
| 40 |
response.raise_for_status()
|
| 41 |
payload = response.json()
|
| 42 |
self._session_id = payload.get("session_id")
|
| 43 |
-
observation = CodeReviewObservation.model_validate(
|
|
|
|
|
|
|
| 44 |
if "reward" in payload:
|
| 45 |
observation.reward = payload["reward"]
|
| 46 |
if "done" in payload:
|
|
@@ -57,7 +63,9 @@ class CodeReviewEnv:
|
|
| 57 |
response.raise_for_status()
|
| 58 |
payload = response.json()
|
| 59 |
self._session_id = payload.get("session_id", self._session_id)
|
| 60 |
-
observation = CodeReviewObservation.model_validate(
|
|
|
|
|
|
|
| 61 |
if "reward" in payload:
|
| 62 |
observation.reward = payload["reward"]
|
| 63 |
if "done" in payload:
|
|
@@ -79,7 +87,9 @@ class CodeReviewEnv:
|
|
| 79 |
task_id=self._last_observation.task_id,
|
| 80 |
difficulty=self._last_observation.difficulty,
|
| 81 |
title=self._last_observation.title,
|
| 82 |
-
opened_artifact_ids=list(
|
|
|
|
|
|
|
| 83 |
cumulative_reward=0.0,
|
| 84 |
score=self._last_observation.score,
|
| 85 |
last_action_error=self._last_observation.last_action_error,
|
|
|
|
| 5 |
|
| 6 |
import httpx
|
| 7 |
|
| 8 |
+
from codereview_env.models import (
|
| 9 |
+
CodeReviewAction,
|
| 10 |
+
CodeReviewObservation,
|
| 11 |
+
CodeReviewState,
|
| 12 |
+
)
|
| 13 |
|
| 14 |
|
| 15 |
class SyncCodeReviewEnv:
|
|
|
|
| 44 |
response.raise_for_status()
|
| 45 |
payload = response.json()
|
| 46 |
self._session_id = payload.get("session_id")
|
| 47 |
+
observation = CodeReviewObservation.model_validate(
|
| 48 |
+
payload.get("observation", payload)
|
| 49 |
+
)
|
| 50 |
if "reward" in payload:
|
| 51 |
observation.reward = payload["reward"]
|
| 52 |
if "done" in payload:
|
|
|
|
| 63 |
response.raise_for_status()
|
| 64 |
payload = response.json()
|
| 65 |
self._session_id = payload.get("session_id", self._session_id)
|
| 66 |
+
observation = CodeReviewObservation.model_validate(
|
| 67 |
+
payload.get("observation", payload)
|
| 68 |
+
)
|
| 69 |
if "reward" in payload:
|
| 70 |
observation.reward = payload["reward"]
|
| 71 |
if "done" in payload:
|
|
|
|
| 87 |
task_id=self._last_observation.task_id,
|
| 88 |
difficulty=self._last_observation.difficulty,
|
| 89 |
title=self._last_observation.title,
|
| 90 |
+
opened_artifact_ids=list(
|
| 91 |
+
self._last_observation.metadata.get("opened_artifact_ids", [])
|
| 92 |
+
),
|
| 93 |
cumulative_reward=0.0,
|
| 94 |
score=self._last_observation.score,
|
| 95 |
last_action_error=self._last_observation.last_action_error,
|
codereview_env/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class PaginationConfig(BaseModel):
|
| 5 |
+
MIN_PAGE: int = 1
|
| 6 |
+
MAX_PAGE_SIZE: int = 100
|
| 7 |
+
DEFAULT_PAGE_SIZE: int = 10
|
| 8 |
+
ERROR_INVALID_PAGE: str = "Page number must be >= {min_page}"
|
| 9 |
+
ERROR_INVALID_SIZE: str = "Page size must be between 1 and {max_size}"
|
| 10 |
+
ERROR_NOT_A_LIST: str = "Items must be a list"
|
| 11 |
+
ERROR_NOT_NUMERIC: str = "Page and PageSize must be numeric"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RewardConfig(BaseModel):
|
| 15 |
+
MIN_REWARD: float = 0.0
|
| 16 |
+
MAX_REWARD: float = 1.0
|
| 17 |
+
DECIMAL_PRECISION: int = 4
|
| 18 |
+
BONUS_COEFFICIENT: float = 0.75
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class SafetyConfig(BaseModel):
|
| 22 |
+
pagination: PaginationConfig = PaginationConfig()
|
| 23 |
+
reward: RewardConfig = RewardConfig()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Centralized configuration singleton
|
| 27 |
+
CONFIG = SafetyConfig()
|
codereview_env/models.py
CHANGED
|
@@ -69,7 +69,9 @@ class CodeReviewObservation(Observation):
|
|
| 69 |
# Step-level signals (populated by environment)
|
| 70 |
done: bool = Field(default=False, description="Whether the episode has ended.")
|
| 71 |
reward: float = Field(default=0.0, description="Reward earned on this step.")
|
| 72 |
-
metadata: Dict[str, Any] = Field(
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
class CodeReviewState(State):
|
|
|
|
| 69 |
# Step-level signals (populated by environment)
|
| 70 |
done: bool = Field(default=False, description="Whether the episode has ended.")
|
| 71 |
reward: float = Field(default=0.0, description="Reward earned on this step.")
|
| 72 |
+
metadata: Dict[str, Any] = Field(
|
| 73 |
+
default_factory=dict, description="Extra per-step metadata."
|
| 74 |
+
)
|
| 75 |
|
| 76 |
|
| 77 |
class CodeReviewState(State):
|
codereview_env/safety.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, List, Optional
|
| 2 |
+
from codereview_env.config import CONFIG
|
| 3 |
+
from utils.pagination import get_paged_items
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class PaginationValidator:
|
| 7 |
+
"""
|
| 8 |
+
Validation layer for pagination inputs.
|
| 9 |
+
Strictly handles edge cases and enforced constraints.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
def validate_inputs(items: List[Any], page: Any, page_size: Any) -> tuple[int, int]:
|
| 14 |
+
"""
|
| 15 |
+
Validates types and ranges for pagination.
|
| 16 |
+
|
| 17 |
+
Formula:
|
| 18 |
+
min_page = CONFIG.pagination.MIN_PAGE
|
| 19 |
+
max_size = CONFIG.pagination.MAX_PAGE_SIZE
|
| 20 |
+
"""
|
| 21 |
+
# Rule 3.1: Wrong types
|
| 22 |
+
if not isinstance(items, list):
|
| 23 |
+
raise TypeError(CONFIG.pagination.ERROR_NOT_A_LIST)
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
p = int(page)
|
| 27 |
+
ps = int(page_size)
|
| 28 |
+
except (ValueError, TypeError):
|
| 29 |
+
raise TypeError(CONFIG.pagination.ERROR_NOT_NUMERIC)
|
| 30 |
+
|
| 31 |
+
# Rule 3.1: Null/Zero/Negative
|
| 32 |
+
conf = CONFIG.pagination
|
| 33 |
+
if p < conf.MIN_PAGE:
|
| 34 |
+
msg = conf.ERROR_INVALID_PAGE.format(min_page=conf.MIN_PAGE)
|
| 35 |
+
raise ValueError(msg)
|
| 36 |
+
|
| 37 |
+
if ps < conf.MIN_PAGE or ps > conf.MAX_PAGE_SIZE:
|
| 38 |
+
msg = conf.ERROR_INVALID_SIZE.format(max_size=conf.MAX_PAGE_SIZE)
|
| 39 |
+
raise ValueError(msg)
|
| 40 |
+
|
| 41 |
+
return p, ps
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class SafePaginationFacade:
|
| 45 |
+
"""
|
| 46 |
+
Facade pattern providing a safe entry point to the pagination system.
|
| 47 |
+
Complies with Rule 1: Zero modifications to existing code.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(self, items: List[Any]):
|
| 51 |
+
self._items = items
|
| 52 |
+
self._length = len(items)
|
| 53 |
+
|
| 54 |
+
def get_page(self, page: int, page_size: Optional[int] = None) -> List[Any]:
|
| 55 |
+
"""
|
| 56 |
+
Safely retrieves a page of items.
|
| 57 |
+
Calls the underlying black-box system after thorough validation.
|
| 58 |
+
"""
|
| 59 |
+
ps = page_size if page_size is not None else CONFIG.pagination.DEFAULT_PAGE_SIZE
|
| 60 |
+
|
| 61 |
+
# Validation Layer
|
| 62 |
+
clean_page, clean_ps = PaginationValidator.validate_inputs(
|
| 63 |
+
self._items, page, ps
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Rule 3.1: Handle empty list or out-of-bounds gracefully before calling existing
|
| 67 |
+
if not self._items:
|
| 68 |
+
return []
|
| 69 |
+
|
| 70 |
+
# Existing System Call
|
| 71 |
+
return get_paged_items(self._items, clean_page, clean_ps)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class SafeRewardCalculator:
|
| 75 |
+
"""
|
| 76 |
+
Wrapper for reward calculations to ensure mathematical accuracy.
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
@staticmethod
|
| 80 |
+
def calculate_final_reward(score: float, bonus: float) -> float:
|
| 81 |
+
"""
|
| 82 |
+
Mathematically correct reward summation with final-step rounding.
|
| 83 |
+
|
| 84 |
+
Boundary Math:
|
| 85 |
+
score [0.0, 1.0], bonus [0.0, 0.25]
|
| 86 |
+
Return range: [0.0, 1.0]
|
| 87 |
+
"""
|
| 88 |
+
conf = CONFIG.reward
|
| 89 |
+
|
| 90 |
+
# Intermediate calculations (full precision)
|
| 91 |
+
# Using raw floats for calculation per Rule 3.5
|
| 92 |
+
total = (score * conf.BONUS_COEFFICIENT) + bonus
|
| 93 |
+
|
| 94 |
+
# Clipping to safety boundaries
|
| 95 |
+
safe_total = max(conf.MIN_REWARD, min(conf.MAX_REWARD, total))
|
| 96 |
+
|
| 97 |
+
# Rule 3.5: Round floating-point numbers ONLY at the final output step
|
| 98 |
+
return round(safe_total, conf.DECIMAL_PRECISION)
|
environment.py
CHANGED
|
@@ -45,7 +45,10 @@ class CodeReviewEnvironment(
|
|
| 45 |
}
|
| 46 |
|
| 47 |
def reset(
|
| 48 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 49 |
) -> CodeReviewObservation:
|
| 50 |
task_id = kwargs.get("task_id")
|
| 51 |
if task_id:
|
|
@@ -88,16 +91,22 @@ class CodeReviewEnvironment(
|
|
| 88 |
reward_breakdown = self._handle_submit(action)
|
| 89 |
done = True
|
| 90 |
else:
|
| 91 |
-
reward_breakdown = self.reward_computer.invalid_action(
|
|
|
|
|
|
|
| 92 |
done = False
|
| 93 |
|
| 94 |
if self.step_count >= self._step_limit and not done:
|
| 95 |
-
self._recent_events.append(
|
|
|
|
|
|
|
| 96 |
done = True
|
| 97 |
|
| 98 |
self._episode_done = done
|
| 99 |
self._last_action_error = reward_breakdown.last_action_error
|
| 100 |
-
self._cumulative_reward = max(
|
|
|
|
|
|
|
| 101 |
self._recent_events.append(
|
| 102 |
f"Step {self.step_count}: {action.action_type} -> reward {reward_breakdown.reward:.2f}"
|
| 103 |
)
|
|
@@ -133,15 +142,21 @@ class CodeReviewEnvironment(
|
|
| 133 |
def _handle_open_artifact(self, action: CodeReviewAction) -> RewardBreakdown:
|
| 134 |
artifact_id = action.artifact_id
|
| 135 |
if not artifact_id:
|
| 136 |
-
return self.reward_computer.invalid_action(
|
|
|
|
|
|
|
| 137 |
if artifact_id not in self.task.artifacts:
|
| 138 |
-
return self.reward_computer.invalid_action(
|
|
|
|
|
|
|
| 139 |
|
| 140 |
repeated = artifact_id in self._opened_artifact_ids
|
| 141 |
self._opened_artifact_ids.add(artifact_id)
|
| 142 |
artifact = self.task.artifacts[artifact_id]
|
| 143 |
self._recent_events.append(f"Opened {artifact.title}.")
|
| 144 |
-
return self.reward_computer.artifact_reward(
|
|
|
|
|
|
|
| 145 |
|
| 146 |
def _handle_submit(self, action: CodeReviewAction) -> RewardBreakdown:
|
| 147 |
self._submitted_findings = list(action.findings)
|
|
@@ -172,7 +187,11 @@ class CodeReviewEnvironment(
|
|
| 172 |
title=artifact.title,
|
| 173 |
preview=artifact.preview,
|
| 174 |
opened=artifact_id in self._opened_artifact_ids,
|
| 175 |
-
content=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
)
|
| 177 |
available_artifacts.append(model)
|
| 178 |
if model.opened:
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
def reset(
|
| 48 |
+
self,
|
| 49 |
+
seed: Optional[int] = None,
|
| 50 |
+
episode_id: Optional[str] = None,
|
| 51 |
+
**kwargs: Any,
|
| 52 |
) -> CodeReviewObservation:
|
| 53 |
task_id = kwargs.get("task_id")
|
| 54 |
if task_id:
|
|
|
|
| 91 |
reward_breakdown = self._handle_submit(action)
|
| 92 |
done = True
|
| 93 |
else:
|
| 94 |
+
reward_breakdown = self.reward_computer.invalid_action(
|
| 95 |
+
"Unsupported action_type."
|
| 96 |
+
)
|
| 97 |
done = False
|
| 98 |
|
| 99 |
if self.step_count >= self._step_limit and not done:
|
| 100 |
+
self._recent_events.append(
|
| 101 |
+
"Step limit reached before review was submitted."
|
| 102 |
+
)
|
| 103 |
done = True
|
| 104 |
|
| 105 |
self._episode_done = done
|
| 106 |
self._last_action_error = reward_breakdown.last_action_error
|
| 107 |
+
self._cumulative_reward = max(
|
| 108 |
+
0.0, min(1.0, self._cumulative_reward + reward_breakdown.reward)
|
| 109 |
+
)
|
| 110 |
self._recent_events.append(
|
| 111 |
f"Step {self.step_count}: {action.action_type} -> reward {reward_breakdown.reward:.2f}"
|
| 112 |
)
|
|
|
|
| 142 |
def _handle_open_artifact(self, action: CodeReviewAction) -> RewardBreakdown:
|
| 143 |
artifact_id = action.artifact_id
|
| 144 |
if not artifact_id:
|
| 145 |
+
return self.reward_computer.invalid_action(
|
| 146 |
+
"artifact_id is required for open_artifact."
|
| 147 |
+
)
|
| 148 |
if artifact_id not in self.task.artifacts:
|
| 149 |
+
return self.reward_computer.invalid_action(
|
| 150 |
+
f"Unknown artifact_id: {artifact_id}"
|
| 151 |
+
)
|
| 152 |
|
| 153 |
repeated = artifact_id in self._opened_artifact_ids
|
| 154 |
self._opened_artifact_ids.add(artifact_id)
|
| 155 |
artifact = self.task.artifacts[artifact_id]
|
| 156 |
self._recent_events.append(f"Opened {artifact.title}.")
|
| 157 |
+
return self.reward_computer.artifact_reward(
|
| 158 |
+
self.task, artifact_id, self._opened_artifact_ids, repeated
|
| 159 |
+
)
|
| 160 |
|
| 161 |
def _handle_submit(self, action: CodeReviewAction) -> RewardBreakdown:
|
| 162 |
self._submitted_findings = list(action.findings)
|
|
|
|
| 187 |
title=artifact.title,
|
| 188 |
preview=artifact.preview,
|
| 189 |
opened=artifact_id in self._opened_artifact_ids,
|
| 190 |
+
content=(
|
| 191 |
+
artifact.content
|
| 192 |
+
if artifact_id in self._opened_artifact_ids
|
| 193 |
+
else None
|
| 194 |
+
),
|
| 195 |
)
|
| 196 |
available_artifacts.append(model)
|
| 197 |
if model.opened:
|
examples/run_basic_agent.py
CHANGED
|
@@ -13,6 +13,7 @@ import asyncio
|
|
| 13 |
from codereview_env.client import CodeReviewEnv
|
| 14 |
from codereview_env.models import CodeReviewAction
|
| 15 |
|
|
|
|
| 16 |
async def main():
|
| 17 |
print("============================================================")
|
| 18 |
print(" π€ Welcome to CodeReview-Env Basic Agent Run ")
|
|
@@ -20,7 +21,7 @@ async def main():
|
|
| 20 |
print(" diff, and submit a hardcoded actionable review.")
|
| 21 |
print("============================================================\n")
|
| 22 |
|
| 23 |
-
port = os.getenv("PORT", "8000")
|
| 24 |
base_url = f"http://localhost:{port}"
|
| 25 |
print(f"[*] Connecting to Environment Server at {base_url}...")
|
| 26 |
|
|
@@ -31,31 +32,40 @@ async def main():
|
|
| 31 |
print(f" Loaded file: {obs.filename} ({obs.language})")
|
| 32 |
print(" PR Diff Snippet:")
|
| 33 |
print("------------------------------------------------------------")
|
| 34 |
-
print(
|
|
|
|
|
|
|
| 35 |
print("------------------------------------------------------------\n")
|
| 36 |
|
| 37 |
hardcoded_review = (
|
| 38 |
"Line 3: There's an off-by-one error here. The loop should use < len(items) "
|
| 39 |
"instead of <= len(items). Consider using enumerate() for cleaner iteration."
|
| 40 |
)
|
| 41 |
-
print(f
|
| 42 |
|
| 43 |
action = CodeReviewAction(
|
| 44 |
-
review_comment=hardcoded_review,
|
| 45 |
-
severity="major",
|
| 46 |
-
line_references=[3]
|
| 47 |
)
|
| 48 |
|
| 49 |
result = await env.step(action)
|
| 50 |
-
|
| 51 |
# The result could be a StepResult or mapping depending on OpenEnv integration
|
| 52 |
-
info =
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
breakdown = info.get("reward_breakdown", {})
|
| 55 |
checks = breakdown.get("checks", {})
|
| 56 |
llm = breakdown.get("llm_scores", {})
|
| 57 |
|
| 58 |
-
def tick(val):
|
|
|
|
| 59 |
|
| 60 |
table = f"""
|
| 61 |
βββββββββββββββββββββββββββββββ¬βββββββββ
|
|
@@ -80,5 +90,6 @@ async def main():
|
|
| 80 |
print(f"Error communicating with environment: {e}")
|
| 81 |
print("Make sure your API server is running (uvicorn server.app:app)")
|
| 82 |
|
|
|
|
| 83 |
if __name__ == "__main__":
|
| 84 |
asyncio.run(main())
|
|
|
|
| 13 |
from codereview_env.client import CodeReviewEnv
|
| 14 |
from codereview_env.models import CodeReviewAction
|
| 15 |
|
| 16 |
+
|
| 17 |
async def main():
|
| 18 |
print("============================================================")
|
| 19 |
print(" π€ Welcome to CodeReview-Env Basic Agent Run ")
|
|
|
|
| 21 |
print(" diff, and submit a hardcoded actionable review.")
|
| 22 |
print("============================================================\n")
|
| 23 |
|
| 24 |
+
port = os.getenv("PORT", "8000") # Use 7860 if running via docker mapping
|
| 25 |
base_url = f"http://localhost:{port}"
|
| 26 |
print(f"[*] Connecting to Environment Server at {base_url}...")
|
| 27 |
|
|
|
|
| 32 |
print(f" Loaded file: {obs.filename} ({obs.language})")
|
| 33 |
print(" PR Diff Snippet:")
|
| 34 |
print("------------------------------------------------------------")
|
| 35 |
+
print(
|
| 36 |
+
obs.pr_diff[:300] + "\n..." if len(obs.pr_diff) > 300 else obs.pr_diff
|
| 37 |
+
)
|
| 38 |
print("------------------------------------------------------------\n")
|
| 39 |
|
| 40 |
hardcoded_review = (
|
| 41 |
"Line 3: There's an off-by-one error here. The loop should use < len(items) "
|
| 42 |
"instead of <= len(items). Consider using enumerate() for cleaner iteration."
|
| 43 |
)
|
| 44 |
+
print(f'[*] Submitting Review:\n "{hardcoded_review}"\n')
|
| 45 |
|
| 46 |
action = CodeReviewAction(
|
| 47 |
+
review_comment=hardcoded_review, severity="major", line_references=[3]
|
|
|
|
|
|
|
| 48 |
)
|
| 49 |
|
| 50 |
result = await env.step(action)
|
| 51 |
+
|
| 52 |
# The result could be a StepResult or mapping depending on OpenEnv integration
|
| 53 |
+
info = (
|
| 54 |
+
result.info
|
| 55 |
+
if hasattr(result, "info")
|
| 56 |
+
else result.get("info", {}) if isinstance(result, dict) else {}
|
| 57 |
+
)
|
| 58 |
+
reward = float(
|
| 59 |
+
result.reward
|
| 60 |
+
if hasattr(result, "reward")
|
| 61 |
+
else result.get("reward", 0.0) if hasattr(result, "get") else 0.0
|
| 62 |
+
)
|
| 63 |
breakdown = info.get("reward_breakdown", {})
|
| 64 |
checks = breakdown.get("checks", {})
|
| 65 |
llm = breakdown.get("llm_scores", {})
|
| 66 |
|
| 67 |
+
def tick(val):
|
| 68 |
+
return "β
" if val else "β"
|
| 69 |
|
| 70 |
table = f"""
|
| 71 |
βββββββββββββββββββββββββββββββ¬βββββββββ
|
|
|
|
| 90 |
print(f"Error communicating with environment: {e}")
|
| 91 |
print("Make sure your API server is running (uvicorn server.app:app)")
|
| 92 |
|
| 93 |
+
|
| 94 |
if __name__ == "__main__":
|
| 95 |
asyncio.run(main())
|
examples/run_benchmark.py
CHANGED
|
@@ -16,9 +16,13 @@ from codereview_env.models import CodeReviewAction
|
|
| 16 |
TYPES = [
|
| 17 |
{"name": "Generic (bad)", "text": "LGTM looks good!"},
|
| 18 |
{"name": "Medium", "text": "There might be an issue here. Consider fixing it."},
|
| 19 |
-
{
|
|
|
|
|
|
|
|
|
|
| 20 |
]
|
| 21 |
|
|
|
|
| 22 |
async def main():
|
| 23 |
print("============================================================")
|
| 24 |
print(" π CodeReview-Env Dynamic Benchmarking ")
|
|
@@ -27,12 +31,8 @@ async def main():
|
|
| 27 |
|
| 28 |
port = os.getenv("PORT", "8000")
|
| 29 |
base_url = f"http://localhost:{port}"
|
| 30 |
-
|
| 31 |
-
results = {
|
| 32 |
-
"Generic (bad)": [],
|
| 33 |
-
"Medium": [],
|
| 34 |
-
"Specific (good)": []
|
| 35 |
-
}
|
| 36 |
|
| 37 |
async with CodeReviewEnv(base_url=base_url) as env:
|
| 38 |
for t in TYPES:
|
|
@@ -40,25 +40,31 @@ async def main():
|
|
| 40 |
for i in range(10):
|
| 41 |
try:
|
| 42 |
await env.reset()
|
| 43 |
-
action = CodeReviewAction(
|
|
|
|
|
|
|
| 44 |
result = await env.step(action)
|
| 45 |
-
rew = float(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
results[t["name"]].append(rew)
|
| 47 |
except Exception as e:
|
| 48 |
-
print(
|
|
|
|
|
|
|
| 49 |
break
|
| 50 |
-
|
| 51 |
# Calculate statistics
|
| 52 |
stats = {}
|
| 53 |
for k, v in results.items():
|
| 54 |
if len(v) == 0:
|
| 55 |
stats[k] = (0.0, 0.0, 0.0)
|
| 56 |
continue
|
| 57 |
-
stats[k] = (
|
| 58 |
-
sum(v) / len(v), # Avg
|
| 59 |
-
min(v), # Min
|
| 60 |
-
max(v) # Max
|
| 61 |
-
)
|
| 62 |
|
| 63 |
# Output Table
|
| 64 |
print("\nββββββββββββββββββ¬βββββββββββββββ¬βββββββββββββββ¬βββββββββββββββ")
|
|
@@ -68,5 +74,6 @@ async def main():
|
|
| 68 |
print(f"β {k:<14} β {avg:.2f} β {mn:.2f} β {mx:.2f} β")
|
| 69 |
print("ββββββββββββββββββ΄βββββββββββββββ΄βββββββββββββββ΄βββββββββββββββ\n")
|
| 70 |
|
|
|
|
| 71 |
if __name__ == "__main__":
|
| 72 |
asyncio.run(main())
|
|
|
|
| 16 |
TYPES = [
|
| 17 |
{"name": "Generic (bad)", "text": "LGTM looks good!"},
|
| 18 |
{"name": "Medium", "text": "There might be an issue here. Consider fixing it."},
|
| 19 |
+
{
|
| 20 |
+
"name": "Specific (good)",
|
| 21 |
+
"text": "Line 3: Critical bug β The indexing is exceeding array bounds causing a runtime error. Switch `>` to `>=` to patch it safely.",
|
| 22 |
+
},
|
| 23 |
]
|
| 24 |
|
| 25 |
+
|
| 26 |
async def main():
|
| 27 |
print("============================================================")
|
| 28 |
print(" π CodeReview-Env Dynamic Benchmarking ")
|
|
|
|
| 31 |
|
| 32 |
port = os.getenv("PORT", "8000")
|
| 33 |
base_url = f"http://localhost:{port}"
|
| 34 |
+
|
| 35 |
+
results = {"Generic (bad)": [], "Medium": [], "Specific (good)": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
async with CodeReviewEnv(base_url=base_url) as env:
|
| 38 |
for t in TYPES:
|
|
|
|
| 40 |
for i in range(10):
|
| 41 |
try:
|
| 42 |
await env.reset()
|
| 43 |
+
action = CodeReviewAction(
|
| 44 |
+
review_comment=t["text"], severity="major"
|
| 45 |
+
)
|
| 46 |
result = await env.step(action)
|
| 47 |
+
rew = float(
|
| 48 |
+
result.reward
|
| 49 |
+
if hasattr(result, "reward")
|
| 50 |
+
else (
|
| 51 |
+
result.get("reward", 0.0) if hasattr(result, "get") else 0.0
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
results[t["name"]].append(rew)
|
| 55 |
except Exception as e:
|
| 56 |
+
print(
|
| 57 |
+
f"Error on iteration {i}: {e}. Ensure API relies on localhost:{port}"
|
| 58 |
+
)
|
| 59 |
break
|
| 60 |
+
|
| 61 |
# Calculate statistics
|
| 62 |
stats = {}
|
| 63 |
for k, v in results.items():
|
| 64 |
if len(v) == 0:
|
| 65 |
stats[k] = (0.0, 0.0, 0.0)
|
| 66 |
continue
|
| 67 |
+
stats[k] = (sum(v) / len(v), min(v), max(v)) # Avg # Min # Max
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
# Output Table
|
| 70 |
print("\nββββββββββββββββββ¬βββββββββββββββ¬βββββββββββββββ¬βββββββββββββββ")
|
|
|
|
| 74 |
print(f"β {k:<14} β {avg:.2f} β {mn:.2f} β {mx:.2f} β")
|
| 75 |
print("ββββββββββββββββββ΄βββββββββββββββ΄βββββββββββββββ΄βββββββββββββββ\n")
|
| 76 |
|
| 77 |
+
|
| 78 |
if __name__ == "__main__":
|
| 79 |
asyncio.run(main())
|
examples/run_grpo_training.py
CHANGED
|
@@ -12,12 +12,13 @@ pip install trl accelerate
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import os
|
| 15 |
-
|
|
|
|
| 16 |
# mapping dataset inputs over the reward output. TRL makes this seamless with the RewardAPI.
|
| 17 |
-
import torch
|
| 18 |
try:
|
| 19 |
-
from trl import GRPOTrainer, GRPOConfig
|
| 20 |
-
from transformers import AutoTokenizer, AutoModelForCausalLM
|
|
|
|
| 21 |
TRL_AVAILABLE = True
|
| 22 |
except ImportError:
|
| 23 |
TRL_AVAILABLE = False
|
|
@@ -25,6 +26,7 @@ except ImportError:
|
|
| 25 |
|
| 26 |
from codereview_env.client import CodeReviewEnv
|
| 27 |
|
|
|
|
| 28 |
def main():
|
| 29 |
print("============================================================")
|
| 30 |
print(" π CodeReview-Env TRL GRPO Training Setup Example ")
|
|
@@ -40,7 +42,7 @@ def main():
|
|
| 40 |
# We instantiate our synchronous environment:
|
| 41 |
port = os.getenv("PORT", "8000")
|
| 42 |
env = CodeReviewEnv(base_url=f"http://localhost:{port}").sync()
|
| 43 |
-
|
| 44 |
def openenv_reward_function(completions, prompts, **kwargs):
|
| 45 |
"""
|
| 46 |
TRL passes the generated completions and the source prompts.
|
|
@@ -50,20 +52,20 @@ def main():
|
|
| 50 |
for prompt, completion in zip(prompts, completions):
|
| 51 |
# Evaluate the single completion against the mocked API diff prompt
|
| 52 |
# (Assuming prompt contains the diff structure)
|
| 53 |
-
|
| 54 |
res = env.get_reward_breakdown(completion)
|
| 55 |
rewards.append(res.get("total_reward", 0.0))
|
| 56 |
return rewards
|
| 57 |
|
| 58 |
# 2. Setup your training configuration
|
| 59 |
print("[*] Initializing GRPOConfig parameters...")
|
| 60 |
-
|
| 61 |
output_dir="codereview-model",
|
| 62 |
learning_rate=1e-5,
|
| 63 |
per_device_train_batch_size=4,
|
| 64 |
gradient_accumulation_steps=2,
|
| 65 |
num_train_epochs=3,
|
| 66 |
-
logging_steps=10
|
| 67 |
)
|
| 68 |
|
| 69 |
# 3. Load Agent Model (e.g. Qwen or LLaMa-3 lightweight adapter)
|
|
@@ -73,7 +75,7 @@ def main():
|
|
| 73 |
# model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16)
|
| 74 |
|
| 75 |
# 4. Integrate Dataset
|
| 76 |
-
# TRL accepts Huggingface mapped datasets directly.
|
| 77 |
# train_dataset = load_dataset("microsoft/CodeReviewer", split="train[:1000]")
|
| 78 |
|
| 79 |
# 5. Initialize & Train
|
|
@@ -85,8 +87,9 @@ def main():
|
|
| 85 |
# train_dataset=train_dataset
|
| 86 |
# )
|
| 87 |
# trainer.train()
|
| 88 |
-
|
| 89 |
print("\nβ
Setup complete! Uncomment model initialization and dataset to run.")
|
| 90 |
|
|
|
|
| 91 |
if __name__ == "__main__":
|
| 92 |
main()
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import os
|
| 15 |
+
|
| 16 |
+
# We provide a boilerplate structural example. Standard GRPO involves the environment
|
| 17 |
# mapping dataset inputs over the reward output. TRL makes this seamless with the RewardAPI.
|
|
|
|
| 18 |
try:
|
| 19 |
+
from trl import GRPOTrainer, GRPOConfig # noqa: F401
|
| 20 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM # noqa: F401
|
| 21 |
+
|
| 22 |
TRL_AVAILABLE = True
|
| 23 |
except ImportError:
|
| 24 |
TRL_AVAILABLE = False
|
|
|
|
| 26 |
|
| 27 |
from codereview_env.client import CodeReviewEnv
|
| 28 |
|
| 29 |
+
|
| 30 |
def main():
|
| 31 |
print("============================================================")
|
| 32 |
print(" π CodeReview-Env TRL GRPO Training Setup Example ")
|
|
|
|
| 42 |
# We instantiate our synchronous environment:
|
| 43 |
port = os.getenv("PORT", "8000")
|
| 44 |
env = CodeReviewEnv(base_url=f"http://localhost:{port}").sync()
|
| 45 |
+
|
| 46 |
def openenv_reward_function(completions, prompts, **kwargs):
|
| 47 |
"""
|
| 48 |
TRL passes the generated completions and the source prompts.
|
|
|
|
| 52 |
for prompt, completion in zip(prompts, completions):
|
| 53 |
# Evaluate the single completion against the mocked API diff prompt
|
| 54 |
# (Assuming prompt contains the diff structure)
|
| 55 |
+
(prompt.split("=== PR DIFF ===")[-1] if "=== PR DIFF ===" in prompt else "")
|
| 56 |
res = env.get_reward_breakdown(completion)
|
| 57 |
rewards.append(res.get("total_reward", 0.0))
|
| 58 |
return rewards
|
| 59 |
|
| 60 |
# 2. Setup your training configuration
|
| 61 |
print("[*] Initializing GRPOConfig parameters...")
|
| 62 |
+
GRPOConfig(
|
| 63 |
output_dir="codereview-model",
|
| 64 |
learning_rate=1e-5,
|
| 65 |
per_device_train_batch_size=4,
|
| 66 |
gradient_accumulation_steps=2,
|
| 67 |
num_train_epochs=3,
|
| 68 |
+
logging_steps=10,
|
| 69 |
)
|
| 70 |
|
| 71 |
# 3. Load Agent Model (e.g. Qwen or LLaMa-3 lightweight adapter)
|
|
|
|
| 75 |
# model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16)
|
| 76 |
|
| 77 |
# 4. Integrate Dataset
|
| 78 |
+
# TRL accepts Huggingface mapped datasets directly.
|
| 79 |
# train_dataset = load_dataset("microsoft/CodeReviewer", split="train[:1000]")
|
| 80 |
|
| 81 |
# 5. Initialize & Train
|
|
|
|
| 87 |
# train_dataset=train_dataset
|
| 88 |
# )
|
| 89 |
# trainer.train()
|
| 90 |
+
|
| 91 |
print("\nβ
Setup complete! Uncomment model initialization and dataset to run.")
|
| 92 |
|
| 93 |
+
|
| 94 |
if __name__ == "__main__":
|
| 95 |
main()
|
inference.py
CHANGED
|
@@ -10,7 +10,6 @@ from codereview_env.models import CodeReviewAction
|
|
| 10 |
from server.environment import CodeReviewEnvironment
|
| 11 |
from server.tasks import TASKS
|
| 12 |
|
| 13 |
-
|
| 14 |
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 15 |
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 16 |
API_KEY = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY") or os.getenv("API_KEY")
|
|
@@ -50,7 +49,11 @@ def _observation_to_prompt(observation: Dict[str, Any]) -> str:
|
|
| 50 |
def _scripted_policy(task_id: str, opened_ids: List[str]) -> Dict[str, Any]:
|
| 51 |
plans = {
|
| 52 |
"pagination-regression": [
|
| 53 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
{
|
| 55 |
"action_type": "submit_review",
|
| 56 |
"findings": [
|
|
@@ -67,8 +70,16 @@ def _scripted_policy(task_id: str, opened_ids: List[str]) -> Dict[str, Any]:
|
|
| 67 |
},
|
| 68 |
],
|
| 69 |
"tenant-export-auth": [
|
| 70 |
-
{
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
{
|
| 73 |
"action_type": "submit_review",
|
| 74 |
"findings": [
|
|
@@ -85,10 +96,26 @@ def _scripted_policy(task_id: str, opened_ids: List[str]) -> Dict[str, Any]:
|
|
| 85 |
},
|
| 86 |
],
|
| 87 |
"refund-idempotency": [
|
| 88 |
-
{
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
{
|
| 93 |
"action_type": "submit_review",
|
| 94 |
"findings": [
|
|
@@ -108,7 +135,11 @@ def _scripted_policy(task_id: str, opened_ids: List[str]) -> Dict[str, Any]:
|
|
| 108 |
plan = plans[task_id]
|
| 109 |
if not opened_ids:
|
| 110 |
return plan[0]
|
| 111 |
-
open_count = sum(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
return plan[min(open_count, len(plan) - 1)]
|
| 113 |
|
| 114 |
|
|
@@ -145,8 +176,14 @@ def main() -> None:
|
|
| 145 |
try:
|
| 146 |
while steps < MAX_STEPS and not observation.done:
|
| 147 |
obs_dict = observation.model_dump()
|
| 148 |
-
opened_ids = [
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
action = CodeReviewAction.model_validate(action_payload)
|
| 151 |
observation = env.step(action)
|
| 152 |
steps += 1
|
|
|
|
| 10 |
from server.environment import CodeReviewEnvironment
|
| 11 |
from server.tasks import TASKS
|
| 12 |
|
|
|
|
| 13 |
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 14 |
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 15 |
API_KEY = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY") or os.getenv("API_KEY")
|
|
|
|
| 49 |
def _scripted_policy(task_id: str, opened_ids: List[str]) -> Dict[str, Any]:
|
| 50 |
plans = {
|
| 51 |
"pagination-regression": [
|
| 52 |
+
{
|
| 53 |
+
"action_type": "open_artifact",
|
| 54 |
+
"artifact_id": "test_log",
|
| 55 |
+
"note": "Need the failing test.",
|
| 56 |
+
},
|
| 57 |
{
|
| 58 |
"action_type": "submit_review",
|
| 59 |
"findings": [
|
|
|
|
| 70 |
},
|
| 71 |
],
|
| 72 |
"tenant-export-auth": [
|
| 73 |
+
{
|
| 74 |
+
"action_type": "open_artifact",
|
| 75 |
+
"artifact_id": "auth_middleware",
|
| 76 |
+
"note": "Inspect auth helpers.",
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"action_type": "open_artifact",
|
| 80 |
+
"artifact_id": "security_policy",
|
| 81 |
+
"note": "Confirm tenant policy.",
|
| 82 |
+
},
|
| 83 |
{
|
| 84 |
"action_type": "submit_review",
|
| 85 |
"findings": [
|
|
|
|
| 96 |
},
|
| 97 |
],
|
| 98 |
"refund-idempotency": [
|
| 99 |
+
{
|
| 100 |
+
"action_type": "open_artifact",
|
| 101 |
+
"artifact_id": "payment_client",
|
| 102 |
+
"note": "Check refund API.",
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"action_type": "open_artifact",
|
| 106 |
+
"artifact_id": "worker_log",
|
| 107 |
+
"note": "Inspect incident evidence.",
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"action_type": "open_artifact",
|
| 111 |
+
"artifact_id": "db_model",
|
| 112 |
+
"note": "Look for idempotency fields.",
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"action_type": "open_artifact",
|
| 116 |
+
"artifact_id": "regression_test",
|
| 117 |
+
"note": "Check test coverage.",
|
| 118 |
+
},
|
| 119 |
{
|
| 120 |
"action_type": "submit_review",
|
| 121 |
"findings": [
|
|
|
|
| 135 |
plan = plans[task_id]
|
| 136 |
if not opened_ids:
|
| 137 |
return plan[0]
|
| 138 |
+
open_count = sum(
|
| 139 |
+
1
|
| 140 |
+
for step in plan
|
| 141 |
+
if step["action_type"] == "open_artifact" and step["artifact_id"] in opened_ids
|
| 142 |
+
)
|
| 143 |
return plan[min(open_count, len(plan) - 1)]
|
| 144 |
|
| 145 |
|
|
|
|
| 176 |
try:
|
| 177 |
while steps < MAX_STEPS and not observation.done:
|
| 178 |
obs_dict = observation.model_dump()
|
| 179 |
+
opened_ids = [
|
| 180 |
+
artifact["artifact_id"] for artifact in obs_dict["opened_artifacts"]
|
| 181 |
+
]
|
| 182 |
+
action_payload = (
|
| 183 |
+
_llm_action(client, obs_dict)
|
| 184 |
+
if client
|
| 185 |
+
else _scripted_policy(task.task_id, opened_ids)
|
| 186 |
+
)
|
| 187 |
action = CodeReviewAction.model_validate(action_payload)
|
| 188 |
observation = env.step(action)
|
| 189 |
steps += 1
|
models.py
CHANGED
|
@@ -69,7 +69,9 @@ class CodeReviewObservation(Observation):
|
|
| 69 |
# Step-level signals (populated by environment)
|
| 70 |
done: bool = Field(default=False, description="Whether the episode has ended.")
|
| 71 |
reward: float = Field(default=0.0, description="Reward earned on this step.")
|
| 72 |
-
metadata: Dict[str, Any] = Field(
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
class CodeReviewState(State):
|
|
|
|
| 69 |
# Step-level signals (populated by environment)
|
| 70 |
done: bool = Field(default=False, description="Whether the episode has ended.")
|
| 71 |
reward: float = Field(default=0.0, description="Reward earned on this step.")
|
| 72 |
+
metadata: Dict[str, Any] = Field(
|
| 73 |
+
default_factory=dict, description="Extra per-step metadata."
|
| 74 |
+
)
|
| 75 |
|
| 76 |
|
| 77 |
class CodeReviewState(State):
|
server/app.py
CHANGED
|
@@ -11,7 +11,11 @@ import os
|
|
| 11 |
from typing import Any
|
| 12 |
import uvicorn
|
| 13 |
|
| 14 |
-
from codereview_env.models import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from server.environment import CodeReviewEnvironment
|
| 16 |
from server.tasks import TASKS, TASKS_BY_ID, grade_submission
|
| 17 |
|
|
@@ -52,7 +56,9 @@ def _serialize_step(observation: CodeReviewObservation, session_id: str) -> dict
|
|
| 52 |
def _resolve_session(session_id: str | None) -> tuple[str, CodeReviewEnvironment]:
|
| 53 |
selected_session_id = session_id or _latest_session_id
|
| 54 |
if not selected_session_id or selected_session_id not in _sessions:
|
| 55 |
-
raise HTTPException(
|
|
|
|
|
|
|
| 56 |
return selected_session_id, _sessions[selected_session_id]
|
| 57 |
|
| 58 |
|
|
@@ -61,22 +67,24 @@ def _resolve_session(session_id: str | None) -> tuple[str, CodeReviewEnvironment
|
|
| 61 |
@app.get("/ui", include_in_schema=False, response_model=None)
|
| 62 |
def root(request: Request) -> Any:
|
| 63 |
index_path = _frontend_dir / "index.html"
|
| 64 |
-
|
| 65 |
# Debug info for logs
|
| 66 |
print(f"DEBUG: Root request for {request.url.path}")
|
| 67 |
print(f"DEBUG: Looking for index.html at {index_path}")
|
| 68 |
-
|
| 69 |
if not index_path.exists():
|
| 70 |
return JSONResponse(
|
| 71 |
-
status_code=404,
|
| 72 |
content={
|
| 73 |
"error": "Dashboard files missing",
|
| 74 |
"searched_at": str(index_path),
|
| 75 |
"cwd": os.getcwd(),
|
| 76 |
"frontend_dir_exists": _frontend_dir.exists(),
|
| 77 |
"frontend_dir": str(_frontend_dir),
|
| 78 |
-
"files_in_frontend":
|
| 79 |
-
|
|
|
|
|
|
|
| 80 |
)
|
| 81 |
return FileResponse(index_path)
|
| 82 |
|
|
@@ -195,7 +203,9 @@ def demo() -> dict:
|
|
| 195 |
|
| 196 |
@app.exception_handler(KeyError)
|
| 197 |
async def handle_key_error(request: Request, exc: KeyError) -> JSONResponse:
|
| 198 |
-
return JSONResponse(
|
|
|
|
|
|
|
| 199 |
|
| 200 |
|
| 201 |
def main() -> None:
|
|
|
|
| 11 |
from typing import Any
|
| 12 |
import uvicorn
|
| 13 |
|
| 14 |
+
from codereview_env.models import (
|
| 15 |
+
CodeReviewAction,
|
| 16 |
+
CodeReviewObservation,
|
| 17 |
+
CodeReviewState,
|
| 18 |
+
)
|
| 19 |
from server.environment import CodeReviewEnvironment
|
| 20 |
from server.tasks import TASKS, TASKS_BY_ID, grade_submission
|
| 21 |
|
|
|
|
| 56 |
def _resolve_session(session_id: str | None) -> tuple[str, CodeReviewEnvironment]:
|
| 57 |
selected_session_id = session_id or _latest_session_id
|
| 58 |
if not selected_session_id or selected_session_id not in _sessions:
|
| 59 |
+
raise HTTPException(
|
| 60 |
+
status_code=404, detail="No active session. Call /reset first."
|
| 61 |
+
)
|
| 62 |
return selected_session_id, _sessions[selected_session_id]
|
| 63 |
|
| 64 |
|
|
|
|
| 67 |
@app.get("/ui", include_in_schema=False, response_model=None)
|
| 68 |
def root(request: Request) -> Any:
|
| 69 |
index_path = _frontend_dir / "index.html"
|
| 70 |
+
|
| 71 |
# Debug info for logs
|
| 72 |
print(f"DEBUG: Root request for {request.url.path}")
|
| 73 |
print(f"DEBUG: Looking for index.html at {index_path}")
|
| 74 |
+
|
| 75 |
if not index_path.exists():
|
| 76 |
return JSONResponse(
|
| 77 |
+
status_code=404,
|
| 78 |
content={
|
| 79 |
"error": "Dashboard files missing",
|
| 80 |
"searched_at": str(index_path),
|
| 81 |
"cwd": os.getcwd(),
|
| 82 |
"frontend_dir_exists": _frontend_dir.exists(),
|
| 83 |
"frontend_dir": str(_frontend_dir),
|
| 84 |
+
"files_in_frontend": (
|
| 85 |
+
os.listdir(str(_frontend_dir)) if _frontend_dir.exists() else []
|
| 86 |
+
),
|
| 87 |
+
},
|
| 88 |
)
|
| 89 |
return FileResponse(index_path)
|
| 90 |
|
|
|
|
| 203 |
|
| 204 |
@app.exception_handler(KeyError)
|
| 205 |
async def handle_key_error(request: Request, exc: KeyError) -> JSONResponse:
|
| 206 |
+
return JSONResponse(
|
| 207 |
+
status_code=400, content={"error": f"Unknown key: {exc.args[0]}"}
|
| 208 |
+
)
|
| 209 |
|
| 210 |
|
| 211 |
def main() -> None:
|
server/dataset_loader.py
CHANGED
|
@@ -8,13 +8,13 @@ KEY CLASSES/FUNCTIONS: DatasetLoader
|
|
| 8 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 9 |
"""
|
| 10 |
|
| 11 |
-
import os
|
| 12 |
import random
|
| 13 |
import logging
|
| 14 |
from typing import Dict, Any
|
| 15 |
|
| 16 |
try:
|
| 17 |
from datasets import load_dataset
|
|
|
|
| 18 |
DATASETS_AVAILABLE = True
|
| 19 |
except ImportError:
|
| 20 |
DATASETS_AVAILABLE = False
|
|
@@ -25,30 +25,31 @@ FALLBACK_SAMPLES = [
|
|
| 25 |
{
|
| 26 |
"filename": "utils/pagination.py",
|
| 27 |
"patch": "def get_page_items(items, page, page_size):\n- start = page * page_size\n- end = start + page_size\n- return items[start:end]\n+ start = (page - 1) * page_size\n+ end = start + page_size\n+ return items[start:end]",
|
| 28 |
-
"comment": "Line 2: page indexing was wrong β pages are 1-indexed so (page-1)*page_size is correct"
|
| 29 |
},
|
| 30 |
{
|
| 31 |
"filename": "api/userController.js",
|
| 32 |
"patch": "- const userName = user.profile.name;\n+ const userName = user?.profile?.name ?? 'Anonymous';",
|
| 33 |
-
"comment": "Missing null check β user.profile could be undefined causing TypeError"
|
| 34 |
},
|
| 35 |
{
|
| 36 |
"filename": "db/queries.py",
|
| 37 |
-
"patch":
|
| 38 |
-
"comment": "Critical: SQL injection vulnerability β never format user input into queries"
|
| 39 |
},
|
| 40 |
{
|
| 41 |
"filename": "services/emailService.js",
|
| 42 |
"patch": "- const result = sendEmail(user.email, template);\n+ const result = await sendEmail(user.email, template);",
|
| 43 |
-
"comment": "Missing await β sendEmail is async, without await result is a Promise not the value"
|
| 44 |
},
|
| 45 |
{
|
| 46 |
"filename": "core/processor.py",
|
| 47 |
-
"patch":
|
| 48 |
-
"comment": "Bare except catches everything including SystemExit β always catch specific exceptions"
|
| 49 |
-
}
|
| 50 |
]
|
| 51 |
|
|
|
|
| 52 |
class DatasetLoader:
|
| 53 |
"""
|
| 54 |
Loads real PR diffs from the microsoft/CodeReviewer dataset.
|
|
@@ -59,21 +60,25 @@ class DatasetLoader:
|
|
| 59 |
"""Initializes the dataset loader, attempting to pull from HF."""
|
| 60 |
self.dataset = None
|
| 61 |
self.is_loaded = False
|
| 62 |
-
|
| 63 |
# We try to load dataset if HF `datasets` library is available
|
| 64 |
if DATASETS_AVAILABLE:
|
| 65 |
try:
|
| 66 |
-
# Use a specific split or subset if possible, but CodeReviewer is large.
|
| 67 |
# We'll just configure it gracefully.
|
| 68 |
logger.info("Attempting to load 'microsoft/CodeReviewer' dataset...")
|
| 69 |
# To avoid downloading 20GB in hackathon setup, we might load with streaming=True
|
| 70 |
# But typically we can just rely on the fallback samples if it takes too long.
|
| 71 |
-
ds = load_dataset(
|
|
|
|
|
|
|
| 72 |
# Keep a robust iterator bounded cache
|
| 73 |
self._iterator = iter(ds)
|
| 74 |
self.is_loaded = True
|
| 75 |
except Exception as e:
|
| 76 |
-
logger.warning(
|
|
|
|
|
|
|
| 77 |
else:
|
| 78 |
logger.warning("datasets module not found. Using fallback samples.")
|
| 79 |
|
|
@@ -87,7 +92,7 @@ class DatasetLoader:
|
|
| 87 |
if self.is_loaded and self._iterator:
|
| 88 |
try:
|
| 89 |
# Try getting next valid sample from HF stream
|
| 90 |
-
for _ in range(50):
|
| 91 |
record = next(self._iterator)
|
| 92 |
patch = record.get("patch", "")
|
| 93 |
if 50 <= len(patch) <= 2000:
|
|
@@ -95,13 +100,17 @@ class DatasetLoader:
|
|
| 95 |
"filename": record.get("filename", "unknown"),
|
| 96 |
"patch": patch,
|
| 97 |
"comment": record.get("comment", ""),
|
| 98 |
-
"language": self.get_language_from_filename(
|
| 99 |
-
|
|
|
|
|
|
|
| 100 |
}
|
| 101 |
except Exception as e:
|
| 102 |
-
logger.warning(
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
| 105 |
# Return fallback if streaming failed or isn't loaded
|
| 106 |
return random.choice(self.samples)
|
| 107 |
|
|
@@ -122,7 +131,7 @@ class DatasetLoader:
|
|
| 122 |
"php": "php",
|
| 123 |
"html": "html",
|
| 124 |
"css": "css",
|
| 125 |
-
"json": "json"
|
| 126 |
}
|
| 127 |
return mapping.get(ext, "unknown")
|
| 128 |
|
|
@@ -131,10 +140,15 @@ class DatasetLoader:
|
|
| 131 |
lang_counts = {}
|
| 132 |
for s in self.samples:
|
| 133 |
lang_counts[s["language"]] = lang_counts.get(s["language"], 0) + 1
|
| 134 |
-
|
| 135 |
return {
|
| 136 |
-
"total_samples":
|
|
|
|
|
|
|
| 137 |
"languages_breakdown": lang_counts if not self.is_loaded else "Mixed",
|
| 138 |
-
"avg_patch_length": sum(len(s.get("patch", "")) for s in self.samples)
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
| 140 |
}
|
|
|
|
| 8 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 9 |
"""
|
| 10 |
|
|
|
|
| 11 |
import random
|
| 12 |
import logging
|
| 13 |
from typing import Dict, Any
|
| 14 |
|
| 15 |
try:
|
| 16 |
from datasets import load_dataset
|
| 17 |
+
|
| 18 |
DATASETS_AVAILABLE = True
|
| 19 |
except ImportError:
|
| 20 |
DATASETS_AVAILABLE = False
|
|
|
|
| 25 |
{
|
| 26 |
"filename": "utils/pagination.py",
|
| 27 |
"patch": "def get_page_items(items, page, page_size):\n- start = page * page_size\n- end = start + page_size\n- return items[start:end]\n+ start = (page - 1) * page_size\n+ end = start + page_size\n+ return items[start:end]",
|
| 28 |
+
"comment": "Line 2: page indexing was wrong β pages are 1-indexed so (page-1)*page_size is correct",
|
| 29 |
},
|
| 30 |
{
|
| 31 |
"filename": "api/userController.js",
|
| 32 |
"patch": "- const userName = user.profile.name;\n+ const userName = user?.profile?.name ?? 'Anonymous';",
|
| 33 |
+
"comment": "Missing null check β user.profile could be undefined causing TypeError",
|
| 34 |
},
|
| 35 |
{
|
| 36 |
"filename": "db/queries.py",
|
| 37 |
+
"patch": '- query = f"SELECT * FROM users WHERE id = {user_id}"\n+ query = "SELECT * FROM users WHERE id = %s"\n+ cursor.execute(query, (user_id,))',
|
| 38 |
+
"comment": "Critical: SQL injection vulnerability β never format user input into queries",
|
| 39 |
},
|
| 40 |
{
|
| 41 |
"filename": "services/emailService.js",
|
| 42 |
"patch": "- const result = sendEmail(user.email, template);\n+ const result = await sendEmail(user.email, template);",
|
| 43 |
+
"comment": "Missing await β sendEmail is async, without await result is a Promise not the value",
|
| 44 |
},
|
| 45 |
{
|
| 46 |
"filename": "core/processor.py",
|
| 47 |
+
"patch": '- except:\n+ except (ValueError, TypeError) as e:\n+ logger.error(f"Processing failed: {e}")',
|
| 48 |
+
"comment": "Bare except catches everything including SystemExit β always catch specific exceptions",
|
| 49 |
+
},
|
| 50 |
]
|
| 51 |
|
| 52 |
+
|
| 53 |
class DatasetLoader:
|
| 54 |
"""
|
| 55 |
Loads real PR diffs from the microsoft/CodeReviewer dataset.
|
|
|
|
| 60 |
"""Initializes the dataset loader, attempting to pull from HF."""
|
| 61 |
self.dataset = None
|
| 62 |
self.is_loaded = False
|
| 63 |
+
|
| 64 |
# We try to load dataset if HF `datasets` library is available
|
| 65 |
if DATASETS_AVAILABLE:
|
| 66 |
try:
|
| 67 |
+
# Use a specific split or subset if possible, but CodeReviewer is large.
|
| 68 |
# We'll just configure it gracefully.
|
| 69 |
logger.info("Attempting to load 'microsoft/CodeReviewer' dataset...")
|
| 70 |
# To avoid downloading 20GB in hackathon setup, we might load with streaming=True
|
| 71 |
# But typically we can just rely on the fallback samples if it takes too long.
|
| 72 |
+
ds = load_dataset(
|
| 73 |
+
"microsoft/CodeReviewer", split="train", streaming=True
|
| 74 |
+
)
|
| 75 |
# Keep a robust iterator bounded cache
|
| 76 |
self._iterator = iter(ds)
|
| 77 |
self.is_loaded = True
|
| 78 |
except Exception as e:
|
| 79 |
+
logger.warning(
|
| 80 |
+
f"Failed to load HuggingFace dataset: {e}. Using fallback samples."
|
| 81 |
+
)
|
| 82 |
else:
|
| 83 |
logger.warning("datasets module not found. Using fallback samples.")
|
| 84 |
|
|
|
|
| 92 |
if self.is_loaded and self._iterator:
|
| 93 |
try:
|
| 94 |
# Try getting next valid sample from HF stream
|
| 95 |
+
for _ in range(50): # try up to 50 times to find bounded patch
|
| 96 |
record = next(self._iterator)
|
| 97 |
patch = record.get("patch", "")
|
| 98 |
if 50 <= len(patch) <= 2000:
|
|
|
|
| 100 |
"filename": record.get("filename", "unknown"),
|
| 101 |
"patch": patch,
|
| 102 |
"comment": record.get("comment", ""),
|
| 103 |
+
"language": self.get_language_from_filename(
|
| 104 |
+
record.get("filename", "unknown")
|
| 105 |
+
),
|
| 106 |
+
"msg": record.get("msg", ""),
|
| 107 |
}
|
| 108 |
except Exception as e:
|
| 109 |
+
logger.warning(
|
| 110 |
+
f"Error fetching from dataset stream: {e}. Falling back to default."
|
| 111 |
+
)
|
| 112 |
+
self.is_loaded = False # fallback forever
|
| 113 |
+
|
| 114 |
# Return fallback if streaming failed or isn't loaded
|
| 115 |
return random.choice(self.samples)
|
| 116 |
|
|
|
|
| 131 |
"php": "php",
|
| 132 |
"html": "html",
|
| 133 |
"css": "css",
|
| 134 |
+
"json": "json",
|
| 135 |
}
|
| 136 |
return mapping.get(ext, "unknown")
|
| 137 |
|
|
|
|
| 140 |
lang_counts = {}
|
| 141 |
for s in self.samples:
|
| 142 |
lang_counts[s["language"]] = lang_counts.get(s["language"], 0) + 1
|
| 143 |
+
|
| 144 |
return {
|
| 145 |
+
"total_samples": (
|
| 146 |
+
len(self.samples) if not self.is_loaded else "1M+ (Streaming)"
|
| 147 |
+
),
|
| 148 |
"languages_breakdown": lang_counts if not self.is_loaded else "Mixed",
|
| 149 |
+
"avg_patch_length": sum(len(s.get("patch", "")) for s in self.samples)
|
| 150 |
+
/ max(1, len(self.samples)),
|
| 151 |
+
"source": (
|
| 152 |
+
"microsoft/CodeReviewer" if self.is_loaded else "Fallback Synthetic"
|
| 153 |
+
),
|
| 154 |
}
|
server/environment.py
CHANGED
|
@@ -45,7 +45,10 @@ class CodeReviewEnvironment(
|
|
| 45 |
}
|
| 46 |
|
| 47 |
def reset(
|
| 48 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 49 |
) -> CodeReviewObservation:
|
| 50 |
task_id = kwargs.get("task_id")
|
| 51 |
if isinstance(seed, str) and not task_id:
|
|
@@ -91,16 +94,22 @@ class CodeReviewEnvironment(
|
|
| 91 |
reward_breakdown = self._handle_submit(action)
|
| 92 |
done = True
|
| 93 |
else:
|
| 94 |
-
reward_breakdown = self.reward_computer.invalid_action(
|
|
|
|
|
|
|
| 95 |
done = False
|
| 96 |
|
| 97 |
if self.step_count >= self._step_limit and not done:
|
| 98 |
-
self._recent_events.append(
|
|
|
|
|
|
|
| 99 |
done = True
|
| 100 |
|
| 101 |
self._episode_done = done
|
| 102 |
self._last_action_error = reward_breakdown.last_action_error
|
| 103 |
-
self._cumulative_reward = max(
|
|
|
|
|
|
|
| 104 |
self._recent_events.append(
|
| 105 |
f"Step {self.step_count}: {action.action_type} -> reward {reward_breakdown.reward:.2f}"
|
| 106 |
)
|
|
@@ -136,15 +145,21 @@ class CodeReviewEnvironment(
|
|
| 136 |
def _handle_open_artifact(self, action: CodeReviewAction) -> RewardBreakdown:
|
| 137 |
artifact_id = action.artifact_id
|
| 138 |
if not artifact_id:
|
| 139 |
-
return self.reward_computer.invalid_action(
|
|
|
|
|
|
|
| 140 |
if artifact_id not in self.task.artifacts:
|
| 141 |
-
return self.reward_computer.invalid_action(
|
|
|
|
|
|
|
| 142 |
|
| 143 |
repeated = artifact_id in self._opened_artifact_ids
|
| 144 |
self._opened_artifact_ids.add(artifact_id)
|
| 145 |
artifact = self.task.artifacts[artifact_id]
|
| 146 |
self._recent_events.append(f"Opened {artifact.title}.")
|
| 147 |
-
return self.reward_computer.artifact_reward(
|
|
|
|
|
|
|
| 148 |
|
| 149 |
def _handle_submit(self, action: CodeReviewAction) -> RewardBreakdown:
|
| 150 |
self._submitted_findings = list(action.findings)
|
|
@@ -175,7 +190,11 @@ class CodeReviewEnvironment(
|
|
| 175 |
title=artifact.title,
|
| 176 |
preview=artifact.preview,
|
| 177 |
opened=artifact_id in self._opened_artifact_ids,
|
| 178 |
-
content=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
)
|
| 180 |
available_artifacts.append(model)
|
| 181 |
if model.opened:
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
def reset(
|
| 48 |
+
self,
|
| 49 |
+
seed: Optional[int] = None,
|
| 50 |
+
episode_id: Optional[str] = None,
|
| 51 |
+
**kwargs: Any,
|
| 52 |
) -> CodeReviewObservation:
|
| 53 |
task_id = kwargs.get("task_id")
|
| 54 |
if isinstance(seed, str) and not task_id:
|
|
|
|
| 94 |
reward_breakdown = self._handle_submit(action)
|
| 95 |
done = True
|
| 96 |
else:
|
| 97 |
+
reward_breakdown = self.reward_computer.invalid_action(
|
| 98 |
+
"Unsupported action_type."
|
| 99 |
+
)
|
| 100 |
done = False
|
| 101 |
|
| 102 |
if self.step_count >= self._step_limit and not done:
|
| 103 |
+
self._recent_events.append(
|
| 104 |
+
"Step limit reached before review was submitted."
|
| 105 |
+
)
|
| 106 |
done = True
|
| 107 |
|
| 108 |
self._episode_done = done
|
| 109 |
self._last_action_error = reward_breakdown.last_action_error
|
| 110 |
+
self._cumulative_reward = max(
|
| 111 |
+
0.0, min(1.0, self._cumulative_reward + reward_breakdown.reward)
|
| 112 |
+
)
|
| 113 |
self._recent_events.append(
|
| 114 |
f"Step {self.step_count}: {action.action_type} -> reward {reward_breakdown.reward:.2f}"
|
| 115 |
)
|
|
|
|
| 145 |
def _handle_open_artifact(self, action: CodeReviewAction) -> RewardBreakdown:
|
| 146 |
artifact_id = action.artifact_id
|
| 147 |
if not artifact_id:
|
| 148 |
+
return self.reward_computer.invalid_action(
|
| 149 |
+
"artifact_id is required for open_artifact."
|
| 150 |
+
)
|
| 151 |
if artifact_id not in self.task.artifacts:
|
| 152 |
+
return self.reward_computer.invalid_action(
|
| 153 |
+
f"Unknown artifact_id: {artifact_id}"
|
| 154 |
+
)
|
| 155 |
|
| 156 |
repeated = artifact_id in self._opened_artifact_ids
|
| 157 |
self._opened_artifact_ids.add(artifact_id)
|
| 158 |
artifact = self.task.artifacts[artifact_id]
|
| 159 |
self._recent_events.append(f"Opened {artifact.title}.")
|
| 160 |
+
return self.reward_computer.artifact_reward(
|
| 161 |
+
self.task, artifact_id, self._opened_artifact_ids, repeated
|
| 162 |
+
)
|
| 163 |
|
| 164 |
def _handle_submit(self, action: CodeReviewAction) -> RewardBreakdown:
|
| 165 |
self._submitted_findings = list(action.findings)
|
|
|
|
| 190 |
title=artifact.title,
|
| 191 |
preview=artifact.preview,
|
| 192 |
opened=artifact_id in self._opened_artifact_ids,
|
| 193 |
+
content=(
|
| 194 |
+
artifact.content
|
| 195 |
+
if artifact_id in self._opened_artifact_ids
|
| 196 |
+
else None
|
| 197 |
+
),
|
| 198 |
)
|
| 199 |
available_artifacts.append(model)
|
| 200 |
if model.opened:
|
server/reward.py
CHANGED
|
@@ -18,7 +18,11 @@ class RewardBreakdown:
|
|
| 18 |
|
| 19 |
class RewardComputer:
|
| 20 |
def artifact_reward(
|
| 21 |
-
self,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
) -> RewardBreakdown:
|
| 23 |
if repeated:
|
| 24 |
return RewardBreakdown(
|
|
@@ -59,7 +63,13 @@ class RewardComputer:
|
|
| 59 |
efficiency_bonus = max(0.0, 0.08 - 0.02 * max(0, step_count - 2))
|
| 60 |
empty_penalty = -0.12 if not findings else 0.0
|
| 61 |
overstep_penalty = -0.05 if step_count > step_limit else 0.0
|
| 62 |
-
shaped_reward =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
shaped_reward = max(0.0, min(1.0, shaped_reward))
|
| 64 |
return RewardBreakdown(
|
| 65 |
reward=shaped_reward,
|
|
|
|
| 18 |
|
| 19 |
class RewardComputer:
|
| 20 |
def artifact_reward(
|
| 21 |
+
self,
|
| 22 |
+
task: ReviewTask,
|
| 23 |
+
artifact_id: str,
|
| 24 |
+
opened_artifacts: Set[str],
|
| 25 |
+
repeated: bool,
|
| 26 |
) -> RewardBreakdown:
|
| 27 |
if repeated:
|
| 28 |
return RewardBreakdown(
|
|
|
|
| 63 |
efficiency_bonus = max(0.0, 0.08 - 0.02 * max(0, step_count - 2))
|
| 64 |
empty_penalty = -0.12 if not findings else 0.0
|
| 65 |
overstep_penalty = -0.05 if step_count > step_limit else 0.0
|
| 66 |
+
shaped_reward = (
|
| 67 |
+
score * 0.75
|
| 68 |
+
+ coverage_bonus
|
| 69 |
+
+ efficiency_bonus
|
| 70 |
+
+ empty_penalty
|
| 71 |
+
+ overstep_penalty
|
| 72 |
+
)
|
| 73 |
shaped_reward = max(0.0, min(1.0, shaped_reward))
|
| 74 |
return RewardBreakdown(
|
| 75 |
reward=shaped_reward,
|
server/tasks.py
CHANGED
|
@@ -5,7 +5,6 @@ from typing import Dict, List, Literal, Sequence, Set
|
|
| 5 |
|
| 6 |
from codereview_env.models import ReviewFinding
|
| 7 |
|
| 8 |
-
|
| 9 |
Difficulty = Literal["easy", "medium", "hard"]
|
| 10 |
|
| 11 |
|
|
@@ -54,7 +53,9 @@ def _contains_group(text: str, groups: Sequence[Set[str]]) -> float:
|
|
| 54 |
return hits / len(groups)
|
| 55 |
|
| 56 |
|
| 57 |
-
def grade_findings(
|
|
|
|
|
|
|
| 58 |
matched = []
|
| 59 |
total = 0.0
|
| 60 |
finding_texts = []
|
|
@@ -83,7 +84,11 @@ def grade_findings(task: ReviewTask, findings: Sequence[ReviewFinding], opened_a
|
|
| 83 |
if not criterion.preferred_artifacts
|
| 84 |
else min(
|
| 85 |
1.0,
|
| 86 |
-
sum(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
/ len(criterion.preferred_artifacts),
|
| 88 |
)
|
| 89 |
)
|
|
@@ -167,7 +172,13 @@ TASKS: List[ReviewTask] = [
|
|
| 167 |
severity="medium",
|
| 168 |
weight=0.65,
|
| 169 |
required_terms=(
|
| 170 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
{"slice", "negative index", "items[-", "from the end"},
|
| 172 |
),
|
| 173 |
recommendation_terms=(
|
|
@@ -263,14 +274,24 @@ TASKS: List[ReviewTask] = [
|
|
| 263 |
severity="critical",
|
| 264 |
weight=0.7,
|
| 265 |
required_terms=(
|
| 266 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
{"query param", "account_id", "untrusted", "arbitrary"},
|
| 268 |
),
|
| 269 |
recommendation_terms=(
|
| 270 |
{"require_account_scope", "tenant check", "scope", "authorize"},
|
| 271 |
{"before export", "request.user.account_id", "is_global_admin"},
|
| 272 |
),
|
| 273 |
-
preferred_artifacts=(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
),
|
| 275 |
GraderCriterion(
|
| 276 |
criterion_id="missing-admin-gate",
|
|
@@ -282,7 +303,9 @@ TASKS: List[ReviewTask] = [
|
|
| 282 |
{"require_admin", "admin role", "admin gate", "privilege"},
|
| 283 |
{"missing", "not called", "no check", "unguarded"},
|
| 284 |
),
|
| 285 |
-
recommendation_terms=(
|
|
|
|
|
|
|
| 286 |
preferred_artifacts=("route_diff", "auth_middleware"),
|
| 287 |
),
|
| 288 |
),
|
|
@@ -383,14 +406,24 @@ TASKS: List[ReviewTask] = [
|
|
| 383 |
severity="critical",
|
| 384 |
weight=0.5,
|
| 385 |
required_terms=(
|
| 386 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
{"timeout", "retry", "second call", "replay"},
|
| 388 |
),
|
| 389 |
recommendation_terms=(
|
| 390 |
{"idempotency_key", "persist", "reuse"},
|
| 391 |
{"before calling processor", "db", "same key on retry"},
|
| 392 |
),
|
| 393 |
-
preferred_artifacts=(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
),
|
| 395 |
GraderCriterion(
|
| 396 |
criterion_id="status-update-race",
|
|
@@ -399,10 +432,24 @@ TASKS: List[ReviewTask] = [
|
|
| 399 |
severity="high",
|
| 400 |
weight=0.35,
|
| 401 |
required_terms=(
|
| 402 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
{"status", "after success", "not locked", "before call"},
|
| 404 |
),
|
| 405 |
-
recommendation_terms=(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
preferred_artifacts=("worker_diff", "worker_log"),
|
| 407 |
),
|
| 408 |
GraderCriterion(
|
|
@@ -413,7 +460,12 @@ TASKS: List[ReviewTask] = [
|
|
| 413 |
weight=0.15,
|
| 414 |
required_terms=(
|
| 415 |
{"test", "regression", "integration test"},
|
| 416 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
),
|
| 418 |
recommendation_terms=({"add", "cover", "simulate"},),
|
| 419 |
preferred_artifacts=("regression_test",),
|
|
@@ -430,6 +482,7 @@ TASKS_BY_ID = {task.task_id: task for task in TASKS}
|
|
| 430 |
# Compatibility shims β used by environment.py, app.py, and inference.py
|
| 431 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 432 |
|
|
|
|
| 433 |
def list_tasks() -> List[Dict[str, object]]:
|
| 434 |
"""Return lightweight task metadata list (for /tasks endpoint)."""
|
| 435 |
return [
|
|
@@ -447,7 +500,9 @@ def list_tasks() -> List[Dict[str, object]]:
|
|
| 447 |
def get_task(task_id: str) -> Dict[str, object]:
|
| 448 |
"""Return a task as a plain dict by ID. Raises ValueError if unknown."""
|
| 449 |
if task_id not in TASKS_BY_ID:
|
| 450 |
-
raise ValueError(
|
|
|
|
|
|
|
| 451 |
t = TASKS_BY_ID[task_id]
|
| 452 |
|
| 453 |
# Build the dict shape expected by environment.py
|
|
@@ -535,7 +590,8 @@ def grade_submission(
|
|
| 535 |
# Determine which artifacts were "opened" (inferred from review text mentioning artifact IDs)
|
| 536 |
text_lower = (review_text or "").lower()
|
| 537 |
opened: Set[str] = {
|
| 538 |
-
aid
|
|
|
|
| 539 |
if aid.replace("_", " ") in text_lower or aid in text_lower
|
| 540 |
}
|
| 541 |
|
|
@@ -551,6 +607,7 @@ def grade_submission(
|
|
| 551 |
|
| 552 |
# ββ Internal helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 553 |
|
|
|
|
| 554 |
def _primary_file(task: "ReviewTask") -> str:
|
| 555 |
"""Return the first grader criterion's file_path as the 'primary' file."""
|
| 556 |
if task.grader:
|
|
|
|
| 5 |
|
| 6 |
from codereview_env.models import ReviewFinding
|
| 7 |
|
|
|
|
| 8 |
Difficulty = Literal["easy", "medium", "hard"]
|
| 9 |
|
| 10 |
|
|
|
|
| 53 |
return hits / len(groups)
|
| 54 |
|
| 55 |
|
| 56 |
+
def grade_findings(
|
| 57 |
+
task: ReviewTask, findings: Sequence[ReviewFinding], opened_artifacts: Set[str]
|
| 58 |
+
) -> Dict[str, object]:
|
| 59 |
matched = []
|
| 60 |
total = 0.0
|
| 61 |
finding_texts = []
|
|
|
|
| 84 |
if not criterion.preferred_artifacts
|
| 85 |
else min(
|
| 86 |
1.0,
|
| 87 |
+
sum(
|
| 88 |
+
1
|
| 89 |
+
for artifact_id in criterion.preferred_artifacts
|
| 90 |
+
if artifact_id in opened_artifacts
|
| 91 |
+
)
|
| 92 |
/ len(criterion.preferred_artifacts),
|
| 93 |
)
|
| 94 |
)
|
|
|
|
| 172 |
severity="medium",
|
| 173 |
weight=0.65,
|
| 174 |
required_terms=(
|
| 175 |
+
{
|
| 176 |
+
"page 0",
|
| 177 |
+
"page zero",
|
| 178 |
+
"negative page",
|
| 179 |
+
"page must be >= 1",
|
| 180 |
+
"invalid page",
|
| 181 |
+
},
|
| 182 |
{"slice", "negative index", "items[-", "from the end"},
|
| 183 |
),
|
| 184 |
recommendation_terms=(
|
|
|
|
| 274 |
severity="critical",
|
| 275 |
weight=0.7,
|
| 276 |
required_terms=(
|
| 277 |
+
{
|
| 278 |
+
"cross-tenant",
|
| 279 |
+
"tenant",
|
| 280 |
+
"account scope",
|
| 281 |
+
"data leak",
|
| 282 |
+
"authorization",
|
| 283 |
+
},
|
| 284 |
{"query param", "account_id", "untrusted", "arbitrary"},
|
| 285 |
),
|
| 286 |
recommendation_terms=(
|
| 287 |
{"require_account_scope", "tenant check", "scope", "authorize"},
|
| 288 |
{"before export", "request.user.account_id", "is_global_admin"},
|
| 289 |
),
|
| 290 |
+
preferred_artifacts=(
|
| 291 |
+
"route_diff",
|
| 292 |
+
"auth_middleware",
|
| 293 |
+
"security_policy",
|
| 294 |
+
),
|
| 295 |
),
|
| 296 |
GraderCriterion(
|
| 297 |
criterion_id="missing-admin-gate",
|
|
|
|
| 303 |
{"require_admin", "admin role", "admin gate", "privilege"},
|
| 304 |
{"missing", "not called", "no check", "unguarded"},
|
| 305 |
),
|
| 306 |
+
recommendation_terms=(
|
| 307 |
+
{"call require_admin", "guard", "before reading params"},
|
| 308 |
+
),
|
| 309 |
preferred_artifacts=("route_diff", "auth_middleware"),
|
| 310 |
),
|
| 311 |
),
|
|
|
|
| 406 |
severity="critical",
|
| 407 |
weight=0.5,
|
| 408 |
required_terms=(
|
| 409 |
+
{
|
| 410 |
+
"idempotency",
|
| 411 |
+
"duplicate refund",
|
| 412 |
+
"same refund twice",
|
| 413 |
+
"processor accepted",
|
| 414 |
+
},
|
| 415 |
{"timeout", "retry", "second call", "replay"},
|
| 416 |
),
|
| 417 |
recommendation_terms=(
|
| 418 |
{"idempotency_key", "persist", "reuse"},
|
| 419 |
{"before calling processor", "db", "same key on retry"},
|
| 420 |
),
|
| 421 |
+
preferred_artifacts=(
|
| 422 |
+
"worker_diff",
|
| 423 |
+
"payment_client",
|
| 424 |
+
"db_model",
|
| 425 |
+
"incident_ticket",
|
| 426 |
+
),
|
| 427 |
),
|
| 428 |
GraderCriterion(
|
| 429 |
criterion_id="status-update-race",
|
|
|
|
| 432 |
severity="high",
|
| 433 |
weight=0.35,
|
| 434 |
required_terms=(
|
| 435 |
+
{
|
| 436 |
+
"concurrent",
|
| 437 |
+
"two workers",
|
| 438 |
+
"race",
|
| 439 |
+
"visibility timeout",
|
| 440 |
+
"picked queued job",
|
| 441 |
+
},
|
| 442 |
{"status", "after success", "not locked", "before call"},
|
| 443 |
),
|
| 444 |
+
recommendation_terms=(
|
| 445 |
+
{
|
| 446 |
+
"claim",
|
| 447 |
+
"lock",
|
| 448 |
+
"transaction",
|
| 449 |
+
"update status first",
|
| 450 |
+
"compare-and-set",
|
| 451 |
+
},
|
| 452 |
+
),
|
| 453 |
preferred_artifacts=("worker_diff", "worker_log"),
|
| 454 |
),
|
| 455 |
GraderCriterion(
|
|
|
|
| 460 |
weight=0.15,
|
| 461 |
required_terms=(
|
| 462 |
{"test", "regression", "integration test"},
|
| 463 |
+
{
|
| 464 |
+
"timeout after success",
|
| 465 |
+
"replay",
|
| 466 |
+
"concurrent worker",
|
| 467 |
+
"duplicate refund",
|
| 468 |
+
},
|
| 469 |
),
|
| 470 |
recommendation_terms=({"add", "cover", "simulate"},),
|
| 471 |
preferred_artifacts=("regression_test",),
|
|
|
|
| 482 |
# Compatibility shims β used by environment.py, app.py, and inference.py
|
| 483 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 484 |
|
| 485 |
+
|
| 486 |
def list_tasks() -> List[Dict[str, object]]:
|
| 487 |
"""Return lightweight task metadata list (for /tasks endpoint)."""
|
| 488 |
return [
|
|
|
|
| 500 |
def get_task(task_id: str) -> Dict[str, object]:
|
| 501 |
"""Return a task as a plain dict by ID. Raises ValueError if unknown."""
|
| 502 |
if task_id not in TASKS_BY_ID:
|
| 503 |
+
raise ValueError(
|
| 504 |
+
f"Unknown task '{task_id}'. Available: {list(TASKS_BY_ID.keys())}"
|
| 505 |
+
)
|
| 506 |
t = TASKS_BY_ID[task_id]
|
| 507 |
|
| 508 |
# Build the dict shape expected by environment.py
|
|
|
|
| 590 |
# Determine which artifacts were "opened" (inferred from review text mentioning artifact IDs)
|
| 591 |
text_lower = (review_text or "").lower()
|
| 592 |
opened: Set[str] = {
|
| 593 |
+
aid
|
| 594 |
+
for aid in task.artifacts
|
| 595 |
if aid.replace("_", " ") in text_lower or aid in text_lower
|
| 596 |
}
|
| 597 |
|
|
|
|
| 607 |
|
| 608 |
# ββ Internal helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 609 |
|
| 610 |
+
|
| 611 |
def _primary_file(task: "ReviewTask") -> str:
|
| 612 |
"""Return the first grader criterion's file_path as the 'primary' file."""
|
| 613 |
if task.grader:
|
test_smoke.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
ο»Ώ"""Quick smoke test - run with: python test_smoke.py"""
|
|
|
|
| 2 |
from codereview_env.models import CodeReviewAction, ReviewFinding
|
| 3 |
from server.environment import CodeReviewEnvironment
|
| 4 |
from server.tasks import grade_submission, list_tasks
|
|
@@ -9,7 +10,7 @@ tasks = list_tasks()
|
|
| 9 |
print(f"Tasks ({len(tasks)}): {[t['task_id'] for t in tasks]}")
|
| 10 |
assert len(tasks) == 3, f"Expected 3 tasks, got {len(tasks)}"
|
| 11 |
|
| 12 |
-
difficulties = [t[
|
| 13 |
assert "easy" in difficulties
|
| 14 |
assert "medium" in difficulties
|
| 15 |
assert "hard" in difficulties
|
|
@@ -48,7 +49,9 @@ result2 = env.step(
|
|
| 48 |
],
|
| 49 |
)
|
| 50 |
)
|
| 51 |
-
print(
|
|
|
|
|
|
|
| 52 |
assert result2.done
|
| 53 |
assert result2.score > 0.1
|
| 54 |
|
|
|
|
| 1 |
ο»Ώ"""Quick smoke test - run with: python test_smoke.py"""
|
| 2 |
+
|
| 3 |
from codereview_env.models import CodeReviewAction, ReviewFinding
|
| 4 |
from server.environment import CodeReviewEnvironment
|
| 5 |
from server.tasks import grade_submission, list_tasks
|
|
|
|
| 10 |
print(f"Tasks ({len(tasks)}): {[t['task_id'] for t in tasks]}")
|
| 11 |
assert len(tasks) == 3, f"Expected 3 tasks, got {len(tasks)}"
|
| 12 |
|
| 13 |
+
difficulties = [t["difficulty"] for t in tasks]
|
| 14 |
assert "easy" in difficulties
|
| 15 |
assert "medium" in difficulties
|
| 16 |
assert "hard" in difficulties
|
|
|
|
| 49 |
],
|
| 50 |
)
|
| 51 |
)
|
| 52 |
+
print(
|
| 53 |
+
f" submit_review: reward={result2.reward:.3f} score={result2.score:.3f} done={result2.done}"
|
| 54 |
+
)
|
| 55 |
assert result2.done
|
| 56 |
assert result2.score > 0.1
|
| 57 |
|
tests/test_client.py
CHANGED
|
@@ -2,7 +2,6 @@ import httpx
|
|
| 2 |
import pytest
|
| 3 |
|
| 4 |
from codereview_env.client import CodeReviewEnv
|
| 5 |
-
from codereview_env.models import CodeReviewAction
|
| 6 |
from server.app import app
|
| 7 |
|
| 8 |
|
|
|
|
| 2 |
import pytest
|
| 3 |
|
| 4 |
from codereview_env.client import CodeReviewEnv
|
|
|
|
| 5 |
from server.app import app
|
| 6 |
|
| 7 |
|
tests/test_environment.py
CHANGED
|
@@ -10,14 +10,20 @@ def test_reset_returns_task_observation(env):
|
|
| 10 |
|
| 11 |
def test_open_artifact_gives_partial_reward(env):
|
| 12 |
env.reset(task_id="pagination-regression")
|
| 13 |
-
observation = env.step(
|
|
|
|
|
|
|
| 14 |
assert 0.0 < observation.reward <= 0.2
|
| 15 |
-
assert any(
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def test_submit_review_finishes_episode_with_score(env):
|
| 19 |
env.reset(task_id="tenant-export-auth")
|
| 20 |
-
env.step(
|
|
|
|
|
|
|
| 21 |
observation = env.step(
|
| 22 |
CodeReviewAction(
|
| 23 |
action_type="submit_review",
|
|
@@ -40,7 +46,9 @@ def test_submit_review_finishes_episode_with_score(env):
|
|
| 40 |
|
| 41 |
def test_state_reports_progress(env):
|
| 42 |
env.reset(task_id="refund-idempotency")
|
| 43 |
-
env.step(
|
|
|
|
|
|
|
| 44 |
state = env.state
|
| 45 |
assert isinstance(state, CodeReviewState)
|
| 46 |
assert state.step_count == 1
|
|
@@ -50,5 +58,7 @@ def test_state_reports_progress(env):
|
|
| 50 |
def test_step_limit_ends_episode(env):
|
| 51 |
observation = env.reset(task_id="pagination-regression")
|
| 52 |
for _ in range(observation.step_limit):
|
| 53 |
-
observation = env.step(
|
|
|
|
|
|
|
| 54 |
assert observation.done is True
|
|
|
|
| 10 |
|
| 11 |
def test_open_artifact_gives_partial_reward(env):
|
| 12 |
env.reset(task_id="pagination-regression")
|
| 13 |
+
observation = env.step(
|
| 14 |
+
CodeReviewAction(action_type="open_artifact", artifact_id="test_log")
|
| 15 |
+
)
|
| 16 |
assert 0.0 < observation.reward <= 0.2
|
| 17 |
+
assert any(
|
| 18 |
+
artifact.artifact_id == "test_log" for artifact in observation.opened_artifacts
|
| 19 |
+
)
|
| 20 |
|
| 21 |
|
| 22 |
def test_submit_review_finishes_episode_with_score(env):
|
| 23 |
env.reset(task_id="tenant-export-auth")
|
| 24 |
+
env.step(
|
| 25 |
+
CodeReviewAction(action_type="open_artifact", artifact_id="auth_middleware")
|
| 26 |
+
)
|
| 27 |
observation = env.step(
|
| 28 |
CodeReviewAction(
|
| 29 |
action_type="submit_review",
|
|
|
|
| 46 |
|
| 47 |
def test_state_reports_progress(env):
|
| 48 |
env.reset(task_id="refund-idempotency")
|
| 49 |
+
env.step(
|
| 50 |
+
CodeReviewAction(action_type="open_artifact", artifact_id="payment_client")
|
| 51 |
+
)
|
| 52 |
state = env.state
|
| 53 |
assert isinstance(state, CodeReviewState)
|
| 54 |
assert state.step_count == 1
|
|
|
|
| 58 |
def test_step_limit_ends_episode(env):
|
| 59 |
observation = env.reset(task_id="pagination-regression")
|
| 60 |
for _ in range(observation.step_limit):
|
| 61 |
+
observation = env.step(
|
| 62 |
+
CodeReviewAction(action_type="open_artifact", artifact_id="ticket")
|
| 63 |
+
)
|
| 64 |
assert observation.done is True
|
tests/test_reward.py
CHANGED
|
@@ -2,8 +2,12 @@ from codereview_env.models import ReviewFinding
|
|
| 2 |
|
| 3 |
|
| 4 |
def test_reopening_artifact_penalizes_loops(reward_computer, hard_task):
|
| 5 |
-
first = reward_computer.artifact_reward(
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
assert first.reward > 0.0
|
| 8 |
assert second.components["loop_penalty"] < 0.0
|
| 9 |
|
|
|
|
| 2 |
|
| 3 |
|
| 4 |
def test_reopening_artifact_penalizes_loops(reward_computer, hard_task):
|
| 5 |
+
first = reward_computer.artifact_reward(
|
| 6 |
+
hard_task, "payment_client", set(), repeated=False
|
| 7 |
+
)
|
| 8 |
+
second = reward_computer.artifact_reward(
|
| 9 |
+
hard_task, "payment_client", {"payment_client"}, repeated=True
|
| 10 |
+
)
|
| 11 |
assert first.reward > 0.0
|
| 12 |
assert second.components["loop_penalty"] < 0.0
|
| 13 |
|
tests/test_safety.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from unittest.mock import patch
|
| 3 |
+
from codereview_env.safety import SafePaginationFacade, SafeRewardCalculator
|
| 4 |
+
from codereview_env.config import CONFIG
|
| 5 |
+
|
| 6 |
+
# ββ Tests for Pagination Wrapper ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_pagination_happy_path():
|
| 10 |
+
"""Verify correct inputs reach the existing system."""
|
| 11 |
+
items = [1, 2, 3, 4, 5]
|
| 12 |
+
facade = SafePaginationFacade(items)
|
| 13 |
+
|
| 14 |
+
with patch("codereview_env.safety.get_paged_items") as mock_legacy:
|
| 15 |
+
mock_legacy.return_value = [1, 2]
|
| 16 |
+
result = facade.get_page(page=1, page_size=2)
|
| 17 |
+
|
| 18 |
+
assert result == [1, 2]
|
| 19 |
+
mock_legacy.assert_called_once_with(items, 1, 2)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_pagination_boundary_limits():
|
| 23 |
+
"""Test max page size from config."""
|
| 24 |
+
items = list(range(200))
|
| 25 |
+
facade = SafePaginationFacade(items)
|
| 26 |
+
|
| 27 |
+
# Exceed max size
|
| 28 |
+
with pytest.raises(ValueError) as exc:
|
| 29 |
+
facade.get_page(
|
| 30 |
+
page=CONFIG.pagination.MIN_PAGE,
|
| 31 |
+
page_size=CONFIG.pagination.MAX_PAGE_SIZE * 2,
|
| 32 |
+
)
|
| 33 |
+
expected_msg = CONFIG.pagination.ERROR_INVALID_SIZE.format(
|
| 34 |
+
max_size=CONFIG.pagination.MAX_PAGE_SIZE
|
| 35 |
+
)
|
| 36 |
+
assert expected_msg in str(exc.value)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_pagination_edge_cases():
|
| 40 |
+
"""Test 0, -1, and None (empty list)."""
|
| 41 |
+
# Empty list
|
| 42 |
+
facade = SafePaginationFacade([])
|
| 43 |
+
result = facade.get_page(page=1)
|
| 44 |
+
assert result == []
|
| 45 |
+
|
| 46 |
+
# Page before min_page
|
| 47 |
+
facade = SafePaginationFacade([1, 2, 3])
|
| 48 |
+
with pytest.raises(ValueError) as exc:
|
| 49 |
+
facade.get_page(page=CONFIG.pagination.MIN_PAGE - 1)
|
| 50 |
+
expected_msg = CONFIG.pagination.ERROR_INVALID_PAGE.format(
|
| 51 |
+
min_page=CONFIG.pagination.MIN_PAGE
|
| 52 |
+
)
|
| 53 |
+
assert expected_msg in str(exc.value)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_pagination_wrong_types():
|
| 57 |
+
"""Test string input for numeric fields."""
|
| 58 |
+
facade = SafePaginationFacade([1, 2])
|
| 59 |
+
with pytest.raises(TypeError):
|
| 60 |
+
facade.get_page(page="first_page")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ββ Tests for Reward Calculator βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_reward_precision():
|
| 67 |
+
"""Prove that rounding only happens at the final step."""
|
| 68 |
+
# (0.55555 * 0.75) + 0.12345 = 0.4166625 + 0.12345 = 0.5401125
|
| 69 |
+
# Rounded to 4 digits: 0.5401
|
| 70 |
+
score = 0.55555
|
| 71 |
+
bonus = 0.12345
|
| 72 |
+
|
| 73 |
+
result = SafeRewardCalculator.calculate_final_reward(score, bonus)
|
| 74 |
+
assert result == 0.5401
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_reward_clipping():
|
| 78 |
+
"""Verify max reward boundary."""
|
| 79 |
+
# Should clip to MAX_REWARD per CONFIG
|
| 80 |
+
result = SafeRewardCalculator.calculate_final_reward(
|
| 81 |
+
CONFIG.reward.MAX_REWARD, CONFIG.reward.MAX_REWARD
|
| 82 |
+
)
|
| 83 |
+
assert result == CONFIG.reward.MAX_REWARD
|
utils/pagination.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# EXISTING CODE - DO NOT MODIFY (Treat as Read-Only Library)
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def get_paged_items(items: list, page: int, page_size: int) -> list:
|
| 5 |
+
"""
|
| 6 |
+
Existing buggy/unsafe pagination logic.
|
| 7 |
+
- Fails on page 0 (returns negative slice)
|
| 8 |
+
- No type checking
|
| 9 |
+
- No bounds checking
|
| 10 |
+
"""
|
| 11 |
+
start = (page - 1) * page_size
|
| 12 |
+
end = start + page_size
|
| 13 |
+
return items[start:end]
|