Skip to content

Commit 936d700

Browse files
committed
feat(data): export PR discussions as parquet stream
1 parent 45dacc4 commit 936d700

1 file changed

Lines changed: 204 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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

Comments
 (0)