init: adaptive-model — handler, gateway, mcp-server, training
Browse files- .env.example +15 -0
- .gitignore +11 -0
- README.md +155 -0
- gateway/__init__.py +0 -0
- gateway/app.py +104 -0
- gateway/openapi-schema.yaml +111 -0
- gateway/requirements.txt +5 -0
- handler.py +88 -0
- lib/__init__.py +0 -0
- lib/markdown_renderer.py +111 -0
- lib/router.py +59 -0
- mcp-server/requirements.txt +3 -0
- mcp-server/server.py +106 -0
- mcp-server/widget/adaptive.html +323 -0
- requirements.txt +4 -0
- training/generate_examples.py +164 -0
- training/requirements.txt +8 -0
- training/train_adapter.py +262 -0
.env.example
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy to .env and fill in real values — never commit .env
|
| 2 |
+
|
| 3 |
+
# Hugging Face Inference Endpoint URL (found in HF Spaces / Endpoints dashboard)
|
| 4 |
+
HF_ENDPOINT_URL=https://api-inference.huggingface.co/models/your-org/your-model
|
| 5 |
+
|
| 6 |
+
# HF access token (read or write depending on endpoint privacy setting)
|
| 7 |
+
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx
|
| 8 |
+
|
| 9 |
+
# Base model ID on the Hub (used by hf-endpoint/handler.py during container build)
|
| 10 |
+
BASE_MODEL=Qwen/Qwen2.5-7B-Instruct
|
| 11 |
+
|
| 12 |
+
# Adapter repo IDs on the Hub (one per mode)
|
| 13 |
+
ADAPTER_SUPPORT=your-org/adapter-support
|
| 14 |
+
ADAPTER_ANALYTICS=your-org/adapter-analytics
|
| 15 |
+
ADAPTER_FORM=your-org/adapter-form
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyo
|
| 5 |
+
.venv/
|
| 6 |
+
venv/
|
| 7 |
+
dist/
|
| 8 |
+
build/
|
| 9 |
+
*.egg-info/
|
| 10 |
+
.DS_Store
|
| 11 |
+
data/*.jsonl
|
README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# adaptive-model
|
| 2 |
+
|
| 3 |
+
Multi-adapter language model with two chat surfaces:
|
| 4 |
+
|
| 5 |
+
| Surface | Mechanism | UI |
|
| 6 |
+
|---------|-----------|-----|
|
| 7 |
+
| **Path A** — Custom GPT Action | OpenAPI schema → `/generate` | Markdown fallback |
|
| 8 |
+
| **Path B** — MCP Apps | MCP tool → `structuredContent` | Real interactive iframe widget |
|
| 9 |
+
|
| 10 |
+
Both surfaces share the same Hugging Face model backend and heuristic adapter router.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## Architecture
|
| 15 |
+
|
| 16 |
+
```
|
| 17 |
+
Chat turn
|
| 18 |
+
│
|
| 19 |
+
▼
|
| 20 |
+
lib/router.py ── heuristic or explicit mode
|
| 21 |
+
│
|
| 22 |
+
▼
|
| 23 |
+
HF Inference Endpoint (handler.py)
|
| 24 |
+
│ base model + 3 LoRA adapters (support / analytics / form)
|
| 25 |
+
│ emits: <ui>{json}</ui> then prose
|
| 26 |
+
│
|
| 27 |
+
├─── Path A ── gateway/app.py (FastAPI)
|
| 28 |
+
│ renders ui_spec → markdown
|
| 29 |
+
│ served via openapi-schema.yaml → Custom GPT Action
|
| 30 |
+
│
|
| 31 |
+
└─── Path B ── mcp-server/server.py (FastMCP)
|
| 32 |
+
returns structuredContent
|
| 33 |
+
widget/adaptive.html renders in iframe
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## Quick start
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
cp .env.example .env
|
| 42 |
+
# fill in HF_ENDPOINT_URL, HF_TOKEN, adapter repo IDs
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
### Path A — Custom GPT gateway
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
pip install -r gateway/requirements.txt
|
| 49 |
+
python -m gateway.app # runs on :8000
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
Paste `gateway/openapi-schema.yaml` into your GPT's Actions editor.
|
| 53 |
+
Set the server URL to your deployed gateway (ngrok / Railway / Fly.io).
|
| 54 |
+
|
| 55 |
+
### Path B — MCP server
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
pip install -r mcp-server/requirements.txt
|
| 59 |
+
|
| 60 |
+
# stdio (local MCP client, e.g. Claude Desktop)
|
| 61 |
+
python mcp-server/server.py
|
| 62 |
+
|
| 63 |
+
# SSE (remote clients, e.g. ChatGPT plugin host)
|
| 64 |
+
python mcp-server/server.py --http # listens on :3100
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
Add to your MCP client config:
|
| 68 |
+
```json
|
| 69 |
+
{
|
| 70 |
+
"mcpServers": {
|
| 71 |
+
"adaptive-model": {
|
| 72 |
+
"command": "python",
|
| 73 |
+
"args": ["mcp-server/server.py"]
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## Hugging Face endpoint
|
| 82 |
+
|
| 83 |
+
1. Push `hf-endpoint/handler.py` to your model repo on the Hub.
|
| 84 |
+
2. Create an **Inference Endpoint** pointing at that repo.
|
| 85 |
+
- Hardware: minimum A10G (24 GB) for a 7B base + 3 LoRA adapters in bf16.
|
| 86 |
+
- Set env vars: `BASE_MODEL`, `ADAPTER_SUPPORT`, `ADAPTER_ANALYTICS`, `ADAPTER_FORM`.
|
| 87 |
+
3. Copy the endpoint URL into `.env` as `HF_ENDPOINT_URL`.
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
## Adapter training
|
| 92 |
+
|
| 93 |
+
Adapters must be fine-tuned to emit `<ui>{...}</ui>` followed by prose.
|
| 94 |
+
Generate synthetic seed data to bootstrap each adapter:
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
pip install -r hf-endpoint/requirements.txt
|
| 98 |
+
|
| 99 |
+
python training/generate_examples.py --all --n 200 -o data/
|
| 100 |
+
# writes data/support.jsonl data/analytics.jsonl data/form.jsonl
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
Then fine-tune with your preferred PEFT trainer (TRL `SFTTrainer` works well):
|
| 104 |
+
|
| 105 |
+
```python
|
| 106 |
+
from trl import SFTTrainer, SFTConfig
|
| 107 |
+
from peft import LoraConfig
|
| 108 |
+
|
| 109 |
+
lora_cfg = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj","v_proj"])
|
| 110 |
+
trainer = SFTTrainer(
|
| 111 |
+
model=base_model,
|
| 112 |
+
args=SFTConfig(output_dir="./adapter-support", num_train_epochs=3),
|
| 113 |
+
train_dataset=support_dataset,
|
| 114 |
+
peft_config=lora_cfg,
|
| 115 |
+
)
|
| 116 |
+
trainer.train()
|
| 117 |
+
trainer.push_to_hub("your-org/adapter-support")
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## ui_spec contract
|
| 123 |
+
|
| 124 |
+
The model emits one of these component shapes:
|
| 125 |
+
|
| 126 |
+
```jsonc
|
| 127 |
+
// form
|
| 128 |
+
{"component":"form","props":{"title":"...","fields":[{"name":"x","label":"X","type":"text","required":true}],"submitLabel":"Send"}}
|
| 129 |
+
|
| 130 |
+
// chart
|
| 131 |
+
{"component":"chart","props":{"title":"...","type":"bar","data":{"labels":["Jan","Feb"],"datasets":[{"label":"Revenue","data":[400,600]}]}}}
|
| 132 |
+
|
| 133 |
+
// card
|
| 134 |
+
{"component":"card","props":{"title":"...","body":"...","items":[{"label":"Status","value":"OK"}]}}
|
| 135 |
+
|
| 136 |
+
// table
|
| 137 |
+
{"component":"table","props":{"columns":["Name","Value"],"rows":[["Alpha",1],["Beta",2]]}}
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
The widget renders any of these interactively.
|
| 141 |
+
Path A degrades each to markdown via `lib/markdown_renderer.py`.
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
## Routing
|
| 146 |
+
|
| 147 |
+
The heuristic router (`lib/router.py`) scores keywords in the last user turn:
|
| 148 |
+
|
| 149 |
+
| Adapter | Triggers |
|
| 150 |
+
|---------|---------|
|
| 151 |
+
| `support` | error, help, issue, bug, broken, fix … |
|
| 152 |
+
| `analytics` | chart, graph, trend, metric, dashboard … |
|
| 153 |
+
| `form` | form, fill, submit, register, sign up … |
|
| 154 |
+
|
| 155 |
+
Pass `mode` explicitly to override. Default is `support`.
|
gateway/__init__.py
ADDED
|
File without changes
|
gateway/app.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI gateway — Custom GPT Action surface (Path A).
|
| 3 |
+
|
| 4 |
+
Receives messages from the GPT Action, routes to the right adapter,
|
| 5 |
+
calls the HF Inference Endpoint, and returns both raw ui_spec and a
|
| 6 |
+
markdown fallback so the GPT can render something useful inline.
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
from fastapi import FastAPI, HTTPException
|
| 15 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
+
from pydantic import BaseModel
|
| 17 |
+
|
| 18 |
+
load_dotenv()
|
| 19 |
+
|
| 20 |
+
# Allow shared lib to be imported when running from repo root or this dir
|
| 21 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 22 |
+
from lib.router import route
|
| 23 |
+
from lib.markdown_renderer import render
|
| 24 |
+
|
| 25 |
+
HF_ENDPOINT_URL = os.getenv("HF_ENDPOINT_URL", "")
|
| 26 |
+
HF_TOKEN = os.getenv("HF_TOKEN", "")
|
| 27 |
+
|
| 28 |
+
app = FastAPI(
|
| 29 |
+
title="Adaptive Model Gateway",
|
| 30 |
+
version="1.0.0",
|
| 31 |
+
description="Routes chat turns to the right LoRA adapter and returns adaptive UI specs.",
|
| 32 |
+
)
|
| 33 |
+
app.add_middleware(
|
| 34 |
+
CORSMiddleware,
|
| 35 |
+
allow_origins=["https://chat.openai.com", "https://chatgpt.com"],
|
| 36 |
+
allow_methods=["POST", "GET"],
|
| 37 |
+
allow_headers=["*"],
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ── request / response models ─────────────────────────────────────────────────
|
| 42 |
+
|
| 43 |
+
class Message(BaseModel):
|
| 44 |
+
role: str
|
| 45 |
+
content: str
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class GenerateRequest(BaseModel):
|
| 49 |
+
messages: list[Message]
|
| 50 |
+
mode: str | None = None # explicit override; omit for heuristic routing
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class GenerateResponse(BaseModel):
|
| 54 |
+
text: str
|
| 55 |
+
ui_spec: dict | None = None
|
| 56 |
+
ui_markdown: str | None = None # markdown fallback for Custom GPT rendering
|
| 57 |
+
adapter: str
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── endpoints ─────────────────────────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
@app.post("/generate", response_model=GenerateResponse)
|
| 63 |
+
async def generate(req: GenerateRequest) -> GenerateResponse:
|
| 64 |
+
if not HF_ENDPOINT_URL:
|
| 65 |
+
raise HTTPException(500, "HF_ENDPOINT_URL not set — check .env")
|
| 66 |
+
|
| 67 |
+
messages = [m.model_dump() for m in req.messages]
|
| 68 |
+
adapter = route(messages, req.mode)
|
| 69 |
+
|
| 70 |
+
headers = {"Content-Type": "application/json"}
|
| 71 |
+
if HF_TOKEN:
|
| 72 |
+
headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
| 73 |
+
|
| 74 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 75 |
+
resp = await client.post(
|
| 76 |
+
HF_ENDPOINT_URL,
|
| 77 |
+
json={"inputs": {"messages": messages, "mode": adapter}},
|
| 78 |
+
headers=headers,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
if resp.status_code != 200:
|
| 82 |
+
raise HTTPException(resp.status_code, f"HF endpoint error: {resp.text[:400]}")
|
| 83 |
+
|
| 84 |
+
data = resp.json()
|
| 85 |
+
ui_spec = data.get("ui_spec")
|
| 86 |
+
|
| 87 |
+
return GenerateResponse(
|
| 88 |
+
text=data.get("text", ""),
|
| 89 |
+
ui_spec=ui_spec,
|
| 90 |
+
ui_markdown=render(ui_spec),
|
| 91 |
+
adapter=data.get("adapter", adapter),
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@app.get("/health")
|
| 96 |
+
async def health() -> dict:
|
| 97 |
+
return {"status": "ok", "endpoint_configured": bool(HF_ENDPOINT_URL)}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ── dev entry-point ───────────────────────────────────────────────────────────
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
import uvicorn
|
| 104 |
+
uvicorn.run("gateway.app:app", host="0.0.0.0", port=8000, reload=True)
|
gateway/openapi-schema.yaml
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openapi: 3.1.0
|
| 2 |
+
info:
|
| 3 |
+
title: Adaptive Model Gateway
|
| 4 |
+
description: >
|
| 5 |
+
Routes chat turns to a multi-adapter language model on Hugging Face and returns
|
| 6 |
+
a structured UI spec alongside the text response. Paste this into the ChatGPT
|
| 7 |
+
Action editor and set the server URL to your deployed gateway.
|
| 8 |
+
version: 1.0.0
|
| 9 |
+
|
| 10 |
+
servers:
|
| 11 |
+
- url: https://your-gateway.example.com # replace with your deployed URL
|
| 12 |
+
|
| 13 |
+
paths:
|
| 14 |
+
/generate:
|
| 15 |
+
post:
|
| 16 |
+
operationId: generate
|
| 17 |
+
summary: Run the adaptive model for the current conversation turn.
|
| 18 |
+
description: >
|
| 19 |
+
Sends the full conversation history to the model. The model selects
|
| 20 |
+
the appropriate LoRA adapter (support / analytics / form) and returns
|
| 21 |
+
a prose response plus an optional ui_spec for richer rendering.
|
| 22 |
+
In the Custom GPT Action context the ui_markdown field is a markdown
|
| 23 |
+
fallback the model can include directly in its reply.
|
| 24 |
+
requestBody:
|
| 25 |
+
required: true
|
| 26 |
+
content:
|
| 27 |
+
application/json:
|
| 28 |
+
schema:
|
| 29 |
+
type: object
|
| 30 |
+
required:
|
| 31 |
+
- messages
|
| 32 |
+
properties:
|
| 33 |
+
messages:
|
| 34 |
+
type: array
|
| 35 |
+
description: Full conversation history, oldest first.
|
| 36 |
+
items:
|
| 37 |
+
type: object
|
| 38 |
+
required:
|
| 39 |
+
- role
|
| 40 |
+
- content
|
| 41 |
+
properties:
|
| 42 |
+
role:
|
| 43 |
+
type: string
|
| 44 |
+
enum: [user, assistant, system]
|
| 45 |
+
content:
|
| 46 |
+
type: string
|
| 47 |
+
mode:
|
| 48 |
+
type: string
|
| 49 |
+
enum: [support, analytics, form]
|
| 50 |
+
description: >
|
| 51 |
+
Optional explicit adapter override.
|
| 52 |
+
Omit to let the gateway choose via heuristic routing.
|
| 53 |
+
responses:
|
| 54 |
+
"200":
|
| 55 |
+
description: Successful generation
|
| 56 |
+
content:
|
| 57 |
+
application/json:
|
| 58 |
+
schema:
|
| 59 |
+
type: object
|
| 60 |
+
properties:
|
| 61 |
+
text:
|
| 62 |
+
type: string
|
| 63 |
+
description: Plain-text model response.
|
| 64 |
+
ui_spec:
|
| 65 |
+
type: object
|
| 66 |
+
nullable: true
|
| 67 |
+
description: >
|
| 68 |
+
Structured component specification emitted by the model.
|
| 69 |
+
null when the turn needs no interactive UI.
|
| 70 |
+
properties:
|
| 71 |
+
component:
|
| 72 |
+
type: string
|
| 73 |
+
enum: [form, chart, card, table]
|
| 74 |
+
props:
|
| 75 |
+
type: object
|
| 76 |
+
additionalProperties: true
|
| 77 |
+
ui_markdown:
|
| 78 |
+
type: string
|
| 79 |
+
nullable: true
|
| 80 |
+
description: >
|
| 81 |
+
Markdown rendering of ui_spec for use in plain-text chat.
|
| 82 |
+
Include this verbatim in your reply when ui_spec is present.
|
| 83 |
+
adapter:
|
| 84 |
+
type: string
|
| 85 |
+
description: Which adapter handled this turn.
|
| 86 |
+
"500":
|
| 87 |
+
description: Gateway or upstream model error
|
| 88 |
+
content:
|
| 89 |
+
application/json:
|
| 90 |
+
schema:
|
| 91 |
+
type: object
|
| 92 |
+
properties:
|
| 93 |
+
detail:
|
| 94 |
+
type: string
|
| 95 |
+
|
| 96 |
+
/health:
|
| 97 |
+
get:
|
| 98 |
+
operationId: health
|
| 99 |
+
summary: Health check
|
| 100 |
+
responses:
|
| 101 |
+
"200":
|
| 102 |
+
description: Gateway is running
|
| 103 |
+
content:
|
| 104 |
+
application/json:
|
| 105 |
+
schema:
|
| 106 |
+
type: object
|
| 107 |
+
properties:
|
| 108 |
+
status:
|
| 109 |
+
type: string
|
| 110 |
+
endpoint_configured:
|
| 111 |
+
type: boolean
|
gateway/requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.112.0
|
| 2 |
+
uvicorn[standard]>=0.30.0
|
| 3 |
+
httpx>=0.27.0
|
| 4 |
+
pydantic>=2.8.0
|
| 5 |
+
python-dotenv>=1.0.0
|
handler.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hugging Face Inference Endpoint custom handler.
|
| 3 |
+
|
| 4 |
+
Loads a base model with multiple LoRA adapters (one per mode).
|
| 5 |
+
Adapters stay resident in memory; set_adapter() switches cheaply per request.
|
| 6 |
+
The model is trained to emit <ui>{...json...}</ui> then prose.
|
| 7 |
+
"""
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import re
|
| 11 |
+
import torch
|
| 12 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 13 |
+
from peft import PeftModel
|
| 14 |
+
|
| 15 |
+
BASE_MODEL = os.getenv("BASE_MODEL", "Qwen/Qwen2.5-7B-Instruct")
|
| 16 |
+
|
| 17 |
+
# Override via env vars to point at your own adapter repos on the Hub
|
| 18 |
+
ADAPTERS: dict[str, str] = {
|
| 19 |
+
"support": os.getenv("ADAPTER_SUPPORT", "your-org/adapter-support"),
|
| 20 |
+
"analytics": os.getenv("ADAPTER_ANALYTICS", "your-org/adapter-analytics"),
|
| 21 |
+
"form": os.getenv("ADAPTER_FORM", "your-org/adapter-form"),
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
_UI_RE = re.compile(r"<ui>(.*?)</ui>", re.DOTALL)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class EndpointHandler:
|
| 28 |
+
def __init__(self, path: str = ""):
|
| 29 |
+
self.tok = AutoTokenizer.from_pretrained(BASE_MODEL)
|
| 30 |
+
|
| 31 |
+
base = AutoModelForCausalLM.from_pretrained(
|
| 32 |
+
BASE_MODEL,
|
| 33 |
+
torch_dtype=torch.bfloat16,
|
| 34 |
+
device_map="auto",
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
names = list(ADAPTERS.keys())
|
| 38 |
+
# First adapter via from_pretrained so PEFT wraps the model
|
| 39 |
+
self.model = PeftModel.from_pretrained(
|
| 40 |
+
base, ADAPTERS[names[0]], adapter_name=names[0]
|
| 41 |
+
)
|
| 42 |
+
# Remaining adapters attached in-place
|
| 43 |
+
for name in names[1:]:
|
| 44 |
+
self.model.load_adapter(ADAPTERS[name], adapter_name=name)
|
| 45 |
+
|
| 46 |
+
self.model.eval()
|
| 47 |
+
|
| 48 |
+
def __call__(self, data: dict) -> dict:
|
| 49 |
+
inp = data.get("inputs", {})
|
| 50 |
+
messages = inp.get("messages", [])
|
| 51 |
+
mode = inp.get("mode", "support")
|
| 52 |
+
|
| 53 |
+
if mode not in ADAPTERS:
|
| 54 |
+
mode = "support"
|
| 55 |
+
|
| 56 |
+
self.model.set_adapter(mode)
|
| 57 |
+
|
| 58 |
+
prompt = self.tok.apply_chat_template(
|
| 59 |
+
messages, add_generation_prompt=True, tokenize=False
|
| 60 |
+
)
|
| 61 |
+
ids = self.tok(prompt, return_tensors="pt").to(self.model.device)
|
| 62 |
+
|
| 63 |
+
with torch.no_grad():
|
| 64 |
+
out = self.model.generate(
|
| 65 |
+
**ids,
|
| 66 |
+
max_new_tokens=512,
|
| 67 |
+
do_sample=False,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
new_tokens = out[0][ids.input_ids.shape[1]:]
|
| 71 |
+
text = self.tok.decode(new_tokens, skip_special_tokens=True)
|
| 72 |
+
|
| 73 |
+
ui_spec, clean_text = _extract_ui_spec(text)
|
| 74 |
+
|
| 75 |
+
return {"text": clean_text, "ui_spec": ui_spec, "adapter": mode}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _extract_ui_spec(text: str) -> tuple[dict | None, str]:
|
| 79 |
+
m = _UI_RE.search(text)
|
| 80 |
+
if not m:
|
| 81 |
+
return None, text
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
spec = json.loads(m.group(1).strip())
|
| 85 |
+
except json.JSONDecodeError:
|
| 86 |
+
return None, text
|
| 87 |
+
|
| 88 |
+
return spec, _UI_RE.sub("", text).strip()
|
lib/__init__.py
ADDED
|
File without changes
|
lib/markdown_renderer.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Renders a ui_spec dict to GitHub-flavored markdown.
|
| 3 |
+
|
| 4 |
+
Used by the Custom GPT Action path (Path A) where real interactive UI
|
| 5 |
+
is not available. Interactive behaviour degrades to descriptive markdown.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def render(spec: dict | None) -> str | None:
|
| 10 |
+
if not spec:
|
| 11 |
+
return None
|
| 12 |
+
|
| 13 |
+
component = spec.get("component", "")
|
| 14 |
+
props = spec.get("props", {})
|
| 15 |
+
|
| 16 |
+
renderers = {
|
| 17 |
+
"form": _form,
|
| 18 |
+
"chart": _chart,
|
| 19 |
+
"card": _card,
|
| 20 |
+
"table": _table,
|
| 21 |
+
}
|
| 22 |
+
fn = renderers.get(component)
|
| 23 |
+
return fn(props) if fn else f"*[{component} — view in full UI for interactivity]*"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ── form ─────────────────────────────────────────────────────────────────────
|
| 27 |
+
|
| 28 |
+
def _form(props: dict) -> str:
|
| 29 |
+
lines: list[str] = []
|
| 30 |
+
|
| 31 |
+
title = props.get("title")
|
| 32 |
+
if title:
|
| 33 |
+
lines.append(f"**{title}**\n")
|
| 34 |
+
|
| 35 |
+
for f in props.get("fields", []):
|
| 36 |
+
label = f.get("label") or f.get("name", "")
|
| 37 |
+
ftype = f.get("type", "text")
|
| 38 |
+
required = " *(required)*" if f.get("required") else ""
|
| 39 |
+
hint = f" — {f['hint']}" if f.get("hint") else ""
|
| 40 |
+
lines.append(f"- **{label}** `{ftype}`{required}{hint}")
|
| 41 |
+
|
| 42 |
+
submit = props.get("submitLabel", "Submit")
|
| 43 |
+
lines.append(f"\n> *(Use the full UI widget to fill and submit — **{submit}**)*")
|
| 44 |
+
return "\n".join(lines)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ── chart ─────────────────────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
def _chart(props: dict) -> str:
|
| 50 |
+
title = props.get("title", "Chart")
|
| 51 |
+
chart_type = props.get("type", "")
|
| 52 |
+
lines = [f"**{title}** *(interactive {chart_type} chart in full UI)*\n"]
|
| 53 |
+
|
| 54 |
+
data = props.get("data", {})
|
| 55 |
+
labels = data.get("labels", [])
|
| 56 |
+
datasets = data.get("datasets", [])
|
| 57 |
+
|
| 58 |
+
if labels and datasets:
|
| 59 |
+
header = "| Label | " + " | ".join(ds.get("label", "Value") for ds in datasets) + " |"
|
| 60 |
+
sep = "|---|" + "---|" * len(datasets)
|
| 61 |
+
lines += [header, sep]
|
| 62 |
+
for i, lbl in enumerate(labels):
|
| 63 |
+
cells = [str(ds.get("data", [])[i]) if i < len(ds.get("data", [])) else "" for ds in datasets]
|
| 64 |
+
lines.append(f"| {lbl} | " + " | ".join(cells) + " |")
|
| 65 |
+
|
| 66 |
+
return "\n".join(lines)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ── card ──────────────────────────────────────────────────────────────────────
|
| 70 |
+
|
| 71 |
+
def _card(props: dict) -> str:
|
| 72 |
+
lines: list[str] = []
|
| 73 |
+
|
| 74 |
+
if title := props.get("title"):
|
| 75 |
+
lines.append(f"### {title}")
|
| 76 |
+
if body := props.get("body"):
|
| 77 |
+
lines.append(body)
|
| 78 |
+
|
| 79 |
+
for item in props.get("items", []):
|
| 80 |
+
if isinstance(item, dict):
|
| 81 |
+
key = item.get("label") or item.get("key", "")
|
| 82 |
+
lines.append(f"- **{key}**: {item.get('value', '')}")
|
| 83 |
+
else:
|
| 84 |
+
lines.append(f"- {item}")
|
| 85 |
+
|
| 86 |
+
return "\n".join(lines)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ── table ─────────────────────────────────────────────────────────────────────
|
| 90 |
+
|
| 91 |
+
def _table(props: dict) -> str:
|
| 92 |
+
columns = props.get("columns", [])
|
| 93 |
+
rows = props.get("rows", [])
|
| 94 |
+
|
| 95 |
+
if not columns:
|
| 96 |
+
return "*[empty table]*"
|
| 97 |
+
|
| 98 |
+
header = "| " + " | ".join(columns) + " |"
|
| 99 |
+
sep = "|" + "---|" * len(columns)
|
| 100 |
+
lines = [header, sep]
|
| 101 |
+
|
| 102 |
+
for row in rows:
|
| 103 |
+
if isinstance(row, dict):
|
| 104 |
+
cells = [str(row.get(col, "")) for col in columns]
|
| 105 |
+
elif isinstance(row, list):
|
| 106 |
+
cells = [str(c) for c in row]
|
| 107 |
+
else:
|
| 108 |
+
cells = [str(row)]
|
| 109 |
+
lines.append("| " + " | ".join(cells) + " |")
|
| 110 |
+
|
| 111 |
+
return "\n".join(lines)
|
lib/router.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Heuristic adapter router.
|
| 3 |
+
|
| 4 |
+
Priority: explicit `mode` override → keyword match → default "support".
|
| 5 |
+
Swap in model self-classification by uncommenting _model_classify below.
|
| 6 |
+
"""
|
| 7 |
+
from typing import Literal
|
| 8 |
+
|
| 9 |
+
AdapterName = Literal["support", "analytics", "form"]
|
| 10 |
+
|
| 11 |
+
_KEYWORDS: dict[str, list[str]] = {
|
| 12 |
+
"support": [
|
| 13 |
+
"error", "help", "issue", "problem", "broken", "fix",
|
| 14 |
+
"support", "bug", "crash", "fail", "wrong", "not working",
|
| 15 |
+
],
|
| 16 |
+
"analytics": [
|
| 17 |
+
"chart", "graph", "data", "analytics", "trend", "report",
|
| 18 |
+
"metric", "dashboard", "stat", "plot", "visuali", "insight",
|
| 19 |
+
],
|
| 20 |
+
"form": [
|
| 21 |
+
"form", "fill", "submit", "input", "register", "sign up",
|
| 22 |
+
"signup", "onboard", "enter your", "complete", "provide",
|
| 23 |
+
],
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
_VALID: set[str] = set(_KEYWORDS.keys())
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def route(messages: list[dict], explicit_mode: str | None = None) -> AdapterName:
|
| 30 |
+
if explicit_mode and explicit_mode in _VALID:
|
| 31 |
+
return explicit_mode # type: ignore[return-value]
|
| 32 |
+
|
| 33 |
+
text = _last_user_content(messages).lower()
|
| 34 |
+
if not text:
|
| 35 |
+
return "support"
|
| 36 |
+
|
| 37 |
+
scores: dict[str, int] = {k: 0 for k in _KEYWORDS}
|
| 38 |
+
for name, kws in _KEYWORDS.items():
|
| 39 |
+
for kw in kws:
|
| 40 |
+
if kw in text:
|
| 41 |
+
scores[name] += 1
|
| 42 |
+
|
| 43 |
+
best = max(scores, key=lambda k: scores[k])
|
| 44 |
+
return best if scores[best] > 0 else "support" # type: ignore[return-value]
|
| 45 |
+
|
| 46 |
+
# Uncomment to replace keyword match with model self-classification:
|
| 47 |
+
# return _model_classify(messages)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _last_user_content(messages: list[dict]) -> str:
|
| 51 |
+
for msg in reversed(messages):
|
| 52 |
+
if msg.get("role") == "user":
|
| 53 |
+
return str(msg.get("content", ""))
|
| 54 |
+
return ""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# def _model_classify(messages):
|
| 58 |
+
# """Classify using a lightweight model (e.g. a 0-shot instruct call)."""
|
| 59 |
+
# raise NotImplementedError
|
mcp-server/requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mcp[cli]>=1.3.0
|
| 2 |
+
httpx>=0.27.0
|
| 3 |
+
python-dotenv>=1.0.0
|
mcp-server/server.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MCP server — adaptive UI surface (Path B).
|
| 3 |
+
|
| 4 |
+
Tools return structuredContent that the adaptive.html widget renders
|
| 5 |
+
inline in MCP Apps-compatible hosts (ChatGPT, Claude, Goose, VS Code).
|
| 6 |
+
|
| 7 |
+
Run:
|
| 8 |
+
python -m mcp_server.server # stdio (local MCP client)
|
| 9 |
+
python mcp-server/server.py --http # SSE on :3100 (remote clients)
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import httpx
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
from mcp.server.fastmcp import FastMCP
|
| 19 |
+
|
| 20 |
+
load_dotenv()
|
| 21 |
+
|
| 22 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 23 |
+
from lib.router import route
|
| 24 |
+
|
| 25 |
+
HF_ENDPOINT_URL = os.getenv("HF_ENDPOINT_URL", "")
|
| 26 |
+
HF_TOKEN = os.getenv("HF_TOKEN", "")
|
| 27 |
+
WIDGET_PATH = Path(__file__).parent / "widget" / "adaptive.html"
|
| 28 |
+
|
| 29 |
+
mcp = FastMCP("adaptive-model", version="1.0.0")
|
| 30 |
+
|
| 31 |
+
# ── widget resource ───────────────────────────────────────────────────────────
|
| 32 |
+
# MCP Apps hosts load this URI into an iframe when the tool result carries
|
| 33 |
+
# a _widget key in structuredContent.
|
| 34 |
+
|
| 35 |
+
@mcp.resource("ui://widget/adaptive.html", mime_type="text/html")
|
| 36 |
+
def adaptive_widget() -> str:
|
| 37 |
+
return WIDGET_PATH.read_text(encoding="utf-8")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ── tools ─────────────────────────────────────────────────────────────────────
|
| 41 |
+
|
| 42 |
+
@mcp.tool()
|
| 43 |
+
async def respond(messages: list[dict], mode: str = "auto") -> dict:
|
| 44 |
+
"""
|
| 45 |
+
Run the adaptive model for the current conversation turn.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
messages: Full conversation history, e.g.
|
| 49 |
+
[{"role": "user", "content": "Show me revenue trends"}]
|
| 50 |
+
mode: Adapter override — 'support' | 'analytics' | 'form' | 'auto'
|
| 51 |
+
|
| 52 |
+
Returns a dict with:
|
| 53 |
+
content — text for the model to reason over
|
| 54 |
+
structuredContent — ui_spec for the widget to render interactively
|
| 55 |
+
"""
|
| 56 |
+
resolved = route(messages, None if mode == "auto" else mode)
|
| 57 |
+
|
| 58 |
+
headers = {"Content-Type": "application/json"}
|
| 59 |
+
if HF_TOKEN:
|
| 60 |
+
headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
| 61 |
+
|
| 62 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 63 |
+
r = await client.post(
|
| 64 |
+
HF_ENDPOINT_URL,
|
| 65 |
+
json={"inputs": {"messages": messages, "mode": resolved}},
|
| 66 |
+
headers=headers,
|
| 67 |
+
)
|
| 68 |
+
r.raise_for_status()
|
| 69 |
+
out = r.json()
|
| 70 |
+
|
| 71 |
+
text = out.get("text", "")
|
| 72 |
+
ui_spec = out.get("ui_spec")
|
| 73 |
+
|
| 74 |
+
result: dict = {
|
| 75 |
+
"content": [{"type": "text", "text": text}],
|
| 76 |
+
}
|
| 77 |
+
if ui_spec:
|
| 78 |
+
# _widget tells MCP Apps hosts which resource URI to iframe
|
| 79 |
+
result["structuredContent"] = {**ui_spec, "_widget": "ui://widget/adaptive.html"}
|
| 80 |
+
|
| 81 |
+
return result
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@mcp.tool()
|
| 85 |
+
async def classify_mode(messages: list[dict]) -> dict:
|
| 86 |
+
"""
|
| 87 |
+
Return which adapter would handle this turn without running inference.
|
| 88 |
+
Useful for debugging routing decisions.
|
| 89 |
+
"""
|
| 90 |
+
adapter = route(messages, None)
|
| 91 |
+
return {"adapter": adapter}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ── entry-point ───────────────────────────────────────────────────────────────
|
| 95 |
+
|
| 96 |
+
if __name__ == "__main__":
|
| 97 |
+
import argparse
|
| 98 |
+
parser = argparse.ArgumentParser()
|
| 99 |
+
parser.add_argument("--http", action="store_true",
|
| 100 |
+
help="Serve over SSE on port 3100 instead of stdio")
|
| 101 |
+
args = parser.parse_args()
|
| 102 |
+
|
| 103 |
+
if args.http:
|
| 104 |
+
mcp.run(transport="sse", host="0.0.0.0", port=3100)
|
| 105 |
+
else:
|
| 106 |
+
mcp.run(transport="stdio")
|
mcp-server/widget/adaptive.html
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Adaptive UI Widget</title>
|
| 7 |
+
<style>
|
| 8 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 9 |
+
|
| 10 |
+
:root {
|
| 11 |
+
--bg: #ffffff;
|
| 12 |
+
--surface: #f8f9fa;
|
| 13 |
+
--border: #e2e8f0;
|
| 14 |
+
--text: #1a202c;
|
| 15 |
+
--muted: #718096;
|
| 16 |
+
--accent: #3b82f6;
|
| 17 |
+
--accent-h: #2563eb;
|
| 18 |
+
--danger: #ef4444;
|
| 19 |
+
--success: #22c55e;
|
| 20 |
+
--radius: 8px;
|
| 21 |
+
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
@media (prefers-color-scheme: dark) {
|
| 25 |
+
:root {
|
| 26 |
+
--bg: #1a202c;
|
| 27 |
+
--surface: #2d3748;
|
| 28 |
+
--border: #4a5568;
|
| 29 |
+
--text: #f7fafc;
|
| 30 |
+
--muted: #a0aec0;
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
body {
|
| 35 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 36 |
+
font-size: 14px;
|
| 37 |
+
line-height: 1.5;
|
| 38 |
+
color: var(--text);
|
| 39 |
+
background: var(--bg);
|
| 40 |
+
padding: 16px;
|
| 41 |
+
min-height: 100vh;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* ── generic ── */
|
| 45 |
+
.title { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
|
| 46 |
+
.muted { color: var(--muted); font-size: 12px; }
|
| 47 |
+
.error { color: var(--danger); padding: 12px; background: #fff5f5;
|
| 48 |
+
border: 1px solid #fed7d7; border-radius: var(--radius); }
|
| 49 |
+
.loading { color: var(--muted); padding: 24px; text-align: center; }
|
| 50 |
+
|
| 51 |
+
/* ── form ── */
|
| 52 |
+
.form-field { margin-bottom: 14px; }
|
| 53 |
+
.form-field label { display: block; font-weight: 500; margin-bottom: 4px; }
|
| 54 |
+
.form-field .hint { color: var(--muted); font-size: 12px; margin-bottom: 4px; }
|
| 55 |
+
.form-field input,
|
| 56 |
+
.form-field select,
|
| 57 |
+
.form-field textarea {
|
| 58 |
+
width: 100%; padding: 8px 10px;
|
| 59 |
+
border: 1px solid var(--border); border-radius: var(--radius);
|
| 60 |
+
background: var(--surface); color: var(--text);
|
| 61 |
+
font-size: 14px; outline: none; transition: border-color .15s;
|
| 62 |
+
}
|
| 63 |
+
.form-field input:focus,
|
| 64 |
+
.form-field select:focus,
|
| 65 |
+
.form-field textarea:focus { border-color: var(--accent); }
|
| 66 |
+
.form-field .required-mark { color: var(--danger); margin-left: 2px; }
|
| 67 |
+
.btn {
|
| 68 |
+
display: inline-flex; align-items: center; gap: 6px;
|
| 69 |
+
padding: 8px 18px; border: none; border-radius: var(--radius);
|
| 70 |
+
background: var(--accent); color: #fff; font-size: 14px;
|
| 71 |
+
font-weight: 500; cursor: pointer; transition: background .15s;
|
| 72 |
+
}
|
| 73 |
+
.btn:hover { background: var(--accent-h); }
|
| 74 |
+
.btn:disabled{ opacity: .5; cursor: not-allowed; }
|
| 75 |
+
.form-status { margin-top: 10px; font-size: 13px; }
|
| 76 |
+
.form-status.ok { color: var(--success); }
|
| 77 |
+
.form-status.err { color: var(--danger); }
|
| 78 |
+
|
| 79 |
+
/* ── card ── */
|
| 80 |
+
.card {
|
| 81 |
+
background: var(--surface); border: 1px solid var(--border);
|
| 82 |
+
border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px;
|
| 83 |
+
}
|
| 84 |
+
.card-item { display: flex; justify-content: space-between;
|
| 85 |
+
padding: 6px 0; border-bottom: 1px solid var(--border); }
|
| 86 |
+
.card-item:last-child { border-bottom: none; }
|
| 87 |
+
.card-item .key { color: var(--muted); }
|
| 88 |
+
.card-item .value { font-weight: 500; }
|
| 89 |
+
|
| 90 |
+
/* ── table ── */
|
| 91 |
+
table { width: 100%; border-collapse: collapse; }
|
| 92 |
+
th { background: var(--surface); font-weight: 600; text-align: left;
|
| 93 |
+
padding: 8px 10px; border-bottom: 2px solid var(--border); }
|
| 94 |
+
td { padding: 7px 10px; border-bottom: 1px solid var(--border); }
|
| 95 |
+
tr:last-child td { border-bottom: none; }
|
| 96 |
+
tr:hover td { background: var(--surface); }
|
| 97 |
+
|
| 98 |
+
/* ── chart (SVG bar) ── */
|
| 99 |
+
.chart-wrap { overflow-x: auto; }
|
| 100 |
+
.chart-bars { display: flex; align-items: flex-end; gap: 8px;
|
| 101 |
+
height: 160px; padding-bottom: 24px; }
|
| 102 |
+
.bar-col { display: flex; flex-direction: column; align-items: center;
|
| 103 |
+
gap: 4px; flex: 1; min-width: 28px; }
|
| 104 |
+
.bar-fill { width: 100%; background: var(--accent); border-radius: 4px 4px 0 0;
|
| 105 |
+
transition: height .3s ease; cursor: pointer; }
|
| 106 |
+
.bar-fill:hover { background: var(--accent-h); }
|
| 107 |
+
.bar-label { font-size: 10px; color: var(--muted); text-align: center;
|
| 108 |
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
| 109 |
+
max-width: 56px; }
|
| 110 |
+
.bar-val { font-size: 10px; color: var(--muted); }
|
| 111 |
+
</style>
|
| 112 |
+
</head>
|
| 113 |
+
<body>
|
| 114 |
+
|
| 115 |
+
<div id="root"><p class="loading">Loading…</p></div>
|
| 116 |
+
|
| 117 |
+
<script>
|
| 118 |
+
// ── bridge: try MCP Apps / OpenAI Apps SDK, fall back to postMessage ──────────
|
| 119 |
+
|
| 120 |
+
async function getSpec() {
|
| 121 |
+
// Path 1: OpenAI MCP Apps bridge
|
| 122 |
+
if (window.openai?.ready) {
|
| 123 |
+
return new Promise(resolve => {
|
| 124 |
+
window.openai.ready(async api => {
|
| 125 |
+
const state = await api.state.get();
|
| 126 |
+
resolve(state?.structuredContent ?? null);
|
| 127 |
+
api.state.onChanged(s => render(s?.structuredContent ?? null));
|
| 128 |
+
});
|
| 129 |
+
});
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// Path 2: postMessage from host (generic MCP clients)
|
| 133 |
+
return new Promise(resolve => {
|
| 134 |
+
let resolved = false;
|
| 135 |
+
window.addEventListener("message", ev => {
|
| 136 |
+
const d = ev.data;
|
| 137 |
+
if (d?.type === "mcp:structuredContent") {
|
| 138 |
+
if (!resolved) { resolved = true; resolve(d.payload); }
|
| 139 |
+
else render(d.payload);
|
| 140 |
+
}
|
| 141 |
+
});
|
| 142 |
+
// Signal readiness so host knows to send data
|
| 143 |
+
window.parent?.postMessage({ type: "mcp:ready" }, "*");
|
| 144 |
+
// Resolve null after 4s if nothing arrives (show helpful empty state)
|
| 145 |
+
setTimeout(() => { if (!resolved) { resolved = true; resolve(null); } }, 4000);
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// ── renderers ─────────────────────────────────────────────────────────────────
|
| 150 |
+
|
| 151 |
+
function render(spec) {
|
| 152 |
+
const root = document.getElementById("root");
|
| 153 |
+
if (!spec) { root.innerHTML = `<p class="muted">No UI data received.</p>`; return; }
|
| 154 |
+
|
| 155 |
+
const { component, props = {}, _widget, ...rest } = spec;
|
| 156 |
+
const effectiveProps = Object.keys(props).length ? props : rest;
|
| 157 |
+
|
| 158 |
+
try {
|
| 159 |
+
const map = { form: renderForm, chart: renderChart, card: renderCard, table: renderTable };
|
| 160 |
+
const fn = map[component];
|
| 161 |
+
root.innerHTML = fn ? fn(effectiveProps) : `<p class="muted">[${component}]</p>`;
|
| 162 |
+
if (component === "form") attachFormHandlers(effectiveProps);
|
| 163 |
+
if (component === "chart") drawBars(effectiveProps);
|
| 164 |
+
} catch (e) {
|
| 165 |
+
root.innerHTML = `<p class="error">Render error: ${e.message}</p>`;
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// ── form ──────────────────────────────────────────────────────────────────────
|
| 170 |
+
|
| 171 |
+
function renderForm(p) {
|
| 172 |
+
const fields = (p.fields || []).map(f => {
|
| 173 |
+
const req = f.required ? `<span class="required-mark">*</span>` : "";
|
| 174 |
+
const hint = f.hint ? `<p class="hint">${esc(f.hint)}</p>` : "";
|
| 175 |
+
const ctrl = buildInput(f);
|
| 176 |
+
return `<div class="form-field">
|
| 177 |
+
<label for="f_${esc(f.name)}">${esc(f.label || f.name)}${req}</label>
|
| 178 |
+
${hint}${ctrl}
|
| 179 |
+
</div>`;
|
| 180 |
+
}).join("");
|
| 181 |
+
|
| 182 |
+
return `
|
| 183 |
+
${p.title ? `<p class="title">${esc(p.title)}</p>` : ""}
|
| 184 |
+
<form id="adaptiveForm">
|
| 185 |
+
${fields}
|
| 186 |
+
<button type="submit" class="btn" id="submitBtn">
|
| 187 |
+
${esc(p.submitLabel || "Submit")}
|
| 188 |
+
</button>
|
| 189 |
+
<div class="form-status" id="formStatus"></div>
|
| 190 |
+
</form>`;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function buildInput(f) {
|
| 194 |
+
const id = `f_${esc(f.name)}`;
|
| 195 |
+
const base = `id="${id}" name="${esc(f.name)}" ${f.required ? "required" : ""}`;
|
| 196 |
+
if (f.type === "select") {
|
| 197 |
+
const opts = (f.options || []).map(o =>
|
| 198 |
+
`<option value="${esc(o.value ?? o)}">${esc(o.label ?? o)}</option>`).join("");
|
| 199 |
+
return `<select ${base}>${opts}</select>`;
|
| 200 |
+
}
|
| 201 |
+
if (f.type === "textarea") {
|
| 202 |
+
return `<textarea ${base} rows="3"></textarea>`;
|
| 203 |
+
}
|
| 204 |
+
return `<input type="${esc(f.type || "text")}" ${base}
|
| 205 |
+
placeholder="${esc(f.placeholder || "")}" value="${esc(f.default || "")}">`;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function attachFormHandlers(p) {
|
| 209 |
+
document.getElementById("adaptiveForm")?.addEventListener("submit", async ev => {
|
| 210 |
+
ev.preventDefault();
|
| 211 |
+
const btn = document.getElementById("submitBtn");
|
| 212 |
+
const status = document.getElementById("formStatus");
|
| 213 |
+
btn.disabled = true;
|
| 214 |
+
status.textContent = "";
|
| 215 |
+
|
| 216 |
+
const fd = new FormData(ev.target);
|
| 217 |
+
const data = Object.fromEntries(fd.entries());
|
| 218 |
+
|
| 219 |
+
try {
|
| 220 |
+
if (window.openai?.callTool) {
|
| 221 |
+
await window.openai.callTool("form_submit", { data, formId: p.formId });
|
| 222 |
+
} else {
|
| 223 |
+
window.parent?.postMessage({ type: "mcp:toolCall",
|
| 224 |
+
tool: "form_submit", args: { data, formId: p.formId } }, "*");
|
| 225 |
+
}
|
| 226 |
+
status.className = "form-status ok";
|
| 227 |
+
status.textContent = p.successMessage || "Submitted successfully.";
|
| 228 |
+
} catch (e) {
|
| 229 |
+
status.className = "form-status err";
|
| 230 |
+
status.textContent = `Error: ${e.message}`;
|
| 231 |
+
} finally {
|
| 232 |
+
btn.disabled = false;
|
| 233 |
+
}
|
| 234 |
+
});
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// ── chart ─────────────────────────────────────────────────────────────────────
|
| 238 |
+
|
| 239 |
+
function renderChart(p) {
|
| 240 |
+
return `
|
| 241 |
+
${p.title ? `<p class="title">${esc(p.title)}</p>` : ""}
|
| 242 |
+
<div class="chart-wrap">
|
| 243 |
+
<div class="chart-bars" id="chartBars"></div>
|
| 244 |
+
</div>`;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
function drawBars(p) {
|
| 248 |
+
const data = p.data || {};
|
| 249 |
+
const labels = data.labels || p.labels || [];
|
| 250 |
+
const ds = data.datasets || p.datasets || [];
|
| 251 |
+
const values = ds[0]?.data || data.values || [];
|
| 252 |
+
const max = Math.max(...values, 1);
|
| 253 |
+
const bars = document.getElementById("chartBars");
|
| 254 |
+
if (!bars) return;
|
| 255 |
+
|
| 256 |
+
bars.innerHTML = labels.map((lbl, i) => {
|
| 257 |
+
const v = values[i] ?? 0;
|
| 258 |
+
const pct = Math.round((v / max) * 140);
|
| 259 |
+
return `<div class="bar-col">
|
| 260 |
+
<span class="bar-val">${v}</span>
|
| 261 |
+
<div class="bar-fill" style="height:${pct}px" title="${esc(String(lbl))}: ${v}"></div>
|
| 262 |
+
<span class="bar-label">${esc(String(lbl))}</span>
|
| 263 |
+
</div>`;
|
| 264 |
+
}).join("");
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// ── card ──────────────────────────────────────────────────────────────────────
|
| 268 |
+
|
| 269 |
+
function renderCard(p) {
|
| 270 |
+
const items = (p.items || []).map(it => {
|
| 271 |
+
if (typeof it === "object") {
|
| 272 |
+
const k = it.label ?? it.key ?? "";
|
| 273 |
+
return `<div class="card-item">
|
| 274 |
+
<span class="key">${esc(k)}</span>
|
| 275 |
+
<span class="value">${esc(String(it.value ?? ""))}</span>
|
| 276 |
+
</div>`;
|
| 277 |
+
}
|
| 278 |
+
return `<div class="card-item"><span>${esc(String(it))}</span></div>`;
|
| 279 |
+
}).join("");
|
| 280 |
+
|
| 281 |
+
return `<div class="card">
|
| 282 |
+
${p.title ? `<p class="title">${esc(p.title)}</p>` : ""}
|
| 283 |
+
${p.body ? `<p style="margin-bottom:10px">${esc(p.body)}</p>` : ""}
|
| 284 |
+
${items}
|
| 285 |
+
</div>`;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
// ── table ─────────────────────────────────────────────────────────────────────
|
| 289 |
+
|
| 290 |
+
function renderTable(p) {
|
| 291 |
+
const cols = p.columns || [];
|
| 292 |
+
const rows = p.rows || [];
|
| 293 |
+
const hdr = cols.map(c => `<th>${esc(c)}</th>`).join("");
|
| 294 |
+
const body = rows.map(row => {
|
| 295 |
+
const cells = Array.isArray(row)
|
| 296 |
+
? row.map(c => `<td>${esc(String(c))}</td>`).join("")
|
| 297 |
+
: cols.map(c => `<td>${esc(String(row[c] ?? ""))}</td>`).join("");
|
| 298 |
+
return `<tr>${cells}</tr>`;
|
| 299 |
+
}).join("");
|
| 300 |
+
|
| 301 |
+
return `
|
| 302 |
+
${p.title ? `<p class="title">${esc(p.title)}</p>` : ""}
|
| 303 |
+
<div style="overflow-x:auto">
|
| 304 |
+
<table><thead><tr>${hdr}</tr></thead><tbody>${body}</tbody></table>
|
| 305 |
+
</div>`;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
// ── utils ─────────────────────────────────────────────────────────────────────
|
| 309 |
+
|
| 310 |
+
function esc(s) {
|
| 311 |
+
return String(s ?? "").replace(/&/g,"&").replace(/</g,"<")
|
| 312 |
+
.replace(/>/g,">").replace(/"/g,""");
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
// ── boot ──────────────────────────────────────────────────────────────────────
|
| 316 |
+
|
| 317 |
+
getSpec().then(render).catch(e => {
|
| 318 |
+
document.getElementById("root").innerHTML =
|
| 319 |
+
`<p class="error">Bridge error: ${e.message}</p>`;
|
| 320 |
+
});
|
| 321 |
+
</script>
|
| 322 |
+
</body>
|
| 323 |
+
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
transformers>=4.44.0
|
| 2 |
+
peft>=0.12.0
|
| 3 |
+
torch>=2.3.0
|
| 4 |
+
accelerate>=0.33.0
|
training/generate_examples.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generates synthetic fine-tuning examples in the <ui>…</ui> format.
|
| 3 |
+
|
| 4 |
+
Each adapter needs examples where the assistant emits a valid ui_spec
|
| 5 |
+
wrapped in <ui>…</ui> tags followed by a prose explanation.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python training/generate_examples.py --mode support --n 50 -o data/support.jsonl
|
| 9 |
+
python training/generate_examples.py --mode analytics --n 50 -o data/analytics.jsonl
|
| 10 |
+
python training/generate_examples.py --mode form --n 50 -o data/form.jsonl
|
| 11 |
+
python training/generate_examples.py --all -o data/
|
| 12 |
+
"""
|
| 13 |
+
import argparse
|
| 14 |
+
import json
|
| 15 |
+
import random
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Callable
|
| 18 |
+
|
| 19 |
+
# ── support examples ──────────────────────────────────────────────────────────
|
| 20 |
+
|
| 21 |
+
_SUPPORT_ISSUES = [
|
| 22 |
+
("login fails with 401", "Authentication error — token may be expired."),
|
| 23 |
+
("page not loading", "Possible network timeout or server error."),
|
| 24 |
+
("export button missing", "Feature may be behind a permission flag."),
|
| 25 |
+
("data not syncing", "Background sync may be paused or failing silently."),
|
| 26 |
+
("slow performance", "Cache may be stale — clearing it often helps."),
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
def _support_example() -> dict:
|
| 30 |
+
issue, summary = random.choice(_SUPPORT_ISSUES)
|
| 31 |
+
steps = ["Check console for error codes", "Clear cache and retry",
|
| 32 |
+
"Verify account permissions", "Contact support with error ID"]
|
| 33 |
+
spec = {
|
| 34 |
+
"component": "card",
|
| 35 |
+
"props": {
|
| 36 |
+
"title": f"Troubleshooting: {issue}",
|
| 37 |
+
"body": summary,
|
| 38 |
+
"items": [{"label": f"Step {i+1}", "value": s} for i, s in enumerate(steps)],
|
| 39 |
+
},
|
| 40 |
+
}
|
| 41 |
+
user = f"I have an issue: {issue}"
|
| 42 |
+
assist = f"<ui>{json.dumps(spec)}</ui>\n\n{summary} Here are the recommended steps."
|
| 43 |
+
return {"messages": [{"role": "user", "content": user},
|
| 44 |
+
{"role": "assistant", "content": assist}]}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ── analytics examples ────────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
_METRICS = ["revenue", "active users", "churn rate", "conversion", "page views"]
|
| 50 |
+
_PERIODS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
|
| 51 |
+
|
| 52 |
+
def _analytics_example() -> dict:
|
| 53 |
+
metric = random.choice(_METRICS)
|
| 54 |
+
values = [random.randint(100, 900) for _ in _PERIODS]
|
| 55 |
+
spec = {
|
| 56 |
+
"component": "chart",
|
| 57 |
+
"props": {
|
| 58 |
+
"title": f"{metric.title()} — Last 6 Months",
|
| 59 |
+
"type": "bar",
|
| 60 |
+
"data": {
|
| 61 |
+
"labels": _PERIODS,
|
| 62 |
+
"datasets": [{"label": metric.title(), "data": values}],
|
| 63 |
+
},
|
| 64 |
+
},
|
| 65 |
+
}
|
| 66 |
+
user = f"Show me {metric} trends over the last 6 months"
|
| 67 |
+
insight = f"Peak was {_PERIODS[values.index(max(values))]} at {max(values)}."
|
| 68 |
+
assist = f"<ui>{json.dumps(spec)}</ui>\n\n{insight}"
|
| 69 |
+
return {"messages": [{"role": "user", "content": user},
|
| 70 |
+
{"role": "assistant", "content": assist}]}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ── form examples ─────────────────────────────────────────────────────────────
|
| 74 |
+
|
| 75 |
+
_FORMS = [
|
| 76 |
+
{
|
| 77 |
+
"title": "Contact Support",
|
| 78 |
+
"submitLabel": "Send Request",
|
| 79 |
+
"fields": [
|
| 80 |
+
{"name": "name", "label": "Full Name", "type": "text", "required": True},
|
| 81 |
+
{"name": "email", "label": "Email", "type": "email", "required": True},
|
| 82 |
+
{"name": "subject", "label": "Subject", "type": "text"},
|
| 83 |
+
{"name": "message", "label": "Message", "type": "textarea", "required": True},
|
| 84 |
+
],
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"title": "Account Registration",
|
| 88 |
+
"submitLabel": "Create Account",
|
| 89 |
+
"fields": [
|
| 90 |
+
{"name": "username", "label": "Username", "type": "text", "required": True},
|
| 91 |
+
{"name": "email", "label": "Email", "type": "email", "required": True},
|
| 92 |
+
{"name": "password", "label": "Password", "type": "password", "required": True},
|
| 93 |
+
{"name": "plan", "label": "Plan", "type": "select",
|
| 94 |
+
"options": ["Free", "Pro", "Enterprise"]},
|
| 95 |
+
],
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"title": "Export Data",
|
| 99 |
+
"submitLabel": "Export",
|
| 100 |
+
"fields": [
|
| 101 |
+
{"name": "format", "label": "Format", "type": "select",
|
| 102 |
+
"options": ["CSV", "JSON", "Excel"]},
|
| 103 |
+
{"name": "date_from", "label": "From", "type": "date", "required": True},
|
| 104 |
+
{"name": "date_to", "label": "To", "type": "date", "required": True},
|
| 105 |
+
],
|
| 106 |
+
},
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
def _form_example() -> dict:
|
| 110 |
+
form_def = random.choice(_FORMS)
|
| 111 |
+
spec = {"component": "form", "props": form_def}
|
| 112 |
+
user = f"I need to {form_def['submitLabel'].lower()}"
|
| 113 |
+
assist = (f"<ui>{json.dumps(spec)}</ui>\n\n"
|
| 114 |
+
f"Please fill in the {form_def['title']} form below.")
|
| 115 |
+
return {"messages": [{"role": "user", "content": user},
|
| 116 |
+
{"role": "assistant", "content": assist}]}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ── generator dispatch ────────────────────────────────────────────────────────
|
| 120 |
+
|
| 121 |
+
_GENERATORS: dict[str, Callable[[], dict]] = {
|
| 122 |
+
"support": _support_example,
|
| 123 |
+
"analytics": _analytics_example,
|
| 124 |
+
"form": _form_example,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def generate(mode: str, n: int) -> list[dict]:
|
| 129 |
+
fn = _GENERATORS[mode]
|
| 130 |
+
return [fn() for _ in range(n)]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def write_jsonl(examples: list[dict], path: Path) -> None:
|
| 134 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 135 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 136 |
+
for ex in examples:
|
| 137 |
+
f.write(json.dumps(ex, ensure_ascii=False) + "\n")
|
| 138 |
+
print(f"Wrote {len(examples)} examples → {path}")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
| 142 |
+
|
| 143 |
+
def main() -> None:
|
| 144 |
+
p = argparse.ArgumentParser(description="Generate synthetic adapter training data")
|
| 145 |
+
p.add_argument("--mode", choices=list(_GENERATORS.keys()),
|
| 146 |
+
help="Adapter mode to generate for")
|
| 147 |
+
p.add_argument("--all", action="store_true",
|
| 148 |
+
help="Generate for all modes (saves to <output>/<mode>.jsonl)")
|
| 149 |
+
p.add_argument("--n", type=int, default=50, help="Examples per mode")
|
| 150 |
+
p.add_argument("-o", "--output", default="data", help="Output path or directory")
|
| 151 |
+
args = p.parse_args()
|
| 152 |
+
|
| 153 |
+
modes = list(_GENERATORS.keys()) if args.all else ([args.mode] if args.mode else [])
|
| 154 |
+
if not modes:
|
| 155 |
+
p.error("Specify --mode or --all")
|
| 156 |
+
|
| 157 |
+
out = Path(args.output)
|
| 158 |
+
for mode in modes:
|
| 159 |
+
dest = (out / f"{mode}.jsonl") if args.all or out.suffix != ".jsonl" else out
|
| 160 |
+
write_jsonl(generate(mode, args.n), dest)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
if __name__ == "__main__":
|
| 164 |
+
main()
|
training/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.3.0
|
| 2 |
+
transformers>=4.44.0
|
| 3 |
+
peft>=0.12.0
|
| 4 |
+
trl>=0.11.0
|
| 5 |
+
bitsandbytes>=0.43.0
|
| 6 |
+
accelerate>=0.33.0
|
| 7 |
+
datasets>=2.20.0
|
| 8 |
+
huggingface-hub>=0.24.0
|
training/train_adapter.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Train one LoRA adapter per mode and push to Hugging Face Hub.
|
| 3 |
+
|
| 4 |
+
Each adapter teaches the base model to emit <ui>{json}</ui> then prose
|
| 5 |
+
for its mode, so the handler in hf-endpoint/handler.py can parse it.
|
| 6 |
+
|
| 7 |
+
GPU requirements
|
| 8 |
+
----------------
|
| 9 |
+
QLoRA (default, --no-qlora not set): ~8 GB VRAM (RTX 3080 / T4 / L4)
|
| 10 |
+
bf16 full precision (--no-qlora): ~24 GB VRAM (A10G / A100)
|
| 11 |
+
|
| 12 |
+
Colab/Kaggle tip: use the free T4 with QLoRA for quick experiments.
|
| 13 |
+
|
| 14 |
+
Usage
|
| 15 |
+
-----
|
| 16 |
+
# Login first
|
| 17 |
+
huggingface-cli login
|
| 18 |
+
|
| 19 |
+
# Train one mode (generates data automatically)
|
| 20 |
+
python training/train_adapter.py --mode support --hub-org your-org
|
| 21 |
+
|
| 22 |
+
# Train all three modes back-to-back
|
| 23 |
+
python training/train_adapter.py --all --hub-org your-org
|
| 24 |
+
|
| 25 |
+
# Use your own JSONL instead of the synthetic generator
|
| 26 |
+
python training/train_adapter.py --mode form --data data/form.jsonl --hub-org your-org
|
| 27 |
+
|
| 28 |
+
# Smoke-test with tiny run (no push)
|
| 29 |
+
python training/train_adapter.py --mode support --hub-org your-org --n 20 --epochs 1 --dry-run
|
| 30 |
+
"""
|
| 31 |
+
import argparse
|
| 32 |
+
import json
|
| 33 |
+
import os
|
| 34 |
+
import sys
|
| 35 |
+
from pathlib import Path
|
| 36 |
+
from typing import Optional
|
| 37 |
+
|
| 38 |
+
import torch
|
| 39 |
+
from datasets import Dataset
|
| 40 |
+
from peft import LoraConfig, TaskType
|
| 41 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
|
| 42 |
+
from trl import SFTConfig, SFTTrainer
|
| 43 |
+
|
| 44 |
+
# Allow running from repo root or training/
|
| 45 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 46 |
+
from training.generate_examples import _GENERATORS, generate
|
| 47 |
+
|
| 48 |
+
# ── constants ─────────────────────────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
DEFAULT_BASE = os.getenv("BASE_MODEL", "Qwen/Qwen2.5-7B-Instruct")
|
| 51 |
+
|
| 52 |
+
# Covers Qwen2.5, Llama-3.x, Mistral-0.3.
|
| 53 |
+
# For Phi-3/3.5 replace with ["qkv_proj", "o_proj", "gate_up_proj", "down_proj"].
|
| 54 |
+
LORA_TARGETS = [
|
| 55 |
+
"q_proj", "k_proj", "v_proj", "o_proj",
|
| 56 |
+
"gate_proj", "up_proj", "down_proj",
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── data helpers ──────────────────────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
def load_examples(mode: str, n: int, jsonl_path: Optional[str]) -> list[dict]:
|
| 63 |
+
if jsonl_path:
|
| 64 |
+
p = Path(jsonl_path)
|
| 65 |
+
if not p.exists():
|
| 66 |
+
raise FileNotFoundError(f"JSONL not found: {p}")
|
| 67 |
+
rows = [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()]
|
| 68 |
+
print(f" Loaded {len(rows)} examples from {p}")
|
| 69 |
+
return rows
|
| 70 |
+
|
| 71 |
+
rows = generate(mode, n)
|
| 72 |
+
print(f" Generated {len(rows)} synthetic examples for '{mode}'")
|
| 73 |
+
return rows
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def build_dataset(examples: list[dict], tokenizer) -> tuple[Dataset, Dataset]:
|
| 77 |
+
texts = [
|
| 78 |
+
tokenizer.apply_chat_template(
|
| 79 |
+
ex["messages"], tokenize=False, add_generation_prompt=False
|
| 80 |
+
)
|
| 81 |
+
for ex in examples
|
| 82 |
+
]
|
| 83 |
+
full = Dataset.from_dict({"text": texts})
|
| 84 |
+
split = full.train_test_split(test_size=0.1, seed=42)
|
| 85 |
+
return split["train"], split["test"]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ── model helpers ─────────────────────────────────────────────────────────────
|
| 89 |
+
|
| 90 |
+
def load_base_model(base: str, use_qlora: bool) -> AutoModelForCausalLM:
|
| 91 |
+
bnb_cfg = None
|
| 92 |
+
if use_qlora:
|
| 93 |
+
bnb_cfg = BitsAndBytesConfig(
|
| 94 |
+
load_in_4bit=True,
|
| 95 |
+
bnb_4bit_quant_type="nf4",
|
| 96 |
+
bnb_4bit_compute_dtype=torch.bfloat16,
|
| 97 |
+
bnb_4bit_use_double_quant=True,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 101 |
+
base,
|
| 102 |
+
quantization_config=bnb_cfg,
|
| 103 |
+
torch_dtype=torch.bfloat16 if not use_qlora else None,
|
| 104 |
+
device_map="auto",
|
| 105 |
+
trust_remote_code=True,
|
| 106 |
+
)
|
| 107 |
+
model.config.use_cache = False
|
| 108 |
+
return model
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def lora_config() -> LoraConfig:
|
| 112 |
+
return LoraConfig(
|
| 113 |
+
r=16,
|
| 114 |
+
lora_alpha=32,
|
| 115 |
+
lora_dropout=0.05,
|
| 116 |
+
target_modules=LORA_TARGETS,
|
| 117 |
+
task_type=TaskType.CAUSAL_LM,
|
| 118 |
+
bias="none",
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ── training ──────────────────────────────────────────────────────────────────
|
| 123 |
+
|
| 124 |
+
def train_one(
|
| 125 |
+
mode: str,
|
| 126 |
+
base_model: str,
|
| 127 |
+
hub_org: str,
|
| 128 |
+
n_examples: int,
|
| 129 |
+
epochs: int,
|
| 130 |
+
jsonl_path: Optional[str],
|
| 131 |
+
output_root: Path,
|
| 132 |
+
use_qlora: bool,
|
| 133 |
+
dry_run: bool,
|
| 134 |
+
) -> None:
|
| 135 |
+
print(f"\n{'='*60}")
|
| 136 |
+
print(f" Adapter: {mode}")
|
| 137 |
+
print(f" Base: {base_model}")
|
| 138 |
+
print(f" QLoRA: {use_qlora}")
|
| 139 |
+
print(f"{'='*60}")
|
| 140 |
+
|
| 141 |
+
tokenizer = AutoTokenizer.from_pretrained(base_model, trust_remote_code=True)
|
| 142 |
+
if tokenizer.pad_token is None:
|
| 143 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 144 |
+
tokenizer.padding_side = "right" # prevents warning with flash-attn
|
| 145 |
+
|
| 146 |
+
examples = load_examples(mode, n_examples, jsonl_path)
|
| 147 |
+
train_ds, eval_ds = build_dataset(examples, tokenizer)
|
| 148 |
+
print(f" Train: {len(train_ds)} Eval: {len(eval_ds)}")
|
| 149 |
+
|
| 150 |
+
if dry_run:
|
| 151 |
+
print(" [dry-run] skipping model load, training, and push")
|
| 152 |
+
sample = train_ds[0]["text"][:200]
|
| 153 |
+
print(f" Sample text:\n {sample!r}…\n")
|
| 154 |
+
return
|
| 155 |
+
|
| 156 |
+
model = load_base_model(base_model, use_qlora)
|
| 157 |
+
out_dir = output_root / f"adapter-{mode}"
|
| 158 |
+
|
| 159 |
+
sft_cfg = SFTConfig(
|
| 160 |
+
output_dir=str(out_dir),
|
| 161 |
+
num_train_epochs=epochs,
|
| 162 |
+
per_device_train_batch_size=2,
|
| 163 |
+
gradient_accumulation_steps=4, # effective batch size 8
|
| 164 |
+
gradient_checkpointing=True, # saves ~30 % VRAM
|
| 165 |
+
learning_rate=2e-4,
|
| 166 |
+
warmup_ratio=0.05,
|
| 167 |
+
lr_scheduler_type="cosine",
|
| 168 |
+
bf16=not use_qlora,
|
| 169 |
+
fp16=False,
|
| 170 |
+
logging_steps=10,
|
| 171 |
+
eval_strategy="epoch",
|
| 172 |
+
save_strategy="epoch",
|
| 173 |
+
load_best_model_at_end=True,
|
| 174 |
+
metric_for_best_model="eval_loss",
|
| 175 |
+
max_seq_length=1024,
|
| 176 |
+
dataset_text_field="text",
|
| 177 |
+
packing=True, # packs short examples → less padding waste
|
| 178 |
+
report_to="none",
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
trainer = SFTTrainer(
|
| 182 |
+
model=model,
|
| 183 |
+
args=sft_cfg,
|
| 184 |
+
train_dataset=train_ds,
|
| 185 |
+
eval_dataset=eval_ds,
|
| 186 |
+
peft_config=lora_config(),
|
| 187 |
+
processing_class=tokenizer,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
print(f"\n Training {mode} adapter…")
|
| 191 |
+
trainer.train()
|
| 192 |
+
|
| 193 |
+
trainer.save_model(str(out_dir))
|
| 194 |
+
tokenizer.save_pretrained(str(out_dir))
|
| 195 |
+
print(f" Saved locally → {out_dir}")
|
| 196 |
+
|
| 197 |
+
hub_repo = f"{hub_org}/adapter-{mode}"
|
| 198 |
+
print(f" Pushing → https://huggingface.co/{hub_repo}")
|
| 199 |
+
trainer.push_to_hub(hub_repo, commit_message=f"train: {mode} adapter epoch {epochs}")
|
| 200 |
+
tokenizer.push_to_hub(hub_repo)
|
| 201 |
+
print(f" Done: https://huggingface.co/{hub_repo}")
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
| 205 |
+
|
| 206 |
+
def main() -> None:
|
| 207 |
+
p = argparse.ArgumentParser(
|
| 208 |
+
description="Train LoRA adapters for adaptive-model and push to HF Hub",
|
| 209 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 210 |
+
)
|
| 211 |
+
p.add_argument("--mode", choices=list(_GENERATORS.keys()),
|
| 212 |
+
help="Single adapter mode to train")
|
| 213 |
+
p.add_argument("--all", dest="train_all", action="store_true",
|
| 214 |
+
help="Train all modes sequentially")
|
| 215 |
+
p.add_argument("--hub-org", required=True,
|
| 216 |
+
help="HF Hub org or username (e.g. your-org)")
|
| 217 |
+
p.add_argument("--base-model", default=DEFAULT_BASE,
|
| 218 |
+
help=f"Base model ID on HF Hub (default: {DEFAULT_BASE})")
|
| 219 |
+
p.add_argument("--n", type=int, default=200,
|
| 220 |
+
help="Synthetic examples to generate per mode (default: 200)")
|
| 221 |
+
p.add_argument("--epochs", type=int, default=3,
|
| 222 |
+
help="Training epochs per adapter (default: 3)")
|
| 223 |
+
p.add_argument("--data", metavar="JSONL",
|
| 224 |
+
help="Path to existing JSONL — skips generation (single --mode only)")
|
| 225 |
+
p.add_argument("--output-dir", default="./trained-adapters",
|
| 226 |
+
help="Local root for saved adapter checkpoints")
|
| 227 |
+
p.add_argument("--no-qlora", action="store_true",
|
| 228 |
+
help="Disable 4-bit QLoRA — needs 24 GB+ VRAM")
|
| 229 |
+
p.add_argument("--dry-run", action="store_true",
|
| 230 |
+
help="Parse args and show dataset stats without training or pushing")
|
| 231 |
+
args = p.parse_args()
|
| 232 |
+
|
| 233 |
+
modes: list[str] = list(_GENERATORS.keys()) if args.train_all else \
|
| 234 |
+
([args.mode] if args.mode else [])
|
| 235 |
+
if not modes:
|
| 236 |
+
p.error("Specify --mode <name> or --all")
|
| 237 |
+
if args.data and len(modes) > 1:
|
| 238 |
+
p.error("--data can only be combined with a single --mode")
|
| 239 |
+
|
| 240 |
+
output_root = Path(args.output_dir)
|
| 241 |
+
|
| 242 |
+
for mode in modes:
|
| 243 |
+
train_one(
|
| 244 |
+
mode=mode,
|
| 245 |
+
base_model=args.base_model,
|
| 246 |
+
hub_org=args.hub_org,
|
| 247 |
+
n_examples=args.n,
|
| 248 |
+
epochs=args.epochs,
|
| 249 |
+
jsonl_path=args.data if len(modes) == 1 else None,
|
| 250 |
+
output_root=output_root,
|
| 251 |
+
use_qlora=not args.no_qlora,
|
| 252 |
+
dry_run=args.dry_run,
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
if not args.dry_run:
|
| 256 |
+
print("\nAll adapters pushed. Add these to your .env:")
|
| 257 |
+
for mode in modes:
|
| 258 |
+
print(f" ADAPTER_{mode.upper()}={args.hub_org}/adapter-{mode}")
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
if __name__ == "__main__":
|
| 262 |
+
main()
|