final: sync completed submission

#3
by Dracufeuer - opened
MODEL_SELECTION.md CHANGED
@@ -106,6 +106,11 @@ Primary metrics:
106
  ## Implementation Notes
107
 
108
  - Keep temperature at `0.0-0.2` for explanation generation.
 
 
 
 
 
109
  - Cap explanation context initially at `SFT_MAX_LENGTH=4096`; our RAG pipeline
110
  should retrieve better evidence, not dump the whole database into the prompt.
111
  - Enable `assistant_only_loss=True` only after verifying the selected model's chat
 
106
  ## Implementation Notes
107
 
108
  - Keep temperature at `0.0-0.2` for explanation generation.
109
+ - Thinking mode support is profile-gated. MiniCPM4.1 Balanced supports hybrid
110
+ reasoning via `enable_thinking` and uses `768` recommended max tokens when
111
+ enabled. The selected Qwen3 `*-Instruct-2507` Tiny and Quality profiles are
112
+ documented non-thinking variants, so the app disables Thinking for them rather
113
+ than sending ineffective `/think` hints.
114
  - Cap explanation context initially at `SFT_MAX_LENGTH=4096`; our RAG pipeline
115
  should retrieve better evidence, not dump the whole database into the prompt.
116
  - Enable `assistant_only_loss=True` only after verifying the selected model's chat
PLAN.md CHANGED
@@ -21,7 +21,7 @@
21
  - Storage: raw API JSONL in `data/raw`, normalized Parquet in `data/parquet`, DuckDB at `data/dota2tuned.duckdb`, generated RAG docs in `data/rag`.
22
  - Tables: `dim_patch`, `dim_hero`, `dim_item`, `dim_ability`, `dim_league`, `fact_match`, `fact_player_match`, `fact_draft_pickban`, `fact_item_purchase`, `fact_hero_pair_stats`, `fact_hero_build_stats`, `fact_hero_skill_builds`, `doc_patch_change`, `doc_stratz_match`, `doc_stat_card`, `ingest_run`, `api_call_log`.
23
  - Data clients: Steam discovery/raw facts, OpenDota normalized stats, STRATZ rich enrichment, Valve patch JSON feed, dotaconstants constants.
24
- - Modal backend: deployed `ui`, `remote_smoke`, `train_sft`, and `generate_answer` functions. The HF Space remains available; Modal provides GPU training, adapter inference, and a verified alternate Gradio endpoint.
25
 
26
  ## Interfaces
27
 
@@ -39,7 +39,7 @@
39
  - Recommendations: rank heroes by predicted win-probability delta plus empirical counter/synergy lift; builds come from observed item/skill/talent timing stats, not LLM invention.
40
  - RAG: index patch-change docs, hero stat cards, item constants, item timing stats, skill-build stats, and STRATZ match docs with patch/scope filters before retrieval.
41
  - Fine-tuning: default Tiny profile `Qwen/Qwen3-4B-Instruct-2507`; Balanced profile `openbmb/MiniCPM4.1-8B`; Quality profile `Qwen/Qwen3-30B-A3B-Instruct-2507`. Use explicit 4-bit QLoRA with TRL SFT on Modal `A100-80GB`, 1 epoch, structured answer examples, then push adapters to Hub.
42
- - Serving: HF Gradio Space loads compact predictor/RAG artifacts; Modal `ui` is a verified alternate Gradio endpoint, Modal `train_sft` handles GPU fine-tuning, and Modal `generate_answer` serves the fine-tuned adapter for explicit LLM responses.
43
 
44
  ## Current Execution Process
45
 
@@ -57,7 +57,7 @@
57
  - `uv run python -c "from app import demo; print(type(demo).__name__, len(demo.blocks), len(demo.fns))"`
58
  5. Deploy Modal functions.
59
  - `uv run dota2tuned modal-deploy`
60
- - Expected functions: `ui`, `remote_smoke`, `train_sft`, `generate_answer`.
61
  - Current Modal URL: `https://dracufeuer--dota2tuned-ui.modal.run`
62
  6. Verify Modal runtime artifacts.
63
  - `uv run dota2tuned modal-smoke`
@@ -77,6 +77,9 @@
77
  - Expected files: `adapter_model.safetensors`, `adapter_config.json`, tokenizer files, `training_args.bin`.
78
  8. Verify fine-tuned adapter inference.
79
  - `uv run dota2tuned modal-ask "Suggest one mid hero against Phantom Assassin and Witch Doctor. Include one caveat."`
 
 
 
80
  - Target behavior: concise grounded answer; caveat weak evidence; no invented heroes/items.
81
  9. Configure HF Space runtime variables/secrets for Modal-backed inference.
82
  - Variables: `MODAL_ENABLED=1`, `MODAL_APP_NAME=dota2tuned`.
@@ -212,6 +215,9 @@ All times are `America/Los_Angeles` / PDT unless noted.
212
  - 2026-06-15 16:39: Quality H200 progressed past memory and target-module setup, then failed because PEFT's `ParamWrapper` path rejects nonzero LoRA dropout for this model. Set Quality `lora_dropout=0.0` and prepared another retry.
213
  - 2026-06-15 16:45: Quality Qwen3 30B-A3B retry `fc-01KV6TCTX9CSYVHJ98S3P5PTMK` completed successfully and pushed to `build-small-hackathon/dota2tuned-qwen3-30b-a3b-2507-lora`.
214
  - 2026-06-15 16:47: added Draft-style `New here? How to use ...` guide cards to Ask, Meta, Builds, Predictor, and Data; kept Draft's existing guide; and documented the Build Small README validator/social-post requirement in `README.md` and `SUBMISSION.md`.
 
 
 
215
 
216
  ## Adapter Eval Notes
217
 
 
21
  - Storage: raw API JSONL in `data/raw`, normalized Parquet in `data/parquet`, DuckDB at `data/dota2tuned.duckdb`, generated RAG docs in `data/rag`.
22
  - Tables: `dim_patch`, `dim_hero`, `dim_item`, `dim_ability`, `dim_league`, `fact_match`, `fact_player_match`, `fact_draft_pickban`, `fact_item_purchase`, `fact_hero_pair_stats`, `fact_hero_build_stats`, `fact_hero_skill_builds`, `doc_patch_change`, `doc_stratz_match`, `doc_stat_card`, `ingest_run`, `api_call_log`.
23
  - Data clients: Steam discovery/raw facts, OpenDota normalized stats, STRATZ rich enrichment, Valve patch JSON feed, dotaconstants constants.
24
+ - Modal backend: deployed `ui`, `remote_smoke`, `train_sft`, `train_sft_quality`, `generate_answer`, and `generate_answer_quality` functions. The HF Space remains available; Modal provides GPU training, profile-routed adapter inference, and a verified alternate Gradio endpoint.
25
 
26
  ## Interfaces
27
 
 
39
  - Recommendations: rank heroes by predicted win-probability delta plus empirical counter/synergy lift; builds come from observed item/skill/talent timing stats, not LLM invention.
40
  - RAG: index patch-change docs, hero stat cards, item constants, item timing stats, skill-build stats, and STRATZ match docs with patch/scope filters before retrieval.
41
  - Fine-tuning: default Tiny profile `Qwen/Qwen3-4B-Instruct-2507`; Balanced profile `openbmb/MiniCPM4.1-8B`; Quality profile `Qwen/Qwen3-30B-A3B-Instruct-2507`. Use explicit 4-bit QLoRA with TRL SFT on Modal `A100-80GB`, 1 epoch, structured answer examples, then push adapters to Hub.
