File size: 6,664 Bytes
aacc29a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""CLI: bundle a gated GEC adapter into a self-describing serve package.

The serving API must not import ``gec.*`` (research/serving stay
decoupled). So this offline script freezes everything serving needs into a
``serve_manifest.json``: base model, the variant's ``use_retrieval`` flag, and
the DARAG prompt strings. The bundle also carries the adapter, its tokenizer,
and the enriched datastore, so a server only needs the bundle directory.

    python scribe/training/scripts/export_serve.py \
      --adapter-dir artifacts/gec_lora/qwen3/full/seed-13 \
      --datastore artifacts/retrieval/term_datastore.json \
      --output artifacts/gec_serve

Set ``LLM_PROVIDER=gec_local`` and ``GEC_BUNDLE_PATH=<output>`` to serve it.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import shutil
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scribe" / "training"))
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scribe"))

from gec.cliutil import configure_stdout  # noqa: E402

configure_stdout()

from gec.config import FALLBACK_BASE_MODEL  # noqa: E402
from gec.gate import run_gate  # noqa: E402
from gec.prompts import IM_END, IM_START, SYSTEM_PROMPT  # noqa: E402


def _read_variant(adapter_dir: Path) -> dict:
    marker = adapter_dir / "darag_variant.json"
    if marker.exists():
        try:
            return json.loads(marker.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            pass
    return {"variant": "full", "use_retrieval": True}


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--adapter-dir", required=True, help="trained LoRA adapter directory")
    parser.add_argument("--datastore", required=True, help="enriched term datastore JSON")
    parser.add_argument("--output", default="artifacts/gec_serve", help="bundle output dir")
    parser.add_argument(
        "--base-model",
        default=None,
        help="optional expected model; actual export identity comes from the adapter revision lock",
    )
    parser.add_argument("--fallback-base-model", default=FALLBACK_BASE_MODEL)
    parser.add_argument("--max-new-tokens", type=int, default=256)
    parser.add_argument("--normal-gate-report", required=True)
    parser.add_argument("--frozen-gate-report", required=True)
    parser.add_argument("--frozen-safety-report", required=True)
    args = parser.parse_args()

    adapter_dir = Path(args.adapter_dir)
    if not adapter_dir.exists():
        raise SystemExit(f"Adapter dir not found: {adapter_dir}. Train + gate one first.")
    datastore = Path(args.datastore)
    if not datastore.exists():
        raise SystemExit(f"Datastore not found: {datastore}. Build it first.")
    if (adapter_dir / "adapter_config.json").exists() and json.loads(
        (adapter_dir / "adapter_config.json").read_text(encoding="utf-8")
    ).get("mock"):
        raise SystemExit("mock adapters can never produce an accepted serve bundle")
    revision_lock = json.loads(
        (adapter_dir / "base_revision.json").read_text(encoding="utf-8")
    )
    revision = str(revision_lock.get("revision", ""))
    actual_base_model = str(revision_lock.get("model", ""))
    if not actual_base_model or len(revision) != 40:
        raise SystemExit("adapter lacks an exact base/tokenizer revision lock")
    if args.base_model and actual_base_model != args.base_model:
        raise SystemExit("adapter base model differs from the explicit export expectation")
    normal_path = Path(args.normal_gate_report)
    frozen_path = Path(args.frozen_gate_report)
    safety_path = Path(args.frozen_safety_report)
    normal = json.loads(normal_path.read_text(encoding="utf-8"))
    frozen = json.loads(frozen_path.read_text(encoding="utf-8"))
    safety = json.loads(safety_path.read_text(encoding="utf-8"))
    normal_accepted, _ = run_gate(normal)
    frozen_accepted, _ = run_gate(
        frozen,
        candidate="gec_pred",
        baselines=("raw_asr",),
        splits=("frozen",),
        safety_report=safety,
        safety_split="frozen",
    )
    if not normal_accepted or not frozen_accepted:
        raise SystemExit("normal and frozen GEC gates must both be accepted before export")

    bundle = Path(args.output)
    bundle.mkdir(parents=True, exist_ok=True)
    # Copy the adapter (+ its tokenizer + darag_variant.json) and datastore in,
    # so the bundle is portable from Drive to a serving box on its own.
    adapter_out = bundle / "adapter"
    if adapter_out.exists():
        shutil.rmtree(adapter_out)
    shutil.copytree(adapter_dir, adapter_out)
    shutil.copy2(datastore, bundle / "term_datastore.json")

    variant = _read_variant(adapter_dir)
    manifest = {
        "schema": "carepath.gec.serve/1",
        "base_model": actual_base_model,
        "base_revision": revision,
        "tokenizer_revision": revision,
        "fallback_base_model": args.fallback_base_model,
        "variant": variant.get("variant", "full"),
        "use_retrieval": bool(variant.get("use_retrieval", True)),
        "max_new_tokens": args.max_new_tokens,
        "adapter_dir": "adapter",
        "datastore": "term_datastore.json",
        "gate_accepted": True,
        "gate_evidence": {
            "normal_report_sha256": hashlib.sha256(normal_path.read_bytes()).hexdigest(),
            "frozen_report_sha256": hashlib.sha256(frozen_path.read_bytes()).hexdigest(),
            "frozen_safety_report_sha256": hashlib.sha256(safety_path.read_bytes()).hexdigest(),
            "adapter_files": {
                str(path.relative_to(adapter_dir)): hashlib.sha256(path.read_bytes()).hexdigest()
                for path in sorted(adapter_dir.rglob("*"))
                if path.is_file()
            },
        },
        # Frozen DARAG prompt so serving reproduces the training format with no
        # dependency on gec.prompts.
        "prompt": {
            "system": SYSTEM_PROMPT,
            "im_start": IM_START,
            "im_end": IM_END,
            "best_label": "Best hypothesis: ",
            "others_label": "Other hypotheses:",
            "entities_label": "Named entities: ",
        },
    }
    (bundle / "serve_manifest.json").write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    print(f"Wrote serve bundle -> {bundle}")
    print(f"  variant={manifest['variant']} use_retrieval={manifest['use_retrieval']} "
          f"base={manifest['base_model']}")
    print(f"Serve with: LLM_PROVIDER=gec_local GEC_BUNDLE_PATH={bundle}")


if __name__ == "__main__":
    main()