From 6bc628f2f54867cc7c8765e60cb801ebbf4babe7 Mon Sep 17 00:00:00 2001 From: FenjuFu Date: Mon, 28 Sep 2026 10:19:25 +0800 Subject: [PATCH] feat(space): add a Hugging Face Space demo Add spaces/domux-demo, a Gradio app that turns a smart-home command into the 7-field slots and shows the raw output, a format check and latency. It loads iFlytekOpenSource/Domux with transformers at the revision used by cases/domux-4057-full-eval (ZeroGPU on Spaces), or calls an OpenAI-compatible vLLM/SGLang endpoint when DOMUX_API_BASE is set. Add a workflow that syncs the directory to the Space when HF_TOKEN is configured, and a PR check for the demo. Closes #10 Signed-off-by: FenjuFu --- .github/workflows/space-check.yml | 32 ++++++ .github/workflows/sync-space.yml | 52 +++++++++ CHANGELOG.md | 7 ++ README.md | 2 + README_zh.md | 2 + spaces/domux-demo/README.md | 62 +++++++++++ spaces/domux-demo/app.py | 83 +++++++++++++++ spaces/domux-demo/domux_backend.py | 134 ++++++++++++++++++++++++ spaces/domux-demo/requirements.txt | 5 + spaces/domux-demo/tests/test_backend.py | 92 ++++++++++++++++ 10 files changed, 471 insertions(+) create mode 100644 .github/workflows/space-check.yml create mode 100644 .github/workflows/sync-space.yml create mode 100644 spaces/domux-demo/README.md create mode 100644 spaces/domux-demo/app.py create mode 100644 spaces/domux-demo/domux_backend.py create mode 100644 spaces/domux-demo/requirements.txt create mode 100644 spaces/domux-demo/tests/test_backend.py diff --git a/.github/workflows/space-check.yml b/.github/workflows/space-check.yml new file mode 100644 index 0000000..9ed2f1b --- /dev/null +++ b/.github/workflows/space-check.yml @@ -0,0 +1,32 @@ +name: Space demo check + +on: + pull_request: + paths: + - spaces/domux-demo/** + - .github/workflows/space-check.yml + - .github/workflows/sync-space.yml + push: + branches: + - main + paths: + - spaces/domux-demo/** + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install test dependencies + # The model backend is not loaded here, so torch/transformers are not needed. + run: pip install pytest requests "gradio==6.28.0" + - name: Test parsing, the endpoint backend and the Gradio app + env: + DOMUX_API_BASE: http://127.0.0.1:9/v1 + run: pytest -q spaces/domux-demo/tests diff --git a/.github/workflows/sync-space.yml b/.github/workflows/sync-space.yml new file mode 100644 index 0000000..4014d53 --- /dev/null +++ b/.github/workflows/sync-space.yml @@ -0,0 +1,52 @@ +name: Sync Hugging Face Space + +on: + push: + branches: + - main + paths: + - spaces/domux-demo/** + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sync-space + cancel-in-progress: true + +jobs: + sync: + runs-on: ubuntu-latest + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + HF_SPACE_ID: ${{ vars.HF_SPACE_ID || 'iFlytekOpenSource/Domux-Demo' }} + steps: + - name: Skip when HF_TOKEN is not configured + if: env.HF_TOKEN == '' + run: echo "::notice::HF_TOKEN secret is not set; skipping the Space sync." + - uses: actions/checkout@v6 + if: env.HF_TOKEN != '' + - uses: actions/setup-python@v5 + if: env.HF_TOKEN != '' + with: + python-version: '3.12' + - name: Upload spaces/domux-demo to the Space + if: env.HF_TOKEN != '' + run: | + pip install --quiet "huggingface_hub>=1.0" + python - <<'PY' + import os + from huggingface_hub import HfApi + + api = HfApi(token=os.environ["HF_TOKEN"]) + api.upload_folder( + repo_id=os.environ["HF_SPACE_ID"], + repo_type="space", + folder_path="spaces/domux-demo", + ignore_patterns=["tests/**", "__pycache__/**"], + delete_patterns=["*.py", "requirements.txt", "README.md"], + commit_message=f"Sync from iflytek/domux@{os.environ['GITHUB_SHA'][:7]}", + ) + print(f"Synced to https://huggingface.co/spaces/{os.environ['HF_SPACE_ID']}") + PY diff --git a/CHANGELOG.md b/CHANGELOG.md index 96dae5e..1d7b52a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Gradio demo for Hugging Face Spaces (`spaces/domux-demo`), with a local + ZeroGPU backend or an OpenAI-compatible endpoint backend, and a workflow that + syncs it to the Space + ## [0.1.0] - 2026-06-30 First public release of Domux (`Domux-Gemma-4-E2B-it`), a lightweight, diff --git a/README.md b/README.md index 024c5e2..7c46ca1 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ As an early exploration, Domux is still evolving. We're focusing on three direct ## 🎬 Demo +> 🖥️ **Try it in the browser:** [`spaces/domux-demo`](spaces/domux-demo) is a Gradio demo that runs as a Hugging Face Space (ZeroGPU) or locally, and can also front your own vLLM/SGLang endpoint. + The model outputs pipe-delimited slots with 7 fields: ``` diff --git a/README_zh.md b/README_zh.md index d6b8ba7..49eba3d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -92,6 +92,8 @@ Domux 基于具备泛化能力的基座模型,**不依赖固定的设备白名 ## 🎬 示例演示 +> 🖥️ **在线体验:** [`spaces/domux-demo`](spaces/domux-demo) 提供 Gradio 演示,可部署为 Hugging Face Space(ZeroGPU)或在本地运行,也可以接入你自己的 vLLM/SGLang 服务。 + 模型输出包含 7 个字段的竖线分隔槽位: ``` diff --git a/spaces/domux-demo/README.md b/spaces/domux-demo/README.md new file mode 100644 index 0000000..9c8ceb7 --- /dev/null +++ b/spaces/domux-demo/README.md @@ -0,0 +1,62 @@ +--- +title: Domux Demo +emoji: 🏠 +colorFrom: blue +colorTo: indigo +sdk: gradio +sdk_version: 6.28.0 +python_version: "3.12" +app_file: app.py +pinned: false +license: apache-2.0 +short_description: Smart-home commands to structured slots with Domux +models: + - iFlytekOpenSource/Domux +suggested_hardware: zero-a10g +--- + +# Domux Demo Space + +A Gradio demo for [Domux](https://github.com/iflytek/domux). Type a smart-home command and the demo shows the raw model output, the parsed `action|device|attribute|value|unit|room|floor` slots, whether every line has the 7 fields, and the latency. + +This directory is the source of the Hugging Face Space. `.github/workflows/sync-space.yml` uploads it to the Space whenever it changes on `main`. + +## Backends + +| Mode | When | How it runs | +| --- | --- | --- | +| Local model (default) | `DOMUX_API_BASE` is not set | Loads `iFlytekOpenSource/Domux` with transformers in BF16, pinned to the revision used by the full 4,057-sample evaluation (`cases/domux-4057-full-eval`). On a ZeroGPU Space each request gets a GPU through `@spaces.GPU`. | +| OpenAI-compatible endpoint | `DOMUX_API_BASE` is set | Sends the command to a vLLM or SGLang server (see the main README's Deployment section). The Space itself can then run on free CPU hardware. | + +Environment variables (Space **Settings → Variables and secrets**): + +| Variable | Default | Description | +| --- | --- | --- | +| `DOMUX_MODEL_ID` | `iFlytekOpenSource/Domux` | Model repo for the local backend | +| `DOMUX_REVISION` | `6c71a32f…` | Model revision for the local backend | +| `DOMUX_API_BASE` | | OpenAI-compatible base URL, e.g. `https://host:8000/v1`. Switches to the endpoint backend | +| `DOMUX_API_MODEL` | `domux` | Served model name on that endpoint | +| `DOMUX_API_KEY` | | Bearer token for that endpoint (store as a secret) | + +The model needs about 10 GB for BF16 weights, so the local backend needs a GPU Space (ZeroGPU or a dedicated GPU). It will start on CPU hardware, but each request takes a long time. + +## Run locally + +```bash +cd spaces/domux-demo +pip install gradio==6.28.0 -r requirements.txt +python app.py # local model; needs a GPU with 20 GB+ VRAM +DOMUX_API_BASE=http://localhost:8000/v1 python app.py # or point at a running vLLM/SGLang server +``` + +Open http://127.0.0.1:7860. + +## Set up the Space (maintainers) + +1. Create a Gradio Space, e.g. `iFlytekOpenSource/Domux-Demo`, and select ZeroGPU hardware (or leave it on CPU and set `DOMUX_API_BASE`). +2. In this GitHub repository add a secret `HF_TOKEN` (a token with write access to the Space) and, if the Space id differs from `iFlytekOpenSource/Domux-Demo`, a variable `HF_SPACE_ID`. +3. Run **Sync Hugging Face Space** from the Actions tab once. Later changes to `spaces/domux-demo/` on `main` sync automatically. + +## License + +The demo code is Apache-2.0. The Domux model weights are a Gemma derivative governed by the [Gemma Terms of Use](https://ai.google.dev/gemma/terms); see [NOTICE](https://github.com/iflytek/domux/blob/main/NOTICE). diff --git a/spaces/domux-demo/app.py b/spaces/domux-demo/app.py new file mode 100644 index 0000000..f14fcdb --- /dev/null +++ b/spaces/domux-demo/app.py @@ -0,0 +1,83 @@ +"""Gradio demo for Domux: natural-language smart-home commands to structured slots.""" + +try: + # Must be imported before torch on ZeroGPU Spaces; optional everywhere else. + import spaces +except ImportError: + spaces = None + +import gradio as gr + +from domux_backend import FIELDS, backend_from_env, run + +EXAMPLES = [ + "Turn on the living room light", + "Set bedroom AC to 22 degrees", + "dim the spots in the living room", + "set the curtain in the majlis on the ground floor to 65 percent", + "turn on the ceiling light and open the curtain a bit in bedroom 1", + "Turn off all lights in the Living Room on the Ground Floor, set the AC to Cool mode at " + "24 degrees in the Guest Bedroom, and open the curtains halfway in the Dining Room.", + "Turn on the Master Light in the Master Bedroom on the Second Floor, set brightness to 80%, " + "color temperature to 4000K, color to Blue, and mode to Reading.", + "Activate romantic mode", +] + +DESCRIPTION = """\ +# Domux · Smart-home command understanding + +[Domux](https://github.com/iflytek/domux) turns a natural-language smart-home command into +pipe-delimited slots: `action|device|attribute|value|unit|room|floor`, one line per action, +`*` for fields that don't apply. It is fine-tuned from Gemma-4-E2B-it and trained on English +commands for lights, AC, curtains/blinds and scene modes. + +[Model](https://huggingface.co/iFlytekOpenSource/Domux) · +[Output spec](https://github.com/iflytek/domux/blob/main/docs/output-spec.md) · +[Benchmark](https://github.com/iflytek/domux#-benchmark) +""" + +backend = backend_from_env() +generate = backend.generate +if spaces is not None and backend.__class__.__name__ == "LocalBackend": + generate = spaces.GPU(duration=30)(generate) + + +def parse(query: str): + try: + result = run(generate, query) + except ValueError as exc: + raise gr.Error(str(exc)) from exc + status = ( + f"{'✅ Valid' if result.format_ok else '⚠️ Not 7-field'} format · " + f"{len(result.rows)} action(s) · {result.latency_ms:.0f} ms" + ) + return result.raw, result.rows, status + + +with gr.Blocks(title="Domux Demo") as demo: + gr.Markdown(DESCRIPTION) + with gr.Row(): + with gr.Column(): + query = gr.Textbox( + label="Command", + placeholder="e.g. Set the bedroom AC to 24 degrees and close the curtains", + lines=3, + max_length=1000, + ) + submit = gr.Button("Parse", variant="primary") + gr.Examples(examples=EXAMPLES, inputs=query) + with gr.Column(): + status = gr.Markdown() + slots = gr.Dataframe(headers=FIELDS, label="Slots", interactive=False, wrap=True) + raw = gr.Code(label="Raw model output", language=None) + gr.Markdown( + f"Backend: `{backend.label}`. Latency shown here includes Space queueing and GPU " + "allocation, so it is higher than the benchmark numbers." + ) + + submit.click(parse, inputs=query, outputs=[raw, slots, status]) + query.submit(parse, inputs=query, outputs=[raw, slots, status]) + + +if __name__ == "__main__": + demo.queue(max_size=32).launch() diff --git a/spaces/domux-demo/domux_backend.py b/spaces/domux-demo/domux_backend.py new file mode 100644 index 0000000..3907ae7 --- /dev/null +++ b/spaces/domux-demo/domux_backend.py @@ -0,0 +1,134 @@ +"""Inference backends and slot parsing for the Domux demo Space.""" + +import os +import time +from dataclasses import dataclass + +FIELDS = ["action", "device", "attribute", "value", "unit", "room", "floor"] +MAX_NEW_TOKENS = 256 +MAX_QUERY_CHARS = 1000 + +# Same model revision as the full 4,057-sample evaluation in cases/domux-4057-full-eval. +DEFAULT_MODEL_ID = "iFlytekOpenSource/Domux" +DEFAULT_REVISION = "6c71a32f4d624cadfd9fce9d10240d8068e53456" + + +@dataclass +class ParseResult: + raw: str + rows: list[list[str]] + format_ok: bool + latency_ms: float + + +def split_slots(output: str) -> tuple[list[list[str]], bool]: + """Split model output into 7-field rows. + + Lines are separated by newlines (or `&`, as in eval/run_eval.py). The output is + format-compliant only when every non-empty line has exactly seven fields. + """ + rows: list[list[str]] = [] + format_ok = bool(output.strip()) + for line in output.replace("&", "\n").split("\n"): + line = line.strip() + if not line: + continue + fields = [field.strip() for field in line.split("|")] + if len(fields) != len(FIELDS): + format_ok = False + fields = (fields + [""] * len(FIELDS))[: len(FIELDS)] + rows.append(fields) + return rows, format_ok + + +class LocalBackend: + """Runs the model with transformers, as in cases/domux-4057-full-eval/eval_direct.py.""" + + def __init__(self, model_id: str, revision: str) -> None: + import torch + from transformers import AutoModelForCausalLM, AutoTokenizer + + self._torch = torch + self.device = "cuda" if torch.cuda.is_available() or _on_zero_gpu() else "cpu" + self.tokenizer = AutoTokenizer.from_pretrained(model_id, revision=revision) + self.model = AutoModelForCausalLM.from_pretrained( + model_id, revision=revision, dtype=torch.bfloat16 + ).to(self.device) + self.model.eval() + self.label = f"{model_id}@{revision[:8]} (transformers, {self.device})" + + def generate(self, query: str) -> str: + text = self.tokenizer.apply_chat_template( + [{"role": "user", "content": query}], add_generation_prompt=True, tokenize=False + ) + inputs = self.tokenizer(text, return_tensors="pt").to(self.model.device) + with self._torch.inference_mode(): + output = self.model.generate( + **inputs, + max_new_tokens=MAX_NEW_TOKENS, + do_sample=False, + pad_token_id=self.tokenizer.pad_token_id or self.tokenizer.eos_token_id, + ) + new_tokens = output[0][inputs["input_ids"].shape[1] :] + return self.tokenizer.decode(new_tokens, skip_special_tokens=True).strip() + + +class OpenAIBackend: + """Calls an OpenAI-compatible endpoint (vLLM / SGLang) serving Domux.""" + + def __init__(self, base_url: str, model: str, api_key: str = "", timeout: float = 30) -> None: + import requests + + self._session = requests.Session() + self.url = f"{base_url.rstrip('/')}/chat/completions" + self.model = model + self.headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + self.timeout = timeout + self.label = f"{model} (OpenAI-compatible endpoint)" + + def generate(self, query: str) -> str: + response = self._session.post( + self.url, + headers=self.headers, + json={ + "model": self.model, + "messages": [{"role": "user", "content": query}], + "temperature": 0.0, + "max_tokens": MAX_NEW_TOKENS, + }, + timeout=self.timeout, + ) + response.raise_for_status() + return str(response.json()["choices"][0]["message"]["content"]).strip() + + +def backend_from_env() -> "LocalBackend | OpenAIBackend": + """Use DOMUX_API_BASE when set, otherwise load the model in-process.""" + api_base = os.getenv("DOMUX_API_BASE", "").strip() + if api_base: + return OpenAIBackend( + api_base, + os.getenv("DOMUX_API_MODEL", "domux").strip() or "domux", + os.getenv("DOMUX_API_KEY", "").strip(), + ) + return LocalBackend( + os.getenv("DOMUX_MODEL_ID", DEFAULT_MODEL_ID).strip() or DEFAULT_MODEL_ID, + os.getenv("DOMUX_REVISION", DEFAULT_REVISION).strip() or DEFAULT_REVISION, + ) + + +def run(generate, query: str) -> ParseResult: + query = query.strip() + if not query: + raise ValueError("Enter a smart-home command.") + if len(query) > MAX_QUERY_CHARS: + raise ValueError(f"Commands are limited to {MAX_QUERY_CHARS} characters.") + started = time.perf_counter() + raw = generate(query) + latency_ms = (time.perf_counter() - started) * 1000 + rows, format_ok = split_slots(raw) + return ParseResult(raw=raw, rows=rows, format_ok=format_ok, latency_ms=latency_ms) + + +def _on_zero_gpu() -> bool: + return os.getenv("SPACES_ZERO_GPU", "").lower() in {"1", "true"} diff --git a/spaces/domux-demo/requirements.txt b/spaces/domux-demo/requirements.txt new file mode 100644 index 0000000..730b80b --- /dev/null +++ b/spaces/domux-demo/requirements.txt @@ -0,0 +1,5 @@ +# transformers matches the runtime used for cases/domux-4057-full-eval (97.58% on the full test set). +transformers==5.15.1 +torch +requests +spaces diff --git a/spaces/domux-demo/tests/test_backend.py b/spaces/domux-demo/tests/test_backend.py new file mode 100644 index 0000000..dd0f409 --- /dev/null +++ b/spaces/domux-demo/tests/test_backend.py @@ -0,0 +1,92 @@ +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from domux_backend import OpenAIBackend, backend_from_env, run, split_slots # noqa: E402 + + +def test_split_multi_action_output(): + rows, ok = split_slots( + "turnOff|Light|*|*|*|Living Room|Ground Floor\n" + "set|AC|temperature|24|Celsius|Guest Bedroom|*\n" + ) + assert ok + assert rows == [ + ["turnOff", "Light", "*", "*", "*", "Living Room", "Ground Floor"], + ["set", "AC", "temperature", "24", "Celsius", "Guest Bedroom", "*"], + ] + + +def test_ampersand_separator_matches_eval_script(): + rows, ok = split_slots("turnOn|Light|*|*|*|*|*&turnOff|AC|*|*|*|*|*") + assert ok and len(rows) == 2 + + +def test_malformed_line_is_flagged_and_padded(): + rows, ok = split_slots("turnOn|Light|*") + assert not ok + assert rows == [["turnOn", "Light", "*", "", "", "", ""]] + + +def test_empty_output_is_not_compliant(): + assert split_slots(" \n") == ([], False) + + +def test_run_rejects_empty_and_long_queries(): + with pytest.raises(ValueError): + run(lambda q: "", " ") + with pytest.raises(ValueError): + run(lambda q: "", "x" * 1001) + + +def test_run_strips_query_and_times_call(): + seen = [] + result = run(lambda q: seen.append(q) or "turnOn|Light|*|*|*|Kitchen|*", " light on ") + assert seen == ["light on"] + assert result.format_ok and result.rows[0][5] == "Kitchen" + assert result.latency_ms >= 0 + + +def test_openai_backend_request(monkeypatch): + captured = {} + + class FakeResponse: + def raise_for_status(self): + pass + + def json(self): + return {"choices": [{"message": {"content": " set|AC|mode|Cool|*|*|* "}}]} + + backend = OpenAIBackend("http://localhost:8000/v1/", "domux", "token") + + def fake_post(url, **kwargs): + captured.update(url=url, **kwargs) + return FakeResponse() + + monkeypatch.setattr(backend._session, "post", fake_post) + assert backend.generate("cool mode") == "set|AC|mode|Cool|*|*|*" + assert captured["url"] == "http://localhost:8000/v1/chat/completions" + assert captured["headers"] == {"Authorization": "Bearer token"} + assert captured["json"]["temperature"] == 0.0 + assert captured["json"]["messages"] == [{"role": "user", "content": "cool mode"}] + + +def test_backend_from_env_prefers_endpoint(monkeypatch): + monkeypatch.setenv("DOMUX_API_BASE", "http://vllm:8000/v1") + monkeypatch.setenv("DOMUX_API_MODEL", "domux-served") + backend = backend_from_env() + assert isinstance(backend, OpenAIBackend) + assert backend.model == "domux-served" + + +@pytest.mark.skipif( + not os.getenv("DOMUX_API_BASE"), reason="app import test runs with an endpoint backend" +) +def test_app_builds_without_loading_model(): + import app + + assert app.demo is not None