42
+ - Serving: HF Gradio Space loads compact predictor/RAG artifacts; Modal `ui` is a verified alternate Gradio endpoint, Modal `train_sft` handles Tiny/Balanced GPU fine-tuning, Modal `train_sft_quality` handles H200 Quality fine-tuning, and `generate_answer`/`generate_answer_quality` serve profile-routed adapter responses.
43
 
44
  ## Current Execution Process
45
 
 
57
  - `uv run python -c "from app import demo; print(type(demo).__name__, len(demo.blocks), len(demo.fns))"`
58
  5. Deploy Modal functions.
59
  - `uv run dota2tuned modal-deploy`
60
+ - Expected functions: `ui`, `remote_smoke`, `train_sft`, `train_sft_quality`, `generate_answer`, `generate_answer_quality`.
61
  - Current Modal URL: `https://dracufeuer--dota2tuned-ui.modal.run`
62
  6. Verify Modal runtime artifacts.
63
  - `uv run dota2tuned modal-smoke`
 
77
  - Expected files: `adapter_model.safetensors`, `adapter_config.json`, tokenizer files, `training_args.bin`.
78
  8. Verify fine-tuned adapter inference.
79
  - `uv run dota2tuned modal-ask "Suggest one mid hero against Phantom Assassin and Witch Doctor. Include one caveat."`
80
+ - Quality profile routes to the H200-backed `generate_answer_quality` function.
81
+ - Balanced MiniCPM uses OpenBMB non-reasoning prompt mode and falls back to a grounded evidence response if the adapter returns malformed punctuation/empty text.
82
+ - Ask exposes a Thinking toggle and Max Tokens slider. Current support matrix: Balanced MiniCPM4.1 supports hybrid thinking and recommends `768` tokens; Tiny and Quality Qwen3 Instruct-2507 profiles are non-thinking variants, so the toggle is disabled for them.
83
  - Target behavior: concise grounded answer; caveat weak evidence; no invented heroes/items.
84
  9. Configure HF Space runtime variables/secrets for Modal-backed inference.
85
  - Variables: `MODAL_ENABLED=1`, `MODAL_APP_NAME=dota2tuned`.
 
215
  - 2026-06-15 16:39: Quality H200 progressed past memory and target-module setup, then failed because PEFT's `ParamWrapper` path rejects nonzero LoRA dropout for this model. Set Quality `lora_dropout=0.0` and prepared another retry.
216
  - 2026-06-15 16:45: Quality Qwen3 30B-A3B retry `fc-01KV6TCTX9CSYVHJ98S3P5PTMK` completed successfully and pushed to `build-small-hackathon/dota2tuned-qwen3-30b-a3b-2507-lora`.
217
  - 2026-06-15 16:47: added Draft-style `New here? How to use ...` guide cards to Ask, Meta, Builds, Predictor, and Data; kept Draft's existing guide; and documented the Build Small README validator/social-post requirement in `README.md` and `SUBMISSION.md`.
218
+ - 2026-06-15 17:05: fixed adapter inference routing after live smoke showed Balanced MiniCPM returning malformed punctuation and Quality loading through the wrong A100-backed endpoint. Added profile-aware Modal inference selection, a dedicated H200 `generate_answer_quality` function, MiniCPM non-reasoning prompt mode, and a grounded malformed-output fallback for Balanced.
219
+ - 2026-06-15 17:33: redeployed Modal and verified `modal-ask --profile minicpm4_1_8b` returns a grounded answer with `fallback=balanced_malformed_generation` instead of blank/garbled text; verified `modal-ask --profile qwen3_30b_a3b_2507` returns a real adapter answer from `build-small-hackathon/dota2tuned-qwen3-30b-a3b-2507-lora` through the H200 `generate_answer_quality` function.
220
+ - 2026-06-15 17:52: researched reasoning support for the active bases. `openbmb/MiniCPM4.1-8B` supports hybrid thinking/non-thinking via `enable_thinking`; `Qwen/Qwen3-4B-Instruct-2507` and `Qwen/Qwen3-30B-A3B-Instruct-2507` are documented non-thinking Instruct variants. Added Ask-side Thinking and Max Tokens controls, enabling Thinking only for Balanced and auto-recommending `768` tokens when toggled on.
221
 
222
  ## Adapter Eval Notes
223
 
README.md CHANGED
@@ -32,7 +32,7 @@ The implementation is designed around a simple rule: stats and predictors choose
32
  - Balanced fine-tuned adapter: https://huggingface.co/build-small-hackathon/dota2tuned-minicpm4-1-8b-lora
33
  - Quality fine-tuned adapter: https://huggingface.co/build-small-hackathon/dota2tuned-qwen3-30b-a3b-2507-lora
34
  - Dataset artifacts: https://huggingface.co/datasets/build-small-hackathon/dota2tuned-data
35
- - Demo video: TODO add final demo video URL before validation.
36
  - Social post: TODO add final social post URL before validation.
37
 
38
  ## Hackathon Validation
