FlakeForge / server /api.py
random70249's picture
Upload folder using huggingface_hub
5b3340c verified
Raw
History Blame Contribute Delete
16.4 kB
"""FlakeForge REST API β€” all /api/* routes."""
from __future__ import annotations
import json
import logging
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from .api_models import (
ChallengeRequest,
ChallengeResponse,
EpisodeStartRequest,
EpisodeStartResponse,
EpisodeStatusResponse,
EpisodeStepRequest,
EpisodeStepResponse,
HealthResponse,
ProjectInfo,
RepoInfo,
RepoListResponse,
RunEpisodeRequest,
RunEpisodeResponse,
StepResult,
TrainingStats,
TrainingStatsResponse,
)
from .challenge_engine import analyze_challenge
from .episode_runner import get_episode_runner
logger = logging.getLogger(__name__)
_START_TIME = time.time()
_PROJECT_ROOT = Path(__file__).parents[1]
router = APIRouter(prefix="/api", tags=["api"])
# ── Health & Info ────────────────────────────────────────────────────────────
@router.get("/health", response_model=HealthResponse)
def health_check():
try:
from .challenge_engine import has_challenge_llm_token
except ImportError:
try:
from server.challenge_engine import has_challenge_llm_token
except ImportError:
from FlakeForge.server.challenge_engine import has_challenge_llm_token # type: ignore
return HealthResponse(
status="ok",
version="0.1.0",
uptime_seconds=round(time.time() - _START_TIME, 1),
environment_ready=True,
challenge_llm_configured=has_challenge_llm_token(),
)
@router.get("/info", response_model=ProjectInfo)
def project_info():
try:
from models import ROOT_CAUSE_TYPES
except ImportError:
from ..models import ROOT_CAUSE_TYPES
repos = _scan_test_repos()
return ProjectInfo(
name="FlakeForge",
version="0.1.0",
description="RL Agent for Flaky Test Repair β€” GRPO + Causal Reasoning + Verifiable Rewards",
root_cause_categories=list(ROOT_CAUSE_TYPES),
total_test_repos=len(repos),
max_steps_per_episode=int(os.environ.get("INFERENCE_MAX_STEPS", "8")),
reward_signals=[
"format", "compile", "stability", "causal_proximity",
"failure_entropy", "anti_hack", "regression", "reasoning_consistency",
"oracle_reasoning", "patch_validation", "noop_patch", "think_history",
"terminal_bonus",
],
)
# ── Repos ────────────────────────────────────────────────────────────────────
def _scan_test_repos() -> List[RepoInfo]:
"""Scan test_repos/ directory for available scenarios."""
repos: List[RepoInfo] = []
test_repos_dir = _PROJECT_ROOT / "test_repos"
if not test_repos_dir.exists():
return repos
for category_dir in sorted(test_repos_dir.iterdir()):
if not category_dir.is_dir() or category_dir.name.startswith("."):
continue
if category_dir.name == "synthetic":
for repo_dir in sorted(category_dir.iterdir()):
if not repo_dir.is_dir():
continue
repo = _build_repo_info(repo_dir, parent_category="synthetic")
if repo:
repos.append(repo)
else:
repo = _build_repo_info(category_dir)
if repo:
repos.append(repo)
return repos
def _build_repo_info(repo_dir: Path, parent_category: str = "") -> Optional[RepoInfo]:
"""Build RepoInfo from a repo directory."""
manifest_path = repo_dir / "flake_manifest.json"
manifest = None
category = parent_category or "demo"
test_id = ""
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
category = manifest.get("flake_category", category)
test_id = manifest.get("test_identifier", "")
except Exception:
pass
if not test_id:
test_files = list(repo_dir.glob("tests/test_*.py")) + list(repo_dir.glob("test_*.py"))
if test_files:
rel = test_files[0].relative_to(repo_dir)
test_id = str(rel).replace("\\", "/")
return RepoInfo(
name=repo_dir.name,
path=str(repo_dir.relative_to(_PROJECT_ROOT)).replace("\\", "/"),
category=category,
test_identifier=test_id,
has_manifest=manifest is not None,
manifest=manifest,
)
@router.get("/repos", response_model=RepoListResponse)
def list_repos(category: str = ""):
repos = _scan_test_repos()
if category:
repos = [r for r in repos if r.category == category]
return RepoListResponse(repos=repos, total=len(repos))
@router.get("/repos/{repo_name}")
def get_repo(repo_name: str):
repos = _scan_test_repos()
for r in repos:
if r.name == repo_name:
return r
raise HTTPException(404, f"Repo '{repo_name}' not found")
@router.get("/repos/{repo_name}/manifest")
def get_repo_manifest(repo_name: str):
repos = _scan_test_repos()
for r in repos:
if r.name == repo_name:
if r.manifest:
return r.manifest
raise HTTPException(404, f"Repo '{repo_name}' has no manifest")
raise HTTPException(404, f"Repo '{repo_name}' not found")
@router.get("/repos/{repo_name}/source")
def get_repo_source(repo_name: str, file: str = "source.py"):
"""Read a source file from a test repo."""
repos = _scan_test_repos()
for r in repos:
if r.name == repo_name:
source_path = _PROJECT_ROOT / r.path / file
if source_path.exists() and source_path.is_file():
return {"file": file, "content": source_path.read_text(encoding="utf-8", errors="ignore")[:10000]}
raise HTTPException(404, f"File '{file}' not found in repo '{repo_name}'")
raise HTTPException(404, f"Repo '{repo_name}' not found")
# ── Episode Management ───────────────────────────────────────────────────────
_env_instances: Dict[str, Any] = {}
@router.post("/episode/start", response_model=EpisodeStartResponse)
def start_episode(req: EpisodeStartRequest):
"""Initialize a new episode without running steps. Returns observation."""
import uuid
from .FlakeForge_environment import FlakeForgeEnvironment
from .docker_runner import DockerTestRunner
repo_path = req.repo_path or os.environ.get(
"FF_REPO_PATH",
str(_PROJECT_ROOT / "test_repos" / "timing_race_minimal"),
)
test_id = req.test_identifier or os.environ.get(
"FF_TEST_ID", "tests/test_flaky.py::test_fetch_should_complete"
)
if not repo_path.startswith("/") and not repo_path[1:2] == ":":
repo_path = str(_PROJECT_ROOT / repo_path)
runner = DockerTestRunner(repo_path)
env = FlakeForgeEnvironment(
repo_path=repo_path,
test_identifier=test_id,
max_steps=req.max_steps,
num_runs=req.num_runs,
runner=runner,
)
episode_id = str(uuid.uuid4())[:8]
observation = env.reset(episode_id=episode_id)
_env_instances[episode_id] = env
obs_dict = observation.model_dump() if hasattr(observation, "model_dump") else {}
return EpisodeStartResponse(
episode_id=episode_id,
status="initialized",
observation=obs_dict,
baseline_pass_rate=observation.baseline_pass_rate,
env_type=observation.env_type,
should_train=observation.should_train,
)
@router.post("/episode/{episode_id}/step", response_model=EpisodeStepResponse)
def step_episode(episode_id: str, req: EpisodeStepRequest):
"""Take one step in an active episode."""
env = _env_instances.get(episode_id)
if env is None:
raise HTTPException(404, f"Episode '{episode_id}' not found or expired")
try:
from models import FlakeForgeAction
except ImportError:
from ..models import FlakeForgeAction
action = FlakeForgeAction(
raw_response=req.raw_response,
think_text=req.think_text,
patch_text=req.patch_text,
predicted_category=req.predicted_category,
predicted_confidence=req.predicted_confidence,
)
observation = env.step(action)
state = env.state
step_result = StepResult(
step=state.step_count,
category=req.predicted_category,
confidence=req.predicted_confidence,
reward=round(observation.reward, 4),
reward_breakdown=observation.reward_breakdown,
pass_rate_before=state.baseline_pass_rate,
pass_rate_after=round(observation.current_pass_rate, 4),
patch_applied=bool(observation.patch_result.get("success")),
patch_files=observation.patch_result.get("files_modified", []),
think_summary=req.think_text[:200],
done=observation.done,
done_reason=observation.done_reason,
)
obs_dict = observation.model_dump() if hasattr(observation, "model_dump") else {}
if observation.done:
_env_instances.pop(episode_id, None)
return EpisodeStepResponse(step_result=step_result, observation=obs_dict)
@router.post("/episode/run", response_model=RunEpisodeResponse)
def run_full_episode(req: RunEpisodeRequest):
"""Run a complete episode end-to-end (agent loop). Blocking."""
runner = get_episode_runner()
repo_path = req.repo_path
if repo_path and not repo_path.startswith("/") and not (len(repo_path) > 1 and repo_path[1] == ":"):
repo_path = str(_PROJECT_ROOT / repo_path)
return runner.run_episode_sync(
repo_path=repo_path,
test_identifier=req.test_identifier,
max_steps=req.max_steps,
num_runs=req.num_runs,
)
@router.post("/episode/run-async")
async def run_episode_background(req: RunEpisodeRequest, background_tasks: BackgroundTasks):
"""Start an episode in the background. Returns immediately with episode_id."""
import uuid
episode_id = str(uuid.uuid4())[:8]
runner = get_episode_runner()
repo_path = req.repo_path
if repo_path and not repo_path.startswith("/") and not (len(repo_path) > 1 and repo_path[1] == ":"):
repo_path = str(_PROJECT_ROOT / repo_path)
runner._active_episodes[episode_id] = {
"episode_id": episode_id,
"status": "queued",
"current_step": 0,
"max_steps": req.max_steps,
"pass_rate": 0.0,
"total_reward": 0.0,
"done": False,
}
background_tasks.add_task(
runner.run_episode_sync,
repo_path=repo_path,
test_identifier=req.test_identifier,
max_steps=req.max_steps,
num_runs=req.num_runs,
)
return {"episode_id": episode_id, "status": "queued"}
@router.get("/episode/{episode_id}/status", response_model=EpisodeStatusResponse)
def episode_status(episode_id: str):
runner = get_episode_runner()
status = runner.get_status(episode_id)
if status is None:
if episode_id in _env_instances:
env = _env_instances[episode_id]
state = env.state
return EpisodeStatusResponse(
episode_id=episode_id,
status="active",
current_step=state.step_count,
max_steps=8,
pass_rate=state.current_pass_rate,
total_reward=0.0,
done=state.done,
)
raise HTTPException(404, f"Episode '{episode_id}' not found")
return EpisodeStatusResponse(**status)
@router.get("/episode/{episode_id}/result", response_model=RunEpisodeResponse)
def episode_result(episode_id: str):
runner = get_episode_runner()
result = runner.get_result(episode_id)
if result is None:
raise HTTPException(404, f"No completed result for episode '{episode_id}'")
return result
# ── Challenge ────────────────────────────────────────────────────────────────
@router.post("/challenge/analyze", response_model=ChallengeResponse)
def challenge_analyze(req: ChallengeRequest):
"""Analyze a user-submitted flaky test."""
analysis = analyze_challenge(
code=req.code,
test_code=req.test_code,
preset=req.preset,
)
return ChallengeResponse(status="analyzed", analysis=analysis)
# ── Training Stats ───────────────────────────────────────────────────────────
@router.get("/training/stats", response_model=TrainingStatsResponse)
def training_stats():
"""Return training statistics. Uses completed episodes from the runner."""
runner = get_episode_runner()
completed = runner._completed_episodes
if not completed:
return TrainingStatsResponse(stats=TrainingStats())
total = len(completed)
rewards = [ep.total_reward for ep in completed.values()]
avg_reward = round(sum(rewards) / max(total, 1), 4)
fixes = sum(1 for ep in completed.values() if ep.final_pass_rate >= 1.0)
fix_rate = round(fixes / max(total, 1), 4)
steps_to_fix = [
len(ep.steps) for ep in completed.values() if ep.final_pass_rate >= 1.0
]
avg_steps = round(sum(steps_to_fix) / max(len(steps_to_fix), 1), 2) if steps_to_fix else 0.0
cat_breakdown: Dict[str, int] = {}
for ep in completed.values():
for step in ep.steps:
cat_breakdown[step.category] = cat_breakdown.get(step.category, 0) + 1
return TrainingStatsResponse(
stats=TrainingStats(
total_episodes=total,
avg_reward=avg_reward,
fix_rate=fix_rate,
avg_steps_to_fix=avg_steps,
category_breakdown=cat_breakdown,
reward_history=rewards[-50:],
)
)
# ── WebSocket for live episode streaming ─────────────────────────────────────
@router.websocket("/ws/episode")
async def websocket_episode(ws: WebSocket):
"""Stream episode execution over WebSocket.
Send a JSON message to start: {"repo_path": "...", "test_identifier": "...", "max_steps": 8}
Receives step-by-step results as JSON messages.
"""
await ws.accept()
try:
config = await ws.receive_json()
runner = get_episode_runner()
repo_path = config.get("repo_path", "")
if repo_path and not repo_path.startswith("/") and not (len(repo_path) > 1 and repo_path[1] == ":"):
repo_path = str(_PROJECT_ROOT / repo_path)
await ws.send_json({"type": "started", "data": {"status": "initializing"}})
async for msg in runner.stream_episode(
repo_path=repo_path,
test_identifier=config.get("test_identifier", ""),
max_steps=config.get("max_steps", 8),
num_runs=config.get("num_runs", 10),
):
await ws.send_json(msg)
await ws.send_json({"type": "done", "data": {}})
except WebSocketDisconnect:
logger.info("WebSocket client disconnected")
except Exception as exc:
logger.error("WebSocket error: %s", exc, exc_info=True)
try:
await ws.send_json({"type": "error", "data": {"message": str(exc)}})
except Exception:
pass
finally:
try:
await ws.close()
except Exception:
pass