final: sync completed submission
#3
by Dracufeuer - opened
- MODEL_SELECTION.md +5 -0
- PLAN.md +9 -3
- README.md +3 -3
- SUBMISSION.md +2 -2
- src/dota2tuned/cli.py +20 -2
- src/dota2tuned/modal_backend.py +221 -26
- src/dota2tuned/model_profiles.py +13 -0
- src/dota2tuned/ui/gradio_app.py +163 -10
- tests/test_gradio_app.py +8 -0
- tests/test_model_profiles.py +66 -0
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`,
|
| 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,
|
| 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:
|
| 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
|
| 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:
|
| 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 |
-
- [
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
typer.echo(
|
| 419 |
-
json.dumps(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
| 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 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
try:
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 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 |
-
|
| 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 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 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 |
-
|
| 4555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4950 |
outputs=[assistant_output, tuned_evidence],
|
| 4951 |
)
|
| 4952 |
tuned_question.submit(
|
| 4953 |
tuned_model,
|
| 4954 |
-
inputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4955 |
outputs=[assistant_output, tuned_evidence],
|
| 4956 |
)
|
| 4957 |
tuned_clear.click(
|
| 4958 |
-
lambda: ("", _assistant_empty_state_html(), "[]"),
|
| 4959 |
-
outputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|