Meteord commited on
Commit
e8bd0fc
·
verified ·
1 Parent(s): 47d259d

Sync from GitHub via hub-sync

Browse files
README.md CHANGED
@@ -51,6 +51,27 @@ HF_TOKEN=<a Hugging Face token with write access to build-small-hackathon/smolna
51
 
52
  The sync action mirrors files over the Hub API rather than pushing Git history. This avoids the previous large-file history problem as long as generated training data and model artifacts are not copied into `_space/`.
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  ### llama.cpp Deployment Target
55
 
56
  The intended production path should use the `build-small-hackathon/CodeFlow` llama.cpp pattern: the Gradio Space runs `llama-cpp-python` directly, downloads a GGUF with `huggingface_hub`, and serves a custom frontend through `gr.Server`.
 
51
 
52
  The sync action mirrors files over the Hub API rather than pushing Git history. This avoids the previous large-file history problem as long as generated training data and model artifacts are not copied into `_space/`.
53
 
54
+ ### PEFT Adapter Deployment
55
+
56
+ The first CKAN specialist is a PEFT LoRA adapter, so the fastest Space integration path is the transformers backend:
57
+
58
+ ```text
59
+ SMOLNALYSIS_MINICPM_BACKEND=transformers
60
+ SMOLNALYSIS_MINICPM_TRANSFORMERS_MODEL_ID=openbmb/MiniCPM5-1B
61
+ SMOLNALYSIS_MINICPM_CKAN_RETRIEVAL_ADAPTER_REPO_ID=build-small-hackathon/smolnalysis-ckan-retrieval-minicpm5-lora
62
+ SMOLNALYSIS_MINICPM_CKAN_RETRIEVAL_TEMPERATURE=0
63
+ SMOLNALYSIS_MINICPM_MAX_NEW_TOKENS=384
64
+ ```
65
+
66
+ Upload the trained adapter from a machine with `HF_TOKEN` set:
67
+
68
+ ```bash
69
+ uv run python train/ckan/upload_adapter_to_hf.py \
70
+ --repo-id build-small-hackathon/smolnalysis-ckan-retrieval-minicpm5-lora
71
+ ```
72
+
73
+ The upload script only pushes the deployable top-level adapter files and skips checkpoints and optimizer state. Keep the CKAN agent validator enabled in production; the LoRA proposes tool actions, while Python validates observed package/resource ids and executes tools.
74
+
75
  ### llama.cpp Deployment Target
76
 
77
  The intended production path should use the `build-small-hackathon/CodeFlow` llama.cpp pattern: the Gradio Space runs `llama-cpp-python` directly, downloads a GGUF with `huggingface_hub`, and serves a custom frontend through `gr.Server`.
app/backend/minicpm_transformers.py CHANGED
@@ -5,6 +5,7 @@ import logging
5
  import os
6
  import threading
7
  import time
 
8
  from typing import Any
9
 
10
  try:
@@ -71,6 +72,30 @@ ROLE_ENV_KEYS = {
71
  }
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def _hub_url(repo_id: str) -> str:
75
  value = repo_id.strip().strip("/")
76
  if not value or "/" not in value:
@@ -101,6 +126,20 @@ def route_role(messages: list[dict[str, str]], adapter: str | None = "auto") ->
101
  return "general_agent"
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def _with_role_system_prompt(messages: list[dict[str, str]], role: str) -> list[dict[str, str]]:
105
  if any(message.get("role") == "system" for message in messages):
106
  return messages
@@ -128,6 +167,32 @@ def _load_runtime():
128
  return tokenizer, model
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  def _runtime_device(model: Any):
132
  return next(model.parameters()).device
133
 
@@ -135,15 +200,18 @@ def _runtime_device(model: Any):
135
  def _generate(
136
  messages: list[dict[str, str]],
137
  *,
 
138
  max_new_tokens: int,
139
  temperature: float,
140
  top_p: float,
141
  ) -> tuple[str, dict[str, Any]]:
142
  import torch
143
 
144
- cache_before = _load_runtime.cache_info()
145
- tokenizer, model = _load_runtime()
146
- cache_after = _load_runtime.cache_info()
 
 
147
  device = _runtime_device(model)
