fengchen31's picture
Sync from GitHub refs/heads/main @ 24e1c3d
809f38c verified
Raw
History Blame Contribute Delete
12.9 kB
"""ๅคš่ผชๅฐ่ฉฑๆœๅ‹™ โ€” ้€้Ž 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 # ๅฏ่ฆ†ๅฏซ้ ่จญๅทฅๅ…ทๆธ…ๅ–ฎ
@model_validator(mode="before")
@classmethod
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)