marinarosa commited on
Commit
e18df89
·
1 Parent(s): f84978d

Pin Rio v4 as standalone Modal module

Browse files

Replace the v4 import wrapper with a standalone Modal module whose variant constants are pinned to v4. The wrapper approach let Modal execute the shared v3 module remotely, so the first attempt accepted v3-only teacher categories and wrote a partial teacher file to the v3 data volume.\n\nAdd a separate one-shot restore app that repopulates the v3 data volume from the published Hugging Face v3 dataset package. The runlog records the stopped app, the root cause, and the repair path so the failed attempt is auditable.

finetune/reports/minicpm5_vivamais_text_runlog.md CHANGED
@@ -2495,3 +2495,32 @@ Because v4 intentionally drops the generic/WhatsApp bucket, the v4 acceptance
2495
  gate keeps the dashboard requirements strict (average improvement, unknown
2496
  failures no worse, leakage failures no worse) and treats generic PT-BR as a
2497
  minimum sanity check instead of requiring an overall generic win.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2495
  gate keeps the dashboard requirements strict (average improvement, unknown
2496
  failures no worse, leakage failures no worse) and treats generic PT-BR as a
2497
  minimum sanity check instead of requiring an overall generic win.
2498
+
2499
+ ### Aborted v4 wrapper attempt
2500
+
2501
+ ```text
2502
+ run: https://modal.com/apps/marinaleitecabrera/main/ap-5Z9IbspvamaWKsCF1C6ePt
2503
+ status: stopped manually
2504
+ reason: the import-wrapper v4 launcher resolved the shared module as v3 remotely
2505
+ ```
2506
+
2507
+ The first v4 attempt was stopped during teacher generation after the logs showed
2508
+ v3-only categories such as `customer_service`, `whatsapp_style`, and `open_chat`
2509
+ being accepted. The cause was the small v4 wrapper setting `VIVAMAIS_TEXT_VARIANT`
2510
+ only in the local launcher module; Modal mounted and executed the shared v3
2511
+ module remotely, so the constants fell back to v3 and wrote a partial teacher
2512
+ file to the v3 data volume.
2513
+
2514
+ Corrective action:
2515
+
2516
+ - Replace the v4 wrapper with a standalone v4 module whose constants are pinned
2517
+ to `TEXT_VARIANT = "v4"` and `IS_V4 = True`.
2518
+ - Restore the v3 data volume from the already-published HF dataset using a
2519
+ separate one-shot app:
2520
+
2521
+ ```text
2522
+ script: modal/restore_vivamais_v3_dataset_volume.py
2523
+ app: vivamais-v3-dataset-volume-restore
2524
+ source dataset: marinarosa/minicpm5-vivamais-text-sft-v3
2525
+ target volume: minicpm5-vivamais-v3-rio-data
2526
+ ```
modal/minicpm5_vivamais_v4_rio_pipeline.py CHANGED
@@ -1,9 +1,1700 @@
1
- """Ephemeral Modal launcher for the Viva Mais MiniCPM5 text v4 Rio run."""
2
 
3
  from __future__ import annotations
4
 
 
5
  import os
 
 
 
6
 
7
- os.environ.setdefault("VIVAMAIS_TEXT_VARIANT", "v4")
8
 
9
- from minicpm5_vivamais_v3_rio_pipeline import * # noqa: F401,F403,E402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ephemeral Modal pipeline for Viva Mais MiniCPM5 text v4 with Rio distillation."""
2
 
3
  from __future__ import annotations
4
 
5
+ import json
6
  import os
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
 
11
+ import modal
12
 