148
  inputs = tokenizer.apply_chat_template(
149
  messages,
@@ -169,11 +237,14 @@ def _generate(
169
  text = tokenizer.decode(outputs[0][input_tokens:], skip_special_tokens=True).strip()
170
  return text, {
171
  "cache": {
172
- "hit": cache_after.hits > cache_before.hits,
173
- "loaded_models": cache_after.currsize,
174
- "hits": cache_after.hits,
175
- "misses": cache_after.misses,
 
 
176
  },
 
177
  "device": str(device),
178
  "input_tokens": input_tokens,
179
  "output_tokens": output_tokens,
@@ -192,13 +263,18 @@ def generate_chat_response_with_trace(
192
  ) -> tuple[str, dict[str, Any]]:
193
  started = time.perf_counter()
194
  role = route_role(messages, adapter)
 
195
  routed_messages = _with_role_system_prompt(messages, role)
 
 
 
196
  with MODEL_LOCK:
197
  content, runtime = _generate(
198
  routed_messages,
199
- max_new_tokens=max_new_tokens,
200
- temperature=temperature,
201
- top_p=top_p,
 
202
  )
203
  elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
204
  trace = {
@@ -210,16 +286,16 @@ def generate_chat_response_with_trace(
210
  "message_count": len(messages),
211
  "routed_message_count": len(routed_messages),
212
  "sampling": {
213
- "max_new_tokens": max_new_tokens,
214
- "temperature": temperature,
215
- "top_p": top_p,
216
  "top_k": top_k,
217
  },
218
  "runtime": runtime,
219
  "cache": runtime["cache"],
220
  "events": [
221
  {"name": "route_role", "detail": f"{adapter or 'auto'} -> {role}"},
222
- {"name": "load_model", "detail": "cache hit" if runtime["cache"]["hit"] else "cache miss"},
223
  {"name": "generate", "detail": f"{runtime['output_tokens']} tokens in {elapsed_ms} ms on {runtime['device']}"},
224
  ],
225
  "duration_ms": elapsed_ms,
@@ -229,14 +305,27 @@ def generate_chat_response_with_trace(
229
 
230
 
231
  def runtime_status() -> dict[str, Any]:
232
- cache = _load_runtime.cache_info()
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  return {
234
  "backend": "transformers",
235
  "model_family": "MiniCPM",
236
  "model": DEFAULT_MODEL_ID,
237
  "model_hub_url": _hub_url(DEFAULT_MODEL_ID),
238
  "configured": bool(DEFAULT_MODEL_ID),
239
- "roles": list(ROLE_ENV_KEYS),
240
  "cache": {
241
  "loaded_models": cache.currsize,
242
  "hits": cache.hits,
@@ -252,7 +341,7 @@ def _eager_load_runtime() -> None:
252
  return
253
  started = time.perf_counter()
254
  try:
255
- _load_runtime()
256
  EAGER_LOAD_STATUS["loaded"] = True
257
  except Exception as exc:
258
  logger.exception("MiniCPM transformers eager load failed.")
 
5
  import os
6
  import threading
7
  import time
8
+ from dataclasses import dataclass
9
  from typing import Any
10
 
11
  try:
 
72
  }
73
 
74
 
75
+ @dataclass(frozen=True)
76
+ class TransformersRoleConfig:
77
+ role: str
78
+ adapter_path: str
79
+ adapter_repo_id: str
80
+ max_new_tokens: int
81
+ temperature: float
82
+ top_p: float
83
+
84
+
85
+ def _clean_env_value(name: str, default: str = "") -> str:
86
+ raw = os.getenv(name, default)
87
+ lines = []
88
+ for line in str(raw).splitlines():
89
+ value = line.strip().strip('"').strip("'")
90
+ if value and not value.startswith("#"):
91
+ lines.append(value)
92
+ return lines[-1] if lines else default
93
+
94
+
95
+ def _role_env(role: str, suffix: str) -> str:
96
+ return f"SMOLNALYSIS_MINICPM_{ROLE_ENV_KEYS[role]}_{suffix}"
97
+
98
+
99
  def _hub_url(repo_id: str) -> str:
100
  value = repo_id.strip().strip("/")
101
  if not value or "/" not in value:
 
126
  return "general_agent"
127
 
128
 
129
+ def role_config(role: str) -> TransformersRoleConfig:
130
+ if role not in ROLE_ENV_KEYS:
131
+ available = ", ".join(ROLE_ENV_KEYS)
132
+ raise KeyError(f"Unknown MiniCPM transformers role '{role}'. Available roles: {available}")
133
+
134
+ default_temperature = "0" if role == "ckan_retrieval" else str(DEFAULT_TEMPERATURE)
135
+ adapter_path = _clean_env_value(_role_env(role, "ADAPTER_PATH"), _clean_env_value(_role_env(role, "LORA_PATH"), ""))
136
+ adapter_repo_id = _clean_env_value(_role_env(role, "ADAPTER_REPO_ID"), _clean_env_value(_role_env(role, "LORA_REPO_ID"), ""))
137
+ max_new_tokens = int(_clean_env_value(_role_env(role, "MAX_NEW_TOKENS"), str(DEFAULT_MAX_NEW_TOKENS)))
138
+ temperature = float(_clean_env_value(_role_env(role, "TEMPERATURE"), default_temperature))
139
+ top_p = float(_clean_env_value(_role_env(role, "TOP_P"), str(DEFAULT_TOP_P)))
140
+ return TransformersRoleConfig(role, adapter_path, adapter_repo_id, max_new_tokens, temperature, top_p)
141
+
142
+
143
  def _with_role_system_prompt(messages: list[dict[str, str]], role: str) -> list[dict[str, str]]:
144
  if any(message.get("role") == "system" for message in messages):
145
  return messages
 
167
  return tokenizer, model
168
 
169
 
170
+ @lru_cache(maxsize=4)
171
+ def _load_model_for_role(role: str):
172
+ config = role_config(role)
173
+ adapter_source = config.adapter_path or config.adapter_repo_id
174
+ if not adapter_source:
175
+ tokenizer, base_model = _load_runtime()
176
+ return tokenizer, base_model, ""
177
+
178
+ import torch
179
+ from peft import PeftModel
180
+ from transformers import AutoModelForCausalLM, AutoTokenizer
181
+
182
+ started = time.perf_counter()
183
+ logger.info("loading MiniCPM PEFT adapter for role=%s source=%s", role, adapter_source)
184
+ tokenizer = AutoTokenizer.from_pretrained(DEFAULT_MODEL_ID)
185
+ base_model = AutoModelForCausalLM.from_pretrained(
186
+ DEFAULT_MODEL_ID,
187
+ torch_dtype="auto",
188
+ device_map="auto",
189
+ )
190
+ model = PeftModel.from_pretrained(base_model, adapter_source)
191
+ model.eval()
192
+ logger.info("MiniCPM PEFT adapter loaded in %.1f ms", (time.perf_counter() - started) * 1000)
193
+ return tokenizer, model, adapter_source
194
+
195
+
196
  def _runtime_device(model: Any):
197
  return next(model.parameters()).device
198
 
 
200
  def _generate(
201
  messages: list[dict[str, str]],
202
  *,
203
+ role: str,
204
  max_new_tokens: int,
205
  temperature: float,
206
  top_p: float,
207
  ) -> tuple[str, dict[str, Any]]:
208
  import torch
209
 
210
+ base_cache_before = _load_runtime.cache_info()
211
+ role_cache_before = _load_model_for_role.cache_info()
212
+ tokenizer, model, adapter_source = _load_model_for_role(role)
213
+ base_cache_after = _load_runtime.cache_info()
214
+ role_cache_after = _load_model_for_role.cache_info()
215
  device = _runtime_device(model)
216
  inputs = tokenizer.apply_chat_template(
217
  messages,
 
237
  text = tokenizer.decode(outputs[0][input_tokens:], skip_special_tokens=True).strip()
238
  return text, {
239
  "cache": {
240
+ "hit": role_cache_after.hits > role_cache_before.hits,
241
+ "loaded_models": role_cache_after.currsize,
242
+ "hits": role_cache_after.hits,
243
+ "misses": role_cache_after.misses,
244
+ "base_hit": base_cache_after.hits > base_cache_before.hits,
245
+ "base_loaded_models": base_cache_after.currsize,
246
  },
247
+ "adapter_source": adapter_source,
248
  "device": str(device),
249
  "input_tokens": input_tokens,
250
  "output_tokens": output_tokens,
 
263
  ) -> tuple[str, dict[str, Any]]:
264
  started = time.perf_counter()
265
  role = route_role(messages, adapter)
266
+ config = role_config(role)
267
  routed_messages = _with_role_system_prompt(messages, role)
268
+ effective_max_new_tokens = max_new_tokens if max_new_tokens != DEFAULT_MAX_NEW_TOKENS else config.max_new_tokens
269
+ effective_temperature = temperature if temperature != DEFAULT_TEMPERATURE else config.temperature
270
+ effective_top_p = top_p if top_p != DEFAULT_TOP_P else config.top_p
271
  with MODEL_LOCK:
272
  content, runtime = _generate(
273
  routed_messages,
274
+ role=role,
275
+ max_new_tokens=effective_max_new_tokens,
276
+ temperature=effective_temperature,
277
+ top_p=effective_top_p,
278
  )
279
  elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
280
  trace = {
 
286
  "message_count": len(messages),
287
  "routed_message_count": len(routed_messages),
288
  "sampling": {
289
+ "max_new_tokens": effective_max_new_tokens,
290
+ "temperature": effective_temperature,
291
+ "top_p": effective_top_p,
292
  "top_k": top_k,
293
  },
294
  "runtime": runtime,
295
  "cache": runtime["cache"],
296
  "events": [
297
  {"name": "route_role", "detail": f"{adapter or 'auto'} -> {role}"},
298
+ {"name": "load_model", "detail": runtime.get("adapter_source") or DEFAULT_MODEL_ID},
299
  {"name": "generate", "detail": f"{runtime['output_tokens']} tokens in {elapsed_ms} ms on {runtime['device']}"},
300
  ],
301
  "duration_ms": elapsed_ms,
 
305
 
306
 
307
  def runtime_status() -> dict[str, Any]:
308
+ cache = _load_model_for_role.cache_info()
309
+ roles = {}
310
+ for role in ROLE_ENV_KEYS:
311
+ config = role_config(role)
312
+ roles[role] = {
313
+ "adapter_path": config.adapter_path,
314
+ "adapter_repo_id": config.adapter_repo_id,
315
+ "adapter_hub_url": _hub_url(config.adapter_repo_id),
316
+ "configured": bool(DEFAULT_MODEL_ID),
317
+ "adapter_configured": bool(config.adapter_path or config.adapter_repo_id),
318
+ "max_new_tokens": config.max_new_tokens,
319
+ "temperature": config.temperature,
320
+ "top_p": config.top_p,
321
+ }
322
  return {
323
  "backend": "transformers",
324
  "model_family": "MiniCPM",
325
  "model": DEFAULT_MODEL_ID,
326
  "model_hub_url": _hub_url(DEFAULT_MODEL_ID),
327
  "configured": bool(DEFAULT_MODEL_ID),
328
+ "roles": roles,
329
  "cache": {
330
  "loaded_models": cache.currsize,
331
  "hits": cache.hits,
 
341
  return
342
  started = time.perf_counter()
343
  try:
344
+ _load_model_for_role("general_agent")
345
  EAGER_LOAD_STATUS["loaded"] = True
346
  except Exception as exc:
347
  logger.exception("MiniCPM transformers eager load failed.")
app/requirements.txt CHANGED
@@ -1,2 +1,3 @@
1
  gradio>=6.0,<7
2
  pandas>=2.2,<3
 
 
1
  gradio>=6.0,<7
2
  pandas>=2.2,<3
3
+ peft>=0.13
requirements.txt CHANGED
@@ -7,3 +7,4 @@ spaces
7
  transformers>=5.6
8
  torch>=2.8
9
  accelerate
 
 
7
  transformers>=5.6
8
  torch>=2.8
9
  accelerate
10
+ peft>=0.13