@@ -93,11 +93,11 @@ The submitted Space includes compact serving artifacts under `data/parquet`, `da
93
  - `dota2tuned modal-smoke` validates the deployed Modal app can load artifacts.
94
  - `dota2tuned modal-train --profile qwen3_4b_2507` submits the Tiny Modal GPU QLoRA run. Other profiles include `minicpm4_1_8b` and `qwen3_30b_a3b_2507`; Quality routes to the H200-backed `train_sft_quality` function.
95
  - `uv run python scripts/watch_modal_training.py --call tiny=fc-... --interval 300` polls detached Modal training calls without blocking local development.
96
- - `dota2tuned modal-ask` calls the fine-tuned adapter through Modal GPU inference.
97
  - `dota2tuned finetune --launch-job` remains available for Hugging Face Jobs if a token has `job.write`.
98
  - `dota2tuned serve` launches the Gradio app.
99
 
100
- The Gradio app includes searchable hero, item, role, scope, and ability dropdowns, alias-aware hero lookup (`PA`, `CM`, `AM`, `QOP`, `KOTL`, and curated initials), selected icon previews, and six sidebar modules: Ask, Draft, Meta, Builds, Predictor, and Data. Ask calls the Modal `generate_answer` function when Modal credentials are configured in the runtime environment; otherwise it degrades with a clear unavailable message.
101
 
102
  See [PLAN.md](PLAN.md) for the full architecture and delivery plan.
103
  See [MODEL_SELECTION.md](MODEL_SELECTION.md) for the current LLM decision and eval protocol.
 
32
  - Balanced fine-tuned adapter: https://huggingface.co/build-small-hackathon/dota2tuned-minicpm4-1-8b-lora
33
  - Quality fine-tuned adapter: https://huggingface.co/build-small-hackathon/dota2tuned-qwen3-30b-a3b-2507-lora
34
  - Dataset artifacts: https://huggingface.co/datasets/build-small-hackathon/dota2tuned-data
35
+ - Demo video: https://drive.google.com/file/d/12l0sKN-rJJ3RwKDEE-TGbXfiRMepN_zz/view?usp=drive_link
36
  - Social post: TODO add final social post URL before validation.
37
 
38
  ## Hackathon Validation
 
93
  - `dota2tuned modal-smoke` validates the deployed Modal app can load artifacts.
94
  - `dota2tuned modal-train --profile qwen3_4b_2507` submits the Tiny Modal GPU QLoRA run. Other profiles include `minicpm4_1_8b` and `qwen3_30b_a3b_2507`; Quality routes to the H200-backed `train_sft_quality` function.
95
  - `uv run python scripts/watch_modal_training.py --call tiny=fc-... --interval 300` polls detached Modal training calls without blocking local development.
96
+ - `dota2tuned modal-ask` calls the fine-tuned adapter through Modal GPU inference. Quality routes to H200-backed `generate_answer_quality`; Tiny and Balanced use `generate_answer`. Use `--thinking` only with the Balanced MiniCPM profile; Tiny and Quality use Qwen3 Instruct-2507 non-thinking bases, so the app disables the toggle for them.
97
  - `dota2tuned finetune --launch-job` remains available for Hugging Face Jobs if a token has `job.write`.
98
  - `dota2tuned serve` launches the Gradio app.
99
 
100
+ The Gradio app includes searchable hero, item, role, scope, and ability dropdowns, alias-aware hero lookup (`PA`, `CM`, `AM`, `QOP`, `KOTL`, and curated initials), selected icon previews, and six sidebar modules: Ask, Draft, Meta, Builds, Predictor, and Data. Ask calls the profile-routed Modal inference function when Modal credentials are configured in the runtime environment; otherwise it degrades with a clear unavailable message. Ask also exposes a Thinking toggle and Max Tokens slider; Thinking is enabled only for MiniCPM4.1 Balanced and recommends `768` tokens.
101
 
102
  See [PLAN.md](PLAN.md) for the full architecture and delivery plan.
103
  See [MODEL_SELECTION.md](MODEL_SELECTION.md) for the current LLM decision and eval protocol.
SUBMISSION.md CHANGED
@@ -12,7 +12,7 @@ Last checked: 2026-06-15 16:17 PDT.
12
  - Dataset artifacts: https://huggingface.co/datasets/build-small-hackathon/dota2tuned-data
13
  - Modal alternate UI: https://dracufeuer--dota2tuned-ui.modal.run
14
  - README validator: https://build-small-hackathon-field-guide.hf.space/submit
15
- - Demo video: TODO add final demo video URL.
16
  - Social post: TODO add final social post URL and mirror it in `README.md`.
17
 
18
  ## Hackathon Fit
@@ -45,7 +45,7 @@ Do not claim the Nemotron, Off the Grid, Llama Champion, or Sharing is Caring ba
45
  - [x] Repository license is declared as Apache-2.0.
46
  - [x] GitHub `main` is pushed and clean.
47
  - [x] README lists selected tracks, sponsor prizes, and eligible badges for the validator.
48
- - [ ] Record short demo video.
49
  - [ ] Publish social post and replace the README `Social post` TODO with its URL.
50
  - [ ] Submit Space link, demo video link, and social post link by June 15, 2026.
51
 
 
12
  - Dataset artifacts: https://huggingface.co/datasets/build-small-hackathon/dota2tuned-data
13
  - Modal alternate UI: https://dracufeuer--dota2tuned-ui.modal.run
14
  - README validator: https://build-small-hackathon-field-guide.hf.space/submit
15
+ - Demo video: https://drive.google.com/file/d/12l0sKN-rJJ3RwKDEE-TGbXfiRMepN_zz/view?usp=drive_link
16
  - Social post: TODO add final social post URL and mirror it in `README.md`.
17
 
18
  ## Hackathon Fit
 
45
  - [x] Repository license is declared as Apache-2.0.
46
  - [x] GitHub `main` is pushed and clean.
47
  - [x] README lists selected tracks, sponsor prizes, and eligible badges for the validator.
48
+ - [x] Record short demo video.
49
  - [ ] Publish social post and replace the README `Social post` TODO with its URL.
50
  - [ ] Submit Space link, demo video link, and social post link by June 15, 2026.
51
 
src/dota2tuned/cli.py CHANGED
@@ -19,6 +19,7 @@ from dota2tuned.finetune import (
19
  write_train_script,
20
  )
21
  from dota2tuned.ingest import IngestCoordinator
 
22
  from dota2tuned.model_profiles import resolve_model_profile
23
  from dota2tuned.normalize import SCHEMAS, build_hero_build_stats, normalize_all
24
  from dota2tuned.rag import build_index
@@ -405,6 +406,10 @@ def modal_ask(
405
  str | None,
406
  typer.Option(help="Model profile key, e.g. qwen3_30b_a3b_2507."),
407
  ] = None,
 
 
 
 
408
  ) -> None:
409
  settings = get_settings()
410
  _require_modal(settings)
@@ -414,7 +419,20 @@ def modal_ask(
414
  typer.echo("Install Modal dependencies with `uv sync --extra modal`.", err=True)
415
  raise typer.Exit(1) from exc
416
 
417
- generate_fn = modal.Function.from_name(settings.modal_app_name, "generate_answer")
 
 
 
 
418
  typer.echo(
419
- json.dumps(generate_fn.remote(question, context, max_new_tokens, profile), indent=2)
 
 
 
 
 
 
 
 
 
420
  )
 
19
  write_train_script,
20
  )
21
  from dota2tuned.ingest import IngestCoordinator
22
+ from dota2tuned.modal_backend import modal_infer_function_name
23
  from dota2tuned.model_profiles import resolve_model_profile
24
  from dota2tuned.normalize import SCHEMAS, build_hero_build_stats, normalize_all
25
  from dota2tuned.rag import build_index
 
406
  str | None,
407
  typer.Option(help="Model profile key, e.g. qwen3_30b_a3b_2507."),
408
  ] = None,
409
+ thinking: Annotated[
410
+ bool,
411
+ typer.Option(help="Request reasoning mode when the selected profile supports it."),
412
+ ] = False,
413
  ) -> None:
414
  settings = get_settings()
415
  _require_modal(settings)
 
419
  typer.echo("Install Modal dependencies with `uv sync --extra modal`.", err=True)
420
  raise typer.Exit(1) from exc
421
 
422
+ selected_profile = resolve_model_profile(profile or settings.model_profile)
423
+ generate_fn = modal.Function.from_name(
424
+ settings.modal_app_name,
425
+ modal_infer_function_name(selected_profile.key),
426
+ )
427
  typer.echo(
428
+ json.dumps(
429
+ generate_fn.remote(
430
+ question,
431
+ context,
432
+ max_new_tokens,
433
+ selected_profile.key,
434
+ thinking,
435
+ ),
436
+ indent=2,
437
+ )
438
  )
src/dota2tuned/modal_backend.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import os
 
4
  import subprocess
5
  import sys
6
  from pathlib import Path
@@ -20,6 +21,13 @@ REMOTE_ROOT = Path("/app")
20
  REMOTE_DATA = REMOTE_ROOT / "data"
21
  REMOTE_CACHE = Path("/cache")
22
  REMOTE_OUTPUTS = Path("/outputs")
 
 
 
 
 
 
 
23
 
24
  CORE_DEPS = [
25
  "duckdb>=1.5.3",
@@ -88,6 +96,117 @@ def _runtime_secret_dict() -> dict[str, str]:
88
  return {key: value for key, value in values.items() if value}
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def _add_project_files(image: modal.Image) -> modal.Image:
92
  image = image.add_local_dir(
93
  PROJECT_ROOT / "src" / "dota2tuned",
@@ -114,7 +233,7 @@ if modal is not None and settings.modal_enabled:
114
  modal.Image.debian_slim(python_version="3.12").uv_pip_install(*TRAIN_DEPS).env(REMOTE_ENV)
115
  )
116
  _MODEL_CACHE: dict[str, object] = {}
117
- quality_train_profile = resolve_model_profile("qwen3_30b_a3b_2507")
118
 
119
  @app.function(image=web_image, secrets=[runtime_secret], timeout=900)
120
  def remote_smoke() -> dict[str, object]:
@@ -242,19 +361,12 @@ if modal is not None and settings.modal_enabled:
242
  ) -> dict[str, object]:
243
  return _run_train_sft(dataset_source, profile or quality_train_profile.key)
244
 
245
- @app.function(
246
- image=train_image,
247
- gpu=settings.modal_infer_gpu,
248
- secrets=[runtime_secret],
249
- volumes={REMOTE_CACHE: cache_volume},
250
- timeout=settings.modal_infer_timeout,
251
- max_containers=1,
252
- )
253
- def generate_answer(
254
  question: str,
255
  context: str = "",
256
  max_new_tokens: int = 384,
257
  profile: str | None = None,
 
258
  ) -> dict[str, object]:
259
  import torch
260
  from peft import AutoPeftModelForCausalLM
@@ -262,6 +374,8 @@ if modal is not None and settings.modal_enabled:
262
 
263
  from dota2tuned.model_profiles import resolve_model_profile
264
 
 
 
265
  try:
266
  from transformers.utils import import_utils
267
 
@@ -283,6 +397,7 @@ if modal is not None and settings.modal_enabled:
283
  raise RuntimeError("question is required.")
284
 
285
  selected = resolve_model_profile(profile)
 
286
  model_id = selected.hf_model_repo_id
287
  cache_key = f"{selected.key}:{model_id}"
288
  if cache_key not in _MODEL_CACHE:
@@ -319,37 +434,117 @@ if modal is not None and settings.modal_enabled:
319
  user = question.strip()
320
  if context.strip():
321
  user = f"{user}\n\nEvidence:\n{context.strip()}"
322
- messages = [
323
- {"role": "system", "content": system},
324
- {"role": "user", "content": user},
325
- ]
 
 
 
 
 
 
 
 
 
326
  try:
327
- prompt = tokenizer.apply_chat_template(
328
- messages,
329
- tokenize=False,
330
- add_generation_prompt=True,
331
- )
 
 
332
  except Exception:
333
  prompt = f"{system}\n\nUser: {user}\nAssistant:"
 
334
  inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  with torch.no_grad():
336
  outputs = model.generate(
337
  **inputs,
338
- max_new_tokens=max(32, min(max_new_tokens, 768)),
339
- do_sample=False,
340
- use_cache=False,
341
- pad_token_id=tokenizer.pad_token_id,
342
- eos_token_id=tokenizer.eos_token_id,
343
  )
344
  generated = outputs[0][inputs["input_ids"].shape[-1] :]
345
- answer = tokenizer.decode(generated, skip_special_tokens=True).strip()
346
- return {
 
 
 
 
 
347
  "status": "ok",
348
  "profile": selected.key,
349
  "model": model_id,
350
  "answer": answer,
351
  "tokens": int(generated.numel()),
 
 
 
 
 
 
 
352
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  else:
354
  app = None
355
 
 
1
  from __future__ import annotations
2
 
3
  import os
4
+ import re
5
  import subprocess
6
  import sys
7
  from pathlib import Path
 
21
  REMOTE_DATA = REMOTE_ROOT / "data"
22
  REMOTE_CACHE = Path("/cache")
23
  REMOTE_OUTPUTS = Path("/outputs")
24
+ BALANCED_PROFILE_KEY = "minicpm4_1_8b"
25
+ QUALITY_PROFILE_KEY = "qwen3_30b_a3b_2507"
26
+ QUALITY_INFER_FUNCTION = "generate_answer_quality"
27
+ DEFAULT_INFER_FUNCTION = "generate_answer"
28
+ THINKING_TOKEN_LIMIT = 1536
29
+ NON_THINKING_TOKEN_LIMIT = 768
30
+ THINK_RE = re.compile(r"<think>.*?</think>", flags=re.DOTALL | re.IGNORECASE)
31
 
32
  CORE_DEPS = [
33
  "duckdb>=1.5.3",
 
96
  return {key: value for key, value in values.items() if value}
97
 
98
 
99
+ def modal_infer_function_name(profile: str | None = None) -> str:
100
+ selected = resolve_model_profile(profile)
101
+ if selected.key == QUALITY_PROFILE_KEY:
102
+ return QUALITY_INFER_FUNCTION
103
+ return DEFAULT_INFER_FUNCTION
104
+
105
+
106
+ def _looks_malformed_answer(answer: str) -> bool:
107
+ text = " ".join(str(answer or "").split())
108
+ if not text:
109
+ return True
110
+ if len(text) < 12:
111
+ return True
112
+ if text.count("\\") >= 6 or text.count("�") >= 2:
113
+ return True
114
+ nonspace = [char for char in text if not char.isspace()]
115
+ if not nonspace:
116
+ return True
117
+ alpha_numeric = sum(char.isalnum() for char in nonspace)
118
+ if alpha_numeric / len(nonspace) < 0.25:
119
+ return True
120
+ stripped_words = [
121
+ word.strip(".,;:!?*#`\"'()[]{}")
122
+ for word in text.split()
123
+ ]
124
+ word_like = [
125
+ word
126
+ for word in stripped_words
127
+ if len(word) >= 3 and any(char.isalpha() for char in word)
128
+ ]
129
+ normalized_words = [word.lower() for word in word_like]
130
+ if len(normalized_words) >= 8:
131
+ counts = {
132
+ word: normalized_words.count(word)
133
+ for word in set(normalized_words)
134
+ }
135
+ stems = [word[:5] for word in normalized_words]
136
+ stem_counts = {stem: stems.count(stem) for stem in set(stems)}
137
+ if max(counts.values()) / len(normalized_words) > 0.28:
138
+ return True
139
+ if max(stem_counts.values()) / len(stems) > 0.45:
140
+ return True
141
+ if len(text) < 120 and len(word_like) < 5:
142
+ return True
143
+ marker_count = text.count("##") + text.count("**") + text.count("\\")
144
+ if marker_count >= 3 and len(word_like) < 8:
145
+ return True
146
+ return len(set(nonspace)) <= 4 and len(nonspace) >= 24
147
+
148
+
149
+ def _evidence_fallback_answer(question: str, context: str) -> str:
150
+ evidence_lines = [
151
+ " ".join(line.split())
152
+ for line in str(context or "").splitlines()
153
+ if line.strip()
154
+ ]
155
+ evidence = " ".join(evidence_lines)[:900].strip()
156
+ if not evidence:
157
+ evidence = "No retrieved evidence was supplied for this question."
158
+ return (
159
+ "Based on the retrieved evidence, use the most directly supported draft or "
160
+ "build choice rather than inventing outside context.\n\n"
161
+ f"Question: {question.strip()}\n\n"
162
+ f"Evidence summary: {evidence}\n\n"
163
+ "Recommendation: prefer the hero, item, or draft choice with the strongest "
164
+ "role fit and current-patch evidence in the retrieved context. Caveat: treat "
165
+ "this as evidence-grounded guidance, not a guaranteed match outcome."
166
+ )
167
+
168
+
169
+ def _strip_reasoning_blocks(answer: str) -> str:
170
+ text = str(answer or "")
171
+ text = THINK_RE.sub("", text)
172
+ if "</think>" in text:
173
+ text = text.rsplit("</think>", 1)[-1]
174
+ return text.strip()
175
+
176
+
177
+ def _install_peft_weight_converter_compat() -> bool:
178
+ try:
179
+ import inspect
180
+
181
+ from transformers.core_model_loading import WeightConverter
182
+ except Exception:
183
+ return False
184
+
185
+ if "distributed_operation" in inspect.signature(WeightConverter.__init__).parameters:
186
+ return False
187
+ if getattr(WeightConverter.__init__, "_dota2tuned_compat", False):
188
+ return True
189
+
190
+ original_init = WeightConverter.__init__
191
+
192
+ def _compat_init(
193
+ self,
194
+ source_patterns,
195
+ target_patterns,
196
+ operations,
197
+ distributed_operation=None,
198
+ quantization_operation=None,
199
+ **_kwargs,
200
+ ):
201
+ original_init(self, source_patterns, target_patterns, operations)
202
+ self.distributed_operation = distributed_operation
203
+ self.quantization_operation = quantization_operation
204
+
205
+ _compat_init._dota2tuned_compat = True
206
+ WeightConverter.__init__ = _compat_init
207
+ return True
208
+
209
+
210
  def _add_project_files(image: modal.Image) -> modal.Image:
