File size: 6,152 Bytes
9f12a7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""
export_distillation.py β€” turn captured teacher examples into student-ready JSONL.

Offline/admin tool. Reads the `distillation_examples` Firestore collection (written by
distill.py) and emits one JSONL file per task under an output directory:

  intent_parse.jsonl / translate.jsonl / codegen.jsonl / coach.jsonl / insight.jsonl
      β†’ chat-format SFT  {"messages":[{system},{user},{assistant}]}
  vision.jsonl          β†’ {"image_uri", "instruction", "output"}
  asr.jsonl             β†’ {"audio_uri", "transcript"}
  rejected.jsonl        β†’ hard negatives (verdict=rejected), for DPO / filtering

This is sequence-level (response-based) distillation: targets are the teacher's text /
JSON outputs and the human-verified gold label β€” Gemini exposes no logits.

Usage:
  python scripts/export_distillation.py --out ./dataset \
      --verdicts confirmed corrected pending
  (defaults: SFT uses confirmed+corrected; pass --include-pending to add unlabeled)

Env: FIREBASE (service-account JSON), as used by the app.
"""

import argparse
import json
import os
from collections import Counter, defaultdict
from typing import Any, Dict, List, Optional


def _init_db():
    import firebase_admin
    from firebase_admin import credentials, firestore
    if not firebase_admin._apps:
        sa = json.loads(os.environ["FIREBASE"])
        cred = credentials.Certificate(sa)
        opts = {}
        bucket = os.environ.get("FIREBASE_STORAGE_BUCKET")
        if bucket:
            opts["storageBucket"] = bucket
        firebase_admin.initialize_app(cred, opts)
    return firestore.client()


# Tasks that train as text chat SFT.
_TEXT_TASKS = {"intent_parse", "translate", "codegen", "coach", "insight"}


def _gold_or_teacher(ex: Dict[str, Any]) -> Optional[str]:
    """Prefer the human-corrected gold output; fall back to the teacher output."""
    return ex.get("gold_output") or ex.get("output_raw")


def _to_chat(ex: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    inp = ex.get("input") or {}
    user = inp.get("text") or inp.get("transcript") or ""
    system = inp.get("system_prompt") or ""
    target = _gold_or_teacher(ex)
    if not user or not target:
        return None
    messages = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": user})
    messages.append({"role": "assistant", "content": target})
    return {"messages": messages, "task": ex.get("task"),
            "teacher_model": ex.get("teacher_model"),
            "prompt_version": ex.get("prompt_version"),
            "verdict": ex.get("verdict")}


def _to_vision(ex: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    inp = ex.get("input") or {}
    uri = inp.get("media_uri")
    target = _gold_or_teacher(ex)
    if not uri or not target:
        return None
    return {"image_uri": uri, "instruction": inp.get("caption") or "Extract the bookkeeping transactions as JSON.",
            "output": target, "verdict": ex.get("verdict"),
            "teacher_model": ex.get("teacher_model")}


def _to_asr(ex: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    inp = ex.get("input") or {}
    uri = inp.get("media_uri")
    transcript = ex.get("output_raw")
    if not uri or not transcript:
        return None
    return {"audio_uri": uri, "transcript": transcript, "teacher_model": ex.get("teacher_model")}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--out", default="./dataset", help="output directory")
    ap.add_argument("--verdicts", nargs="*", default=["confirmed", "corrected"],
                    help="verdicts to include for SFT (default: confirmed corrected)")
    ap.add_argument("--include-pending", action="store_true",
                    help="also include unlabeled (pending) examples in SFT")
    ap.add_argument("--limit", type=int, default=0, help="max docs (0 = all)")
    args = ap.parse_args()

    sft_verdicts = set(args.verdicts)
    if args.include_pending:
        sft_verdicts.add("pending")

    db = _init_db()
    os.makedirs(args.out, exist_ok=True)

    writers: Dict[str, Any] = {}

    def _w(name: str):
        if name not in writers:
            writers[name] = open(os.path.join(args.out, f"{name}.jsonl"), "w", encoding="utf-8")
        return writers[name]

    counts: Counter = Counter()
    # Dedup identical (user,target) pairs per task.
    seen: Dict[str, set] = defaultdict(set)

    q = db.collection("distillation_examples")
    docs = q.limit(args.limit).stream() if args.limit else q.stream()

    for doc in docs:
        ex = doc.to_dict() or {}
        task = ex.get("task") or "other"
        verdict = ex.get("verdict") or "pending"

        if verdict == "rejected":
            rec = _to_chat(ex) or {"input": ex.get("input"), "output": ex.get("output_raw")}
            _w("rejected").write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
            counts["rejected"] += 1
            continue

        if task == "asr":
            rec = _to_asr(ex)
            if rec:
                _w("asr").write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
                counts["asr"] += 1
            continue

        if verdict not in sft_verdicts:
            continue

        if task == "vision":
            rec = _to_vision(ex)
            if rec:
                _w("vision").write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
                counts["vision"] += 1
            continue

        if task in _TEXT_TASKS:
            rec = _to_chat(ex)
            if not rec:
                continue
            key = (rec["messages"][-2]["content"], rec["messages"][-1]["content"])
            if key in seen[task]:
                continue
            seen[task].add(key)
            _w(task).write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
            counts[task] += 1

    for f in writers.values():
        f.close()

    print("Exported examples per file:")
    for name, n in sorted(counts.items()):
        print(f"  {name}.jsonl: {n}")
    print(f"Output: {os.path.abspath(args.out)}")


if __name__ == "__main__":
    main()