nextmarte commited on
Commit
ab4e510
·
verified ·
1 Parent(s): 97a2de0

Modal-only inference: app calls deployed case-forge-serve (v3); drop GPU libs; CPU Space

Browse files
Files changed (6) hide show
  1. app.py +1 -0
  2. core/infer.py +35 -98
  3. data/schema.py +12 -12
  4. pipeline/prompts.py +66 -7
  5. requirements.txt +6 -18
  6. shared/i18n.py +4 -0
app.py CHANGED
@@ -270,6 +270,7 @@ def _chips_html(flags: dict, lang: str, demo: bool) -> str:
270
  chips.append('<span class="cf-chip ' + cls + '" style="animation-delay:' + delay
271
  + '">' + mark + ' ' + labels[key] + '</span>')
272
  head = '<div class="cf-chips">' + "".join(chips) + '</div>'
 
273
  if demo:
274
  head += '<div class="cf-demo">' + i18n.t("cf.demo_note", lang) + '</div>'
275
  return head
 
270
  chips.append('<span class="cf-chip ' + cls + '" style="animation-delay:' + delay
271
  + '">' + mark + ' ' + labels[key] + '</span>')
272
  head = '<div class="cf-chips">' + "".join(chips) + '</div>'
273
+ head += '<div class="cf-demo">⚠ ' + i18n.t("cf.disclaimer", lang) + '</div>'
274
  if demo:
275
  head += '<div class="cf-demo">' + i18n.t("cf.demo_note", lang) + '</div>'
276
  return head
core/infer.py CHANGED
@@ -1,16 +1,15 @@
1
  """Inference for the fine-tuned student — short request → full case+note JSON.
2
 
3
- Runtime = ZeroGPU in the Space: load Qwen3-4B-Instruct-2507 once and stack the
4
- published LoRA adapter (the fine-tune is what makes the short prompt expand into
5
- the whole schema). The actual `.generate` is wrapped with `@gpu` so ZeroGPU
6
- allocates a GPU per call; locally (no `spaces`, no CUDA) it falls back to a real
7
- sample case from the corpus so the UI is fully testable without weights.
8
 
9
  Config (env):
10
- CASE_FORGE_BASE base model id (default Qwen/Qwen3-4B-Instruct-2507)
11
- CASE_FORGE_ADAPTER HF repo id of the published LoRA (empty → base-only)
12
- CASE_FORGE_DEMO=1 force the demo sample (no model load)
13
- CASE_FORGE_MAX_TOKENS generation cap (default 4096)
14
  """
15
 
16
  from __future__ import annotations
@@ -27,79 +26,25 @@ for _p in (str(_ROOT), str(_MONOREPO)):
27
  sys.path.insert(0, _p)
28
 
29
  from data.schema import validate_case # noqa: E402
30
- from pipeline.prompts import Seed, build_minimal_prompt # noqa: E402
31
- from shared import gpu # noqa: E402
32
-
33
- BASE_MODEL = os.environ.get("CASE_FORGE_BASE", "Qwen/Qwen3-4B-Instruct-2507")
34
- # Published Case Forge LoRA (Well-Tuned). Override via env if needed.
35
- ADAPTER_REPO = os.environ.get(
36
- "CASE_FORGE_ADAPTER", "build-small-hackathon/case-forge-qwen3-4b").strip()
37
- MAX_NEW_TOKENS = int(os.environ.get("CASE_FORGE_MAX_TOKENS", "4096"))
38
  FORCE_DEMO = os.environ.get("CASE_FORGE_DEMO", "").strip() in ("1", "true", "yes")
39
 
40
- _MODEL = None
41
- _TOK = None
42
 
43
 
