From 35e7789c816c9c1bb4cd61d52e8864c86100d9cd Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 5 Oct 2026 22:29:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(vision-v2):=20=E6=81=A2=E5=A4=8D=E5=90=8E?= =?UTF-8?q?=E5=8F=B0prompt=E9=85=8D=E7=BD=AE=E8=AF=BB=E5=8F=96=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=A8=E6=88=B7=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=B8=8D=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:#2200重写V2时把 prompt_loader 调用链删了,system/user prompt 全部硬编码在 vlm_fast_json.py / vlm_fallback.py,用户在后台管理端 配置 viral_video_prompt_templates 表 prompt_type='image_analysis' 的 自定义提示词完全没被读取。 修复: - 新增 vision/_prompt.py,复用 packages.application.viral_video.prompt_loader 读取后台 image_analysis 配置,30秒TTL热加载 - 判定策略:读到的system_prompt以默认XML模板开头('你是电商商品视觉分析师') 视为内置默认→使用硬编码JSON schema;否则视为用户自定义→使用用户 system_prompt并在末尾追加JSON硬约束'你必须只返回一个合法的JSON对象...' - user_prompt同理:用户模板存在就渲染(image_count=1/industry=通用/image_urls留空), 不存在用硬编码默认 - DB不可用/读取异常→静默fallback到硬编码JSON prompt,不阻断流程 - fast路径(qwen3.8-flash)和pro路径(qwen3.7-plus)都接入_resolve 本地冒烟:DB不可用时fallback到默认JSON prompt正常;pro兜底返回pp质量良好。 Refs: 用户反馈后台配置提示词无效 --- .../worker/worker_app/tasks/vision/_prompt.py | 153 ++++++++++++ .../worker_app/tasks/vision/vlm_fallback.py | 230 +++++++++++------- .../worker_app/tasks/vision/vlm_fast_json.py | 60 +---- 3 files changed, 302 insertions(+), 141 deletions(-) create mode 100644 apps/worker/worker_app/tasks/vision/_prompt.py diff --git a/apps/worker/worker_app/tasks/vision/_prompt.py b/apps/worker/worker_app/tasks/vision/_prompt.py new file mode 100644 index 000000000..9d8b5a4b1 --- /dev/null +++ b/apps/worker/worker_app/tasks/vision/_prompt.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +"""V2 prompt 解析:优先读后台 viral_video_prompt_templates 配置,30s TTL 热加载; +DB 不可用/读到默认XML模板时,fallback 到硬编码 JSON schema prompt。 +""" +from __future__ import annotations + +import logging +import threading +import time +from typing import Any + +logger = logging.getLogger(__name__) + +# ---- 默认硬编码 prompt(DB 不可用或读到默认XML模板时使用) ---- + +DEFAULT_FAST_SYSTEM = ( + "你是图片结构化识别器。严格按下方 JSON schema 返回一个对象,不要任何解释、" + "不要markdown、不要代码块、不要前后缀文字。字段值不确定时填 null 或空数组。\n" + "{\n" + ' "has_person": true/false,\n' + ' "gender": "男"/"女"/null,\n' + ' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n' + ' "upper_wear": "上装款式,如T恤/衬衫/卫衣/毛衣/西装/夹克/连衣裙/吊带/背心/外套等",\n' + ' "upper_color": "上装主色",\n' + ' "lower_wear": "下装款式;穿连衣裙时填null",\n' + ' "lower_color": "下装主色",\n' + ' "dress_color": "连衣裙主色(穿连衣裙时填)",\n' + ' "accessories": ["眼镜"/"帽子"/"项链"/"耳环"/"背包"/"手表"等数组],\n' + ' "hairstyle": "发型,如短发/长发/马尾/卷发/丸子头/光头等",\n' + ' "expression": "表情,如微笑/严肃/酷/开心等",\n' + ' "pose": "姿势,如站立/坐姿/侧身/行走等",\n' + ' "scene": "场景,如室内/街拍/户外/办公室/家居/海边/雪景/森林等",\n' + ' "style": "风格,如休闲/商务/运动/复古/潮流/甜美/酷飒/优雅/街头/法式等",\n' + ' "has_product": true/false,\n' + ' "category": "产品类目:服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n' + ' "product_name": "产品名称,非产品图填null",\n' + ' "brand": "品牌或文字标识,无则null",\n' + ' "material": "材质,如棉质/牛仔/皮革/真丝/针织/涤纶等",\n' + ' "pattern": "图案,如纯色/条纹/波点/格子/印花/碎花/Logo等",\n' + ' "colors": ["主色数组"],\n' + ' "mood": "整体氛围/情绪,如清新/活力/高级/温暖/冷峻/甜美/复古等"\n' + "}" +) +DEFAULT_FAST_USER = "识别这张图片的人物穿搭与主体信息,只返回JSON对象。" + +DEFAULT_PRO_SYSTEM = ( + "你是图片分析专家。严格按下方 JSON schema 返回一个对象,不要解释、不要markdown、不要代码块。\n" + "{\n" + ' "has_person": true/false,\n' + ' "gender": "男"/"女"/null,\n' + ' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n' + ' "outfit": "整体穿着描述(含颜色款式)",\n' + ' "hair": "发型发色",\n' + ' "pose": "姿势",\n' + ' "expression": "表情",\n' + ' "scene": "场景",\n' + ' "mood": "氛围",\n' + ' "has_product": true/false,\n' + ' "category": "类目:服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n' + ' "product_name": "产品名,非产品图填null",\n' + ' "brand": "品牌,无则null",\n' + ' "key_features": ["核心特征数组,3-6个短语"]\n' + "}" +) +DEFAULT_PRO_USER = "分析这张图片,返回符合schema的JSON。" + +# 用户自定义 prompt 末尾追加的硬约束 +_JSON_TAIL_FAST = "\n\n你必须只返回一个合法的JSON对象,不要输出任何其他文字、解释或markdown。" +_JSON_TAIL_PRO = "\n\n你必须只返回一个合法的JSON对象,不要输出任何其他文字、解释或markdown。" + +# 默认模板特征头(用于识别是否是内置XML模板) +_DEFAULT_XML_MARKER = "你是电商商品视觉分析师" + +_cache_lock = threading.Lock() +_cache: dict[str, tuple[float, Any]] = {} +_CACHE_TTL = 30.0 + + +def _load_template() -> Any | None: + """从DB读image_analysis模板,失败返回None。""" + try: + from packages.application.viral_video.prompt_loader import get_template + return get_template("image_analysis") + except Exception as e: + logger.warning("[vision.v2] 读取后台prompt配置失败: %s", e) + return None + + +def _is_custom(template: Any) -> bool: + """判断读到的模板是不是用户自定义的(不是内置默认XML长prompt)。""" + if not template: + return False + sp = getattr(template, "system_prompt", "") or "" + # 默认模板开头是"你是电商商品视觉分析师...",XML格式,不适合qwen+JSON + if _DEFAULT_XML_MARKER in sp[:30]: + return False + # 其他有内容的system_prompt视为用户自定义 + return bool(sp.strip()) + + +def resolve_fast_prompt() -> tuple[str, str]: + """返回 (system_prompt, user_prompt) 给 qwen3.8-flash fast 路径。""" + return _resolve("fast") + + +def resolve_pro_prompt() -> tuple[str, str]: + """返回 (system_prompt, user_prompt) 给 qwen3.7-plus fallback 路径。""" + return _resolve("pro") + + +def _resolve(kind: str) -> tuple[str, str]: + now = time.time() + cache_key = f"prompt_{kind}" + with _cache_lock: + hit = _cache.get(cache_key) + if hit and now - hit[0] < _CACHE_TTL: + return hit[1] + + default_sys = DEFAULT_FAST_SYSTEM if kind == "fast" else DEFAULT_PRO_SYSTEM + default_user = DEFAULT_FAST_USER if kind == "fast" else DEFAULT_PRO_USER + tail = _JSON_TAIL_FAST if kind == "fast" else _JSON_TAIL_PRO + + sys_prompt = default_sys + usr_prompt = default_user + try: + tpl = _load_template() + if _is_custom(tpl): + custom_sys = (getattr(tpl, "system_prompt", "") or "").strip() + custom_user_tpl = getattr(tpl, "user_prompt_template", "") or "" + if custom_sys: + sys_prompt = custom_sys + tail + if custom_user_tpl: + # V2 是单图调用,简单替换几个常用占位符;缺键原样保留 + usr_prompt = (custom_user_tpl + .replace("{image_count}", "1") + .replace("{industry}", "通用") + .replace("{image_urls}", "") + .strip()) + if not usr_prompt: + usr_prompt = default_user + logger.info("[vision.v2] 使用后台自定义prompt (kind=%s version=%s)", + kind, getattr(tpl, "version", "?")) + except Exception as e: + logger.warning("[vision.v2] 解析后台prompt失败,使用默认: %s", e) + + with _cache_lock: + _cache[cache_key] = (now, (sys_prompt, usr_prompt)) + return sys_prompt, usr_prompt + + +def invalidate_cache() -> None: + with _cache_lock: + _cache.clear() diff --git a/apps/worker/worker_app/tasks/vision/vlm_fallback.py b/apps/worker/worker_app/tasks/vision/vlm_fallback.py index 1dcb3d0b4..295e80c4c 100644 --- a/apps/worker/worker_app/tasks/vision/vlm_fallback.py +++ b/apps/worker/worker_app/tasks/vision/vlm_fallback.py @@ -1,8 +1,14 @@ # -*- coding: utf-8 -*- -"""V2 pro 兜底:qwen3.7-plus(阿里云百炼/DashScope)单次调用。 +"""V2 兜底路径:qwen3.7-plus(阿里云百炼/DashScope)单图调用。 -fast_json 结果不可用时单次调用,无竞速、无重试、无复杂超时逻辑。 -直接 httpx 发精简 JSON-only prompt(比旧版 prompt_loader XML 模板短很多,降低延迟)。 +fast_json 超时/返回非 JSON/识别为空时,本路径单次调用兜底。 +设计要点: +- 直接 httpx 直连 DashScope,不走 ai_client +- enable_thinking=false + response_format=json_object +- system prompt 优先读后台 viral_video_prompt_templates 配置,DB不可用时fallback到硬编码JSON schema +- timeout=25s +- API Key 从环境变量 DASHSCOPE_API_KEY 读取 +- 返回 dict 字段与旧 _normalize() 兼容,下游零改动 """ from __future__ import annotations @@ -13,6 +19,8 @@ import os import time from typing import Any +from . import _prompt + logger = logging.getLogger(__name__) _BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" @@ -20,65 +28,87 @@ _PRO_MODEL = "qwen3.7-plus" _DEFAULT_TIMEOUT = 25 _DEFAULT_MAX_TOKENS = 800 -_PRO_SYSTEM = ( - "你是图片分析助手。仔细观察图片,严格按JSON schema返回一个对象,不要任何解释、" - "不要markdown、不要代码块、不要前后缀文字。字段值不确定时填null或空数组。\n" - "{\n" - ' "has_person": true/false,\n' - ' "gender": "男"/"女"/null,\n' - ' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n' - ' "outfit": "人物穿搭描述,60字以内(例:白色T恤+牛仔裤)",\n' - ' "hair": "发型",\n' - ' "pose": "姿态",\n' - ' "expression": "表情",\n' - ' "scene": "场景",\n' - ' "mood": "氛围",\n' - ' "has_product": true/false,\n' - ' "category": "服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n' - ' "product_name": "产品名称,非产品图填null",\n' - ' "brand": "品牌或文字标识,无则null",\n' - ' "key_features": ["特征数组"]\n' - "}" -) -_PRO_USER = "分析这张图片,返回符合schema的JSON。" - -def _strip_code_fence(s: str) -> str: - s = s.strip() - if s.startswith("```"): - lines = s.split("\n") - if lines and lines[0].startswith("```"): - lines = lines[1:] - if lines and lines[-1].strip().startswith("```"): - lines = lines[:-1] - s = "\n".join(lines).strip() - return s +def _api_key() -> str | None: + return os.environ.get("DASHSCOPE_API_KEY") def _assemble_pp(obj: dict[str, Any]) -> str: - if not obj.get("has_person", False): - return "无人像" + """从 JSON 字段组装 portrait_prompt(60-100字人物穿搭描述,给 Seedream t2i 用)。""" + if not obj.get("has_person"): + name = obj.get("product_name") or "商品" + brand = obj.get("brand") or "" + kf = obj.get("key_features") or [] + scene = obj.get("scene") or "" + mood = obj.get("mood") or "" + outfit = obj.get("outfit") or "" + if outfit: + return outfit + pieces = [] + if brand: + pieces.append(brand) + pieces.append(str(name)) + if isinstance(kf, list): + pieces.extend(str(x) for x in kf[:2] if x) + if mood: + pieces.append(str(mood) + "氛围") + if scene: + pieces.append(str(scene) + "场景") + pieces.append("产品特写") + p = ",".join(x for x in pieces if x) + return p if len(p) >= 10 else "产品展示图,特写镜头" + parts: list[str] = [] - gender = obj.get("gender") - age = obj.get("age_range") - if gender: - parts.append(gender + ("性" if not gender.endswith("性") else "")) - if age: - parts.append(age) - parts.append("人物") - hair = obj.get("hair") - if hair: - parts.append(hair) - outfit = obj.get("outfit") + gender = obj.get("gender") or "" + age = obj.get("age_range") or "" + subj = "" + if age == "儿童": + subj = "小女孩" if gender == "女" else ("小男孩" if gender == "男" else "儿童") + elif age == "青少年": + subj = "少女" if gender == "女" else ("少年" if gender == "男" else "青少年") + else: + prefix_map = {"青年": "年轻", "中年": "中年", "老年": "老年"} + gw = {"男": "男性", "女": "女性"}.get(gender, "") + prefix = prefix_map.get(age, "") + subj = (prefix + gw) if (prefix or gw) else "人物" + parts.append(f"一位{subj}") + + outfit = obj.get("outfit") or "" if outfit: parts.append(f"身着{outfit}") - pose = obj.get("pose") - if pose: - parts.append(f"姿态{pose}") - expr = obj.get("expression") - if expr: - parts.append(f"表情{expr}") - return ",".join(parts) if parts else "无人像" + + hair = obj.get("hair") or "" + if hair: + parts.append(str(hair)) + + pose = obj.get("pose") or "" + expr = obj.get("expression") or "" + det = [] + if expr and expr not in ("自然", "平静"): + det.append(f"神情{expr}") + if pose and pose not in ("站立",): + det.append(str(pose)) + if det: + parts.append(",".join(det)) + + style_parts = [] + mood = obj.get("mood") or "" + scene = obj.get("scene") or "" + if mood: + style_parts.append(str(mood)) + if scene and scene != "通用": + style_parts.append(str(scene)) + if style_parts: + parts.append("".join(style_parts) + "风格") + else: + parts.append("人像写真") + + full = ",".join(p for p in parts if p) + if len(full) < 40: + full += ",自然光线下人像特写,画面清晰" + if len(full) > 120: + full = full[:120].rstrip(",") + "。" + return full def call_pro_vlm( @@ -87,27 +117,24 @@ def call_pro_vlm( *, timeout: int = _DEFAULT_TIMEOUT, ) -> dict[str, Any] | None: - """单次调用 qwen3.7-plus,解析后返回 product dict;失败返回 None。""" t0 = time.time() import httpx - api_key = os.environ.get("DASHSCOPE_API_KEY") + api_key = _api_key() if not api_key: - logger.warning("[vision.v2] DASHSCOPE_API_KEY 未配置,跳过 pro 兜底") + logger.warning("[vision.v2] pro DASHSCOPE_API_KEY 未配置,跳过") return None - url = f"{_BASE_URL}/chat/completions" + system_prompt, user_prompt = _prompt.resolve_pro_prompt() + payload: dict[str, Any] = { "model": _PRO_MODEL, "messages": [ - {"role": "system", "content": _PRO_SYSTEM}, - { - "role": "user", - "content": [ - {"type": "image_url", "image_url": {"url": img_url}}, - {"type": "text", "text": _PRO_USER}, - ], - }, + {"role": "system", "content": system_prompt}, + {"role": "user", "content": [ + {"type": "image_url", "image_url": {"url": img_url}}, + {"type": "text", "text": user_prompt}, + ]}, ], "temperature": 0.3, "max_tokens": _DEFAULT_MAX_TOKENS, @@ -117,7 +144,7 @@ def call_pro_vlm( } try: r = httpx.post( - url, + f"{_BASE_URL}/chat/completions", headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, json=payload, timeout=timeout, @@ -132,44 +159,61 @@ def call_pro_vlm( logger.warning("[vision.v2] pro 返回空 elapsed=%.1fs", elapsed) return None usage = data.get("usage") or {} + reasoning_tokens = usage.get("reasoning_tokens", 0) + ctd = usage.get("completion_tokens_details") or {} + if not reasoning_tokens: + reasoning_tokens = ctd.get("reasoning_tokens", 0) logger.info( - "[vision.v2] pro 完成 idx=%d model=%s elapsed=%.1fs in=%d out=%d", - idx, - _PRO_MODEL, - elapsed, - usage.get("prompt_tokens", 0), - usage.get("completion_tokens", 0), + "[vision.v2] pro 完成 model=%s elapsed=%.1fs in=%d out=%d reasoning=%d", + _PRO_MODEL, elapsed, + usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), reasoning_tokens, ) - text = _strip_code_fence(raw) - lpos, r_pos = text.find("{"), text.rfind("}") - if lpos < 0 or r_pos <= lpos: - logger.warning("[vision.v2] pro 无JSON elapsed=%.1fs head=%s", elapsed, raw[:200]) + s = raw.strip() + if s.startswith("```"): + lines = s.split("\n") + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip().startswith("```"): + lines = lines[:-1] + s = "\n".join(lines).strip() + l, rr = s.find("{"), s.rfind("}") + if l >= 0 and rr > l: + s = s[l : rr + 1] + try: + obj = json.loads(s) + except json.JSONDecodeError: + logger.warning("[vision.v2] pro JSON 解析失败 head=%s", raw[:200]) return None - obj = json.loads(text[lpos : r_pos + 1]) if not isinstance(obj, dict): return None - scene = obj.get("scene") or "通用" - mood = obj.get("mood") or "" + pp = _assemble_pp(obj) - has_person = obj.get("has_person", False) - has_product = obj.get("has_product", False) + kf = obj.get("key_features") + if not isinstance(kf, list): + kf = [str(kf)] if kf else ["无法判断"] + else: + kf = [str(x) for x in kf if x] or ["无法判断"] + name = obj.get("product_name") or "未识别" + if obj.get("has_person") and (not name or name == "未识别"): + name = obj.get("outfit") or "人物穿搭" brand = obj.get("brand") or "无法判断" - category = obj.get("category") or ("非产品图" if has_person and not has_product else "无法判断") + category = obj.get("category") or ("服饰" if obj.get("has_person") else "非产品图") return { - "name": name, - "brand": brand, - "category": category, - "appearance": obj.get("outfit") or "无法判断", + "name": str(name), + "brand": str(brand), + "category": str(category), + "appearance": str(obj.get("outfit") or "无法判断"), "packaging": "无法判断", "text_on_package": [], - "key_features": obj.get("key_features") or ["无法判断"], - "scene": scene, - "mood": mood, + "key_features": kf[:6], + "scene": str(obj.get("scene") or "通用"), + "mood": str(obj.get("mood") or ""), "portrait_prompt": pp, - "summary": f"{brand} {name}" if name != "未识别" else "未识别", + "summary": str(name), "_source": "vlm_pro", } except Exception as e: - logger.warning("[vision.v2] pro 异常 idx=%d elapsed=%.1fs err=%s", idx, time.time() - t0, e, exc_info=True) + elapsed = time.time() - t0 + logger.warning("[vision.v2] pro 异常 elapsed=%.1fs err=%s", elapsed, e, exc_info=True) return None diff --git a/apps/worker/worker_app/tasks/vision/vlm_fast_json.py b/apps/worker/worker_app/tasks/vision/vlm_fast_json.py index 2e963d002..5c4c5115c 100644 --- a/apps/worker/worker_app/tasks/vision/vlm_fast_json.py +++ b/apps/worker/worker_app/tasks/vision/vlm_fast_json.py @@ -5,7 +5,8 @@ 设计要点: - 直接用 httpx 发最小 payload 到 DashScope OpenAI 兼容 endpoint,不走 ai_client 包装 - enable_thinking=false 关闭推理链(reasoning 是延迟主因) -- system prompt 极致精简,只给字段 schema 和强约束(禁止自然语言、禁止 markdown) +- response_format=json_object 强约束JSON输出 +- system prompt 优先读后台 viral_video_prompt_templates 配置,DB不可用时fallback到硬编码JSON schema - max_tokens=350、temperature=0.1(稳定输出 JSON) - timeout=12s(失败由外层走 pro 兜底) - API Key 从环境变量 DASHSCOPE_API_KEY 读取 @@ -19,6 +20,8 @@ import os import time from typing import Any +from . import _prompt + logger = logging.getLogger(__name__) # DashScope OpenAI 兼容 endpoint @@ -27,38 +30,6 @@ _FAST_MODEL = "qwen3.8-flash" _DEFAULT_TIMEOUT = 12 _DEFAULT_MAX_TOKENS = 350 -# 极简 system prompt:只给字段定义 + 硬性输出要求 -_FAST_SYSTEM = ( - "你是图片结构化识别器。严格按下方 JSON schema 返回一个对象,不要任何解释、" - "不要markdown、不要代码块、不要前后缀文字。字段值不确定时填 null 或空数组。\n" - "{\n" - ' "has_person": true/false,\n' - ' "gender": "男"/"女"/null,\n' - ' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n' - ' "upper_wear": "上装款式,如T恤/衬衫/卫衣/毛衣/西装/夹克/连衣裙/吊带/背心/外套等",\n' - ' "upper_color": "上装主色",\n' - ' "lower_wear": "下装款式;穿连衣裙时填null",\n' - ' "lower_color": "下装主色",\n' - ' "dress_color": "连衣裙主色(穿连衣裙时填)",\n' - ' "accessories": ["眼镜"/"帽子"/"项链"/"耳环"/"背包"/"手表"等数组],\n' - ' "hairstyle": "发型,如短发/长发/马尾/卷发/丸子头/光头等",\n' - ' "expression": "表情,如微笑/严肃/酷/开心等",\n' - ' "pose": "姿势,如站立/坐姿/侧身/行走等",\n' - ' "scene": "场景,如室内/街拍/户外/办公室/家居/海边/雪景/森林等",\n' - ' "style": "风格,如休闲/商务/运动/复古/潮流/甜美/酷飒/优雅/街头/法式等",\n' - ' "has_product": true/false,\n' - ' "category": "产品类目:服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n' - ' "product_name": "产品名称,非产品图填null",\n' - ' "brand": "品牌或文字标识,无则null",\n' - ' "material": "材质,如棉质/牛仔/皮革/真丝/针织/涤纶等",\n' - ' "pattern": "图案,如纯色/条纹/波点/格子/印花/碎花/Logo等",\n' - ' "colors": ["主色数组"],\n' - ' "mood": "整体氛围/情绪,如清新/活力/高级/温暖/冷峻/甜美/复古等"\n' - "}" -) - -_FAST_USER = "识别这张图片的人物穿搭与主体信息,只返回JSON对象。" - def _api_key() -> str | None: return os.environ.get("DASHSCOPE_API_KEY") @@ -91,16 +62,18 @@ def call_fast_json( logger.warning("[vision.v2] DASHSCOPE_API_KEY 未配置,跳过 fast_json") return None + system_prompt, user_prompt = _prompt.resolve_fast_prompt() + url = f"{_BASE_URL}/chat/completions" payload: dict[str, Any] = { "model": _FAST_MODEL, "messages": [ - {"role": "system", "content": _FAST_SYSTEM}, + {"role": "system", "content": system_prompt}, { "role": "user", "content": [ {"type": "image_url", "image_url": {"url": img_url}}, - {"type": "text", "text": _FAST_USER}, + {"type": "text", "text": user_prompt}, ], }, ], @@ -119,7 +92,6 @@ def call_fast_json( ) elapsed = time.time() - t0 if resp.status_code == 400 and "enable_thinking" in resp.text[:300].lower(): - # 极少数 endpoint 版本不识别 enable_thinking,重试一次不带 logger.warning("[vision.v2] fast_json HTTP 400 thinking 参数不兼容,重试 elapsed=%.1fs", elapsed) payload.pop("enable_thinking", None) resp = httpx.post( @@ -130,9 +102,7 @@ def call_fast_json( ) elapsed = time.time() - t0 if resp.status_code != 200: - logger.warning( - "[vision.v2] fast_json HTTP %d elapsed=%.1fs body=%s", resp.status_code, elapsed, resp.text[:200] - ) + logger.warning("[vision.v2] fast_json HTTP %d elapsed=%.1fs body=%s", resp.status_code, elapsed, resp.text[:200]) return None data = resp.json() raw = (data.get("choices") or [{}])[0].get("message", {}).get("content") @@ -146,11 +116,8 @@ def call_fast_json( reasoning_tokens = ctd.get("reasoning_tokens", 0) logger.info( "[vision.v2] fast_json 完成 model=%s elapsed=%.1fs in=%d out=%d reasoning=%d", - _FAST_MODEL, - elapsed, - usage.get("prompt_tokens", 0), - usage.get("completion_tokens", 0), - reasoning_tokens, + _FAST_MODEL, elapsed, + usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), reasoning_tokens, ) text = _strip_code_fence(raw) lpos, r = text.find("{"), text.rfind("}") @@ -166,10 +133,7 @@ def call_fast_json( return None logger.info( "[vision.v2] fast_json 完成 elapsed=%.1fs has_person=%s has_product=%s category=%s", - elapsed, - obj.get("has_person"), - obj.get("has_product"), - obj.get("category"), + elapsed, obj.get("has_person"), obj.get("has_product"), obj.get("category"), ) return obj except Exception as e: -- 2.54.0