211
  image = image.add_local_dir(
212
  PROJECT_ROOT / "src" / "dota2tuned",
 
233
  modal.Image.debian_slim(python_version="3.12").uv_pip_install(*TRAIN_DEPS).env(REMOTE_ENV)
234
  )
235
  _MODEL_CACHE: dict[str, object] = {}
236
+ quality_train_profile = resolve_model_profile(QUALITY_PROFILE_KEY)
237
 
238
  @app.function(image=web_image, secrets=[runtime_secret], timeout=900)
239
  def remote_smoke() -> dict[str, object]:
 
361
  ) -> dict[str, object]:
362
  return _run_train_sft(dataset_source, profile or quality_train_profile.key)
363
 
364
+ def _run_generate_answer(
 
 
 
 
 
 
 
 
365
  question: str,
366
  context: str = "",
367
  max_new_tokens: int = 384,
368
  profile: str | None = None,
369
+ thinking: bool = False,
370
  ) -> dict[str, object]:
371
  import torch
372
  from peft import AutoPeftModelForCausalLM
 
374
 
375
  from dota2tuned.model_profiles import resolve_model_profile
376
 
377
+ _install_peft_weight_converter_compat()
378
+
379
  try:
380
  from transformers.utils import import_utils
381
 
 
397
  raise RuntimeError("question is required.")
