226 lines
7.4 KiB
Python
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()
|