|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Export PR discussions from prs.sqlite into route-by-fit training parquet. |
| 3 | +
|
| 4 | +This is the standalone PR stream companion to the commit pipeline. Commit docs |
| 5 | +already inject ``record['pr_discussion']`` at the head of PRE/POST/diff samples; |
| 6 | +this script emits the same PR discussion content as its own document stream for |
| 7 | +curriculum mixes that want explicit ``pr`` batches. |
| 8 | +
|
| 9 | +The output is intentionally compatible with the existing conveyor layout: |
| 10 | +
|
| 11 | + outputs/reindexed_pr/{1024,2048,4096,...}/pr_discussions_<offset>.parquet |
| 12 | +
|
| 13 | +RULE #1: every stage uses the existing materializer/packer path and raises on |
| 14 | +missing input, empty output, or malformed store rows. There is no fallback path. |
| 15 | +""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import argparse |
| 20 | +import json |
| 21 | +import sqlite3 |
| 22 | +import sys |
| 23 | +import tempfile |
| 24 | +from pathlib import Path |
| 25 | + |
| 26 | +REPO_ROOT = Path(__file__).resolve().parents[2] |
| 27 | +if str(REPO_ROOT) not in sys.path: |
| 28 | + sys.path.insert(0, str(REPO_ROOT)) |
| 29 | +if str(REPO_ROOT / "scripts") not in sys.path: |
| 30 | + sys.path.insert(0, str(REPO_ROOT / "scripts")) |
| 31 | + |
| 32 | +import streaming_reindex as sr # noqa: E402 |
| 33 | +from streaming_reindex_commits import route_by_fit, recompress_zstd_max # noqa: E402 |
| 34 | +from scripts.pr_ingest.pr_store import connect, get_by_pr # noqa: E402 |
| 35 | +from scripts.pr_ingest.render_discussion import render_discussion # noqa: E402 |
| 36 | + |
| 37 | + |
| 38 | +DEFAULT_STORE = REPO_ROOT / "outputs" / "pr_ingest" / "prs.sqlite" |
| 39 | +DEFAULT_OUTPUT_ROOT = REPO_ROOT / "outputs" / "reindexed_pr" |
| 40 | +ZSTD_LEVELS = (1024, 2048, 4096, 8192, 16384) |
| 41 | + |
| 42 | + |
| 43 | +def _comment_safe(text: str) -> str: |
| 44 | + return text.replace("*/", "* /") |
| 45 | + |
| 46 | + |
| 47 | +def _render_training_doc(rec: dict) -> str: |
| 48 | + discussion = render_discussion(rec) |
| 49 | + if not discussion: |
| 50 | + return "" |
| 51 | + lines = [ |
| 52 | + "/*", |
| 53 | + "@doc_type pr_discussion", |
| 54 | + f"@repo {rec['repo']}", |
| 55 | + f"@pr {rec['pr_number']}", |
| 56 | + ] |
| 57 | + sha = rec.get("merge_commit_sha") |
| 58 | + if sha: |
| 59 | + lines.append(f"@merge_commit_sha {sha}") |
| 60 | + lines.extend(["@discussion", _comment_safe(discussion), "*/", ""]) |
| 61 | + return "\n".join(lines) |
| 62 | + |
| 63 | + |
| 64 | +def _iter_pr_keys( |
| 65 | + conn: sqlite3.Connection, |
| 66 | + *, |
| 67 | + repo: str | None, |
| 68 | + offset: int, |
| 69 | + limit: int | None, |
| 70 | +): |
| 71 | + sql = "SELECT repo, pr_number FROM prs" |
| 72 | + params: list[object] = [] |
| 73 | + if repo: |
| 74 | + sql += " WHERE repo=?" |
| 75 | + params.append(repo) |
| 76 | + sql += " ORDER BY repo, pr_number LIMIT ? OFFSET ?" |
| 77 | + params.append(-1 if limit is None else int(limit)) |
| 78 | + params.append(int(offset)) |
| 79 | + yield from conn.execute(sql, params) |
| 80 | + |
| 81 | + |
| 82 | +def _write_pr_jsonl( |
| 83 | + conn: sqlite3.Connection, |
| 84 | + out_jsonl: Path, |
| 85 | + *, |
| 86 | + repo: str | None, |
| 87 | + offset: int, |
| 88 | + limit: int | None, |
| 89 | +) -> int: |
| 90 | + n = 0 |
| 91 | + with out_jsonl.open("w", encoding="utf-8") as fh: |
| 92 | + for row in _iter_pr_keys(conn, repo=repo, offset=offset, limit=limit): |
| 93 | + rec = get_by_pr(conn, row["repo"], int(row["pr_number"])) |
| 94 | + if rec is None: |
| 95 | + raise RuntimeError( |
| 96 | + f"PR key disappeared during export: {row['repo']}#{row['pr_number']}" |
| 97 | + ) |
| 98 | + text = _render_training_doc(rec) |
| 99 | + if not text: |
| 100 | + continue |
| 101 | + payload = { |
| 102 | + "text": text, |
| 103 | + "source_text": text, |
| 104 | + "repo": rec["repo"], |
| 105 | + "filepath": f"PR#{rec['pr_number']}", |
| 106 | + "doc_type": "pr_discussion", |
| 107 | + "language": "pr", |
| 108 | + "pr_number": int(rec["pr_number"]), |
| 109 | + "commit_hash": rec.get("merge_commit_sha") or "", |
| 110 | + "constituent_provenance_json": json.dumps( |
| 111 | + [{ |
| 112 | + "kind": "pr_discussion", |
| 113 | + "repo": rec["repo"], |
| 114 | + "pr_number": int(rec["pr_number"]), |
| 115 | + "merge_commit_sha": rec.get("merge_commit_sha") or "", |
| 116 | + }], |
| 117 | + separators=(",", ":"), |
| 118 | + ), |
| 119 | + } |
| 120 | + fh.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) |
| 121 | + fh.write("\n") |
| 122 | + n += 1 |
| 123 | + if n == 0: |
| 124 | + raise RuntimeError( |
| 125 | + f"PR export produced zero rendered docs (repo={repo!r}, offset={offset}, limit={limit})" |
| 126 | + ) |
| 127 | + return n |
| 128 | + |
| 129 | + |
| 130 | +def _append_output(shard_name: str, packed: Path, target_length: int, output_root: Path) -> dict: |
| 131 | + out_dir = output_root / str(target_length) |
| 132 | + out_dir.mkdir(parents=True, exist_ok=True) |
| 133 | + dest = out_dir / f"{shard_name}.parquet" |
| 134 | + dest.write_bytes(packed.read_bytes()) |
| 135 | + recompress_zstd_max(dest) |
| 136 | + return sr._parquet_stats(dest, target_length) |
| 137 | + |
| 138 | + |
| 139 | +def export_pr_parquet(args: argparse.Namespace) -> dict: |
| 140 | + store = Path(args.store) |
| 141 | + if not store.exists(): |
| 142 | + raise FileNotFoundError(f"--store does not exist: {store}") |
| 143 | + output_root = Path(args.output_root) |
| 144 | + lengths = tuple(sorted(int(x) for x in args.target_lengths.split(",") if x.strip())) |
| 145 | + if not lengths: |
| 146 | + raise ValueError("--target-lengths produced no lengths") |
| 147 | + |
| 148 | + conn = connect(str(store), create=False) |
| 149 | + shard_name = f"pr_discussions_{int(args.offset):08d}" |
| 150 | + with tempfile.TemporaryDirectory(prefix="pr_parquet_export_") as tmp: |
| 151 | + work = Path(tmp) |
| 152 | + jsonl = work / f"{shard_name}.jsonl" |
| 153 | + n_docs = _write_pr_jsonl( |
| 154 | + conn, |
| 155 | + jsonl, |
| 156 | + repo=args.repo, |
| 157 | + offset=int(args.offset), |
| 158 | + limit=args.limit, |
| 159 | + ) |
| 160 | + tok = sr.stage_materialize( |
| 161 | + shard_name, |
| 162 | + jsonl, |
| 163 | + work, |
| 164 | + memory_limit_gb=float(args.memory_limit_gb), |
| 165 | + ) |
| 166 | + routed = route_by_fit(tok, lengths, work / "routed") |
| 167 | + if not routed: |
| 168 | + raise RuntimeError(f"no PR docs routed for {jsonl}") |
| 169 | + per_length: dict[str, dict] = {} |
| 170 | + for length, route_parquet in sorted(routed.items()): |
| 171 | + packed = sr.stage_pack(shard_name, route_parquet, length, work) |
| 172 | + per_length[str(length)] = _append_output( |
| 173 | + shard_name, packed, length, output_root, |
| 174 | + ) |
| 175 | + return { |
| 176 | + "source": "pr", |
| 177 | + "store": str(store), |
| 178 | + "output_root": str(output_root), |
| 179 | + "shard": shard_name, |
| 180 | + "rendered_docs": n_docs, |
| 181 | + "lengths": per_length, |
| 182 | + } |
| 183 | + |
| 184 | + |
| 185 | +def parse_args(argv: list[str]) -> argparse.Namespace: |
| 186 | + p = argparse.ArgumentParser(description=__doc__) |
| 187 | + p.add_argument("--store", default=str(DEFAULT_STORE)) |
| 188 | + p.add_argument("--output-root", default=str(DEFAULT_OUTPUT_ROOT)) |
| 189 | + p.add_argument("--target-lengths", default=",".join(str(x) for x in ZSTD_LEVELS)) |
| 190 | + p.add_argument("--repo", default=None, help="Optional owner/repo filter.") |
| 191 | + p.add_argument("--offset", type=int, default=0) |
| 192 | + p.add_argument("--limit", type=int, default=10_000) |
| 193 | + p.add_argument("--memory-limit-gb", type=float, default=10.0) |
| 194 | + return p.parse_args(argv) |
| 195 | + |
| 196 | + |
| 197 | +def main(argv: list[str]) -> int: |
| 198 | + args = parse_args(argv) |
| 199 | + print(json.dumps(export_pr_parquet(args), indent=2, sort_keys=True)) |
| 200 | + return 0 |
| 201 | + |
| 202 | + |
| 203 | +if __name__ == "__main__": |
| 204 | + raise SystemExit(main(sys.argv[1:])) |
0 commit comments