398
 
399
  selected = resolve_model_profile(profile)
400
+ thinking_enabled = bool(thinking) and bool(selected.supports_thinking)
401
  model_id = selected.hf_model_repo_id
402
  cache_key = f"{selected.key}:{model_id}"
403
  if cache_key not in _MODEL_CACHE:
 
434
  user = question.strip()
435
  if context.strip():
436
  user = f"{user}\n\nEvidence:\n{context.strip()}"
437
+ if selected.key == BALANCED_PROFILE_KEY:
438
+ thinking_tag = "/think" if thinking_enabled else "/no_think"
439
+ messages = [
440
+ {
441
+ "role": "user",
442
+ "content": f"{system}\n\n{user}\n\n{thinking_tag}",
443
+ }
444
+ ]
445
+ else:
446
+ messages = [
447
+ {"role": "system", "content": system},
448
+ {"role": "user", "content": user},
449
+ ]
450
  try:
451
+ template_kwargs = {
452
+ "tokenize": False,
453
+ "add_generation_prompt": True,
454
+ }
455
+ if selected.key == BALANCED_PROFILE_KEY:
456
+ template_kwargs["enable_thinking"] = thinking_enabled
457
+ prompt = tokenizer.apply_chat_template(messages, **template_kwargs)
458
  except Exception:
459
  prompt = f"{system}\n\nUser: {user}\nAssistant:"
460
+ token_limit = THINKING_TOKEN_LIMIT if thinking_enabled else NON_THINKING_TOKEN_LIMIT
461
  inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
462
+ generation_kwargs = {
463
+ "max_new_tokens": max(32, min(max_new_tokens, token_limit)),
464
+ "use_cache": False,
465
+ "pad_token_id": tokenizer.pad_token_id,
466
+ "eos_token_id": tokenizer.eos_token_id,
467
+ }
468
+ if selected.key == BALANCED_PROFILE_KEY:
469
+ generation_kwargs.update(
470
+ {
471
+ "do_sample": True,
472
+ "temperature": 0.6,
473
+ "top_p": 0.95,
474
+ "repetition_penalty": 1.05,
475
+ }
476
+ )
477
+ else:
478
+ generation_kwargs["do_sample"] = False
479
  with torch.no_grad():
480
  outputs = model.generate(
481
  **inputs,
482
+ **generation_kwargs,
 
 
 
 
483
  )
484
  generated = outputs[0][inputs["input_ids"].shape[-1] :]
485
+ raw_answer = tokenizer.decode(generated, skip_special_tokens=True).strip()
486
+ answer = _strip_reasoning_blocks(raw_answer)
487
+ fallback_reason = None
488
+ if selected.key == BALANCED_PROFILE_KEY and _looks_malformed_answer(answer):
489
+ answer = _evidence_fallback_answer(question, context)
490
+ fallback_reason = "balanced_malformed_generation"
491
+ response = {
492
  "status": "ok",
493
  "profile": selected.key,
494
  "model": model_id,
495
  "answer": answer,
496
  "tokens": int(generated.numel()),
497
+ "thinking_requested": bool(thinking),
498
+ "thinking_enabled": thinking_enabled,
499
+ "recommended_max_tokens": (
500
+ selected.thinking_recommended_max_tokens
501
+ if selected.supports_thinking
502
+ else NON_THINKING_TOKEN_LIMIT
503
+ ),
504
  }
505
+ if fallback_reason:
506
+ response["fallback"] = fallback_reason
507
+ return response
508
+
509
+ @app.function(
510
+ image=train_image,
511
+ gpu=settings.modal_infer_gpu,
512
+ secrets=[runtime_secret],
513
+ volumes={REMOTE_CACHE: cache_volume},
514
+ timeout=settings.modal_infer_timeout,
515
+ max_containers=1,
516
+ )
517
+ def generate_answer(
518
+ question: str,
519
+ context: str = "",
520
+ max_new_tokens: int = 384,
521
+ profile: str | None = None,
522
+ thinking: bool = False,
523
+ ) -> dict[str, object]:
524
+ return _run_generate_answer(question, context, max_new_tokens, profile, thinking)
525
+
526
+ @app.function(
527
+ image=train_image,
528
+ gpu=quality_train_profile.modal_infer_gpu,
529
+ secrets=[runtime_secret],
530
+ volumes={REMOTE_CACHE: cache_volume},
531
+ timeout=quality_train_profile.modal_infer_timeout,
532
+ max_containers=1,
533
+ )
534
+ def generate_answer_quality(
535
+ question: str,
536
+ context: str = "",
537
+ max_new_tokens: int = 384,
538
+ profile: str | None = None,
539
+ thinking: bool = False,
540
+ ) -> dict[str, object]:
541
+ return _run_generate_answer(
542
+ question,
543
+ context,
544
+ max_new_tokens,
545
+ profile or quality_train_profile.key,
546
+ thinking,
547
+ )
548
  else:
549
  app = None
550
 
src/dota2tuned/model_profiles.py CHANGED
@@ -29,6 +29,8 @@ class ModelProfile:
29
  sft_grad_accum: int = 8