44
- def _has_cuda() -> bool:
45
- try:
46
- import torch
47
- return torch.cuda.is_available()
48
- except Exception:
49
- return False
50
-
51
-
52
- def _ensure_model() -> None:
53
- """Lazy-load base + LoRA. Called inside the GPU-allocated context."""
54
- global _MODEL, _TOK
55
- if _MODEL is not None:
56
- return
57
- import torch
58
- from transformers import AutoModelForCausalLM, AutoTokenizer
59
-
60
- tok = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)
61
- model = AutoModelForCausalLM.from_pretrained(
62
- BASE_MODEL, torch_dtype=torch.bfloat16, device_map="cuda",
63
- trust_remote_code=True,
64
- )
65
- if ADAPTER_REPO:
66
- from peft import PeftModel
67
- model = PeftModel.from_pretrained(model, ADAPTER_REPO)
68
- model.eval()
69
- _MODEL, _TOK = model, tok
70
 
71
 
72
- @gpu.gpu(duration=120)
73
- def _generate_raw(messages: list[dict]) -> str:
74
- """Run the model on ZeroGPU and return the raw decoded completion."""
75
- import torch
76
-
77
- _ensure_model()
78
- try:
79
- text = _TOK.apply_chat_template(
80
- messages, tokenize=False, add_generation_prompt=True,
81
- enable_thinking=False,
82
- )
83
- except TypeError: # base without the enable_thinking kwarg
84
- text = _TOK.apply_chat_template(
85
- messages, tokenize=False, add_generation_prompt=True,
86
- )
87
- inputs = _TOK(text, return_tensors="pt").to(_MODEL.device)
88
- with torch.no_grad():
89
- out = _MODEL.generate(
90
- **inputs, max_new_tokens=MAX_NEW_TOKENS,
91
- do_sample=True, temperature=0.7, top_p=0.95,
92
- pad_token_id=_TOK.pad_token_id or _TOK.eos_token_id,
93
- )
94
- return _TOK.decode(out[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
95
-
96
-
97
- def _parse(raw: str) -> dict | None:
98
- """Pull the JSON object out of a completion (tolerant of stray prose)."""
99
- try:
100
- return json.loads(raw[raw.find("{"): raw.rfind("}") + 1])
101
- except Exception:
102
- return None
103
 
104
 
105
  # --- demo fallback -------------------------------------------------------
@@ -108,16 +53,12 @@ _DEMO_BANK: dict[str, dict] | None = None
108
 
109
 
110
  def _demo_bank() -> dict[str, dict]:
111
- """One valid sample case per language, pulled from the local corpus.
112
-
113
- Lets the whole UI (render, quality badges, export) work with real data when
114
- no GPU/model is available — for local smoke tests and screenshots.
115
- """
116
  global _DEMO_BANK
117
  if _DEMO_BANK is not None:
118
  return _DEMO_BANK
119
  bank: dict[str, dict] = {}
120
- # demo_infer.json is a single PT sample produced by the real model.
121
  demo = _ROOT / "data" / "synthetic" / "demo_infer.json"
122
  if demo.exists():
123
  try:
@@ -126,8 +67,7 @@ def _demo_bank() -> dict[str, dict]:
126
  bank[obj.get("language", "pt")] = obj
127
  except Exception:
128
  pass
129
- # Fill any missing language from the training corpus.
130
- for name in ("pairs_v2.jsonl", "pairs.jsonl"):
131
  if len(bank) >= 2:
132
  break
133
  path = _ROOT / "data" / "synthetic" / name
@@ -158,10 +98,9 @@ def _demo_result(seed: Seed) -> dict:
158
 
159
  def generate(domain: str, topic: str, level: str = "MBA",
160
  language: str = "pt", theory: str = "") -> dict:
161
- """Forge one case+note from a short request.
162
 
163
- Returns {obj, valid, errors, warnings, raw, demo}. `obj` is the schema dict
164
- (or None if parsing failed). `demo` is True when the sample fallback was used.
165
  """
166
  seed = Seed(
167
  domain=(domain or "administração").strip(),
@@ -171,20 +110,18 @@ def generate(domain: str, topic: str, level: str = "MBA",
171
  theory=[t.strip() for t in (theory or "").split(",") if t.strip()],
172
  )
173
 
174
- if FORCE_DEMO or not (gpu._HAS_SPACES or _has_cuda()):
175
  return _demo_result(seed)
176
 
177
  try:
178
- raw = _generate_raw(build_minimal_prompt(seed))
179
- except Exception as exc: # model/GPU failure → don't crash the UI
180
- res = _demo_result(seed)
181
- res["errors"] = [f"falha na geração: {exc}"] + res["errors"]
182
- return res
183
-
184
- obj = _parse(raw)
185
- ok, errs, warns = validate_case(obj) if obj else (False, ["parse falhou"], [])
186
- return {"obj": obj, "valid": ok, "errors": errs, "warnings": warns,
187
- "raw": raw, "demo": False}
188
 
189
 
190
  __all__ = ["generate", "Seed"]
 
1
  """Inference for the fine-tuned student — short request → full case+note JSON.
2
 
3
+ Runtime = **Modal** (no ZeroGPU for this project). The Gradio app calls a deployed
4
+ warm Modal class (`case-forge-serve` / `CaseForge`, see `pipeline/serve_modal.py`)
5
+ that holds base Qwen3-4B + the `qwen3-4b-v3` LoRA. Modal creds come from the Space
6
+ secrets (MODAL_TOKEN_ID / MODAL_TOKEN_SECRET). With no creds / on failure it falls
7
+ back to a real sample case so the UI is fully testable offline.
8
 
9
  Config (env):
10
+ CASE_FORGE_MODAL_APP deployed Modal app name (default case-forge-serve)
11
+ CASE_FORGE_MODAL_CLS class name (default CaseForge)
12
+ CASE_FORGE_DEMO=1 force the demo sample (no Modal call)
 
13
  """
14
 
15
  from __future__ import annotations
 
26
  sys.path.insert(0, _p)
27
 
28
  from data.schema import validate_case # noqa: E402
29
+ from pipeline.prompts import Seed # noqa: E402
30
+
31
+ MODAL_APP = os.environ.get("CASE_FORGE_MODAL_APP", "case-forge-serve")
32
+ MODAL_CLS = os.environ.get("CASE_FORGE_MODAL_CLS", "CaseForge")
 
 
 
 
33
  FORCE_DEMO = os.environ.get("CASE_FORGE_DEMO", "").strip() in ("1", "true", "yes")
34
 
35
+ _CLS = None # cached Modal class handle
 
36
 
37
 
38
+ def _modal_ready() -> bool:
39
+ return bool(os.environ.get("MODAL_TOKEN_ID") and os.environ.get("MODAL_TOKEN_SECRET"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
 
42
+ def _cls():
43
+ global _CLS
44
+ if _CLS is None:
45
+ import modal
46
+ _CLS = modal.Cls.from_name(MODAL_APP, MODAL_CLS)
47
+ return _CLS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
 
50
  # --- demo fallback -------------------------------------------------------
 
53
 
54
 
55
  def _demo_bank() -> dict[str, dict]:
56
+ """One valid sample case per language from the local corpus — lets the whole
57
+ UI (render, badges, export) work without Modal (offline/screenshots)."""
 
 
 
58
  global _DEMO_BANK
59
  if _DEMO_BANK is not None:
60
  return _DEMO_BANK
61
  bank: dict[str, dict] = {}
 
62
  demo = _ROOT / "data" / "synthetic" / "demo_infer.json"
63
  if demo.exists():
64
  try:
 
67
  bank[obj.get("language", "pt")] = obj
68
  except Exception:
69
  pass
70
+ for name in ("pairs_v3.jsonl", "pairs_v2.jsonl", "pairs.jsonl"):
 
71
  if len(bank) >= 2:
72
  break
73
  path = _ROOT / "data" / "synthetic" / name
 
98
 
99
  def generate(domain: str, topic: str, level: str = "MBA",
100
  language: str = "pt", theory: str = "") -> dict:
101
+ """Forge one case+note from a short request, via the Modal server.
102
 
103
+ Returns {obj, valid, errors, warnings, raw, demo}.
 
104
  """
105
  seed = Seed(
106
  domain=(domain or "administração").strip(),
 
110
  theory=[t.strip() for t in (theory or "").split(",") if t.strip()],
111
  )
112
 
113
+ if FORCE_DEMO or not _modal_ready():
114
  return _demo_result(seed)
115
 
116
  try:
117
+ res = _cls()().generate.remote(seed.__dict__)
118
+ except Exception as exc: # Modal unreachable / cold-start failure → don't crash UI
119
+ out = _demo_result(seed)
120
+ out["errors"] = [f"falha na geração (Modal): {exc}"] + out["errors"]
121
+ return out
122
+
123
+ res["demo"] = False
124
+ return res
 
 
125
 
126
 
127
  __all__ = ["generate", "Seed"]
data/schema.py CHANGED
@@ -195,28 +195,28 @@ def validate_case(obj: dict) -> tuple[bool, list[str], list[str]]:
195
  "idealmente cada objetivo é coberto por ≥1 questão."
196
  )
197
 
198
- # 2d) Dados com lastro de fonte (soft) — gênero pede evidência sustentada.
 
 
199
  data = case.get("data") or []
200
- if data and not any(_looks_sourced(d) for d in data):
201
  warnings.append(
202
- "nenhum item de dados parece citar fonte; no gênero, "
203
- "informação é evidência e deve ser sustentada."
204
  )
205
 
206
  return (len(errors) == 0, errors, warnings)
207
 
208
 
209
- def _looks_sourced(text: str) -> bool:
210
- """Heurística leve: o dado parece citar uma fonte (ano, '(Fonte', '%', etc.)."""
211
  import re
212
 
213
  t = text.lower()
214
- return bool(
215
- re.search(r"\b(19|20)\d{2}\b", t) # um ano
216
- or "fonte" in t or "source" in t
217
- or "segundo" in t or "according to" in t
218
- or "%" in t
219
- )
220
 
221
 
222
  __all__ = ["CASE_SCHEMA", "validate_case"]
 
195
  "idealmente cada objetivo é coberto por ≥1 questão."
196
  )
197
 
198
+ # 2d) Fontes FABRICADAS (soft) — o gênero NÃO deve inventar citações com
199
+ # nome de relatório/instituto/ano. Sinalizamos pra o auditor limpar. (Antes
200
+ # premiávamos "dado com fonte", o que incentivava o modelo a fabricar — invertido.)
201
  data = case.get("data") or []
202
+ if any(_looks_fabricated_source(d) for d in data):
203
  warnings.append(
204
+ "algum dado parece citar uma FONTE FABRICADA (ex.: 'Fonte: Relatório …, 2023'); "
205
+ "números são ilustrativos remova citações inventadas."
206
  )
207
 
208
  return (len(errors) == 0, errors, warnings)
209
 
210
 
211
+ def _looks_fabricated_source(text: str) -> bool:
212
+ """Heurística: o dado cita uma 'fonte' com cara de inventada (rótulo + ano)."""
213
  import re
214
 
215
  t = text.lower()
216
+ has_cite = ("fonte" in t or "source" in t or "relatório" in t or "report" in t
217
+ or "pesquisa de mercado" in t or "según" in t)
218
+ has_year = bool(re.search(r"\b(19|20)\d{2}\b", t))
219
+ return has_cite and has_year
 
 
220
 
221
 
222
  __all__ = ["CASE_SCHEMA", "validate_case"]
pipeline/prompts.py CHANGED
@@ -42,7 +42,7 @@ Responda APENAS com um objeto JSON, sem markdown, sem comentários, exatamente n
42
  "protagonist": "<o decisor que o leitor veste>",
43
  "decision_point":"<a pergunta concreta que o protagonista PRECISA decidir>",
44
  "context": "<organização, setor e atores envolvidos>",
45
- "data": ["<fato objetivo com fonte/ano/número>", "... (>=3)"],
46
  "exhibits": [{"title": "<anexo opcional>", "content": "<tabela/quadro>"}],
47
  "alternatives": ["<argumento p/ um caminho>", "<argumento p/ outro>", "... (>=2)"],
48
  "closing": "<revisita o dilema NO ponto de decisão — NUNCA revela a escolha>",
@@ -53,7 +53,7 @@ Responda APENAS com um objeto JSON, sem markdown, sem comentários, exatamente n
53
  "audience": "<curso, nível e pré-requisitos>",
54
  "managerial_relevance": "<por que importa pra gestão>",
55
  "learning_objectives": ["<objetivo mensurável (verbo de Bloom)>", "... (1 a 4, NUNCA mais de 4)"],
56
- "data_sources": "<de onde vêm os dados do caso>",
57
  "theoretical_anchor": ["<teoria/conceito a mobilizar>"],
58
  "discussion_plan": [{"block": "<bloco>", "minutes": 10, "activity": "<atividade>"}],
59
  "discussion_questions": ["<questão alinhada aos objetivos>", "... (>= nº de objetivos)"],
@@ -71,11 +71,29 @@ Regras invioláveis:
71
  O desfecho (se houver) vai SÓ no "epilogue" da nota de ensino.
72
  2. NO MÁXIMO 4 objetivos de aprendizagem, cada um mensurável e começando por verbo de Bloom
73
  (analisar, avaliar, comparar, calcular, propor...).
74
- 3. Cada item de "data" é uma EVIDÊNCIA: traga número, ano ou fonte. Nada de afirmação solta.
75
- 4. As "discussion_questions" cobrem os objetivos pelo menos uma por objetivo.
76
- 5. O caso é FICÇÃO PLAUSÍVEL e ORIGINAL: invente empresa, pessoas e números coerentes.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  Não copie nenhuma organização real específica.
78
- 6. Escreva todo o conteúdo em {lang_name}. Tom narrativo, concreto, sem moralizar."""
79
 
80
 
81
  _SEED_EXTRACTION = """\
@@ -164,5 +182,46 @@ Idioma do conteúdo: {lang_name}{theory_line}{style_line}
164
  ]
165
 
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  __all__ = ["Seed", "build_generation_prompt", "build_minimal_prompt",
168
- "build_seed_extraction_prompt"]
 
42
  "protagonist": "<o decisor que o leitor veste>",
43
  "decision_point":"<a pergunta concreta que o protagonista PRECISA decidir>",
44
  "context": "<organização, setor e atores envolvidos>",
45
+ "data": ["<fato quantitativo ilustrativo, em unidade consistente, SEM citação fabricada>", "... (>=3)"],
46
  "exhibits": [{"title": "<anexo opcional>", "content": "<tabela/quadro>"}],
47
  "alternatives": ["<argumento p/ um caminho>", "<argumento p/ outro>", "... (>=2)"],
48
  "closing": "<revisita o dilema NO ponto de decisão — NUNCA revela a escolha>",
 
53
  "audience": "<curso, nível e pré-requisitos>",
54
  "managerial_relevance": "<por que importa pra gestão>",
55
  "learning_objectives": ["<objetivo mensurável (verbo de Bloom)>", "... (1 a 4, NUNCA mais de 4)"],
56
+ "data_sources": "<declare que os números são ILUSTRATIVOS/FICTÍCIOS para fins de ensino>",
57
  "theoretical_anchor": ["<teoria/conceito a mobilizar>"],
58
  "discussion_plan": [{"block": "<bloco>", "minutes": 10, "activity": "<atividade>"}],
59
  "discussion_questions": ["<questão alinhada aos objetivos>", "... (>= nº de objetivos)"],
 
71
  O desfecho (se houver) vai SÓ no "epilogue" da nota de ensino.
72
  2. NO MÁXIMO 4 objetivos de aprendizagem, cada um mensurável e começando por verbo de Bloom
73
  (analisar, avaliar, comparar, calcular, propor...).
74
+ 3. NÚMEROS ILUSTRATIVOS, MAS INTERNAMENTE CONSISTENTES (esta é a regra mais importante):
75
+ - Use SEMPRE as mesmas unidades no caso todo: mensal vs anual, por-unidade vs por-período,
76
+ a mesma moeda. Se um custo é "por voo/por mês", a receita comparável também é "por voo/por
77
+ mês" — ou forneça a capacidade/volume para converter. Nunca misture mês e ano para o mesmo item.
78
+ - TODA CONTA TEM QUE FECHAR. Recalcule antes de escrever: somas, percentuais, margens,
79
+ crescimento, ratios, payback. Ex.: só existe "payback" se o investimento GERA economia ou
80
+ receita extra — não calcule payback sobre algo que AUMENTA o custo mensal.
81
+ - Os números do exhibit/tabela TÊM QUE BATER com os do texto. Nada de três valores diferentes
82
+ para o mesmo aumento/indicador.
83
+ - Magnitudes plausíveis para o porte (uma rede de 12 lojas não gasta R$15 mi/ano em mídia digital).
84
+ 4. NÃO INVENTE FONTES NEM CITAÇÕES. Proibido "(Fonte: Relatório Interno, 2023)", "Pesquisa de
85
+ Mercado, 2021", nomes de institutos/órgãos como autoridade. O dado é ilustrativo: se indicar
86
+ origem, use rótulo genérico e honesto ("dados internos", "estimativa da equipe"), sem
87
+ relatório/ano/instituição fabricados. Em "data_sources", afirme que os números são
88
+ ILUSTRATIVOS/FICTÍCIOS para fins de ensino.
89
+ 5. O PROTAGONISTA (com nome) aparece JÁ no "hook"/"context" — nunca surge só no fechamento.
90
+ Use nomes variados e plausíveis (evite repetir sobrenomes).
91
+ 6. A "analysis" da nota, se mostrar contas, usa EXATAMENTE os números do caso e os resolve
92
+ CORRETAMENTE. Confira a aritmética — o professor vai ensinar isso como gabarito.
93
+ 7. As "discussion_questions" cobrem os objetivos — pelo menos uma por objetivo.
94
+ 8. O caso é FICÇÃO PLAUSÍVEL e ORIGINAL: invente empresa, pessoas e números coerentes.
95
  Não copie nenhuma organização real específica.
96
+ 9. Escreva todo o conteúdo em {lang_name}. Tom narrativo, concreto, sem moralizar."""
97
 
98
 
99
  _SEED_EXTRACTION = """\
 
182
  ]
183
 
184
 
185
+ _AUDIT_SYSTEM = (
186
+ "Você é um editor-auditor meticuloso de casos de ensino. Sua obsessão é "
187
+ "CONSISTÊNCIA NUMÉRICA. Você devolve SEMPRE o objeto JSON inteiro corrigido, "
188
+ "no mesmo formato recebido, sem comentários."
189
+ )
190
+
191
+ _AUDIT_CHECKLIST = """\
192
+ Audite e CORRIJA o caso+nota abaixo. Reescreva o JSON INTEIRO já corrigido (mesmas chaves).
193
+ Não invente conteúdo novo desnecessário — apenas conserte os defeitos:
194
+
195
+ 1. UNIDADES: todo número numa unidade coerente. Se um item é "por mês", não trate o mesmo
196
+ valor como anual em outro lugar. Custo "por voo/unidade" só se compara a receita
197
+ "por voo/unidade" (ou inclua a capacidade/volume p/ converter). Conserte mismatches.
198
+ 2. ARITMÉTICA: recalcule somas, %, margens, crescimento, ratios e PAYBACK. Payback só existe
199
+ se o investimento gera economia ou receita extra — se os números não sustentam, conserte
200
+ os números OU reescreva a conta corretamente. A "analysis" da nota deve bater com os dados.
201
+ 3. EXHIBIT × TEXTO: os números da tabela têm que ser idênticos aos do texto. Um único valor
202
+ por indicador. Elimine linhas idênticas/padding.
203
+ 4. MAGNITUDE: valores plausíveis para o porte descrito. Conserte exageros (ex.: orçamento
204
+ gigante para empresa pequena).
205
+ 5. FONTES: REMOVA toda citação fabricada ("Fonte: Relatório X, 2023", institutos, anos de
206
+ relatório inventados). Deixe o dado ilustrativo; em "data_sources" diga que os números são
207
+ ilustrativos/fictícios para ensino.
208
+ 6. PROTAGONISTA: o nome aparece no "hook"/"context", não só no fechamento.
209
+ 7. NÃO revele a decisão no "closing" (o desfecho fica no "epilogue" da nota).
210
+
211
+ JSON a auditar:
212
+ """
213
+
214
+
215
+ def build_audit_prompt(pair_obj: dict) -> list[dict]:
216
+ """Mensagens para o professor AUDITAR e reparar um par caso+nota já gerado —
217
+ foco em consistência numérica e remoção de fontes fabricadas. Retorna JSON inteiro."""
218
+ import json as _json
219
+ payload = _json.dumps(pair_obj, ensure_ascii=False)
220
+ return [
221
+ {"role": "system", "content": _AUDIT_SYSTEM},
222
+ {"role": "user", "content": _AUDIT_CHECKLIST + payload},
223
+ ]
224
+
225
+
226
  __all__ = ["Seed", "build_generation_prompt", "build_minimal_prompt",
227
+ "build_seed_extraction_prompt", "build_audit_prompt"]
requirements.txt CHANGED
@@ -1,22 +1,10 @@
1
- # Case Forge — HF Space runtime (Gradio app on ZeroGPU)
2
  #
3
- # The app loads Qwen3-4B-Instruct-2507 + the published Case Forge LoRA adapter
4
- # in-Space via ZeroGPU. The data pipeline (collect/generate/train) runs on Modal
5
- # and only needs the extras flagged at the bottom.
 
6
 
7
- # --- app runtime ---
8
  gradio>=6
9
- spaces # ZeroGPU (@spaces.GPU) no-op locally
10
- transformers>=4.49
11
- peft>=0.13 # stack the LoRA adapter onto the base
12
- accelerate>=1
13
- torch
14
  jsonschema>=4 # full structural validation of the output contract
15
-
16
- # --- data pipeline only (collect → generate → train; runs on Modal) ---
17
- # modal
18
- # requests
19
- # beautifulsoup4
20
- # pypdf
21
- # datasets
22
- # bitsandbytes
 
1
+ # Case Forge — HF Space runtime (Gradio app).
2
  #
3
+ # Inference runs on MODAL (no ZeroGPU for this project): the app calls a deployed
4
+ # warm Modal class (case-forge-serve / CaseForge, see pipeline/serve_modal.py),
5
+ # so the Space itself needs NO GPU libs (torch/transformers/peft) it just renders
6
+ # and calls Modal. Set MODAL_TOKEN_ID / MODAL_TOKEN_SECRET as Space secrets.
7
 
 
8
  gradio>=6
9
+ modal # calls the deployed Modal inference server
 
 
 
 
10
  jsonschema>=4 # full structural validation of the output contract
 
 
 
 
 
 
 
 
shared/i18n.py CHANGED
@@ -103,6 +103,10 @@ _T = {
103
  "en": "Showing a sample case (model not loaded in this environment).",
104
  "pt": "Exibindo um caso de amostra (modelo não carregado neste ambiente).",
105
  },
 
 
 
 
106
  }
107
 
108
 
 
103
  "en": "Showing a sample case (model not loaded in this environment).",
104
  "pt": "Exibindo um caso de amostra (modelo não carregado neste ambiente).",
105
  },
106
+ "cf.disclaimer": {
107
+ "en": "Figures are illustrative/fictional — verify them before classroom use.",
108
+ "pt": "Números são ilustrativos/fictícios — confira antes de usar em aula.",
109
+ },
110
  }
111
 
112