joduor commited on
Commit
1de92bf
·
verified ·
1 Parent(s): 4d1f9dd

init: adaptive-model — handler, gateway, mcp-server, training

Browse files
.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,"&amp;").replace(/</g,"&lt;")
312
+ .replace(/>/g,"&gt;").replace(/"/g,"&quot;");
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()