fix(vision-v2): 恢复后台prompt配置读取,修复用户自定义提示词不生效
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m7s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m21s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m7s
AI Code Review / AI Code Review (pull_request) Successful in 6m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 40s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 11m26s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 14m6s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 15m2s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 37m14s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m7s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m21s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m7s
AI Code Review / AI Code Review (pull_request) Successful in 6m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 40s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 11m26s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 14m6s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 15m2s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 37m14s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
根因:#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: 用户反馈后台配置提示词无效
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user