30
  model_load_in_4bit: bool = True
31
  model_torch_dtype: str = "bfloat16"
 
 
32
 
33
 
34
  DEFAULT_MODEL_PROFILE = "qwen3_4b_2507"
@@ -69,6 +71,8 @@ DEFAULT_MODEL_PROFILES: dict[str, ModelProfile] = {
69
  lora_alpha=16,
70
  sft_learning_rate=1.5e-4,
71
  sft_grad_accum=8,
 
 
72
  ),
73
  }
74
 
@@ -151,4 +155,13 @@ def apply_profile_env_overrides(profile: ModelProfile) -> ModelProfile:
151
  os.getenv("MODEL_LOAD_IN_4BIT"), profile.model_load_in_4bit
152
  ),
153
  model_torch_dtype=os.getenv("MODEL_TORCH_DTYPE", profile.model_torch_dtype),
 
 
 
 
 
 
 
 
 
154
  )
 
29
  sft_grad_accum: int = 8
30
  model_load_in_4bit: bool = True
31
  model_torch_dtype: str = "bfloat16"
32
+ supports_thinking: bool = False
33
+ thinking_recommended_max_tokens: int = 768
34
 
35
 
36
  DEFAULT_MODEL_PROFILE = "qwen3_4b_2507"
 
71
  lora_alpha=16,
72
  sft_learning_rate=1.5e-4,
73
  sft_grad_accum=8,
74
+ supports_thinking=True,
75
+ thinking_recommended_max_tokens=768,
76
  ),
77
  }
78
 
 
155
  os.getenv("MODEL_LOAD_IN_4BIT"), profile.model_load_in_4bit
156
  ),
157
  model_torch_dtype=os.getenv("MODEL_TORCH_DTYPE", profile.model_torch_dtype),
158
+ supports_thinking=_bool(
159
+ os.getenv("MODEL_SUPPORTS_THINKING"), profile.supports_thinking
160
+ ),
161
+ thinking_recommended_max_tokens=int(
162
+ os.getenv(
163
+ "THINKING_RECOMMENDED_MAX_TOKENS",
164
+ str(profile.thinking_recommended_max_tokens),
165
+ )
166
+ ),
167
  )
src/dota2tuned/ui/gradio_app.py CHANGED
@@ -10,7 +10,8 @@ import polars as pl
10
  from starlette.middleware import Middleware
11
 
12
  from dota2tuned.config import get_settings
13
- from dota2tuned.model_profiles import profile_choices
 
14
  from dota2tuned.normalize import ROLE_ALL
15
  from dota2tuned.rag import Retriever
16
  from dota2tuned.recommend import DraftRecommender
@@ -976,6 +977,19 @@ __NAV_ICON_CSS__
976
  font-size: 15px !important;
977
  line-height: 22px !important;
978
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
979
  .assistant-actions {
980
  align-items: center;
981
  }
@@ -4536,6 +4550,7 @@ def _call_tuned_model(
4536
  context: str,
4537
  max_new_tokens: int = 384,
4538
  profile: str | None = None,
 
4539
  ) -> str:
4540
  if not question.strip():
4541
  return "Enter a question."
@@ -4551,8 +4566,18 @@ def _call_tuned_model(
4551
  os.environ["MODAL_TOKEN_ID"] = settings.modal_token_id
4552
  os.environ["MODAL_TOKEN_SECRET"] = settings.modal_token_secret
4553
  try:
4554
- generate_fn = modal.Function.from_name(settings.modal_app_name, "generate_answer")
4555
- result = generate_fn.remote(question, context, max_new_tokens, profile)
 
 
 
 
 
 
 
 
 
 
4556
  except Exception as exc:
4557
  return f"Tuned model call failed: {str(exc)[:500]}"
4558
 
@@ -4562,7 +4587,14 @@ def _call_tuned_model(
4562
  model = result.get("model") or settings.hf_model_repo_id
4563
  profile_key = result.get("profile") or profile or settings.model_profile
4564
  tokens = result.get("tokens")
4565
- return f"{answer}\n\n`profile: {profile_key}` `model: {model}` `tokens: {tokens}`"
 
 
 
 
 
 
 
4566
 
4567
 
4568
  def build_app() -> gr.Blocks:
@@ -4684,10 +4716,72 @@ def build_app() -> gr.Blocks:
4684
  )
4685
  return _render_module_result_html(answer, docs, hero_metadata, item_metadata)
4686
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4687
  def tuned_model(
4688
- question: str, model_profile: str | None, source_filter: str
 
 
 
 
4689
  ) -> tuple[str, str]:
4690
  question = (question or "").strip()
 
 
 
4691
  docs = retriever.search(question or "current meta", patch="current", limit=6)
4692
  if source_filter and source_filter != "All sources":
4693
  docs = [
@@ -4698,7 +4792,14 @@ def build_app() -> gr.Blocks:
4698
  evidence = "\n\n".join(
4699
  f"{doc['source']} score={doc['score']}\n{doc['text']}" for doc in docs
4700
  )
4701
- answer = _call_tuned_model(settings, question, evidence, 384, model_profile)
 
 
 
 
 
 
 
4702
  html = _render_tuned_answer_html(answer, docs, hero_metadata, item_metadata)
4703
  return html, json.dumps(docs, indent=2)
4704
 
@@ -4927,6 +5028,26 @@ def build_app() -> gr.Blocks:
4927
  label="Sources",
4928
  elem_classes=["dota-dropdown"],
4929
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4930
  with gr.Row(elem_classes=["assistant-actions"]):
4931
  tuned_button = gr.Button("Ask DOTA2Tuned", variant="primary")
4932
  tuned_clear = gr.Button("Clear")
@@ -4944,19 +5065,51 @@ def build_app() -> gr.Blocks:
4944
  queue=False,
4945
  )
4946
  tuned_evidence = gr.Code(label="Evidence", language="json", value="[]")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4947
  tuned_button.click(
4948
  tuned_model,
4949
- inputs=[tuned_question, tuned_profile, tuned_source_filter],
 
 
 
 
 
 
4950
  outputs=[assistant_output, tuned_evidence],
4951
  )
4952
  tuned_question.submit(
4953
  tuned_model,
4954
- inputs=[tuned_question, tuned_profile, tuned_source_filter],
 
 
 
 
 
 
4955
  outputs=[assistant_output, tuned_evidence],
4956
  )
4957
  tuned_clear.click(
4958
- lambda: ("", _assistant_empty_state_html(), "[]"),
4959
- outputs=[tuned_question, assistant_output, tuned_evidence],
 
 
 
 
 
 
4960
  api_visibility="private",
4961
  queue=False,
4962
  )
 
10
  from starlette.middleware import Middleware
11
 
12
  from dota2tuned.config import get_settings
13
+ from dota2tuned.modal_backend import modal_infer_function_name
14
+ from dota2tuned.model_profiles import profile_choices, resolve_model_profile
15
  from dota2tuned.normalize import ROLE_ALL
16
  from dota2tuned.rag import Retriever
17
  from dota2tuned.recommend import DraftRecommender
 
977
  font-size: 15px !important;
978
  line-height: 22px !important;
979
  }
980
+ .assistant-thinking-note-wrap,
981
+ .assistant-thinking-note-wrap .block,
982
+ .assistant-thinking-note-wrap .prose {
983
+ border: 0 !important;
984
+ background: transparent !important;
985
+ box-shadow: none !important;
986
+ }
987
+ .assistant-thinking-note {
988
+ color: rgba(239, 229, 187, 0.86);
989
+ font-size: 11.5px;
990
+ line-height: 17px;
991
+ padding: 0 2px 2px;
992
+ }
993
  .assistant-actions {
994
  align-items: center;
995
  }
 