13
+ TEXT_VARIANT = "v4"
14
+ IS_V4 = True
15
+
16
+ APP_NAME = "minicpm5-vivamais-v4-rio-safety" if IS_V4 else "minicpm5-vivamais-v3-rio"
17
+ DATA_VOLUME_NAME = (
18
+ "minicpm5-vivamais-v4-rio-safety-data"
19
+ if IS_V4
20
+ else "minicpm5-vivamais-v3-rio-data"
21
+ )
22
+ RESULTS_VOLUME_NAME = (
23
+ "minicpm5-vivamais-v4-rio-safety-results"
24
+ if IS_V4
25
+ else "minicpm5-vivamais-v3-rio-results"
26
+ )
27
+ V1_MODEL = "marinarosa/minicpm5-1b-vivamais-v1"
28
+ V3_MODEL_REPO = (
29
+ "marinarosa/minicpm5-1b-vivamais-v4"
30
+ if IS_V4
31
+ else "marinarosa/minicpm5-1b-vivamais-v3"
32
+ )
33
+ V3_GGUF_REPO = (
34
+ "marinarosa/minicpm5-1b-vivamais-v4-GGUF"
35
+ if IS_V4
36
+ else "marinarosa/minicpm5-1b-vivamais-v3-GGUF"
37
+ )
38
+ RIO_TEACHER_MODEL = "prefeitura-rio/Rio-3.1-Open-4B-Instruct"
39
+ TRAIN_REMOTE = (
40
+ "/data/minicpm5_v4_rio_sft.jsonl"
41
+ if IS_V4
42
+ else "/data/minicpm5_v3_rio_sft.jsonl"
43
+ )
44
+ EVAL_REMOTE = "/data/vivamais_qa_eval.jsonl"
45
+ TEACHER_REMOTE = (
46
+ "/data/teacher_distill_v4_rio.jsonl"
47
+ if IS_V4
48
+ else "/data/teacher_distill_v3_rio.jsonl"
49
+ )
50
+ LORA_DIR = "/data/text-v4-rio-lora-out" if IS_V4 else "/data/text-v3-rio-lora-out"
51
+ MERGED_DIR = "/data/text-v4-rio-merged-out" if IS_V4 else "/data/text-v3-rio-merged-out"
52
+ GGUF_DIR = "/data/text-v4-rio-gguf" if IS_V4 else "/data/text-v3-rio-gguf"
53
+ DEFAULT_CONTEXT_LENGTH = 8192
54
+ NO_THINKING_RESPONSE_PREFIX = "<|im_start|>assistant\n<think>\n\n</think>\n\n"
55
+ DEFAULT_V3_ROWS = 4_000 if IS_V4 else 3_500
56
+ DEFAULT_TEACHER_ROWS = 80 if IS_V4 else 140
57
+ DEFAULT_TEACHER_SEED_LIMIT = 0
58
+ DEFAULT_LEARNING_RATE = 1.0e-5 if IS_V4 else 1.2e-5
59
+ DEFAULT_EPOCHS = 2.0 if IS_V4 else 1.2
60
+ V3_MIN_DASHBOARD_DELTA = 0.03
61
+ V3_MIN_GENERIC_SCORE = 0.55 if IS_V4 else 0.70
62
+ DEFAULT_PUBLIC_WEIGHT = 0.0
63
+ DEFAULT_GENERIC_WEIGHT = 0.0 if IS_V4 else 0.35
64
+ DEFAULT_DOMAIN_WEIGHT = 0.38 if IS_V4 else 0.25
65
+ DEFAULT_NEGATIVE_WEIGHT = 0.60 if IS_V4 else 0.36
66
+ DEFAULT_DISTILL_WEIGHT = 0.02 if IS_V4 else 0.04
67
+ DEFAULT_RUN_NAME = "v4_rio_001" if IS_V4 else "v3_rio_001"
68
+ TRAIN_GPU = "A100" if IS_V4 else "A10G"
69
+ GENERATION_GPU = "A100" if IS_V4 else "A10G"
70
+ V4_TEACHER_CATEGORIES = {
71
+ "entity_isolation",
72
+ "grounded_qa",
73
+ "grounded_refusal",
74
+ "grounded_unknown",
75
+ "payment_math",
76
+ "summary_rewrite",
77
+ "summarization",
78
+ "travel_qa",
79
+ }
80
+
81
+ app = modal.App(APP_NAME)
82
+ data_volume = modal.Volume.from_name(DATA_VOLUME_NAME, create_if_missing=True)
83
+ results_volume = modal.Volume.from_name(RESULTS_VOLUME_NAME, create_if_missing=True)
84
+
85
+ image = (
86
+ modal.Image.debian_slim(python_version="3.11")
87
+ .apt_install("git", "cmake", "build-essential", "curl", "libcurl4-openssl-dev", "libssl-dev")
88
+ .pip_install(
89
+ "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git",
90
+ "trl",
91
+ "transformers>=4.51.0",
92
+ "datasets",
93
+ "accelerate",
94
+ "peft",
95
+ "huggingface_hub",
96
+ "sentencepiece",
97
+ "protobuf",
98
+ "bitsandbytes",
99
+ )
100
+ .add_local_file(
101
+ local_path="finetune/build_vivamais_qa_eval.py",
102
+ remote_path="/opt/vivamais/finetune/build_vivamais_qa_eval.py",
103
+ )
104
+ .add_local_file(
105
+ local_path="finetune/prepare_minicpm5_text_sft.py",
106
+ remote_path="/opt/vivamais/finetune/prepare_minicpm5_text_sft.py",
107
+ )
108
+ .add_local_file(
109
+ local_path="finetune/prepare_minicpm5_v2_distill_sft.py",
110
+ remote_path="/opt/vivamais/finetune/prepare_minicpm5_v2_distill_sft.py",
111
+ )
112
+ .add_local_file(
113
+ local_path="finetune/redact.py",
114
+ remote_path="/opt/vivamais/finetune/redact.py",
115
+ )
116
+ .add_local_file(
117
+ local_path="evals/vivamais_qa_scoring.py",
118
+ remote_path="/opt/vivamais/evals/vivamais_qa_scoring.py",
119
+ )
120
+ .add_local_file(
121
+ local_path="evals/ptbr_conversation_eval.py",
122
+ remote_path="/opt/vivamais/evals/ptbr_conversation_eval.py",
123
+ )
124
+ .add_local_dir(local_path="src", remote_path="/opt/vivamais/src")
125
+ )
126
+
127
+ inspect_image = (
128
+ modal.Image.debian_slim(python_version="3.11")
129
+ .pip_install("pytest")
130
+ .add_local_file(
131
+ local_path="finetune/build_vivamais_qa_eval.py",
132
+ remote_path="/opt/vivamais/finetune/build_vivamais_qa_eval.py",
133
+ )
134
+ .add_local_file(
135
+ local_path="evals/vivamais_qa_scoring.py",
136
+ remote_path="/opt/vivamais/evals/vivamais_qa_scoring.py",
137
+ )
138
+ .add_local_file(
139
+ local_path="evals/ptbr_conversation_eval.py",
140
+ remote_path="/opt/vivamais/evals/ptbr_conversation_eval.py",
141
+ )
142
+ .add_local_file(
143
+ local_path="tests/unit/finetune/test_build_vivamais_qa_eval.py",
144
+ remote_path="/opt/vivamais/tests/unit/finetune/test_build_vivamais_qa_eval.py",
145
+ )
146
+ .add_local_file(
147
+ local_path="tests/unit/evals/test_vivamais_qa_scoring.py",
148
+ remote_path="/opt/vivamais/tests/unit/evals/test_vivamais_qa_scoring.py",
149
+ )
150
+ .add_local_file(
151
+ local_path="tests/unit/evals/test_ptbr_conversation_eval.py",
152
+ remote_path="/opt/vivamais/tests/unit/evals/test_ptbr_conversation_eval.py",
153
+ )
154
+ .add_local_dir(local_path="src", remote_path="/opt/vivamais/src")
155
+ )
156
+
157
+
158
+ def _ensure_paths() -> None:
159
+ import sys
160
+
161
+ for path in (
162
+ "/opt/vivamais",
163
+ "/opt/vivamais/src",
164
+ "/opt/vivamais/finetune",
165
+ "/opt/vivamais/evals",
166
+ ):
167
+ if path not in sys.path:
168
+ sys.path.insert(0, path)
169
+
170
+
171
+ def _load_jsonl(path: Path) -> list[dict[str, Any]]:
172
+ rows: list[dict[str, Any]] = []
173
+ if not path.is_file():
174
+ return rows
175
+ with path.open(encoding="utf-8") as handle:
176
+ for line in handle:
177
+ if line.strip():
178
+ row = json.loads(line)
179
+ if isinstance(row, dict):
180
+ rows.append(row)
181
+ return rows
182
+
183
+
184
+ def _write_jsonl(path: Path, row: dict[str, Any]) -> None:
185
+ with path.open("a", encoding="utf-8") as handle:
186
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
187
+
188
+
189
+ def _preview(text: str, limit: int = 500) -> str:
190
+ text = text.strip()
191
+ if len(text) <= limit:
192
+ return text
193
+ return f"{text[:limit].rstrip()}..."
194
+
195
+
196
+ ACCENT_REPLACEMENTS = (
197
+ (r"\b[Nn]ao\b", "não"),
198
+ (r"\b[Nn]a\b", "na"),
199
+ (r"\binformacao\b", "informação"),
200
+ (r"\binformacoes\b", "informações"),
201
+ (r"\borcamento\b", "orçamento"),
202
+ (r"\btransacao\b", "transação"),
203
+ (r"\btransacoes\b", "transações"),
204
+ (r"\bcartao\b", "cartão"),
205
+ (r"\bcodigo\b", "código"),
206
+ (r"\bportao\b", "portão"),
207
+ (r"\baereo\b", "aéreo"),
208
+ (r"\baerea\b", "aérea"),
209
+ (r"\bate\b", "até"),
210
+ (r"\bvoce\b", "você"),
211
+ )
212
+
213
+ ACCENTLESS_MARKERS = (
214
+ " nao",
215
+ "nao ",
216
+ "informacao",
217
+ "informacoes",
218
+ "orcamento",
219
+ "transacao",
220
+ "transacoes",
221
+ "cartao",
222
+ "codigo",
223
+ "portao",
224
+ )
225
+
226
+ INSTRUCTION_ECHO_PREFIXES = (
227
+ "avise ",
228
+ "avise que",
229
+ "confirme ",
230
+ "diga ",
231
+ "diga que",
232
+ "escreva ",
233
+ "explique ",
234
+ "informe ",
235
+ "peça ",
236
+ "peca ",
237
+ "pergunte ",
238
+ "responda ",
239
+ "resposta:",
240
+ )
241
+
242
+
243
+ def _normalize_ptbr_accents(text: str) -> str:
244
+ normalized = text
245
+ for pattern, replacement in ACCENT_REPLACEMENTS:
246
+ normalized = re.sub(pattern, replacement, normalized, flags=re.IGNORECASE)
247
+ return normalized
248
+
249
+
250
+ def _configure_tokenizer_for_generation(tokenizer: Any, model: Any | None = None) -> None:
251
+ if getattr(tokenizer, "pad_token_id", None) is None:
252
+ tokenizer.pad_token = getattr(tokenizer, "eos_token", None)
253
+ pad_token_id = getattr(tokenizer, "pad_token_id", None)
254
+ if model is None or pad_token_id is None:
255
+ return
256
+ if getattr(model, "config", None) is not None:
257
+ model.config.pad_token_id = pad_token_id
258
+ if getattr(model, "generation_config", None) is not None:
259
+ model.generation_config.pad_token_id = pad_token_id
260
+
261
+
262
+ @app.function(image=inspect_image, timeout=10 * 60, cpu=2, memory=4096)
263
+ def inspect_eval_suites() -> dict[str, Any]:
264
+ from collections import Counter
265
+
266
+ _ensure_paths()
267
+ from build_vivamais_qa_eval import build_eval_cases
268
+ from ptbr_conversation_eval import GENERIC_EVAL_CASES
269
+ from vivamais_qa_scoring import score_case
270
+
271
+ dashboard_cases = build_eval_cases()
272
+ dashboard_ids = [str(case["id"]) for case in dashboard_cases]
273
+ generic_ids = [str(case["id"]) for case in GENERIC_EVAL_CASES]
274
+ unknown_probe = score_case(
275
+ {
276
+ "id": "unknown_probe",
277
+ "category": "not_enough_information",
278
+ "answer_type": "unknown",
279
+ "expected_answer": "Não há informação suficiente no contexto.",
280
+ "expected_contains": [],
281
+ "forbidden": ["[HOTEL_1]"],
282
+ },
283
+ "Não consta hospedagem no contexto informado.",
284
+ )
285
+ summary = {
286
+ "dashboard_cases": len(dashboard_cases),
287
+ "dashboard_categories": dict(Counter(str(case["category"]) for case in dashboard_cases)),
288
+ "dashboard_long_context_cases": sum(1 for case in dashboard_cases if case["long_context"]),
289
+ "dashboard_duplicate_ids": len(dashboard_ids) - len(set(dashboard_ids)),
290
+ "generic_cases": len(GENERIC_EVAL_CASES),
291
+ "generic_categories": dict(Counter(str(case["category"]) for case in GENERIC_EVAL_CASES)),
292
+ "generic_duplicate_ids": len(generic_ids) - len(set(generic_ids)),
293
+ "unknown_probe_passed": bool(unknown_probe["passed"]),
294
+ }
295
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
296
+ return summary
297
+
298
+
299
+ @app.function(image=inspect_image, timeout=10 * 60, cpu=2, memory=4096)
300
+ def run_eval_unit_subset() -> dict[str, Any]:
301
+ import os
302
+ import subprocess
303
+
304
+ env = os.environ.copy()
305
+ env["PYTHONPATH"] = "/opt/vivamais:/opt/vivamais/src:/opt/vivamais/evals"
306
+ command = [
307
+ "python",
308
+ "-m",
309
+ "pytest",
310
+ "/opt/vivamais/tests/unit/finetune/test_build_vivamais_qa_eval.py",
311
+ "/opt/vivamais/tests/unit/evals/test_vivamais_qa_scoring.py",
312
+ "/opt/vivamais/tests/unit/evals/test_ptbr_conversation_eval.py",
313
+ ]
314
+ completed = subprocess.run(command, capture_output=True, text=True, env=env, check=False)
315
+ if completed.stdout:
316
+ print(completed.stdout)
317
+ if completed.stderr:
318
+ print(completed.stderr)
319
+ if completed.returncode != 0:
320
+ raise RuntimeError(f"Eval unit subset failed with exit code {completed.returncode}")
321
+ return {"command": command, "returncode": completed.returncode}
322
+
323
+
324
+ CURATED_TEACHER_PROMPTS = (
325
+ (
326
+ "open_chat",
327
+ "Escreva uma legenda curta e natural para Instagram sobre uma viagem em família.",
328
+ ),
329
+ (
330
+ "customer_service",
331
+ (
332
+ "Responda com educação: o cliente achou o pacote caro e pediu uma "
333
+ "alternativa mais barata."
334
+ ),
335
+ ),
336
+ (
337
+ "travel_qa",
338
+ "Explique a diferença entre bagagem de mão, item pessoal e bagagem despachada.",
339
+ ),
340
+ (
341
+ "travel_qa",
342
+ "Quando vale a pena contratar seguro viagem? Responda de forma simples.",
343
+ ),
344
+ (
345
+ "summarization",
346
+ (
347
+ "Resuma em duas frases: cliente quer viajar em julho, prefere praia, "
348
+ "tem duas crianças e quer parcelar."
349
+ ),
350
+ ),
351
+ (
352
+ "grounded_qa",
353
+ "Contexto: Total R$3.000,00. Pago R$1.250,00. Pergunta: Quanto falta pagar?",
354
+ ),
355
+ (
356
+ "grounded_qa",
357
+ "Contexto: Reserva LA 3456 em 12/07 às 08:30, assento 12A. Pergunta: Qual é o assento?",
358
+ ),
359
+ (
360
+ "grounded_unknown",
361
+ (
362
+ "Contexto: A reserva informa voo LA 3456 e pagamento de R$1.250,00. "
363
+ "Pergunta: Qual é o hotel?"
364
+ ),
365
+ ),
366
+ (
367
+ "whatsapp_style",
368
+ "Transforme em uma mensagem curta de WhatsApp: precisamos do RG para emitir os bilhetes.",
369
+ ),
370
+ (
371
+ "informal_ptbr",
372
+ "O cliente escreveu 'tá caro demais'. Responda de forma profissional e natural.",
373
+ ),
374
+ )
375
+
376
+
377
+ def _synthetic_vivamais_teacher_prompts() -> list[tuple[str, str]]:
378
+ prompts: list[tuple[str, str]] = []
379
+ balances = (
380
+ ("R$3.000,00", "R$1.250,00"),
381
+ ("R$4.800,00", "R$1.950,00"),
382
+ ("R$2.200,00", "R$2.200,00"),
383
+ ("R$5.400,00", "R$2.100,00"),
384
+ ("R$980,00", "R$300,00"),
385
+ ("R$3.600,00", "R$900,00"),
386
+ ("R$1.870,00", "R$870,00"),
387
+ ("R$6.250,00", "R$2.500,00"),
388
+ )
389
+ for total, paid in balances:
390
+ prompts.append(
391
+ (
392
+ f"Contexto: total {total}; pago {paid}. Pergunta: quanto falta pagar?",
393
+ "payment_math",
394
+ )
395
+ )
396
+ prompts.append(
397
+ (
398
+ f"Escreva uma resposta curta de WhatsApp avisando que já entrou {paid} "
399
+ f"de um pacote de {total}.",
400
+ "whatsapp_payment",
401
+ )
402
+ )
403
+
404
+ missing_fields = (
405
+ ("hotel", "voo LA 3456 e pagamento de R$1.250,00"),
406
+ ("assento", "localizador ABC123 e data 12/07"),
407
+ ("bagagem despachada", "valor R$2.700,00 e prazo até sexta"),
408
+ ("validade do orçamento", "destino Recife e total R$4.800,00"),
409
+ ("pagamento", "cliente pediu cotação, mas não enviou comprovante"),
410
+ ("documento do acompanhante", "passageiro titular enviou RG"),
411
+ )
412
+ for field, context in missing_fields:
413
+ prompts.append(
414
+ (
415
+ f"Contexto: {context}. Pergunta: qual é {field}? Responda sem inventar.",
416
+ "grounded_refusal",
417
+ )
418
+ )
419
+ prompts.append(
420
+ (
421
+ f"Reescreva de forma natural: não consta no contexto a informação sobre {field}.",
422
+ "grounded_refusal",
423
+ )
424
+ )
425
+
426
+ customer_prompts = (
427
+ "O cliente disse: 'tá caro demais'. Responda de forma curta e profissional.",
428
+ "O cliente pediu uma opção mais barata. Responda com empatia e proponha revisar datas.",
429
+ "Peça o RG do acompanhante de forma natural para WhatsApp.",
430
+ "Avise que a companhia ainda não confirmou a emissão e que você vai acompanhar.",
431
+ "Ofereça seguro viagem de forma consultiva, sem dizer que é obrigatório.",
432
+ "Peça flexibilidade de datas em uma frase curta.",
433
+ "Faça follow-up perguntando se o cliente conseguiu olhar a proposta.",
434
+ "Avise que a tarifa pode mudar sem soar alarmista.",
435
+ "Peça desculpas pelo atraso na resposta e retome o atendimento.",
436
+ "Pergunte destino, datas e quantidade de passageiros para iniciar uma cotação.",
437
+ )
438
+ prompts.extend(
439
+ (
440
+ f"Responda somente com a mensagem final para WhatsApp: {prompt}",
441
+ "customer_service",
442
+ )
443
+ for prompt in customer_prompts
444
+ )
445
+
446
+ travel_prompts = (
447
+ "Explique em poucas frases a diferença entre bagagem de mão e bagagem despachada.",
448
+ "Quando normalmente abre o check-in online de voo doméstico?",
449
+ "O que significa conexão em um voo?",
450
+ "Para que serve o localizador da reserva?",
451
+ "Qual é a diferença entre check-in e check-out no hotel?",
452
+ "O que o seguro viagem costuma cobrir? Responda de forma simples.",
453
+ "Como explicar franquia de bagagem para um cliente leigo?",
454
+ "O que o cliente precisa conferir antes da emissão da passagem?",
455
+ "Explique por que tarifa aérea pode mudar antes da emissão.",
456
+ "Diga quando faz sentido reservar hotel próximo ao aeroporto.",
457
+ )
458
+ prompts.extend(
459
+ (f"Responda em até duas frases, sem lista: {prompt}", "travel_qa")
460
+ for prompt in travel_prompts
461
+ )
462
+
463
+ entity_cases = (
464
+ (
465
+ "[CLIENTE_A] tem voo AD 2210, localizador AAA111 e embarque 14/09.",
466
+ "[CLIENTE_B] tem voo LA 9090, localizador BBB222 e embarque 20/10.",
467
+ "[CLIENTE_A]",
468
+ "qual é o localizador?",
469
+ ),
470
+ (
471
+ "[CLIENTE_A] pagou R$1.200,00 e ainda falta R$800,00.",
472
+ "[CLIENTE_B] pagou R$900,00 e ainda falta R$1.100,00.",
473
+ "[CLIENTE_B]",
474
+ "quanto falta pagar?",
475
+ ),
476
+ (
477
+ "[CLIENTE_A] já enviou RG e comprovante.",
478
+ "[CLIENTE_B] enviou apenas o RG.",
479
+ "[CLIENTE_B]",
480
+ "qual documento ainda falta?",
481
+ ),
482
+ (
483
+ "[CLIENTE_A] viaja para Recife em 08/08 com hotel confirmado.",
484
+ "[CLIENTE_B] viaja para Salvador em 19/08 e aguarda hotel.",
485
+ "[CLIENTE_A]",
486
+ "qual é o destino?",
487
+ ),
488
+ (
489
+ "[CLIENTE_A] tem assento 12A e bagagem despachada inclusa.",
490
+ "[CLIENTE_B] tem assento 18C e apenas bagagem de mão.",
491
+ "[CLIENTE_B]",
492
+ "qual é o assento?",
493
+ ),
494
+ (
495
+ "[CLIENTE_A] recebeu orçamento válido até 18:00.",
496
+ "[CLIENTE_B] recebeu orçamento válido até 21:00.",
497
+ "[CLIENTE_A]",
498
+ "até que hora vale o orçamento?",
499
+ ),
500
+ (
501
+ "[CLIENTE_A] pediu seguro viagem e traslado.",
502
+ "[CLIENTE_B] recusou seguro viagem e pediu só hospedagem.",
503
+ "[CLIENTE_A]",
504
+ "quais extras foram solicitados?",
505
+ ),
506
+ (
507
+ "[CLIENTE_A] tem reserva no [HOTEL_1].",
508
+ "[CLIENTE_B] tem reserva no [HOTEL_2].",
509
+ "[CLIENTE_B]",
510
+ "qual hotel está reservado?",
511
+ ),
512
+ (
513
+ "[CLIENTE_A] parcelou em 6 vezes sem entrada.",
514
+ "[CLIENTE_B] deu entrada de R$500,00 e parcelou o restante.",
515
+ "[CLIENTE_B]",
516
+ "qual foi a entrada?",
517
+ ),
518
+ (
519
+ "[CLIENTE_A] está em cotação.",
520
+ "[CLIENTE_B] está aguardando emissão.",
521
+ "[CLIENTE_A]",
522
+ "em que etapa está o atendimento?",
523
+ ),
524
+ (
525
+ "[CLIENTE_A] vai embarcar no aeroporto SDU.",
526
+ "[CLIENTE_B] vai embarcar no aeroporto GIG.",
527
+ "[CLIENTE_B]",
528
+ "qual é o aeroporto de embarque?",
529
+ ),
530
+ (
531
+ "[CLIENTE_A] confirmou duas crianças.",
532
+ "[CLIENTE_B] confirmou um adulto e uma criança.",
533
+ "[CLIENTE_A]",
534
+ "quantas crianças foram confirmadas?",
535
+ ),
536
+ (
537
+ "[CLIENTE_A] pediu quarto triplo.",
538
+ "[CLIENTE_B] pediu quarto duplo.",
539
+ "[CLIENTE_A]",
540
+ "qual tipo de quarto foi pedido?",
541
+ ),
542
+ (
543
+ "[CLIENTE_A] precisa enviar CPF.",
544
+ "[CLIENTE_B] precisa enviar comprovante de residência.",
545
+ "[CLIENTE_B]",
546
+ "o que ainda precisa enviar?",
547
+ ),
548
+ (
549
+ "[CLIENTE_A] vai retornar em 25/11.",
550
+ "[CLIENTE_B] vai retornar em 28/11.",
551
+ "[CLIENTE_A]",
552
+ "qual é a data de retorno?",
553
+ ),
554
+ (
555
+ "[CLIENTE_A] aprovou a proposta de R$3.400,00.",
556
+ "[CLIENTE_B] ainda está comparando proposta de R$2.900,00.",
557
+ "[CLIENTE_A]",
558
+ "qual proposta foi aprovada?",
559
+ ),
560
+ (
561
+ "[CLIENTE_A] solicitou nota fiscal.",
562
+ "[CLIENTE_B] solicitou recibo simples.",
563
+ "[CLIENTE_B]",
564
+ "qual comprovante foi solicitado?",
565
+ ),
566
+ (
567
+ "[CLIENTE_A] vai levar bebê de colo.",
568
+ "[CLIENTE_B] vai viajar sem criança.",
569
+ "[CLIENTE_A]",
570
+ "há bebê de colo?",
571
+ ),
572
+ )
573
+ for first, second, client, question in entity_cases:
574
+ prompts.append(
575
+ (
576
+ f"Contexto: {first} {second} Pergunta: sobre {client}, {question}",
577
+ "entity_isolation",
578
+ )
579
+ )
580
+
581
+ summary_cases = (
582
+ "cliente quer viajar em setembro, prefere praia, pediu parcelamento e falta RG",
583
+ "família de quatro pessoas quer Natal, precisa de hotel com café e traslado",
584
+ "casal pediu lua de mel em Gramado, orçamento alto e datas flexíveis",
585
+ "cliente aprovou voo, falta escolher hotel e enviar comprovante",
586
+ "passageira pediu remarcação, informou doença e aguarda regras da tarifa",
587
+ "grupo quer Buenos Aires, pediu bagagem despachada e pagamento no cartão",
588
+ "cliente quer economizar, aceita voo com conexão e hotel simples",
589
+ "cliente pediu pacote para Porto Seguro, duas crianças e saída em julho",
590
+ "cliente enviou comprovante, falta validar valor e confirmar emissão",
591
+ "cliente perguntou seguro viagem, bagagem e possibilidade de parcelar",
592
+ "cliente recusou primeira proposta e pediu opção com datas alternativas",
593
+ "cliente confirmou dados dos passageiros, mas falta documento do bebê",
594
+ "cliente quer viajar no feriado, prefere voo direto e hotel perto da praia",
595
+ "cliente pediu cancelamento, quer saber multa e prazo de reembolso",
596
+ "cliente recebeu orçamento, achou caro e pediu algo até R$3.000,00",
597
+ "cliente quer réveillon, não definiu destino e informou orçamento aberto",
598
+ "cliente pediu recibo, nota fiscal e confirmação do valor pago",
599
+ "cliente perguntou se o pacote inclui ingresso, traslado e café da manhã",
600
+ )
601
+ for details in summary_cases:
602
+ prompts.append(
603
+ (
604
+ f"Resuma em dois bullets para uma agente de viagens: {details}.",
605
+ "summary_rewrite",
606
+ )
607
+ )
608
+
609
+ service_prompts = (
610
+ "Responda ao cliente que pediu desconto, mantendo tom humano e sem prometer valor.",
611
+ "Avise que você vai conferir disponibilidade antes de confirmar a tarifa.",
612
+ "Peça os dados completos dos passageiros para emissão em uma mensagem curta.",
613
+ "Diga que a proposta foi atualizada com uma opção mais econômica.",
614
+ "Explique que a reserva só fica garantida depois da emissão.",
615
+ "Pergunte se o cliente prefere voo direto ou aceita conexão para economizar.",
616
+ "Avise que recebeu o comprovante e vai conferir com o financeiro.",
617
+ "Informe que falta apenas o documento do acompanhante para seguir.",
618
+ "Diga que encontrou uma opção com hotel melhor, mas valor mais alto.",
619
+ "Peça autorização final antes de emitir a passagem.",
620
+ "Responda a um cliente ansioso sem inventar prazo da companhia.",
621
+ "Avise que vai mandar duas opções: uma econômica e uma mais confortável.",
622
+ "Explique que parcelamento pode depender da forma de pagamento.",
623
+ "Confirme que os nomes precisam estar iguais aos documentos.",
624
+ "Peça desculpas pelo atraso e retome a conversa com a próxima ação.",
625
+ "Diga que a tarifa mudou e ofereça revisar datas para baixar o valor.",
626
+ "Pergunte se o cliente deseja incluir bagagem despachada.",
627
+ "Avise que o hotel solicitado não tem disponibilidade nas datas.",
628
+ "Confirme que vai salvar o orçamento e acompanhar a decisão do cliente.",
629
+ "Diga que precisa do comprovante legível para conciliar o pagamento.",
630
+ )
631
+ prompts.extend(
632
+ (
633
+ f"Responda somente com a mensagem final para WhatsApp: {prompt}",
634
+ "customer_service",
635
+ )
636
+ for prompt in service_prompts
637
+ )
638
+
639
+ workflow_prompts = (
640
+ "Escreva uma mensagem curta pedindo confirmação dos nomes antes da emissão.",
641
+ "Escreva uma mensagem curta avisando que a cotação vence hoje às 18:00.",
642
+ "Escreva uma resposta natural para cliente que perguntou se pode parcelar.",
643
+ "Escreva uma resposta curta dizendo que o comprovante ficou ilegível.",
644
+ "Escreva uma mensagem pedindo o CPF sem expor dados sensíveis.",
645
+ "Escreva uma resposta explicando que a tarifa depende de disponibilidade.",
646
+ "Escreva uma mensagem avisando que falta escolher o horário do voo.",
647
+ "Escreva uma resposta para cliente que pediu hotel com café da manhã.",
648
+ "Escreva uma mensagem confirmando recebimento do RG do passageiro.",
649
+ "Escreva uma resposta dizendo que você vai conferir regras de remarcação.",
650
+ "Escreva uma mensagem avisando que a opção mais barata tem conexão.",
651
+ "Escreva uma resposta explicando que voo direto ficou mais caro.",
652
+ "Escreva uma mensagem curta pedindo autorização para emitir.",
653
+ "Escreva uma resposta dizendo que o seguro viagem é opcional.",
654
+ "Escreva uma mensagem pedindo o comprovante de pagamento.",
655
+ "Escreva uma resposta para cliente que ainda não decidiu o destino.",
656
+ "Escreva uma mensagem oferecendo revisar as datas para reduzir o preço.",
657
+ "Escreva uma resposta dizendo que a hospedagem ainda não foi confirmada.",
658
+ "Escreva uma mensagem curta avisando que o pagamento foi localizado.",
659
+ "Escreva uma resposta explicando que crianças precisam de documento.",
660
+ "Escreva uma mensagem pedindo confirmação de ida e volta.",
661
+ "Escreva uma resposta dizendo que bagagem despachada muda o valor.",
662
+ "Escreva uma mensagem informando que a reserva está aguardando pagamento.",
663
+ "Escreva uma resposta educada para cliente que pediu retorno urgente.",
664
+ "Escreva uma mensagem dizendo que vai enviar duas alternativas de hotel.",
665
+ "Escreva uma resposta para cliente que pediu recibo após pagar.",
666
+ "Escreva uma mensagem curta pedindo telefone de contato atualizado.",
667
+ "Escreva uma resposta dizendo que a emissão depende da aprovação final.",
668
+ "Escreva uma mensagem avisando que o orçamento foi salvo no atendimento.",
669
+ "Escreva uma resposta para cliente que quer incluir mais um passageiro.",
670
+ )
671
+ prompts.extend(
672
+ (
673
+ f"Responda somente com a mensagem final para WhatsApp: {prompt}",
674
+ "whatsapp_style",
675
+ )
676
+ for prompt in workflow_prompts
677
+ )
678
+
679
+ grounded_cases = (
680
+ ("Total R$3.400,00; pago R$1.000,00.", "quanto falta pagar?"),
681
+ ("Total R$5.200,00; pago R$5.200,00.", "a viagem está quitada?"),
682
+ ("Reserva informa voo AD 2210 e localizador VMA123.", "qual é o voo?"),
683
+ ("Reserva informa voo AD 2210 e localizador VMA123.", "qual é o hotel?"),
684
+ ("Cotação válida até 14/06 às 18:00.", "até quando vale?"),
685
+ ("Cotação válida até 14/06 às 18:00.", "qual é a política de cancelamento?"),
686
+ ("Passageira Ana enviou RG; Bruno ainda não enviou documento.", "quem está pendente?"),
687
+ ("Pagamento recebido em PIX no valor de R$870,00.", "qual foi a forma de pagamento?"),
688
+ ("Cliente pediu Recife, julho, duas crianças.", "qual é o destino?"),
689
+ ("Cliente pediu Recife, julho, duas crianças.", "qual é o número do voo?"),
690
+ ("Hotel confirmado com café da manhã, sem traslado.", "inclui traslado?"),
691
+ ("Pacote inclui bagagem de mão e item pessoal.", "inclui bagagem despachada?"),
692
+ ("Localizador ABC123, assento 12A, embarque 08:30.", "qual é o assento?"),
693
+ ("Orçamento de R$2.980,00 em até 6 vezes.", "qual é o valor total?"),
694
+ ("Cliente recusou seguro viagem e pediu só passagem.", "inclui seguro viagem?"),
695
+ ("Cliente aprovou emissão após confirmar nomes.", "qual é a próxima etapa?"),
696
+ ("Falta enviar CPF do acompanhante.", "qual documento está pendente?"),
697
+ ("A tarifa mudou de R$2.400,00 para R$2.650,00.", "qual é o novo valor?"),
698
+ ("Cliente quer voo direto, mas só há opção com conexão.", "há voo direto?"),
699
+ ("Pagamento de entrada venceu ontem e não há comprovante.", "o pagamento foi confirmado?"),
700
+ )
701
+ for context, question in grounded_cases:
702
+ prompts.append(
703
+ (
704
+ f"Contexto: {context} Pergunta: {question} Responda sem inventar.",
705
+ "grounded_qa",
706
+ )
707
+ )
708
+ return prompts
709
+
710
+
711
+ def _collect_gigaverbo_prompts(*, limit: int, seed: int) -> list[tuple[str, str]]:
712
+ from datasets import load_dataset
713
+
714
+ subsets = ("general", "retrieval", "summarization", "rewriting", "system_prompts")
715
+ rng = __import__("random").Random(seed)
716
+ prompts: list[tuple[str, str]] = []
717
+ per_subset = max(1, limit // len(subsets))
718
+ for subset in subsets:
719
+ if len(prompts) >= limit:
720
+ break
721
+ try:
722
+ ds = load_dataset("Polygl0t/gigaverbo-v2-sft", subset, split="train", streaming=True)
723
+ except Exception as exc:
724
+ print(f"Skipping GigaVerbo subset {subset}: {exc}")
725
+ continue
726
+ seen = 0
727
+ for row in ds:
728
+ messages = row.get("messages")
729
+ if not isinstance(messages, list):
730
+ continue
731
+ users = [
732
+ str(message.get("content", "")).strip()
733
+ for message in messages
734
+ if isinstance(message, dict)
735
+ and message.get("role") == "user"
736
+ and str(message.get("content", "")).strip()
737
+ ]
738
+ if not users:
739
+ continue
740
+ if rng.random() < 0.35:
741
+ prompts.append((users[-1], f"gigaverbo_{subset}"))
742
+ seen += 1
743
+ if seen >= per_subset or len(prompts) >= limit:
744
+ break
745
+ return prompts[:limit]
746
+
747
+
748
+ def _teacher_prompt_pool(*, gigaverbo_limit: int, seed: int) -> list[tuple[str, str]]:
749
+ prompts = [(prompt, category) for category, prompt in CURATED_TEACHER_PROMPTS]
750
+ prompts.extend(_synthetic_vivamais_teacher_prompts())
751
+ if gigaverbo_limit > 0:
752
+ prompts.extend(_collect_gigaverbo_prompts(limit=gigaverbo_limit, seed=seed))
753
+ if IS_V4:
754
+ prompts = [
755
+ (prompt, category)
756
+ for prompt, category in prompts
757
+ if category in V4_TEACHER_CATEGORIES
758
+ ]
759
+ return prompts
760
+
761
+
762
+ def _teacher_system_prompt() -> str:
763
+ if IS_V4:
764
+ return (
765
+ "Você é a assistente de atendimento da Viva Mais. Responda somente com a "
766
+ "resposta final, em português brasileiro natural e com acentos. Não copie "
767
+ "nem transforme a instrução do usuário em uma frase como 'Avise que', "
768
+ "'Pergunte', 'Peça', 'Diga' ou 'Escreva'. Não use preâmbulos como "
769
+ "'Claro' ou 'Aqui está'. Quando houver contexto, use apenas o contexto; "
770
+ "se a informação não aparecer, diga que não consta no contexto e não use "
771
+ "dados de outro cliente."
772
+ )
773
+ return (
774
+ "Você é a assistente de atendimento da Viva Mais. Responda de forma "
775
+ "direta, natural e objetiva em português brasileiro. Não use preâmbulos "
776
+ "como 'Claro, aqui está'. Quando a pergunta trouxer contexto, use apenas "
777
+ "esse contexto e diga quando a informação não estiver presente. Entregue "
778
+ "somente a resposta final, sem explicar estratégia, critérios ou rótulos "
779
+ "como 'Resposta:'."
780
+ )
781
+
782
+
783
+ def _usable_teacher_answer(text: str) -> bool:
784
+ cleaned = text.strip()
785
+ lowered = cleaned.lower()
786
+ if not cleaned or len(cleaned) > 800:
787
+ return False
788
+ if IS_V4 and lowered.startswith(INSTRUCTION_ECHO_PREFIXES):
789
+ return False
790
+ if IS_V4 and any(marker in lowered for marker in ACCENTLESS_MARKERS):
791
+ return False
792
+ if lowered.startswith(("claro:", "claro.", "claro,", "claro!", "vai ", "pode ser que")):
793
+ return False
794
+ blocked = (
795
+ "<think",
796
+ "</think",
797
+ "```",
798
+ "###",
799
+ "---",
800
+ "**",
801
+ "😊",
802
+ "✈",
803
+ "claro! aqui",
804
+ "aqui está",
805
+ "aqui vai",
806
+ "posso adaptar",
807
+ "se quiser",
808
+ "como se fosse",
809
+ "vamos ",
810
+ "por exemplo:",
811
+ "a pergunta pede",
812
+ "a frase original",
813
+ "a melhor resposta",
814
+ "a resposta deve",
815
+ "contexto fornecido",
816
+ "não há evidência",
817
+ "o contexto indica",
818
+ "por favor, forneça",
819
+ "resposta:",
820
+ "[seu nome]",
821
+ "[nome da agência]",
822
+ )
823
+ return not any(marker in lowered for marker in blocked)
824
+
825
+
826
+ def _normalize_assistant_accents_in_jsonl(path: Path) -> int:
827
+ if not IS_V4:
828
+ return 0
829
+ rows = _load_jsonl(path)
830
+ changed = 0
831
+ with path.open("w", encoding="utf-8") as handle:
832
+ for row in rows:
833
+ messages = row.get("messages")
834
+ if isinstance(messages, list):
835
+ for message in messages:
836
+ if (
837
+ not isinstance(message, dict)
838
+ or message.get("role") != "assistant"
839
+ ):
840
+ continue
841
+ content = str(message.get("content", ""))
842
+ normalized = _normalize_ptbr_accents(content)
843
+ if normalized != content:
844
+ message["content"] = normalized
845
+ changed += 1
846
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
847
+ return changed
848
+
849
+
850
+ @app.function(
851
+ gpu=GENERATION_GPU,
852
+ image=image,
853
+ volumes={"/data": data_volume},
854
+ timeout=8 * 3600,
855
+ )
856
+ def distill_teacher_rows(
857
+ *,
858
+ teacher_model: str = RIO_TEACHER_MODEL,
859
+ output_path: str = TEACHER_REMOTE,
860
+ target_rows: int = DEFAULT_TEACHER_ROWS,
861
+ gigaverbo_seed_limit: int = DEFAULT_TEACHER_SEED_LIMIT,
862
+ seed: int = 42,
863
+ max_new_tokens: int = 220,
864
+ ) -> dict[str, Any]:
865
+ import random
866
+
867
+ import torch
868
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
869
+
870
+ _ensure_paths()
871
+ tokenizer = AutoTokenizer.from_pretrained(teacher_model, trust_remote_code=True)
872
+ model = AutoModelForCausalLM.from_pretrained(
873
+ teacher_model,
874
+ trust_remote_code=True,
875
+ quantization_config=BitsAndBytesConfig(
876
+ load_in_4bit=True,
877
+ bnb_4bit_compute_dtype=torch.bfloat16,
878
+ bnb_4bit_quant_type="nf4",
879
+ bnb_4bit_use_double_quant=True,
880
+ ),
881
+ device_map={"": 0},
882
+ )
883
+ _configure_tokenizer_for_generation(tokenizer, model)
884
+ model.eval()
885
+ prompts = _teacher_prompt_pool(gigaverbo_limit=gigaverbo_seed_limit, seed=seed)
886
+ random.Random(seed).shuffle(prompts)
887
+ output = Path(output_path)
888
+ output.parent.mkdir(parents=True, exist_ok=True)
889
+ output.write_text("", encoding="utf-8")
890
+ rows: list[dict[str, Any]] = []
891
+ rejected = 0
892
+ for user_prompt, category in prompts:
893
+ if len(rows) >= target_rows:
894
+ break
895
+ messages = [
896
+ {
897
+ "role": "system",
898
+ "content": _teacher_system_prompt(),
899
+ },
900
+ {"role": "user", "content": user_prompt},
901
+ ]
902
+ text = tokenizer.apply_chat_template(
903
+ messages,
904
+ tokenize=False,
905
+ add_generation_prompt=True,
906
+ enable_thinking=False,
907
+ )
908
+ inputs = tokenizer(text, return_tensors="pt").to(model.device)
909
+ with torch.no_grad():
910
+ output_ids = model.generate(
911
+ **inputs,
912
+ max_new_tokens=max_new_tokens,
913
+ do_sample=False,
914
+ pad_token_id=tokenizer.pad_token_id,
915
+ )
916
+ answer = tokenizer.decode(
917
+ output_ids[0][inputs["input_ids"].shape[1] :],
918
+ skip_special_tokens=True,
919
+ ).strip()
920
+ if IS_V4:
921
+ answer = _normalize_ptbr_accents(answer)
922
+ accepted = _usable_teacher_answer(answer)
923
+ print(
924
+ json.dumps(
925
+ {
926
+ "event": "teacher_output",
927
+ "category": category,
928
+ "accepted": accepted,
929
+ "accepted_count": len(rows),
930
+ "rejected_count": rejected,
931
+ "prompt_preview": _preview(user_prompt, 240),
932
+ "answer_preview": _preview(answer, 500),
933
+ },
934
+ ensure_ascii=False,
935
+ ),
936
+ flush=True,
937
+ )
938
+ if not accepted:
939
+ rejected += 1
940
+ continue
941
+ row = {
942
+ "messages": [
943
+ {"role": "user", "content": user_prompt},
944
+ {"role": "assistant", "content": answer},
945
+ ],
946
+ "source": "rio31_teacher_distill",
947
+ "seed_category": category,
948
+ "teacher_model": teacher_model,
949
+ }
950
+ rows.append(row)
951
+ _write_jsonl(output, row)
952
+ data_volume.commit()
953
+ data_volume.commit()
954
+ stats = {
955
+ "teacher_model": teacher_model,
956
+ "rows": len(rows),
957
+ "rejected": rejected,
958
+ "output": output_path,
959
+ "target_rows": target_rows,
960
+ }
961
+ print(json.dumps(stats, indent=2, ensure_ascii=False))
962
+ return stats
963
+
964
+
965
+ @app.function(image=image, volumes={"/data": data_volume}, timeout=4 * 3600, cpu=4, memory=16384)
966
+ def prepare_v3_dataset(
967
+ *,
968
+ total_limit: int = DEFAULT_V3_ROWS,
969
+ seed: int = 42,
970
+ teacher_rows_path: str = TEACHER_REMOTE,
971
+ public_weight: float = DEFAULT_PUBLIC_WEIGHT,
972
+ distill_weight: float = DEFAULT_DISTILL_WEIGHT,
973
+ domain_weight: float = DEFAULT_DOMAIN_WEIGHT,
974
+ travel_weight: float = 0.0,
975
+ negative_weight: float = DEFAULT_NEGATIVE_WEIGHT,
976
+ generic_weight: float = DEFAULT_GENERIC_WEIGHT,
977
+ ) -> dict[str, Any]:
978
+ _ensure_paths()
979
+ from prepare_minicpm5_v2_distill_sft import (
980
+ DISTILL_BUCKET,
981
+ GENERIC_BUCKET,
982
+ prepare_minicpm5_v2_distill_sft,
983
+ )
984
+ from prepare_minicpm5_text_sft import (
985
+ DOMAIN_BUCKET,
986
+ NEGATIVE_BUCKET,
987
+ PUBLIC_BUCKET,
988
+ TRAVEL_BUCKET,
989
+ )
990
+
991
+ stats = prepare_minicpm5_v2_distill_sft(
992
+ output_path=Path(TRAIN_REMOTE),
993
+ eval_output_path=Path(EVAL_REMOTE),
994
+ teacher_rows_path=Path(teacher_rows_path),
995
+ total_limit=total_limit,
996
+ seed=seed,
997
+ weights={
998
+ PUBLIC_BUCKET: public_weight,
999
+ DISTILL_BUCKET: distill_weight,
1000
+ DOMAIN_BUCKET: domain_weight,
1001
+ TRAVEL_BUCKET: travel_weight,
1002
+ NEGATIVE_BUCKET: negative_weight,
1003
+ GENERIC_BUCKET: generic_weight,
1004
+ },
1005
+ )
1006
+ stats["assistant_accent_normalized_rows"] = _normalize_assistant_accents_in_jsonl(
1007
+ Path(TRAIN_REMOTE)
1008
+ )
1009
+ data_volume.commit()
1010
+ print(json.dumps(stats, indent=2, ensure_ascii=False))
1011
+ return stats
1012
+
1013
+
1014
+ def _load_peft_model(model_name: str, *, context_length: int, load_in_4bit: bool):
1015
+ from unsloth import FastLanguageModel
1016
+
1017
+ return FastLanguageModel.from_pretrained(
1018
+ model_name=model_name,
1019
+ max_seq_length=context_length,
1020
+ dtype=None,
1021
+ load_in_4bit=load_in_4bit,
1022
+ )
1023
+
1024
+
1025
+ def _attach_lora(model, *, rank: int, alpha: int, dropout: float):
1026
+ from unsloth import FastLanguageModel
1027
+
1028
+ return FastLanguageModel.get_peft_model(
1029
+ model,
1030
+ r=rank,
1031
+ target_modules=[
1032
+ "q_proj",
1033
+ "k_proj",
1034
+ "v_proj",
1035
+ "o_proj",
1036
+ "gate_proj",
1037
+ "up_proj",
1038
+ "down_proj",
1039
+ ],
1040
+ lora_alpha=alpha,
1041
+ lora_dropout=dropout,
1042
+ use_gradient_checkpointing="unsloth",
1043
+ )
1044
+
1045
+
1046
+ def _render_sft_text(tokenizer, messages: list[dict[str, str]]) -> str:
1047
+ prompt = tokenizer.apply_chat_template(
1048
+ messages[:-1],
1049
+ tokenize=False,
1050
+ add_generation_prompt=True,
1051
+ enable_thinking=False,
1052
+ )
1053
+ answer = str(messages[-1].get("content", "")).strip()
1054
+ return f"{prompt}{answer}<|im_end|>\n"
1055
+
1056
+
1057
+ @app.function(
1058
+ gpu=TRAIN_GPU,
1059
+ image=image,
1060
+ volumes={"/data": data_volume},
1061
+ timeout=12 * 3600,
1062
+ )
1063
+ def train_sft(
1064
+ *,
1065
+ base_model: str = V1_MODEL,
1066
+ output_dir: str = LORA_DIR,
1067
+ context_length: int = DEFAULT_CONTEXT_LENGTH,
1068
+ num_epochs: float = DEFAULT_EPOCHS,
1069
+ learning_rate: float = DEFAULT_LEARNING_RATE,
1070
+ per_device_batch_size: int = 2,
1071
+ gradient_accumulation_steps: int = 8,
1072
+ lora_rank: int = 16,
1073
+ lora_alpha: int = 32,
1074
+ ) -> str:
1075
+ os.environ["UNSLOTH_COMPILE_DISABLE"] = "1"
1076
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
1077
+ from datasets import load_dataset
1078
+ from peft import PeftModel
1079
+ from transformers import DataCollatorForLanguageModeling, Trainer, TrainingArguments
1080
+ from unsloth.chat_templates import train_on_responses_only
1081
+
1082
+ model, tokenizer = _load_peft_model(
1083
+ base_model,
1084
+ context_length=context_length,
1085
+ load_in_4bit=True,
1086
+ )
1087
+ _configure_tokenizer_for_generation(tokenizer, model)
1088
+ if isinstance(model, PeftModel):
1089
+ raise RuntimeError(f"Base model {base_model!r} still has LoRA adapters attached")
1090
+ model = _attach_lora(model, rank=lora_rank, alpha=lora_alpha, dropout=0.05)
1091
+
1092
+ dataset = load_dataset("json", data_files=TRAIN_REMOTE, split="train")
1093
+ print(f"Loaded {len(dataset)} examples from {TRAIN_REMOTE}")
1094
+
1095
+ def to_text(example: dict) -> dict:
1096
+ return {"text": _render_sft_text(tokenizer, example["messages"])}
1097
+
1098
+ dataset = dataset.map(to_text, remove_columns=dataset.column_names, num_proc=1)
1099
+
1100
+ def tokenize(example: dict) -> dict:
1101
+ return tokenizer(example["text"], truncation=True, max_length=context_length)
1102
+
1103
+ dataset = dataset.map(tokenize, remove_columns=["text"], num_proc=1)
1104
+ collator = DataCollatorForLanguageModeling(
1105
+ tokenizer=tokenizer,
1106
+ mlm=False,
1107
+ pad_to_multiple_of=8,
1108
+ )
1109
+ trainer = Trainer(
1110
+ model=model,
1111
+ processing_class=tokenizer,
1112
+ train_dataset=dataset,
1113
+ data_collator=collator,
1114
+ args=TrainingArguments(
1115
+ output_dir=output_dir,
1116
+ per_device_train_batch_size=per_device_batch_size,
1117
+ gradient_accumulation_steps=gradient_accumulation_steps,
1118
+ num_train_epochs=num_epochs,
1119
+ learning_rate=learning_rate,
1120
+ warmup_ratio=0.03,
1121
+ lr_scheduler_type="cosine",
1122
+ bf16=True,
1123
+ logging_steps=20,
1124
+ save_strategy="steps",
1125
+ save_steps=500,
1126
+ save_total_limit=2,
1127
+ dataloader_num_workers=0,
1128
+ dataloader_pin_memory=False,
1129
+ report_to="none",
1130
+ remove_unused_columns=False,
1131
+ ),
1132
+ )
1133
+ trainer = train_on_responses_only(
1134
+ trainer,
1135
+ instruction_part="<|im_start|>user\n",
1136
+ response_part=NO_THINKING_RESPONSE_PREFIX,
1137
+ num_proc=1,
1138
+ )
1139
+ trainer.train()
1140
+ model.save_pretrained(output_dir)
1141
+ tokenizer.save_pretrained(output_dir)
1142
+ data_volume.commit()
1143
+ return output_dir
1144
+
1145
+
1146
+ @app.function(
1147
+ gpu=TRAIN_GPU,
1148
+ image=image,
1149
+ volumes={"/data": data_volume},
1150
+ timeout=4 * 3600,
1151
+ )
1152
+ def merge_lora(
1153
+ *,
1154
+ lora_dir: str = LORA_DIR,
1155
+ merged_dir: str = MERGED_DIR,
1156
+ context_length: int = DEFAULT_CONTEXT_LENGTH,
1157
+ ) -> str:
1158
+ from unsloth import FastLanguageModel
1159
+
1160
+ model, tokenizer = FastLanguageModel.from_pretrained(
1161
+ model_name=lora_dir,
1162
+ max_seq_length=context_length,
1163
+ dtype=None,
1164
+ load_in_4bit=False,
1165
+ )
1166
+ model.save_pretrained_merged(merged_dir, tokenizer, save_method="merged_16bit")
1167
+ data_volume.commit()
1168
+ return merged_dir
1169
+
1170
+
1171
+ def _generate_answer(
1172
+ *,
1173
+ model,
1174
+ tokenizer,
1175
+ messages: list[dict[str, Any]],
1176
+ max_new_tokens: int,
1177
+ do_sample: bool = False,
1178
+ ) -> str:
1179
+ import torch
1180
+
1181
+ _configure_tokenizer_for_generation(tokenizer, model)
1182
+ text = tokenizer.apply_chat_template(
1183
+ messages,
1184
+ tokenize=False,
1185
+ add_generation_prompt=True,
1186
+ enable_thinking=False,
1187
+ )
1188
+ inputs = tokenizer(text, return_tensors="pt").to(model.device)
1189
+ generation_kwargs: dict[str, Any] = {
1190
+ "max_new_tokens": max_new_tokens,
1191
+ "do_sample": do_sample,
1192
+ "pad_token_id": tokenizer.pad_token_id,
1193
+ "eos_token_id": tokenizer.eos_token_id,
1194
+ }
1195
+ if do_sample:
1196
+ generation_kwargs["temperature"] = 0.7
1197
+ generation_kwargs["top_p"] = 0.9
1198
+ with torch.no_grad():
1199
+ output_ids = model.generate(**inputs, **generation_kwargs)
1200
+ return tokenizer.decode(
1201
+ output_ids[0][inputs["input_ids"].shape[1] :],
1202
+ skip_special_tokens=True,
1203
+ )
1204
+
1205
+
1206
+ @app.function(
1207
+ gpu=GENERATION_GPU,
1208
+ image=image,
1209
+ volumes={"/data": data_volume, "/results": results_volume},
1210
+ timeout=4 * 3600,
1211
+ )
1212
+ def eval_vivamais(
1213
+ *,
1214
+ model_id: str,
1215
+ run_name: str,
1216
+ max_new_tokens: int = 160,
1217
+ ) -> dict[str, Any]:
1218
+ import torch
1219
+ from transformers import AutoModelForCausalLM, AutoTokenizer
1220
+
1221
+ _ensure_paths()
1222
+ import sys
1223
+
1224
+ sys.path.insert(0, "/opt/vivamais/evals")
1225
+ from vivamais_qa_scoring import score_case, summarize_results
1226
+
1227
+ eval_file = Path(EVAL_REMOTE)
1228
+ cases = _load_jsonl(eval_file)
1229
+ if len(cases) < 120:
1230
+ from build_vivamais_qa_eval import build_vivamais_qa_eval
1231
+
1232
+ build_vivamais_qa_eval(eval_file)
1233
+ data_volume.commit()
1234
+ cases = _load_jsonl(eval_file)
1235
+ output_dir = Path("/results") / run_name
1236
+ output_dir.mkdir(parents=True, exist_ok=True)
1237
+ output_log_path = output_dir / "outputs.jsonl"
1238
+ output_log_path.write_text("", encoding="utf-8")
1239
+ tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
1240
+ model = AutoModelForCausalLM.from_pretrained(
1241
+ model_id,
1242
+ trust_remote_code=True,
1243
+ torch_dtype=torch.bfloat16,
1244
+ device_map="auto",
1245
+ )
1246
+ model.eval()
1247
+ predictions = []
1248
+ for case_number, case in enumerate(cases, start=1):
1249
+ prediction = _generate_answer(
1250
+ model=model,
1251
+ tokenizer=tokenizer,
1252
+ messages=case["messages"],
1253
+ max_new_tokens=max_new_tokens,
1254
+ )
1255
+ scored = score_case(case, prediction)
1256
+ row = {
1257
+ "id": case.get("id"),
1258
+ "category": case.get("category"),
1259
+ "case": case,
1260
+ "prediction": scored["prediction"],
1261
+ "score": scored["score"],
1262
+ "passed": scored["passed"],
1263
+ "forbidden_hits": scored["forbidden_hits"],
1264
+ "unknown_gate": scored["unknown_gate"],
1265
+ "expected_answer": case.get("expected_answer"),
1266
+ }
1267
+ predictions.append(row)
1268
+ _write_jsonl(output_log_path, row)
1269
+ print(
1270
+ json.dumps(
1271
+ {
1272
+ "event": "vivamais_eval_output",
1273
+ "run_name": run_name,
1274
+ "case_number": case_number,
1275
+ "total_cases": len(cases),
1276
+ "id": row["id"],
1277
+ "category": row["category"],
1278
+ "passed": row["passed"],
1279
+ "score": row["score"],
1280
+ "forbidden_hits": row["forbidden_hits"],
1281
+ "unknown_gate": row["unknown_gate"],
1282
+ "prediction_preview": _preview(row["prediction"]),
1283
+ },
1284
+ ensure_ascii=False,
1285
+ ),
1286
+ flush=True,
1287
+ )
1288
+ payload = {
1289
+ "model_name": model_id,
1290
+ "run_name": run_name,
1291
+ "generation": {"enable_thinking": False, "max_new_tokens": max_new_tokens},
1292
+ "predictions": predictions,
1293
+ }
1294
+ summary = summarize_results(payload)
1295
+ (output_dir / "results.json").write_text(json.dumps(payload, indent=2, ensure_ascii=False))
1296
+ (output_dir / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False))
1297
+ results_volume.commit()
1298
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
1299
+ return summary
1300
+
1301
+
1302
+ @app.function(
1303
+ gpu=GENERATION_GPU,
1304
+ image=image,
1305
+ volumes={"/data": data_volume, "/results": results_volume},
1306
+ timeout=2 * 3600,
1307
+ )
1308
+ def eval_generic_ptbr(
1309
+ *,
1310
+ model_id: str,
1311
+ run_name: str,
1312
+ max_new_tokens: int = 180,
1313
+ ) -> dict[str, Any]:
1314
+ import torch
1315
+ from transformers import AutoModelForCausalLM, AutoTokenizer
1316
+
1317
+ _ensure_paths()
1318
+ from ptbr_conversation_eval import (
1319
+ GENERIC_EVAL_CASES,
1320
+ score_generic_case,
1321
+ summarize_generic_scores,
1322
+ )
1323
+
1324
+ tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
1325
+ model = AutoModelForCausalLM.from_pretrained(
1326
+ model_id,
1327
+ trust_remote_code=True,
1328
+ torch_dtype=torch.bfloat16,
1329
+ device_map="auto",
1330
+ )
1331
+ model.eval()
1332
+ scored = []
1333
+ output_dir = Path("/results") / run_name
1334
+ output_dir.mkdir(parents=True, exist_ok=True)
1335
+ output_log_path = output_dir / "outputs.jsonl"
1336
+ output_log_path.write_text("", encoding="utf-8")
1337
+ for case_number, case in enumerate(GENERIC_EVAL_CASES, start=1):
1338
+ prediction = _generate_answer(
1339
+ model=model,
1340
+ tokenizer=tokenizer,
1341
+ messages=case["messages"],
1342
+ max_new_tokens=max_new_tokens,
1343
+ )
1344
+ row = score_generic_case(case, prediction)
1345
+ row["case"] = case
1346
+ scored.append(row)
1347
+ _write_jsonl(output_log_path, row)
1348
+ print(
1349
+ json.dumps(
1350
+ {
1351
+ "event": "generic_eval_output",
1352
+ "run_name": run_name,
1353
+ "case_number": case_number,
1354
+ "total_cases": len(GENERIC_EVAL_CASES),
1355
+ "id": row["id"],
1356
+ "category": row["category"],
1357
+ "passed": row["passed"],
1358
+ "score": row["score"],
1359
+ "forbidden_hits": row["forbidden_hits"],
1360
+ "prediction_preview": _preview(row["prediction"]),
1361
+ },
1362
+ ensure_ascii=False,
1363
+ ),
1364
+ flush=True,
1365
+ )
1366
+ summary = summarize_generic_scores(model=model_id, run_name=run_name, scored=scored)
1367
+ (output_dir / "results.json").write_text(
1368
+ json.dumps({"summary": summary, "predictions": scored}, indent=2, ensure_ascii=False)
1369
+ )
1370
+ (output_dir / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False))
1371
+ results_volume.commit()
1372
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
1373
+ return summary
1374
+
1375
+
1376
+ def _candidate_passes(
1377
+ *,
1378
+ baseline_vivamais: dict[str, Any],
1379
+ candidate_vivamais: dict[str, Any],
1380
+ baseline_generic: dict[str, Any],
1381
+ candidate_generic: dict[str, Any],
1382
+ min_dashboard_delta: float = V3_MIN_DASHBOARD_DELTA,
1383
+ min_generic_score: float = V3_MIN_GENERIC_SCORE,
1384
+ ) -> bool:
1385
+ dashboard_passed = (
1386
+ candidate_vivamais["average_score"]
1387
+ >= baseline_vivamais["average_score"] + min_dashboard_delta
1388
+ and candidate_vivamais["unknown_failures"] <= baseline_vivamais["unknown_failures"]
1389
+ and candidate_vivamais["leakage_failures"] <= baseline_vivamais["leakage_failures"]
1390
+ )
1391
+ if IS_V4:
1392
+ return dashboard_passed and candidate_generic["average_score"] >= min_generic_score
1393
+ return (
1394
+ dashboard_passed
1395
+ and candidate_generic["average_score"] > baseline_generic["average_score"]
1396
+ and candidate_generic["average_score"] >= min_generic_score
1397
+ )
1398
+
1399
+
1400
+ @app.function(image=image, volumes={"/results": results_volume}, timeout=10 * 60)
1401
+ def show_eval_details(
1402
+ *,
1403
+ run_names: list[str],
1404
+ failed_only: bool = True,
1405
+ max_prediction_chars: int = 600,
1406
+ ) -> dict[str, Any]:
1407
+ summaries: dict[str, Any] = {}
1408
+ for run_name in run_names:
1409
+ results_path = Path("/results") / run_name / "results.json"
1410
+ if not results_path.is_file():
1411
+ summaries[run_name] = {"error": f"Missing results file: {results_path}"}
1412
+ continue
1413
+ payload = json.loads(results_path.read_text(encoding="utf-8"))
1414
+ rows = payload.get("predictions", [])
1415
+ details = []
1416
+ for row in rows:
1417
+ passed = bool(row.get("passed"))
1418
+ if failed_only and passed:
1419
+ continue
1420
+ case = row.get("case", {})
1421
+ if not isinstance(case, dict):
1422
+ case = {}
1423
+ prediction = str(row.get("prediction", "")).strip()
1424
+ details.append(
1425
+ {
1426
+ "id": row.get("id") or case.get("id"),
1427
+ "category": row.get("category") or case.get("category"),
1428
+ "passed": passed,
1429
+ "score": row.get("score"),
1430
+ "forbidden_hits": row.get("forbidden_hits", []),
1431
+ "unknown_gate": row.get("unknown_gate"),
1432
+ "expected_answer": row.get("expected_answer") or case.get("expected_answer"),
1433
+ "prediction": prediction[:max_prediction_chars],
1434
+ }
1435
+ )
1436
+ summaries[run_name] = {
1437
+ "summary": payload.get("summary"),
1438
+ "details": details,
1439
+ }
1440
+ print(json.dumps(summaries, indent=2, ensure_ascii=False))
1441
+ return summaries
1442
+
1443
+
1444
+ @app.function(image=image, volumes={"/data": data_volume}, timeout=4 * 3600, cpu=4, memory=16384)
1445
+ def publish_model(
1446
+ *,
1447
+ repo_id: str = V3_MODEL_REPO,
1448
+ source_dir: str = MERGED_DIR,
1449
+ base_model: str = V1_MODEL,
1450
+ hf_token: str,
1451
+ ) -> dict[str, str]:
1452
+ from huggingface_hub import HfApi
1453
+
1454
+ token = hf_token.strip()
1455
+ if not token:
1456
+ raise RuntimeError(f"HF token is required to publish {TEXT_VARIANT}")
1457
+ source = Path(source_dir)
1458
+ readme = source / "README.md"
1459
+ readme.write_text(
1460
+ f"""---
1461
+ language:
1462
+ - pt
1463
+ license: apache-2.0
1464
+ base_model: {base_model}
1465
+ library_name: transformers
1466
+ tags:
1467
+ - brazilian-portuguese
1468
+ - minicpm5
1469
+ - vivamais
1470
+ - distilled
1471
+ - conversational
1472
+ - grounded-qa
1473
+ ---
1474
+
1475
+ # {repo_id.split("/")[-1]}
1476
+
1477
+ MiniCPM5-1B Viva Mais text {TEXT_VARIANT}, trained from `{base_model}` with a
1478
+ broader Brazilian Portuguese conversational and grounded-Q&A mix.
1479
+
1480
+ The training package uses a redacted Viva Mais dashboard Q&A replay set,
1481
+ grounding-negative rows, and filtered Rio 3.1 teacher-distilled rows. The v4
1482
+ variant intentionally excludes the generic WhatsApp-style bucket and uses a
1483
+ stricter final-answer-only teacher prompt. Teacher rows are filtered to reject
1484
+ verbose wrappers and hidden-reasoning artifacts. No raw WhatsApp exports, client
1485
+ identifiers, identity documents, or local private data are published.
1486
+ """,
1487
+ encoding="utf-8",
1488
+ )
1489
+ api = HfApi(token=token)
1490
+ api.create_repo(repo_id=repo_id, repo_type="model", exist_ok=True)
1491
+ api.upload_folder(
1492
+ folder_path=str(source),
1493
+ repo_id=repo_id,
1494
+ repo_type="model",
1495
+ commit_message=f"Publish MiniCPM5 Viva Mais text {TEXT_VARIANT}",
1496
+ )
1497
+ return {"repo": repo_id, "url": f"https://huggingface.co/{repo_id}"}
1498
+
1499
+
1500
+ @app.function(gpu="A100", image=image, volumes={"/data": data_volume}, timeout=6 * 3600)
1501
+ def export_and_publish_gguf(
1502
+ *,
1503
+ source_model: str = V3_MODEL_REPO,
1504
+ repo_id: str = V3_GGUF_REPO,
1505
+ quant: str = "q4_k_m",
1506
+ context_length: int = DEFAULT_CONTEXT_LENGTH,
1507
+ hf_token: str,
1508
+ ) -> dict[str, Any]:
1509
+ import shutil
1510
+
1511
+ from huggingface_hub import HfApi
1512
+ from unsloth import FastLanguageModel
1513
+
1514
+ token = hf_token.strip()
1515
+ if not token:
1516
+ raise RuntimeError(f"HF token is required to publish {TEXT_VARIANT} GGUF")
1517
+ output_dir = Path(GGUF_DIR)
1518
+ gguf_output_dir = Path(f"{GGUF_DIR}_gguf")
1519
+ if output_dir.exists():
1520
+ shutil.rmtree(output_dir)
1521
+ if gguf_output_dir.exists():
1522
+ shutil.rmtree(gguf_output_dir)
1523
+ output_dir.mkdir(parents=True, exist_ok=True)
1524
+ model, tokenizer = FastLanguageModel.from_pretrained(
1525
+ model_name=source_model,
1526
+ max_seq_length=context_length,
1527
+ dtype=None,
1528
+ load_in_4bit=False,
1529
+ )
1530
+ FastLanguageModel.for_inference(model)
1531
+ model.save_pretrained_gguf(str(output_dir), tokenizer, quantization_method=quant)
1532
+ gguf_files = sorted(gguf_output_dir.glob("*.gguf"))
1533
+ if len(gguf_files) != 1:
1534
+ found = ", ".join(path.name for path in gguf_files) or "none"
1535
+ raise RuntimeError(f"Expected one GGUF file in {gguf_output_dir}, found {found}")
1536
+ target_name = f"{source_model.split('/')[-1]}-{quant.upper()}.gguf"
1537
+ target_path = gguf_output_dir / target_name
1538
+ if gguf_files[0] != target_path:
1539
+ gguf_files[0].rename(target_path)
1540
+ (gguf_output_dir / "README.md").write_text(
1541
+ f"""---
1542
+ language:
1543
+ - pt
1544
+ license: apache-2.0
1545
+ base_model: {source_model}
1546
+ library_name: gguf
1547
+ tags:
1548
+ - gguf
1549
+ - llama-cpp
1550
+ - minicpm5
1551
+ - vivamais
1552
+ - brazilian-portuguese
1553
+ ---
1554
+
1555
+ # {repo_id.split("/")[-1]}
1556
+
1557
+ GGUF export of `{source_model}` for llama.cpp-compatible Viva Mais inference.
1558
+
1559
+ - Quantization: `{quant.upper()}`
1560
+ - File: `{target_name}`
1561
+ """,
1562
+ encoding="utf-8",
1563
+ )
1564
+ data_volume.commit()
1565
+ api = HfApi(token=token)
1566
+ api.create_repo(repo_id=repo_id, repo_type="model", exist_ok=True)
1567
+ api.upload_folder(
1568
+ folder_path=str(gguf_output_dir),
1569
+ repo_id=repo_id,
1570
+ repo_type="model",
1571
+ commit_message=(
1572
+ f"Publish {quant.upper()} GGUF for MiniCPM5 Viva Mais {TEXT_VARIANT}"
1573
+ ),
1574
+ )
1575
+ return {
1576
+ "repo": repo_id,
1577
+ "url": f"https://huggingface.co/{repo_id}",
1578
+ "file": target_name,
1579
+ "size_bytes": target_path.stat().st_size,
1580
+ }
1581
+
1582
+
1583
+ @app.local_entrypoint()
1584
+ def main(
1585
+ *,
1586
+ step: str = "candidate",
1587
+ run_name: str = DEFAULT_RUN_NAME,
1588
+ teacher_model: str = RIO_TEACHER_MODEL,
1589
+ teacher_rows: int = DEFAULT_TEACHER_ROWS,
1590
+ teacher_gigaverbo_seed_limit: int = DEFAULT_TEACHER_SEED_LIMIT,
1591
+ teacher_max_new_tokens: int = 180,
1592
+ total_limit: int = DEFAULT_V3_ROWS,
1593
+ public_weight: float = DEFAULT_PUBLIC_WEIGHT,
1594
+ distill_weight: float = DEFAULT_DISTILL_WEIGHT,
1595
+ domain_weight: float = DEFAULT_DOMAIN_WEIGHT,
1596
+ travel_weight: float = 0.0,
1597
+ negative_weight: float = DEFAULT_NEGATIVE_WEIGHT,
1598
+ generic_weight: float = DEFAULT_GENERIC_WEIGHT,
1599
+ learning_rate: float = DEFAULT_LEARNING_RATE,
1600
+ num_epochs: float = DEFAULT_EPOCHS,
1601
+ baseline_eval_model: str = V1_MODEL,
1602
+ candidate_eval_model: str = "",
1603
+ details_run_names: str = "",
1604
+ publish: bool = False,
1605
+ publish_gguf: bool = False,
1606
+ ) -> None:
1607
+ if step in {"publish", "gguf"}:
1608
+ raise RuntimeError(
1609
+ "Direct publish steps are disabled. Run --step eval with --publish and/or "
1610
+ "--publish-gguf so the v1 comparison gates run in the same invocation."
1611
+ )
1612
+ if step in {"candidate", "distill"}:
1613
+ stats = distill_teacher_rows.remote(
1614
+ teacher_model=teacher_model,
1615
+ target_rows=teacher_rows,
1616
+ gigaverbo_seed_limit=teacher_gigaverbo_seed_limit,
1617
+ max_new_tokens=teacher_max_new_tokens,
1618
+ )
1619
+ print(f"Teacher distill stats: {stats}")
1620
+ if step in {"candidate", "prepare"}:
1621
+ stats = prepare_v3_dataset.remote(
1622
+ total_limit=total_limit,
1623
+ public_weight=public_weight,
1624
+ distill_weight=distill_weight,
1625
+ domain_weight=domain_weight,
1626
+ travel_weight=travel_weight,
1627
+ negative_weight=negative_weight,
1628
+ generic_weight=generic_weight,
1629
+ )
1630
+ print(f"Dataset stats: {stats}")
1631
+ if step in {"candidate", "train"}:
1632
+ lora = train_sft.remote(learning_rate=learning_rate, num_epochs=num_epochs)
1633
+ print(f"LoRA saved to {lora}")
1634
+ if step in {"candidate", "merge"}:
1635
+ merged = merge_lora.remote()
1636
+ print(f"Merged model saved to {merged}")
1637
+ if step == "inspect-evals":
1638
+ summary = inspect_eval_suites.remote()
1639
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
1640
+ if step == "check-evals":
1641
+ result = run_eval_unit_subset.remote()
1642
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1643
+ if step in {"candidate", "eval"}:
1644
+ selected_candidate = candidate_eval_model or MERGED_DIR
1645
+ baseline_vivamais = eval_vivamais.remote(
1646
+ model_id=baseline_eval_model,
1647
+ run_name=f"{run_name}_baseline_vivamais",
1648
+ )
1649
+ candidate_vivamais = eval_vivamais.remote(
1650
+ model_id=selected_candidate,
1651
+ run_name=f"{run_name}_candidate_vivamais",
1652
+ )
1653
+ baseline_generic = eval_generic_ptbr.remote(
1654
+ model_id=baseline_eval_model,
1655
+ run_name=f"{run_name}_baseline_generic",
1656
+ )
1657
+ candidate_generic = eval_generic_ptbr.remote(
1658
+ model_id=selected_candidate,
1659
+ run_name=f"{run_name}_candidate_generic",
1660
+ )
1661
+ decision = _candidate_passes(
1662
+ baseline_vivamais=baseline_vivamais,
1663
+ candidate_vivamais=candidate_vivamais,
1664
+ baseline_generic=baseline_generic,
1665
+ candidate_generic=candidate_generic,
1666
+ )
1667
+ print(
1668
+ json.dumps(
1669
+ {
1670
+ "accepted": decision,
1671
+ "baseline_vivamais": baseline_vivamais,
1672
+ "candidate_vivamais": candidate_vivamais,
1673
+ "baseline_generic": baseline_generic,
1674
+ "candidate_generic": candidate_generic,
1675
+ },
1676
+ indent=2,
1677
+ ensure_ascii=False,
1678
+ )
1679
+ )
1680
+ if not decision and (publish or publish_gguf):
1681
+ raise RuntimeError("Candidate did not pass v3 publish gates")
1682
+ if step == "details":
1683
+ run_names = [part.strip() for part in details_run_names.split(",") if part.strip()]
1684
+ if not run_names:
1685
+ raise RuntimeError("--details-run-names is required for --step details")
1686
+ show_eval_details.remote(run_names=run_names)
1687
+ if step == "publish" or publish:
1688
+ published = publish_model.remote(
1689
+ repo_id=V3_MODEL_REPO,
1690
+ source_dir=MERGED_DIR,
1691
+ hf_token=os.environ.get("HF_TOKEN", ""),
1692
+ )
1693
+ print(f"Published model: {published}")
1694
+ if step == "gguf" or publish_gguf:
1695
+ published_gguf = export_and_publish_gguf.remote(
1696
+ source_model=V3_MODEL_REPO,
1697
+ repo_id=V3_GGUF_REPO,
1698
+ hf_token=os.environ.get("HF_TOKEN", ""),
1699
+ )
1700
+ print(f"Published GGUF: {published_gguf}")
modal/restore_vivamais_v3_dataset_volume.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Restore Viva Mais MiniCPM5 text v3 data volume from the HF dataset package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import modal
12
+
13
+ APP_NAME = "vivamais-v3-dataset-volume-restore"
14
+ DATA_VOLUME_NAME = "minicpm5-vivamais-v3-rio-data"
15
+ DATASET_REPO = "marinarosa/minicpm5-vivamais-text-sft-v3"
16
+
17
+ RESTORE_MAP = {
18
+ "data/train.jsonl": Path("/data/minicpm5_v3_rio_sft.jsonl"),
19
+ "data/eval/vivamais_qa_eval.jsonl": Path("/data/vivamais_qa_eval.jsonl"),
20
+ "data/teacher/rio31_teacher_distill.jsonl": Path("/data/teacher_distill_v3_rio.jsonl"),
21
+ }
22
+
23
+ app = modal.App(APP_NAME)
24
+ data_volume = modal.Volume.from_name(DATA_VOLUME_NAME, create_if_missing=False)
25
+
26
+ image = modal.Image.debian_slim(python_version="3.11").pip_install("huggingface_hub")
27
+
28
+
29
+ def _count_jsonl(path: Path) -> int:
30
+ with path.open(encoding="utf-8") as handle:
31
+ return sum(1 for line in handle if line.strip())
32
+
33
+
34
+ @app.function(
35
+ image=image,
36
+ volumes={"/data": data_volume},
37
+ timeout=30 * 60,
38
+ cpu=2,
39
+ memory=4096,
40
+ )
41
+ def restore_dataset(repo_id: str, hf_token: str) -> dict[str, Any]:
42
+ from huggingface_hub import hf_hub_download
43
+
44
+ token = hf_token.strip()
45
+ if not token:
46
+ raise RuntimeError("HF_TOKEN is required to restore the v3 data volume")
47
+
48
+ restored: dict[str, Any] = {}
49
+ for source_path, target_path in RESTORE_MAP.items():
50
+ downloaded = Path(
51
+ hf_hub_download(
52
+ repo_id=repo_id,
53
+ filename=source_path,
54
+ repo_type="dataset",
55
+ token=token,
56
+ )
57
+ )
58
+ target_path.parent.mkdir(parents=True, exist_ok=True)
59
+ shutil.copy2(downloaded, target_path)
60
+ restored[source_path] = {
61
+ "target": str(target_path),
62
+ "rows": _count_jsonl(target_path),
63
+ "bytes": target_path.stat().st_size,
64
+ }
65
+ data_volume.commit()
66
+ print(json.dumps(restored, ensure_ascii=False, indent=2))
67
+ return restored
68
+
69
+
70
+ @app.local_entrypoint()
71
+ def main(repo_id: str = DATASET_REPO) -> None:
72
+ result = restore_dataset.remote(
73
+ repo_id=repo_id,
74
+ hf_token=os.environ.get("HF_TOKEN", ""),
75
+ )
76
+ print(json.dumps(result, ensure_ascii=False, indent=2))