Spaces:
Running
Running
| """ๅค่ผชๅฐ่ฉฑๆๅ โ ้้ ModelRegistry ๅๅพ LLM๏ผSprint D๏ผ้ ่จญ Qwen3-VL-8B๏ผ | |
| ใ้ๅๆจก็ตๅจๅไป้บผ๏ผใ | |
| ๆฅๆถๅค่ผชๅฐ่ฉฑๆญทๅฒ๏ผ็ๆ่ช็ถ่ช่จๅๆใ | |
| ๆฏๆด prompt-based tool calling๏ผๆจกๅๅฏ่ผธๅบ JSON ๅทฅๅ ทๅผๅซ๏ผ | |
| ็ฑไธๅฑค๏ผhub-service๏ผๅท่กๅพๅฐ็ตๆๅๅณ็นผ็บๅฐ่ฉฑใ | |
| ใๆจกๅไพๆบใ๏ผSprint D, 2026-04-27๏ผ | |
| ModelRegistry.get("chat") โ ้ ่จญ qwen3-vl-8b-instruct๏ผApache 2.0๏ผdense 8B | |
| vision-language๏ผ256K context๏ผ๏ผCHAT_MODEL=gemma-4-e4b ๅฏๅ้ Gemma 4 rollback | |
| hatch๏ผApple Silicon dev / Qwen3-VL prod issue / git-bisect๏ผใๅ ถ้ค 9 ๅ capability | |
| ไป็ถ gemma-4-e4b๏ผsplit ไธ replace๏ผ๏ผไธๅ่ chat ๅ ฑไบซๅไธ model instanceใ | |
| ใ็บไป้บผ tokenizer.apply_chat_template ่ๅ ฉๅ model ้ฝ้๏ผใ | |
| Qwen3-VL ็จ AutoProcessor๏ผmultimodal๏ผ๏ผไฝ processor.tokenizer ๆฏๆด็ดๆๅญ | |
| apply_chat_template๏ผไป้ข่ Gemma 4 / Qwen2.5 text tokenizer ไธ่ดใๆฌๆช่ตฐ็ดๆๅญ | |
| path๏ผ็ก image input๏ผ๏ผmodel-agnosticใtool calling ่ตฐ prompt-based JSON parse๏ผ | |
| ไธไพ่ณด transformers native tool calling๏ผ่ทจๆจกๅ็ธๅฎนใ | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import re | |
| from pydantic import BaseModel, ConfigDict, model_validator | |
| logger = logging.getLogger(__name__) | |
| # โโ ่ผธๅ ฅๅคงๅฐ้ๅถ๏ผ้ฒๆญขๆกๆ่ถ ๅคง payload ้ ๆ OOM๏ผ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| MAX_PAGE_CONTEXT_BYTES = 4096 # page_context JSON ๅบๅๅไธ้๏ผไฝๅ ็ต๏ผ | |
| MAX_MESSAGES_COUNT = 50 # messages ้ฃๅๆๅคง็ญๆธ | |
| MAX_MESSAGE_CONTENT_CHARS = 4000 # ๅฎๅ่จๆฏ content ๅญไธฒไธ้๏ผๅญๅ ๏ผ | |
| # โโ ่ณๆๆจกๅ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| class ChatRequest(BaseModel): | |
| """ๅค่ผชๅฐ่ฉฑ่ซๆฑ | |
| v17 P2 โ extra="forbid": defends against caller field-name drift. | |
| Existing model_validator already enforces size limits. | |
| """ | |
| model_config = ConfigDict(extra="forbid") | |
| messages: list[dict] # [{role, content, toolCalls?, toolName?}] | |
| page_context: dict # {page: str, data: dict} | |
| tool_definitions: list[dict] | None = None # ๅฏ่ฆๅฏซ้ ่จญๅทฅๅ ทๆธ ๅฎ | |
| def validate_payload_size(cls, values: dict) -> dict: | |
| """้ฉ่ญ่ผธๅ ฅๅคงๅฐ๏ผ้ฟๅ ๆปๆ่ ้่ถ ๅคง payload ๅฐ่ด tokenization ้ๆฎต OOMใ""" | |
| # 1. page_context JSON ๅบๅๅไธๅพ่ถ ้ 4096 bytes | |
| page_ctx = values.get("page_context") | |
| if page_ctx is not None: | |
| ctx_bytes = len( | |
| json.dumps(page_ctx, ensure_ascii=False, default=str).encode() | |
| ) | |
| if ctx_bytes > MAX_PAGE_CONTEXT_BYTES: | |
| msg = ( | |
| f"page_context ๅบๅๅๅพ็บ {ctx_bytes} bytes๏ผ" | |
| f"่ถ ้ไธ้ {MAX_PAGE_CONTEXT_BYTES} bytes" | |
| ) | |
| raise ValueError(msg) | |
| # 2. messages ้ฃๅไธๅพ่ถ ้ 50 ็ญ | |
| msgs = values.get("messages") | |
| if msgs is not None: | |
| if not isinstance(msgs, list): | |
| raise ValueError("messages ๅฟ ้ ็บ้ฃๅ") | |
| if len(msgs) > MAX_MESSAGES_COUNT: | |
| msg = f"messages ๅ ฑ {len(msgs)} ็ญ๏ผ่ถ ้ไธ้ {MAX_MESSAGES_COUNT} ็ญ" | |
| raise ValueError(msg) | |
| # 3. ๆฏๅ่จๆฏ็ content ไธๅพ่ถ ้ 4000 ๅญๅ | |
| for i, m in enumerate(msgs): | |
| content = m.get("content", "") if isinstance(m, dict) else "" | |
| if ( | |
| isinstance(content, str) | |
| and len(content) > MAX_MESSAGE_CONTENT_CHARS | |
| ): | |
| msg = ( | |
| f"messages[{i}].content ้ทๅบฆ {len(content)}๏ผ" | |
| f"่ถ ้ไธ้ {MAX_MESSAGE_CONTENT_CHARS} ๅญๅ " | |
| ) | |
| raise ValueError(msg) | |
| return values | |
| class ChatResponse(BaseModel): | |
| """ๅฐ่ฉฑๅๆ๏ผๅฏ่ฝๅ ๅซๅทฅๅ ทๅผๅซ๏ผ""" | |
| content: str | |
| tool_calls: list[dict] | None = None # [{name, arguments}] | |
| # "local_model" = ็ๆจกๅ่ผธๅบ๏ผ"fallback" = ๆจกๅ่ผๅ ฅๅคฑๆๆ็ cannedใwarming upใๅ่ฆใ | |
| # ่ฎ client ่ฝ disclose ไธฆๆไพ้่ฉฆใ | |
| source: str = "local_model" | |
| # โโ ้ ่จญๅทฅๅ ทๅฎ็พฉ๏ผๅตๅ ฅ system prompt๏ผ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| DEFAULT_TOOLS = [ | |
| "search_items(query: string) โ Search user's wardrobe by text", | |
| "get_item_details(item_id: string) โ Get details of a specific item", | |
| "search_marketplace(query: string, max_price?: number) โ Search marketplace listings", | |
| "compare_items(item_ids: string[]) โ Compare multiple items side by side", | |
| "get_price_info(item_id: string) โ Get market price and trend for an item", | |
| "add_to_wishlist(item_id: string) โ Add an item to user's wishlist", | |
| "get_recommendations(occasion?: string) โ Get outfit recommendations", | |
| "filter_feed(query: string) โ Filter feed content by topic", | |
| "get_user_taste_profile() โ Get the user's style DNA: top brands, price range, wardrobe stats, style keywords", | |
| "get_brand_affinity(limit?: number) โ Get top fashion brands with counts and values", | |
| "answer_persona_question(question: string) โ Answer questions about the user's taste and style preferences", | |
| ] | |
| MAX_HISTORY = 10 # ๆๅคงๅฐ่ฉฑๆญทๅฒ้ทๅบฆ๏ผQwen3-VL 256K ้ๅค ๏ผไฝ prompt ๆงๆๆฌ + ๅปถ้ฒ๏ผ | |
| MAX_NEW_TOKENS = 512 # ๆๅคง็ๆ token ๆธ | |
| # โโ System prompt ็ต่ฃ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _build_system_prompt( | |
| page_context: dict, | |
| tool_definitions: list[dict] | None, | |
| ) -> str: | |
| """็ต่ฃ system prompt๏ผๅ ๅซๅทฅๅ ทๅฎ็พฉๅ้ ้ขไธไธๆใ | |
| tool_definitions ่ช็พฉ๏ผ | |
| - None๏ผๆชๅณ๏ผโ ๆณจๅ ฅ DEFAULT_TOOLS๏ผไธ่ฌ่ณผ็ฉ copilot๏ผ่ก็บไธ่ฎ๏ผใ | |
| - ้็ฉบ list โ ็จ caller ่ช่จๅทฅๅ ทๆธ ๅฎใ | |
| - **็ฉบ list `[]` โ ๅฎๅ จไธๅฎฃๅๅทฅๅ ท**๏ผ้ฃใrespond with JSON blockใๆไปค้ฝๆฟๆใ | |
| ้็ตฆใ็ดๆๅญ็ๆใ็จ้๏ผๅฆ่ญฐๅนๅ่ฆ่็จฟ๏ผ๏ผๆณจๅ ฅๅ งๅฎน็กๆณ่ชๅฐๆจกๅๅ tool_calls | |
| JSON ้ฒ่ผธๅบ๏ผๅ ็บ system prompt ๆ นๆฌๆฒๆ้ๅทฅๅ ท๏ผsecurity review P1, #150๏ผใ | |
| """ | |
| if tool_definitions is not None and len(tool_definitions) == 0: | |
| page_desc = json.dumps(page_context, ensure_ascii=False, default=str) | |
| return ( | |
| "You are a helpful fashion assistant for Wardrobe OS.\n" | |
| f"Current page context: {page_desc}\n\n" | |
| "Respond in natural conversational text. Be concise and helpful." | |
| ) | |
| if tool_definitions: | |
| tool_lines = "\n".join( | |
| f"- {t.get('name', '?')}({t.get('params', '')}) โ {t.get('description', '')}" | |
| for t in tool_definitions | |
| ) | |
| else: | |
| tool_lines = "\n".join(f"- {t}" for t in DEFAULT_TOOLS) | |
| page_desc = json.dumps(page_context, ensure_ascii=False, default=str) | |
| return ( | |
| "You are a helpful fashion assistant for Wardrobe OS.\n" | |
| "You can use tools to help answer questions. " | |
| "When you need to use a tool, respond with ONLY a JSON block:\n" | |
| '{"tool_calls": [{"name": "tool_name", "arguments": {"arg": "value"}}]}\n\n' | |
| f"Available tools:\n{tool_lines}\n\n" | |
| f"Current page context: {page_desc}\n\n" | |
| "When you have enough information, respond in natural conversational text. " | |
| "Be concise and helpful." | |
| ) | |
| # โโ ๅฐ่ฉฑๆญทๅฒๆ ผๅผๅ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _format_messages(messages: list[dict]) -> list[dict]: | |
| """ๅฐๅ็ซฏ่จๆฏ้ฃๅ่ฝๆ็บ Qwen chat ๆ ผๅผใtool_result ๅ ่ฃๆ user ่จๆฏใ""" | |
| formatted: list[dict] = [] | |
| for msg in messages[-MAX_HISTORY:]: | |
| role = msg.get("role", "user") | |
| content = msg.get("content", "") | |
| if role == "tool_result": | |
| # ๅทฅๅ ท็ตๆๅ ่ฃๆ user ่จๆฏ๏ผ่ฎๆจกๅ็ฅ้ๅทฅๅ ทๅๅณไบไป้บผ | |
| tool_name = msg.get("toolName", "unknown") | |
| formatted.append( | |
| { | |
| "role": "user", | |
| "content": f"Tool '{tool_name}' returned: {content}", | |
| } | |
| ) | |
| elif role in ("user", "assistant"): | |
| formatted.append({"role": role, "content": content}) | |
| return formatted | |
| # โโ ๅทฅๅ ทๅผๅซ่งฃๆ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _parse_tool_calls(text: str) -> list[dict] | None: | |
| """ | |
| ๅ่ฉฆๅพๆจกๅ่ผธๅบ่งฃๆ tool_calls JSONใ | |
| ็ญ็ฅ๏ผ็ดๆฅ JSON ่งฃๆ โ regex ๆฝๅ โ None๏ผ็ดๆๅญๅๆ๏ผ | |
| """ | |
| text = text.strip() | |
| # ๅๆ markdown code fence | |
| if "```" in text: | |
| lines = text.split("\n") | |
| text = "\n".join(ln for ln in lines if not ln.strip().startswith("```")) | |
| text = text.strip() | |
| # ็ญ็ฅไธ๏ผ็ดๆฅ่งฃๆๆดๆฎตๆๅญ | |
| try: | |
| data = json.loads(text) | |
| if isinstance(data, dict) and "tool_calls" in data: | |
| calls = data["tool_calls"] | |
| if isinstance(calls, list) and len(calls) > 0: | |
| return calls | |
| except (json.JSONDecodeError, ValueError): | |
| pass | |
| # ็ญ็ฅไบ๏ผregex ๆฝๅ {"tool_calls": [...]} ๅๅก | |
| match = re.search(r'\{"tool_calls"\s*:\s*\[.*?\]\s*\}', text, re.DOTALL) | |
| if match: | |
| try: | |
| data = json.loads(match.group()) | |
| calls = data.get("tool_calls", []) | |
| if isinstance(calls, list) and len(calls) > 0: | |
| return calls | |
| except (json.JSONDecodeError, ValueError): | |
| pass | |
| return None | |
| # โโ ๆจกๅๅญๅ๏ผ้้ ModelRegistry ้ไธญ็ฎก็๏ผ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def _get_chat_model(): | |
| """ๅพ Registry ๅๅพ capability `chat` ๆจกๅๅฏฆไพ๏ผSprint D๏ผ้ ่จญ Qwen3-VL-8B๏ผใ""" | |
| from src.registry import get_registry | |
| reg = get_registry() | |
| loaded = await reg.get("chat") | |
| return loaded.model, loaded.tokenizer | |
| # โโ ไธปๅฝๅผ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async def chat_respond(request: ChatRequest) -> ChatResponse: | |
| """ | |
| ๅค่ผชๅฐ่ฉฑๅๆ๏ผๅซ tool calling ๆฏๆด๏ผใ | |
| ๆต็จ๏ผ็ต่ฃ system prompt โ ๅพ Registry ๅๅพๆจกๅ โ ๆจ่ซ โ ่งฃๆ tool_calls ๆๅ็ดๆๅญ | |
| """ | |
| import asyncio | |
| # ็ฉบ่จๆฏๆชขๆฅ | |
| if not request.messages: | |
| return ChatResponse(content="Please send a message to start the conversation.") | |
| # ๅพ Registry ๅๅพๆจกๅ | |
| try: | |
| model, tokenizer = await _get_chat_model() | |
| except Exception as e: | |
| logger.warning("Chat ๆจกๅไธๅฏ็จ: %s", e) | |
| return ChatResponse( | |
| content="I'm still warming up. Please try again in a moment.", | |
| source="fallback", | |
| ) | |
| # ็ต่ฃๅฐ่ฉฑ | |
| system_prompt = _build_system_prompt(request.page_context, request.tool_definitions) | |
| chat_messages = [{"role": "system", "content": system_prompt}] | |
| chat_messages.extend(_format_messages(request.messages)) | |
| def _inference(): | |
| import torch | |
| # Tokenize + ๆจ่ซ | |
| text = tokenizer.apply_chat_template( | |
| chat_messages, | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| ) | |
| inputs = tokenizer(text, return_tensors="pt").to(model.device) | |
| with torch.no_grad(): | |
| output = model.generate( | |
| **inputs, | |
| max_new_tokens=MAX_NEW_TOKENS, | |
| do_sample=False, | |
| ) | |
| # ๅชๅๆฐ็ๆ็ token | |
| generated = output[0][inputs["input_ids"].shape[1] :] | |
| return tokenizer.decode(generated, skip_special_tokens=True).strip() | |
| result_text = await asyncio.to_thread(_inference) | |
| # ๅ่ฉฆ่งฃๆๅทฅๅ ทๅผๅซ | |
| tool_calls = _parse_tool_calls(result_text) | |
| if tool_calls: | |
| return ChatResponse(content="", tool_calls=tool_calls) | |
| return ChatResponse(content=result_text) | |