Files
nucleic/ml/purpose-classifier/export_nucleic_prompts.py
T

226 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Export the first user prompt from each Nucleic transcript for purpose labeling."""
from __future__ import annotations
import argparse
import hashlib
import json
import unicodedata
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
LABELS = (
"planning",
"backendImpl",
"frontendImpl",
"quickFix",
"refactor",
"debugging",
"review",
"writing",
)
LABEL_DEFINITIONS = {
"planning": "Architecture, design, migration strategy, or multi-step project planning.",
"backendImpl": "Server, API, data, algorithm, systems, or CLI implementation.",
"frontendImpl": "UI, views, styling, components, layout, or animation implementation.",
"quickFix": "A typo, one-liner, config tweak, or small contained bug fix.",
"refactor": "Restructuring, renaming, extraction, or cleanup without behavior change.",
"debugging": "Diagnosing a crash, failure, regression, or unexplained behavior.",
"review": "Reviewing, explaining, auditing, or answering questions about existing code.",
"writing": "Documentation, commit messages, summaries, formatting, or other prose work.",
}
class ExportError(RuntimeError):
"""The transcript export could not be completed safely."""
def normalized_prompt(prompt: str) -> str:
"""Match the classifier dataset's stable prompt normalization."""
return " ".join(unicodedata.normalize("NFKC", prompt).split())
def prompt_hash(prompt: str) -> str:
return hashlib.sha256(normalized_prompt(prompt).casefold().encode("utf-8")).hexdigest()
def canonical_json(value: dict[str, Any]) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def read_session(transcript: Path) -> tuple[dict[str, Any], str | None]:
header: dict[str, Any] | None = None
first_prompt: str | None = None
with transcript.open(encoding="utf-8") as handle:
for line_number, line in enumerate(handle, start=1):
try:
value = json.loads(line)
except json.JSONDecodeError as error:
raise ExportError(
f"{transcript}:{line_number}: malformed JSON: {error.msg}"
) from error
if not isinstance(value, dict):
raise ExportError(f"{transcript}:{line_number}: expected a JSON object")
if line_number == 1:
header = value
continue
kind = value.get("kind")
if isinstance(kind, dict) and kind.get("type") == "userText":
text = kind.get("text")
if not isinstance(text, str) or not text.strip():
raise ExportError(
f"{transcript}:{line_number}: first userText has no prompt text"
)
first_prompt = text
break
if header is None:
raise ExportError(f"{transcript}: transcript is empty")
return header, first_prompt
def session_sort_key(record: dict[str, Any]) -> tuple[str, str]:
return str(record.get("createdAt") or ""), str(record["sessionID"])
def export(sessions_dir: Path, output: Path, manifest_path: Path) -> dict[str, Any]:
if not sessions_dir.is_dir():
raise ExportError(f"sessions directory does not exist: {sessions_dir}")
records: list[dict[str, Any]] = []
omitted: list[dict[str, Any]] = []
backend_counts: Counter[str] = Counter()
session_directories = sorted(path for path in sessions_dir.iterdir() if path.is_dir())
for session_dir in session_directories:
transcript = session_dir / "transcript.jsonl"
if not transcript.is_file():
raise ExportError(f"missing transcript: {transcript}")
header, prompt = read_session(transcript)
session_id = header.get("sessionID")
if session_id != session_dir.name:
raise ExportError(
f"{transcript}: header sessionID {session_id!r} does not match directory"
)
backend = str(header.get("backend") or "unknown")
backend_counts[backend] += 1
common = {
"sessionID": session_id,
"createdAt": header.get("createdAt"),
"backend": backend,
}
if prompt is None:
omitted.append({**common, "reason": "no_user_text"})
continue
records.append(
{
"schemaVersion": 1,
**common,
"promptHash": prompt_hash(prompt),
"prompt": prompt,
"purpose": None,
"labelNotes": None,
}
)
records.sort(key=session_sort_key)
omitted.sort(key=session_sort_key)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
"".join(f"{canonical_json(record)}\n" for record in records),
encoding="utf-8",
)
output_sha256 = hashlib.sha256(output.read_bytes()).hexdigest()
duplicate_session_prompts = sum(
count - 1 for count in Counter(record["promptHash"] for record in records).values()
if count > 1
)
manifest = {
"schemaVersion": 1,
"generatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"source": {
"kind": "nucleic_transcript_history",
"sessionsDirectory": str(sessions_dir),
"selection": "first non-empty AgentEvent kind=userText in each transcript",
"deduplicated": False,
},
"output": {
"path": str(output),
"format": "jsonl",
"sha256": output_sha256,
},
"counts": {
"sessionDirectories": len(session_directories),
"exportedPrompts": len(records),
"sessionsWithoutUserPrompt": len(omitted),
"duplicateSessionPrompts": duplicate_session_prompts,
"backends": dict(sorted(backend_counts.items())),
},
"labelingContract": {
"field": "purpose",
"unlabeledValue": None,
"allowedValues": list(LABELS),
"generalIsNotALabel": True,
"definitions": LABEL_DEFINITIONS,
"instruction": (
"Assign exactly one allowed purpose to every JSONL record. "
"Preserve all other fields and the original record order."
),
},
"sessionsWithoutUserPrompt": omitted,
}
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
return manifest
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--sessions-dir",
type=Path,
required=True,
help="Nucleic Application Support sessions directory",
)
parser.add_argument("--output", type=Path, required=True, help="Unlabeled JSONL output")
parser.add_argument(
"--manifest",
type=Path,
required=True,
help="JSON manifest describing the output and labeling contract",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
manifest = export(
args.sessions_dir.expanduser().resolve(),
args.output.expanduser().resolve(),
args.manifest.expanduser().resolve(),
)
print(json.dumps(manifest["counts"], sort_keys=True))
if __name__ == "__main__":
main()