4550
  context: str,
4551
  max_new_tokens: int = 384,
4552
  profile: str | None = None,
4553
+ thinking: bool = False,
4554
  ) -> str:
4555
  if not question.strip():
4556
  return "Enter a question."
 
4566
  os.environ["MODAL_TOKEN_ID"] = settings.modal_token_id
4567
  os.environ["MODAL_TOKEN_SECRET"] = settings.modal_token_secret
4568
  try:
4569
+ selected_profile = resolve_model_profile(profile or settings.model_profile)
4570
+ generate_fn = modal.Function.from_name(
4571
+ settings.modal_app_name,
4572
+ modal_infer_function_name(selected_profile.key),
4573
+ )
4574
+ result = generate_fn.remote(
4575
+ question,
4576
+ context,
4577
+ max_new_tokens,
4578
+ selected_profile.key,
4579
+ thinking,
4580
+ )
4581
  except Exception as exc:
4582
  return f"Tuned model call failed: {str(exc)[:500]}"
4583
 
 
4587
  model = result.get("model") or settings.hf_model_repo_id
4588
  profile_key = result.get("profile") or profile or settings.model_profile
4589
  tokens = result.get("tokens")
4590
+ thinking_label = "on" if result.get("thinking_enabled") else "off"
4591
+ fallback = result.get("fallback")
4592
+ fallback_text = f" `fallback: {fallback}`" if fallback else ""
4593
+ return (
4594
+ f"{answer}\n\n"
4595
+ f"`profile: {profile_key}` `model: {model}` `tokens: {tokens}` "
4596
+ f"`thinking: {thinking_label}`{fallback_text}"
4597
+ )
4598
 
4599
 
4600
  def build_app() -> gr.Blocks:
 
4716
  )
4717
  return _render_module_result_html(answer, docs, hero_metadata, item_metadata)
4718
 
4719
+ def thinking_note_html(model_profile: str | None, thinking: bool = False) -> str:
4720
+ selected = resolve_model_profile(model_profile or settings.model_profile)
4721
+ if selected.supports_thinking:
4722
+ status = "enabled" if thinking else "available"
4723
+ return (
4724
+ "<div class='assistant-thinking-note d2-text-glitch'>"
4725
+ f"Thinking {status} for {selected.label}. "
4726
+ f"Recommended max tokens: {selected.thinking_recommended_max_tokens}."
4727
+ "</div>"
4728
+ )
4729
+ return (
4730
+ "<div class='assistant-thinking-note'>"
4731
+ f"Thinking is disabled for {selected.label}; this profile uses a "
4732
+ "non-thinking base model."
4733
+ "</div>"
4734
+ )
4735
+
4736
+ def thinking_controls(
4737
+ model_profile: str | None,
4738
+ thinking: bool | None = False,
4739
+ max_tokens: int | float | None = 384,
4740
+ ) -> tuple[dict, dict, str]:
4741
+ selected = resolve_model_profile(model_profile or settings.model_profile)
4742
+ if not selected.supports_thinking:
4743
+ return (
4744
+ gr.update(value=False, interactive=False),
4745
+ gr.update(value=384, maximum=768),
4746
+ thinking_note_html(selected.key, False),
4747
+ )
4748
+ enabled = bool(thinking)
4749
+ recommended = int(selected.thinking_recommended_max_tokens)
4750
+ current_tokens = int(max_tokens or 384)
4751
+ token_value = recommended if enabled else min(current_tokens, 768)
4752
+ return (
4753
+ gr.update(value=enabled, interactive=True),
4754
+ gr.update(value=token_value, maximum=1536),
4755
+ thinking_note_html(selected.key, enabled),
4756
+ )
4757
+
4758
+ def thinking_toggle_controls(
4759
+ model_profile: str | None,
4760
+ thinking: bool | None,
4761
+ max_tokens: int | float | None = 384,
4762
+ ) -> tuple[dict, str]:
4763
+ selected = resolve_model_profile(model_profile or settings.model_profile)
4764
+ if not selected.supports_thinking:
4765
+ return gr.update(value=384, maximum=768), thinking_note_html(selected.key, False)
4766
+ enabled = bool(thinking)
4767
+ token_value = (
4768
+ int(selected.thinking_recommended_max_tokens)
4769
+ if enabled
4770
+ else min(int(max_tokens or 384), 768)
4771
+ )
4772
+ return gr.update(value=token_value, maximum=1536), thinking_note_html(selected.key, enabled)
4773
+
4774
  def tuned_model(
4775
+ question: str,
4776
+ model_profile: str | None,
4777
+ source_filter: str,
4778
+ thinking: bool | None,
4779
+ max_tokens: int | float | None,
4780
  ) -> tuple[str, str]:
4781
  question = (question or "").strip()
4782
+ selected_profile = resolve_model_profile(model_profile or settings.model_profile)
4783
+ thinking_enabled = bool(thinking) and bool(selected_profile.supports_thinking)
4784
+ token_budget = int(max_tokens or 384)
4785
  docs = retriever.search(question or "current meta", patch="current", limit=6)
4786
  if source_filter and source_filter != "All sources":
4787
  docs = [
 
4792
  evidence = "\n\n".join(
4793
  f"{doc['source']} score={doc['score']}\n{doc['text']}" for doc in docs
4794
  )
4795
+ answer = _call_tuned_model(
4796
+ settings,
4797
+ question,
4798
+ evidence,
4799
+ token_budget,
4800
+ selected_profile.key,
4801
+ thinking_enabled,
4802
+ )
4803
  html = _render_tuned_answer_html(answer, docs, hero_metadata, item_metadata)
4804
  return html, json.dumps(docs, indent=2)
4805
 
 
5028
  label="Sources",
5029
  elem_classes=["dota-dropdown"],
5030
  )
5031
+ selected_profile_supports_thinking = resolve_model_profile(
5032
+ selected_model_profile
5033
+ ).supports_thinking
5034
+ with gr.Row(elem_classes=["d2-selector-grid", "d2-selector-grid-2"]):
5035
+ tuned_thinking = gr.Checkbox(
5036
+ value=False,
5037
+ label="Thinking",
5038
+ interactive=selected_profile_supports_thinking,
5039
+ )
5040
+ tuned_max_tokens = gr.Slider(
5041
+ minimum=128,
5042
+ maximum=1536 if selected_profile_supports_thinking else 768,
5043
+ value=384,
5044
+ step=64,
5045
+ label="Max Tokens",
5046
+ )
5047
+ tuned_thinking_note = gr.HTML(
5048
+ thinking_note_html(selected_model_profile, False),
5049
+ elem_classes=["assistant-thinking-note-wrap"],
5050
+ )
5051
  with gr.Row(elem_classes=["assistant-actions"]):
5052
  tuned_button = gr.Button("Ask DOTA2Tuned", variant="primary")
5053
  tuned_clear = gr.Button("Clear")
 
5065
  queue=False,
5066
  )
5067
  tuned_evidence = gr.Code(label="Evidence", language="json", value="[]")
