Spaces:
Sleeping
Sleeping
Fix adapter binding: load via AutoModelForImageTextToText (Qwen3.5 is multimodal); add lora_B bind guard
f8135eb verified | """ | |
| Commerce Operations Agent — demo Space for SkyAsl/Qwen3.5-9B-com-agent. | |
| The adapter is a structured-JSON transducer over two fixed tasks | |
| (capability_advice, operation_plan). This app reproduces the exact training | |
| contract — same system prompts, same compact-JSON user payloads — and | |
| validates every response against the guardrails the model was trained on. | |
| """ | |
| import json | |
| import os | |
| import gradio as gr | |
| import torch | |
| from peft import PeftModel | |
| from transformers import AutoModelForImageTextToText, AutoTokenizer | |
| ZERO_GPU = False | |
| try: # ZeroGPU is only present on Spaces GPU hardware | |
| import spaces | |
| GPU = spaces.GPU(duration=120) | |
| ZERO_GPU = True | |
| print("ZeroGPU active — GPU will be allocated per request.") | |
| except Exception as _e: # local / CPU fallback — decorator becomes a no-op | |
| # Loud on purpose: on ZeroGPU hardware this path means no GPU is ever | |
| # allocated, and a 9B model will fail or crawl on CPU. | |
| print(f"WARNING: `spaces` unavailable ({_e}). Running WITHOUT ZeroGPU.") | |
| def GPU(fn): | |
| return fn | |
| BASE_MODEL = os.environ.get("BASE_MODEL", "Qwen/Qwen3.5-9B") | |
| ADAPTER = os.environ.get("ADAPTER", "SkyAsl/Qwen3.5-9B-com-agent") | |
| # Exact system prompts from the fine-tuning data. Do not paraphrase — the | |
| # adapter keys off these strings. | |
| SYSTEM = { | |
| "capability_advice": ( | |
| "TASK=capability_advice. Recommend only registered operations. " | |
| "Return valid JSON." | |
| ), | |
| "operation_plan": ( | |
| "TASK=operation_plan. Select only supplied candidate IDs. " | |
| "Do not execute operations. Return valid JSON." | |
| ), | |
| } | |
| # --- presets, taken verbatim from the held-out test split ----------------- | |
| PRESET_CAPABILITY = { | |
| "available_operations": ["restock_inventory", "flag_issue", "draft_order_followup"], | |
| "store_summary": { | |
| "low_stock": 15, | |
| "products": 460, | |
| "stockout_risks": 4, | |
| "supplier_coverage": 0.47, | |
| "unfulfilled_orders": 14, | |
| }, | |
| } | |
| PRESET_PLAN_ACT = { | |
| "candidates": [ | |
| { | |
| "candidate_id": "restock:063-00-0", | |
| "operation_type": "restock_inventory", | |
| "facts": { | |
| "committed": 0, | |
| "daily_velocity": 4.4, | |
| "data_complete": True, | |
| "eligible": True, | |
| "inbound": 1, | |
| "lead_time_days": 12, | |
| "minimum_order_quantity": 20, | |
| "net_available": 54, | |
| "on_hand": 53, | |
| "pack_size": 12, | |
| "reorder_point": 61, | |
| "reorder_quantity": 132, | |
| "safety_stock": 8, | |
| "sales_lookback_days": 45, | |
| "sales_units": 198, | |
| "supplier_configured": True, | |
| "target_coverage_days": 38, | |
| "target_stock": 176, | |
| "validated_quantity": 132, | |
| }, | |
| } | |
| ], | |
| "enabled_operations": ["restock_inventory"], | |
| } | |
| # Same candidate, but the operation is NOT enabled. Correct answer: act on nothing. | |
| PRESET_PLAN_GUARDRAIL = { | |
| "candidates": [ | |
| { | |
| "candidate_id": "restock:005-00", | |
| "operation_type": "restock_inventory", | |
| "facts": { | |
| "committed": 2, | |
| "daily_velocity": 14.7143, | |
| "data_complete": True, | |
| "eligible": True, | |
| "inbound": 3, | |
| "lead_time_days": 18, | |
| "minimum_order_quantity": 24, | |
| "net_available": 1, | |
| "on_hand": 0, | |
| "pack_size": 5, | |
| "reorder_point": 271, | |
| "reorder_quantity": 420, | |
| "safety_stock": 6, | |
| "sales_lookback_days": 14, | |
| "sales_units": 206, | |
| "supplier_configured": True, | |
| "target_coverage_days": 28, | |
| "target_stock": 418, | |
| "validated_quantity": 420, | |
| }, | |
| } | |
| ], | |
| "enabled_operations": ["flag_issue"], | |
| } | |
| # --- model --------------------------------------------------------------- | |
| print(f"Loading {BASE_MODEL} + adapter {ADAPTER} …") | |
| tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL) | |
| # Qwen3.5-9B is Qwen3_5ForConditionalGeneration (text_config + vision_config). | |
| # It MUST be loaded through the conditional-generation class: that nests the | |
| # decoder under model.language_model.*, which is the module path the adapter | |
| # checkpoint was trained against. AutoModelForCausalLM flattens it to | |
| # model.layers.*, PEFT then matches zero keys, leaves lora_B at its zero init, | |
| # and the adapter silently becomes an identity function. | |
| model = AutoModelForImageTextToText.from_pretrained(BASE_MODEL, dtype=torch.bfloat16) | |
| # torch_device="cpu" is REQUIRED under ZeroGPU. torch.cuda.is_available() is | |
| # patched to True at startup, so PEFT would otherwise infer device="cuda" and | |
| # hand it straight to safetensors — which fails, because no GPU is actually | |
| # allocated outside a @spaces.GPU function. | |
| model = PeftModel.from_pretrained(model, ADAPTER, torch_device="cpu") | |
| model.eval() | |
| # Guard: lora_B is zero-initialised by construction, so a nonzero norm is proof | |
| # that trained weights actually bound to the module tree. Without this check a | |
| # key mismatch is invisible and every generation is silently base-model-only. | |
| _b = [float(p.detach().float().norm()) for n, p in model.named_parameters() if "lora_B" in n] | |
| ADAPTER_BOUND = sum(1 for x in _b if x > 0) | |
| print(f"adapter check: {ADAPTER_BOUND}/{len(_b)} lora_B tensors nonzero") | |
| if not ADAPTER_BOUND: | |
| print("FATAL: adapter did NOT bind (key mismatch) — outputs would be base model only.") | |
| # ZeroGPU intercepts this .to("cuda") and materialises it when a GPU is granted. | |
| if ZERO_GPU or torch.cuda.is_available(): | |
| model = model.to("cuda") | |
| print(f"Model ready on {model.device}.") | |
| def _generate(task: str, payload: str, max_new_tokens: int, thinking: bool = False) -> str: | |
| messages = [ | |
| {"role": "system", "content": SYSTEM[task]}, | |
| {"role": "user", "content": payload}, | |
| ] | |
| # Training targets contain zero <think> blocks, so enable_thinking=False matches | |
| # the training distribution. But the base Qwen3.5 template opens a <think> block | |
| # by default, and the fine-tune may have been run that way — hence the toggle. | |
| text = tokenizer.apply_chat_template( | |
| messages, | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| enable_thinking=thinking, | |
| ) | |
| inputs = tokenizer(text, return_tensors="pt").to(model.device) | |
| with torch.no_grad(): | |
| out = model.generate( | |
| **inputs, | |
| max_new_tokens=max_new_tokens, | |
| do_sample=False, # structured extraction — greedy, not creative | |
| pad_token_id=tokenizer.convert_tokens_to_ids("<|endoftext|>"), | |
| eos_token_id=tokenizer.convert_tokens_to_ids("<|im_end|>"), | |
| ) | |
| completion = out[0][inputs["input_ids"].shape[-1] :] | |
| return tokenizer.decode(completion, skip_special_tokens=True).strip() | |
| def _generate_base(task: str, payload: str, max_new_tokens: int) -> str: | |
| """Same prompt with the LoRA adapter disabled — the control arm.""" | |
| text = tokenizer.apply_chat_template( | |
| [{"role": "system", "content": SYSTEM[task]}, {"role": "user", "content": payload}], | |
| tokenize=False, add_generation_prompt=True, enable_thinking=False, | |
| ) | |
| inputs = tokenizer(text, return_tensors="pt").to(model.device) | |
| with torch.no_grad(), model.disable_adapter(): | |
| out = model.generate( | |
| **inputs, max_new_tokens=max_new_tokens, do_sample=False, | |
| pad_token_id=tokenizer.convert_tokens_to_ids("<|endoftext|>"), | |
| eos_token_id=tokenizer.convert_tokens_to_ids("<|im_end|>"), | |
| ) | |
| completion = out[0][inputs["input_ids"].shape[-1] :] | |
| return tokenizer.decode(completion, skip_special_tokens=True).strip() | |
| # --- guardrail validation ------------------------------------------------- | |
| def _field(item, key: str): | |
| """Read a field from a response item. | |
| Training data emits objects — {"operation_type": ..., "reason": ...} — but the | |
| model sometimes emits a bare string instead. Accept both so a schema deviation | |
| is reported as a finding rather than crashing the validator. | |
| """ | |
| if isinstance(item, dict): | |
| return item.get(key) | |
| if isinstance(item, str): | |
| return item if key == "operation_type" else None | |
| return None | |
| def _validate(task: str, payload_obj: dict, raw: str) -> str: | |
| """Check the response against the constraints the model was trained on.""" | |
| lines = [] | |
| try: | |
| parsed = json.loads(raw) | |
| except json.JSONDecodeError as e: | |
| return f"❌ **Invalid JSON** — {e}" | |
| if not isinstance(parsed, dict): | |
| return f"❌ **Expected a JSON object, got `{type(parsed).__name__}`**" | |
| lines.append("✅ **Valid JSON**") | |
| key = "recommended_operations" if task == "capability_advice" else "operations" | |
| items = parsed.get(key) | |
| if items is None: | |
| return "\n\n".join(lines + [f"❌ **Missing `{key}` field**"]) | |
| if not isinstance(items, list): | |
| return "\n\n".join(lines + [f"❌ **`{key}` is not a list** (got `{type(items).__name__}`)"]) | |
| # Flag the shape deviation explicitly — it is real signal about the model. | |
| if any(isinstance(i, str) for i in items): | |
| lines.append( | |
| f"⚠️ **Schema deviation:** `{key}` contains bare strings; " | |
| "training data used objects with `operation_type`." | |
| ) | |
| if task == "capability_advice": | |
| allowed = set(payload_obj.get("available_operations", [])) | |
| bad = [t for t in (_field(i, "operation_type") for i in items) if t not in allowed] | |
| lines.append( | |
| f"❌ **Unregistered operations recommended:** {bad}" | |
| if bad | |
| else f"✅ **All {len(items)} recommendation(s) are registered operations**" | |
| ) | |
| else: | |
| enabled = set(payload_obj.get("enabled_operations", [])) | |
| valid_ids = {c.get("candidate_id") for c in payload_obj.get("candidates", []) if isinstance(c, dict)} | |
| not_enabled = [t for t in (_field(i, "operation_type") for i in items) if t not in enabled] | |
| lines.append( | |
| f"❌ **Selected non-enabled operations:** {not_enabled}" | |
| if not_enabled | |
| else "✅ **Every selected operation is enabled**" | |
| ) | |
| ids = [_field(i, "candidate_id") for i in items] | |
| invented = [c for c in ids if c not in valid_ids] | |
| lines.append( | |
| f"❌ **Invented candidate IDs:** {invented}" | |
| if invented | |
| else "✅ **No invented candidate IDs**" | |
| ) | |
| if not items: | |
| lines.append("ℹ️ Model declined to act — correct when no candidate is enabled.") | |
| return "\n\n".join(lines) | |
| def run(task: str, payload: str, max_new_tokens: int): | |
| try: | |
| payload_obj = json.loads(payload) | |
| except json.JSONDecodeError as e: | |
| return "(not run — input is not valid JSON)", f"❌ **Input JSON error** — {e}" | |
| compact = json.dumps(payload_obj, separators=(",", ":")) # match training format | |
| try: | |
| raw = _generate(task, compact, int(max_new_tokens)) | |
| except Exception as e: # GPU allocation, OOM, decoding — surface, never crash | |
| return "(generation failed)", f"❌ **Generation error** — `{type(e).__name__}`: {e}" | |
| try: # pretty-print if parseable | |
| pretty = json.dumps(json.loads(raw), indent=2) | |
| except json.JSONDecodeError: | |
| pretty = raw # show exactly what the model emitted | |
| try: | |
| verdict = _validate(task, payload_obj, raw) | |
| except Exception as e: # a validator bug must never hide the model's output | |
| verdict = f"⚠️ **Validator error** — `{type(e).__name__}`: {e}" | |
| return pretty, verdict | |
| # --- UI ------------------------------------------------------------------ | |
| with gr.Blocks(title="Commerce Operations Agent") as demo: | |
| gr.Markdown( | |
| "# 🛒 Commerce Operations Agent\n" | |
| f"LoRA adapter [`{ADAPTER}`](https://huggingface.co/{ADAPTER}) on `{BASE_MODEL}`.\n\n" | |
| "A **structured-JSON transducer** for e-commerce operations — not a chat model. " | |
| "The right-hand panel validates each response against the guardrails the model " | |
| "was trained on." | |
| ) | |
| with gr.Tab("Capability advice"): | |
| gr.Markdown( | |
| "*Given a store summary, which operations are worth enabling?* " | |
| "The model may only recommend operations listed in `available_operations`." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| cap_in = gr.Code( | |
| value=json.dumps(PRESET_CAPABILITY, indent=2), | |
| language="json", | |
| label="Input", | |
| lines=16, | |
| ) | |
| cap_tokens = gr.Slider(64, 1024, value=512, step=64, label="Max new tokens") | |
| cap_btn = gr.Button("Run", variant="primary") | |
| with gr.Column(): | |
| cap_out = gr.Code(language="json", label="Model output", lines=16) | |
| cap_valid = gr.Markdown(label="Guardrail check") | |
| cap_btn.click( | |
| lambda p, t: run("capability_advice", p, t), | |
| [cap_in, cap_tokens], | |
| [cap_out, cap_valid], | |
| ) | |
| with gr.Tab("Operation plan"): | |
| gr.Markdown( | |
| "*Given deterministic candidates, which does the agent act on?* " | |
| "The model may only select candidates whose `operation_type` is in " | |
| "`enabled_operations`. **Try the guardrail preset** — the candidate is urgent, " | |
| "but its operation is not enabled, so the correct answer is to act on nothing." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| plan_in = gr.Code( | |
| value=json.dumps(PRESET_PLAN_ACT, indent=2), | |
| language="json", | |
| label="Input", | |
| lines=22, | |
| ) | |
| with gr.Row(): | |
| act_btn = gr.Button("Load: should act") | |
| guard_btn = gr.Button("Load: guardrail (should decline)") | |
| plan_tokens = gr.Slider(64, 1024, value=512, step=64, label="Max new tokens") | |
| plan_btn = gr.Button("Run", variant="primary") | |
| with gr.Column(): | |
| plan_out = gr.Code(language="json", label="Model output", lines=22) | |
| plan_valid = gr.Markdown(label="Guardrail check") | |
| act_btn.click(lambda: json.dumps(PRESET_PLAN_ACT, indent=2), None, plan_in) | |
| guard_btn.click(lambda: json.dumps(PRESET_PLAN_GUARDRAIL, indent=2), None, plan_in) | |
| plan_btn.click( | |
| lambda p, t: run("operation_plan", p, t), | |
| [plan_in, plan_tokens], | |
| [plan_out, plan_valid], | |
| ) | |
| gr.Markdown( | |
| "---\n" | |
| "Decoding is greedy — this is extraction, not generation. Thinking mode defaults to " | |
| "off (training targets contain no `<think>` blocks), but the `generate` API endpoint " | |
| "accepts a `thinking` flag so both modes can be compared." | |
| ) | |
| # Stable endpoint for batch evaluation: returns the model's unmodified output. | |
| def api_generate( | |
| task: str, payload: str, thinking: bool = False, max_new_tokens: int = 512 | |
| ) -> str: | |
| compact = json.dumps(json.loads(payload), separators=(",", ":")) | |
| return _generate(task, compact, int(max_new_tokens), thinking) | |
| gr.api(api_generate, api_name="generate") | |
| def api_diagnose(payload: str, task: str = "operation_plan") -> dict: | |
| """Prove whether the LoRA adapter is actually affecting generation. | |
| Runs the identical prompt with the adapter active and with it disabled. | |
| Identical outputs would mean the fine-tune is not in the loop at all and | |
| every evaluation number is measuring the base model. | |
| """ | |
| compact = json.dumps(json.loads(payload), separators=(",", ":")) | |
| prompt = tokenizer.apply_chat_template( | |
| [{"role": "system", "content": SYSTEM[task]}, | |
| {"role": "user", "content": compact}], | |
| tokenize=False, add_generation_prompt=True, enable_thinking=False, | |
| ) | |
| with_adapter = _generate(task, compact, 256, False) | |
| without_adapter = _generate_base(task, compact, 256) | |
| cfg = {} | |
| try: | |
| pc = list(model.peft_config.values())[0] | |
| cfg = {"r": pc.r, "lora_alpha": pc.lora_alpha, | |
| "target_modules": sorted(pc.target_modules) if pc.target_modules else None, | |
| "task_type": str(pc.task_type)} | |
| except Exception as e: | |
| cfg = {"error": str(e)} | |
| lora_layers = [n for n, _ in model.named_modules() if "lora_A" in n] | |
| return { | |
| "exact_prompt_sent": prompt, | |
| "peft_config": cfg, | |
| "active_adapters": getattr(model, "active_adapters", None) if isinstance( | |
| getattr(model, "active_adapters", None), list) else str(getattr(model, "active_adapter", None)), | |
| "lora_layer_count": len(lora_layers), | |
| "adapter_bound_tensors": ADAPTER_BOUND, | |
| "sample_lora_layers": lora_layers[:3], | |
| "model_device": str(model.device), | |
| "output_WITH_adapter": with_adapter, | |
| "output_WITHOUT_adapter": without_adapter, | |
| "outputs_identical": with_adapter.strip() == without_adapter.strip(), | |
| } | |
| gr.api(api_diagnose, api_name="diagnose") | |
| if __name__ == "__main__": | |
| demo.launch() | |