Merge nucleic/jolly-coral-egret-smoz into dev
This commit is contained in:
+34
-12
@@ -18,7 +18,7 @@ import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
from typing import Any, Callable, Sequence, TypeVar
|
||||
|
||||
import label_nucleic_prompts as base
|
||||
from purpose_data import DataError, SOURCE_FIELDS, canonical_json, prompt_hash, validate_source_record
|
||||
@@ -30,6 +30,7 @@ DEFAULT_OUTPUT = SCRIPT_DIR / ".artifacts" / "swe-chat" / "labeled-source.jsonl"
|
||||
DEFAULT_MODEL = "gpt-5.6-luna"
|
||||
DEFAULT_REASONING_EFFORT = "low"
|
||||
STATE_SCHEMA_VERSION = 1
|
||||
T = TypeVar("T")
|
||||
DEFAULT_MAX_RESPONSE_CHARS = 48_000
|
||||
FENCED_CODE_RE = re.compile(r"(?ms)^[ \t]*```[^\n]*\n.*?^[ \t]*```[ \t]*$")
|
||||
TOOL_PAYLOAD_RE = re.compile(
|
||||
@@ -57,7 +58,8 @@ class Candidate:
|
||||
return base.Candidate(self.line, self.prompt, prompt_hash(self.prompt), self.session_id)
|
||||
|
||||
|
||||
def _text(value: Any, field: str, location: str) -> str:
|
||||
def required_text(value: Any, field: str, location: str) -> str:
|
||||
"""A non-empty, NUL-free string field. Shared with the sibling prose labeler."""
|
||||
if not isinstance(value, str) or not value.strip() or "\x00" in value:
|
||||
raise DataError(f"{location}: invalid {field}")
|
||||
return value
|
||||
@@ -76,8 +78,8 @@ def candidates(lines: Sequence[base.SourceLine]) -> list[Candidate]:
|
||||
location = f"candidate line {line.number}"
|
||||
if value.get("schemaVersion") != 1:
|
||||
raise DataError(f"{location}: unsupported candidate schema")
|
||||
prompt = _text(value.get("prompt"), "prompt", location)
|
||||
session_id = _text(value.get("sessionID"), "sessionID", location)
|
||||
prompt = required_text(value.get("prompt"), "prompt", location)
|
||||
session_id = required_text(value.get("sessionID"), "sessionID", location)
|
||||
response = value.get("teacherResponse")
|
||||
turn_ids = value.get("sourceTurnIDs")
|
||||
response_hash = value.get("teacherResponseHash")
|
||||
@@ -86,7 +88,7 @@ def candidates(lines: Sequence[base.SourceLine]) -> list[Candidate]:
|
||||
first_hash = value.get("promptHash")
|
||||
if first_hash != prompt_hash(prompt):
|
||||
raise DataError(f"{location}: promptHash does not match prompt")
|
||||
response_text = _text(response, "teacherResponse", location)
|
||||
response_text = required_text(response, "teacherResponse", location)
|
||||
if response_hash != prompt_hash(response_text):
|
||||
raise DataError(f"{location}: teacherResponseHash does not match teacherResponse")
|
||||
candidate = Candidate(
|
||||
@@ -96,7 +98,7 @@ def candidates(lines: Sequence[base.SourceLine]) -> list[Candidate]:
|
||||
session_id=session_id,
|
||||
repo_id=value.get("repoID") if isinstance(value.get("repoID"), str) else None,
|
||||
user_id=value.get("userID") if isinstance(value.get("userID"), str) else None,
|
||||
turn_ids=tuple(_text(item, "sourceTurnID", location) for item in turn_ids), # type: ignore[arg-type]
|
||||
turn_ids=tuple(required_text(item, "sourceTurnID", location) for item in turn_ids), # type: ignore[arg-type]
|
||||
response_hash=response_hash,
|
||||
)
|
||||
digest = prompt_hash(prompt)
|
||||
@@ -236,14 +238,25 @@ def validate_decisions(batch: Sequence[Candidate], response: Any) -> list[tuple[
|
||||
return [(candidate, decisions[candidate.id]) for candidate in batch]
|
||||
|
||||
|
||||
def invoke_codex(args: argparse.Namespace, batch: Sequence[Candidate]) -> list[tuple[Candidate, dict[str, Any]]]:
|
||||
prompt = labeling_prompt(batch, args.max_prompt_chars, args.max_response_chars)
|
||||
def run_codex(
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
prompt: str,
|
||||
schema: dict[str, Any],
|
||||
validate: Callable[[Any], T],
|
||||
) -> T:
|
||||
"""Run one hardened, sandboxed Codex batch and return its validated decisions.
|
||||
|
||||
`validate` runs inside the retry loop on purpose: a response that parses but violates
|
||||
the label contract is a failed attempt, not a fatal error. Sibling labelers share this
|
||||
runner so the sandbox flags and retry semantics can only be changed in one place.
|
||||
"""
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, args.max_attempts + 1):
|
||||
with tempfile.TemporaryDirectory(prefix="purpose-swe-label-") as directory:
|
||||
root = Path(directory)
|
||||
schema_path, response_path = root / "schema.json", root / "response.json"
|
||||
schema_path.write_text(json.dumps(response_schema(batch), ensure_ascii=False), encoding="utf-8")
|
||||
schema_path.write_text(json.dumps(schema, ensure_ascii=False), encoding="utf-8")
|
||||
isolation = args.codex_isolation
|
||||
if isolation == "auto":
|
||||
isolation = "external" if os.environ.get("NUCLEIC_SESSION_ID") else "read-only"
|
||||
@@ -258,13 +271,22 @@ def invoke_codex(args: argparse.Namespace, batch: Sequence[Candidate]) -> list[t
|
||||
completed = subprocess.run(command, input=prompt, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=root, timeout=args.timeout_seconds, check=False)
|
||||
if completed.returncode != 0:
|
||||
raise DataError(f"Codex exited {completed.returncode}: {completed.stderr[-4000:].strip() or 'no stderr'}")
|
||||
return validate_decisions(batch, json.loads(response_path.read_text(encoding="utf-8")))
|
||||
return validate(json.loads(response_path.read_text(encoding="utf-8")))
|
||||
except (DataError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as error:
|
||||
last_error = error
|
||||
print(f"batch attempt {attempt}/{args.max_attempts} failed: {error}", file=sys.stderr, flush=True)
|
||||
raise DataError(f"Codex batch failed after {args.max_attempts} attempts: {last_error}")
|
||||
|
||||
|
||||
def invoke_codex(args: argparse.Namespace, batch: Sequence[Candidate]) -> list[tuple[Candidate, dict[str, Any]]]:
|
||||
return run_codex(
|
||||
args,
|
||||
prompt=labeling_prompt(batch, args.max_prompt_chars, args.max_response_chars),
|
||||
schema=response_schema(batch),
|
||||
validate=lambda payload: validate_decisions(batch, payload),
|
||||
)
|
||||
|
||||
|
||||
def _state(candidate: Candidate, *, status: str, record: dict[str, Any] | None, reason: str | None, recoverable: bool | None) -> dict[str, Any]:
|
||||
return {
|
||||
"schemaVersion": STATE_SCHEMA_VERSION, "sourceLine": candidate.line.number,
|
||||
@@ -296,7 +318,7 @@ def _load_state(path: Path, candidates_by_line: dict[int, Candidate]) -> dict[in
|
||||
return result
|
||||
|
||||
|
||||
def _append(path: Path, states: Sequence[dict[str, Any]]) -> None:
|
||||
def append_jsonl(path: Path, states: Sequence[dict[str, Any]]) -> None:
|
||||
if not states:
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -356,7 +378,7 @@ def run(args: argparse.Namespace) -> dict[str, int]:
|
||||
newly.append(_state(candidate, status="labeled", record=record, reason=None, recoverable=decision["recoverableFromFirst"]))
|
||||
else:
|
||||
newly.append(_state(candidate, status="rejected", record=None, reason=f"semantic_junk:{decision['junkReason'].strip()}", recoverable=None))
|
||||
_append(args.state, newly)
|
||||
append_jsonl(args.state, newly)
|
||||
states.update({state["sourceLine"]: state for state in newly})
|
||||
print(f"labeled batch {number}/{len(batch_list)} ({len(batch)} sessions)", flush=True)
|
||||
labeled = [state["record"] for _, state in sorted(states.items()) if state["status"] == "labeled"]
|
||||
|
||||
Reference in New Issue
Block a user