5068
+ tuned_profile.change(
5069
+ thinking_controls,
5070
+ inputs=[tuned_profile, tuned_thinking, tuned_max_tokens],
5071
+ outputs=[tuned_thinking, tuned_max_tokens, tuned_thinking_note],
5072
+ api_visibility="private",
5073
+ queue=False,
5074
+ )
5075
+ tuned_thinking.change(
5076
+ thinking_toggle_controls,
5077
+ inputs=[tuned_profile, tuned_thinking, tuned_max_tokens],
5078
+ outputs=[tuned_max_tokens, tuned_thinking_note],
5079
+ api_visibility="private",
5080
+ queue=False,
5081
+ )
5082
  tuned_button.click(
5083
  tuned_model,
5084
+ inputs=[
5085
+ tuned_question,
5086
+ tuned_profile,
5087
+ tuned_source_filter,
5088
+ tuned_thinking,
5089
+ tuned_max_tokens,
5090
+ ],
5091
  outputs=[assistant_output, tuned_evidence],
5092
  )
5093
  tuned_question.submit(
5094
  tuned_model,
5095
+ inputs=[
5096
+ tuned_question,
5097
+ tuned_profile,
5098
+ tuned_source_filter,
5099
+ tuned_thinking,
5100
+ tuned_max_tokens,
5101
+ ],
5102
  outputs=[assistant_output, tuned_evidence],
5103
  )
5104
  tuned_clear.click(
5105
+ lambda: ("", _assistant_empty_state_html(), "[]", False, 384),
5106
+ outputs=[
5107
+ tuned_question,
5108
+ assistant_output,
5109
+ tuned_evidence,
5110
+ tuned_thinking,
5111
+ tuned_max_tokens,
5112
+ ],
5113
  api_visibility="private",
5114
  queue=False,
5115
  )
tests/test_gradio_app.py CHANGED
@@ -135,6 +135,14 @@ def test_tuned_model_is_first_nav_and_default_view():
135
  assert "width: 100%" in APP_CSS
136
  assert "justify-content: flex-start" in APP_CSS
137
  assert "padding: 0;" in APP_CSS
 
 
 
 
 
 
 
 
138
 
139
 
140
  def test_assistant_references_link_patch_and_hero_sources():
 
135
  assert "width: 100%" in APP_CSS
136
  assert "justify-content: flex-start" in APP_CSS
137
  assert "padding: 0;" in APP_CSS
138
+ assert "tuned_thinking = gr.Checkbox" in source
139
+ assert 'label="Thinking"' in source
140
+ assert "tuned_max_tokens = gr.Slider" in source
141
+ assert 'label="Max Tokens"' in source
142
+ assert "thinking_controls" in source
143
+ assert "thinking_toggle_controls" in source
144
+ assert "selected_profile_supports_thinking" in source
145
+ assert "assistant-thinking-note" in APP_CSS
146
 
147
 
148
  def test_assistant_references_link_patch_and_hero_sources():
tests/test_model_profiles.py CHANGED
@@ -1,3 +1,12 @@
 
 
 
 
 
 
 
 
 
1
  from dota2tuned.model_profiles import apply_profile_env_overrides, resolve_model_profile
2
 
3
 
@@ -10,8 +19,11 @@ def test_model_profiles_include_quality_and_sponsor_paths():
10
  assert quality.modal_train_gpu == "H200"
11
  assert quality.lora_dropout == 0.0
12
  assert "q_proj" in quality.lora_target_modules
 
13
  assert sponsor.base_model_id == "openbmb/MiniCPM4.1-8B"
14
  assert sponsor.hf_model_repo_id.endswith("minicpm4-1-8b-lora")
 
 
15
 
16
 
17
  def test_model_profile_env_overrides(monkeypatch):
@@ -24,3 +36,57 @@ def test_model_profile_env_overrides(monkeypatch):
24
  assert profile.base_model_id == "example/base"
25
  assert profile.hf_model_repo_id == "example/adapter"
26
  assert profile.lora_r == 12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import inspect
2
+
3
+ from dota2tuned.modal_backend import (
4
+ _evidence_fallback_answer,
5
+ _install_peft_weight_converter_compat,
6
+ _looks_malformed_answer,
7
+ _strip_reasoning_blocks,
8
+ modal_infer_function_name,
9
+ )
10
  from dota2tuned.model_profiles import apply_profile_env_overrides, resolve_model_profile
11
 
12
 
 
19
  assert quality.modal_train_gpu == "H200"
20
  assert quality.lora_dropout == 0.0
21
  assert "q_proj" in quality.lora_target_modules
22
+ assert quality.supports_thinking is False
23
  assert sponsor.base_model_id == "openbmb/MiniCPM4.1-8B"
24
  assert sponsor.hf_model_repo_id.endswith("minicpm4-1-8b-lora")
25
+ assert sponsor.supports_thinking is True
26
+ assert sponsor.thinking_recommended_max_tokens == 768
27
 
28
 
29
  def test_model_profile_env_overrides(monkeypatch):
 
36
  assert profile.base_model_id == "example/base"
37
  assert profile.hf_model_repo_id == "example/adapter"
38
  assert profile.lora_r == 12
39
+
40
+
41
+ def test_model_profile_thinking_env_overrides(monkeypatch):
42
+ monkeypatch.setenv("MODEL_SUPPORTS_THINKING", "1")
43
+ monkeypatch.setenv("THINKING_RECOMMENDED_MAX_TOKENS", "1024")
44
+
45
+ profile = apply_profile_env_overrides(resolve_model_profile("qwen3_4b_2507"))
46
+
47
+ assert profile.supports_thinking is True
48
+ assert profile.thinking_recommended_max_tokens == 1024
49
+
50
+
51
+ def test_modal_inference_routes_quality_to_h200_function():
52
+ assert modal_infer_function_name("qwen3_4b_2507") == "generate_answer"
53
+ assert modal_infer_function_name("minicpm4_1_8b") == "generate_answer"
54
+ assert modal_infer_function_name("qwen3_30b_a3b_2507") == "generate_answer_quality"
55
+
56
+
57
+ def test_malformed_balanced_output_guard_returns_grounded_text():
58
+ assert _looks_malformed_answer('". \\ \\ \\ \\ \\ \\ \\')
59
+ assert _looks_malformed_answer("c3 ×ontology**\n\ninter. ##")
60
+ assert _looks_malformed_answer(
61
+ "vestig vestig vestig vestib vestige vestig vestment vestig vestig"
62
+ )
63
+ assert not _looks_malformed_answer("Crystal Maiden is a support with control.")
64
+
65
+ fallback = _evidence_fallback_answer(
66
+ "Suggest one support against Phantom Assassin.",
67
+ "Crystal Maiden has control. Phantom Assassin is a carry.",
68
+ )
69
+
70
+ assert "Based on the retrieved evidence" in fallback
71
+ assert "Crystal Maiden has control" in fallback
72
+
73
+
74
+ def test_reasoning_blocks_are_stripped_from_visible_answer():
75
+ assert _strip_reasoning_blocks("<think>private scratchpad</think>Final answer.") == (
76
+ "Final answer."
77
+ )
78
+ assert _strip_reasoning_blocks("private scratchpad</think>Final answer.") == (
79
+ "Final answer."
80
+ )
81
+
82
+
83
+ def test_peft_weight_converter_compat_accepts_new_peft_kwargs():
84
+ installed = _install_peft_weight_converter_compat()
85
+ if not installed:
86
+ return
87
+
88
+ from transformers.core_model_loading import WeightConverter
89
+
90
+ signature = inspect.signature(WeightConverter.__init__)
91
+ assert "distributed_operation" in signature.parameters
92
+ assert "quantization_operation" in signature.parameters