Initial commit for CodeForge GRPO training
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +25 -0
- app.py +60 -0
- codeforge/__init__.py +1 -0
- codeforge/app.py +139 -0
- codeforge/audit/__init__.py +7 -0
- codeforge/audit/ledger.py +39 -0
- codeforge/audit/models.py +18 -0
- codeforge/audit/reporter.py +53 -0
- codeforge/environment.py +515 -0
- codeforge/grader.py +39 -0
- codeforge/grounder.py +202 -0
- codeforge/interrogator/__init__.py +6 -0
- codeforge/interrogator/interrogator.py +51 -0
- codeforge/interrogator/models.py +9 -0
- codeforge/kb/__init__.py +17 -0
- codeforge/kb/cluster.py +169 -0
- codeforge/kb/code_graph.py +109 -0
- codeforge/kb/corpus_manager.py +138 -0
- codeforge/kb/indexer.py +168 -0
- codeforge/kb/models.py +37 -0
- codeforge/kb/skills_corpus.jsonl +0 -0
- codeforge/kb/skills_corpus.manifest.json +14 -0
- codeforge/kb/tokenizer.py +9 -0
- codeforge/mcp_server.py +848 -0
- codeforge/models.py +73 -0
- codeforge/observation.py +50 -0
- codeforge/ralph/__init__.py +17 -0
- codeforge/ralph/checkpoint.py +23 -0
- codeforge/ralph/loop.py +167 -0
- codeforge/ralph/models.py +55 -0
- codeforge/ralph/planner.py +90 -0
- codeforge/ralph/synthesizer.py +355 -0
- codeforge/sandbox/__init__.py +17 -0
- codeforge/sandbox/imports.py +67 -0
- codeforge/sandbox/metric.py +40 -0
- codeforge/sandbox/models.py +41 -0
- codeforge/sandbox/runner.py +61 -0
- codeforge/sandbox/sandbox.py +100 -0
- codeforge/sandbox/tools.py +85 -0
- codeforge/scraper/__init__.py +16 -0
- codeforge/scraper/chunker.py +108 -0
- codeforge/scraper/discovery.py +34 -0
- codeforge/scraper/parser.py +32 -0
- codeforge/scraper/pipeline.py +154 -0
- codeforge/scraper/tagger.py +62 -0
- codeforge/scraper/writer.py +52 -0
- codeforge/shaping.py +36 -0
- codeforge/tasks.py +137 -0
- dataset/mbpp.jsonl +0 -0
- requirements.txt +11 -0
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM pytorch/pytorch:2.5.1-cuda12.4-cudnn9-devel
|
| 2 |
+
|
| 3 |
+
RUN useradd -m -u 1000 user
|
| 4 |
+
USER user
|
| 5 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Upgrade pip and install unsloth properly
|
| 10 |
+
RUN pip install --no-cache-dir --upgrade pip
|
| 11 |
+
RUN pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"
|
| 12 |
+
|
| 13 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 14 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 15 |
+
|
| 16 |
+
# Copy all the code
|
| 17 |
+
COPY --chown=user . /app
|
| 18 |
+
|
| 19 |
+
# Ensure correct permissions for checkpoint saving
|
| 20 |
+
USER root
|
| 21 |
+
RUN chown -R user:user /app
|
| 22 |
+
USER user
|
| 23 |
+
|
| 24 |
+
# Hugging Face spaces expect the app to run on 7860
|
| 25 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
app.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, BackgroundTasks
|
| 2 |
+
import subprocess
|
| 3 |
+
import threading
|
| 4 |
+
import os
|
| 5 |
+
from fastapi.responses import PlainTextResponse
|
| 6 |
+
|
| 7 |
+
app = FastAPI()
|
| 8 |
+
|
| 9 |
+
# Global state to track training
|
| 10 |
+
training_status = {
|
| 11 |
+
"status": "idle",
|
| 12 |
+
"log": ""
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
def run_training():
|
| 16 |
+
global training_status
|
| 17 |
+
training_status["status"] = "running"
|
| 18 |
+
training_status["log"] = "Started training...\n"
|
| 19 |
+
|
| 20 |
+
# Run the trainer script and capture output
|
| 21 |
+
process = subprocess.Popen(
|
| 22 |
+
["python", "-m", "trainer.train", "--steps", "300"],
|
| 23 |
+
stdout=subprocess.PIPE,
|
| 24 |
+
stderr=subprocess.STDOUT,
|
| 25 |
+
text=True,
|
| 26 |
+
bufsize=1,
|
| 27 |
+
universal_newlines=True
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
for line in process.stdout:
|
| 31 |
+
training_status["log"] += line
|
| 32 |
+
|
| 33 |
+
process.wait()
|
| 34 |
+
training_status["status"] = f"finished with code {process.returncode}"
|
| 35 |
+
training_status["log"] += f"\nTraining finished with exit code: {process.returncode}\n"
|
| 36 |
+
|
| 37 |
+
@app.get("/")
|
| 38 |
+
def read_root():
|
| 39 |
+
return {
|
| 40 |
+
"message": "CodeForge GRPO Training Node Active",
|
| 41 |
+
"status": training_status["status"],
|
| 42 |
+
"endpoints": {
|
| 43 |
+
"/start": "Start training (runs in background)",
|
| 44 |
+
"/logs": "View live training logs"
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
@app.post("/start")
|
| 49 |
+
def start_training(background_tasks: BackgroundTasks):
|
| 50 |
+
if training_status["status"] == "running":
|
| 51 |
+
return {"message": "Training is already running!"}
|
| 52 |
+
|
| 53 |
+
# Run in a separate thread so it doesn't block the API
|
| 54 |
+
thread = threading.Thread(target=run_training)
|
| 55 |
+
thread.start()
|
| 56 |
+
return {"message": "Training started! Go to /logs to monitor."}
|
| 57 |
+
|
| 58 |
+
@app.get("/logs", response_class=PlainTextResponse)
|
| 59 |
+
def get_logs():
|
| 60 |
+
return training_status["log"]
|
codeforge/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
codeforge/app.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
import shutil
|
| 10 |
+
import threading
|
| 11 |
+
import time
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
from uuid import uuid4
|
| 15 |
+
|
| 16 |
+
from fastapi import FastAPI, Response
|
| 17 |
+
from openenv.core.env_server.http_server import create_app
|
| 18 |
+
|
| 19 |
+
from codeforge.environment import CodeForgeEnvironment
|
| 20 |
+
from codeforge.models import CodeForgeAction, CodeForgeObservation
|
| 21 |
+
from codeforge.tasks import TASKS
|
| 22 |
+
|
| 23 |
+
_log = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
_corpus_path_str = os.environ.get("GROUNDLOOP_CORPUS_PATH")
|
| 26 |
+
_corpus_path = Path(_corpus_path_str) if _corpus_path_str else None
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Session-keyed environment pool (SYSTEM_DESIGN §15)
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
_lock = threading.Lock()
|
| 32 |
+
_sessions: dict[str, CodeForgeEnvironment] = {}
|
| 33 |
+
_session_access: dict[str, float] = {} # session_id → last access timestamp
|
| 34 |
+
_MAX_SESSIONS = int(os.environ.get("CODEFORGE_MAX_SESSIONS", "10"))
|
| 35 |
+
_SESSION_TTL_S = int(os.environ.get("CODEFORGE_SESSION_TTL", "3600"))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _get_or_create_env() -> CodeForgeEnvironment:
|
| 39 |
+
"""For OpenEnv compliance — creates a single-session env.
|
| 40 |
+
|
| 41 |
+
The session pool below is used by the MCP server layer.
|
| 42 |
+
"""
|
| 43 |
+
return CodeForgeEnvironment(corpus_path=_corpus_path)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _expire_stale_sessions() -> None:
|
| 47 |
+
"""Remove sessions older than TTL. Must hold _lock."""
|
| 48 |
+
now = time.monotonic()
|
| 49 |
+
expired = [
|
| 50 |
+
sid for sid, ts in _session_access.items()
|
| 51 |
+
if now - ts > _SESSION_TTL_S
|
| 52 |
+
]
|
| 53 |
+
for sid in expired:
|
| 54 |
+
_sessions.pop(sid, None)
|
| 55 |
+
_session_access.pop(sid, None)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def get_session(session_id: str) -> CodeForgeEnvironment | None:
|
| 59 |
+
"""Retrieve an existing session by ID. Returns None if expired or missing."""
|
| 60 |
+
with _lock:
|
| 61 |
+
_expire_stale_sessions()
|
| 62 |
+
env = _sessions.get(session_id)
|
| 63 |
+
if env is not None:
|
| 64 |
+
_session_access[session_id] = time.monotonic()
|
| 65 |
+
return env
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def create_session() -> tuple[str, CodeForgeEnvironment]:
|
| 69 |
+
"""Create a new session. Evicts LRU session if at capacity."""
|
| 70 |
+
sid = uuid4().hex[:16]
|
| 71 |
+
env = CodeForgeEnvironment(corpus_path=_corpus_path)
|
| 72 |
+
now = time.monotonic()
|
| 73 |
+
with _lock:
|
| 74 |
+
_expire_stale_sessions()
|
| 75 |
+
if len(_sessions) >= _MAX_SESSIONS:
|
| 76 |
+
# Evict least-recently-used session
|
| 77 |
+
lru_sid = min(_session_access, key=_session_access.get) # type: ignore[arg-type]
|
| 78 |
+
_sessions.pop(lru_sid, None)
|
| 79 |
+
_session_access.pop(lru_sid, None)
|
| 80 |
+
_sessions[sid] = env
|
| 81 |
+
_session_access[sid] = now
|
| 82 |
+
return sid, env
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# OpenEnv compliance app
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
app: FastAPI = create_app(_get_or_create_env, CodeForgeAction, CodeForgeObservation)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@app.get("/", summary="Health check")
|
| 92 |
+
def root() -> dict[str, str]:
|
| 93 |
+
return {"name": "code-forge", "version": "0.2.0", "status": "ok", "docs": "/docs"}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@app.get("/favicon.ico", include_in_schema=False)
|
| 97 |
+
def favicon() -> Response:
|
| 98 |
+
return Response(status_code=204)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@app.get("/tasks", summary="List tasks + action schema")
|
| 102 |
+
def list_tasks() -> dict[str, Any]:
|
| 103 |
+
return {
|
| 104 |
+
"tasks": [
|
| 105 |
+
{
|
| 106 |
+
"id": t.task_id,
|
| 107 |
+
"difficulty": t.task_level,
|
| 108 |
+
"brief": t.brief,
|
| 109 |
+
"target_score": t.target_score,
|
| 110 |
+
"max_budget": t.max_budget,
|
| 111 |
+
"tools": list(t.tools),
|
| 112 |
+
}
|
| 113 |
+
for t in TASKS
|
| 114 |
+
],
|
| 115 |
+
"action_schema": {
|
| 116 |
+
"action_types": [
|
| 117 |
+
"query_kb",
|
| 118 |
+
"query_cluster",
|
| 119 |
+
"interrogate",
|
| 120 |
+
"run_ralph",
|
| 121 |
+
"submit",
|
| 122 |
+
"get_audit",
|
| 123 |
+
],
|
| 124 |
+
},
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.get("/health/deep", summary="Deep health check")
|
| 129 |
+
def health_check() -> dict[str, Any]:
|
| 130 |
+
"""Check all dependencies: corpus file, tools."""
|
| 131 |
+
checks: dict[str, bool] = {
|
| 132 |
+
"ruff": shutil.which("ruff") is not None,
|
| 133 |
+
"mypy": shutil.which("mypy") is not None,
|
| 134 |
+
"pytest": shutil.which("pytest") is not None,
|
| 135 |
+
}
|
| 136 |
+
corpus_ok = _corpus_path is not None and Path(_corpus_path).is_file()
|
| 137 |
+
checks["corpus"] = corpus_ok
|
| 138 |
+
all_ok = all(checks.values())
|
| 139 |
+
return {"status": "ok" if all_ok else "degraded", "checks": checks}
|
codeforge/audit/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from codeforge.audit.ledger import AuditLedger
|
| 4 |
+
from codeforge.audit.models import AuditReport
|
| 5 |
+
from codeforge.audit.reporter import AuditReporter
|
| 6 |
+
|
| 7 |
+
__all__ = ["AuditLedger", "AuditReport", "AuditReporter"]
|
codeforge/audit/ledger.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import Counter
|
| 4 |
+
from dataclasses import asdict
|
| 5 |
+
|
| 6 |
+
from codeforge.models import AuditEntry
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AuditLedger:
|
| 10 |
+
"""Per-episode, per-step append-only audit log."""
|
| 11 |
+
|
| 12 |
+
def __init__(self) -> None:
|
| 13 |
+
self._entries: list[AuditEntry] = []
|
| 14 |
+
|
| 15 |
+
def append(self, entry: AuditEntry) -> None:
|
| 16 |
+
self._entries.append(entry)
|
| 17 |
+
|
| 18 |
+
def entries(self) -> tuple[AuditEntry, ...]:
|
| 19 |
+
return tuple(self._entries)
|
| 20 |
+
|
| 21 |
+
def total_reward(self) -> float:
|
| 22 |
+
return sum(e.reward for e in self._entries)
|
| 23 |
+
|
| 24 |
+
def citation_count(self) -> dict[str, int]:
|
| 25 |
+
counts: Counter[str] = Counter()
|
| 26 |
+
for entry in self._entries:
|
| 27 |
+
counts.update(entry.cited_skill_ids)
|
| 28 |
+
return dict(counts)
|
| 29 |
+
|
| 30 |
+
def step_count(self) -> int:
|
| 31 |
+
return len(self._entries)
|
| 32 |
+
|
| 33 |
+
def serialize(self) -> dict[str, object]:
|
| 34 |
+
return {
|
| 35 |
+
"entries": [asdict(e) for e in self._entries],
|
| 36 |
+
"total_reward": self.total_reward(),
|
| 37 |
+
"step_count": self.step_count(),
|
| 38 |
+
"citation_count": self.citation_count(),
|
| 39 |
+
}
|
codeforge/audit/models.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AuditReport(BaseModel):
|
| 7 |
+
model_config = ConfigDict(frozen=True)
|
| 8 |
+
run_id: str
|
| 9 |
+
summary: str
|
| 10 |
+
iterations_total: int
|
| 11 |
+
iterations_kept: int
|
| 12 |
+
iterations_regressed: int
|
| 13 |
+
iterations_plateau: int
|
| 14 |
+
skill_citations: tuple[tuple[str, int], ...]
|
| 15 |
+
score_trajectory: tuple[float, ...]
|
| 16 |
+
final_score: float
|
| 17 |
+
terminated_by: str
|
| 18 |
+
hallucination_rate: float
|
codeforge/audit/reporter.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import Counter
|
| 4 |
+
from typing import TYPE_CHECKING
|
| 5 |
+
|
| 6 |
+
from codeforge.audit.models import AuditReport
|
| 7 |
+
|
| 8 |
+
if TYPE_CHECKING:
|
| 9 |
+
from codeforge.ralph.models import RunResult
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AuditReporter:
|
| 13 |
+
"""Builds an AuditReport from a Ralph RunResult."""
|
| 14 |
+
|
| 15 |
+
@staticmethod
|
| 16 |
+
def build(run: RunResult, hallucination_rate: float = 0.0) -> AuditReport:
|
| 17 |
+
cites: Counter[str] = Counter()
|
| 18 |
+
kept = 0
|
| 19 |
+
regressed = 0
|
| 20 |
+
plateau = 0
|
| 21 |
+
trajectory: list[float] = []
|
| 22 |
+
|
| 23 |
+
for it in run.iterations:
|
| 24 |
+
cites.update(it.cited_node_ids)
|
| 25 |
+
trajectory.append(it.sandbox_score_after)
|
| 26 |
+
if it.reason == "score_improved":
|
| 27 |
+
kept += 1
|
| 28 |
+
elif it.reason == "score_regressed":
|
| 29 |
+
regressed += 1
|
| 30 |
+
elif it.reason == "score_plateau":
|
| 31 |
+
plateau += 1
|
| 32 |
+
|
| 33 |
+
summary = (
|
| 34 |
+
f"run={run.run_id} iters={len(run.iterations)} "
|
| 35 |
+
f"final={run.final_score:.3f} terminated_by={run.terminated_by}"
|
| 36 |
+
)
|
| 37 |
+
skill_citations = tuple(
|
| 38 |
+
sorted(cites.items(), key=lambda kv: (-kv[1], kv[0]))
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
return AuditReport(
|
| 42 |
+
run_id=run.run_id,
|
| 43 |
+
summary=summary,
|
| 44 |
+
iterations_total=len(run.iterations),
|
| 45 |
+
iterations_kept=kept,
|
| 46 |
+
iterations_regressed=regressed,
|
| 47 |
+
iterations_plateau=plateau,
|
| 48 |
+
skill_citations=skill_citations,
|
| 49 |
+
score_trajectory=tuple(trajectory),
|
| 50 |
+
final_score=run.final_score,
|
| 51 |
+
terminated_by=run.terminated_by,
|
| 52 |
+
hallucination_rate=hallucination_rate,
|
| 53 |
+
)
|
codeforge/environment.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import re
|
| 5 |
+
import uuid
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from openenv.core.env_server.interfaces import Environment
|
| 10 |
+
|
| 11 |
+
from codeforge.audit.ledger import AuditLedger
|
| 12 |
+
from codeforge.grader import compute_reward
|
| 13 |
+
from codeforge.grounder import ground
|
| 14 |
+
from codeforge.interrogator.interrogator import Interrogator
|
| 15 |
+
from codeforge.kb.cluster import build_clusters
|
| 16 |
+
from codeforge.kb.indexer import SkillsIndex
|
| 17 |
+
from codeforge.models import AuditEntry, CodeForgeAction, CodeForgeActionType, CodeForgeObservation
|
| 18 |
+
from codeforge.observation import build_observation
|
| 19 |
+
from codeforge.ralph.loop import run_loop
|
| 20 |
+
from codeforge.ralph.models import LoopConfig
|
| 21 |
+
from codeforge.ralph.synthesizer import StubSynthesizer, Synthesizer
|
| 22 |
+
from codeforge.sandbox.sandbox import run_sandbox
|
| 23 |
+
from codeforge.shaping import citation_shaping_bonus
|
| 24 |
+
from codeforge.tasks import Task, get_task
|
| 25 |
+
|
| 26 |
+
_log = logging.getLogger(__name__)
|
| 27 |
+
_DEFAULT_CORPUS = Path(__file__).resolve().parent / "kb" / "skills_corpus.jsonl"
|
| 28 |
+
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
# Filename validation (SYSTEM_DESIGN §14.2, §14.3)
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
_FILENAME_RE = re.compile(r"^[a-z][a-z0-9_]*\.py$")
|
| 33 |
+
_FORBIDDEN_FILENAMES = frozenset({
|
| 34 |
+
"conftest.py", "pytest.ini", "setup.cfg", "pyproject.toml", "tox.ini",
|
| 35 |
+
})
|
| 36 |
+
_MAX_FILES = 10
|
| 37 |
+
_MAX_FILE_SIZE = 50 * 1024 # 50 KB
|
| 38 |
+
_MAX_TOTAL_SIZE = 200 * 1024 # 200 KB
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _validate_files(files: dict[str, str]) -> str | None:
|
| 42 |
+
"""Return an error message if *files* violates submission constraints, else None."""
|
| 43 |
+
if not files:
|
| 44 |
+
return "files dict is empty"
|
| 45 |
+
if len(files) > _MAX_FILES:
|
| 46 |
+
return f"too many files ({len(files)} > {_MAX_FILES})"
|
| 47 |
+
total_size = 0
|
| 48 |
+
for name, content in files.items():
|
| 49 |
+
if name in _FORBIDDEN_FILENAMES:
|
| 50 |
+
return f"filename '{name}' is not allowed"
|
| 51 |
+
if not _FILENAME_RE.match(name):
|
| 52 |
+
return f"filename '{name}' must match [a-z][a-z0-9_]*.py"
|
| 53 |
+
size = len(content.encode("utf-8"))
|
| 54 |
+
if size > _MAX_FILE_SIZE:
|
| 55 |
+
return f"file '{name}' exceeds {_MAX_FILE_SIZE} bytes"
|
| 56 |
+
total_size += size
|
| 57 |
+
if total_size > _MAX_TOTAL_SIZE:
|
| 58 |
+
return f"total size ({total_size}) exceeds {_MAX_TOTAL_SIZE} bytes"
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
# Valid action types (for fast membership check)
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
_VALID_ACTION_TYPES = frozenset(member.value for member in CodeForgeActionType)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Environment
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class CodeForgeEnvironment(Environment): # type: ignore[type-arg]
|
| 74 |
+
"""OpenEnv-compliant RL environment with all 6 CodeForge actions.
|
| 75 |
+
|
| 76 |
+
Implements SYSTEM_DESIGN §4.9, §5.2, §17.
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 80 |
+
|
| 81 |
+
def __init__(
|
| 82 |
+
self,
|
| 83 |
+
*,
|
| 84 |
+
corpus_path: Path | None = None,
|
| 85 |
+
synthesizer: Synthesizer | None = None,
|
| 86 |
+
) -> None:
|
| 87 |
+
super().__init__()
|
| 88 |
+
self._corpus_path = corpus_path or _DEFAULT_CORPUS
|
| 89 |
+
self._synthesizer = synthesizer
|
| 90 |
+
self._index: SkillsIndex | None = None
|
| 91 |
+
self._task: Task | None = None
|
| 92 |
+
self._episode_id: str = ""
|
| 93 |
+
self._budget_remaining: int = 0
|
| 94 |
+
self._current_files: dict[str, str] = {}
|
| 95 |
+
self._previous_score: float = 0.0
|
| 96 |
+
self._is_done: bool = False
|
| 97 |
+
|
| 98 |
+
# Per-step state
|
| 99 |
+
self._last_citations: tuple[dict[str, object], ...] = ()
|
| 100 |
+
self._last_grounding: dict[str, object] | None = None
|
| 101 |
+
self._last_reward: float = 0.0
|
| 102 |
+
self._last_cluster_hits: tuple[str, ...] = ()
|
| 103 |
+
self._last_interrogation_questions: tuple[str, ...] = ()
|
| 104 |
+
self._last_ralph_run_id: str | None = None
|
| 105 |
+
self._last_ralph_iterations: tuple[dict[str, object], ...] = ()
|
| 106 |
+
|
| 107 |
+
# Brier/quality tracking for audit entries
|
| 108 |
+
self._last_brier_penalty: float | None = None
|
| 109 |
+
self._last_quality: float = 0.0
|
| 110 |
+
|
| 111 |
+
# Episode-level accumulators
|
| 112 |
+
self._all_episode_citations: list[dict[str, object]] = []
|
| 113 |
+
self._all_episode_cluster_hits: list[str] = []
|
| 114 |
+
self._ledger: AuditLedger | None = None
|
| 115 |
+
self._step_index: int = 0
|
| 116 |
+
|
| 117 |
+
# ------------------------------------------------------------------
|
| 118 |
+
# Index management
|
| 119 |
+
# ------------------------------------------------------------------
|
| 120 |
+
|
| 121 |
+
def _ensure_index(self) -> SkillsIndex:
|
| 122 |
+
if self._index is None:
|
| 123 |
+
if not self._corpus_path.is_file():
|
| 124 |
+
msg = (
|
| 125 |
+
f"corpus not found: {self._corpus_path}. "
|
| 126 |
+
f"Run the skills scraper first."
|
| 127 |
+
)
|
| 128 |
+
raise FileNotFoundError(msg)
|
| 129 |
+
idx = SkillsIndex(corpus_path=self._corpus_path)
|
| 130 |
+
idx.build()
|
| 131 |
+
# Build and attach clusters
|
| 132 |
+
import json
|
| 133 |
+
nodes: list[dict[str, Any]] = []
|
| 134 |
+
with self._corpus_path.open(encoding="utf-8") as f:
|
| 135 |
+
for line in f:
|
| 136 |
+
line = line.strip()
|
| 137 |
+
if line:
|
| 138 |
+
nodes.append(json.loads(line))
|
| 139 |
+
manifest = build_clusters(nodes)
|
| 140 |
+
idx.attach_cluster_manifest(manifest)
|
| 141 |
+
self._index = idx
|
| 142 |
+
return self._index
|
| 143 |
+
|
| 144 |
+
# ------------------------------------------------------------------
|
| 145 |
+
# OpenEnv interface
|
| 146 |
+
# ------------------------------------------------------------------
|
| 147 |
+
|
| 148 |
+
def reset(
|
| 149 |
+
self,
|
| 150 |
+
seed: int | None = None,
|
| 151 |
+
episode_id: str | None = None,
|
| 152 |
+
**kwargs: Any,
|
| 153 |
+
) -> CodeForgeObservation:
|
| 154 |
+
task_level: str = kwargs.get("task_level", "easy")
|
| 155 |
+
task = get_task(task_level)
|
| 156 |
+
self._task = task
|
| 157 |
+
self._episode_id = episode_id or uuid.uuid4().hex[:12]
|
| 158 |
+
self._budget_remaining = task.max_budget
|
| 159 |
+
self._current_files = dict(task.initial_files)
|
| 160 |
+
self._previous_score = 0.0
|
| 161 |
+
self._is_done = False
|
| 162 |
+
|
| 163 |
+
# Reset per-step
|
| 164 |
+
self._last_citations = ()
|
| 165 |
+
self._last_grounding = None
|
| 166 |
+
self._last_reward = 0.0
|
| 167 |
+
self._last_cluster_hits = ()
|
| 168 |
+
self._last_interrogation_questions = ()
|
| 169 |
+
self._last_ralph_run_id = None
|
| 170 |
+
self._last_ralph_iterations = ()
|
| 171 |
+
|
| 172 |
+
# Reset episode accumulators
|
| 173 |
+
self._all_episode_citations = []
|
| 174 |
+
self._all_episode_cluster_hits = []
|
| 175 |
+
self._ledger = AuditLedger()
|
| 176 |
+
self._step_index = 0
|
| 177 |
+
|
| 178 |
+
_log.info(
|
| 179 |
+
"reset id=%s task=%s budget=%s",
|
| 180 |
+
self._episode_id, task.task_id, task.max_budget,
|
| 181 |
+
)
|
| 182 |
+
return self._build_obs()
|
| 183 |
+
|
| 184 |
+
def step(
|
| 185 |
+
self,
|
| 186 |
+
action: CodeForgeAction,
|
| 187 |
+
timeout_s: float | None = None,
|
| 188 |
+
**kwargs: Any,
|
| 189 |
+
) -> CodeForgeObservation:
|
| 190 |
+
# --- Pre-check: no active episode --------------------------------
|
| 191 |
+
if self._task is None:
|
| 192 |
+
return self._error_obs("No active episode — call reset() first")
|
| 193 |
+
|
| 194 |
+
# --- Pre-check: episode already done -----------------------------
|
| 195 |
+
if self._is_done:
|
| 196 |
+
return self._build_obs()
|
| 197 |
+
|
| 198 |
+
# --- Pre-check: valid action_type --------------------------------
|
| 199 |
+
action_type_str = str(action.action_type)
|
| 200 |
+
if action_type_str not in _VALID_ACTION_TYPES:
|
| 201 |
+
return self._error_obs(f"Unknown action_type: {action_type_str!r}")
|
| 202 |
+
|
| 203 |
+
# --- Budget check (variable cost) --------------------------------
|
| 204 |
+
cost = self._action_cost(action)
|
| 205 |
+
if cost > self._budget_remaining:
|
| 206 |
+
return self._error_obs(
|
| 207 |
+
f"Insufficient budget: need {cost}, have {self._budget_remaining}"
|
| 208 |
+
)
|
| 209 |
+
self._budget_remaining -= cost
|
| 210 |
+
|
| 211 |
+
# --- Clear per-step state ----------------------------------------
|
| 212 |
+
self._last_reward = 0.0
|
| 213 |
+
self._last_citations = ()
|
| 214 |
+
self._last_grounding = None
|
| 215 |
+
self._last_cluster_hits = ()
|
| 216 |
+
self._last_interrogation_questions = ()
|
| 217 |
+
self._last_ralph_run_id = None
|
| 218 |
+
self._last_ralph_iterations = ()
|
| 219 |
+
error: str | None = None
|
| 220 |
+
|
| 221 |
+
# --- Route to handler --------------------------------------------
|
| 222 |
+
try:
|
| 223 |
+
if action_type_str == CodeForgeActionType.QUERY_KB:
|
| 224 |
+
error = self._handle_query_kb(action)
|
| 225 |
+
elif action_type_str == CodeForgeActionType.QUERY_CLUSTER:
|
| 226 |
+
error = self._handle_query_cluster(action)
|
| 227 |
+
elif action_type_str == CodeForgeActionType.INTERROGATE:
|
| 228 |
+
error = self._handle_interrogate(action)
|
| 229 |
+
elif action_type_str == CodeForgeActionType.SUBMIT:
|
| 230 |
+
error = self._handle_submit(action)
|
| 231 |
+
elif action_type_str == CodeForgeActionType.RUN_RALPH:
|
| 232 |
+
error = self._handle_run_ralph(action)
|
| 233 |
+
elif action_type_str == CodeForgeActionType.GET_AUDIT:
|
| 234 |
+
error = self._handle_get_audit(action)
|
| 235 |
+
except Exception as exc:
|
| 236 |
+
_log.exception("handler error: %s", exc)
|
| 237 |
+
error = f"Internal error: {exc}"
|
| 238 |
+
|
| 239 |
+
# --- Append audit entry ------------------------------------------
|
| 240 |
+
assert self._ledger is not None
|
| 241 |
+
_cited: list[str] = []
|
| 242 |
+
_cite: dict[str, object]
|
| 243 |
+
for _cite in self._last_citations:
|
| 244 |
+
_cited.append(str(_cite.get("node_id", "")))
|
| 245 |
+
cited_ids: tuple[str, ...] = tuple(_cited)
|
| 246 |
+
self._ledger.append(
|
| 247 |
+
AuditEntry(
|
| 248 |
+
step_index=self._step_index,
|
| 249 |
+
action_type=action_type_str,
|
| 250 |
+
cited_skill_ids=cited_ids,
|
| 251 |
+
cited_clusters=self._last_cluster_hits,
|
| 252 |
+
grounding_report=(
|
| 253 |
+
self._last_grounding if self._last_grounding else None
|
| 254 |
+
),
|
| 255 |
+
reward=self._last_reward,
|
| 256 |
+
brier_penalty=(
|
| 257 |
+
self._last_brier_penalty
|
| 258 |
+
if action_type_str == CodeForgeActionType.SUBMIT
|
| 259 |
+
else None
|
| 260 |
+
),
|
| 261 |
+
confidence_declared=(
|
| 262 |
+
action.confidence
|
| 263 |
+
if action_type_str == CodeForgeActionType.SUBMIT
|
| 264 |
+
else None
|
| 265 |
+
),
|
| 266 |
+
quality=(
|
| 267 |
+
self._last_quality
|
| 268 |
+
if action_type_str == CodeForgeActionType.SUBMIT
|
| 269 |
+
else self._previous_score
|
| 270 |
+
),
|
| 271 |
+
),
|
| 272 |
+
)
|
| 273 |
+
self._step_index += 1
|
| 274 |
+
|
| 275 |
+
# --- Check budget exhaustion -------------------------------------
|
| 276 |
+
if self._budget_remaining <= 0:
|
| 277 |
+
self._is_done = True
|
| 278 |
+
|
| 279 |
+
return self._build_obs(error=error)
|
| 280 |
+
|
| 281 |
+
@property
|
| 282 |
+
def state(self) -> CodeForgeObservation:
|
| 283 |
+
if self._task is None:
|
| 284 |
+
return self._error_obs("No active episode — call reset() first")
|
| 285 |
+
return self._build_obs()
|
| 286 |
+
|
| 287 |
+
# ------------------------------------------------------------------
|
| 288 |
+
# Cost computation
|
| 289 |
+
# ------------------------------------------------------------------
|
| 290 |
+
|
| 291 |
+
@staticmethod
|
| 292 |
+
def _action_cost(action: CodeForgeAction) -> int:
|
| 293 |
+
"""Variable-cost budget accounting (SYSTEM_DESIGN §17.2)."""
|
| 294 |
+
if str(action.action_type) == CodeForgeActionType.GET_AUDIT:
|
| 295 |
+
return 0
|
| 296 |
+
if str(action.action_type) == CodeForgeActionType.RUN_RALPH:
|
| 297 |
+
return action.max_iters
|
| 298 |
+
return 1
|
| 299 |
+
|
| 300 |
+
# ------------------------------------------------------------------
|
| 301 |
+
# Action handlers (each returns an error string or None)
|
| 302 |
+
# ------------------------------------------------------------------
|
| 303 |
+
|
| 304 |
+
def _handle_query_kb(self, action: CodeForgeAction) -> str | None:
|
| 305 |
+
try:
|
| 306 |
+
idx = self._ensure_index()
|
| 307 |
+
except FileNotFoundError as e:
|
| 308 |
+
_log.warning("query_kb: no corpus: %s", e)
|
| 309 |
+
self._last_citations = ()
|
| 310 |
+
return None
|
| 311 |
+
tags = set(action.required_tags) if action.required_tags else None
|
| 312 |
+
results = idx.search(
|
| 313 |
+
action.claim or "", top_k=action.top_k, required_tags=tags,
|
| 314 |
+
)
|
| 315 |
+
self._last_citations = tuple(
|
| 316 |
+
{
|
| 317 |
+
"node_id": r.node_id,
|
| 318 |
+
"skill_name": r.skill_name,
|
| 319 |
+
"section_path": list(r.section_path),
|
| 320 |
+
"section_body": r.section_body,
|
| 321 |
+
"score": r.score,
|
| 322 |
+
"rank": r.rank,
|
| 323 |
+
}
|
| 324 |
+
for r in results
|
| 325 |
+
)
|
| 326 |
+
self._all_episode_citations.extend(self._last_citations)
|
| 327 |
+
return None
|
| 328 |
+
|
| 329 |
+
def _handle_query_cluster(self, action: CodeForgeAction) -> str | None:
|
| 330 |
+
try:
|
| 331 |
+
idx = self._ensure_index()
|
| 332 |
+
except FileNotFoundError as e:
|
| 333 |
+
_log.warning("query_cluster: no corpus: %s", e)
|
| 334 |
+
self._last_cluster_hits = ()
|
| 335 |
+
return None
|
| 336 |
+
label = action.cluster_label or ""
|
| 337 |
+
results = idx.nodes_in_cluster(label)
|
| 338 |
+
if not results:
|
| 339 |
+
self._last_cluster_hits = ()
|
| 340 |
+
return None
|
| 341 |
+
self._last_cluster_hits = tuple(r.node_id for r in results)
|
| 342 |
+
self._all_episode_cluster_hits.extend(self._last_cluster_hits)
|
| 343 |
+
return None
|
| 344 |
+
|
| 345 |
+
def _handle_interrogate(self, action: CodeForgeAction) -> str | None:
|
| 346 |
+
idx: SkillsIndex | None
|
| 347 |
+
try:
|
| 348 |
+
idx = self._ensure_index()
|
| 349 |
+
except FileNotFoundError:
|
| 350 |
+
idx = None
|
| 351 |
+
interrogator = Interrogator(idx)
|
| 352 |
+
assert self._task is not None
|
| 353 |
+
result = interrogator.generate(self._task.brief)
|
| 354 |
+
self._last_interrogation_questions = result.questions
|
| 355 |
+
return None
|
| 356 |
+
|
| 357 |
+
def _handle_submit(self, action: CodeForgeAction) -> str | None:
|
| 358 |
+
if action.files is None:
|
| 359 |
+
return "files required for submit"
|
| 360 |
+
file_err = _validate_files(action.files)
|
| 361 |
+
if file_err is not None:
|
| 362 |
+
return file_err
|
| 363 |
+
|
| 364 |
+
self._current_files = dict(action.files)
|
| 365 |
+
assert self._task is not None
|
| 366 |
+
|
| 367 |
+
# Merge hidden correctness tests into sandbox files (agent cannot see these)
|
| 368 |
+
sandbox_files = dict(action.files)
|
| 369 |
+
if self._task.hidden_tests:
|
| 370 |
+
sandbox_files.update(self._task.hidden_tests)
|
| 371 |
+
|
| 372 |
+
# Run sandbox
|
| 373 |
+
try:
|
| 374 |
+
sandbox_result = run_sandbox(
|
| 375 |
+
files=sandbox_files,
|
| 376 |
+
tools=self._task.tools,
|
| 377 |
+
timeout_per_tool=30.0,
|
| 378 |
+
)
|
| 379 |
+
sandbox_score = sandbox_result.composite_score
|
| 380 |
+
except Exception as e:
|
| 381 |
+
_log.exception("sandbox error: %s", e)
|
| 382 |
+
sandbox_score = 0.0
|
| 383 |
+
|
| 384 |
+
# Run grounder (pass local module names so they're not penalized)
|
| 385 |
+
local_modules = frozenset(
|
| 386 |
+
f.removesuffix(".py") for f in action.files if f.endswith(".py")
|
| 387 |
+
)
|
| 388 |
+
concatenated = "\n".join(action.files.values())
|
| 389 |
+
grounding_report = ground(concatenated, local_modules=local_modules)
|
| 390 |
+
self._last_grounding = grounding_report.model_dump()
|
| 391 |
+
|
| 392 |
+
# Compute reward with Brier calibration
|
| 393 |
+
quality = 0.6 * sandbox_score + 0.4 * grounding_report.groundedness
|
| 394 |
+
effective_conf = action.confidence if action.confidence is not None else 0.5
|
| 395 |
+
brier_penalty: float | None = min((effective_conf - quality) ** 2, 0.5)
|
| 396 |
+
self._last_brier_penalty = brier_penalty
|
| 397 |
+
self._last_quality = quality
|
| 398 |
+
|
| 399 |
+
reward = compute_reward(
|
| 400 |
+
sandbox_score=sandbox_score,
|
| 401 |
+
groundedness=grounding_report.groundedness,
|
| 402 |
+
confidence=action.confidence,
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
# Apply citation shaping bonus only on successful submits (§4.8.4)
|
| 406 |
+
if reward > 0:
|
| 407 |
+
shaping = citation_shaping_bonus(
|
| 408 |
+
submit_files=action.files,
|
| 409 |
+
prior_citations=self._all_episode_citations,
|
| 410 |
+
prior_cluster_hits=self._all_episode_cluster_hits,
|
| 411 |
+
)
|
| 412 |
+
reward = round(min(1.0, reward + shaping), 3)
|
| 413 |
+
|
| 414 |
+
self._last_reward = reward
|
| 415 |
+
self._previous_score = reward
|
| 416 |
+
|
| 417 |
+
# Check target score
|
| 418 |
+
if reward >= self._task.target_score:
|
| 419 |
+
self._is_done = True
|
| 420 |
+
|
| 421 |
+
return None
|
| 422 |
+
|
| 423 |
+
def _handle_run_ralph(self, action: CodeForgeAction) -> str | None:
|
| 424 |
+
assert self._task is not None
|
| 425 |
+
try:
|
| 426 |
+
idx = self._ensure_index()
|
| 427 |
+
except FileNotFoundError as e:
|
| 428 |
+
return f"corpus not available: {e}"
|
| 429 |
+
|
| 430 |
+
config = LoopConfig(
|
| 431 |
+
max_iters=action.max_iters,
|
| 432 |
+
target_score=self._task.target_score,
|
| 433 |
+
tools=self._task.tools,
|
| 434 |
+
)
|
| 435 |
+
synthesizer = self._synthesizer or StubSynthesizer()
|
| 436 |
+
result = run_loop(
|
| 437 |
+
spec=self._task.brief,
|
| 438 |
+
initial_files=self._current_files,
|
| 439 |
+
index=idx,
|
| 440 |
+
synthesizer=synthesizer,
|
| 441 |
+
config=config,
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
self._last_ralph_run_id = result.run_id
|
| 445 |
+
self._last_ralph_iterations = tuple(
|
| 446 |
+
it.model_dump() for it in result.iterations
|
| 447 |
+
)
|
| 448 |
+
self._current_files = dict(result.final_files)
|
| 449 |
+
|
| 450 |
+
# Compute ralph reward (SYSTEM_DESIGN §4.8.5)
|
| 451 |
+
concatenated = "\n".join(result.final_files.values())
|
| 452 |
+
grounding_report = ground(concatenated)
|
| 453 |
+
self._last_grounding = grounding_report.model_dump()
|
| 454 |
+
|
| 455 |
+
wasted = sum(
|
| 456 |
+
1 for it in result.iterations if it.reason in ("score_regressed", "score_plateau")
|
| 457 |
+
)
|
| 458 |
+
base = compute_reward(
|
| 459 |
+
sandbox_score=result.final_score,
|
| 460 |
+
groundedness=grounding_report.groundedness,
|
| 461 |
+
confidence=0.75,
|
| 462 |
+
)
|
| 463 |
+
waste_penalty = wasted * 0.05
|
| 464 |
+
ralph_reward = round(max(0.0, min(1.0, base - waste_penalty)), 3)
|
| 465 |
+
|
| 466 |
+
self._last_reward = ralph_reward
|
| 467 |
+
self._previous_score = ralph_reward
|
| 468 |
+
return None
|
| 469 |
+
|
| 470 |
+
def _handle_get_audit(self, action: CodeForgeAction) -> str | None:
|
| 471 |
+
# Audit data is populated in _build_obs via cumulative_audit_summary
|
| 472 |
+
return None
|
| 473 |
+
|
| 474 |
+
# ------------------------------------------------------------------
|
| 475 |
+
# Observation helpers
|
| 476 |
+
# ------------------------------------------------------------------
|
| 477 |
+
|
| 478 |
+
def _build_obs(self, *, error: str | None = None) -> CodeForgeObservation:
|
| 479 |
+
assert self._task is not None
|
| 480 |
+
audit_summary: dict[str, object] | None = None
|
| 481 |
+
if self._ledger is not None:
|
| 482 |
+
audit_summary = self._ledger.serialize()
|
| 483 |
+
return build_observation(
|
| 484 |
+
episode_id=self._episode_id,
|
| 485 |
+
task=self._task,
|
| 486 |
+
current_files=self._current_files,
|
| 487 |
+
budget_remaining=self._budget_remaining,
|
| 488 |
+
previous_score=self._previous_score,
|
| 489 |
+
last_citations=self._last_citations,
|
| 490 |
+
last_grounding=self._last_grounding,
|
| 491 |
+
is_done=self._is_done,
|
| 492 |
+
last_reward=self._last_reward,
|
| 493 |
+
last_cluster_hits=self._last_cluster_hits,
|
| 494 |
+
last_interrogation_questions=self._last_interrogation_questions,
|
| 495 |
+
last_ralph_run_id=self._last_ralph_run_id,
|
| 496 |
+
last_ralph_iterations=self._last_ralph_iterations,
|
| 497 |
+
cumulative_audit_summary=audit_summary,
|
| 498 |
+
error=error,
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
def _error_obs(self, msg: str) -> CodeForgeObservation:
|
| 502 |
+
"""Return an error observation without modifying episode state."""
|
| 503 |
+
if self._task is None:
|
| 504 |
+
# No task set — use a dummy task for the observation structure
|
| 505 |
+
dummy = get_task("easy")
|
| 506 |
+
return build_observation(
|
| 507 |
+
episode_id=self._episode_id or "none",
|
| 508 |
+
task=dummy,
|
| 509 |
+
current_files=self._current_files,
|
| 510 |
+
budget_remaining=self._budget_remaining,
|
| 511 |
+
previous_score=self._previous_score,
|
| 512 |
+
is_done=self._is_done,
|
| 513 |
+
error=msg,
|
| 514 |
+
)
|
| 515 |
+
return self._build_obs(error=msg)
|
codeforge/grader.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
_SANDBOX_WEIGHT = 0.6
|
| 4 |
+
_GROUNDING_WEIGHT = 0.4
|
| 5 |
+
_BRIER_CAP = 0.5
|
| 6 |
+
_UNCERTAIN_CONFIDENCE_THRESHOLD = 0.3
|
| 7 |
+
_UNCERTAIN_QUALITY_THRESHOLD = 0.5
|
| 8 |
+
_UNCERTAIN_FLOOR = 0.50
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def compute_reward(
|
| 12 |
+
*,
|
| 13 |
+
sandbox_score: float,
|
| 14 |
+
groundedness: float,
|
| 15 |
+
confidence: float | None = None,
|
| 16 |
+
) -> float:
|
| 17 |
+
"""Compute the final reward for a submit action.
|
| 18 |
+
|
| 19 |
+
quality = weighted combination of sandbox and grounding signals
|
| 20 |
+
brier = calibration penalty (overconfidence on bad code is punished)
|
| 21 |
+
uncertain = floor reward for honest uncertainty (below all task targets)
|
| 22 |
+
"""
|
| 23 |
+
quality = _SANDBOX_WEIGHT * sandbox_score + _GROUNDING_WEIGHT * groundedness
|
| 24 |
+
|
| 25 |
+
# Brier calibration: confidence=None treated as 0.5 (mediocre calibration)
|
| 26 |
+
# so agents cannot bypass Brier entirely by omitting confidence.
|
| 27 |
+
effective_confidence = confidence if confidence is not None else 0.5
|
| 28 |
+
brier_penalty = min((effective_confidence - quality) ** 2, _BRIER_CAP)
|
| 29 |
+
|
| 30 |
+
reward = quality * (1.0 - brier_penalty)
|
| 31 |
+
|
| 32 |
+
if (
|
| 33 |
+
confidence is not None
|
| 34 |
+
and confidence < _UNCERTAIN_CONFIDENCE_THRESHOLD
|
| 35 |
+
and quality < _UNCERTAIN_QUALITY_THRESHOLD
|
| 36 |
+
):
|
| 37 |
+
reward = max(reward, _UNCERTAIN_FLOOR)
|
| 38 |
+
|
| 39 |
+
return round(max(0.0, min(1.0, reward)), 3)
|
codeforge/grounder.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import ast
|
| 4 |
+
import importlib
|
| 5 |
+
import importlib.util
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Literal
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel, ConfigDict
|
| 10 |
+
|
| 11 |
+
_log = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# Models
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Symbol(BaseModel):
|
| 20 |
+
"""A single symbol extracted from source code by AST walking."""
|
| 21 |
+
|
| 22 |
+
model_config = ConfigDict(frozen=True)
|
| 23 |
+
module: str
|
| 24 |
+
attr: str | None
|
| 25 |
+
kind: Literal["import", "attribute"]
|
| 26 |
+
resolved: bool
|
| 27 |
+
line: int
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class GroundingReport(BaseModel):
|
| 31 |
+
"""Result of grounding analysis on source code."""
|
| 32 |
+
|
| 33 |
+
model_config = ConfigDict(frozen=True)
|
| 34 |
+
total_symbols: int
|
| 35 |
+
grounded: tuple[Symbol, ...]
|
| 36 |
+
ungrounded: tuple[Symbol, ...]
|
| 37 |
+
groundedness: float
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# Helpers
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _module_spec(name: str) -> bool:
|
| 46 |
+
"""Return True if the module can be found by the import system."""
|
| 47 |
+
try:
|
| 48 |
+
return importlib.util.find_spec(name) is not None
|
| 49 |
+
except (ImportError, ValueError, ModuleNotFoundError):
|
| 50 |
+
return False
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _has_attr(module_name: str, attr: str) -> bool:
|
| 54 |
+
"""Check if *module_name* exposes *attr*.
|
| 55 |
+
|
| 56 |
+
Uses the FULL module path (e.g. ``os.path``) — not just
|
| 57 |
+
the top-level package. This is the fix for SYSTEM_DESIGN §4.8.3
|
| 58 |
+
bug #3.
|
| 59 |
+
"""
|
| 60 |
+
try:
|
| 61 |
+
mod = importlib.import_module(module_name)
|
| 62 |
+
except Exception:
|
| 63 |
+
return False
|
| 64 |
+
return hasattr(mod, attr)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
# Public API
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def ground(
|
| 73 |
+
source: str,
|
| 74 |
+
*,
|
| 75 |
+
local_modules: frozenset[str] = frozenset(),
|
| 76 |
+
) -> GroundingReport:
|
| 77 |
+
"""AST-parse *source*, check every import and attribute access resolves.
|
| 78 |
+
|
| 79 |
+
Three fixes baked in from day one (SYSTEM_DESIGN §4.8.3):
|
| 80 |
+
1. SyntaxError → groundedness=0.0 (was 1.0)
|
| 81 |
+
2. Zero symbols → groundedness=0.5 (was 1.0)
|
| 82 |
+
3. Attribute resolution against full module path (was top-level only)
|
| 83 |
+
|
| 84 |
+
*local_modules*: set of module names (e.g. ``{"core", "main"}``) that are
|
| 85 |
+
local to the agent's project and should be treated as grounded even though
|
| 86 |
+
``importlib.util.find_spec`` cannot resolve them from the grader process.
|
| 87 |
+
"""
|
| 88 |
+
# ----- parse --------------------------------------------------------
|
| 89 |
+
try:
|
| 90 |
+
tree = ast.parse(source)
|
| 91 |
+
except SyntaxError:
|
| 92 |
+
# FIX 1: unparseable code → 0.0, not 1.0
|
| 93 |
+
return GroundingReport(
|
| 94 |
+
total_symbols=0,
|
| 95 |
+
grounded=(),
|
| 96 |
+
ungrounded=(),
|
| 97 |
+
groundedness=0.0,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
symbols: list[Symbol] = []
|
| 101 |
+
import_to_module: dict[str, str] = {}
|
| 102 |
+
|
| 103 |
+
# ----- walk imports -------------------------------------------------
|
| 104 |
+
for node in ast.walk(tree):
|
| 105 |
+
if isinstance(node, ast.Import):
|
| 106 |
+
for alias in node.names:
|
| 107 |
+
pkg = alias.name.split(".")[0]
|
| 108 |
+
# Local modules are always treated as grounded
|
| 109 |
+
resolved = (
|
| 110 |
+
pkg in local_modules or _module_spec(alias.name)
|
| 111 |
+
)
|
| 112 |
+
symbols.append(
|
| 113 |
+
Symbol(
|
| 114 |
+
module=alias.name,
|
| 115 |
+
attr=None,
|
| 116 |
+
kind="import",
|
| 117 |
+
resolved=resolved,
|
| 118 |
+
line=node.lineno,
|
| 119 |
+
)
|
| 120 |
+
)
|
| 121 |
+
import_to_module[alias.asname or pkg] = alias.name
|
| 122 |
+
|
| 123 |
+
elif isinstance(node, ast.ImportFrom):
|
| 124 |
+
if node.level != 0 or node.module is None:
|
| 125 |
+
continue
|
| 126 |
+
mod_top = node.module.split(".")[0]
|
| 127 |
+
is_local = mod_top in local_modules
|
| 128 |
+
resolved_mod = is_local or _module_spec(node.module)
|
| 129 |
+
for alias in (node.names or []):
|
| 130 |
+
attr_resolved = resolved_mod if is_local else (
|
| 131 |
+
resolved_mod and _has_attr(node.module, alias.name)
|
| 132 |
+
)
|
| 133 |
+
symbols.append(
|
| 134 |
+
Symbol(
|
| 135 |
+
module=node.module,
|
| 136 |
+
attr=alias.name,
|
| 137 |
+
kind="import",
|
| 138 |
+
resolved=attr_resolved,
|
| 139 |
+
line=node.lineno,
|
| 140 |
+
)
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# ----- walk attribute accesses --------------------------------------
|
| 144 |
+
for node in ast.walk(tree):
|
| 145 |
+
if not isinstance(node, ast.Attribute):
|
| 146 |
+
continue
|
| 147 |
+
|
| 148 |
+
# Resolve the chain: e.g. os.path.join → base="os", chain=["path"], attr="join"
|
| 149 |
+
chain: list[str] = []
|
| 150 |
+
cursor: ast.expr = node.value
|
| 151 |
+
while isinstance(cursor, ast.Attribute):
|
| 152 |
+
chain.append(cursor.attr)
|
| 153 |
+
cursor = cursor.value
|
| 154 |
+
if not isinstance(cursor, ast.Name):
|
| 155 |
+
continue
|
| 156 |
+
|
| 157 |
+
base = cursor.id
|
| 158 |
+
mod_name = import_to_module.get(base)
|
| 159 |
+
if mod_name is None:
|
| 160 |
+
continue
|
| 161 |
+
|
| 162 |
+
# Build the full module path for chained access:
|
| 163 |
+
# import os.path → import_to_module["os"] = "os.path"
|
| 164 |
+
# os.path.join → chain=["path"], we need to resolve "join" against "os.path"
|
| 165 |
+
# The chain intermediates are sub-module parts already covered by mod_name.
|
| 166 |
+
# We check the final attr against the deepest resolvable module.
|
| 167 |
+
if chain:
|
| 168 |
+
# chain was built bottom-up, reverse to get top-down order
|
| 169 |
+
chain.reverse()
|
| 170 |
+
# Build candidate module: mod_name + chain parts
|
| 171 |
+
full_mod = mod_name + "." + ".".join(chain)
|
| 172 |
+
# Try the full module first; fall back to mod_name if it doesn't exist
|
| 173 |
+
check_mod = full_mod if _module_spec(full_mod) else mod_name
|
| 174 |
+
else:
|
| 175 |
+
check_mod = mod_name
|
| 176 |
+
|
| 177 |
+
# FIX 3: resolve against full module path, not just top-level
|
| 178 |
+
resolved = _has_attr(check_mod, node.attr)
|
| 179 |
+
symbols.append(
|
| 180 |
+
Symbol(
|
| 181 |
+
module=check_mod,
|
| 182 |
+
attr=node.attr,
|
| 183 |
+
kind="attribute",
|
| 184 |
+
resolved=resolved,
|
| 185 |
+
line=node.lineno,
|
| 186 |
+
)
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
# ----- compute groundedness -----------------------------------------
|
| 190 |
+
grounded = tuple(s for s in symbols if s.resolved)
|
| 191 |
+
ungrounded = tuple(s for s in symbols if not s.resolved)
|
| 192 |
+
total = len(symbols)
|
| 193 |
+
|
| 194 |
+
# FIX 2: zero symbols → 0.5 (neutral), not 1.0
|
| 195 |
+
groundedness = 0.5 if total == 0 else len(grounded) / total
|
| 196 |
+
|
| 197 |
+
return GroundingReport(
|
| 198 |
+
total_symbols=total,
|
| 199 |
+
grounded=grounded,
|
| 200 |
+
ungrounded=ungrounded,
|
| 201 |
+
groundedness=groundedness,
|
| 202 |
+
)
|
codeforge/interrogator/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from codeforge.interrogator.interrogator import Interrogator
|
| 4 |
+
from codeforge.interrogator.models import InterrogationResult
|
| 5 |
+
|
| 6 |
+
__all__ = ["InterrogationResult", "Interrogator"]
|
codeforge/interrogator/interrogator.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import TYPE_CHECKING
|
| 4 |
+
|
| 5 |
+
from codeforge.interrogator.models import InterrogationResult
|
| 6 |
+
|
| 7 |
+
if TYPE_CHECKING:
|
| 8 |
+
from codeforge.kb.indexer import SkillsIndex
|
| 9 |
+
|
| 10 |
+
_TEMPLATES = (
|
| 11 |
+
"What is the exact success criterion for '{brief_head}'?",
|
| 12 |
+
"Have you considered the guidance from {skill_name}: '{section_title}'?",
|
| 13 |
+
"Which of these assumptions is most load-bearing: success metric, inputs, failure modes?",
|
| 14 |
+
"What is the single hardest edge case for '{brief_head}'?",
|
| 15 |
+
"Have you consulted {skill_name2} for the patterns it recommends?",
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Interrogator:
|
| 20 |
+
"""Generates Socratic questions that cite real skill corpus nodes."""
|
| 21 |
+
|
| 22 |
+
def __init__(self, index: SkillsIndex | None) -> None:
|
| 23 |
+
self._index = index
|
| 24 |
+
|
| 25 |
+
def generate(self, brief: str, *, top_k: int = 5) -> InterrogationResult:
|
| 26 |
+
brief_head = brief.strip()[:80] or "the task"
|
| 27 |
+
results = (
|
| 28 |
+
self._index.search(brief, top_k=top_k)
|
| 29 |
+
if self._index is not None
|
| 30 |
+
else []
|
| 31 |
+
)
|
| 32 |
+
cited_ids = tuple(r.node_id for r in results[:2])
|
| 33 |
+
first = results[0] if results else None
|
| 34 |
+
second = results[1] if len(results) > 1 else first
|
| 35 |
+
|
| 36 |
+
skill_name = first.skill_name if first else "the skill library"
|
| 37 |
+
section_title = (
|
| 38 |
+
"/".join(first.section_path) if first else "the relevant section"
|
| 39 |
+
)
|
| 40 |
+
skill_name2 = second.skill_name if second else skill_name
|
| 41 |
+
|
| 42 |
+
questions = tuple(
|
| 43 |
+
t.format(
|
| 44 |
+
brief_head=brief_head,
|
| 45 |
+
skill_name=skill_name,
|
| 46 |
+
section_title=section_title,
|
| 47 |
+
skill_name2=skill_name2,
|
| 48 |
+
)
|
| 49 |
+
for t in _TEMPLATES
|
| 50 |
+
)
|
| 51 |
+
return InterrogationResult(questions=questions, cited_node_ids=cited_ids)
|
codeforge/interrogator/models.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class InterrogationResult(BaseModel):
|
| 7 |
+
model_config = ConfigDict(frozen=True)
|
| 8 |
+
questions: tuple[str, ...]
|
| 9 |
+
cited_node_ids: tuple[str, ...]
|
codeforge/kb/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from codeforge.kb.cluster import build_clusters
|
| 4 |
+
from codeforge.kb.indexer import SkillsIndex
|
| 5 |
+
from codeforge.kb.models import (
|
| 6 |
+
Cluster,
|
| 7 |
+
ClusterManifest,
|
| 8 |
+
SearchResult,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
__all__ = [
|
| 12 |
+
"Cluster",
|
| 13 |
+
"ClusterManifest",
|
| 14 |
+
"SearchResult",
|
| 15 |
+
"SkillsIndex",
|
| 16 |
+
"build_clusters",
|
| 17 |
+
]
|
codeforge/kb/cluster.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
from collections import Counter
|
| 5 |
+
from collections.abc import Iterable
|
| 6 |
+
from datetime import UTC, datetime
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from codeforge.kb.models import Cluster, ClusterManifest
|
| 10 |
+
from codeforge.kb.tokenizer import tokenize
|
| 11 |
+
|
| 12 |
+
_STOPWORDS: frozenset[str] = frozenset({
|
| 13 |
+
"the", "and", "for", "with", "this", "that", "are", "was",
|
| 14 |
+
"not", "but", "use", "can", "all", "one", "from", "when",
|
| 15 |
+
"which", "have", "any", "should", "would", "must", "will",
|
| 16 |
+
"your", "you", "our", "its", "their", "them", "they",
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _filter_tokens(tokens: list[str], min_length: int = 3) -> list[str]:
|
| 21 |
+
return [t for t in tokens if len(t) >= min_length and t not in _STOPWORDS]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
| 25 |
+
if not a and not b:
|
| 26 |
+
return 0.0
|
| 27 |
+
union = a | b
|
| 28 |
+
inter = a & b
|
| 29 |
+
return len(inter) / len(union)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _connected_components(
|
| 33 |
+
node_ids: list[str], adj: dict[str, set[str]],
|
| 34 |
+
) -> list[set[str]]:
|
| 35 |
+
seen: set[str] = set()
|
| 36 |
+
comps: list[set[str]] = []
|
| 37 |
+
for start in node_ids:
|
| 38 |
+
if start in seen:
|
| 39 |
+
continue
|
| 40 |
+
comp: set[str] = set()
|
| 41 |
+
stack = [start]
|
| 42 |
+
while stack:
|
| 43 |
+
nid = stack.pop()
|
| 44 |
+
if nid in comp:
|
| 45 |
+
continue
|
| 46 |
+
comp.add(nid)
|
| 47 |
+
seen.add(nid)
|
| 48 |
+
for nbr in adj.get(nid, ()):
|
| 49 |
+
if nbr not in comp:
|
| 50 |
+
stack.append(nbr)
|
| 51 |
+
comps.append(comp)
|
| 52 |
+
return comps
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _dominant_domain(tags_list: Iterable[tuple[str, ...]]) -> str:
|
| 56 |
+
counts: Counter[str] = Counter()
|
| 57 |
+
for tags in tags_list:
|
| 58 |
+
for t in tags:
|
| 59 |
+
if t.startswith("domain:"):
|
| 60 |
+
counts[t.split(":", 1)[1]] += 1
|
| 61 |
+
if not counts:
|
| 62 |
+
return "general"
|
| 63 |
+
return counts.most_common(1)[0][0]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _label_cluster(
|
| 67 |
+
nodes_data: list[dict[str, Any]],
|
| 68 |
+
) -> tuple[str, tuple[str, ...], str]:
|
| 69 |
+
all_tokens: Counter[str] = Counter()
|
| 70 |
+
tag_lists: list[tuple[str, ...]] = []
|
| 71 |
+
for nd in nodes_data:
|
| 72 |
+
all_tokens.update(nd["tokens"])
|
| 73 |
+
tag_lists.append(tuple(nd.get("tags", ())))
|
| 74 |
+
top3 = [t for t, _ in all_tokens.most_common(3)]
|
| 75 |
+
dominant = _dominant_domain(tag_lists)
|
| 76 |
+
label = f"{dominant}_" + "_".join(top3) if top3 else dominant
|
| 77 |
+
return label, tuple(top3), dominant
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def build_clusters(
|
| 81 |
+
nodes: list[dict[str, Any]],
|
| 82 |
+
*,
|
| 83 |
+
jaccard_threshold: float = 0.15,
|
| 84 |
+
min_token_length: int = 3,
|
| 85 |
+
corpus_sha256: str = "",
|
| 86 |
+
generated_at: str | None = None,
|
| 87 |
+
) -> ClusterManifest:
|
| 88 |
+
if not nodes:
|
| 89 |
+
stamp = (
|
| 90 |
+
generated_at
|
| 91 |
+
if generated_at is not None
|
| 92 |
+
else datetime.now(UTC).isoformat(timespec="seconds")
|
| 93 |
+
)
|
| 94 |
+
return ClusterManifest(
|
| 95 |
+
generated_at=stamp,
|
| 96 |
+
corpus_sha256=corpus_sha256,
|
| 97 |
+
jaccard_threshold=jaccard_threshold,
|
| 98 |
+
total_clusters=0,
|
| 99 |
+
total_nodes_clustered=0,
|
| 100 |
+
singletons=0,
|
| 101 |
+
clusters=(),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
node_ids = [str(n["id"]) for n in nodes]
|
| 105 |
+
token_sets: dict[str, set[str]] = {
|
| 106 |
+
str(n["id"]): set(
|
| 107 |
+
_filter_tokens(
|
| 108 |
+
tokenize(str(n.get("section_body", ""))), min_token_length,
|
| 109 |
+
),
|
| 110 |
+
)
|
| 111 |
+
for n in nodes
|
| 112 |
+
}
|
| 113 |
+
adj: dict[str, set[str]] = {nid: set() for nid in node_ids}
|
| 114 |
+
for i, a in enumerate(node_ids):
|
| 115 |
+
for b in node_ids[i + 1 :]:
|
| 116 |
+
sim = _jaccard(token_sets[a], token_sets[b])
|
| 117 |
+
if sim >= jaccard_threshold:
|
| 118 |
+
adj[a].add(b)
|
| 119 |
+
adj[b].add(a)
|
| 120 |
+
|
| 121 |
+
components = _connected_components(node_ids, adj)
|
| 122 |
+
node_by_id = {str(n["id"]): n for n in nodes}
|
| 123 |
+
|
| 124 |
+
clusters: list[Cluster] = []
|
| 125 |
+
singletons = 0
|
| 126 |
+
for comp in components:
|
| 127 |
+
member_ids = sorted(comp)
|
| 128 |
+
nodes_data = [
|
| 129 |
+
{
|
| 130 |
+
"tokens": _filter_tokens(
|
| 131 |
+
tokenize(str(node_by_id[nid].get("section_body", ""))),
|
| 132 |
+
min_token_length,
|
| 133 |
+
),
|
| 134 |
+
"tags": tuple(node_by_id[nid].get("tags", ())),
|
| 135 |
+
}
|
| 136 |
+
for nid in member_ids
|
| 137 |
+
]
|
| 138 |
+
label, top_tokens, dominant = _label_cluster(nodes_data)
|
| 139 |
+
cluster_id = hashlib.sha256(
|
| 140 |
+
"|".join(member_ids).encode(),
|
| 141 |
+
).hexdigest()[:12]
|
| 142 |
+
if len(member_ids) == 1:
|
| 143 |
+
singletons += 1
|
| 144 |
+
clusters.append(
|
| 145 |
+
Cluster(
|
| 146 |
+
cluster_id=cluster_id,
|
| 147 |
+
label=label,
|
| 148 |
+
dominant_domain=dominant,
|
| 149 |
+
top_tokens=top_tokens,
|
| 150 |
+
node_count=len(member_ids),
|
| 151 |
+
member_node_ids=tuple(member_ids),
|
| 152 |
+
),
|
| 153 |
+
)
|
| 154 |
+
clusters.sort(key=lambda c: (-c.node_count, c.cluster_id))
|
| 155 |
+
|
| 156 |
+
stamp = (
|
| 157 |
+
generated_at
|
| 158 |
+
if generated_at is not None
|
| 159 |
+
else datetime.now(UTC).isoformat(timespec="seconds")
|
| 160 |
+
)
|
| 161 |
+
return ClusterManifest(
|
| 162 |
+
generated_at=stamp,
|
| 163 |
+
corpus_sha256=corpus_sha256,
|
| 164 |
+
jaccard_threshold=jaccard_threshold,
|
| 165 |
+
total_clusters=len(clusters),
|
| 166 |
+
total_nodes_clustered=len(node_ids),
|
| 167 |
+
singletons=singletons,
|
| 168 |
+
clusters=tuple(clusters),
|
| 169 |
+
)
|
codeforge/kb/code_graph.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import ast
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import networkx as nx # type: ignore[import-untyped]
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def build_code_graph(files: dict[str, str]) -> nx.DiGraph:
|
| 10 |
+
"""Build a structural graph from Python source files.
|
| 11 |
+
|
| 12 |
+
Nodes: modules, functions, classes
|
| 13 |
+
Edges: imports, calls, inheritance, exports
|
| 14 |
+
"""
|
| 15 |
+
g: nx.DiGraph = nx.DiGraph()
|
| 16 |
+
for filename, source in files.items():
|
| 17 |
+
module = filename.removesuffix(".py")
|
| 18 |
+
g.add_node(module, kind="module")
|
| 19 |
+
try:
|
| 20 |
+
tree = ast.parse(source, filename=filename)
|
| 21 |
+
except SyntaxError:
|
| 22 |
+
continue
|
| 23 |
+
for node in ast.walk(tree):
|
| 24 |
+
if isinstance(node, ast.FunctionDef):
|
| 25 |
+
fqn = f"{module}.{node.name}"
|
| 26 |
+
g.add_node(fqn, kind="function", line=node.lineno)
|
| 27 |
+
g.add_edge(module, fqn, relation="exports")
|
| 28 |
+
elif isinstance(node, ast.ClassDef):
|
| 29 |
+
fqn = f"{module}.{node.name}"
|
| 30 |
+
g.add_node(fqn, kind="class", line=node.lineno)
|
| 31 |
+
g.add_edge(module, fqn, relation="exports")
|
| 32 |
+
for base in node.bases:
|
| 33 |
+
if isinstance(base, ast.Name):
|
| 34 |
+
g.add_edge(fqn, base.id, relation="inherits")
|
| 35 |
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
| 36 |
+
g.add_edge(module, node.module, relation="imports")
|
| 37 |
+
elif isinstance(node, ast.Import):
|
| 38 |
+
for alias in node.names:
|
| 39 |
+
g.add_edge(module, alias.name, relation="imports")
|
| 40 |
+
return g
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def query_graph(g: nx.DiGraph, question: str) -> list[dict[str, Any]]:
|
| 44 |
+
"""Structural queries on the code graph.
|
| 45 |
+
|
| 46 |
+
Supported question prefixes:
|
| 47 |
+
- "exports_of <module>" -- functions/classes exported by module
|
| 48 |
+
- "imports_of <module>" -- modules imported by module
|
| 49 |
+
- "dependents_of <module>" -- modules that import this module
|
| 50 |
+
- "all_modules" -- list all module nodes
|
| 51 |
+
- "all_functions" -- list all function nodes
|
| 52 |
+
- "all_classes" -- list all class nodes
|
| 53 |
+
"""
|
| 54 |
+
parts = question.strip().split(maxsplit=1)
|
| 55 |
+
if len(parts) < 1 or not parts[0]:
|
| 56 |
+
return []
|
| 57 |
+
|
| 58 |
+
cmd = parts[0].lower()
|
| 59 |
+
target = parts[1] if len(parts) > 1 else ""
|
| 60 |
+
|
| 61 |
+
if cmd == "exports_of":
|
| 62 |
+
if target not in g:
|
| 63 |
+
return []
|
| 64 |
+
return [
|
| 65 |
+
{"node": n, **g.nodes[n]}
|
| 66 |
+
for n in g.successors(target)
|
| 67 |
+
if g.edges[target, n].get("relation") == "exports"
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
if cmd == "imports_of":
|
| 71 |
+
if target not in g:
|
| 72 |
+
return []
|
| 73 |
+
return [
|
| 74 |
+
{"node": n}
|
| 75 |
+
for n in g.successors(target)
|
| 76 |
+
if g.edges[target, n].get("relation") == "imports"
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
if cmd == "dependents_of":
|
| 80 |
+
if target not in g:
|
| 81 |
+
return []
|
| 82 |
+
return [
|
| 83 |
+
{"node": n}
|
| 84 |
+
for n in g.predecessors(target)
|
| 85 |
+
if g.edges[n, target].get("relation") == "imports"
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
if cmd == "all_modules":
|
| 89 |
+
return [
|
| 90 |
+
{"node": n, **d}
|
| 91 |
+
for n, d in g.nodes(data=True)
|
| 92 |
+
if d.get("kind") == "module"
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
if cmd == "all_functions":
|
| 96 |
+
return [
|
| 97 |
+
{"node": n, **d}
|
| 98 |
+
for n, d in g.nodes(data=True)
|
| 99 |
+
if d.get("kind") == "function"
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
if cmd == "all_classes":
|
| 103 |
+
return [
|
| 104 |
+
{"node": n, **d}
|
| 105 |
+
for n, d in g.nodes(data=True)
|
| 106 |
+
if d.get("kind") == "class"
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
return []
|
codeforge/kb/corpus_manager.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from codeforge.scraper.discovery import SourceRoot, walk_sources
|
| 9 |
+
from codeforge.scraper.parser import ParseError, parse_skill
|
| 10 |
+
from codeforge.scraper.pipeline import _dedupe, scrape_single_skill
|
| 11 |
+
|
| 12 |
+
_log = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class SkillCorpusManager:
|
| 16 |
+
"""Manages the skill corpus: add, remove, refresh, save/load JSONL."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, *, corpus_path: Path) -> None:
|
| 19 |
+
self._corpus_path = corpus_path
|
| 20 |
+
self._nodes: list[dict[str, Any]] = []
|
| 21 |
+
|
| 22 |
+
@property
|
| 23 |
+
def nodes(self) -> list[dict[str, Any]]:
|
| 24 |
+
"""Read-only access to the current node list."""
|
| 25 |
+
return list(self._nodes)
|
| 26 |
+
|
| 27 |
+
def load(self) -> None:
|
| 28 |
+
"""Load existing corpus from JSONL file."""
|
| 29 |
+
if not self._corpus_path.is_file():
|
| 30 |
+
self._nodes = []
|
| 31 |
+
return
|
| 32 |
+
nodes: list[dict[str, Any]] = []
|
| 33 |
+
with self._corpus_path.open(encoding="utf-8") as f:
|
| 34 |
+
for line in f:
|
| 35 |
+
line = line.strip()
|
| 36 |
+
if not line:
|
| 37 |
+
continue
|
| 38 |
+
nodes.append(json.loads(line))
|
| 39 |
+
self._nodes = nodes
|
| 40 |
+
|
| 41 |
+
def add_skill(self, path: Path) -> int:
|
| 42 |
+
"""Scrape a single SKILL.md, append nodes to corpus. Returns count added."""
|
| 43 |
+
new_nodes = scrape_single_skill(path)
|
| 44 |
+
if not new_nodes:
|
| 45 |
+
return 0
|
| 46 |
+
combined = list(self._nodes) + new_nodes
|
| 47 |
+
deduped = _dedupe(combined)
|
| 48 |
+
added = len(deduped) - len(self._nodes)
|
| 49 |
+
self._nodes = deduped
|
| 50 |
+
return max(added, 0)
|
| 51 |
+
|
| 52 |
+
def remove_skill(self, skill_name: str) -> int:
|
| 53 |
+
"""Remove all nodes for skill_name. Returns count removed."""
|
| 54 |
+
before = len(self._nodes)
|
| 55 |
+
self._nodes = [n for n in self._nodes if n["skill_name"] != skill_name]
|
| 56 |
+
return before - len(self._nodes)
|
| 57 |
+
|
| 58 |
+
def refresh(
|
| 59 |
+
self, *, sources: list[SourceRoot] | None = None
|
| 60 |
+
) -> dict[str, int]:
|
| 61 |
+
"""Diff disk sources vs corpus by mtime/body_hash.
|
| 62 |
+
|
| 63 |
+
Returns {added, removed, unchanged}.
|
| 64 |
+
"""
|
| 65 |
+
if sources is None:
|
| 66 |
+
sources = []
|
| 67 |
+
|
| 68 |
+
# Build a map of current corpus keyed by source_path
|
| 69 |
+
existing_by_path: dict[str, list[dict[str, Any]]] = {}
|
| 70 |
+
for node in self._nodes:
|
| 71 |
+
sp = node.get("source_path", "")
|
| 72 |
+
existing_by_path.setdefault(sp, []).append(node)
|
| 73 |
+
|
| 74 |
+
# Discover what's on disk
|
| 75 |
+
disk_paths: set[str] = set()
|
| 76 |
+
disk_nodes: list[dict[str, Any]] = []
|
| 77 |
+
for path, root in walk_sources(sources):
|
| 78 |
+
disk_paths.add(str(path))
|
| 79 |
+
try:
|
| 80 |
+
parse_skill(path)
|
| 81 |
+
except ParseError:
|
| 82 |
+
continue
|
| 83 |
+
file_nodes = scrape_single_skill(path)
|
| 84 |
+
for n in file_nodes:
|
| 85 |
+
n["source_root"] = root.label
|
| 86 |
+
disk_nodes.extend(file_nodes)
|
| 87 |
+
|
| 88 |
+
# Nodes from sources not on disk anymore -> removed
|
| 89 |
+
removed_count = 0
|
| 90 |
+
kept: list[dict[str, Any]] = []
|
| 91 |
+
for node in self._nodes:
|
| 92 |
+
sp = node.get("source_path", "")
|
| 93 |
+
if sp in disk_paths:
|
| 94 |
+
# Check if body_hash or mtime changed
|
| 95 |
+
pass # handled below
|
| 96 |
+
elif sources:
|
| 97 |
+
# source_path no longer on disk via any source root
|
| 98 |
+
removed_count += 1
|
| 99 |
+
continue
|
| 100 |
+
kept.append(node)
|
| 101 |
+
|
| 102 |
+
# Determine unchanged vs changed via body_hash comparison
|
| 103 |
+
existing_hashes: set[tuple[str, str]] = set()
|
| 104 |
+
for node in kept:
|
| 105 |
+
existing_hashes.add(
|
| 106 |
+
(node.get("source_path", ""), node.get("body_hash", ""))
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Add new/changed nodes from disk
|
| 110 |
+
added_count = 0
|
| 111 |
+
for dn in disk_nodes:
|
| 112 |
+
key = (dn.get("source_path", ""), dn.get("body_hash", ""))
|
| 113 |
+
if key not in existing_hashes:
|
| 114 |
+
added_count += 1
|
| 115 |
+
|
| 116 |
+
# Replace nodes from disk-paths with fresh scraped versions
|
| 117 |
+
non_disk = [n for n in kept if n.get("source_path", "") not in disk_paths]
|
| 118 |
+
combined = non_disk + disk_nodes
|
| 119 |
+
deduped = _dedupe(combined)
|
| 120 |
+
unchanged = len(self._nodes) - removed_count - added_count
|
| 121 |
+
if unchanged < 0:
|
| 122 |
+
unchanged = 0
|
| 123 |
+
|
| 124 |
+
self._nodes = deduped
|
| 125 |
+
return {"added": added_count, "removed": removed_count, "unchanged": unchanged}
|
| 126 |
+
|
| 127 |
+
def save(self) -> None:
|
| 128 |
+
"""Write corpus back to JSONL."""
|
| 129 |
+
self._corpus_path.parent.mkdir(parents=True, exist_ok=True)
|
| 130 |
+
sorted_nodes = sorted(self._nodes, key=lambda n: str(n["id"]))
|
| 131 |
+
with self._corpus_path.open("w", encoding="utf-8") as f:
|
| 132 |
+
for node in sorted_nodes:
|
| 133 |
+
f.write(json.dumps(node, default=str))
|
| 134 |
+
f.write("\n")
|
| 135 |
+
|
| 136 |
+
def node_count(self) -> int:
|
| 137 |
+
"""Return current number of nodes in the corpus."""
|
| 138 |
+
return len(self._nodes)
|
codeforge/kb/indexer.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from rank_bm25 import BM25Okapi # type: ignore[import-untyped]
|
| 9 |
+
|
| 10 |
+
from codeforge.kb.models import Cluster, ClusterManifest, SearchResult
|
| 11 |
+
from codeforge.kb.tokenizer import tokenize
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SkillsIndex:
|
| 15 |
+
"""BM25-backed full-text search over the skill documentation corpus."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, *, corpus_path: Path) -> None:
|
| 18 |
+
self._corpus_path = corpus_path
|
| 19 |
+
self._nodes: list[dict[str, Any]] = []
|
| 20 |
+
self._tokenized: list[list[str]] = []
|
| 21 |
+
self._bm25: BM25Okapi | None = None
|
| 22 |
+
self._corpus_sha256: str = ""
|
| 23 |
+
self._cluster_manifest: ClusterManifest | None = None
|
| 24 |
+
self._node_to_cluster: dict[str, Cluster] = {}
|
| 25 |
+
|
| 26 |
+
def build(self) -> None:
|
| 27 |
+
"""Load JSONL corpus and build BM25 index."""
|
| 28 |
+
if not self._corpus_path.is_file():
|
| 29 |
+
msg = f"corpus missing: {self._corpus_path}"
|
| 30 |
+
raise FileNotFoundError(msg)
|
| 31 |
+
self._corpus_sha256 = hashlib.sha256(
|
| 32 |
+
self._corpus_path.read_bytes(),
|
| 33 |
+
).hexdigest()
|
| 34 |
+
self._nodes = []
|
| 35 |
+
self._tokenized = []
|
| 36 |
+
with self._corpus_path.open(encoding="utf-8") as f:
|
| 37 |
+
for line in f:
|
| 38 |
+
line = line.strip()
|
| 39 |
+
if not line:
|
| 40 |
+
continue
|
| 41 |
+
node: dict[str, Any] = json.loads(line)
|
| 42 |
+
self._nodes.append(node)
|
| 43 |
+
self._tokenized.append(
|
| 44 |
+
tokenize(str(node.get("section_body", ""))),
|
| 45 |
+
)
|
| 46 |
+
if self._tokenized:
|
| 47 |
+
self._bm25 = BM25Okapi(self._tokenized)
|
| 48 |
+
|
| 49 |
+
def search(
|
| 50 |
+
self,
|
| 51 |
+
query: str,
|
| 52 |
+
*,
|
| 53 |
+
top_k: int = 5,
|
| 54 |
+
required_tags: set[str] | None = None,
|
| 55 |
+
) -> list[SearchResult]:
|
| 56 |
+
"""BM25 search over corpus, optionally filtered by tags."""
|
| 57 |
+
q_tokens = tokenize(query)
|
| 58 |
+
if not q_tokens or self._bm25 is None or not self._nodes:
|
| 59 |
+
return []
|
| 60 |
+
candidates: list[int] = list(range(len(self._nodes)))
|
| 61 |
+
if required_tags:
|
| 62 |
+
candidates = [
|
| 63 |
+
i
|
| 64 |
+
for i in candidates
|
| 65 |
+
if required_tags.issubset(set(self._nodes[i].get("tags", [])))
|
| 66 |
+
]
|
| 67 |
+
if not candidates:
|
| 68 |
+
return []
|
| 69 |
+
all_scores = self._bm25.get_scores(q_tokens)
|
| 70 |
+
scored = [(i, float(all_scores[i])) for i in candidates]
|
| 71 |
+
scored.sort(key=lambda pair: (-pair[1], self._nodes[pair[0]]["id"]))
|
| 72 |
+
top = scored[:top_k]
|
| 73 |
+
results: list[SearchResult] = []
|
| 74 |
+
for rank, (i, score) in enumerate(top, start=1):
|
| 75 |
+
node = self._nodes[i]
|
| 76 |
+
cluster = self._node_to_cluster.get(node["id"])
|
| 77 |
+
results.append(
|
| 78 |
+
SearchResult(
|
| 79 |
+
node_id=node["id"],
|
| 80 |
+
skill_name=node["skill_name"],
|
| 81 |
+
section_path=tuple(node["section_path"]),
|
| 82 |
+
section_body=node["section_body"],
|
| 83 |
+
tags=tuple(node["tags"]),
|
| 84 |
+
source_path=node["source_path"],
|
| 85 |
+
score=score,
|
| 86 |
+
rank=rank,
|
| 87 |
+
cluster_id=cluster.cluster_id if cluster else None,
|
| 88 |
+
),
|
| 89 |
+
)
|
| 90 |
+
return results
|
| 91 |
+
|
| 92 |
+
def attach_cluster_manifest(self, manifest: ClusterManifest) -> None:
|
| 93 |
+
"""Wire cluster assignments to nodes for search enrichment."""
|
| 94 |
+
self._cluster_manifest = manifest
|
| 95 |
+
self._node_to_cluster = {
|
| 96 |
+
nid: cluster
|
| 97 |
+
for cluster in manifest.clusters
|
| 98 |
+
for nid in cluster.member_node_ids
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
def cluster_by_label(self, label: str) -> Cluster | None:
|
| 102 |
+
"""Look up a cluster by its label string."""
|
| 103 |
+
if self._cluster_manifest is None:
|
| 104 |
+
return None
|
| 105 |
+
for c in self._cluster_manifest.clusters:
|
| 106 |
+
if c.label == label:
|
| 107 |
+
return c
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
def nodes_in_cluster(
|
| 111 |
+
self,
|
| 112 |
+
cluster_label: str,
|
| 113 |
+
top_k: int = 50,
|
| 114 |
+
) -> list[SearchResult]:
|
| 115 |
+
"""Return corpus nodes belonging to the named cluster."""
|
| 116 |
+
cluster = self.cluster_by_label(cluster_label)
|
| 117 |
+
if cluster is None:
|
| 118 |
+
return []
|
| 119 |
+
member_ids = set(cluster.member_node_ids)
|
| 120 |
+
results: list[SearchResult] = []
|
| 121 |
+
for node in self._nodes:
|
| 122 |
+
if node["id"] not in member_ids:
|
| 123 |
+
continue
|
| 124 |
+
results.append(
|
| 125 |
+
SearchResult(
|
| 126 |
+
node_id=node["id"],
|
| 127 |
+
skill_name=node["skill_name"],
|
| 128 |
+
section_path=tuple(node["section_path"]),
|
| 129 |
+
section_body=node["section_body"],
|
| 130 |
+
tags=tuple(node["tags"]),
|
| 131 |
+
source_path=node["source_path"],
|
| 132 |
+
score=0.0,
|
| 133 |
+
rank=len(results) + 1,
|
| 134 |
+
cluster_id=cluster.cluster_id,
|
| 135 |
+
),
|
| 136 |
+
)
|
| 137 |
+
if len(results) >= top_k:
|
| 138 |
+
break
|
| 139 |
+
return results
|
| 140 |
+
|
| 141 |
+
def stats(self) -> dict[str, int | float]:
|
| 142 |
+
"""Return index statistics: node_count, vocab_size, avg_doc_len."""
|
| 143 |
+
if not self._tokenized:
|
| 144 |
+
return {"node_count": 0, "vocab_size": 0, "avg_doc_len": 0.0}
|
| 145 |
+
vocab: set[str] = set()
|
| 146 |
+
total_len = 0
|
| 147 |
+
for toks in self._tokenized:
|
| 148 |
+
vocab.update(toks)
|
| 149 |
+
total_len += len(toks)
|
| 150 |
+
return {
|
| 151 |
+
"node_count": len(self._nodes),
|
| 152 |
+
"vocab_size": len(vocab),
|
| 153 |
+
"avg_doc_len": total_len / len(self._tokenized),
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
def all_cluster_labels(self) -> list[str]:
|
| 157 |
+
"""Return all cluster labels (for MCP discovery tool)."""
|
| 158 |
+
if self._cluster_manifest is None:
|
| 159 |
+
return []
|
| 160 |
+
return [c.label for c in self._cluster_manifest.clusters]
|
| 161 |
+
|
| 162 |
+
def all_tags(self) -> set[str]:
|
| 163 |
+
"""Return all unique tags across the corpus (for MCP discovery tool)."""
|
| 164 |
+
tags: set[str] = set()
|
| 165 |
+
for node in self._nodes:
|
| 166 |
+
for t in node.get("tags", []):
|
| 167 |
+
tags.add(str(t))
|
| 168 |
+
return tags
|
codeforge/kb/models.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class SearchResult(BaseModel):
|
| 7 |
+
model_config = ConfigDict(frozen=True)
|
| 8 |
+
node_id: str
|
| 9 |
+
skill_name: str
|
| 10 |
+
section_path: tuple[str, ...]
|
| 11 |
+
section_body: str
|
| 12 |
+
tags: tuple[str, ...]
|
| 13 |
+
source_path: str
|
| 14 |
+
score: float
|
| 15 |
+
rank: int
|
| 16 |
+
cluster_id: str | None = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Cluster(BaseModel):
|
| 20 |
+
model_config = ConfigDict(frozen=True)
|
| 21 |
+
cluster_id: str
|
| 22 |
+
label: str
|
| 23 |
+
dominant_domain: str
|
| 24 |
+
top_tokens: tuple[str, ...]
|
| 25 |
+
node_count: int
|
| 26 |
+
member_node_ids: tuple[str, ...]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ClusterManifest(BaseModel):
|
| 30 |
+
model_config = ConfigDict(frozen=True)
|
| 31 |
+
generated_at: str
|
| 32 |
+
corpus_sha256: str
|
| 33 |
+
jaccard_threshold: float
|
| 34 |
+
total_clusters: int
|
| 35 |
+
total_nodes_clustered: int
|
| 36 |
+
singletons: int
|
| 37 |
+
clusters: tuple[Cluster, ...]
|
codeforge/kb/skills_corpus.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
codeforge/kb/skills_corpus.manifest.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"generated_at": "2026-04-18T10:23:57+00:00",
|
| 3 |
+
"sources": [
|
| 4 |
+
{
|
| 5 |
+
"label": "ecc",
|
| 6 |
+
"glob": "CODEFORGE/everything-claude-code/skills/*/SKILL.md"
|
| 7 |
+
}
|
| 8 |
+
],
|
| 9 |
+
"scraped_files": 183,
|
| 10 |
+
"skipped_files": 0,
|
| 11 |
+
"total_nodes": 2212,
|
| 12 |
+
"errors": [],
|
| 13 |
+
"corpus_sha256": "7cbc3be718ad4365b2061ee253e15d9f6de7e0e52c43198ffd021754c02029d3"
|
| 14 |
+
}
|
codeforge/kb/tokenizer.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
_SPLIT_RE = re.compile(r"[^\w]+", re.UNICODE)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def tokenize(text: str) -> list[str]:
|
| 9 |
+
return [t for t in _SPLIT_RE.split(text.lower()) if t]
|
codeforge/mcp_server.py
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
from uuid import uuid4
|
| 9 |
+
|
| 10 |
+
from codeforge.environment import CodeForgeEnvironment
|
| 11 |
+
from codeforge.models import CodeForgeAction, CodeForgeActionType
|
| 12 |
+
from codeforge.ralph.synthesizer import Synthesizer
|
| 13 |
+
from codeforge.tasks import TASKS
|
| 14 |
+
|
| 15 |
+
_log = logging.getLogger(__name__)
|
| 16 |
+
_VERSION = "0.2.0"
|
| 17 |
+
|
| 18 |
+
_Handler = Callable[
|
| 19 |
+
["CodeForgeMCPServer", "dict[str, Any]"],
|
| 20 |
+
"dict[str, Any]",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
_SID_DESC = "Session ID from codeforge_reset."
|
| 24 |
+
_SID_PROP: dict[str, str] = {
|
| 25 |
+
"type": "string",
|
| 26 |
+
"description": _SID_DESC,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
# -------------------------------------------------------------------
|
| 30 |
+
# Tool schema definitions (SYSTEM_DESIGN §9.1)
|
| 31 |
+
# -------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
_TOOL_DEFS: tuple[dict[str, Any], ...] = (
|
| 34 |
+
{
|
| 35 |
+
"name": "codeforge_reset",
|
| 36 |
+
"description": (
|
| 37 |
+
"Start a new CodeForge episode. You will receive a task "
|
| 38 |
+
"brief and initial files. Your goal is to produce working "
|
| 39 |
+
"Python code that passes sandbox verification. Budget is "
|
| 40 |
+
"limited — plan your actions carefully."
|
| 41 |
+
),
|
| 42 |
+
"inputSchema": {
|
| 43 |
+
"type": "object",
|
| 44 |
+
"properties": {
|
| 45 |
+
"task_level": {
|
| 46 |
+
"type": "string",
|
| 47 |
+
"enum": ["easy", "medium", "hard"],
|
| 48 |
+
"description": (
|
| 49 |
+
"Difficulty level. Easy: single file, budget "
|
| 50 |
+
"4. Medium: multi-file with tests, budget 6. "
|
| 51 |
+
"Hard: three-file module, budget 10."
|
| 52 |
+
),
|
| 53 |
+
},
|
| 54 |
+
},
|
| 55 |
+
"required": ["task_level"],
|
| 56 |
+
},
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"name": "codeforge_query_kb",
|
| 60 |
+
"description": (
|
| 61 |
+
"Search the coding skills knowledge base. Returns real "
|
| 62 |
+
"documentation from 1006 skill nodes. Use this to find "
|
| 63 |
+
"patterns, best practices, and guidance BEFORE writing "
|
| 64 |
+
"code. Costs 1 budget unit. DO NOT guess library APIs — "
|
| 65 |
+
"search for them here or verify via documentation first."
|
| 66 |
+
),
|
| 67 |
+
"inputSchema": {
|
| 68 |
+
"type": "object",
|
| 69 |
+
"properties": {
|
| 70 |
+
"session_id": _SID_PROP,
|
| 71 |
+
"claim": {
|
| 72 |
+
"type": "string",
|
| 73 |
+
"description": (
|
| 74 |
+
"What you want to find guidance on. Be "
|
| 75 |
+
"specific. Example: 'pytest fixture patterns "
|
| 76 |
+
"for testing greet functions'"
|
| 77 |
+
),
|
| 78 |
+
},
|
| 79 |
+
"top_k": {
|
| 80 |
+
"type": "integer",
|
| 81 |
+
"default": 5,
|
| 82 |
+
"minimum": 1,
|
| 83 |
+
"maximum": 20,
|
| 84 |
+
"description": "Number of results to return",
|
| 85 |
+
},
|
| 86 |
+
"required_tags": {
|
| 87 |
+
"type": "array",
|
| 88 |
+
"items": {"type": "string"},
|
| 89 |
+
"default": [],
|
| 90 |
+
"description": (
|
| 91 |
+
"Only return nodes that have ALL of these "
|
| 92 |
+
"tags"
|
| 93 |
+
),
|
| 94 |
+
},
|
| 95 |
+
},
|
| 96 |
+
"required": ["session_id", "claim"],
|
| 97 |
+
},
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"name": "codeforge_query_cluster",
|
| 101 |
+
"description": (
|
| 102 |
+
"Browse a skill cluster by label. Clusters are communities "
|
| 103 |
+
"of related skill nodes grouped by Jaccard similarity. Use "
|
| 104 |
+
"this to explore a topic area deeply. Costs 1 budget unit."
|
| 105 |
+
),
|
| 106 |
+
"inputSchema": {
|
| 107 |
+
"type": "object",
|
| 108 |
+
"properties": {
|
| 109 |
+
"session_id": _SID_PROP,
|
| 110 |
+
"cluster_label": {
|
| 111 |
+
"type": "string",
|
| 112 |
+
"description": (
|
| 113 |
+
"The cluster label to look up. Example: "
|
| 114 |
+
"'python_testing_pytest_fixtures'"
|
| 115 |
+
),
|
| 116 |
+
},
|
| 117 |
+
"top_k": {
|
| 118 |
+
"type": "integer",
|
| 119 |
+
"default": 10,
|
| 120 |
+
"minimum": 1,
|
| 121 |
+
"maximum": 50,
|
| 122 |
+
},
|
| 123 |
+
},
|
| 124 |
+
"required": ["session_id", "cluster_label"],
|
| 125 |
+
},
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"name": "codeforge_interrogate",
|
| 129 |
+
"description": (
|
| 130 |
+
"Get Socratic questions about the task that cite real skill "
|
| 131 |
+
"corpus nodes. Use this BEFORE writing code to identify "
|
| 132 |
+
"edge cases, success criteria, and assumptions you might be "
|
| 133 |
+
"wrong about. Costs 1 budget unit."
|
| 134 |
+
),
|
| 135 |
+
"inputSchema": {
|
| 136 |
+
"type": "object",
|
| 137 |
+
"properties": {
|
| 138 |
+
"session_id": _SID_PROP,
|
| 139 |
+
"brief_override": {
|
| 140 |
+
"type": "string",
|
| 141 |
+
"description": (
|
| 142 |
+
"Optional override for the task brief. "
|
| 143 |
+
"If omitted, uses the current task brief."
|
| 144 |
+
),
|
| 145 |
+
},
|
| 146 |
+
},
|
| 147 |
+
"required": ["session_id"],
|
| 148 |
+
},
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"name": "codeforge_run_ralph",
|
| 152 |
+
"description": (
|
| 153 |
+
"Run autonomous improvement iterations on your current "
|
| 154 |
+
"code. Each iteration: synthesize improvement → "
|
| 155 |
+
"sandbox-score → keep if better. Costs max_iters budget "
|
| 156 |
+
"units. Wasted iterations (no improvement) cost 0.05 "
|
| 157 |
+
"penalty each. Use when you want the environment to "
|
| 158 |
+
"iteratively improve your code."
|
| 159 |
+
),
|
| 160 |
+
"inputSchema": {
|
| 161 |
+
"type": "object",
|
| 162 |
+
"properties": {
|
| 163 |
+
"session_id": _SID_PROP,
|
| 164 |
+
"max_iters": {
|
| 165 |
+
"type": "integer",
|
| 166 |
+
"default": 3,
|
| 167 |
+
"minimum": 1,
|
| 168 |
+
"maximum": 10,
|
| 169 |
+
"description": (
|
| 170 |
+
"Maximum iterations. Each costs 1 budget. "
|
| 171 |
+
"Choose carefully."
|
| 172 |
+
),
|
| 173 |
+
},
|
| 174 |
+
},
|
| 175 |
+
"required": ["session_id", "max_iters"],
|
| 176 |
+
},
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"name": "codeforge_submit",
|
| 180 |
+
"description": (
|
| 181 |
+
"Submit Python files for grading. Your code will be: "
|
| 182 |
+
"(1) written to a sandbox and checked by ruff, mypy "
|
| 183 |
+
"--strict, pytest, and import resolution — these are REAL "
|
| 184 |
+
"tools, not mocks; (2) AST-grounded to verify every "
|
| 185 |
+
"import and attribute access resolves to a real Python "
|
| 186 |
+
"module/attribute; (3) scored via quality = 0.6*sandbox + "
|
| 187 |
+
"0.4*groundedness; (4) if you provide confidence, "
|
| 188 |
+
"Brier-penalized: reward = quality * (1 - "
|
| 189 |
+
"min((confidence-quality)^2, 0.5)). DO NOT fabricate "
|
| 190 |
+
"library names or API signatures — the grounder WILL "
|
| 191 |
+
"catch them and your score WILL drop."
|
| 192 |
+
),
|
| 193 |
+
"inputSchema": {
|
| 194 |
+
"type": "object",
|
| 195 |
+
"properties": {
|
| 196 |
+
"session_id": _SID_PROP,
|
| 197 |
+
"files": {
|
| 198 |
+
"type": "object",
|
| 199 |
+
"additionalProperties": {"type": "string"},
|
| 200 |
+
"description": (
|
| 201 |
+
"Map of filename to file content. Example: "
|
| 202 |
+
'{"main.py": "def greet(name: str) -> str:'
|
| 203 |
+
"\\n return f'Hello, {name}!'\\n\"}"
|
| 204 |
+
),
|
| 205 |
+
},
|
| 206 |
+
"confidence": {
|
| 207 |
+
"type": "number",
|
| 208 |
+
"minimum": 0.0,
|
| 209 |
+
"maximum": 1.0,
|
| 210 |
+
"description": (
|
| 211 |
+
"Your confidence that this submission is "
|
| 212 |
+
"correct (0.0 = no idea, 1.0 = certain). "
|
| 213 |
+
"Overconfidence on bad code is PENALIZED. "
|
| 214 |
+
"Honest uncertainty is treated more "
|
| 215 |
+
"favorably. If you are unsure, say so."
|
| 216 |
+
),
|
| 217 |
+
},
|
| 218 |
+
},
|
| 219 |
+
"required": ["session_id", "files"],
|
| 220 |
+
},
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"name": "codeforge_get_audit",
|
| 224 |
+
"description": (
|
| 225 |
+
"Read the audit trail for the current episode (or a "
|
| 226 |
+
"specific run). Returns every action taken, every citation "
|
| 227 |
+
"made, every reward earned, and the evidence behind each. "
|
| 228 |
+
"Costs 0 budget. Use this to review your progress and "
|
| 229 |
+
"understand what worked."
|
| 230 |
+
),
|
| 231 |
+
"inputSchema": {
|
| 232 |
+
"type": "object",
|
| 233 |
+
"properties": {
|
| 234 |
+
"session_id": _SID_PROP,
|
| 235 |
+
"target_run_id": {
|
| 236 |
+
"type": "string",
|
| 237 |
+
"description": (
|
| 238 |
+
"Optional run ID to audit. "
|
| 239 |
+
"Defaults to current episode."
|
| 240 |
+
),
|
| 241 |
+
},
|
| 242 |
+
},
|
| 243 |
+
"required": ["session_id"],
|
| 244 |
+
},
|
| 245 |
+
},
|
| 246 |
+
{
|
| 247 |
+
"name": "codeforge_state",
|
| 248 |
+
"description": (
|
| 249 |
+
"Get current episode state without taking an action. Shows "
|
| 250 |
+
"task brief, current files, budget remaining, last reward, "
|
| 251 |
+
"and whether the episode is done. Costs 0 budget."
|
| 252 |
+
),
|
| 253 |
+
"inputSchema": {
|
| 254 |
+
"type": "object",
|
| 255 |
+
"properties": {"session_id": _SID_PROP},
|
| 256 |
+
"required": ["session_id"],
|
| 257 |
+
},
|
| 258 |
+
},
|
| 259 |
+
{
|
| 260 |
+
"name": "codeforge_list_clusters",
|
| 261 |
+
"description": (
|
| 262 |
+
"List all available cluster labels and their node counts. "
|
| 263 |
+
"Use this to discover what topic areas exist before "
|
| 264 |
+
"calling codeforge_query_cluster. Costs 0 budget."
|
| 265 |
+
),
|
| 266 |
+
"inputSchema": {
|
| 267 |
+
"type": "object",
|
| 268 |
+
"properties": {"session_id": _SID_PROP},
|
| 269 |
+
},
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"name": "codeforge_list_tags",
|
| 273 |
+
"description": (
|
| 274 |
+
"List all available tags in the skill corpus. Use this to "
|
| 275 |
+
"discover valid values for the required_tags parameter. "
|
| 276 |
+
"Costs 0 budget."
|
| 277 |
+
),
|
| 278 |
+
"inputSchema": {
|
| 279 |
+
"type": "object",
|
| 280 |
+
"properties": {"session_id": _SID_PROP},
|
| 281 |
+
},
|
| 282 |
+
},
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
# -------------------------------------------------------------------
|
| 286 |
+
# Resource definitions
|
| 287 |
+
# -------------------------------------------------------------------
|
| 288 |
+
|
| 289 |
+
_RESOURCE_DEFS: tuple[dict[str, str], ...] = (
|
| 290 |
+
{
|
| 291 |
+
"uri": "codeforge://corpus/stats",
|
| 292 |
+
"name": "Corpus Statistics",
|
| 293 |
+
"description": (
|
| 294 |
+
"Corpus statistics (node count, vocab size, cluster count)"
|
| 295 |
+
),
|
| 296 |
+
"mimeType": "application/json",
|
| 297 |
+
},
|
| 298 |
+
{
|
| 299 |
+
"uri": "codeforge://corpus/node/{node_id}",
|
| 300 |
+
"name": "Skill Node",
|
| 301 |
+
"description": (
|
| 302 |
+
"Full content of a specific skill node (free, no budget)"
|
| 303 |
+
),
|
| 304 |
+
"mimeType": "application/json",
|
| 305 |
+
},
|
| 306 |
+
{
|
| 307 |
+
"uri": "codeforge://tasks",
|
| 308 |
+
"name": "Task Definitions",
|
| 309 |
+
"description": (
|
| 310 |
+
"Task definitions with briefs, budgets, targets, tools"
|
| 311 |
+
),
|
| 312 |
+
"mimeType": "application/json",
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
"uri": "codeforge://audit/{episode_id}",
|
| 316 |
+
"name": "Audit Ledger",
|
| 317 |
+
"description": (
|
| 318 |
+
"Serialized audit ledger for a completed episode"
|
| 319 |
+
),
|
| 320 |
+
"mimeType": "application/json",
|
| 321 |
+
},
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
# -------------------------------------------------------------------
|
| 325 |
+
# Prompt text
|
| 326 |
+
# -------------------------------------------------------------------
|
| 327 |
+
|
| 328 |
+
_SYSTEM_PROMPT_TEXT = (
|
| 329 |
+
"You are solving a CodeForge episode. Your code is graded by "
|
| 330 |
+
"REAL tools (ruff, mypy --strict, pytest, import resolution) in "
|
| 331 |
+
"a sandbox. Every import and attribute access is AST-grounded "
|
| 332 |
+
"against the real Python runtime. Overconfidence is penalized "
|
| 333 |
+
"via Brier scoring. Honest uncertainty about genuinely uncertain "
|
| 334 |
+
"results is rewarded.\n\n"
|
| 335 |
+
"Rules:\n"
|
| 336 |
+
"- DO NOT fabricate library names or API signatures — "
|
| 337 |
+
"the grounder catches them.\n"
|
| 338 |
+
"- DO NOT submit stubs (pass, ..., NotImplementedError) — "
|
| 339 |
+
"they score zero.\n"
|
| 340 |
+
"- Use codeforge_query_kb to find patterns BEFORE writing code.\n"
|
| 341 |
+
"- Use codeforge_interrogate to identify edge cases.\n"
|
| 342 |
+
"- Budget is limited. Plan actions carefully.\n"
|
| 343 |
+
"- If unsure of your confidence, set it low — "
|
| 344 |
+
"the grader rewards honesty.\n"
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
_SESSION_ERR = "Invalid session_id: {sid!r}. Call codeforge_reset first."
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# -------------------------------------------------------------------
|
| 351 |
+
# Helpers
|
| 352 |
+
# -------------------------------------------------------------------
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def _obs_to_dict(obs: Any) -> dict[str, Any]:
|
| 356 |
+
"""Convert a CodeForgeObservation to a serializable dict."""
|
| 357 |
+
result: dict[str, Any] = json.loads(obs.model_dump_json())
|
| 358 |
+
return result
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def _make_response(
|
| 362 |
+
obs: Any,
|
| 363 |
+
*,
|
| 364 |
+
session_id: str | None = None,
|
| 365 |
+
extra: dict[str, Any] | None = None,
|
| 366 |
+
) -> dict[str, Any]:
|
| 367 |
+
"""Build a versioned response dict from an observation."""
|
| 368 |
+
result: dict[str, Any] = {"_codeforge_version": _VERSION}
|
| 369 |
+
if session_id is not None:
|
| 370 |
+
result["session_id"] = session_id
|
| 371 |
+
result["observation"] = _obs_to_dict(obs)
|
| 372 |
+
if extra:
|
| 373 |
+
result.update(extra)
|
| 374 |
+
budget = result["observation"].get("budget_remaining", 0)
|
| 375 |
+
if isinstance(budget, int) and 0 < budget <= 2:
|
| 376 |
+
result["budget_warning"] = (
|
| 377 |
+
f"WARNING: {budget} budget remaining — plan carefully."
|
| 378 |
+
)
|
| 379 |
+
return result
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _session_error(sid: str) -> dict[str, Any]:
|
| 383 |
+
"""Return an isError response for a missing session."""
|
| 384 |
+
return {
|
| 385 |
+
"isError": True,
|
| 386 |
+
"error": _SESSION_ERR.format(sid=sid),
|
| 387 |
+
"_codeforge_version": _VERSION,
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def _require_session(
|
| 392 |
+
server: CodeForgeMCPServer,
|
| 393 |
+
arguments: dict[str, Any],
|
| 394 |
+
) -> tuple[str, CodeForgeEnvironment | None]:
|
| 395 |
+
"""Extract session_id and look up the environment."""
|
| 396 |
+
sid: str = arguments.get("session_id", "")
|
| 397 |
+
return sid, server._get_session(sid)
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
# -------------------------------------------------------------------
|
| 401 |
+
# CodeForgeMCPServer — embedded mode
|
| 402 |
+
# -------------------------------------------------------------------
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
class CodeForgeMCPServer:
|
| 406 |
+
"""MCP server wrapping CodeForgeEnvironment (SYSTEM_DESIGN §9).
|
| 407 |
+
|
| 408 |
+
Embedded mode: imports CodeForgeEnvironment directly.
|
| 409 |
+
Each tool call is routed to a session-keyed environment.
|
| 410 |
+
|
| 411 |
+
**Universal LLM support:** The Ralph loop's synthesizer is configurable.
|
| 412 |
+
Whichever LLM connects to this MCP server can provide its own config::
|
| 413 |
+
|
| 414 |
+
# Ollama (local, free)
|
| 415 |
+
server = CodeForgeMCPServer(llm_provider="ollama", llm_model="llama3")
|
| 416 |
+
|
| 417 |
+
# OpenAI
|
| 418 |
+
server = CodeForgeMCPServer(llm_provider="openai", llm_model="gpt-4o")
|
| 419 |
+
|
| 420 |
+
# Anthropic
|
| 421 |
+
server = CodeForgeMCPServer(llm_provider="anthropic", llm_model="claude-sonnet-4-20250514")
|
| 422 |
+
|
| 423 |
+
# Any OpenAI-compatible (vLLM, LM Studio, Together, Groq)
|
| 424 |
+
server = CodeForgeMCPServer(
|
| 425 |
+
llm_provider="openai",
|
| 426 |
+
llm_base_url="http://localhost:8000/v1",
|
| 427 |
+
llm_model="my-model",
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
When no LLM config is provided, Ralph uses the deterministic
|
| 431 |
+
StubSynthesizer (no API calls needed).
|
| 432 |
+
"""
|
| 433 |
+
|
| 434 |
+
def __init__(
|
| 435 |
+
self,
|
| 436 |
+
*,
|
| 437 |
+
corpus_path: Path | None = None,
|
| 438 |
+
max_sessions: int = 10,
|
| 439 |
+
llm_provider: str | None = None,
|
| 440 |
+
llm_api_key: str | None = None,
|
| 441 |
+
llm_base_url: str | None = None,
|
| 442 |
+
llm_model: str | None = None,
|
| 443 |
+
) -> None:
|
| 444 |
+
self._corpus_path = corpus_path
|
| 445 |
+
self._sessions: dict[str, CodeForgeEnvironment] = {}
|
| 446 |
+
self._max_sessions = max_sessions
|
| 447 |
+
self._llm_provider = llm_provider
|
| 448 |
+
self._llm_api_key = llm_api_key
|
| 449 |
+
self._llm_base_url = llm_base_url
|
| 450 |
+
self._llm_model = llm_model
|
| 451 |
+
|
| 452 |
+
# -- Session management ------------------------------------------
|
| 453 |
+
|
| 454 |
+
def _get_session(
|
| 455 |
+
self,
|
| 456 |
+
session_id: str,
|
| 457 |
+
) -> CodeForgeEnvironment | None:
|
| 458 |
+
return self._sessions.get(session_id)
|
| 459 |
+
|
| 460 |
+
def _create_session(
|
| 461 |
+
self,
|
| 462 |
+
) -> tuple[str, CodeForgeEnvironment]:
|
| 463 |
+
sid = uuid4().hex[:16]
|
| 464 |
+
# Build synthesizer from LLM config (if provided)
|
| 465 |
+
synth: Synthesizer | None = None
|
| 466 |
+
if self._llm_provider:
|
| 467 |
+
from codeforge.ralph.synthesizer import LLMSynthesizer
|
| 468 |
+
|
| 469 |
+
synth = LLMSynthesizer(
|
| 470 |
+
provider=self._llm_provider,
|
| 471 |
+
api_key=self._llm_api_key,
|
| 472 |
+
base_url=self._llm_base_url,
|
| 473 |
+
model=self._llm_model,
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
env = CodeForgeEnvironment(
|
| 477 |
+
corpus_path=self._corpus_path,
|
| 478 |
+
synthesizer=synth,
|
| 479 |
+
)
|
| 480 |
+
if len(self._sessions) >= self._max_sessions:
|
| 481 |
+
oldest = next(iter(self._sessions))
|
| 482 |
+
del self._sessions[oldest]
|
| 483 |
+
self._sessions[sid] = env
|
| 484 |
+
return sid, env
|
| 485 |
+
|
| 486 |
+
# -- Public: definitions -----------------------------------------
|
| 487 |
+
|
| 488 |
+
def tool_definitions(self) -> list[dict[str, Any]]:
|
| 489 |
+
"""Return tool schemas matching SYSTEM_DESIGN §9.1."""
|
| 490 |
+
return [dict(d) for d in _TOOL_DEFS]
|
| 491 |
+
|
| 492 |
+
def resource_definitions(self) -> list[dict[str, str]]:
|
| 493 |
+
"""Return MCP resource definitions."""
|
| 494 |
+
return [dict(r) for r in _RESOURCE_DEFS]
|
| 495 |
+
|
| 496 |
+
def prompt_definitions(self) -> list[dict[str, Any]]:
|
| 497 |
+
"""Return MCP prompt definitions."""
|
| 498 |
+
return [
|
| 499 |
+
{
|
| 500 |
+
"name": "codeforge_system",
|
| 501 |
+
"description": (
|
| 502 |
+
"System prompt injected at session start. "
|
| 503 |
+
"Contains task rules, budget constraints, "
|
| 504 |
+
"grading explanation."
|
| 505 |
+
),
|
| 506 |
+
"arguments": [],
|
| 507 |
+
},
|
| 508 |
+
{
|
| 509 |
+
"name": "codeforge_task_brief",
|
| 510 |
+
"description": (
|
| 511 |
+
"Dynamic prompt populated with the current "
|
| 512 |
+
"task's brief, initial files, budget, target "
|
| 513 |
+
"score, and tool config."
|
| 514 |
+
),
|
| 515 |
+
"arguments": [
|
| 516 |
+
{
|
| 517 |
+
"name": "session_id",
|
| 518 |
+
"description": _SID_DESC,
|
| 519 |
+
"required": True,
|
| 520 |
+
},
|
| 521 |
+
],
|
| 522 |
+
},
|
| 523 |
+
]
|
| 524 |
+
|
| 525 |
+
# -- Public: handle_tool -----------------------------------------
|
| 526 |
+
|
| 527 |
+
def handle_tool(
|
| 528 |
+
self,
|
| 529 |
+
tool_name: str,
|
| 530 |
+
arguments: dict[str, Any],
|
| 531 |
+
) -> dict[str, Any]:
|
| 532 |
+
"""Route tool call to handler, return result dict."""
|
| 533 |
+
handler = _HANDLERS.get(tool_name)
|
| 534 |
+
if handler is None:
|
| 535 |
+
return {
|
| 536 |
+
"isError": True,
|
| 537 |
+
"error": f"Unknown tool: {tool_name!r}",
|
| 538 |
+
"_codeforge_version": _VERSION,
|
| 539 |
+
}
|
| 540 |
+
return handler(self, arguments)
|
| 541 |
+
|
| 542 |
+
# -- Public: resources -------------------------------------------
|
| 543 |
+
|
| 544 |
+
def read_resource(
|
| 545 |
+
self,
|
| 546 |
+
uri: str,
|
| 547 |
+
*,
|
| 548 |
+
session_id: str | None = None,
|
| 549 |
+
) -> dict[str, Any]:
|
| 550 |
+
"""Read an MCP resource by URI."""
|
| 551 |
+
if uri == "codeforge://corpus/stats":
|
| 552 |
+
if session_id is None:
|
| 553 |
+
return {
|
| 554 |
+
"_codeforge_version": _VERSION,
|
| 555 |
+
"error": "session_id required for corpus stats",
|
| 556 |
+
}
|
| 557 |
+
env = self._get_session(session_id)
|
| 558 |
+
if env is None:
|
| 559 |
+
return {"_codeforge_version": _VERSION, "error": "Invalid session_id"}
|
| 560 |
+
idx = env._ensure_index()
|
| 561 |
+
stats = idx.stats()
|
| 562 |
+
cluster_count = len(idx.all_cluster_labels())
|
| 563 |
+
return {
|
| 564 |
+
"node_count": stats["node_count"],
|
| 565 |
+
"vocab_size": stats["vocab_size"],
|
| 566 |
+
"avg_doc_len": stats["avg_doc_len"],
|
| 567 |
+
"cluster_count": cluster_count,
|
| 568 |
+
}
|
| 569 |
+
if uri.startswith("codeforge://corpus/node/"):
|
| 570 |
+
node_id = uri.removeprefix("codeforge://corpus/node/")
|
| 571 |
+
if session_id is None:
|
| 572 |
+
return {
|
| 573 |
+
"_codeforge_version": _VERSION,
|
| 574 |
+
"error": "session_id required for node lookup",
|
| 575 |
+
}
|
| 576 |
+
env = self._get_session(session_id)
|
| 577 |
+
if env is None:
|
| 578 |
+
return {"_codeforge_version": _VERSION, "error": "Invalid session_id"}
|
| 579 |
+
idx = env._ensure_index()
|
| 580 |
+
for node in idx._nodes:
|
| 581 |
+
if node.get("id") == node_id:
|
| 582 |
+
return {"_codeforge_version": _VERSION, "node": node}
|
| 583 |
+
return {"_codeforge_version": _VERSION, "error": f"Node {node_id!r} not found"}
|
| 584 |
+
if uri == "codeforge://tasks":
|
| 585 |
+
return {
|
| 586 |
+
"_codeforge_version": _VERSION,
|
| 587 |
+
"tasks": [
|
| 588 |
+
{
|
| 589 |
+
"id": t.task_id,
|
| 590 |
+
"difficulty": t.task_level,
|
| 591 |
+
"brief": t.brief,
|
| 592 |
+
"target_score": t.target_score,
|
| 593 |
+
"max_budget": t.max_budget,
|
| 594 |
+
"tools": list(t.tools),
|
| 595 |
+
}
|
| 596 |
+
for t in TASKS
|
| 597 |
+
],
|
| 598 |
+
}
|
| 599 |
+
if uri.startswith("codeforge://audit/"):
|
| 600 |
+
episode_id = uri.removeprefix("codeforge://audit/")
|
| 601 |
+
if session_id is None:
|
| 602 |
+
return {
|
| 603 |
+
"_codeforge_version": _VERSION,
|
| 604 |
+
"error": "session_id required for audit lookup",
|
| 605 |
+
}
|
| 606 |
+
env = self._get_session(session_id)
|
| 607 |
+
if env is None:
|
| 608 |
+
return {"_codeforge_version": _VERSION, "error": "Invalid session_id"}
|
| 609 |
+
if env._ledger is not None:
|
| 610 |
+
return {
|
| 611 |
+
"_codeforge_version": _VERSION,
|
| 612 |
+
"episode_id": episode_id,
|
| 613 |
+
"audit": env._ledger.serialize(),
|
| 614 |
+
}
|
| 615 |
+
return {
|
| 616 |
+
"_codeforge_version": _VERSION,
|
| 617 |
+
"error": "No audit data for this session",
|
| 618 |
+
}
|
| 619 |
+
return {"_codeforge_version": _VERSION, "error": f"Unknown resource URI: {uri!r}"}
|
| 620 |
+
|
| 621 |
+
# -- Public: prompts ---------------------------------------------
|
| 622 |
+
|
| 623 |
+
def get_prompt(
|
| 624 |
+
self,
|
| 625 |
+
name: str,
|
| 626 |
+
*,
|
| 627 |
+
session_id: str | None = None,
|
| 628 |
+
) -> list[dict[str, str]]:
|
| 629 |
+
"""Return prompt messages for the given prompt name."""
|
| 630 |
+
if name == "codeforge_system":
|
| 631 |
+
return [
|
| 632 |
+
{"role": "system", "content": _SYSTEM_PROMPT_TEXT},
|
| 633 |
+
]
|
| 634 |
+
if name == "codeforge_task_brief":
|
| 635 |
+
if session_id is None:
|
| 636 |
+
return [
|
| 637 |
+
{
|
| 638 |
+
"role": "system",
|
| 639 |
+
"content": (
|
| 640 |
+
"Error: session_id required for "
|
| 641 |
+
"task_brief prompt."
|
| 642 |
+
),
|
| 643 |
+
},
|
| 644 |
+
]
|
| 645 |
+
env = self._get_session(session_id)
|
| 646 |
+
if env is None:
|
| 647 |
+
return [
|
| 648 |
+
{
|
| 649 |
+
"role": "system",
|
| 650 |
+
"content": "Error: invalid session_id.",
|
| 651 |
+
},
|
| 652 |
+
]
|
| 653 |
+
obs = env.state
|
| 654 |
+
task = env._task
|
| 655 |
+
target = task.target_score if task is not None else 0.0
|
| 656 |
+
content = (
|
| 657 |
+
f"## Task: {obs.task_id}\n"
|
| 658 |
+
f"**Level:** {obs.task_level}\n"
|
| 659 |
+
f"**Brief:** {obs.task_brief}\n"
|
| 660 |
+
f"**Budget:** {obs.budget_remaining}\n"
|
| 661 |
+
f"**Target score:** {target}\n\n"
|
| 662 |
+
"### Initial files\n"
|
| 663 |
+
)
|
| 664 |
+
for fname, body in obs.initial_files.items():
|
| 665 |
+
content += (
|
| 666 |
+
f"\n**{fname}:**\n```python\n{body}\n```\n"
|
| 667 |
+
)
|
| 668 |
+
return [{"role": "system", "content": content}]
|
| 669 |
+
return [
|
| 670 |
+
{
|
| 671 |
+
"role": "system",
|
| 672 |
+
"content": f"Unknown prompt: {name!r}",
|
| 673 |
+
},
|
| 674 |
+
]
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
# -------------------------------------------------------------------
|
| 678 |
+
# Tool handlers (private, keyed by tool name)
|
| 679 |
+
# -------------------------------------------------------------------
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def _handle_reset(
|
| 683 |
+
server: CodeForgeMCPServer,
|
| 684 |
+
arguments: dict[str, Any],
|
| 685 |
+
) -> dict[str, Any]:
|
| 686 |
+
task_level = arguments.get("task_level", "easy")
|
| 687 |
+
sid, env = server._create_session()
|
| 688 |
+
obs = env.reset(task_level=task_level)
|
| 689 |
+
return _make_response(obs, session_id=sid)
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
def _handle_query_kb(
|
| 693 |
+
server: CodeForgeMCPServer,
|
| 694 |
+
arguments: dict[str, Any],
|
| 695 |
+
) -> dict[str, Any]:
|
| 696 |
+
sid, env = _require_session(server, arguments)
|
| 697 |
+
if env is None:
|
| 698 |
+
return _session_error(sid)
|
| 699 |
+
action = CodeForgeAction(
|
| 700 |
+
action_type=CodeForgeActionType.QUERY_KB,
|
| 701 |
+
claim=arguments.get("claim"),
|
| 702 |
+
top_k=arguments.get("top_k", 5),
|
| 703 |
+
required_tags=tuple(arguments.get("required_tags", ())),
|
| 704 |
+
)
|
| 705 |
+
obs = env.step(action)
|
| 706 |
+
return _make_response(obs, session_id=sid)
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
def _handle_query_cluster(
|
| 710 |
+
server: CodeForgeMCPServer,
|
| 711 |
+
arguments: dict[str, Any],
|
| 712 |
+
) -> dict[str, Any]:
|
| 713 |
+
sid, env = _require_session(server, arguments)
|
| 714 |
+
if env is None:
|
| 715 |
+
return _session_error(sid)
|
| 716 |
+
action = CodeForgeAction(
|
| 717 |
+
action_type=CodeForgeActionType.QUERY_CLUSTER,
|
| 718 |
+
cluster_label=arguments.get("cluster_label"),
|
| 719 |
+
top_k=arguments.get("top_k", 10),
|
| 720 |
+
)
|
| 721 |
+
obs = env.step(action)
|
| 722 |
+
return _make_response(obs, session_id=sid)
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
def _handle_interrogate(
|
| 726 |
+
server: CodeForgeMCPServer,
|
| 727 |
+
arguments: dict[str, Any],
|
| 728 |
+
) -> dict[str, Any]:
|
| 729 |
+
sid, env = _require_session(server, arguments)
|
| 730 |
+
if env is None:
|
| 731 |
+
return _session_error(sid)
|
| 732 |
+
action = CodeForgeAction(
|
| 733 |
+
action_type=CodeForgeActionType.INTERROGATE,
|
| 734 |
+
)
|
| 735 |
+
obs = env.step(action)
|
| 736 |
+
return _make_response(obs, session_id=sid)
|
| 737 |
+
|
| 738 |
+
|
| 739 |
+
def _handle_run_ralph(
|
| 740 |
+
server: CodeForgeMCPServer,
|
| 741 |
+
arguments: dict[str, Any],
|
| 742 |
+
) -> dict[str, Any]:
|
| 743 |
+
sid, env = _require_session(server, arguments)
|
| 744 |
+
if env is None:
|
| 745 |
+
return _session_error(sid)
|
| 746 |
+
action = CodeForgeAction(
|
| 747 |
+
action_type=CodeForgeActionType.RUN_RALPH,
|
| 748 |
+
max_iters=arguments.get("max_iters", 3),
|
| 749 |
+
)
|
| 750 |
+
obs = env.step(action)
|
| 751 |
+
return _make_response(obs, session_id=sid)
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
def _handle_submit(
|
| 755 |
+
server: CodeForgeMCPServer,
|
| 756 |
+
arguments: dict[str, Any],
|
| 757 |
+
) -> dict[str, Any]:
|
| 758 |
+
sid, env = _require_session(server, arguments)
|
| 759 |
+
if env is None:
|
| 760 |
+
return _session_error(sid)
|
| 761 |
+
action = CodeForgeAction(
|
| 762 |
+
action_type=CodeForgeActionType.SUBMIT,
|
| 763 |
+
files=arguments.get("files"),
|
| 764 |
+
confidence=arguments.get("confidence"),
|
| 765 |
+
)
|
| 766 |
+
obs = env.step(action)
|
| 767 |
+
return _make_response(obs, session_id=sid)
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
def _handle_get_audit(
|
| 771 |
+
server: CodeForgeMCPServer,
|
| 772 |
+
arguments: dict[str, Any],
|
| 773 |
+
) -> dict[str, Any]:
|
| 774 |
+
sid, env = _require_session(server, arguments)
|
| 775 |
+
if env is None:
|
| 776 |
+
return _session_error(sid)
|
| 777 |
+
action = CodeForgeAction(
|
| 778 |
+
action_type=CodeForgeActionType.GET_AUDIT,
|
| 779 |
+
target_run_id=arguments.get("target_run_id"),
|
| 780 |
+
)
|
| 781 |
+
obs = env.step(action)
|
| 782 |
+
return _make_response(obs, session_id=sid)
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def _handle_state(
|
| 786 |
+
server: CodeForgeMCPServer,
|
| 787 |
+
arguments: dict[str, Any],
|
| 788 |
+
) -> dict[str, Any]:
|
| 789 |
+
sid, env = _require_session(server, arguments)
|
| 790 |
+
if env is None:
|
| 791 |
+
return _session_error(sid)
|
| 792 |
+
obs = env.state
|
| 793 |
+
return _make_response(obs, session_id=sid)
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
def _handle_list_clusters(
|
| 797 |
+
server: CodeForgeMCPServer,
|
| 798 |
+
arguments: dict[str, Any],
|
| 799 |
+
) -> dict[str, Any]:
|
| 800 |
+
_sid, env = _require_session(server, arguments)
|
| 801 |
+
if env is None:
|
| 802 |
+
return {"_codeforge_version": _VERSION, "clusters": []}
|
| 803 |
+
try:
|
| 804 |
+
idx = env._ensure_index()
|
| 805 |
+
except FileNotFoundError:
|
| 806 |
+
return {"_codeforge_version": _VERSION, "clusters": []}
|
| 807 |
+
labels = idx.all_cluster_labels()
|
| 808 |
+
cluster_info: list[dict[str, Any]] = []
|
| 809 |
+
for label in labels:
|
| 810 |
+
cluster = idx.cluster_by_label(label)
|
| 811 |
+
if cluster is not None:
|
| 812 |
+
cluster_info.append({
|
| 813 |
+
"label": cluster.label,
|
| 814 |
+
"node_count": cluster.node_count,
|
| 815 |
+
})
|
| 816 |
+
return {"_codeforge_version": _VERSION, "clusters": cluster_info}
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
def _handle_list_tags(
|
| 820 |
+
server: CodeForgeMCPServer,
|
| 821 |
+
arguments: dict[str, Any],
|
| 822 |
+
) -> dict[str, Any]:
|
| 823 |
+
_sid, env = _require_session(server, arguments)
|
| 824 |
+
if env is None:
|
| 825 |
+
return {"_codeforge_version": _VERSION, "tags": []}
|
| 826 |
+
try:
|
| 827 |
+
idx = env._ensure_index()
|
| 828 |
+
except FileNotFoundError:
|
| 829 |
+
return {"_codeforge_version": _VERSION, "tags": []}
|
| 830 |
+
return {
|
| 831 |
+
"_codeforge_version": _VERSION,
|
| 832 |
+
"tags": sorted(idx.all_tags()),
|
| 833 |
+
}
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
# Handler dispatch table
|
| 837 |
+
_HANDLERS: dict[str, _Handler] = {
|
| 838 |
+
"codeforge_reset": _handle_reset,
|
| 839 |
+
"codeforge_query_kb": _handle_query_kb,
|
| 840 |
+
"codeforge_query_cluster": _handle_query_cluster,
|
| 841 |
+
"codeforge_interrogate": _handle_interrogate,
|
| 842 |
+
"codeforge_run_ralph": _handle_run_ralph,
|
| 843 |
+
"codeforge_submit": _handle_submit,
|
| 844 |
+
"codeforge_get_audit": _handle_get_audit,
|
| 845 |
+
"codeforge_state": _handle_state,
|
| 846 |
+
"codeforge_list_clusters": _handle_list_clusters,
|
| 847 |
+
"codeforge_list_tags": _handle_list_tags,
|
| 848 |
+
}
|
codeforge/models.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from enum import StrEnum
|
| 5 |
+
|
| 6 |
+
from openenv.core.env_server.types import Action, Observation
|
| 7 |
+
from pydantic import Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class CodeForgeActionType(StrEnum):
|
| 11 |
+
QUERY_KB = "query_kb"
|
| 12 |
+
QUERY_CLUSTER = "query_cluster"
|
| 13 |
+
INTERROGATE = "interrogate"
|
| 14 |
+
RUN_RALPH = "run_ralph"
|
| 15 |
+
SUBMIT = "submit"
|
| 16 |
+
GET_AUDIT = "get_audit"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class CodeForgeAction(Action):
|
| 20 |
+
action_type: CodeForgeActionType
|
| 21 |
+
# query_kb fields
|
| 22 |
+
claim: str | None = None
|
| 23 |
+
top_k: int = 5
|
| 24 |
+
required_tags: tuple[str, ...] = ()
|
| 25 |
+
# submit fields
|
| 26 |
+
files: dict[str, str] | None = None
|
| 27 |
+
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
| 28 |
+
# query_cluster fields
|
| 29 |
+
cluster_label: str | None = None
|
| 30 |
+
# run_ralph fields
|
| 31 |
+
max_iters: int = Field(default=3, ge=1, le=10)
|
| 32 |
+
# get_audit fields
|
| 33 |
+
target_run_id: str | None = None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class CodeForgeObservation(Observation):
|
| 37 |
+
episode_id: str
|
| 38 |
+
task_id: str
|
| 39 |
+
task_level: str
|
| 40 |
+
task_brief: str
|
| 41 |
+
initial_files: dict[str, str]
|
| 42 |
+
current_files: dict[str, str]
|
| 43 |
+
budget_remaining: int
|
| 44 |
+
previous_score: float
|
| 45 |
+
last_reward: float
|
| 46 |
+
is_done: bool
|
| 47 |
+
# KB results
|
| 48 |
+
last_citations: tuple[dict[str, object], ...] = ()
|
| 49 |
+
last_grounding: dict[str, object] | None = None
|
| 50 |
+
# Cluster results
|
| 51 |
+
last_cluster_hits: tuple[str, ...] = ()
|
| 52 |
+
# Interrogation results
|
| 53 |
+
last_interrogation_questions: tuple[str, ...] = ()
|
| 54 |
+
# Ralph results
|
| 55 |
+
last_ralph_run_id: str | None = None
|
| 56 |
+
last_ralph_iterations: tuple[dict[str, object], ...] = ()
|
| 57 |
+
# Audit summary
|
| 58 |
+
cumulative_audit_summary: dict[str, object] = Field(default_factory=dict)
|
| 59 |
+
# Error field
|
| 60 |
+
error: str | None = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@dataclass(frozen=True)
|
| 64 |
+
class AuditEntry:
|
| 65 |
+
step_index: int
|
| 66 |
+
action_type: str
|
| 67 |
+
cited_skill_ids: tuple[str, ...]
|
| 68 |
+
cited_clusters: tuple[str, ...]
|
| 69 |
+
grounding_report: dict[str, object] | None
|
| 70 |
+
reward: float
|
| 71 |
+
brier_penalty: float | None
|
| 72 |
+
confidence_declared: float | None
|
| 73 |
+
quality: float
|
codeforge/observation.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import TYPE_CHECKING
|
| 4 |
+
|
| 5 |
+
from codeforge.models import CodeForgeObservation
|
| 6 |
+
|
| 7 |
+
if TYPE_CHECKING:
|
| 8 |
+
from codeforge.tasks import Task
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def build_observation(
|
| 12 |
+
*,
|
| 13 |
+
episode_id: str,
|
| 14 |
+
task: Task,
|
| 15 |
+
current_files: dict[str, str],
|
| 16 |
+
budget_remaining: int,
|
| 17 |
+
previous_score: float,
|
| 18 |
+
last_citations: tuple[dict[str, object], ...] = (),
|
| 19 |
+
last_grounding: dict[str, object] | None = None,
|
| 20 |
+
is_done: bool = False,
|
| 21 |
+
last_reward: float = 0.0,
|
| 22 |
+
last_cluster_hits: tuple[str, ...] = (),
|
| 23 |
+
last_interrogation_questions: tuple[str, ...] = (),
|
| 24 |
+
last_ralph_run_id: str | None = None,
|
| 25 |
+
last_ralph_iterations: tuple[dict[str, object], ...] = (),
|
| 26 |
+
cumulative_audit_summary: dict[str, object] | None = None,
|
| 27 |
+
error: str | None = None,
|
| 28 |
+
) -> CodeForgeObservation:
|
| 29 |
+
return CodeForgeObservation(
|
| 30 |
+
episode_id=episode_id,
|
| 31 |
+
task_id=task.task_id,
|
| 32 |
+
task_level=task.task_level,
|
| 33 |
+
task_brief=task.brief,
|
| 34 |
+
initial_files=dict(task.initial_files),
|
| 35 |
+
current_files=dict(current_files),
|
| 36 |
+
budget_remaining=budget_remaining,
|
| 37 |
+
previous_score=previous_score,
|
| 38 |
+
last_citations=last_citations,
|
| 39 |
+
last_grounding=last_grounding,
|
| 40 |
+
is_done=is_done,
|
| 41 |
+
last_reward=last_reward,
|
| 42 |
+
last_cluster_hits=last_cluster_hits,
|
| 43 |
+
last_interrogation_questions=last_interrogation_questions,
|
| 44 |
+
last_ralph_run_id=last_ralph_run_id,
|
| 45 |
+
last_ralph_iterations=last_ralph_iterations,
|
| 46 |
+
cumulative_audit_summary=cumulative_audit_summary or {},
|
| 47 |
+
error=error,
|
| 48 |
+
reward=last_reward,
|
| 49 |
+
done=is_done,
|
| 50 |
+
)
|
codeforge/ralph/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from codeforge.ralph.loop import run_loop
|
| 4 |
+
from codeforge.ralph.models import LoopConfig, RunResult
|
| 5 |
+
from codeforge.ralph.planner import Planner, Subtask
|
| 6 |
+
from codeforge.ralph.synthesizer import LLMSynthesizer, StubSynthesizer, Synthesizer
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"LLMSynthesizer",
|
| 10 |
+
"LoopConfig",
|
| 11 |
+
"Planner",
|
| 12 |
+
"RunResult",
|
| 13 |
+
"StubSynthesizer",
|
| 14 |
+
"Subtask",
|
| 15 |
+
"Synthesizer",
|
| 16 |
+
"run_loop",
|
| 17 |
+
]
|
codeforge/ralph/checkpoint.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from codeforge.ralph.models import RunResult
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def save_checkpoint(run: RunResult, checkpoint_dir: Path) -> Path:
|
| 10 |
+
"""Atomically write a RunResult to ``checkpoint_dir/run_{run_id}.json``."""
|
| 11 |
+
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
| 12 |
+
final = checkpoint_dir / f"run_{run.run_id}.json"
|
| 13 |
+
tmp = final.with_suffix(".json.tmp")
|
| 14 |
+
tmp.write_text(run.model_dump_json(indent=2), encoding="utf-8")
|
| 15 |
+
os.replace(tmp, final)
|
| 16 |
+
return final
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def load_checkpoint(checkpoint_path: Path) -> RunResult:
|
| 20 |
+
"""Load a RunResult from a checkpoint JSON file."""
|
| 21 |
+
return RunResult.model_validate_json(
|
| 22 |
+
checkpoint_path.read_text(encoding="utf-8"),
|
| 23 |
+
)
|
codeforge/ralph/loop.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import uuid
|
| 5 |
+
from collections.abc import Mapping
|
| 6 |
+
from datetime import UTC, datetime
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import TYPE_CHECKING
|
| 9 |
+
|
| 10 |
+
from codeforge.ralph.checkpoint import save_checkpoint
|
| 11 |
+
from codeforge.ralph.models import (
|
| 12 |
+
Iteration,
|
| 13 |
+
IterationReason,
|
| 14 |
+
LoopConfig,
|
| 15 |
+
RunResult,
|
| 16 |
+
TerminationReason,
|
| 17 |
+
)
|
| 18 |
+
from codeforge.sandbox.sandbox import run_sandbox
|
| 19 |
+
|
| 20 |
+
if TYPE_CHECKING:
|
| 21 |
+
from codeforge.kb.indexer import SkillsIndex
|
| 22 |
+
from codeforge.ralph.synthesizer import Synthesizer
|
| 23 |
+
|
| 24 |
+
_log = logging.getLogger(__name__)
|
| 25 |
+
_STUCK_THRESHOLD = 3
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _score_files(files: Mapping[str, str], config: LoopConfig) -> float:
|
| 29 |
+
"""Score files via the real sandbox. Returns 0.0 on any error."""
|
| 30 |
+
try:
|
| 31 |
+
result = run_sandbox(
|
| 32 |
+
files=dict(files),
|
| 33 |
+
tools=config.tools,
|
| 34 |
+
timeout_per_tool=config.timeout_per_tool,
|
| 35 |
+
)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
_log.exception("sandbox error: %s", e)
|
| 38 |
+
return 0.0
|
| 39 |
+
return result.composite_score
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def run_loop(
|
| 43 |
+
*,
|
| 44 |
+
spec: str,
|
| 45 |
+
initial_files: Mapping[str, str],
|
| 46 |
+
index: SkillsIndex,
|
| 47 |
+
synthesizer: Synthesizer,
|
| 48 |
+
config: LoopConfig | None = None,
|
| 49 |
+
checkpoint_dir: Path | None = None,
|
| 50 |
+
) -> RunResult:
|
| 51 |
+
"""Run the score-gated retry loop.
|
| 52 |
+
|
| 53 |
+
Each iteration: score current → synthesize → score proposed → keep if better.
|
| 54 |
+
Terminates on target_hit, max_iters, or stuck (3 consecutive regressions).
|
| 55 |
+
"""
|
| 56 |
+
cfg = config or LoopConfig()
|
| 57 |
+
run_id = f"ralph_{uuid.uuid4().hex[:12]}"
|
| 58 |
+
started_at = datetime.now(UTC).isoformat(timespec="seconds")
|
| 59 |
+
|
| 60 |
+
current: dict[str, str] = dict(initial_files)
|
| 61 |
+
iterations: list[Iteration] = []
|
| 62 |
+
consecutive_regressions = 0
|
| 63 |
+
terminated_by: TerminationReason = "max_iters"
|
| 64 |
+
|
| 65 |
+
for i in range(cfg.max_iters):
|
| 66 |
+
score_before = _score_files(current, cfg)
|
| 67 |
+
if score_before >= cfg.target_score:
|
| 68 |
+
terminated_by = "target_hit"
|
| 69 |
+
break
|
| 70 |
+
|
| 71 |
+
citations = index.search(spec, top_k=cfg.top_k_citations)
|
| 72 |
+
|
| 73 |
+
synth_reason: IterationReason | None = None
|
| 74 |
+
try:
|
| 75 |
+
synth = synthesizer.synthesize(
|
| 76 |
+
spec=spec,
|
| 77 |
+
current_files=current,
|
| 78 |
+
citations=citations,
|
| 79 |
+
iteration=i,
|
| 80 |
+
)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
_log.exception("synthesizer error: %s", e)
|
| 83 |
+
synth = None
|
| 84 |
+
synth_reason = "synthesizer_error"
|
| 85 |
+
|
| 86 |
+
if synth is None:
|
| 87 |
+
iterations.append(
|
| 88 |
+
Iteration(
|
| 89 |
+
index=i,
|
| 90 |
+
cited_node_ids=(),
|
| 91 |
+
rationale="synth_error",
|
| 92 |
+
proposed_files=current,
|
| 93 |
+
sandbox_score_before=score_before,
|
| 94 |
+
sandbox_score_after=score_before,
|
| 95 |
+
kept=False,
|
| 96 |
+
reason=synth_reason or "synthesizer_error",
|
| 97 |
+
),
|
| 98 |
+
)
|
| 99 |
+
consecutive_regressions += 1
|
| 100 |
+
else:
|
| 101 |
+
score_after = _score_files(synth.proposed_files, cfg)
|
| 102 |
+
reason: IterationReason
|
| 103 |
+
if score_after > score_before:
|
| 104 |
+
kept = True
|
| 105 |
+
reason = "score_improved"
|
| 106 |
+
consecutive_regressions = 0
|
| 107 |
+
current = dict(synth.proposed_files)
|
| 108 |
+
elif score_after < score_before:
|
| 109 |
+
kept = False
|
| 110 |
+
reason = "score_regressed"
|
| 111 |
+
consecutive_regressions += 1
|
| 112 |
+
else:
|
| 113 |
+
kept = False
|
| 114 |
+
reason = "score_plateau"
|
| 115 |
+
consecutive_regressions = 0
|
| 116 |
+
iterations.append(
|
| 117 |
+
Iteration(
|
| 118 |
+
index=i,
|
| 119 |
+
cited_node_ids=synth.cited_node_ids,
|
| 120 |
+
rationale=synth.rationale,
|
| 121 |
+
proposed_files=synth.proposed_files,
|
| 122 |
+
sandbox_score_before=score_before,
|
| 123 |
+
sandbox_score_after=score_after,
|
| 124 |
+
kept=kept,
|
| 125 |
+
reason=reason,
|
| 126 |
+
),
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
if checkpoint_dir is not None:
|
| 130 |
+
try:
|
| 131 |
+
save_checkpoint(
|
| 132 |
+
RunResult(
|
| 133 |
+
run_id=run_id,
|
| 134 |
+
spec=spec,
|
| 135 |
+
started_at=started_at,
|
| 136 |
+
ended_at=datetime.now(UTC).isoformat(timespec="seconds"),
|
| 137 |
+
final_score=iterations[-1].sandbox_score_after,
|
| 138 |
+
final_files=current,
|
| 139 |
+
iterations=tuple(iterations),
|
| 140 |
+
terminated_by="in_progress",
|
| 141 |
+
),
|
| 142 |
+
checkpoint_dir,
|
| 143 |
+
)
|
| 144 |
+
except OSError as e:
|
| 145 |
+
_log.warning("checkpoint write failed: %s", e)
|
| 146 |
+
|
| 147 |
+
if consecutive_regressions >= _STUCK_THRESHOLD:
|
| 148 |
+
terminated_by = "stuck"
|
| 149 |
+
break
|
| 150 |
+
|
| 151 |
+
final_score = _score_files(current, cfg)
|
| 152 |
+
result = RunResult(
|
| 153 |
+
run_id=run_id,
|
| 154 |
+
spec=spec,
|
| 155 |
+
started_at=started_at,
|
| 156 |
+
ended_at=datetime.now(UTC).isoformat(timespec="seconds"),
|
| 157 |
+
final_score=final_score,
|
| 158 |
+
final_files=current,
|
| 159 |
+
iterations=tuple(iterations),
|
| 160 |
+
terminated_by=terminated_by,
|
| 161 |
+
)
|
| 162 |
+
if checkpoint_dir is not None:
|
| 163 |
+
try:
|
| 164 |
+
save_checkpoint(result, checkpoint_dir)
|
| 165 |
+
except OSError as e:
|
| 166 |
+
_log.warning("final checkpoint write failed: %s", e)
|
| 167 |
+
return result
|
codeforge/ralph/models.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Literal
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 6 |
+
|
| 7 |
+
TerminationReason = Literal["target_hit", "max_iters", "stuck", "in_progress"]
|
| 8 |
+
IterationReason = Literal[
|
| 9 |
+
"score_improved",
|
| 10 |
+
"score_regressed",
|
| 11 |
+
"score_plateau",
|
| 12 |
+
"target_hit",
|
| 13 |
+
"sandbox_error",
|
| 14 |
+
"synthesizer_error",
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class LoopConfig(BaseModel):
|
| 19 |
+
model_config = ConfigDict(frozen=True)
|
| 20 |
+
max_iters: int = Field(default=5, gt=0, le=100)
|
| 21 |
+
target_score: float = Field(default=0.95, gt=0.0, le=2.0)
|
| 22 |
+
tools: tuple[str, ...] = ("ruff", "imports")
|
| 23 |
+
timeout_per_tool: float = 60.0
|
| 24 |
+
top_k_citations: int = Field(default=5, gt=0, le=50)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class SynthesisResult(BaseModel):
|
| 28 |
+
model_config = ConfigDict(frozen=True)
|
| 29 |
+
proposed_files: dict[str, str]
|
| 30 |
+
rationale: str
|
| 31 |
+
cited_node_ids: tuple[str, ...]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class Iteration(BaseModel):
|
| 35 |
+
model_config = ConfigDict(frozen=True)
|
| 36 |
+
index: int
|
| 37 |
+
cited_node_ids: tuple[str, ...]
|
| 38 |
+
rationale: str
|
| 39 |
+
proposed_files: dict[str, str]
|
| 40 |
+
sandbox_score_before: float
|
| 41 |
+
sandbox_score_after: float
|
| 42 |
+
kept: bool
|
| 43 |
+
reason: IterationReason
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class RunResult(BaseModel):
|
| 47 |
+
model_config = ConfigDict(frozen=True)
|
| 48 |
+
run_id: str
|
| 49 |
+
spec: str
|
| 50 |
+
started_at: str
|
| 51 |
+
ended_at: str
|
| 52 |
+
final_score: float
|
| 53 |
+
final_files: dict[str, str]
|
| 54 |
+
iterations: tuple[Iteration, ...]
|
| 55 |
+
terminated_by: TerminationReason
|
codeforge/ralph/planner.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass(frozen=True)
|
| 7 |
+
class Subtask:
|
| 8 |
+
"""A single step in a decomposed task plan."""
|
| 9 |
+
|
| 10 |
+
description: str
|
| 11 |
+
target_files: tuple[str, ...]
|
| 12 |
+
acceptance: str
|
| 13 |
+
tools: tuple[str, ...]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Planner:
|
| 17 |
+
"""Decomposes a task spec into ordered subtasks.
|
| 18 |
+
|
| 19 |
+
The planner analyzes the spec and initial files to determine:
|
| 20 |
+
1. Which files need to be created/modified
|
| 21 |
+
2. In what order (dependency-based)
|
| 22 |
+
3. Which tools are relevant for scoring each subtask
|
| 23 |
+
|
| 24 |
+
This enables incremental scoring: implement core.py with ruff+mypy only,
|
| 25 |
+
then add tests with ruff+mypy+pytest.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def plan(
|
| 29 |
+
self,
|
| 30 |
+
spec: str,
|
| 31 |
+
initial_files: dict[str, str],
|
| 32 |
+
) -> list[Subtask]:
|
| 33 |
+
"""Decompose spec into ordered subtasks.
|
| 34 |
+
|
| 35 |
+
Strategy:
|
| 36 |
+
1. Identify empty files (need implementation)
|
| 37 |
+
2. Identify test files (depends on implementation files)
|
| 38 |
+
3. Order: implementation files first, then test files
|
| 39 |
+
4. Implementation files get ruff+mypy+imports tools
|
| 40 |
+
5. Test files get ruff+mypy+imports+pytest tools
|
| 41 |
+
"""
|
| 42 |
+
empty_files = [f for f, content in initial_files.items() if not content.strip()]
|
| 43 |
+
|
| 44 |
+
# Separate test files from implementation files
|
| 45 |
+
test_files = [f for f in empty_files if f.startswith("test_")]
|
| 46 |
+
impl_files = [f for f in empty_files if not f.startswith("test_")]
|
| 47 |
+
|
| 48 |
+
subtasks: list[Subtask] = []
|
| 49 |
+
|
| 50 |
+
# Phase 1: Implement empty non-test files
|
| 51 |
+
for f in impl_files:
|
| 52 |
+
subtasks.append(
|
| 53 |
+
Subtask(
|
| 54 |
+
description=f"Implement {f}",
|
| 55 |
+
target_files=(f,),
|
| 56 |
+
acceptance=f"{f} passes ruff, mypy --strict, and imports check",
|
| 57 |
+
tools=("ruff", "imports", "mypy"),
|
| 58 |
+
)
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Phase 2: Write tests
|
| 62 |
+
for f in test_files:
|
| 63 |
+
subtasks.append(
|
| 64 |
+
Subtask(
|
| 65 |
+
description=f"Write tests in {f}",
|
| 66 |
+
target_files=(f,),
|
| 67 |
+
acceptance=f"{f} passes all tools including pytest",
|
| 68 |
+
tools=("ruff", "imports", "mypy", "pytest"),
|
| 69 |
+
)
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# If no subtasks were generated, create one for everything
|
| 73 |
+
if not subtasks:
|
| 74 |
+
all_files = tuple(initial_files.keys())
|
| 75 |
+
has_tests = any(f.startswith("test_") for f in initial_files)
|
| 76 |
+
tools = (
|
| 77 |
+
("ruff", "imports", "mypy", "pytest")
|
| 78 |
+
if has_tests
|
| 79 |
+
else ("ruff", "imports", "mypy")
|
| 80 |
+
)
|
| 81 |
+
subtasks.append(
|
| 82 |
+
Subtask(
|
| 83 |
+
description="Implement the complete task",
|
| 84 |
+
target_files=all_files,
|
| 85 |
+
acceptance="All tools pass",
|
| 86 |
+
tools=tools,
|
| 87 |
+
)
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
return subtasks
|
codeforge/ralph/synthesizer.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
from collections.abc import Mapping, Sequence
|
| 6 |
+
from typing import TYPE_CHECKING, Protocol
|
| 7 |
+
|
| 8 |
+
from codeforge.ralph.models import SynthesisResult
|
| 9 |
+
|
| 10 |
+
if TYPE_CHECKING:
|
| 11 |
+
from codeforge.kb.models import SearchResult
|
| 12 |
+
|
| 13 |
+
_FENCED_PY_RE = re.compile(r"```python\n(.*?)\n```", re.DOTALL)
|
| 14 |
+
|
| 15 |
+
# Matches '# filename: foo.py' or '## foo.py' immediately before a fenced block
|
| 16 |
+
_FILENAME_HEADER_RE = re.compile(
|
| 17 |
+
r"(?:^#{1,2}\s+(?:filename:\s*)?(\S+\.py)\s*$)",
|
| 18 |
+
re.MULTILINE,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Synthesizer(Protocol):
|
| 23 |
+
"""Abstract synthesizer interface for Ralph loop."""
|
| 24 |
+
|
| 25 |
+
def synthesize(
|
| 26 |
+
self,
|
| 27 |
+
*,
|
| 28 |
+
spec: str,
|
| 29 |
+
current_files: Mapping[str, str],
|
| 30 |
+
citations: Sequence[SearchResult],
|
| 31 |
+
iteration: int,
|
| 32 |
+
) -> SynthesisResult: ...
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class StubSynthesizer:
|
| 36 |
+
"""Deterministic, KB-grounded stub. No LLM. Used in tests and as default."""
|
| 37 |
+
|
| 38 |
+
def synthesize(
|
| 39 |
+
self,
|
| 40 |
+
*,
|
| 41 |
+
spec: str,
|
| 42 |
+
current_files: Mapping[str, str],
|
| 43 |
+
citations: Sequence[SearchResult],
|
| 44 |
+
iteration: int,
|
| 45 |
+
) -> SynthesisResult:
|
| 46 |
+
del spec, iteration # inputs retained for protocol; stub ignores
|
| 47 |
+
|
| 48 |
+
if not citations:
|
| 49 |
+
return SynthesisResult(
|
| 50 |
+
proposed_files=dict(current_files),
|
| 51 |
+
rationale="no_citations",
|
| 52 |
+
cited_node_ids=(),
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
top = citations[0]
|
| 56 |
+
main = current_files.get("main.py", "")
|
| 57 |
+
blocks = _FENCED_PY_RE.findall(top.section_body)
|
| 58 |
+
|
| 59 |
+
wrapper_name = f"_from_{top.skill_name.replace('-', '_')}_{top.rank}"
|
| 60 |
+
if blocks and wrapper_name not in main:
|
| 61 |
+
body = "\n".join(
|
| 62 |
+
f" {ln}" if ln.strip() else "" for ln in blocks[0].splitlines()
|
| 63 |
+
)
|
| 64 |
+
snippet = f"\n\ndef {wrapper_name}() -> None:\n{body}\n"
|
| 65 |
+
new_main = main + snippet
|
| 66 |
+
rationale = (
|
| 67 |
+
f"Applied suggestion from "
|
| 68 |
+
f"{top.skill_name}/{'/'.join(top.section_path)}"
|
| 69 |
+
)
|
| 70 |
+
elif blocks:
|
| 71 |
+
new_main = main
|
| 72 |
+
rationale = (
|
| 73 |
+
f"Already applied "
|
| 74 |
+
f"{top.skill_name}/{'/'.join(top.section_path)}"
|
| 75 |
+
)
|
| 76 |
+
else:
|
| 77 |
+
comment = f"# consulted: {top.skill_name}/{'/'.join(top.section_path)}\n"
|
| 78 |
+
new_main = main + (comment if comment not in main else "")
|
| 79 |
+
rationale = (
|
| 80 |
+
f"Consulted "
|
| 81 |
+
f"{top.skill_name}/{'/'.join(top.section_path)} (no code block)"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
new_files = {**current_files, "main.py": new_main}
|
| 85 |
+
return SynthesisResult(
|
| 86 |
+
proposed_files=new_files,
|
| 87 |
+
rationale=rationale,
|
| 88 |
+
cited_node_ids=(top.node_id,),
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class LLMSynthesizer:
|
| 93 |
+
"""Calls any LLM to produce improved code given spec + current files + citations.
|
| 94 |
+
|
| 95 |
+
Provider-agnostic: works with Ollama, OpenAI, Anthropic, or any
|
| 96 |
+
OpenAI-compatible API. Set *provider* to choose the backend:
|
| 97 |
+
|
| 98 |
+
- ``"openai"`` — OpenAI / OpenAI-compatible (default). Works with Ollama,
|
| 99 |
+
LM Studio, vLLM, Together, Groq, etc. Set *base_url* for local models.
|
| 100 |
+
- ``"anthropic"`` — Anthropic Claude API.
|
| 101 |
+
- ``"ollama"`` — Shortcut for Ollama (sets base_url to localhost:11434).
|
| 102 |
+
|
| 103 |
+
Examples::
|
| 104 |
+
|
| 105 |
+
# Ollama (local, no API key needed)
|
| 106 |
+
LLMSynthesizer(provider="ollama", model="llama3")
|
| 107 |
+
|
| 108 |
+
# OpenAI
|
| 109 |
+
LLMSynthesizer(provider="openai", model="gpt-4o")
|
| 110 |
+
|
| 111 |
+
# Anthropic
|
| 112 |
+
LLMSynthesizer(provider="anthropic", model="claude-sonnet-4-20250514")
|
| 113 |
+
|
| 114 |
+
# Any OpenAI-compatible endpoint (vLLM, LM Studio, Together, etc.)
|
| 115 |
+
LLMSynthesizer(
|
| 116 |
+
provider="openai",
|
| 117 |
+
base_url="http://localhost:8000/v1",
|
| 118 |
+
model="my-local-model",
|
| 119 |
+
)
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
def __init__(
|
| 123 |
+
self,
|
| 124 |
+
*,
|
| 125 |
+
provider: str = "openai",
|
| 126 |
+
api_key: str | None = None,
|
| 127 |
+
base_url: str | None = None,
|
| 128 |
+
model: str | None = None,
|
| 129 |
+
max_tokens: int = 4096,
|
| 130 |
+
) -> None:
|
| 131 |
+
self._provider: str = provider.lower()
|
| 132 |
+
self._max_tokens: int = max_tokens
|
| 133 |
+
|
| 134 |
+
if self._provider == "ollama":
|
| 135 |
+
self._base_url = base_url or "http://localhost:11434/v1"
|
| 136 |
+
self._api_key = api_key or "ollama" # Ollama ignores this
|
| 137 |
+
self._model = model or "llama3"
|
| 138 |
+
elif self._provider == "anthropic":
|
| 139 |
+
self._base_url = base_url or "https://api.anthropic.com"
|
| 140 |
+
self._api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
| 141 |
+
self._model = model or "claude-sonnet-4-20250514"
|
| 142 |
+
else: # openai or any compatible
|
| 143 |
+
self._base_url = base_url or "https://api.openai.com/v1"
|
| 144 |
+
self._api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
|
| 145 |
+
self._model = model or "gpt-4o"
|
| 146 |
+
|
| 147 |
+
# ------------------------------------------------------------------
|
| 148 |
+
# Public API (satisfies Synthesizer protocol)
|
| 149 |
+
# ------------------------------------------------------------------
|
| 150 |
+
|
| 151 |
+
def synthesize(
|
| 152 |
+
self,
|
| 153 |
+
*,
|
| 154 |
+
spec: str,
|
| 155 |
+
current_files: Mapping[str, str],
|
| 156 |
+
citations: Sequence[SearchResult],
|
| 157 |
+
iteration: int,
|
| 158 |
+
) -> SynthesisResult:
|
| 159 |
+
"""Build prompt, call LLM, parse response into *SynthesisResult*."""
|
| 160 |
+
prompt = self._build_prompt(spec, current_files, citations, iteration)
|
| 161 |
+
response_text = self._call_llm(prompt)
|
| 162 |
+
result = self._parse_response(response_text, citations)
|
| 163 |
+
|
| 164 |
+
# If no code blocks were parsed, fall back to current files unchanged.
|
| 165 |
+
if not result.proposed_files:
|
| 166 |
+
return SynthesisResult(
|
| 167 |
+
proposed_files=dict(current_files),
|
| 168 |
+
rationale=result.rationale or "No parseable code blocks in LLM response",
|
| 169 |
+
cited_node_ids=result.cited_node_ids,
|
| 170 |
+
)
|
| 171 |
+
return result
|
| 172 |
+
|
| 173 |
+
# ------------------------------------------------------------------
|
| 174 |
+
# Prompt construction
|
| 175 |
+
# ------------------------------------------------------------------
|
| 176 |
+
|
| 177 |
+
def _build_prompt(
|
| 178 |
+
self,
|
| 179 |
+
spec: str,
|
| 180 |
+
current_files: Mapping[str, str],
|
| 181 |
+
citations: Sequence[SearchResult],
|
| 182 |
+
iteration: int,
|
| 183 |
+
) -> str:
|
| 184 |
+
"""Build the synthesis prompt with spec, files, citations, and iteration."""
|
| 185 |
+
parts: list[str] = [
|
| 186 |
+
"You are a Python code synthesis assistant.",
|
| 187 |
+
f"Iteration: {iteration}",
|
| 188 |
+
"",
|
| 189 |
+
"## Task Specification",
|
| 190 |
+
spec,
|
| 191 |
+
]
|
| 192 |
+
|
| 193 |
+
if current_files:
|
| 194 |
+
parts.append("")
|
| 195 |
+
parts.append("## Current Files")
|
| 196 |
+
for fname, content in current_files.items():
|
| 197 |
+
parts.append(f"\n### {fname}")
|
| 198 |
+
parts.append(f"```python\n{content}\n```")
|
| 199 |
+
|
| 200 |
+
if citations:
|
| 201 |
+
parts.append("")
|
| 202 |
+
parts.append("## Skill Corpus Citations")
|
| 203 |
+
for cit in citations:
|
| 204 |
+
parts.append(
|
| 205 |
+
f"\n### {cit.skill_name} / {'/'.join(cit.section_path)}"
|
| 206 |
+
f" (score={cit.score:.1f})"
|
| 207 |
+
)
|
| 208 |
+
parts.append(cit.section_body)
|
| 209 |
+
|
| 210 |
+
parts.append("")
|
| 211 |
+
parts.append("## Instructions")
|
| 212 |
+
parts.append(
|
| 213 |
+
"Produce improved Python files. For EACH file, emit a header "
|
| 214 |
+
"`# filename: <name>.py` followed by a fenced python code block. "
|
| 215 |
+
"After all files, write a short rationale explaining your changes."
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
return "\n".join(parts)
|
| 219 |
+
|
| 220 |
+
# ------------------------------------------------------------------
|
| 221 |
+
# LLM API call (provider-agnostic)
|
| 222 |
+
# ------------------------------------------------------------------
|
| 223 |
+
|
| 224 |
+
def _call_llm(self, prompt: str) -> str:
|
| 225 |
+
"""Call the configured LLM provider.
|
| 226 |
+
|
| 227 |
+
Supports three backends:
|
| 228 |
+
- **openai / ollama**: OpenAI-compatible ``/chat/completions`` endpoint.
|
| 229 |
+
Works with Ollama, LM Studio, vLLM, Together, Groq, OpenAI, etc.
|
| 230 |
+
- **anthropic**: Anthropic ``/v1/messages`` endpoint.
|
| 231 |
+
"""
|
| 232 |
+
if self._provider == "anthropic":
|
| 233 |
+
return self._call_anthropic(prompt)
|
| 234 |
+
return self._call_openai_compatible(prompt)
|
| 235 |
+
|
| 236 |
+
def _call_openai_compatible(self, prompt: str) -> str:
|
| 237 |
+
"""OpenAI-compatible API (works with Ollama, LM Studio, vLLM, etc.)."""
|
| 238 |
+
import httpx
|
| 239 |
+
|
| 240 |
+
url = f"{self._base_url.rstrip('/')}/chat/completions"
|
| 241 |
+
headers: dict[str, str] = {"content-type": "application/json"}
|
| 242 |
+
if self._api_key and self._api_key != "ollama":
|
| 243 |
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
| 244 |
+
|
| 245 |
+
resp = httpx.post(
|
| 246 |
+
url,
|
| 247 |
+
headers=headers,
|
| 248 |
+
json={
|
| 249 |
+
"model": self._model,
|
| 250 |
+
"max_tokens": self._max_tokens,
|
| 251 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 252 |
+
},
|
| 253 |
+
timeout=120.0,
|
| 254 |
+
)
|
| 255 |
+
resp.raise_for_status()
|
| 256 |
+
data = resp.json()
|
| 257 |
+
return str(data["choices"][0]["message"]["content"])
|
| 258 |
+
|
| 259 |
+
def _call_anthropic(self, prompt: str) -> str:
|
| 260 |
+
"""Anthropic Claude API."""
|
| 261 |
+
if not self._api_key:
|
| 262 |
+
msg = (
|
| 263 |
+
"ANTHROPIC_API_KEY not set. For local models, use "
|
| 264 |
+
"provider='ollama' or provider='openai' with base_url."
|
| 265 |
+
)
|
| 266 |
+
raise ValueError(msg)
|
| 267 |
+
|
| 268 |
+
try:
|
| 269 |
+
import anthropic
|
| 270 |
+
|
| 271 |
+
client = anthropic.Anthropic(api_key=self._api_key)
|
| 272 |
+
message = client.messages.create(
|
| 273 |
+
model=self._model,
|
| 274 |
+
max_tokens=self._max_tokens,
|
| 275 |
+
messages=[{"role": "user", "content": prompt}],
|
| 276 |
+
)
|
| 277 |
+
block = message.content[0]
|
| 278 |
+
return str(getattr(block, "text", ""))
|
| 279 |
+
except ImportError:
|
| 280 |
+
import httpx
|
| 281 |
+
|
| 282 |
+
resp = httpx.post(
|
| 283 |
+
f"{self._base_url.rstrip('/')}/v1/messages",
|
| 284 |
+
headers={
|
| 285 |
+
"x-api-key": self._api_key,
|
| 286 |
+
"anthropic-version": "2023-06-01",
|
| 287 |
+
"content-type": "application/json",
|
| 288 |
+
},
|
| 289 |
+
json={
|
| 290 |
+
"model": self._model,
|
| 291 |
+
"max_tokens": self._max_tokens,
|
| 292 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 293 |
+
},
|
| 294 |
+
timeout=120.0,
|
| 295 |
+
)
|
| 296 |
+
resp.raise_for_status()
|
| 297 |
+
data: dict[str, object] = resp.json()
|
| 298 |
+
content = data["content"]
|
| 299 |
+
assert isinstance(content, list)
|
| 300 |
+
first = content[0]
|
| 301 |
+
assert isinstance(first, dict)
|
| 302 |
+
return str(first["text"])
|
| 303 |
+
|
| 304 |
+
# ------------------------------------------------------------------
|
| 305 |
+
# Response parsing
|
| 306 |
+
# ------------------------------------------------------------------
|
| 307 |
+
|
| 308 |
+
def _parse_response(
|
| 309 |
+
self,
|
| 310 |
+
text: str,
|
| 311 |
+
citations: Sequence[SearchResult],
|
| 312 |
+
) -> SynthesisResult:
|
| 313 |
+
"""Parse LLM response: extract fenced code blocks with filename headers."""
|
| 314 |
+
proposed_files: dict[str, str] = {}
|
| 315 |
+
|
| 316 |
+
# Strategy: find all filename headers and pair each with the next
|
| 317 |
+
# fenced python block.
|
| 318 |
+
header_positions: list[tuple[int, str]] = [
|
| 319 |
+
(m.start(), m.group(1))
|
| 320 |
+
for m in _FILENAME_HEADER_RE.finditer(text)
|
| 321 |
+
]
|
| 322 |
+
|
| 323 |
+
code_blocks: list[tuple[int, str]] = [
|
| 324 |
+
(m.start(), m.group(1))
|
| 325 |
+
for m in _FENCED_PY_RE.finditer(text)
|
| 326 |
+
]
|
| 327 |
+
|
| 328 |
+
if header_positions and code_blocks:
|
| 329 |
+
for hdr_pos, filename in header_positions:
|
| 330 |
+
# Find the first code block that follows this header
|
| 331 |
+
for blk_pos, code in code_blocks:
|
| 332 |
+
if blk_pos > hdr_pos:
|
| 333 |
+
proposed_files[filename] = code
|
| 334 |
+
break
|
| 335 |
+
|
| 336 |
+
# Extract rationale from non-code, non-header text
|
| 337 |
+
rationale_text = text
|
| 338 |
+
for _, code in code_blocks:
|
| 339 |
+
rationale_text = rationale_text.replace(f"```python\n{code}\n```", "")
|
| 340 |
+
for m in _FILENAME_HEADER_RE.finditer(rationale_text):
|
| 341 |
+
rationale_text = rationale_text.replace(m.group(0), "")
|
| 342 |
+
rationale = rationale_text.strip()
|
| 343 |
+
# Collapse to a single line for storage
|
| 344 |
+
rationale = " ".join(rationale.split())
|
| 345 |
+
|
| 346 |
+
if not proposed_files:
|
| 347 |
+
rationale = rationale or "No parseable code blocks in LLM response"
|
| 348 |
+
|
| 349 |
+
cited_node_ids = tuple(c.node_id for c in citations)
|
| 350 |
+
|
| 351 |
+
return SynthesisResult(
|
| 352 |
+
proposed_files=proposed_files,
|
| 353 |
+
rationale=rationale,
|
| 354 |
+
cited_node_ids=cited_node_ids,
|
| 355 |
+
)
|
codeforge/sandbox/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from codeforge.sandbox.models import (
|
| 4 |
+
ImportReport,
|
| 5 |
+
ParsedResult,
|
| 6 |
+
SandboxResult,
|
| 7 |
+
ToolResult,
|
| 8 |
+
)
|
| 9 |
+
from codeforge.sandbox.sandbox import run_sandbox
|
| 10 |
+
|
| 11 |
+
__all__ = [
|
| 12 |
+
"ImportReport",
|
| 13 |
+
"ParsedResult",
|
| 14 |
+
"SandboxResult",
|
| 15 |
+
"ToolResult",
|
| 16 |
+
"run_sandbox",
|
| 17 |
+
]
|
codeforge/sandbox/imports.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import ast
|
| 4 |
+
import importlib.util
|
| 5 |
+
import logging
|
| 6 |
+
from typing import TYPE_CHECKING
|
| 7 |
+
|
| 8 |
+
from codeforge.sandbox.models import ImportReport
|
| 9 |
+
|
| 10 |
+
if TYPE_CHECKING:
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
_log = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _top_level(name: str) -> str:
|
| 17 |
+
return name.split(".", maxsplit=1)[0]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _extract_imports(tree: ast.AST) -> set[str]:
|
| 21 |
+
out: set[str] = set()
|
| 22 |
+
for node in ast.walk(tree):
|
| 23 |
+
if isinstance(node, ast.Import):
|
| 24 |
+
for alias in node.names:
|
| 25 |
+
out.add(_top_level(alias.name))
|
| 26 |
+
elif isinstance(node, ast.ImportFrom):
|
| 27 |
+
if node.level != 0 or node.module is None:
|
| 28 |
+
continue
|
| 29 |
+
out.add(_top_level(node.module))
|
| 30 |
+
return out
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _local_modules(project_dir: Path) -> set[str]:
|
| 34 |
+
local: set[str] = set()
|
| 35 |
+
for py in project_dir.rglob("*.py"):
|
| 36 |
+
if py.name == "__init__.py":
|
| 37 |
+
local.add(py.parent.name)
|
| 38 |
+
else:
|
| 39 |
+
local.add(py.stem)
|
| 40 |
+
return local
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def scan_imports(project_dir: Path) -> ImportReport:
|
| 44 |
+
by_file: dict[str, tuple[str, ...]] = {}
|
| 45 |
+
all_pkgs: set[str] = set()
|
| 46 |
+
total = 0
|
| 47 |
+
|
| 48 |
+
for py in sorted(project_dir.rglob("*.py")):
|
| 49 |
+
try:
|
| 50 |
+
tree = ast.parse(py.read_text(encoding="utf-8"))
|
| 51 |
+
except (SyntaxError, UnicodeDecodeError) as e:
|
| 52 |
+
_log.warning("imports: parse error %s: %s", py, e)
|
| 53 |
+
by_file[str(py.relative_to(project_dir))] = ("__parse_error__",)
|
| 54 |
+
continue
|
| 55 |
+
pkgs = _extract_imports(tree)
|
| 56 |
+
total += len(pkgs)
|
| 57 |
+
by_file[str(py.relative_to(project_dir))] = tuple(sorted(pkgs))
|
| 58 |
+
all_pkgs.update(pkgs)
|
| 59 |
+
|
| 60 |
+
local = _local_modules(project_dir)
|
| 61 |
+
unresolved = tuple(
|
| 62 |
+
sorted(
|
| 63 |
+
p for p in all_pkgs
|
| 64 |
+
if p not in local and importlib.util.find_spec(p) is None
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
return ImportReport(total=total, unresolved=unresolved, by_file=by_file)
|
codeforge/sandbox/metric.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import TYPE_CHECKING
|
| 4 |
+
|
| 5 |
+
if TYPE_CHECKING:
|
| 6 |
+
from codeforge.sandbox.models import SandboxResult
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def composite_score(
|
| 10 |
+
result: SandboxResult,
|
| 11 |
+
*,
|
| 12 |
+
tools: tuple[str, ...] | None = None,
|
| 13 |
+
) -> float:
|
| 14 |
+
"""Compute composite score from sandbox results.
|
| 15 |
+
|
| 16 |
+
When tools is None, score all tools that were run (full-project scoring).
|
| 17 |
+
When tools is provided, score only those tools (subtask scoring).
|
| 18 |
+
This lets the planner score 'implement core.py' with only ruff+mypy+imports,
|
| 19 |
+
without pytest destroying the score because tests aren't written yet.
|
| 20 |
+
"""
|
| 21 |
+
parsed = result.parsed
|
| 22 |
+
if tools is not None:
|
| 23 |
+
parsed = {k: v for k, v in parsed.items() if k in tools}
|
| 24 |
+
if not parsed:
|
| 25 |
+
return 0.0
|
| 26 |
+
|
| 27 |
+
# Penalty-only scoring (no double-counting with pass_rate)
|
| 28 |
+
imports_penalty = min(1.0, len(result.imports.unresolved) * 0.1)
|
| 29 |
+
|
| 30 |
+
ruff = parsed.get("ruff")
|
| 31 |
+
mypy = parsed.get("mypy")
|
| 32 |
+
pytest_result = parsed.get("pytest")
|
| 33 |
+
|
| 34 |
+
ruff_penalty = min(ruff.count, 20) / 40 if ruff else 0.0
|
| 35 |
+
mypy_penalty = min(mypy.count, 20) / 40 if mypy else 0.0
|
| 36 |
+
pytest_penalty = 0.5 if pytest_result and not pytest_result.ok else 0.0
|
| 37 |
+
|
| 38 |
+
# Start at 1.0, subtract penalties. No pass_rate to avoid double-counting.
|
| 39 |
+
raw = 1.0 - imports_penalty - ruff_penalty - mypy_penalty - pytest_penalty
|
| 40 |
+
return max(0.0, min(1.0, raw))
|
codeforge/sandbox/models.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, ConfigDict
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ToolResult(BaseModel):
|
| 9 |
+
model_config = ConfigDict(frozen=True)
|
| 10 |
+
name: str
|
| 11 |
+
argv: tuple[str, ...]
|
| 12 |
+
exit_code: int
|
| 13 |
+
stdout: str
|
| 14 |
+
stderr: str
|
| 15 |
+
duration_ms: int
|
| 16 |
+
timed_out: bool
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ParsedResult(BaseModel):
|
| 20 |
+
model_config = ConfigDict(frozen=True)
|
| 21 |
+
ok: bool
|
| 22 |
+
count: int
|
| 23 |
+
details: dict[str, Any]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ImportReport(BaseModel):
|
| 27 |
+
model_config = ConfigDict(frozen=True)
|
| 28 |
+
total: int
|
| 29 |
+
unresolved: tuple[str, ...]
|
| 30 |
+
by_file: dict[str, tuple[str, ...]]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class SandboxResult(BaseModel):
|
| 34 |
+
model_config = ConfigDict(frozen=True)
|
| 35 |
+
project_dir: str
|
| 36 |
+
tools_run: tuple[str, ...]
|
| 37 |
+
tool_results: dict[str, ToolResult]
|
| 38 |
+
parsed: dict[str, ParsedResult]
|
| 39 |
+
imports: ImportReport
|
| 40 |
+
composite_score: float
|
| 41 |
+
generated_at: str
|
codeforge/sandbox/runner.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import subprocess
|
| 5 |
+
import time
|
| 6 |
+
from typing import TYPE_CHECKING
|
| 7 |
+
|
| 8 |
+
from codeforge.sandbox.models import ToolResult
|
| 9 |
+
|
| 10 |
+
if TYPE_CHECKING:
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def run_tool(
|
| 15 |
+
name: str,
|
| 16 |
+
argv: list[str],
|
| 17 |
+
*,
|
| 18 |
+
cwd: Path,
|
| 19 |
+
timeout: float = 60.0,
|
| 20 |
+
env_overrides: dict[str, str] | None = None,
|
| 21 |
+
) -> ToolResult:
|
| 22 |
+
t0 = time.monotonic()
|
| 23 |
+
timed_out = False
|
| 24 |
+
stdout = ""
|
| 25 |
+
stderr = ""
|
| 26 |
+
exit_code = -1
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
proc = subprocess.run(
|
| 30 |
+
argv,
|
| 31 |
+
cwd=str(cwd),
|
| 32 |
+
capture_output=True,
|
| 33 |
+
text=True,
|
| 34 |
+
timeout=timeout,
|
| 35 |
+
check=False,
|
| 36 |
+
env=None if env_overrides is None else {**_os_env(), **env_overrides},
|
| 37 |
+
)
|
| 38 |
+
exit_code = proc.returncode
|
| 39 |
+
stdout = proc.stdout
|
| 40 |
+
stderr = proc.stderr
|
| 41 |
+
except subprocess.TimeoutExpired as e:
|
| 42 |
+
timed_out = True
|
| 43 |
+
stdout = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or "")
|
| 44 |
+
stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or "")
|
| 45 |
+
except (FileNotFoundError, OSError) as e:
|
| 46 |
+
stderr = f"binary not found: {e}"
|
| 47 |
+
|
| 48 |
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
| 49 |
+
return ToolResult(
|
| 50 |
+
name=name,
|
| 51 |
+
argv=tuple(argv),
|
| 52 |
+
exit_code=exit_code,
|
| 53 |
+
stdout=stdout,
|
| 54 |
+
stderr=stderr,
|
| 55 |
+
duration_ms=duration_ms,
|
| 56 |
+
timed_out=timed_out,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _os_env() -> dict[str, str]:
|
| 61 |
+
return dict(os.environ)
|
codeforge/sandbox/sandbox.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import shutil
|
| 4 |
+
import tempfile
|
| 5 |
+
from datetime import UTC, datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import TYPE_CHECKING
|
| 8 |
+
|
| 9 |
+
from codeforge.sandbox.imports import scan_imports
|
| 10 |
+
from codeforge.sandbox.metric import composite_score
|
| 11 |
+
from codeforge.sandbox.models import (
|
| 12 |
+
ImportReport,
|
| 13 |
+
ParsedResult,
|
| 14 |
+
SandboxResult,
|
| 15 |
+
ToolResult,
|
| 16 |
+
)
|
| 17 |
+
from codeforge.sandbox.runner import run_tool
|
| 18 |
+
from codeforge.sandbox.tools import (
|
| 19 |
+
DEFAULT_TOOLS,
|
| 20 |
+
argv_for,
|
| 21 |
+
is_available,
|
| 22 |
+
parse,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
if TYPE_CHECKING:
|
| 26 |
+
from collections.abc import Iterable
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def run_sandbox(
|
| 30 |
+
*,
|
| 31 |
+
project_dir: Path | None = None,
|
| 32 |
+
files: dict[str, str] | None = None,
|
| 33 |
+
tools: Iterable[str] = DEFAULT_TOOLS,
|
| 34 |
+
timeout_per_tool: float = 60.0,
|
| 35 |
+
) -> SandboxResult:
|
| 36 |
+
if (project_dir is None) == (files is None):
|
| 37 |
+
msg = "exactly one of project_dir / files must be set"
|
| 38 |
+
raise ValueError(msg)
|
| 39 |
+
|
| 40 |
+
tmp_root: Path | None = None
|
| 41 |
+
try:
|
| 42 |
+
if files is not None:
|
| 43 |
+
tmp_root = Path(tempfile.mkdtemp(prefix="codeforge_sandbox_"))
|
| 44 |
+
tmp_root_resolved = tmp_root.resolve()
|
| 45 |
+
for name, content in files.items():
|
| 46 |
+
target = (tmp_root / name).resolve()
|
| 47 |
+
if not target.is_relative_to(tmp_root_resolved):
|
| 48 |
+
msg = f"path escapes sandbox root: {name!r}"
|
| 49 |
+
raise ValueError(msg)
|
| 50 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
target.write_text(content, encoding="utf-8")
|
| 52 |
+
project_dir = tmp_root
|
| 53 |
+
|
| 54 |
+
assert project_dir is not None
|
| 55 |
+
tool_list = tuple(tools)
|
| 56 |
+
tool_results: dict[str, ToolResult] = {}
|
| 57 |
+
parsed_results: dict[str, ParsedResult] = {}
|
| 58 |
+
imports_report: ImportReport | None = None
|
| 59 |
+
|
| 60 |
+
for name in tool_list:
|
| 61 |
+
if name == "imports":
|
| 62 |
+
imports_report = scan_imports(project_dir)
|
| 63 |
+
parsed_results[name] = ParsedResult(
|
| 64 |
+
ok=len(imports_report.unresolved) == 0,
|
| 65 |
+
count=len(imports_report.unresolved),
|
| 66 |
+
details={"unresolved": list(imports_report.unresolved)},
|
| 67 |
+
)
|
| 68 |
+
continue
|
| 69 |
+
if not is_available(name):
|
| 70 |
+
tool_results[name] = ToolResult(
|
| 71 |
+
name=name, argv=(name,), exit_code=-1,
|
| 72 |
+
stdout="", stderr="binary not found",
|
| 73 |
+
duration_ms=0, timed_out=False,
|
| 74 |
+
)
|
| 75 |
+
parsed_results[name] = ParsedResult(
|
| 76 |
+
ok=False, count=0, details={"unavailable": True},
|
| 77 |
+
)
|
| 78 |
+
continue
|
| 79 |
+
argv = argv_for(name, project_dir)
|
| 80 |
+
tr = run_tool(name, argv, cwd=project_dir, timeout=timeout_per_tool)
|
| 81 |
+
tool_results[name] = tr
|
| 82 |
+
parsed_results[name] = parse(name, tr)
|
| 83 |
+
|
| 84 |
+
if imports_report is None:
|
| 85 |
+
imports_report = ImportReport(total=0, unresolved=(), by_file={})
|
| 86 |
+
|
| 87 |
+
result = SandboxResult(
|
| 88 |
+
project_dir=str(project_dir),
|
| 89 |
+
tools_run=tool_list,
|
| 90 |
+
tool_results=tool_results,
|
| 91 |
+
parsed=parsed_results,
|
| 92 |
+
imports=imports_report,
|
| 93 |
+
composite_score=0.0,
|
| 94 |
+
generated_at=datetime.now(UTC).isoformat(timespec="seconds"),
|
| 95 |
+
)
|
| 96 |
+
score = composite_score(result)
|
| 97 |
+
return result.model_copy(update={"composite_score": score})
|
| 98 |
+
finally:
|
| 99 |
+
if tmp_root is not None and tmp_root.exists():
|
| 100 |
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
codeforge/sandbox/tools.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
import shutil
|
| 6 |
+
from typing import TYPE_CHECKING
|
| 7 |
+
|
| 8 |
+
from codeforge.sandbox.models import ParsedResult, ToolResult
|
| 9 |
+
|
| 10 |
+
if TYPE_CHECKING:
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
DEFAULT_TOOLS: tuple[str, ...] = ("ruff", "mypy", "pytest", "imports")
|
| 14 |
+
|
| 15 |
+
_MYPY_ERROR_RE = re.compile(r"Found (\d+) errors?")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def argv_for(name: str, project_dir: Path) -> list[str]:
|
| 19 |
+
"""Return the argv for running a tool. Runner sets cwd=project_dir."""
|
| 20 |
+
del project_dir
|
| 21 |
+
if name == "ruff":
|
| 22 |
+
return ["ruff", "check", "--output-format", "json", "."]
|
| 23 |
+
if name == "mypy":
|
| 24 |
+
return ["mypy", "--no-incremental", "--strict", "."]
|
| 25 |
+
if name == "pytest":
|
| 26 |
+
return ["pytest", "-q", "--tb=line", "--no-header"]
|
| 27 |
+
if name == "pip-audit":
|
| 28 |
+
return ["pip-audit", "--format", "json"]
|
| 29 |
+
msg = f"unknown tool: {name}"
|
| 30 |
+
raise ValueError(msg)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def parse(name: str, tool_result: ToolResult) -> ParsedResult:
|
| 34 |
+
if name == "ruff":
|
| 35 |
+
return _parse_ruff(tool_result)
|
| 36 |
+
if name == "mypy":
|
| 37 |
+
return _parse_mypy(tool_result)
|
| 38 |
+
if name == "pytest":
|
| 39 |
+
return _parse_pytest(tool_result)
|
| 40 |
+
if name == "pip-audit":
|
| 41 |
+
return _parse_pip_audit(tool_result)
|
| 42 |
+
return ParsedResult(ok=tool_result.exit_code == 0, count=0, details={})
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _parse_ruff(tr: ToolResult) -> ParsedResult:
|
| 46 |
+
try:
|
| 47 |
+
items = json.loads(tr.stdout or "[]")
|
| 48 |
+
except json.JSONDecodeError:
|
| 49 |
+
return ParsedResult(ok=False, count=0, details={"parse_error": tr.stdout[:500]})
|
| 50 |
+
count = len(items) if isinstance(items, list) else 0
|
| 51 |
+
return ParsedResult(
|
| 52 |
+
ok=count == 0 and tr.exit_code == 0,
|
| 53 |
+
count=count,
|
| 54 |
+
details={"violations": items[:20]},
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _parse_mypy(tr: ToolResult) -> ParsedResult:
|
| 59 |
+
if tr.exit_code == 0 and "Success" in tr.stdout:
|
| 60 |
+
return ParsedResult(ok=True, count=0, details={})
|
| 61 |
+
m = _MYPY_ERROR_RE.search(tr.stdout)
|
| 62 |
+
count = int(m.group(1)) if m else 0
|
| 63 |
+
return ParsedResult(ok=False, count=count, details={"tail": tr.stdout[-500:]})
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _parse_pytest(tr: ToolResult) -> ParsedResult:
|
| 67 |
+
ok = tr.exit_code == 0
|
| 68 |
+
return ParsedResult(ok=ok, count=0 if ok else 1, details={"tail": tr.stdout[-500:]})
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _parse_pip_audit(tr: ToolResult) -> ParsedResult:
|
| 72 |
+
if tr.exit_code == 0:
|
| 73 |
+
return ParsedResult(ok=True, count=0, details={})
|
| 74 |
+
try:
|
| 75 |
+
data = json.loads(tr.stdout or "{}")
|
| 76 |
+
except json.JSONDecodeError:
|
| 77 |
+
data = {}
|
| 78 |
+
count = len(data.get("vulnerabilities", []))
|
| 79 |
+
return ParsedResult(ok=count == 0, count=count, details={"tail": tr.stdout[-500:]})
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def is_available(name: str) -> bool:
|
| 83 |
+
if name == "imports":
|
| 84 |
+
return True
|
| 85 |
+
return shutil.which(name) is not None
|
codeforge/scraper/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from codeforge.scraper.discovery import SourceRoot, walk_sources
|
| 4 |
+
from codeforge.scraper.parser import ParsedSkill, ParseError, parse_skill
|
| 5 |
+
from codeforge.scraper.pipeline import ScrapeResult, run_scraper, scrape_single_skill
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"ParseError",
|
| 9 |
+
"ParsedSkill",
|
| 10 |
+
"ScrapeResult",
|
| 11 |
+
"SourceRoot",
|
| 12 |
+
"parse_skill",
|
| 13 |
+
"run_scraper",
|
| 14 |
+
"scrape_single_skill",
|
| 15 |
+
"walk_sources",
|
| 16 |
+
]
|
codeforge/scraper/chunker.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from markdown_it import MarkdownIt
|
| 4 |
+
from markdown_it.token import Token
|
| 5 |
+
from pydantic import BaseModel, ConfigDict
|
| 6 |
+
|
| 7 |
+
MIN_CHUNK_CHARS = 80
|
| 8 |
+
MAX_HEADING_LEVEL = 3
|
| 9 |
+
|
| 10 |
+
_md = MarkdownIt()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SectionChunk(BaseModel):
|
| 14 |
+
model_config = ConfigDict(frozen=True)
|
| 15 |
+
section_path: tuple[str, ...]
|
| 16 |
+
section_body: str
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _segment_tokens(
|
| 20 |
+
tokens: list[Token],
|
| 21 |
+
) -> list[tuple[int, str, list[str]]]:
|
| 22 |
+
"""Walk token stream, returning heading-delimited segments.
|
| 23 |
+
|
| 24 |
+
Each segment is (heading_level, heading_text, body_texts).
|
| 25 |
+
Level 0 is a sentinel for content before the first H1-H3.
|
| 26 |
+
"""
|
| 27 |
+
segments: list[tuple[int, str, list[str]]] = []
|
| 28 |
+
current: tuple[int, str, list[str]] | None = None
|
| 29 |
+
|
| 30 |
+
for i, tok in enumerate(tokens):
|
| 31 |
+
if tok.type == "heading_open":
|
| 32 |
+
level = int(tok.tag[1]) # "h2" -> 2
|
| 33 |
+
heading_text = tokens[i + 1].content.strip()
|
| 34 |
+
if level <= MAX_HEADING_LEVEL:
|
| 35 |
+
if current is not None:
|
| 36 |
+
segments.append(current)
|
| 37 |
+
current = (level, heading_text, [])
|
| 38 |
+
else:
|
| 39 |
+
if current is None:
|
| 40 |
+
current = (0, "", [])
|
| 41 |
+
current[2].append(heading_text)
|
| 42 |
+
elif tok.type.endswith("_open"):
|
| 43 |
+
continue
|
| 44 |
+
elif tok.type == "inline":
|
| 45 |
+
if current is None:
|
| 46 |
+
current = (0, "", [])
|
| 47 |
+
current[2].append(tok.content)
|
| 48 |
+
|
| 49 |
+
if current is not None:
|
| 50 |
+
segments.append(current)
|
| 51 |
+
return segments
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def chunk_body(body: str) -> list[SectionChunk]:
|
| 55 |
+
"""Split markdown body into section chunks by H1-H3 headings."""
|
| 56 |
+
tokens = _md.parse(body)
|
| 57 |
+
segments = _segment_tokens(tokens)
|
| 58 |
+
|
| 59 |
+
if not segments:
|
| 60 |
+
text = body.strip()
|
| 61 |
+
if text:
|
| 62 |
+
return [SectionChunk(section_path=(), section_body=text)]
|
| 63 |
+
return []
|
| 64 |
+
|
| 65 |
+
stack: list[str] = []
|
| 66 |
+
chunks: list[SectionChunk] = []
|
| 67 |
+
for level, title, body_parts in segments:
|
| 68 |
+
if level == 0:
|
| 69 |
+
chunks.append(
|
| 70 |
+
SectionChunk(
|
| 71 |
+
section_path=(),
|
| 72 |
+
section_body="\n\n".join(body_parts).strip(),
|
| 73 |
+
)
|
| 74 |
+
)
|
| 75 |
+
continue
|
| 76 |
+
while len(stack) >= level:
|
| 77 |
+
stack.pop()
|
| 78 |
+
stack.append(title)
|
| 79 |
+
chunks.append(
|
| 80 |
+
SectionChunk(
|
| 81 |
+
section_path=tuple(stack),
|
| 82 |
+
section_body="\n\n".join(body_parts).strip(),
|
| 83 |
+
)
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
return _merge_small(chunks)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _merge_small(chunks: list[SectionChunk]) -> list[SectionChunk]:
|
| 90 |
+
"""Merge chunks smaller than MIN_CHUNK_CHARS into their successor."""
|
| 91 |
+
if not chunks:
|
| 92 |
+
return chunks
|
| 93 |
+
merged: list[SectionChunk] = []
|
| 94 |
+
carry: str = ""
|
| 95 |
+
for c in chunks:
|
| 96 |
+
body = (carry + "\n\n" + c.section_body).strip() if carry else c.section_body
|
| 97 |
+
if len(body) < MIN_CHUNK_CHARS and c is not chunks[-1]:
|
| 98 |
+
carry = body
|
| 99 |
+
continue
|
| 100 |
+
merged.append(SectionChunk(section_path=c.section_path, section_body=body))
|
| 101 |
+
carry = ""
|
| 102 |
+
if carry and merged:
|
| 103 |
+
last = merged[-1]
|
| 104 |
+
merged[-1] = SectionChunk(
|
| 105 |
+
section_path=last.section_path,
|
| 106 |
+
section_body=(last.section_body + "\n\n" + carry).strip(),
|
| 107 |
+
)
|
| 108 |
+
return merged
|
codeforge/scraper/discovery.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import glob as glob_mod
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
from collections.abc import Iterator
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel, ConfigDict
|
| 10 |
+
|
| 11 |
+
_log = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SourceRoot(BaseModel):
|
| 15 |
+
model_config = ConfigDict(frozen=True)
|
| 16 |
+
label: str
|
| 17 |
+
glob: str
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def walk_sources(sources: list[SourceRoot]) -> Iterator[tuple[Path, SourceRoot]]:
|
| 21 |
+
"""Yield (path, source_root) for every readable SKILL.md matched by a glob."""
|
| 22 |
+
for root in sources:
|
| 23 |
+
pattern = os.path.expanduser(root.glob)
|
| 24 |
+
for match in glob_mod.glob(pattern, recursive=True):
|
| 25 |
+
path = Path(match)
|
| 26 |
+
if not path.is_file():
|
| 27 |
+
_log.warning("discovery: skipping non-file %s", path)
|
| 28 |
+
continue
|
| 29 |
+
try:
|
| 30 |
+
path.stat()
|
| 31 |
+
except OSError as e:
|
| 32 |
+
_log.warning("discovery: unreadable %s: %s", path, e)
|
| 33 |
+
continue
|
| 34 |
+
yield path, root
|
codeforge/scraper/parser.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import frontmatter # type: ignore[import-untyped]
|
| 6 |
+
import yaml
|
| 7 |
+
from pydantic import BaseModel, ConfigDict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ParsedSkill(BaseModel):
|
| 11 |
+
model_config = ConfigDict(frozen=True)
|
| 12 |
+
frontmatter: dict[str, object]
|
| 13 |
+
body: str
|
| 14 |
+
mtime: float
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ParseError(Exception):
|
| 18 |
+
"""Raised when a SKILL.md cannot be parsed."""
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def parse_skill(path: Path) -> ParsedSkill:
|
| 22 |
+
"""Parse a SKILL.md file: extract YAML frontmatter and markdown body."""
|
| 23 |
+
try:
|
| 24 |
+
raw = path.read_text(encoding="utf-8")
|
| 25 |
+
except OSError as e:
|
| 26 |
+
raise ParseError(f"unreadable: {path}: {e}") from e
|
| 27 |
+
try:
|
| 28 |
+
post = frontmatter.loads(raw)
|
| 29 |
+
except yaml.YAMLError as e:
|
| 30 |
+
raise ParseError(f"malformed yaml in {path}: {e}") from e
|
| 31 |
+
mtime = path.stat().st_mtime
|
| 32 |
+
return ParsedSkill(frontmatter=dict(post.metadata), body=post.content, mtime=mtime)
|
codeforge/scraper/pipeline.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import logging
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from codeforge.scraper.chunker import chunk_body
|
| 10 |
+
from codeforge.scraper.discovery import SourceRoot, walk_sources
|
| 11 |
+
from codeforge.scraper.parser import ParseError, parse_skill
|
| 12 |
+
from codeforge.scraper.tagger import infer_tags
|
| 13 |
+
from codeforge.scraper.writer import write_corpus, write_manifest
|
| 14 |
+
|
| 15 |
+
_log = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class ScrapeResult:
|
| 20 |
+
scraped_files: int
|
| 21 |
+
skipped_files: int
|
| 22 |
+
total_nodes: int
|
| 23 |
+
errors: list[dict[str, str]]
|
| 24 |
+
corpus_path: Path
|
| 25 |
+
manifest_path: Path
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _node_id(source_path: str, section_path: tuple[str, ...]) -> str:
|
| 29 |
+
raw = source_path + "#" + "/".join(section_path)
|
| 30 |
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _body_hash(body: str) -> str:
|
| 34 |
+
return hashlib.sha256(body.encode()).hexdigest()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def scrape_single_skill(path: Path) -> list[dict[str, Any]]:
|
| 38 |
+
"""Scrape a single SKILL.md file and return a list of node dicts."""
|
| 39 |
+
parsed = parse_skill(path)
|
| 40 |
+
body = parsed.body.strip()
|
| 41 |
+
if not body:
|
| 42 |
+
return []
|
| 43 |
+
|
| 44 |
+
fm = parsed.frontmatter
|
| 45 |
+
name_val = fm.get("name")
|
| 46 |
+
skill_name = str(name_val) if name_val else path.parent.name
|
| 47 |
+
desc_val = fm.get("description", "")
|
| 48 |
+
skill_desc = str(desc_val) if desc_val else ""
|
| 49 |
+
type_val = fm.get("type")
|
| 50 |
+
skill_type: str | None = (
|
| 51 |
+
str(type_val) if type_val in ("flexible", "rigid") else None
|
| 52 |
+
)
|
| 53 |
+
triggers = skill_desc
|
| 54 |
+
|
| 55 |
+
raw_chunks = chunk_body(body)
|
| 56 |
+
if not raw_chunks:
|
| 57 |
+
return []
|
| 58 |
+
|
| 59 |
+
chunks = [
|
| 60 |
+
c.model_copy(update={"section_path": (skill_name,)})
|
| 61 |
+
if c.section_path == ()
|
| 62 |
+
else c
|
| 63 |
+
for c in raw_chunks
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
nodes: list[dict[str, Any]] = []
|
| 67 |
+
for chunk in chunks:
|
| 68 |
+
tags = list(
|
| 69 |
+
infer_tags(
|
| 70 |
+
skill_name,
|
| 71 |
+
chunk.section_path[-1] if chunk.section_path else "",
|
| 72 |
+
chunk.section_body,
|
| 73 |
+
)
|
| 74 |
+
)
|
| 75 |
+
node: dict[str, Any] = {
|
| 76 |
+
"id": _node_id(str(path), chunk.section_path),
|
| 77 |
+
"skill_name": skill_name,
|
| 78 |
+
"skill_description": skill_desc,
|
| 79 |
+
"skill_type": skill_type,
|
| 80 |
+
"section_path": list(chunk.section_path),
|
| 81 |
+
"section_title": chunk.section_path[-1] if chunk.section_path else "",
|
| 82 |
+
"section_body": chunk.section_body,
|
| 83 |
+
"source_path": str(path),
|
| 84 |
+
"source_root": "",
|
| 85 |
+
"tags": tags,
|
| 86 |
+
"trigger_hints": triggers,
|
| 87 |
+
"mtime": parsed.mtime,
|
| 88 |
+
"body_hash": _body_hash(chunk.section_body),
|
| 89 |
+
"alias_sources": [],
|
| 90 |
+
}
|
| 91 |
+
nodes.append(node)
|
| 92 |
+
return nodes
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _dedupe(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 96 |
+
"""Deduplicate nodes by (skill_name, section_path, body_hash)."""
|
| 97 |
+
index: dict[tuple[str, tuple[str, ...], str], dict[str, Any]] = {}
|
| 98 |
+
for n in nodes:
|
| 99 |
+
key = (n["skill_name"], tuple(n["section_path"]), n["body_hash"])
|
| 100 |
+
existing = index.get(key)
|
| 101 |
+
if existing is None:
|
| 102 |
+
index[key] = n
|
| 103 |
+
else:
|
| 104 |
+
aliases = list(existing.get("alias_sources", []))
|
| 105 |
+
aliases.append(n["source_path"])
|
| 106 |
+
existing["alias_sources"] = aliases
|
| 107 |
+
return list(index.values())
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def run_scraper(
|
| 111 |
+
*, sources: list[SourceRoot], output: Path
|
| 112 |
+
) -> ScrapeResult:
|
| 113 |
+
"""Full pipeline: discover -> parse -> chunk -> tag -> deduplicate -> write."""
|
| 114 |
+
nodes: list[dict[str, Any]] = []
|
| 115 |
+
errors: list[dict[str, str]] = []
|
| 116 |
+
scraped = 0
|
| 117 |
+
skipped = 0
|
| 118 |
+
|
| 119 |
+
for path, root in walk_sources(sources):
|
| 120 |
+
try:
|
| 121 |
+
file_nodes = scrape_single_skill(path)
|
| 122 |
+
except ParseError as e:
|
| 123 |
+
errors.append({"path": str(path), "stage": "parse", "reason": str(e)})
|
| 124 |
+
skipped += 1
|
| 125 |
+
continue
|
| 126 |
+
|
| 127 |
+
if not file_nodes:
|
| 128 |
+
skipped += 1
|
| 129 |
+
continue
|
| 130 |
+
|
| 131 |
+
for n in file_nodes:
|
| 132 |
+
n["source_root"] = root.label
|
| 133 |
+
nodes.extend(file_nodes)
|
| 134 |
+
scraped += 1
|
| 135 |
+
|
| 136 |
+
deduped = _dedupe(nodes)
|
| 137 |
+
corpus_path = write_corpus(deduped, output)
|
| 138 |
+
manifest_path = write_manifest(
|
| 139 |
+
corpus_path=corpus_path,
|
| 140 |
+
sources=[{"label": s.label, "glob": s.glob} for s in sources],
|
| 141 |
+
scraped_files=scraped,
|
| 142 |
+
skipped_files=skipped,
|
| 143 |
+
total_nodes=len(deduped),
|
| 144 |
+
errors=errors,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
return ScrapeResult(
|
| 148 |
+
scraped_files=scraped,
|
| 149 |
+
skipped_files=skipped,
|
| 150 |
+
total_nodes=len(deduped),
|
| 151 |
+
errors=errors,
|
| 152 |
+
corpus_path=corpus_path,
|
| 153 |
+
manifest_path=manifest_path,
|
| 154 |
+
)
|
codeforge/scraper/tagger.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
DOMAIN_RULES: list[tuple[str, list[str]]] = [
|
| 6 |
+
("python", ["python", "pytest", "pip", "pydantic"]),
|
| 7 |
+
("javascript", ["javascript", "typescript", "react", "next.js", "node"]),
|
| 8 |
+
("go", ["golang", "go"]),
|
| 9 |
+
("kotlin", ["kotlin", "gradle"]),
|
| 10 |
+
("security", ["security", "auth", "secret", "owasp", "injection"]),
|
| 11 |
+
("frontend", ["frontend", "ui", "ux", "tailwind", "css"]),
|
| 12 |
+
("api", ["api", "endpoint", "rest", "graphql", "openapi"]),
|
| 13 |
+
("backend", ["backend", "fastapi", "django", "spring"]),
|
| 14 |
+
("data", ["pandas", "numpy", "clickhouse", "postgres", "sql"]),
|
| 15 |
+
("mcp", ["mcp", "model context protocol"]),
|
| 16 |
+
("devops", ["docker", "kubernetes", "ci/cd", "deploy"]),
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
PHASE_RULES: list[tuple[str, list[str]]] = [
|
| 20 |
+
("plan", ["plan", "design", "architecture", "brainstorm"]),
|
| 21 |
+
("build", ["build", "implement", "feature", "write"]),
|
| 22 |
+
("test", ["test", "pytest", "coverage", "tdd"]),
|
| 23 |
+
("review", ["review", "critic", "checklist", "audit"]),
|
| 24 |
+
("deploy", ["deploy", "release", "ship", "publish"]),
|
| 25 |
+
("debug", ["debug", "fix", "troubleshoot", "bug"]),
|
| 26 |
+
("docs", ["docs", "documentation", "readme"]),
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
_TOKEN_RE = re.compile(r"[a-z0-9]+(?:\.[a-z0-9]+)*")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _tokenize(text: str) -> frozenset[str]:
|
| 33 |
+
"""Split lowercased text into alphanumeric tokens."""
|
| 34 |
+
return frozenset(_TOKEN_RE.findall(text.lower()))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _matches(keyword: str, tokens: frozenset[str], joined_lower: str) -> bool:
|
| 38 |
+
if " " in keyword:
|
| 39 |
+
return keyword in joined_lower
|
| 40 |
+
return keyword in tokens
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def infer_tags(skill_name: str, section_title: str, body: str) -> list[str]:
|
| 44 |
+
"""Infer domain and phase tags from skill name, section title, and body."""
|
| 45 |
+
joined_lower = " ".join(
|
| 46 |
+
p.lower() for p in (skill_name, section_title, body) if p
|
| 47 |
+
)
|
| 48 |
+
tokens = _tokenize(joined_lower)
|
| 49 |
+
tags: list[str] = []
|
| 50 |
+
|
| 51 |
+
domain = "general"
|
| 52 |
+
for name, keywords in DOMAIN_RULES:
|
| 53 |
+
if any(_matches(k, tokens, joined_lower) for k in keywords):
|
| 54 |
+
domain = name
|
| 55 |
+
break
|
| 56 |
+
tags.append(f"domain:{domain}")
|
| 57 |
+
|
| 58 |
+
for name, keywords in PHASE_RULES:
|
| 59 |
+
if any(_matches(k, tokens, joined_lower) for k in keywords):
|
| 60 |
+
tags.append(f"phase:{name}")
|
| 61 |
+
|
| 62 |
+
return tags
|
codeforge/scraper/writer.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
from collections.abc import Iterable
|
| 6 |
+
from datetime import UTC, datetime
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def write_corpus(nodes: Iterable[dict[str, Any]], out_path: Path) -> Path:
|
| 12 |
+
"""Write nodes as sorted JSONL."""
|
| 13 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 14 |
+
sorted_nodes = sorted(nodes, key=lambda n: str(n["id"]))
|
| 15 |
+
with out_path.open("w", encoding="utf-8") as f:
|
| 16 |
+
for node in sorted_nodes:
|
| 17 |
+
f.write(json.dumps(node, default=str))
|
| 18 |
+
f.write("\n")
|
| 19 |
+
return out_path
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def compute_corpus_sha256(corpus_path: Path) -> str:
|
| 23 |
+
"""SHA256 hex digest of the corpus file."""
|
| 24 |
+
return hashlib.sha256(corpus_path.read_bytes()).hexdigest()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def write_manifest(
|
| 28 |
+
*,
|
| 29 |
+
corpus_path: Path,
|
| 30 |
+
sources: list[dict[str, str]],
|
| 31 |
+
scraped_files: int,
|
| 32 |
+
skipped_files: int,
|
| 33 |
+
total_nodes: int,
|
| 34 |
+
errors: list[dict[str, str]],
|
| 35 |
+
) -> Path:
|
| 36 |
+
"""Write a manifest JSON alongside the corpus."""
|
| 37 |
+
digest = compute_corpus_sha256(corpus_path)
|
| 38 |
+
manifest = {
|
| 39 |
+
"generated_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
| 40 |
+
"sources": sources,
|
| 41 |
+
"scraped_files": scraped_files,
|
| 42 |
+
"skipped_files": skipped_files,
|
| 43 |
+
"total_nodes": total_nodes,
|
| 44 |
+
"errors": errors,
|
| 45 |
+
"corpus_sha256": digest,
|
| 46 |
+
}
|
| 47 |
+
manifest_path = corpus_path.with_suffix(".manifest.json")
|
| 48 |
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 49 |
+
manifest_path.write_text(
|
| 50 |
+
json.dumps(manifest, indent=2), encoding="utf-8"
|
| 51 |
+
)
|
| 52 |
+
return manifest_path
|
codeforge/shaping.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
# Match explicit citation comments: # cited: skill-name or # ref: skill-name
|
| 6 |
+
_CITATION_RE = re.compile(r"#\s*(?:cited|ref|source|from):\s*(\S+)", re.IGNORECASE)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def citation_shaping_bonus(
|
| 10 |
+
*,
|
| 11 |
+
submit_files: dict[str, str],
|
| 12 |
+
prior_citations: list[dict[str, object]],
|
| 13 |
+
prior_cluster_hits: list[str],
|
| 14 |
+
) -> float:
|
| 15 |
+
"""Retroactive shaping bonus for prior queries whose cited skills appear in submitted code.
|
| 16 |
+
|
| 17 |
+
+0.01 per cited skill name found as an explicit citation comment in the code,
|
| 18 |
+
max 0.05. Only fires on submit with reward > 0. See SYSTEM_DESIGN §4.8.4.
|
| 19 |
+
|
| 20 |
+
Uses explicit comment matching (``# cited: skill-name``) instead of substring
|
| 21 |
+
matching to prevent common-word skill names from trivially matching any code.
|
| 22 |
+
"""
|
| 23 |
+
if not prior_citations:
|
| 24 |
+
return 0.0
|
| 25 |
+
|
| 26 |
+
cited_skills: set[str] = set()
|
| 27 |
+
for c in prior_citations:
|
| 28 |
+
sn = c.get("skill_name")
|
| 29 |
+
if isinstance(sn, str):
|
| 30 |
+
cited_skills.add(sn.lower())
|
| 31 |
+
|
| 32 |
+
code_text = "\n".join(submit_files.values())
|
| 33 |
+
code_citations = {m.group(1).lower() for m in _CITATION_RE.finditer(code_text)}
|
| 34 |
+
|
| 35 |
+
overlap = len(cited_skills & code_citations)
|
| 36 |
+
return min(overlap * 0.01, 0.05)
|
codeforge/tasks.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass(frozen=True)
|
| 7 |
+
class Task:
|
| 8 |
+
task_id: str
|
| 9 |
+
task_level: str
|
| 10 |
+
brief: str
|
| 11 |
+
initial_files: dict[str, str]
|
| 12 |
+
target_score: float
|
| 13 |
+
max_budget: int
|
| 14 |
+
tools: tuple[str, ...]
|
| 15 |
+
hidden_tests: dict[str, str] = () # type: ignore[assignment]
|
| 16 |
+
"""Hidden correctness tests injected by the environment during grading.
|
| 17 |
+
|
| 18 |
+
The agent never sees these. They are written into the sandbox temp dir
|
| 19 |
+
alongside the agent's submitted files so pytest runs them automatically.
|
| 20 |
+
This prevents "clean garbage" exploits where syntactically valid but
|
| 21 |
+
semantically wrong code scores perfectly.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# -- Hidden test suites (agent never sees these) ----------------------------
|
| 26 |
+
|
| 27 |
+
_HIDDEN_EASY = {
|
| 28 |
+
"test_hidden_greet.py": (
|
| 29 |
+
"from __future__ import annotations\n"
|
| 30 |
+
"from main import greet\n\n"
|
| 31 |
+
"def test_greet_alice() -> None:\n"
|
| 32 |
+
' assert greet("Alice") == "Hello, Alice!"\n\n'
|
| 33 |
+
"def test_greet_bob() -> None:\n"
|
| 34 |
+
' assert greet("Bob") == "Hello, Bob!"\n\n'
|
| 35 |
+
"def test_greet_empty() -> None:\n"
|
| 36 |
+
' assert greet("") == "Hello, !"\n'
|
| 37 |
+
),
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
_HIDDEN_MEDIUM = {
|
| 41 |
+
"test_hidden_greet.py": (
|
| 42 |
+
"from __future__ import annotations\n"
|
| 43 |
+
"import pytest\n"
|
| 44 |
+
"from main import greet\n\n"
|
| 45 |
+
"def test_greet_alice() -> None:\n"
|
| 46 |
+
' assert greet("Alice") == "Hello, Alice!"\n\n'
|
| 47 |
+
"def test_greet_none_raises() -> None:\n"
|
| 48 |
+
" with pytest.raises(ValueError):\n"
|
| 49 |
+
" greet(None) # type: ignore[arg-type]\n\n"
|
| 50 |
+
"def test_greet_returns_str() -> None:\n"
|
| 51 |
+
' assert isinstance(greet("X"), str)\n'
|
| 52 |
+
),
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
_HIDDEN_HARD = {
|
| 56 |
+
"test_hidden_core.py": (
|
| 57 |
+
"from __future__ import annotations\n"
|
| 58 |
+
"import pytest\n"
|
| 59 |
+
"from core import greet\n\n"
|
| 60 |
+
"def test_greet_alice() -> None:\n"
|
| 61 |
+
' assert greet("Alice") == "Hello, Alice!"\n\n'
|
| 62 |
+
"def test_greet_bob() -> None:\n"
|
| 63 |
+
' assert greet("Bob") == "Hello, Bob!"\n\n'
|
| 64 |
+
"def test_greet_returns_str() -> None:\n"
|
| 65 |
+
' assert isinstance(greet("X"), str)\n\n'
|
| 66 |
+
"def test_greet_empty() -> None:\n"
|
| 67 |
+
' assert greet("") == "Hello, !"\n'
|
| 68 |
+
),
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
TASKS: tuple[Task, ...] = (
|
| 73 |
+
Task(
|
| 74 |
+
task_id="greet_single_file",
|
| 75 |
+
task_level="easy",
|
| 76 |
+
brief=(
|
| 77 |
+
"Implement `greet(name)` in `main.py` so that `greet(\"Alice\")` returns "
|
| 78 |
+
'`"Hello, Alice!"`. Use type hints. Keep the module under 15 lines.'
|
| 79 |
+
),
|
| 80 |
+
initial_files={"main.py": "def greet(name):\n pass\n"},
|
| 81 |
+
target_score=0.90,
|
| 82 |
+
max_budget=4,
|
| 83 |
+
tools=("ruff", "imports", "mypy", "pytest"),
|
| 84 |
+
hidden_tests=_HIDDEN_EASY,
|
| 85 |
+
),
|
| 86 |
+
Task(
|
| 87 |
+
task_id="greet_with_tests",
|
| 88 |
+
task_level="medium",
|
| 89 |
+
brief=(
|
| 90 |
+
"Extend `main.py` so that `greet(None)` raises `ValueError`, "
|
| 91 |
+
"and add a `test_main.py` with pytest assertions. Keep `ruff` and "
|
| 92 |
+
"`mypy --strict` clean."
|
| 93 |
+
),
|
| 94 |
+
initial_files={
|
| 95 |
+
"main.py": (
|
| 96 |
+
"from __future__ import annotations\n\n\n"
|
| 97 |
+
"def greet(name: str) -> str:\n"
|
| 98 |
+
' return f"Hello, {name}!"\n'
|
| 99 |
+
),
|
| 100 |
+
"test_main.py": "",
|
| 101 |
+
},
|
| 102 |
+
target_score=0.80,
|
| 103 |
+
max_budget=6,
|
| 104 |
+
tools=("ruff", "imports", "mypy", "pytest"),
|
| 105 |
+
hidden_tests=_HIDDEN_MEDIUM,
|
| 106 |
+
),
|
| 107 |
+
Task(
|
| 108 |
+
task_id="multi_file_module",
|
| 109 |
+
task_level="hard",
|
| 110 |
+
brief=(
|
| 111 |
+
"Split into three files: `main.py` (entry), `core.py` (the greet "
|
| 112 |
+
"function), `test_core.py` (tests). Every function must be type-hinted. "
|
| 113 |
+
"All tests pass. `mypy --strict` clean."
|
| 114 |
+
),
|
| 115 |
+
initial_files={
|
| 116 |
+
"main.py": (
|
| 117 |
+
"from __future__ import annotations\n\nfrom core import greet\n\n\n"
|
| 118 |
+
'if __name__ == "__main__":\n'
|
| 119 |
+
' print(greet("World"))\n'
|
| 120 |
+
),
|
| 121 |
+
"core.py": "",
|
| 122 |
+
"test_core.py": "",
|
| 123 |
+
},
|
| 124 |
+
target_score=0.70,
|
| 125 |
+
max_budget=10,
|
| 126 |
+
tools=("ruff", "imports", "mypy", "pytest"),
|
| 127 |
+
hidden_tests=_HIDDEN_HARD,
|
| 128 |
+
),
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def get_task(task_level: str) -> Task:
|
| 133 |
+
for t in TASKS:
|
| 134 |
+
if t.task_level == task_level:
|
| 135 |
+
return t
|
| 136 |
+
msg = f"unknown task_level: {task_level!r} (expected easy|medium|hard)"
|
| 137 |
+
raise ValueError(msg)
|
dataset/mbpp.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
unsloth
|
| 4 |
+
trl>=0.14.0
|
| 5 |
+
datasets
|
| 6 |
+
peft
|
| 7 |
+
transformers
|
| 8 |
+
accelerate
|
| 9 |
+
pytest
|
| 10 |
+
ruff
|
| 11 |
+
mypy
|