feat: 同步 V9.3 生图工作流、用户意图解析和参考图一致性

This commit is contained in:
喻长发
2026-09-16 08:55:25 +08:00
parent a7f496e635
commit d931871baa
15 changed files with 5840 additions and 84 deletions
+44 -1
View File
@@ -22,7 +22,50 @@
- evidenceIds 只用于机器追溯,不作为用户可见说明。 - evidenceIds 只用于机器追溯,不作为用户可见说明。
- 未采集经营数据时,不推断市场规模、转化率、利润或竞品表现。 - 未采集经营数据时,不推断市场规模、转化率、利润或竞品表现。
## 文本长度 ## 参考图拍摄一致策略(`matchReferenceShooting`)
该字段是一个显式的视觉策略开关,默认 `false`。它解决“产品要不要换”和“拍摄方式要不要跟参考图”被模型混为一谈的问题:
- `false`:参考图只作为商品身份、结构、材质、颜色和比例锚点;机位、景别、背景、光线、具体动作和摆放状态按当前图片的业务目标与产品目标重新设计。
- `true`:同时迁移参考图的拍摄语法与产品展示状态。除机位/视角、景别、透视、构图、背景、光线和留白外,还必须具体复现平铺/悬挂/折叠/展开/堆叠/铺设/穿着/手持/使用中等状态,以及朝向、折叠程度、垂落褶皱、落点、支撑/接触点、遮挡、部件关系和人物动作阶段;不复制品牌、文字、无关商品或未经证实的卖点。
提示词优先级为:平台合规与安全 > 用户明确商品设定 / `identityLock` > 方案产品目标与批准的产品变化 > 当前槽位业务目标和证据任务 > 勾选时的参考图拍摄语言 > 默认视觉风格。首张 `main:1` 以第一张参考图作为商品外观母版;只有产品目标或用户最终设定明确列出的字段可以改变,其余轮廓、比例、材质、颜色、件数、结构和配件必须冻结。中文约束和 English constraints 同时写入最终 prompt,预览与正式生成共享同一策略。
## 对应参考图文案生成补充
开启参考一致时,对应采集图提供表达主题、产品展示状态和文字版式;上传产品图提供当前产品身份。参考图原文和参数不能直接作为当前产品事实。
每张生图任务携带对应图片的主题、视觉信息与商业含义分析。用户未指定文字策略时,按方案与参考图表达生成当前产品文案;用户明确要求决定文字是否出现、具体内容及排版变化。文字识别缺失不等于参考图无字,须查看实际参考图。
展示状态和文字版式作为固定视觉约束,方案与默认槽位任务在其中表达,不得改换参考图主题。保留文字位置、大小占比、对齐、层级、换行、行距和留白;文案过长时精简内容,不能移动或缩小商品、改变动作或折叠状态腾出文字区域。不复制参考商品原文、品牌或未经确认的宣称。
本模式的执行顺序为:先确认当前产品身份与事实,锁定对应参考图展示状态和文字版式,再将方案与业务文案适配进去;默认风格不得改变固定视觉约束。上述补充取代旧版“不复制文字”的笼统限制及允许业务任务改动参考状态的排序:禁止照抄原文,不禁止生成当前产品文案。预览与正式生成共享相同规则。
## 参考数据实际传输
开启参考一致时,按对应图片证据 ID 读取原始图片角色分析和构图观察,不用页面固定槽位名称改写主题,也不套用页面标签长度限制截断原始文字、构图或分析字段。完整原始分析随该图提示词传递;其中采集商品参数仅是参考表达,不是当前产品事实。
当前 API易 GPT Image 渠道携带参考图时使用 `/images/edits` 的 multipart 请求:第一个 `image[]` 文件为上传产品身份图,第二个为本槽位对应采集图。发送真实图片字节,不以 URL 别名字段或图片数量记录代替图片输入。读取失败、参考绑定不准确或图片数据无效时终止该槽位,不降级为无参考生图或其他采集图。
## 用户输入、方案与对应参考图的统一判断
没有全局或单图输入时,沿用所选方案的默认生图流程,不增加意图分析调用。参考一致开关继续决定展示与排版策略。
有输入时,使用完整用户内容、所选方案、产品与品类背景以及逐图原始参考分析进行统一语义判断,不以固定槽位关键词决定是否丢弃输入。方案始终是底座;用户明确要求及必然关联内容覆盖对应默认字段,其他适用方向继续保留。换品类不自动清空方案、重做报告或要求补齐所有新品资料;文案与展示按新品适配,不把旧品专属参数冒充新品事实。
判断结果包含明确产品更新、整套执行要求和每张图的主题/展示/文案关系。单图视觉要求只作用对应图片,单图明确的产品更新作用整套商品。参考一致开启时保持对应图适用的状态及文字位置、大小、层级和排版;用户明确改变的项可覆盖,跨品类不强迫新品执行不适用的旧品动作。
预览与正式生成调用同一解析函数,对相同上下文在当前服务进程的有界缓存内复用结果;输入、方案、产品图片或参考分析变化后重新判断。解析格式或接口异常保留完整输入与上下文交由生图模型理解,不终止整套任务;没有原文依据的解析更新不当成确认事实。单图原始编辑不再额外追加“无条件最高优先”提示,避免覆盖已统一的关系。
## 生图有效指令组装
参考观察增加通用品类无关的displayRelations:可见局部、局部间连接/接触/覆盖/遮挡/方向/相对位置/形变、视角及不可确认区域。主图和详情分析按原图证据ID记录,逐图完整传递,不猜隐藏结构,不将层数当件数。参考一致开启时,有输入和无输入共用空间关系执行约束;产品固有组成与展示形变分离。旧报告没有该字段时,生成模型直接核对实际对应参考图,不因字段缺失阻断生成,也不填固定折叠模板。输入检查不等于生成结果已精确复现,结果仍需按可见关系对照。
用户输入只授权其涉及的变化,不能由辅助理解将局部调整扩大为重拍或品类重判。开启参考一致时,对应图实际展示状态及原始构图观察是未被覆盖的执行依据,不仅是低优先级资料;产品图只确定身份,不能覆盖参考姿态或场景。辅助解析位于有效约束之后,仅解释授权变化。商品解析更新被拒绝时,同批可能受其污染的逐图描述也退出生图输入;原始返回仍保存用于排查,完整用户输入继续执行,不终止整套生图。
当前产品身份不可擅自变化,用户明确要求其次,适用参考关系再次,方案补充未指定内容。参考排版只在仍适用时保持。统一判断结果为辅助理解,完整原文保留在最终请求;普通解析格式或接口异常不终止生图,未能验证的商品解析更新不提升为已确认事实。最终有效指令重新组装,参考观察和方案以资料上下文传递,不重复追加旧强制指令。原始解析返回及异常随任务用户上下文保存用于排查。
## 文本长度限制
- 核心判断与专业意见不超过55字。 - 核心判断与专业意见不超过55字。
- 老板结论不超过28字,补充说明不超过28字。 - 老板结论不超过28字,补充说明不超过28字。
+218 -2
View File
@@ -1,8 +1,14 @@
import json, os, re, ssl, time, urllib.request, urllib.error import base64, json, os, re, ssl, time, uuid, urllib.request, urllib.error
from urllib.parse import urlparse
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
SERVER_VERSION = '9.3.0' SERVER_VERSION = '9.3.0'
IMAGE_QUALITY_OPTIONS = ('auto', 'low', 'medium', 'high', 'xhigh', 'max')
def normalize_image_quality(value):
value = str(value or '').strip().lower()
return value if value in IMAGE_QUALITY_OPTIONS else ''
def load_config(): def load_config():
cfg = {} cfg = {}
@@ -25,14 +31,45 @@ def load_config():
cfg['api_base'] = os.getenv('AI_API_BASE', cfg.get('api_base','https://api.bananarouter.com/v1')).rstrip('/') cfg['api_base'] = os.getenv('AI_API_BASE', cfg.get('api_base','https://api.bananarouter.com/v1')).rstrip('/')
cfg['api_key'] = os.getenv('AI_API_KEY', cfg.get('api_key','')) cfg['api_key'] = os.getenv('AI_API_KEY', cfg.get('api_key',''))
cfg['model'] = os.getenv('AI_MODEL', cfg.get('model','')) cfg['model'] = os.getenv('AI_MODEL', cfg.get('model',''))
cfg['image_api_base'] = os.getenv('IMAGE_API_BASE', cfg.get('image_api_base','')).rstrip('/')
cfg['image_api_key'] = os.getenv('IMAGE_API_KEY', cfg.get('image_api_key',''))
cfg['image_model'] = os.getenv('IMAGE_MODEL', cfg.get('image_model',''))
cfg['image_quality'] = normalize_image_quality(os.getenv('IMAGE_QUALITY', cfg.get('image_quality','')))
raw_image_models = os.getenv('IMAGE_MODELS', cfg.get('image_models', []))
if isinstance(raw_image_models, str):
raw_image_models = [x.strip() for x in raw_image_models.replace('\n', ',').split(',') if x.strip()]
if not isinstance(raw_image_models, list):
raw_image_models = []
image_models = []
for item in raw_image_models:
if isinstance(item, dict):
model_id = str(item.get('id') or item.get('model') or '').strip()
label = str(item.get('label') or model_id).strip()
quality = normalize_image_quality(item.get('quality'))
else:
model_id = str(item or '').strip()
label = model_id
quality = ''
if model_id and not any(x['id'] == model_id for x in image_models):
image_models.append({'id': model_id, 'label': label or model_id, 'quality': quality})
default_image_model = str(cfg['image_model'] or '').strip()
if default_image_model and not any(x['id'] == default_image_model for x in image_models):
image_models.insert(0, {'id': default_image_model, 'label': default_image_model, 'quality': cfg['image_quality']})
for item in image_models:
if not item.get('quality'):
item['quality'] = cfg['image_quality']
cfg['image_models'] = image_models
cfg['chat_path'] = str(cfg.get('chat_path','/chat/completions')) cfg['chat_path'] = str(cfg.get('chat_path','/chat/completions'))
cfg['models_path'] = str(cfg.get('models_path','/models')) cfg['models_path'] = str(cfg.get('models_path','/models'))
cfg['image_models_path'] = str(cfg.get('image_models_path','/models'))
try: cfg['temperature'] = float(os.getenv('AI_TEMPERATURE', cfg.get('temperature',0.2))) try: cfg['temperature'] = float(os.getenv('AI_TEMPERATURE', cfg.get('temperature',0.2)))
except Exception: cfg['temperature'] = 0.2 except Exception: cfg['temperature'] = 0.2
try: cfg['timeout'] = int(os.getenv('AI_TIMEOUT', cfg.get('timeout',120))) try: cfg['timeout'] = int(os.getenv('AI_TIMEOUT', cfg.get('timeout',120)))
except Exception: cfg['timeout'] = 120 except Exception: cfg['timeout'] = 120
try: cfg['retries'] = max(0, int(cfg.get('retries',2))) try: cfg['retries'] = max(0, int(cfg.get('retries',2)))
except Exception: cfg['retries'] = 2 except Exception: cfg['retries'] = 2
try: cfg['image_concurrency'] = max(1, min(5, int(os.getenv('IMAGE_CONCURRENCY', cfg.get('image_concurrency',5)))))
except Exception: cfg['image_concurrency'] = 5
cfg['extra_headers'] = cfg.get('extra_headers',{}) or {} cfg['extra_headers'] = cfg.get('extra_headers',{}) or {}
cfg['ca_bundle'] = str(cfg.get('ca_bundle','') or '') cfg['ca_bundle'] = str(cfg.get('ca_bundle','') or '')
cfg['ssl_verify'] = bool(cfg.get('ssl_verify', True)) cfg['ssl_verify'] = bool(cfg.get('ssl_verify', True))
@@ -68,6 +105,63 @@ def _headers(c):
h.update(c.get('extra_headers',{})) h.update(c.get('extra_headers',{}))
return h return h
def image_channel(c=None):
"""Return an image-provider config, falling back to the analysis channel for compatibility."""
source=dict(c or load_config())
source['api_base']=str(source.get('image_api_base') or source.get('api_base') or '').rstrip('/')
source['api_key']=str(source.get('image_api_key') or source.get('api_key') or '')
source['models_path']=str(source.get('image_models_path') or '/models')
return source
def image_model_options(c=None):
"""Return configured image models while keeping the legacy single-model field."""
source = c if isinstance(c, dict) else load_config()
options = source.get('image_models') or []
result = []
for item in options:
if isinstance(item, dict):
model_id = str(item.get('id') or item.get('model') or '').strip()
label = str(item.get('label') or model_id).strip()
quality = normalize_image_quality(item.get('quality'))
else:
model_id = str(item or '').strip()
label = model_id
quality = ''
if model_id and not any(x['id'] == model_id for x in result):
result.append({'id': model_id, 'label': label or model_id, 'quality': quality})
legacy = str(source.get('image_model') or '').strip()
if legacy and not any(x['id'] == legacy for x in result):
result.insert(0, {'id': legacy, 'label': legacy, 'quality': normalize_image_quality(source.get('image_quality'))})
default_quality = normalize_image_quality(source.get('image_quality'))
for item in result:
if not item.get('quality'):
item['quality'] = default_quality
return result
def probe_image_api(timeout=15):
c=image_channel()
options=image_model_options(c)
model=str(c.get('image_model') or (options[0]['id'] if options else '')).strip()
if not c.get('api_base') or not c.get('api_key') or not options:
return {'ok':False,'stage':'image_config','error':'生图渠道 API Base、API Key 或生图模型未配置'}
try:
req=urllib.request.Request(_join(c['api_base'],c['models_path']),headers=_headers(c),method='GET')
with urllib.request.urlopen(req,timeout=min(timeout,c['timeout']),context=_ssl_context(c)) as r:
obj=json.loads(r.read().decode('utf-8','ignore') or '{}')
ids=[x.get('id') for x in (obj.get('data') or []) if isinstance(x,dict)]
model_checks=[{'id':item['id'],'label':item['label'],'quality':item.get('quality') or '',
'found':(item['id'] in ids) if ids else None} for item in options]
return {'ok':True,'stage':'image_models','http':200,'imageModel':model,
'imageModelFound':(model in ids) if ids else None,'modelsCount':len(ids),
'imageModels':model_checks,
'allImageModelsFound':all(item['found'] is not False for item in model_checks),
'separateChannel':bool(c.get('image_api_base') or c.get('image_api_key'))}
except urllib.error.HTTPError as e:
body=e.read().decode('utf-8','ignore')
return {'ok':False,'stage':'image_models','http':e.code,'error':body[:500] or str(e),'imageModel':model}
except Exception as e:
return {'ok':False,'stage':'image_network','error':str(e),'imageModel':model}
def _join(base,path): def _join(base,path):
path='/' + str(path or '').lstrip('/') path='/' + str(path or '').lstrip('/')
return base.rstrip('/') + path return base.rstrip('/') + path
@@ -83,7 +177,10 @@ def probe_model_api(timeout=15):
with urllib.request.urlopen(req,timeout=min(timeout,c['timeout']),context=_ssl_context(c)) as r: with urllib.request.urlopen(req,timeout=min(timeout,c['timeout']),context=_ssl_context(c)) as r:
obj=json.loads(r.read().decode('utf-8','ignore') or '{}') obj=json.loads(r.read().decode('utf-8','ignore') or '{}')
ids=[x.get('id') for x in (obj.get('data') or []) if isinstance(x,dict)] ids=[x.get('id') for x in (obj.get('data') or []) if isinstance(x,dict)]
return {'ok':True,'stage':'models','http':200,'modelFound':(c['model'] in ids) if ids else None,'model':c['model'],'modelsCount':len(ids)} image_model=str(c.get('image_model') or '').strip()
return {'ok':True,'stage':'models','http':200,'modelFound':(c['model'] in ids) if ids else None,'model':c['model'],
'imageModelConfigured':bool(image_model),'imageModel':image_model or None,
'imageModelFound':(image_model in ids) if ids and image_model else None,'modelsCount':len(ids)}
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
body=e.read().decode('utf-8','ignore') body=e.read().decode('utf-8','ignore')
return {'ok':False,'stage':'models','http':e.code,'error':body[:500] or str(e)} return {'ok':False,'stage':'models','http':e.code,'error':body[:500] or str(e)}
@@ -107,6 +204,125 @@ def _do_post(c,payload):
with urllib.request.urlopen(req,timeout=c['timeout'],context=_ssl_context(c)) as r: with urllib.request.urlopen(req,timeout=c['timeout'],context=_ssl_context(c)) as r:
return json.loads(r.read().decode('utf-8','ignore')) return json.loads(r.read().decode('utf-8','ignore'))
def image_generate(prompt, size='1024x1024', n=1, model=None, reference_images=None, quality=None, style=None):
"""Generate commerce images through an OpenAI-compatible images endpoint.
Image generation is deliberately separate from chat generation: operators can
keep a text model for analysis and configure a dedicated image model/path in
config.json. The return value is the provider response so the server can
persist both URL and base64 responses locally.
"""
c=image_channel()
if c.get('_config_error'):
raise RuntimeError(c['_config_error'])
if not c.get('api_key'):
raise RuntimeError('未配置 API Key,无法生成图片。')
image_model=str(model or c.get('image_model') or '').strip()
if not image_model:
raise RuntimeError('未配置独立的生图模型 image_model。请在本地服务配置页与分析模型一起填写。')
try: count=max(1,min(8,int(n)))
except Exception: count=1
payload={
'model':image_model,
'prompt':str(prompt or '').strip(),
'size':str(size or c.get('image_size') or '1024x1024'),
'n':count,
}
quality_value=normalize_image_quality(c.get('image_quality') if quality is None else quality)
if quality_value: payload['quality']=quality_value
style_value=c.get('image_style') if style is None else style
if style_value: payload['style']=style_value
path=str(c.get('image_path') or '/images/generations')
last=None
refs=[str(x).strip() for x in (reference_images or []) if str(x).strip()]
if len(refs)>4:
raise ValueError('单次生图最多四张参考图,不能静默丢弃输入')
edit_request=bool(refs and image_model.startswith('gpt-image-') and (
urlparse(c['api_base']).hostname in ('api.apiyi.com','api.openai.com') or path.rstrip('/').endswith('/images/edits')
))
if edit_request and path.rstrip('/').endswith('/images/generations'):
path=path.rstrip('/')[:-len('generations')]+'edits'
variants=[]
if edit_request:
variants=[payload]
elif refs:
# OpenAI-compatible image gateways use different names for image guidance.
# Try the common JSON shapes, but never silently drop a requested reference.
for key in ('image','reference_images','images'):
v=dict(payload); v[key]=refs[0] if key=='image' and len(refs)==1 else refs; variants.append(v)
else:
variants=[payload]
# Gateways differ: retry without optional fields before failing the job.
if not edit_request and ('quality' in payload or 'style' in payload):
clean=[]
for item in variants:
v=dict(item); v.pop('quality',None); v.pop('style',None); clean.append(v)
variants.extend(clean)
retryable_http=(408,409,429,500,502,503,504)
# Try payload aliases only when the provider rejects a shape. A transport
# failure must retry the same request after backoff; cycling every alias on
# a TLS EOF/reset creates a burst of duplicate generation requests.
for variant in variants:
shape_rejected=False
for attempt in range(c['retries']+1):
try:
return _do_image_edit(c,path,variant,refs) if edit_request else _do_post_path(c,path,variant)
except urllib.error.HTTPError as e:
body=e.read().decode('utf-8','ignore')
if e.code in (502,503,504):
last=RuntimeError(f'生图网关暂时不可用(HTTP {e.code}),系统已自动重试;请稍后仅重试失败图片')
elif body.lstrip().lower().startswith(('<!doctype html','<html')):
last=RuntimeError(f'图片接口 HTTP {e.code}:上游返回了网页错误页')
else:
last=RuntimeError(f'图片接口 HTTP {e.code}: {body[:900]}')
if e.code==400:
if edit_request:
raise last
shape_rejected=True
break
if e.code not in retryable_http:
raise last
except Exception as e:
last=RuntimeError(f'图片接口调用失败: {e}')
if attempt<c['retries']:
time.sleep(min(2 ** attempt * 2,8))
if not shape_rejected:
raise last or RuntimeError('图片接口调用失败')
raise last or RuntimeError('图片接口调用失败')
def _do_image_edit(c,path,payload,refs):
"""Send ordered, actual image files using the provider's edit contract."""
boundary='tmall_image_'+uuid.uuid4().hex
chunks=[]
for key,value in payload.items():
chunks.append((f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n{value}\r\n').encode('utf-8'))
for index,ref in enumerate(refs,1):
if not ref.startswith('data:image/') or ',' not in ref:
raise ValueError('编辑接口必须接收已读取的图片数据,不能传远程链接')
head,encoded=ref.split(',',1)
if ';base64' not in head:
raise ValueError('参考图必须使用 Base64 图片数据')
data=base64.b64decode(encoded,validate=True)
mime=head[5:].split(';',1)[0].lower()
formats={'image/png':('png',data.startswith(b'\x89PNG\r\n\x1a\n')),
'image/jpeg':('jpg',data.startswith(b'\xff\xd8\xff')),
'image/webp':('webp',data[:4]==b'RIFF' and data[8:12]==b'WEBP')}
if mime not in formats or not formats[mime][1] or len(data)>16*1024*1024:
raise ValueError('参考图类型、数据签名或大小不符合编辑接口要求')
ext=formats[mime][0]
chunks.append((f'--{boundary}\r\nContent-Disposition: form-data; name="image[]"; filename="reference-{index}.{ext}"\r\nContent-Type: {mime}\r\n\r\n').encode('ascii'))
chunks.extend((data,b'\r\n'))
chunks.append(f'--{boundary}--\r\n'.encode('ascii'))
headers=_headers(c);headers['Content-Type']='multipart/form-data; boundary='+boundary
req=urllib.request.Request(_join(c['api_base'],path),data=b''.join(chunks),headers=headers,method='POST')
with urllib.request.urlopen(req,timeout=c['timeout'],context=_ssl_context(c)) as response:
return json.loads(response.read().decode('utf-8','ignore'))
def _do_post_path(c,path,payload):
req=urllib.request.Request(_join(c['api_base'],path),data=json.dumps(payload,ensure_ascii=False).encode('utf-8'),headers=_headers(c),method='POST')
with urllib.request.urlopen(req,timeout=c['timeout'],context=_ssl_context(c)) as r:
return json.loads(r.read().decode('utf-8','ignore'))
def chat_json(system, user, images=None, max_tokens=5000): def chat_json(system, user, images=None, max_tokens=5000):
c=load_config() c=load_config()
if c.get('_config_error'): if c.get('_config_error'):
+280 -20
View File
@@ -79,12 +79,12 @@ FINAL_REPORT_PROMPT='''任务G|V9.3.0 七角色总编报告编排器。你只
{"label":"下一款优先","headline":"<=18字","note":"<=22字","evidenceIds":[]} {"label":"下一款优先","headline":"<=18字","note":"<=22字","evidenceIds":[]}
]}, ]},
"titleAnalysis":{"professionalOpinion":"<=45字","originalTitle":"","currentExpression":[{"value":"<=10字","evidenceIds":[]}],"reinforce":[{"topic":"<=10字","evidenceIds":[]}],"recommendedActions":[{"action":"<=22字","evidenceIds":[]}],"recommendedTitles":[]}, "titleAnalysis":{"professionalOpinion":"<=45字","originalTitle":"","currentExpression":[{"value":"<=10字","evidenceIds":[]}],"reinforce":[{"topic":"<=10字","evidenceIds":[]}],"recommendedActions":[{"action":"<=22字","evidenceIds":[]}],"recommendedTitles":[]},
"visualCommerce":{"professionalOpinion":"<=45字","items":[{"imageEvidenceId":"IMG_MAIN_0001","imageRole":"<=8字","visualSignals":["<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","evidenceIds":[]}],"evidenceDetail":[]}, "visualCommerce":{"professionalOpinion":"<=45字","items":[{"imageEvidenceId":"IMG_MAIN_0001","imageRole":"<=8字","visualSignals":["<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","contentKey":"product_overview|usage_scene|material_touch|structure_function|spec_choice","evidenceIds":[]}],"evidenceDetail":[]},
"detailCommerce":{"professionalOpinion":"<=45字","contentGroups":[{"type":"品牌服务|套件组成|材质品质|产品功能|使用便利|花型选择|用户验证|其他","representativeImageId":"IMG_DETAIL_0001","visualSignals":["<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","imageEvidenceIds":[],"evidenceIds":[]}],"pageSequence":[]}, "detailCommerce":{"professionalOpinion":"<=45字","contentGroups":[{"type":"场景证明|材质证明|结构工艺|功能表现|规格适配|使用维护","representativeImageId":"IMG_DETAIL_0001","visualSignals":["<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","contentKey":"scene_problem|material_proof|structure_proof|function_proof|spec_adaptation|care_durability","imageEvidenceIds":[],"evidenceIds":[]}],"pageSequence":[]},
"skuAnalysis":{"professionalOpinion":"<=35字","selectionDimensions":[{"name":"<=8字","values":[],"evidenceIds":[]}],"recommendedOrder":[],"representativeImageIds":[],"recommendedAction":"<=24字","structuredStatus":"confirmed|missing","structuredCount":null,"imageCount":0}, "skuAnalysis":{"professionalOpinion":"<=35字","selectionDimensions":[{"name":"<=8字","values":[],"evidenceIds":[]}],"recommendedOrder":[],"representativeImageIds":[],"recommendedAction":"<=24字","structuredStatus":"confirmed|missing","structuredCount":null,"imageCount":0},
"customerExperience":{"professionalOpinion":"<=45字","consumerInsights":{"experienceSignals":[{"type":"稳定体验|体验差异|购前关注|对新品的启示","insight":"<=28字","evidenceIds":[]}],"productImplications":[]},"buyerEvidence":{}}, "customerExperience":{"professionalOpinion":"<=45字","consumerInsights":{"experienceSignals":[{"type":"稳定体验|体验差异|购前关注|对新品的启示","insight":"<=28字","evidenceIds":[]}],"productImplications":[]},"buyerEvidence":{}},
"expressionContinuity":{"coreClaims":[{"claim":"<=24字","mainStatus":"expressed|partial|missing","detailStatus":"proven|partial|missing","reviewStatus":"validated|partial|missing","nextAction":"<=24字","evidenceIds":[]}]}, "expressionContinuity":{"coreClaims":[{"claim":"<=24字","mainStatus":"expressed|partial|missing","detailStatus":"proven|partial|missing","reviewStatus":"validated|partial|missing","nextAction":"<=24字","evidenceIds":[]}]},
"newProductPlans":{"professionalOpinion":"<=45字","plans":[{"sourceType":"证据驱动优化|反馈驱动升级|探索性方向","type":"现有基础优化|强化体验区分|强化设计选择|其他","name":"<=18字","whyThisPlan":"<=32字","visualReferenceImageIds":[],"productAction":"<=28字","pageAction":"<=28字","validation":"<=20字","evidenceIds":[]}]}, "newProductPlans":{"professionalOpinion":"<=45字","plans":[{"sourceType":"证据驱动优化|反馈驱动升级|探索性方向","name":"<=18字","productAction":"<=45字","pageAction":"<=36字"}]},
"productExperience":{"parameterFacts":[],"gates":[]}, "productExperience":{"parameterFacts":[],"gates":[]},
"validationLoop":{"rows":[]} "validationLoop":{"rows":[]}
} }
@@ -92,8 +92,10 @@ FINAL_REPORT_PROMPT='''任务G|V9.3.0 七角色总编报告编排器。你只
硬规则: 硬规则:
- ownerOverview.cards 必须正好3项,且分别回答“为什么能卖 / 值得继承 / 下一款优先”。 - ownerOverview.cards 必须正好3项,且分别回答“为什么能卖 / 值得继承 / 下一款优先”。
- visualCommerce.items 中每张图必须优先给 visualSignals;不要写长 message。 - visualCommerce.items 中每张图必须优先给 visualSignals;不要写长 message。
- detailCommerce.contentGroups 证据允许时输出4-6组。 - detailCommerce.contentGroups 证据允许时输出4-6组,顺序必须是场景 → 材质 → 结构/工艺 → 功能 → 规格 → 使用维护。
- newProductPlans.plans>=1;第三类创新若不是直接证据结论必须标 sourceType=探索性方向。 - 主图与详情必须形成连续页面:主图先让用户看懂商品和使用入口,详情逐项证明;同一卖点只能在一个信息任务中承担主责。
- 主图和详情只表达商品本身的结构、材质、成分、尺寸、件数、功能、工艺、触感、使用场景和洗护等内容。品牌、商标、专利、授权、认证、奖项、包邮、物流、价格、销量、促销、客服、售后、二维码、防伪和店铺信息不得进入 visualCommerce 或 detailCommerce。
- newProductPlans.plans 必须正好3项,依次为证据驱动优化、反馈驱动升级、探索性方向;探索项必须明确为待验证方向,不能当作已验证结论。
- productExperience.parameterFacts>=2、productExperience.gates>=2、validationLoop.rows>=3,继续用于工程证据校验,但默认报告不展示。 - productExperience.parameterFacts>=2、productExperience.gates>=2、validationLoop.rows>=3,继续用于工程证据校验,但默认报告不展示。
- 禁止用户可见字段出现[数据事实][分析判断][设计建议][证据不足]。 - 禁止用户可见字段出现[数据事实][分析判断][设计建议][证据不足]。
- 只返回有真实内容的可选字段;不得用空字符串、空数组、空对象或“暂无/未采集/待补充”填充版面。 - 只返回有真实内容的可选字段;不得用空字符串、空数组、空对象或“暂无/未采集/待补充”填充版面。
@@ -102,7 +104,7 @@ FINAL_REPORT_PROMPT='''任务G|V9.3.0 七角色总编报告编排器。你只
FINAL_REPORT_REPAIR_PROMPT='''任务G-R|修复 V9.3.0 最终报告 JSON。只修复结构缺失和证据引用,不扩写正文。 FINAL_REPORT_REPAIR_PROMPT='''任务G-R|修复 V9.3.0 最终报告 JSON。只修复结构缺失和证据引用,不扩写正文。
必须保留短文本与图片信息优先规则。 必须保留短文本与图片信息优先规则。
强制最小结构:reportSummary.title/verdict非空;ownerOverview.cards>=3;productExperience.parameterFacts>=2;productExperience.gates>=2;newProductPlans.plans>=1;validationLoop.rows>=3。 强制最小结构:reportSummary.title/verdict非空;ownerOverview.cards>=3;productExperience.parameterFacts>=2;productExperience.gates>=2;newProductPlans.plans=3;validationLoop.rows>=3。
如果 customerExperience 有评论证据,优先保留 experienceSignals;无评论时允许为空。 如果 customerExperience 有评论证据,优先保留 experienceSignals;无评论时允许为空。
所有 evidenceIds 只能来自 ACTUAL_EVIDENCE。不要输出 Markdown、解释或包装层。 所有 evidenceIds 只能来自 ACTUAL_EVIDENCE。不要输出 Markdown、解释或包装层。
可选字段没有真实内容时直接省略,不得填入“暂无、未采集、待补充”或空容器。 可选字段没有真实内容时直接省略,不得填入“暂无、未采集、待补充”或空容器。
@@ -680,6 +682,69 @@ def _apply_selling_points(exp, raw, base=None):
} }
return exp return exp
def _ensure_three_product_plans(exp, raw, base=None):
"""Keep one evidence-backed plan for each decision horizon, even when the model returns fewer."""
if not isinstance(exp,dict):return exp
base=base or baseline(raw); block=exp.setdefault('newProductPlans',{})
existing=[x for x in block.get('plans',[]) or [] if isinstance(x,dict)]
valid_types=('证据驱动优化','反馈驱动升级','探索性方向')
by_type={}
untyped=[]
for plan in existing:
source=_txt(plan.get('sourceType')).strip()
if source in valid_types and source not in by_type:by_type[source]=dict(plan)
else:untyped.append(dict(plan))
for source in valid_types:
if source not in by_type and untyped:
plan=untyped.pop(0);plan['sourceType']=source;by_type[source]=plan
attrs=_attribute_rows(raw);issue=_review_issue(raw);points=_derive_next_selling_points(raw,base,issue)
attr_refs=[f'ATTR_{i:04d}' for i in range(1,min(len(attrs),6)+1)]
review_refs=[]
if issue and issue.get('evidenceId'):review_refs.append(issue['evidenceId'])
for point in points:
review_refs.extend(x for x in point.get('evidenceIds',[]) or [] if _txt(x).startswith('REV_'))
review_refs=_uniq(review_refs)[:6]
shared_refs=_uniq((attr_refs or ['P_TITLE'])+review_refs)
strongest=points[0] if points else {}
fallbacks={
'证据驱动优化':{
'sourceType':'证据驱动优化','name':'规格校准款',
'productAction':_txt(strongest.get('productAction')).strip() or '统一现有规格、材质、结构与套件口径',
'pageAction':'主图讲清商品与场景,详情逐项证明材质、结构和规格',
'evidenceIds':shared_refs,
},
'反馈驱动升级':{
'sourceType':'反馈驱动升级','name':'体验升级款',
'productAction':(_txt(issue.get('action')).strip() if issue else '') or '围绕已采消费者反馈优化使用体验与规格适配',
'pageAction':'用实拍动作、细节近景和对照信息回应购买顾虑',
'evidenceIds':_uniq(review_refs+attr_refs) or shared_refs,
},
'探索性方向':{
'sourceType':'探索性方向','name':'差异探索款',
'productAction':'基于现有结构开发差异化配色或规格小样,先验证再定款',
'pageAction':'独立展示探索设计与适用场景,不宣称已获市场验证',
'evidenceIds':shared_refs,
},
}
plans=[]
for source in valid_types:
plan=by_type.get(source) or fallbacks[source]
plan['sourceType']=source
if not _txt(plan.get('name')).strip():plan['name']=fallbacks[source]['name']
name=_txt(plan.get('name')).strip()
if len(name)>14:name=fallbacks[source]['name']
if not name.endswith('款'):name=name[:17].rstrip(' ,。-')+'款'
plan['name']=name[:17].rstrip(' ,。-')+'款' if len(name)>18 else name
if not _txt(plan.get('productAction')).strip():plan['productAction']=fallbacks[source]['productAction']
if not _txt(plan.get('pageAction')).strip():plan['pageAction']=fallbacks[source]['pageAction']
if not plan.get('evidenceIds'):plan['evidenceIds']=fallbacks[source]['evidenceIds']
plans.append(plan)
block['plans']=plans
block['professionalOpinion']=block.get('professionalOpinion') or '三条路径并行对照:先优化现有商品,再回应反馈,最后小样验证差异方向。'
return exp
def _generic_platform_overview(raw, base=None): def _generic_platform_overview(raw, base=None):
base=base or baseline(raw); route=detect_category(raw); attrs=_attribute_rows(raw); sales=raw.get('sales') or {}; issue=_review_issue(raw) base=base or baseline(raw); route=detect_category(raw); attrs=_attribute_rows(raw); sales=raw.get('sales') or {}; issue=_review_issue(raw)
price=_txt(sales.get('currentPrice')).strip(); sold=_txt(sales.get('sold') or sales.get('cumulativeSales')).strip(); facts=[] price=_txt(sales.get('currentPrice')).strip(); sold=_txt(sales.get('sold') or sales.get('cumulativeSales')).strip(); facts=[]
@@ -746,7 +811,7 @@ def _core_solution_ok(exp):
isinstance((exp.get('ownerOverview') or {}).get('cards'),list) and len((exp.get('ownerOverview') or {}).get('cards'))>=3, isinstance((exp.get('ownerOverview') or {}).get('cards'),list) and len((exp.get('ownerOverview') or {}).get('cards'))>=3,
isinstance((exp.get('productExperience') or {}).get('parameterFacts'),list) and len((exp.get('productExperience') or {}).get('parameterFacts'))>=2, isinstance((exp.get('productExperience') or {}).get('parameterFacts'),list) and len((exp.get('productExperience') or {}).get('parameterFacts'))>=2,
isinstance((exp.get('productExperience') or {}).get('gates'),list) and len((exp.get('productExperience') or {}).get('gates'))>=2, isinstance((exp.get('productExperience') or {}).get('gates'),list) and len((exp.get('productExperience') or {}).get('gates'))>=2,
isinstance((exp.get('newProductPlans') or {}).get('plans'),list) and len((exp.get('newProductPlans') or {}).get('plans'))>=1, isinstance((exp.get('newProductPlans') or {}).get('plans'),list) and len((exp.get('newProductPlans') or {}).get('plans'))>=3,
isinstance((exp.get('validationLoop') or {}).get('rows'),list) and len((exp.get('validationLoop') or {}).get('rows'))>=3, isinstance((exp.get('validationLoop') or {}).get('rows'),list) and len((exp.get('validationLoop') or {}).get('rows'))>=3,
) )
return all(checks) return all(checks)
@@ -759,7 +824,7 @@ def core_solution_diagnostics(exp):
('ownerOverview.cards',isinstance((exp.get('ownerOverview') or {}).get('cards'),list),len((exp.get('ownerOverview') or {}).get('cards') or []),3), ('ownerOverview.cards',isinstance((exp.get('ownerOverview') or {}).get('cards'),list),len((exp.get('ownerOverview') or {}).get('cards') or []),3),
('productExperience.parameterFacts',isinstance((exp.get('productExperience') or {}).get('parameterFacts'),list),len((exp.get('productExperience') or {}).get('parameterFacts') or []),2), ('productExperience.parameterFacts',isinstance((exp.get('productExperience') or {}).get('parameterFacts'),list),len((exp.get('productExperience') or {}).get('parameterFacts') or []),2),
('productExperience.gates',isinstance((exp.get('productExperience') or {}).get('gates'),list),len((exp.get('productExperience') or {}).get('gates') or []),2), ('productExperience.gates',isinstance((exp.get('productExperience') or {}).get('gates'),list),len((exp.get('productExperience') or {}).get('gates') or []),2),
('newProductPlans.plans',isinstance((exp.get('newProductPlans') or {}).get('plans'),list),len((exp.get('newProductPlans') or {}).get('plans') or []),1), ('newProductPlans.plans',isinstance((exp.get('newProductPlans') or {}).get('plans'),list),len((exp.get('newProductPlans') or {}).get('plans') or []),3),
('validationLoop.rows',isinstance((exp.get('validationLoop') or {}).get('rows'),list),len((exp.get('validationLoop') or {}).get('rows') or []),3), ('validationLoop.rows',isinstance((exp.get('validationLoop') or {}).get('rows'),list),len((exp.get('validationLoop') or {}).get('rows') or []),3),
) )
checks=[] checks=[]
@@ -777,7 +842,7 @@ def repair_core_solution_with_evidence(draft, raw, base=None):
list_rules=( list_rules=(
('ownerOverview','cards',3), ('ownerOverview','cards',3),
('productExperience','parameterFacts',2),('productExperience','gates',2), ('productExperience','parameterFacts',2),('productExperience','gates',2),
('newProductPlans','plans',1),('validationLoop','rows',3), ('newProductPlans','plans',3),('validationLoop','rows',3),
) )
for section,key in scalar_rules: for section,key in scalar_rules:
current=dict(draft.get(section) or {}) current=dict(draft.get(section) or {})
@@ -980,10 +1045,194 @@ def _filter_evidence_items(values, valid_ids, image=False):
clean=dict(item);clean['evidenceIds']=refs;out.append(clean) clean=dict(item);clean['evidenceIds']=refs;out.append(clean)
return out return out
VISUAL_EXCLUDED_TERMS=(
'品牌','商标','专利','授权','认证','证书','奖项','背书',
'包邮','物流','快递','运费','价格','销量','优惠','折扣','促销','红包',
'二维码','防伪','官方','店铺','客服','售后','信任服务','品牌服务',
'条形码','商品编码','货号','发明人','正宗','产品等级','执行标准','安全类别'
)
MAIN_VISUAL_TASKS=(
{'contentKey':'product_overview','role':'商品全貌','signals':('主体完整','整体组成'),'keywords':('件数','套件','组合','商品','产品'),'nextAction':'先让用户看清商品主体与整体组成'},
{'contentKey':'usage_scene','role':'使用场景','signals':('真实使用状态','空间适配'),'keywords':('场景','适用','人群','床型','季节','用途'),'nextAction':'承接使用场景,说明产品适合怎么用'},
{'contentKey':'material_touch','role':'材质触感','signals':('材质近景','触感细节'),'keywords':('材质','面料','成分','支数','密度','克重','填充'),'nextAction':'用近景和参数把材质触感讲具体'},
{'contentKey':'structure_function','role':'结构功能','signals':('结构细节','使用动作'),'keywords':('结构','工艺','功能','做工','细节','拉链','接口'),'nextAction':'把结构、工艺或功能的实现方式拍清楚'},
{'contentKey':'spec_choice','role':'规格选择','signals':('尺寸清楚','选择路径'),'keywords':('尺寸','规格','件数','颜色','款式','SKU','适配'),'nextAction':'最后承接尺寸、件数和选择方式'},
)
DETAIL_VISUAL_TASKS=(
{'contentKey':'scene_problem','role':'场景证明','signals':('使用问题','真实场景'),'keywords':('场景','适用','人群','用途','问题'),'nextAction':'先用真实场景回答产品解决什么使用问题'},
{'contentKey':'material_proof','role':'材质证明','signals':('材质参数','表面细节'),'keywords':('材质','面料','成分','支数','密度','克重','填充'),'nextAction':'接着证明材质、成分或填充信息'},
{'contentKey':'structure_proof','role':'结构工艺','signals':('结构拆解','工艺细节'),'keywords':('结构','工艺','做工','细节','缝制','接口','拉链'),'nextAction':'继续证明结构、工艺和关键细节'},
{'contentKey':'function_proof','role':'功能表现','signals':('使用动作','效果边界'),'keywords':('功能','性能','使用','透气','防护','收纳','连接'),'nextAction':'用使用动作说明功能表现,不扩大功效'},
{'contentKey':'spec_adaptation','role':'规格适配','signals':('尺寸对照','套件清单'),'keywords':('规格','尺寸','件数','套件','SKU','适配','颜色','款式'),'nextAction':'单独列清尺寸、件数、款式与适配关系'},
{'contentKey':'care_durability','role':'使用维护','signals':('洗护方式','耐用检查'),'keywords':('洗护','洗涤','护理','耐用','首洗','清洁','保存'),'nextAction':'最后说明洗护、耐用或长期使用注意事项'},
{'contentKey':'material_closeup','role':'材质近观','signals':('纤维纹理','触感近景'),'keywords':('材质','面料','成分','纤维','触感'),'nextAction':'补充面料纹理与触感的微观证明'},
{'contentKey':'craft_closeup','role':'工艺近观','signals':('走线细节','工艺局部'),'keywords':('工艺','缝制','绗缝','做工','细节'),'nextAction':'补充走线、接口或工艺局部的清晰证明'},
{'contentKey':'function_scenario','role':'功能场景','signals':('功能动作','使用结果'),'keywords':('功能','使用','性能','保暖','透气','抗菌'),'nextAction':'在真实使用动作中补充功能表现'},
{'contentKey':'size_compare','role':'尺寸对照','signals':('尺寸标注','实物对照'),'keywords':('尺寸','规格','床型','长度','宽度'),'nextAction':'用实物与标注对照减少规格误解'},
{'contentKey':'set_inventory','role':'套件清单','signals':('部件清楚','组合关系'),'keywords':('件数','套件','组合','配件','被套'),'nextAction':'单独说清套件包含什么及组合关系'},
{'contentKey':'color_selection','role':'款式选择','signals':('颜色对比','款式区分'),'keywords':('颜色','配色','花型','款式','SKU'),'nextAction':'用同一版式清楚区分可选颜色或款式'},
{'contentKey':'care_steps','role':'洗护步骤','signals':('洗护提示','操作步骤'),'keywords':('洗护','洗涤','护理','晾晒','保存'),'nextAction':'把洗护步骤和注意事项拆成可执行说明'},
{'contentKey':'quality_check','role':'耐用核验','signals':('长期使用','品质检查'),'keywords':('耐用','首洗','回弹','起球','褪色'),'nextAction':'补充长期使用和样品核验的观察点'},
{'contentKey':'purchase_summary','role':'选择总结','signals':('适用边界','购买确认'),'keywords':('适用','人群','场景','规格','选择'),'nextAction':'最后汇总适用人群、场景和选择边界'},
)
def _visual_product_text(value, limit=18, blocked_terms=()):
"""Keep only product-facing phrases; trust and transaction metadata stay outside the visual chain."""
if isinstance(value,list):
parts=[]
for item in value:
parts.extend(_visual_product_text(item,limit,blocked_terms))
return _uniq(parts)
if isinstance(value,dict):
for key in ('visualSignals','signal','statement','headline','value','label','topic','action','consumerValue','businessMeaning'):
if value.get(key):
return _visual_product_text(value.get(key),limit,blocked_terms)
return []
text=re.sub(r'\s+',' ',_txt(value)).strip(' ,,。;;')
if not text:return []
pieces=re.split(r'[;;。!?!?,,、|/]+',text)
out=[]
for piece in pieces:
piece=re.sub(r'\s+',' ',piece).strip(' ::')
if not piece or any(term in piece for term in VISUAL_EXCLUDED_TERMS) or any(term and term in piece for term in blocked_terms):continue
if piece not in out:out.append(piece[:limit])
return out
def _visual_refs(item, valid_ids):
refs=[]
if isinstance(item,dict):
refs.extend(x for x in item.get('evidenceIds',[]) or [] if x in valid_ids)
for key in ('imageEvidenceId','representativeImageId'):
if item.get(key) in valid_ids:refs.append(item.get(key))
refs.extend(x for x in item.get('imageEvidenceIds',[]) or [] if x in valid_ids)
return _uniq(refs)
def _visual_fact_signals(raw, keywords, limit=2, blocked_terms=()):
signals=[]
for item in _attribute_rows(raw):
name=_txt(item.get('name')); value=_txt(item.get('value'))
if any(word in name for word in keywords):
signals.extend(_visual_product_text(f'{name}:{value}',18,blocked_terms))
if len(signals)>=limit:break
return _uniq(signals)[:limit]
def _visual_image_ids(raw, group):
return [f'IMG_{group.upper()}_{i:04d}' for i,_ in enumerate((raw.get('images') or {}).get(group,[]) or [],1)]
def _visual_identity_terms(raw):
terms=[]
product=raw.get('product') or {}
for value in (_txt(product.get('brand')),):
if len(value.strip())>=2:terms.append(value.strip())
for item in _attribute_rows(raw):
name=_txt(item.get('name'))
value=_txt(item.get('value')).strip()
if any(key in name for key in ('品牌','商标')) and len(value)>=2:
terms.append(value)
return _uniq(terms)
def _visual_signal_matches(signal, keywords):
return any(keyword in signal for keyword in keywords)
def _canonical_visual_items(raw, items, tasks, valid_ids, plan_points=None, blocked_terms=()):
"""Give every visual asset one job and a stable handoff to the next job."""
source=[x for x in items or [] if isinstance(x,dict)]
image_ids=_visual_image_ids(raw,'main' if tasks is MAIN_VISUAL_TASKS else 'detail')
count=min(len(tasks),max(len(source),len(image_ids)))
if not count:return []
plan_signals=[]
for point in plan_points or []:
if isinstance(point,dict):
plan_signals.extend(_visual_product_text(point.get('slogan') or point.get('consumerValue')))
result=[]
used_sources=set()
for index in range(count):
task=tasks[index]
eid=image_ids[index] if index<len(image_ids) else ''
src=None
if eid:
src=next((x for x in source if x.get('imageEvidenceId')==eid or x.get('representativeImageId')==eid),None)
if src is None:
src=next((x for i,x in enumerate(source) if i not in used_sources),None)
if src is not None:used_sources.add(source.index(src))
refs=_visual_refs(src,valid_ids) if src else []
if eid in valid_ids and eid not in refs:refs.append(eid)
if not refs:continue
signals=_visual_product_text(src.get('visualSignals') if src else [],18,blocked_terms)
signals=[x for x in signals if _visual_signal_matches(x,task['keywords'])]
if not signals and src:
signals=_visual_product_text(src.get('message') or src.get('observation') or src.get('businessMeaning'),18,blocked_terms)
signals=[x for x in signals if _visual_signal_matches(x,task['keywords'])]
fact_signals=_visual_fact_signals(raw,task['keywords'],2,blocked_terms)
task_plan_signals=[x for x in plan_signals if _visual_signal_matches(x,task['keywords'])]
signals=_uniq(signals+fact_signals+task_plan_signals)[:4]
if not signals:signals=list(task['signals'])
result.append({
'contentKey':task['contentKey'],'imageEvidenceId':eid or next((x for x in refs if x.startswith('IMG_')),refs[0]),
'imageRole':task['role'],'visualSignals':signals[:4],
'businessMeaning':f"本图只承担{task['role']}信息,不与其他图片重复",
'nextAction':task['nextAction'],'evidenceIds':_uniq(refs)
})
return result
def _normalize_visual_continuity(exp, raw, valid_ids):
"""Normalize main/detail responsibilities after model output and build the page handoff."""
exp=dict(exp or {})
plans=((exp.get('newProductPlans') or {}).get('plans') or [])
plan_points=plans[0].get('sellingPoints') if plans and isinstance(plans[0],dict) else []
blocked_terms=_visual_identity_terms(raw)
visual=exp.get('visualCommerce') or {}
detail=exp.get('detailCommerce') or {}
main=_canonical_visual_items(raw,visual.get('items'),MAIN_VISUAL_TASKS,valid_ids,plan_points,blocked_terms)
groups=_canonical_visual_items(raw,detail.get('contentGroups'),DETAIL_VISUAL_TASKS,valid_ids,plan_points,blocked_terms)
visual['items']=main
visual['professionalOpinion']='主图先让用户看懂商品、场景和选择入口,每张图只承担一个购买确认任务。'
detail['contentGroups']=[{
'contentKey':item.get('contentKey'),'type':item.get('role'),'representativeImageId':item.get('imageEvidenceId'),
'visualSignals':item.get('visualSignals'),'businessMeaning':item.get('businessMeaning'),
'nextAction':item.get('nextAction'),'evidenceIds':item.get('evidenceIds')
} for item in groups]
detail['professionalOpinion']='详情按场景、材质、结构、功能、规格和维护顺序连续证明产品,不重复主图已经讲清的内容。'
detail['pageSequence']=[{
'order':index+1,'contentKey':item.get('contentKey'),'mainImageRole':main[min(index,len(main)-1)].get('imageRole') if main else '主图信息',
'detailRole':item.get('role'),'handoff':f"主图先提出{item.get('role')},详情继续给出可核对证明"
} for index,item in enumerate(DETAIL_VISUAL_TASKS[:len(groups)])]
claims=[]
for index,item in enumerate(DETAIL_VISUAL_TASKS[:min(len(main),len(groups))]):
main_item=main[index];detail_item=groups[index]
claims.append({
'claim':item['role'],'mainStatus':'expressed','detailStatus':'proven',
'reviewStatus':'partial','nextAction':item['nextAction'],
'evidenceIds':_uniq((main_item.get('evidenceIds') or [])+(detail_item.get('evidenceIds') or []))
})
exp['visualCommerce']=visual;exp['detailCommerce']=detail
if claims:exp['expressionContinuity']={'coreClaims':claims}
for plan in plans:
if not isinstance(plan,dict):continue
page_action=_visual_product_text(plan.get('pageAction'),18,blocked_terms)
if not page_action or any(term in _txt(plan.get('pageAction')) for term in VISUAL_EXCLUDED_TERMS):
plan['pageAction']='主图展示商品全貌与场景,详情依次证明材质、结构、功能和规格'
else:
plan['pageAction']=';'.join(page_action[:2])
points=plan.get('sellingPoints')
if isinstance(points,list):
plan['sellingPoints']=[p for p in points if isinstance(p,dict) and _visual_product_text(p.get('slogan') or p.get('consumerValue'),18,blocked_terms)]
return exp
def _evidence_backed_solution(exp, valid_ids): def _evidence_backed_solution(exp, valid_ids):
exp=dict(exp or {}) exp=dict(exp or {})
owner=exp.get('ownerOverview') or {};owner['cards']=_filter_evidence_items(owner.get('cards'),valid_ids);exp['ownerOverview']=owner owner=exp.get('ownerOverview') or {};owner['cards']=_filter_evidence_items(owner.get('cards'),valid_ids);exp['ownerOverview']=owner
visual=exp.get('visualCommerce') or {};visual['items']=_filter_evidence_items(visual.get('items'),valid_ids,True);exp['visualCommerce']=visual visual=exp.get('visualCommerce') or {};visual['items']=_filter_evidence_items(visual.get('items'),valid_ids,True);exp['visualCommerce']=visual
detail=exp.get('detailCommerce') or {}
groups=[]
for item in detail.get('contentGroups') or []:
if not isinstance(item,dict):continue
clean=dict(item);refs=_visual_refs(item,valid_ids)
if refs:
clean['evidenceIds']=refs;groups.append(clean)
detail['contentGroups']=groups;exp['detailCommerce']=detail
customer=exp.get('customerExperience') or {};customer['stages']=_filter_evidence_items(customer.get('stages'),valid_ids) customer=exp.get('customerExperience') or {};customer['stages']=_filter_evidence_items(customer.get('stages'),valid_ids)
signals=customer.get('signals') or {} signals=customer.get('signals') or {}
for key in ('positive','questions','risks'):signals[key]=_filter_evidence_items(signals.get(key),valid_ids) for key in ('positive','questions','risks'):signals[key]=_filter_evidence_items(signals.get(key),valid_ids)
@@ -1021,6 +1270,13 @@ def ensure_experience_solution(analysis):
exp['newProductPlans']['professionalOpinion']=(analysis.get('launchPlans') or {}).get('professionalOpinion') exp['newProductPlans']['professionalOpinion']=(analysis.get('launchPlans') or {}).get('professionalOpinion')
exp=_separate_qa_signals(exp) exp=_separate_qa_signals(exp)
exp=_sanitize_report_language(_apply_selling_points(exp,raw,analysis['baseline'])) exp=_sanitize_report_language(_apply_selling_points(exp,raw,analysis['baseline']))
exp=_ensure_three_product_plans(exp,raw,analysis['baseline'])
# 核心工程模块缺失时,先使用同一批真实证据确定性补齐;不得带着失败审核输出正式报告。
exp=repair_core_solution_with_evidence(exp,raw,analysis['baseline'])
exp=_evidence_backed_solution(exp,valid_ids)
# 最后一轮把主图与详情收敛为连续的信息任务,避免模型把同一卖点复制到多张图。
exp=_normalize_visual_continuity(exp,raw,valid_ids)
exp=_evidence_backed_solution(exp,valid_ids)
analysis['experienceSolution']=exp analysis['experienceSolution']=exp
analysis['meta'].pop('fallbackReport',None) analysis['meta'].pop('fallbackReport',None)
analysis['meta']['fallbackReport']=False analysis['meta']['fallbackReport']=False
@@ -1031,11 +1287,11 @@ def ensure_experience_solution(analysis):
validation=core_solution_diagnostics(exp) validation=core_solution_diagnostics(exp)
analysis['meta']['reportValidation']=validation analysis['meta']['reportValidation']=validation
if not validation['ok']: if not validation['ok']:
# 审核是质量标记,不再阻断报告输出;缺失项在报告中显式提示,供人工复核。 analysis['meta']['reportReady']=False
analysis['meta']['reportReady']=True
analysis['meta']['reportAuditPassed']=False analysis['meta']['reportAuditPassed']=False
analysis['meta']['reportQuality']='needs_review' analysis['meta']['reportQuality']='blocked'
analysis['meta']['reportNotice']='审核未通过,报告仍已生成。请优先补齐:'+', '.join(validation['missing']) analysis['meta']['reportBlockedReason']='报告审核未通过,已停止生成。缺失:'+', '.join(validation['missing'])
analysis['meta'].pop('reportNotice',None)
return analysis return analysis
analysis['meta']['reportReady']=True analysis['meta']['reportReady']=True
analysis['meta']['reportAuditPassed']=True analysis['meta']['reportAuditPassed']=True
@@ -1262,9 +1518,11 @@ def analyze(raw, progress=lambda *a:None):
main_imgs=raw.get('images',{}).get('main',[])[:8] main_imgs=raw.get('images',{}).get('main',[])[:8]
if use_ai and main_imgs: if use_ai and main_imgs:
prompt='''任务B|主图商业信息提炼。目标不是解释图片,而是让专业用户一眼看到“这张图在卖什么”。逐张提炼图片中可核验的商品信息标签,再给一个短动作。 prompt='''任务B|主图商业信息提炼。目标不是解释图片,而是让专业用户一眼看到“这张图在卖什么”。逐张提炼图片中可核验的商品信息标签,再给一个短动作。
同时逐图记录可见展示空间关系,放入evidenceDetail.displayRelations:{"visibleParts":[{"id":"局部1","description":"可见区域,不把折叠层误认为独立件数"}],"relations":[{"type":"连接|接触|覆盖|遮挡|方向|相对位置|形变","from":"局部1","to":"局部2","observation":"看得见的关系"}],"view":"观察视角","uncertainAreas":["不可见且不能确认的部分"]}。只记录实际图中的关系;折叠、悬挂、手持、铺展等共用此描述,不套固定状态模板,不猜隐藏结构、件数或操作过程。产品固有组成与摆放形成的折边、接触、覆盖必须区分;详细关系不适用页面标签字数限制。
输出: 输出:
{"visualThesis":{"professionalOpinion":"<=45字","evidenceIds":[]},"coreClaims":[],"imageRoles":[{"imageEvidenceId":"IMG_MAIN_0001","imageRole":"<=8字","visualSignals":["2-3个,每项<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","evidenceIds":[],"confidence":0}],"evidenceDetail":[{"imageEvidenceId":"IMG_MAIN_0001","composition":"","textInfo":"","productSubject":"","patternColor":"","evidenceIds":[]}]} {"visualThesis":{"professionalOpinion":"<=45字","evidenceIds":[]},"coreClaims":[],"imageRoles":[{"imageEvidenceId":"IMG_MAIN_0001","imageRole":"<=8字","visualSignals":["2-3个,每项<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","contentKey":"product_overview|usage_scene|material_touch|structure_function|spec_choice","evidenceIds":[],"confidence":0}],"evidenceDetail":[{"imageEvidenceId":"IMG_MAIN_0001","composition":"","textInfo":"","productSubject":"","patternColor":"","evidenceIds":[]}]}
visualSignals必须来自图片可见文字、商品、场景或已确认参数,例如“40支新疆棉/六件套/免费仓储”,不能写抽象评价。 visualSignals必须来自图片可见文字、商品、场景或已确认参数,例如“40支全棉/六件套/90×190cm”,不能写抽象评价。
主图按商品全貌、使用场景、材质触感、结构功能、规格选择分工;每张图只承担一个主任务,不把品牌、专利、授权、价格、销量、物流或服务当作视觉卖点。
''' '''
z,e=call(prompt,{'evidence':compact_evidence(ledger,{'product','sales','image','attribute'},220)},6800,main_imgs) z,e=call(prompt,{'evidence':compact_evidence(ledger,{'product','sales','image','attribute'},220)},6800,main_imgs)
if e:out['modelErrors']['visual']=e if e:out['modelErrors']['visual']=e
@@ -1288,8 +1546,10 @@ visualSignals必须来自图片可见文字、商品、场景或已确认参数
detail_imgs=raw.get('images',{}).get('detail',[])[:20] detail_imgs=raw.get('images',{}).get('detail',[])[:20]
if use_ai and detail_imgs: if use_ai and detail_imgs:
prompt='''任务D|详情页证明信息提炼。按4-6个信息任务组选代表图,不按图片流水账。每组只提炼2-4个图片信息标签和1条专业动作。 prompt='''任务D|详情页证明信息提炼。按4-6个信息任务组选代表图,不按图片流水账。每组只提炼2-4个图片信息标签和1条专业动作。
对实际输入图片在evidenceDetail中按imageEvidenceId记录composition与displayRelations:{"visibleParts":[{"id":"局部1","description":"可见区域"}],"relations":[{"type":"连接|接触|覆盖|遮挡|方向|相对位置|形变","from":"局部1","to":"局部2","observation":"实际可见关系"}],"view":"观察视角","uncertainAreas":["不能确认的部分"]}。区分产品固有结构与展示空间关系,不将可见层数当产品件数,不猜隐藏连接或操作过程;详细观察不按标签长度压缩,保持证据ID对应原图。
输出: 输出:
{"detailThesis":{"professionalOpinion":"<=45字","evidenceIds":[]},"contentGroups":[{"type":"品牌服务|套件组成|材质品质|产品功能|使用便利|花型选择|用户验证|其他","representativeImageId":"IMG_DETAIL_0001","visualSignals":["2-3项,每项<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","imageEvidenceIds":[],"evidenceIds":[]}],"continuityChecks":[{"claim":"<=24字","mainImageEvidenceIds":[],"detailEvidenceIds":[],"status":"closed_loop|detail_missing|main_missing|conflict|review_pending","nextAction":"<=24字","evidenceIds":[]}],"pageSequence":[],"evidenceDetail":[]} {"detailThesis":{"professionalOpinion":"<=45字","evidenceIds":[]},"contentGroups":[{"type":"场景证明|材质证明|结构工艺|功能表现|规格适配|使用维护","representativeImageId":"IMG_DETAIL_0001","visualSignals":["2-3项,每项<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","contentKey":"scene_problem|material_proof|structure_proof|function_proof|spec_adaptation|care_durability","imageEvidenceIds":[],"evidenceIds":[]}],"continuityChecks":[{"claim":"<=24字","mainImageEvidenceIds":[],"detailEvidenceIds":[],"status":"closed_loop|detail_missing|main_missing|conflict|review_pending","nextAction":"<=24字","evidenceIds":[]}],"pageSequence":[],"evidenceDetail":[]}
详情顺序必须接续主图:场景证明 → 材质证明 → 结构工艺 → 功能表现 → 规格适配 → 使用维护;每组只负责一个购买疑问,不重复其他组。不要输出品牌、专利、授权、认证、奖项、包邮、物流、价格、销量、促销、客服或售后等唯一性/交易信息。
''' '''
z,e=call(prompt,{'visualDecision':out.get('visualDecision',{}),'evidence':compact_evidence(ledger,{'product','attribute','promotion','image'},360)},8200,detail_imgs) z,e=call(prompt,{'visualDecision':out.get('visualDecision',{}),'evidence':compact_evidence(ledger,{'product','attribute','promotion','image'},360)},8200,detail_imgs)
if e:out['modelErrors']['detail']=e if e:out['modelErrors']['detail']=e
@@ -1330,14 +1590,14 @@ visualSignals必须来自图片可见文字、商品、场景或已确认参数
progress('plans','processing',95) progress('plans','processing',95)
if use_ai: if use_ai:
context={'reportPlan':out.get('reportPlan',{}),'commercialDecision':out.get('commercialDecision',{}),'consumerResearch':out.get('consumerResearch',{}),'questionResearch':out.get('questionResearch',{}),'fashionDecision':out.get('fashionDecision',{}),'competitionDecision':out.get('competitionDecision',{}),'productDecision':out.get('productDecision',{}),'visualDecision':out.get('visualDecision',{}),'merchandisingDecision':out.get('merchandisingDecision',{}),'detailDecision':out.get('detailDecision',{}),'baseline':base} context={'reportPlan':out.get('reportPlan',{}),'commercialDecision':out.get('commercialDecision',{}),'consumerResearch':out.get('consumerResearch',{}),'questionResearch':out.get('questionResearch',{}),'fashionDecision':out.get('fashionDecision',{}),'competitionDecision':out.get('competitionDecision',{}),'productDecision':out.get('productDecision',{}),'visualDecision':out.get('visualDecision',{}),'merchandisingDecision':out.get('merchandisingDecision',{}),'detailDecision':out.get('detailDecision',{}),'baseline':base}
prompt='''任务F|下一款方案。面向老板先做决策卡,再让专业用户展开细节。每套默认只保留:来源等级、为什么做、产品动作、页面动作、验证项。 prompt='''任务F|下一款产品开品方案。必须输出3套不同的产品新方案;每套只保留四个核心字段:来源等级、名称、产品动作、页面动作。产品动作是核心,必须具体说明下一款产品本身怎么改;页面动作必须说明该方案自己的完整主图证据链与详情图证据链如何承接。不要扩展字段。产品参考图由生成环节处理,不要求模型输出。
至少1套,证据充分2-3套。 必须正好3套,顺序固定为:证据驱动优化、反馈驱动升级、探索性方向。每套名称必须是简洁的“XX款”,不能直接复用商品长标题;三个方案不得只是换名字,产品结构、材质、规格、工艺、配色或使用体验上必须有可辨认差异。
来源等级: 来源等级:
- 证据驱动优化:直接来自当前商品页面/参数/评论; - 证据驱动优化:直接来自当前商品页面/参数/评论;
- 反馈驱动升级:直接来自消费者体验差异/购前关注; - 反馈驱动升级:直接来自消费者体验差异/购前关注;
- 探索性方向:设计创新或扩展想法,必须明确标记,不能当作已验证结论。 - 探索性方向:设计创新或扩展想法,必须明确标记为待小样验证,不能当作已验证结论。
输出: 输出:
{"professionalOpinion":"<=45字","plans":[{"sourceType":"证据驱动优化|反馈驱动升级|探索性方向","type":"现有基础优化|强化体验区分|强化设计选择|其他","name":"<=18字","whyThisPlan":"<=32字","visualReferenceImageIds":[],"productAction":"<=28字","pageAction":"<=28字","validation":"<=20字","evidenceIds":[],"confidence":0}],"nextValidation":[]} {"professionalOpinion":"<=45字","plans":[{"sourceType":"证据驱动优化|反馈驱动升级|探索性方向","name":"<=18字且以款结尾","productAction":"<=45字,明确结构/材质/规格/工艺/配色/包装中实际要改的内容","pageAction":"<=36字,说明该方案完整主图与详情证据链如何表达产品改进"}]}
''' '''
z,e=call(prompt,context,9800) z,e=call(prompt,context,9800)
if e:out['modelErrors']['plans']=e if e:out['modelErrors']['plans']=e
+15
View File
@@ -2,11 +2,26 @@
"api_base": "https://api.bananarouter.com/v1", "api_base": "https://api.bananarouter.com/v1",
"api_key": "", "api_key": "",
"model": "gpt-5.5", "model": "gpt-5.5",
"image_api_base": "",
"image_api_key": "",
"image_model": "",
"image_models": [
{
"id": "gpt-image-2.5-flare",
"label": "GPT Image 2.5 Flare",
"quality": "medium"
}
],
"image_path": "/images/generations",
"image_models_path": "/models",
"image_size": "1024x1024",
"image_quality": "",
"chat_path": "/chat/completions", "chat_path": "/chat/completions",
"models_path": "/models", "models_path": "/models",
"temperature": 0.2, "temperature": 0.2,
"timeout": 120, "timeout": 120,
"retries": 2, "retries": 2,
"image_concurrency": 5,
"ssl_verify": true, "ssl_verify": true,
"ca_bundle": "", "ca_bundle": "",
"extra_headers": {} "extra_headers": {}
+147
View File
@@ -17,6 +17,8 @@ PRODUCT_VARIABLES = {
"evidence": "统一证据账本;模型只能引用其中的 evidenceId", "evidence": "统一证据账本;模型只能引用其中的 evidenceId",
"roleOutputs": "七角色中间结论:商品策略、电商转化、消费者洞察、产品/供应链、视觉设计、老板决策与总编审核;只能交叉校验和编排,不得当作无证据事实", "roleOutputs": "七角色中间结论:商品策略、电商转化、消费者洞察、产品/供应链、视觉设计、老板决策与总编审核;只能交叉校验和编排,不得当作无证据事实",
"renderingPolicy": "最终可见性规则:只展示真实非空内容;证据以来源类型和内容摘要呈现,evidenceId仅用于追溯", "renderingPolicy": "最终可见性规则:只展示真实非空内容;证据以来源类型和内容摘要呈现,evidenceId仅用于追溯",
"prompt": "系统根据真实商品事实、具体开品方案、套图槽位和平台约束生成的完整生图提示词",
"matchReferenceShooting": "是否锁定参考图的拍摄语言与产品展示状态(具体动作、姿态、朝向、展开/折叠、摆放和物体关系),仅影响视觉表达,不改变未经用户确认的商品事实",
} }
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -34,6 +36,7 @@ class PromptTemplate:
TEMPLATES = { TEMPLATES = {
"system": PromptTemplate("system", "{system}\n\n可用商品变量说明:\n{variable_schema}", ("system", "variable_schema")), "system": PromptTemplate("system", "{system}\n\n可用商品变量说明:\n{variable_schema}", ("system", "variable_schema")),
"task": PromptTemplate("task", "{task}\n\n输入变量:{input_variables}", ("task", "input_variables")), "task": PromptTemplate("task", "{task}\n\n输入变量:{input_variables}", ("task", "input_variables")),
"image_generation": PromptTemplate("image_generation", "{prompt}", ("prompt",)),
} }
def register_template(name, body, variables=()): def register_template(name, body, variables=()):
@@ -61,3 +64,147 @@ def build_system_prompt(system_text):
def build_task_prompt(task_text, variables=("evidence",)): def build_task_prompt(task_text, variables=("evidence",)):
names = "、".join(f"{name}({PRODUCT_VARIABLES.get(name, '任务上下文变量')})" for name in variables) names = "、".join(f"{name}({PRODUCT_VARIABLES.get(name, '任务上下文变量')})" for name in variables)
return TEMPLATES["task"].render(task=task_text, input_variables=names) return TEMPLATES["task"].render(task=task_text, input_variables=names)
def build_image_generation_prompt(prompt_text):
"""Apply the operator-configured image prompt wrapper to the real generation prompt."""
body=TEMPLATES["image_generation"].body
override=Path(__file__).with_name('prompts.json')
if override.exists():
try:
data=json.loads(override.read_text('utf-8'))
configured_body=(data.get('image_generation') or {}).get('body') if isinstance(data,dict) else None
if configured_body: body=str(configured_body)
except Exception: pass
if '{prompt}' not in body:
raise ValueError('生图提示词模板必须包含 {prompt},否则无法与真实商品方案联动')
return body.replace('{prompt}', str(prompt_text or ''))
def build_display_relationship_guidance(analysis, enabled=False):
"""One category-independent relationship contract for both prompt paths."""
if not enabled:return ''
analysis=analysis if isinstance(analysis,dict) else {}
relations=analysis.get('displayRelations') or (analysis.get('imageObservation') or {}).get('displayRelations') or {}
return ('【可见展示空间关系|对应原图约束】'+json.dumps(relations,ensure_ascii=False)
+'。使用实际对应参考图核对可见局部之间的连续连接、接触、覆盖顺序、遮挡、方向、相对位置和形变。'
'记录缺失时直接观察实际参考图,不能只按状态名称生成。保留未被用户修改的这些关系,不要求照搬参考商品固有材质或制造结构。'
'产品组成、缝制和配件属于固有身份;折回、垂落、抓握和摆放形成的连接/覆盖属于展示关系,两者不能混淆。'
'连续形变不能重构成独立物体叠放,可见层数不等于商品件数;不能根据模板补造隐藏局部或操作过程。'
'按参考可见关系迁移当前产品,仅允许用户明确授权及实现要求不可避免的最小调整。'
' English constraints: Preserve the visible connectivity, contact, overlap order, occlusion, orientation and relative placement from the corresponding reference. Distinguish intrinsic product construction from display-induced deformation. Do not replace a continuous folded surface with separate stacked objects or infer item count from visible layers. Inspect the actual reference when recorded observations are incomplete; never invent hidden geometry.')
def build_reference_shooting_guidance(
enabled=False,
asset_type='main',
reference_mode='collected_reference',
business_goal='',
product_goal='',
slot_index=None,
):
"""Return the explicit reference-shooting policy for an image prompt.
``matchReferenceShooting`` is deliberately a visual-policy switch rather
than an identity switch. When enabled, the provider is asked to inspect
the collected slot image for shooting grammar and display state, while a
separately uploaded image (when present) supplies only product identity.
When disabled, the reference remains an identity/structure anchor and the
slot's own e-commerce composition is authoritative. Keeping this policy
in the shared template makes preview and execution produce identical
instructions and gives downstream providers a deterministic contract.
"""
kind = '主图' if str(asset_type or '').lower() == 'main' else '详情图'
is_first_main = str(asset_type or '').lower() == 'main' and str(slot_index or '') == '1'
source_labels = {
'uploaded_reference': '用户上传参考图',
'uploaded_identity_collected_reference': '用户上传图(商品身份)+ 采集商品对应槽位图(展示状态)',
'fission_base': '采集商品主图(裂变基准图)',
'fission_followup': '上一张已生成基准图',
'collected_reference': '采集到的商品主图',
}
source = source_labels.get(str(reference_mode or ''), '随请求提供的参考图')
uploaded = str(reference_mode or '') in ('uploaded_reference', 'uploaded_identity_collected_reference')
hybrid = str(reference_mode or '') == 'uploaded_identity_collected_reference'
business = str(business_goal or '').strip() or (
'让用户快速识别商品并理解本图承担的购买确认任务'
)
product = str(product_goal or '').strip() or (
'保持用户最终确认的商品品类、结构、材质、颜色、件数和规格'
)
if hybrid:
first_main_rule = (
'第1张用户上传产品图是唯一商品身份母版,第2张采集商品对应槽位图是拍摄与展示状态母版;'
'首张主图(main:1)也必须按这两个职责分别读取,不能把产品图的机位、构图或摆放状态带入结果。'
'采集参考图中的商品颜色、花型、材质、纹理、品牌、结构和件数不是当前商品事实,全部不得覆盖第1张产品图。'
'只有“产品目标”或用户最终设定明确列出的字段可以改变,其他字段冻结。'
)
elif uploaded and is_first_main:
first_main_rule = (
'首张主图(main:1)必须使用第一张参考图作为商品外观母版;第一张用户上传产品图是唯一商品身份母版,整套主图与详情图都必须继承它的轮廓、比例、件数、颜色/花型、材质纹理、结构和配件位置。'
'采集商品的标题、参数、图片、品牌、颜色、材质和视觉分析不属于当前商品身份,不能改变上传产品图。'
'只有“产品目标”或用户最终设定明确列出的字段可以改变,其他字段冻结。'
)
elif uploaded:
first_main_rule = (
'当前槽位继续继承同一产品身份;第一张用户上传产品图仍是唯一商品身份母版。'
'采集商品的标题、参数、图片、品牌、颜色、材质和视觉分析不属于当前商品身份,不能改变上传产品图。'
'只有用户最终设定明确列出的字段可以改变,其他字段冻结。'
)
elif is_first_main:
first_main_rule = (
'首张主图(main:1)优先使用第一张参考图作为商品外观母版;视觉上严格保持轮廓、比例、件数、颜色/花型、材质纹理、结构和配件位置。'
'只有“产品目标”或用户最终设定明确列出的字段可以改变,其他字段冻结。'
)
else:
first_main_rule = '当前槽位继续继承同一产品身份;只有“产品目标”或用户最终设定明确列出的字段可以改变,其他字段冻结。'
reference_role_rule = (
'【双参考图职责分离】第1张输入图是用户上传产品图,只允许提取商品身份、轮廓、比例、材质、颜色/花型、结构、件数和配件;'
'第2张输入图是采集商品当前槽位图,提供表达主题、文字版式以及机位、视角、景别、透视、构图、背景、光线、具体动作、朝向、展开/折叠、摆放、支撑/接触点、遮挡和部件关系;其原文参数不能当作当前产品事实。'
'结果必须使用第1张图的商品放入第2张图的展示状态;第2张图中的商品外观、颜色、花型、材质和结构不得覆盖第1张图。不得交换两张图的职责。'
if hybrid else
'【单一产品身份参考】第1张输入图是用户上传产品图,也是整套图片唯一的商品身份来源;只允许提取商品品类、轮廓、比例、材质、颜色/花型、结构、件数和配件。采集商品的文字、参数和图片只用于业务目标与证据任务,不得改变当前商品外观。'
if uploaded else
'【参考图职责】当前参考图用于确定商品身份与本策略指定的视觉表达;不得把未授权的其他商品、品牌或文字带入结果。'
)
reference_role_rule_en = (
'【Two-reference role split】Input image 1 is the uploaded product image and is the only source for product identity, silhouette, proportions, material, color/pattern, construction, quantity and accessories. Input image 2 is the collected slot image and may provide only camera/view/display state. Place the product from image 1 into the display state of image 2; image 2 product appearance, color, pattern, material and construction must never override image 1. Never swap these roles.'
if hybrid else
'Input image 1 is the only product identity reference for the entire image set. Use it for the product category, silhouette, proportions, material, color/pattern, construction, quantity and accessories. Collected product text, parameters and images are business context only and must never change the uploaded product appearance.'
if uploaded else
'Use the current reference only for the product identity and the visual policy stated here; never import another product, brand or text.'
)
copy_policy = (
'【参考图文案跟随规则】必须依据当前产品事实和对应图片分析任务生成简短中文文案并渲染到图中。文字识别缺失不代表参考图无字,必须查看实际参考图。参考图有文字时严格保持文字相对位置、大小占比、对齐、标题层级、换行、行距、留白和阅读顺序,仅替换为当前产品对应文案。不得照抄品牌、价格、促销、认证或虚构产品参数,不得默认留白取消文案。'
)
copy_policy_en = (
'Render short Chinese copy derived from the current product and the corresponding slot analysis. Missing OCR is not evidence that the reference is text-free. Inspect the actual collected slot reference. Preserve its text positions, relative font sizes, alignment, title hierarchy, line breaks, line spacing and margins when text is present. Never copy brands or unsupported claims. Display state and text layout take precedence over default creative styling.'
)
if enabled:
return f'''【参考图拍摄与产品展示状态一致|已勾选|matchReferenceShooting=true】
策略定义:锁定对应参考图的拍摄语言、产品展示状态、表达主题和文字版式;替换为当前产品及基于当前产品分析的文案,不照抄参考商品原文。拍摄与摆放母版:{source};本策略适用于当前{kind}。
【防止商品串图|最高优先级】任何采集参考图都不是当前商品外观参考。严禁复制采集图中的颜色、花型、材质、纹理、轮廓、结构、件数、配件、包装或品牌;当前商品只能来自第1张上传产品图和用户最终明确设定。若视觉状态与商品外观发生冲突,保留第1张产品图外观,放弃采集图外观。
{reference_role_rule}
优先级(高→低):平台合规与安全 > 当前产品身份与用户明确商品设定 > 用户明确表达要求 > 对应参考图的产品展示状态与文案排版 > 产品方案与业务任务 > 默认构图与风格。产品不能擅自改变;用户明确表达要求覆盖参考对应项,未涉及的参考约束保持有效。
业务目标(不可丢失):{business}。
产品目标(不可丢失):{product}。
拍摄语言必须尽量一致:机位与视角、景别与主体占比、镜头透视/焦段观感、构图与裁切、背景/场景层级、光线方向与软硬度、色温与阴影、道具、视觉重心和留白节奏。
产品展示状态必须具体复现:先识别商品是平铺、悬挂、折叠、展开、堆叠、卷起、铺设、穿着、手持、使用中还是局部掀开;再保持正反面与朝向、旋转/倾斜角度、展开或折叠程度、弯曲/垂落/褶皱状态、落点与画面坐标、接触面和支撑点、遮挡关系、部件相对位置、与道具/家具/人体的距离和前后层级。
动作状态必须具体复现:若有人手或模特,保持谁在用什么部位、抓握/按压/提拉/铺开/穿戴等动作、手指或身体接触点、发力方向、动作阶段和姿态;不得把“正在动作”改成静态陈列,也不得把静态陈列擅自改成动作场景。先逐项分析实际参考图,再迁移到当前商品。
商品保真:{first_main_rule}
适配规则:在对应参考图既有展示状态与文字版式中表达当前产品的可核验信息;不引入其他商品、品牌Logo或未证实卖点。先锁定产品摆放、动作、朝向、主体占比和部件关系,再精简文案适配原有文字区域,不能为文案重新构图或移动商品。
{reference_role_rule_en}
{copy_policy}
{copy_policy_en}
【English constraints】Input image 1 supplies current product identity only. Input image 2 supplies the corresponding expression theme, shooting grammar, exact display/action state and text layout. Preserve its camera angle, shot scale, perspective, framing, background, lighting, shadows, props, negative space, orientation, fold/unfold amount, drape, placement, support/contact points, occlusion, part relationships, human touch points and action phase. Preserve the current product appearance from image 1. Render new Chinese copy supported by current product facts in the corresponding text positions, sizes, alignment, hierarchy and line spacing of image 2. Shorten copy to fit; never move or resize the product or change its display state to make room for text. Never copy another product identity, logo, original wording, watermark or unsupported claim.
'''
return f'''【参考图拍摄与产品展示状态一致|未勾选|matchReferenceShooting=false】
策略定义:不继承参考图的拍摄语言或产品展示状态。参考来源:{source};参考图只用于核对当前商品身份、结构、材质、颜色和比例。
优先级(高→低):平台合规与安全 > 用户明确商品设定与统一 identityLock > 产品目标/方案允许的明确变化 > 当前槽位业务目标与证据任务 > 适合本图的电商拍摄方案。不要复制其机位、景别、镜头透视、构图、背景、光线、道具、姿态、具体动作、朝向、展开/折叠、摆放位置、支撑/接触点、遮挡或部件关系;不得把参考图的视觉语言或展示状态当成默认要求。
业务目标(不可丢失):{business}。
产品目标(不可丢失):{product}。
商品保真:{first_main_rule}
{reference_role_rule_en}
{copy_policy}
{copy_policy_en}
【English constraints】Use the reference only as a product-identity anchor. Do not copy its camera angle, shot scale, perspective, framing, background, lighting, props, display/action state, orientation, folding, placement, support/contact points, occlusion or part relationships. Design a clear ecommerce shot for the current business goal while preserving the current product identity and approved product changes.
'''
+11 -3
View File
@@ -348,8 +348,16 @@ def visual_status_html(images, verified_visual):
def competition_html(plans, product_exp, overview): def competition_html(plans, product_exp, overview):
point='' point=''
for plan in arr(plans.get('plans'))[:1]: for plan in arr(plans.get('plans'))[:1]:
if isinstance(plan,dict) and arr(plan.get('sellingPoints')): if not isinstance(plan, dict):
point=concise(plan['sellingPoints'][0].get('slogan'),42,False) continue
selling_points = arr(plan.get('sellingPoints'))
first_point = selling_points[0] if selling_points else None
if isinstance(first_point, dict):
point = concise(first_point.get('slogan'), 42, False)
elif isinstance(first_point, list) and first_point:
nested_first = first_point[0]
if isinstance(nested_first, dict):
point = concise(nested_first.get('slogan'), 42, False)
risks=[concise(x.get('label'),34,False) for x in arr(product_exp.get('gates'))[:3] if isinstance(x,dict)] risks=[concise(x.get('label'),34,False) for x in arr(product_exp.get('gates'))[:3] if isinstance(x,dict)]
return '<div class="competition-grid"><article class="strong"><span>核心价值</span><strong>'+H(point or '已确认体验可复用')+'</strong><p>来自页面参数与评论聚合,不写市场推断。</p></article><article class="mid"><span>基础能力</span><strong>类目关键参数、规格与页面需要和真实商品一致</strong><p>基础能力必须被主图、详情和参数共同证明。</p></article><article class="weak"><span>风险点</span><strong>'+H(' / '.join(x for x in risks if x) or '按评论明确问题处理')+'</strong><p>风险只按真实评论和参数校验项呈现。</p></article></div>' return '<div class="competition-grid"><article class="strong"><span>核心价值</span><strong>'+H(point or '已确认体验可复用')+'</strong><p>来自页面参数与评论聚合,不写市场推断。</p></article><article class="mid"><span>基础能力</span><strong>类目关键参数、规格与页面需要和真实商品一致</strong><p>基础能力必须被主图、详情和参数共同证明。</p></article><article class="weak"><span>风险点</span><strong>'+H(' / '.join(x for x in risks if x) or '按评论明确问题处理')+'</strong><p>风险只按真实评论和参数校验项呈现。</p></article></div>'
@@ -673,4 +681,4 @@ def render(a, task_id):
raw=a.get('facts') or {}; product=raw.get('product') or {}; exp=a.get('experienceSolution') or {}; summary=exp.get('reportSummary') or {} raw=a.get('facts') or {}; product=raw.get('product') or {}; exp=a.get('experienceSolution') or {}; summary=exp.get('reportSummary') or {}
title=clean_display(summary.get('title') or product.get('title') or '商品分析报告',60) title=clean_display(summary.get('title') or product.get('title') or '商品分析报告',60)
payload=json.dumps(a,ensure_ascii=False,separators=(',',':')).replace('</','<\\/').replace('\u2028','\\u2028').replace('\u2029','\\u2029') payload=json.dumps(a,ensure_ascii=False,separators=(',',':')).replace('</','<\\/').replace('\u2028','\\u2028').replace('\u2029','\\u2029')
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="generator" content="Sansong Product Intelligence V9.3.0"><title>'+H(title)+'</title><link rel="stylesheet" href="/report.css"></head><body><main id="app" class="report-app"></main><script>window.REPORT_DATA='+payload+';</script><script src="/report.js"></script></body></html>' return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="generator" content="Sansong Product Intelligence V9.3.0"><title>'+H(title)+'</title><link rel="stylesheet" href="/report.css?v=20260911-modal-spec4"></head><body><main id="app" class="report-app"></main><script>window.REPORT_DATA='+payload+';</script><script src="/report.js?v=20260911-modal-spec4"></script></body></html>'
+1301 -5
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>商品开品分析报告</title><link rel="stylesheet" href="/report.css"></head> <title>商品开品分析报告</title><link rel="stylesheet" href="/report.css?v=20260914-modal-spec5"></head>
<body><main id="app" class="report-app"><p>正在加载报告数据…</p></main> <body><main id="app" class="report-app"><p>正在加载报告数据…</p></main>
<script src="/report.js"></script></body></html> <script src="/report.js?v=20260914-modal-spec5"></script></body></html>
+706 -20
View File
@@ -3,6 +3,27 @@ const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&amp
const arr = value => Array.isArray(value) ? value : []; const arr = value => Array.isArray(value) ? value : [];
const compact = value => arr(value).map(item => typeof item === 'object' ? (item.value || item.topic || item.name || item.label || item.action || item.insight || '') : item).filter(Boolean).join(' · '); const compact = value => arr(value).map(item => typeof item === 'object' ? (item.value || item.topic || item.name || item.label || item.action || item.insight || '') : item).filter(Boolean).join(' · ');
const percent = value => `${(Number(value || 0) * 100).toFixed(1).replace('.0','')}%`; const percent = value => `${(Number(value || 0) * 100).toFixed(1).replace('.0','')}%`;
const modelLabel = item => item?.modelLabel || item?.model || '默认生图模型';
const modelQuality = item => item?.quality || '服务商默认';
const IMAGE_QUALITY_OPTIONS = ['auto','low','medium','high','xhigh','max'];
const resultSortKey = item => {
const type = item?.assetType === 'main' ? 0 : 1;
const index = Number(item?.slotIndex || 0);
return [type, index, String(item?.modelLabel || item?.model || ''), String(item?.url || '')];
};
const orderedResults = items => arr(items).slice().sort((a,b) => {
const aa = resultSortKey(a), bb = resultSortKey(b);
return aa[0] - bb[0] || aa[1] - bb[1] || aa[2].localeCompare(bb[2]);
});
async function readJsonResponse(response, endpoint='接口') {
const body=await response.text();
try { return JSON.parse(body); }
catch (_) {
const returnedHtml=/^\s*</.test(body);
const detail=returnedHtml?'返回了网页内容':'返回内容不是有效 JSON';
throw new Error(`${endpoint} ${detail}(HTTP ${response.status})。请关闭旧版服务,并从 http://127.0.0.1:17962 重新打开当前报告。`);
}
}
let evidenceIndex = new Map(); let evidenceIndex = new Map();
const EMPTY_COPY = /^(暂无|无|未采集|未评级|尚未结构化|图片证据未采集|评论原文未采集|日期未采集)$/; const EMPTY_COPY = /^(暂无|无|未采集|未评级|尚未结构化|图片证据未采集|评论原文未采集|日期未采集)$/;
function hasContent(value) { function hasContent(value) {
@@ -37,6 +58,19 @@ const FIELD_LABELS = {
fashionRisks:'视觉风险', designAssets:'设计资产', styleSignals:'风格信号', validation:'审核结果', scope:'审核范围', fashionRisks:'视觉风险', designAssets:'设计资产', styleSignals:'风格信号', validation:'审核结果', scope:'审核范围',
structuredStatus:'结构化状态', status:'状态', gate:'验证门', examples:'评论原话', mentions:'提及次数', sampleRate:'样本占比' structuredStatus:'结构化状态', status:'状态', gate:'验证门', examples:'评论原话', mentions:'提及次数', sampleRate:'样本占比'
}; };
Object.assign(FIELD_LABELS,{
all:'全部图片',batchFindings:'批次发现',buyerShow:'买家实拍',compareDimensions:'对比维度',composition:'构图信息',concern:'购前顾虑',concerns:'购前顾虑',
consumerThesis:'消费者结论',conversionImpact:'转化影响',coreClaims:'核心卖点',coreStory:'核心表达链',decision:'决策',decisionGates:'决策门槛',decisionQuestion:'决策问题',deemphasize:'降低强调',
detailStatus:'详情证明状态',driver:'驱动因素',evidenceId:'证据编号',executiveDecision:'经营决策',expectationGaps:'预期差异',expected:'用户预期',finding:'分析发现',fixAction:'修正动作',
grade:'数据等级',hypothesis:'验证假设',ifOnlyOneAction:'唯一优先动作',imageEvidenceId:'图片证据编号',imageRole:'图片任务',imageRoles:'图片任务分工',images:'图片统计',
isProductRiskOrInfoGap:'问题性质',item:'项目',keepAction:'保留动作',knownLimitations:'已知限制',mainStatus:'主图表达状态',mechanism:'作用机制',metrics:'验收指标',module:'分析模块',
mustFix:'必须修正',oneSentence:'一句话结论',owner:'负责人',pageFix:'页面修正',painPoints:'用户痛点',patternColor:'花型与颜色',prePurchaseConcerns:'购前关注',prePurchaseThesis:'购前判断',
primaryQuestions:'核心问题',priority:'优先级',problem:'问题',productOpportunity:'产品机会',productSubject:'商品主体',promotionAnalysis:'促销分析',purchaseDrivers:'购买驱动',question:'用户问题',
questionConcern:'问答关注点',questionSample:'问答样本',reason:'原因',representativeQuotes:'代表评论',requiredCompetitorSet:'所需竞品样本',reviewReality:'评论实际反馈',reviewSample:'评论样本',
reviewStatus:'评论验证状态',role:'作用',rows:'验证步骤',satisfactionDrivers:'满意驱动',sectionPlan:'章节计划',severity:'严重程度',skuSignals:'规格反馈',stage:'验证阶段',successMeaning:'通过标准',
text:'原文',textInfo:'图中文字',titleAnalysis:'标题分析',unansweredQuestions:'未解决问题',validatedByReviews:'评论验证结果',valuePillars:'价值支柱',verbatimQuotes:'评论原话',verdict:'最终判断',
visualSignals:'视觉信号',visualThesis:'视觉结论',whatUserIsBuying:'用户实际购买价值',whyItBlocksPurchase:'阻碍购买原因',whyItSells:'购买理由',main:'主图',detail:'详情图',sku:'规格结构'
});
const ROLE_META = [ const ROLE_META = [
['productStrategy','01','商品策略','卖什么、为什么值得参考、下一款继承什么'], ['productStrategy','01','商品策略','卖什么、为什么值得参考、下一款继承什么'],
@@ -67,8 +101,14 @@ function decisionCards(items) {
} }
function planCards(items) { function planCards(items) {
const tone = item => String(item?.sourceType || '').includes('探索') ? 'explore' : String(item?.sourceType || '').includes('反馈') ? 'upgrade' : 'evidence'; const tone = item => String(item?.sourceType || '').includes('探索') ? 'explore' : String(item?.sourceType || '').includes('反馈') ? 'upgrade' : 'evidence';
const themeCopy = item => {
const value=String(item?.sourceType||item?.type||'');
if (value.includes('反馈')) return [['升级动作',item.productAction],['体验证明',item.pageAction]];
if (value.includes('探索')) return [['探索假设',item.productAction],['验证表达',item.pageAction]];
return [['证据动作',item.productAction],['证据表达',item.pageAction]];
};
const valid=arr(items).map((item,index)=>({item,index})).filter(({item})=>hasContent(item)); const valid=arr(items).map((item,index)=>({item,index})).filter(({item})=>hasContent(item));
return valid.length ? `<div class="plan-grid">${valid.map(({item,index}) => {const rows=[['产品',item.productAction],['页面',item.pageAction],['验证',item.validation]].filter(([,value])=>hasContent(value));return `<article class="plan-card plan-${tone(item)}">${hasContent(item.sourceType||item.type)?`<span>${esc(item.sourceType||item.type)}</span>`:''}${hasContent(item.name)?`<h3>${esc(item.name)}</h3>`:''}${hasContent(item.whyThisPlan)?`<p>${esc(item.whyThisPlan)}</p>`:''}${rows.length?`<dl>${rows.map(([label,value])=>`<div><dt>${label}</dt><dd>${esc(value)}</dd></div>`).join('')}</dl>`:''}<button class="generate-plan-btn" type="button" data-plan-index="${index}">立即生成 <span>→</span></button></article>`}).join('')}</div>` : ''; return valid.length ? `<div class="plan-grid">${valid.map(({item,index}) => {const rows=themeCopy(item).filter(([,value])=>hasContent(value));return `<article class="plan-card plan-${tone(item)}">${hasContent(item.sourceType||item.type)?`<span>${esc(item.sourceType||item.type)}</span>`:''}${rows.length?`<dl>${rows.map(([label,value])=>`<div><dt>${label}</dt><dd>${esc(value)}</dd></div>`).join('')}</dl>`:''}<button class="generate-plan-btn" type="button" data-plan-index="${index}">按此方向开品 <span>→</span></button></article>`}).join('')}</div>` : '';
} }
function titleAnalysis(block) { function titleAnalysis(block) {
const expressed = compact(block.currentExpression); const expressed = compact(block.currentExpression);
@@ -82,6 +122,18 @@ function mainImages(data, block) {
return `${hasContent(block.professionalOpinion)?`<p class="lead-opinion">${esc(block.professionalOpinion)}</p>`:''}${items.length?`<div class="main-analysis-grid">${items.map(item=>`<article>${figure(entries.get(item.imageEvidenceId), item.imageRole || '商品主图')}<div class="image-copy"><span>主图分析</span>${hasContent(item.imageRole)?`<h3>${esc(item.imageRole)}</h3>`:''}${hasContent(item.visualSignals)?`<b>${esc(compact(item.visualSignals))}</b>`:''}${hasContent(item.businessMeaning)?`<p>${esc(item.businessMeaning)}</p>`:''}${hasContent(item.nextAction)?`<em>${esc(item.nextAction)}</em>`:''}</div></article>`).join('')}</div>`:''}`; return `${hasContent(block.professionalOpinion)?`<p class="lead-opinion">${esc(block.professionalOpinion)}</p>`:''}${items.length?`<div class="main-analysis-grid">${items.map(item=>`<article>${figure(entries.get(item.imageEvidenceId), item.imageRole || '商品主图')}<div class="image-copy"><span>主图分析</span>${hasContent(item.imageRole)?`<h3>${esc(item.imageRole)}</h3>`:''}${hasContent(item.visualSignals)?`<b>${esc(compact(item.visualSignals))}</b>`:''}${hasContent(item.businessMeaning)?`<p>${esc(item.businessMeaning)}</p>`:''}${hasContent(item.nextAction)?`<em>${esc(item.nextAction)}</em>`:''}</div></article>`).join('')}</div>`:''}`;
} }
function classifyDetailGroup(group) { function classifyDetailGroup(group) {
const keyed = {
scene_problem: [10, '场景证明', '先回答为什么需要'],
material_proof: [20, '材质证明', '证明产品基础'],
structure_proof: [30, '结构工艺', '证明结构如何实现'],
function_proof: [40, '功能表现', '证明使用表现'],
spec_adaptation: [50, '规格适配', '降低选择门槛'],
care_durability: [60, '使用维护', '说明洗护与长期使用']
};
if (keyed[group?.contentKey]) {
const [sequencePriority, sequenceLabel, sequenceHint] = keyed[group.contentKey];
return {...group, sequencePriority, sequenceLabel, sequenceHint};
}
const source = `${group?.type || ''} ${group?.stage || ''}`; const source = `${group?.type || ''} ${group?.stage || ''}`;
const rules = [ const rules = [
[/场景|人群|体验|需求/,10,'使用场景','先回答为什么需要'], [/场景|人群|体验|需求/,10,'使用场景','先回答为什么需要'],
@@ -90,7 +142,7 @@ function classifyDetailGroup(group) {
[/工艺|细节|结构|做工/,30,'工艺细节','证明品质如何实现'], [/工艺|细节|结构|做工/,30,'工艺细节','证明品质如何实现'],
[/耐用|性能|功能|洗涤|防护|透气/,40,'性能验证','证明长期使用表现'], [/耐用|性能|功能|洗涤|防护|透气/,40,'性能验证','证明长期使用表现'],
[/规格|尺寸|SKU|选择|适配/,50,'规格选择','降低选择门槛'], [/规格|尺寸|SKU|选择|适配/,50,'规格选择','降低选择门槛'],
[/品牌|服务|质检|证书|信任/,60,'信任背书','最后消除决策顾虑'] [/洗护|护理|清洁|耐用/,60,'使用维护','说明洗护与长期使用']
]; ];
const found = rules.find(([pattern])=>pattern.test(source)); const found = rules.find(([pattern])=>pattern.test(source));
return found ? {...group,sequencePriority:found[1],sequenceLabel:found[2],sequenceHint:found[3]} : {...group,sequencePriority:70,sequenceLabel:'补充证据',sequenceHint:'补充未覆盖的信息'}; return found ? {...group,sequencePriority:found[1],sequenceLabel:found[2],sequenceHint:found[3]} : {...group,sequencePriority:70,sequenceLabel:'补充证据',sequenceHint:'补充未覆盖的信息'};
@@ -125,14 +177,14 @@ function buyerGallery(data, block) {
const grouped = new Map(); const grouped = new Map();
items.forEach(item=>{ items.forEach(item=>{
const key = item.reviewEvidenceId || item.imageEvidenceId; const key = item.reviewEvidenceId || item.imageEvidenceId;
if (!grouped.has(key)) grouped.set(key,{reviewId:item.reviewEvidenceId,quote:item.quote,date:item.date,sku:item.sku,images:[]}); if (!grouped.has(key)) grouped.set(key,{reviewId:item.reviewEvidenceId,quote:item.quote,date:item.date,sku:item.sku,scenario:item.scenario || item.context,insight:item.insight || item.conclusion || item.businessMeaning,images:[]});
grouped.get(key).images.push(byId.get(item.imageEvidenceId)); grouped.get(key).images.push(byId.get(item.imageEvidenceId));
}); });
if (!grouped.size) { if (!grouped.size) {
const loose = imageEntries(data,'buyerShow'); const loose = imageEntries(data,'buyerShow');
return loose.length ? `<div class="buyer-story"><div class="buyer-photo-grid">${loose.map(item=>figure(item,item.id)).join('')}</div><p>买家秀已采集,尚未关联到具体评论。</p></div>` : ''; return loose.length ? `<div class="buyer-story"><div class="buyer-photo-grid">${loose.map(item=>figure(item,item.id)).join('')}</div><p>买家秀已采集,尚未关联到具体评论。</p></div>` : '';
} }
return [...grouped.values()].filter(group=>group.images.some(Boolean)||hasContent(group.quote)||hasContent(group.sku)).map(group=>`<article class="buyer-story"><div class="buyer-story-head">${hasContent(group.reviewId)?`<span>${esc(group.reviewId)}</span>`:''}<small>${group.images.filter(Boolean).length} 张实拍图${hasContent(group.date)?` · ${esc(group.date)}`:''}</small></div>${group.images.some(Boolean)?`<div class="buyer-photo-grid">${group.images.filter(Boolean).map(item=>figure(item,'')).join('')}</div>`:''}${hasContent(group.quote)?`<blockquote>“${esc(group.quote)}”</blockquote>`:''}${hasContent(group.sku)?`<p>${esc(group.sku)}</p>`:''}</article>`).join(''); return [...grouped.values()].filter(group=>group.images.some(Boolean)||hasContent(group.quote)||hasContent(group.sku)).map((group,index)=>`<article class="buyer-story"><div class="buyer-story-head"><span>案例 ${String(index+1).padStart(2,'0')}${hasContent(group.reviewId)?` · ${esc(group.reviewId)}`:''}</span><small>${group.images.filter(Boolean).length} 张实拍图${hasContent(group.date)?` · ${esc(group.date)}`:''}</small></div>${group.images.some(Boolean)?`<div class="buyer-photo-grid">${group.images.filter(Boolean).map(item=>figure(item,'')).join('')}</div>`:''}${hasContent(group.scenario)?`<p class="case-scenario"><b>使用场景</b>${esc(group.scenario)}</p>`:''}${hasContent(group.quote)?`<blockquote>“${esc(group.quote)}”</blockquote>`:''}${hasContent(group.insight)?`<p class="case-insight"><b>案例结论</b>${esc(group.insight)}</p>`:''}${hasContent(group.sku)?`<p><b>SKU</b> ${esc(group.sku)}</p>`:''}</article>`).join('');
} }
function reviews(data, block) { function reviews(data, block) {
const base = data.baseline || {}; const base = data.baseline || {};
@@ -160,7 +212,13 @@ function reviews(data, block) {
const ROLE_DENSE_FIELDS = new Set(['baseline','buyerEvidence','signals','stages','evidenceDetail']); const ROLE_DENSE_FIELDS = new Set(['baseline','buyerEvidence','signals','stages','evidenceDetail']);
function displayValue(value) { function displayValue(value) {
if (typeof value === 'boolean') return value ? '是' : '否'; if (typeof value === 'boolean') return value ? '是' : '否';
const status = {passed:'已通过',confirmed:'已确认',high:'高风险',medium:'中风险',low:'低风险',consumer_supported:'评论支持',conflicted:'存在冲突',page_claim_only:'仅页面宣称',user_stopped:'用户主动停止'}; const status = {
passed:'已通过',confirmed:'已确认',missing:'缺失',partial:'部分满足',unknown:'待确认',high:'高风险',medium:'中风险',low:'低风险',
consumer_supported:'评论支持',conflicted:'存在冲突',page_claim_only:'仅页面宣称',not_enough_evidence:'证据不足',user_stopped:'用户主动停止',
expressed:'主图已表达',proven:'详情已证明',validated:'评论已验证',main_missing:'主图未表达',detail_missing:'详情未证明',review_pending:'等待评论验证',closed_loop:'证据闭环',conflict:'存在冲突',
product_risk:'产品风险',information_gap:'信息缺口',purchase_driver:'购买驱动',satisfaction:'满意因素',pain:'用户痛点',expectation_gap:'预期差异',prepurchase_concern:'购前顾虑',sku_signal:'规格反馈',usage_scene:'使用场景',
complete:'采集完成',limited:'样本有限',collected:'已采集',ready:'已就绪',normal:'一般',good:'良好',risk:'风险',true:'是',false:'否'
};
return status[value] || value; return status[value] || value;
} }
function itemTitle(item) { function itemTitle(item) {
@@ -201,7 +259,7 @@ function renderStructured(value, depth=0) {
const valid=value.filter(hasContent); const valid=value.filter(hasContent);
if (!valid.length) return ''; if (!valid.length) return '';
if (valid.every(item=>typeof item !== 'object')) return `<div class="role-chip-list">${valid.map(item=>`<span>${esc(displayValue(item))}</span>`).join('')}</div>`; if (valid.every(item=>typeof item !== 'object')) return `<div class="role-chip-list">${valid.map(item=>`<span>${esc(displayValue(item))}</span>`).join('')}</div>`;
return `<div class="structured-list">${valid.map(item=>{const title=itemTitle(item);const inner=renderStructured(Object.fromEntries(Object.entries(item || {}).filter(([key])=>!title || !['headline','name','type','asset','signal','claim','risk','gate','topic','field'].includes(key))),depth+1);return title||inner?`<article class="structured-card">${title?`<header><span>${esc(title)}</span></header>`:''}${inner}</article>`:''}).join('')}</div>`; return `<div class="structured-list">${valid.map(item=>{const title=itemTitle(item);const inner=renderStructured(Object.fromEntries(Object.entries(item || {}).filter(([key])=>!title || !['headline','name','type','asset','signal','claim','risk','gate','topic','field'].includes(key))),depth+1);return title||inner?`<article class="structured-card">${title?`<header><span>${esc(displayValue(title))}</span></header>`:''}${inner}</article>`:''}).join('')}</div>`;
} }
return `<div class="structured-object">${Object.entries(value).map(([key,item])=>{ return `<div class="structured-object">${Object.entries(value).map(([key,item])=>{
if (!hasContent(item)) return ''; if (!hasContent(item)) return '';
@@ -276,26 +334,616 @@ function initHeroCarousel() {
window.addEventListener('resize',()=>show(current),{passive:true}); window.addEventListener('resize',()=>show(current),{passive:true});
show(0); start(); show(0); start();
} }
function initPlanActions(plans) { function initPlanActions(plans, context={}) {
const dialog = document.querySelector('#quick-plan-dialog'); const dialog = document.querySelector('#quick-plan-dialog');
if (!dialog) return; if (!dialog) return;
const title = dialog.querySelector('[data-plan-title]'), reason = dialog.querySelector('[data-plan-reason]'); const title = dialog.querySelector('[data-plan-title]'), reason = dialog.querySelector('[data-plan-reason]');
const product = dialog.querySelector('[data-plan-product]'), page = dialog.querySelector('[data-plan-page]'), validation = dialog.querySelector('[data-plan-validation]'); const product = dialog.querySelector('[data-plan-product]'), page = dialog.querySelector('[data-plan-page]'), validation = dialog.querySelector('[data-plan-validation]');
let currentText = ''; const generateButton=dialog.querySelector('[data-generate-images]');
const generateStatus=dialog.querySelector('[data-generate-status]');
const generateResults=dialog.querySelector('[data-generate-results]');
const generationControls=dialog.querySelector('[data-generation-controls]');
const userDirectionInput=dialog.querySelector('[data-user-direction]');
const fissionWrap=dialog.querySelector('[data-fission-wrap]');
const fissionInput=dialog.querySelector('[data-fission-pattern]');
const matchReferenceWrap=dialog.querySelector('[data-match-reference-wrap]');
const matchReferenceInput=dialog.querySelector('[data-match-reference-shooting]');
const matchReferenceNote=dialog.querySelector('[data-match-reference-note]');
const referenceInput=dialog.querySelector('[data-reference-input]');
const referenceList=dialog.querySelector('[data-reference-list]');
const referenceHint=dialog.querySelector('[data-reference-hint]');
let uploadedReferences=[];
if (generateButton) { generateButton.textContent='正在计算生成数量…'; generateButton.disabled=true; }
let activePlanIndex=0, previewSlots=[], selectedSlotKeys=new Set(), promptOverrides={};
let retainedResults=[], generatedTaskSlots=[];
let imageModelOptions=[], selectedImageModels=new Set(), selectedImageQualities=new Map(), imageModelPromise=null;
let submittedModelSpecs=[];
const slotKey=slot=>`${slot.assetType}:${slot.index}`;
const mergeResults=(older,newer)=>{
const resultKey=item=>`${item.modelKey||item.model||'default'}:${item.assetType}:${item.slotIndex}`;
const merged=new Map(arr(older).map(item=>[resultKey(item),item]));
arr(newer).forEach(item=>merged.set(resultKey(item),item));
return orderedResults([...merged.values()]);
};
const selectedTypes=()=>[...new Set(previewSlots.filter(slot=>selectedSlotKeys.has(slotKey(slot))).map(slot=>slot.assetType))];
const generationCounts=()=>({
main:previewSlots.filter(x=>x.assetType==='main'&&selectedSlotKeys.has(slotKey(x))).length,
detail:previewSlots.filter(x=>x.assetType==='detail'&&selectedSlotKeys.has(slotKey(x))).length
});
const countText=counts=>`${counts.main} 张主图 + ${counts.detail} 张详情图`;
const selectedModelOptions=()=>imageModelOptions
.filter(item=>selectedImageModels.has(item.id))
.map(item=>({...item,quality:selectedImageQualities.get(item.id) ?? item.quality ?? ''}));
const comparisonMode=()=>selectedImageModels.size>1;
const modelKey=item=>item?.modelKey||item?.model||'default';
const imageModelControls=()=>dialog.querySelector('[data-image-model-controls]');
const generationRunSummary=()=>dialog.querySelector('[data-generation-run-summary]');
const renderGenerationRunSummary=(status='queued',specs=submittedModelSpecs)=>{
const summary=generationRunSummary();
if (!summary) return;
const items=arr(specs);
if (!items.length) {
summary.hidden=true;
summary.innerHTML='';
return;
}
const stateLabel=status==='complete'?'已完成':status==='error'?'生成失败':status==='running'?'生成中':'已提交';
summary.hidden=false;
summary.innerHTML=`<b>本次生成参数 · ${stateLabel}</b>${items.map(item=>`<span><strong>${esc(item.label||item.id||'默认生图模型')}</strong><small>模型:${esc(item.id||'默认')} · 请求质量:${esc(modelQuality(item))}${item.quality?'':'(实际档位由服务商决定)'}</small></span>`).join('')}`;
};
const renderImageModelControls=()=>{
const controls=imageModelControls();
if (!controls) return;
if (!imageModelOptions.length) {
controls.innerHTML='<span class="image-model-loading">未读取到模型列表,请先在本地配置页填写生图测试模型。</span>';
return;
}
controls.innerHTML=`<b>模型对比</b><span class="image-model-hint">同一图片任务会按模型并排生成,最多建议选择 3 个;模型对比建议统一使用 medium。</span>${imageModelOptions.map(item=>`<label class="image-model-option"><input type="checkbox" data-image-model="${esc(item.id)}" ${selectedImageModels.has(item.id)?'checked':''}><span class="image-model-option-name"><b>${esc(item.label||item.id)}</b><small>${esc(item.id)}</small></span><select data-image-quality-for="${esc(item.id)}" aria-label="${esc(`${item.label||item.id} 图片质量`)}">${['',...IMAGE_QUALITY_OPTIONS].map(value=>`<option value="${esc(value)}" ${((selectedImageQualities.get(item.id) ?? item.quality ?? '')===value)?'selected':''}>${esc(value||'服务商默认')}</option>`).join('')}</select></label>`).join('')}`;
};
const loadImageModels=()=>{
if (imageModelPromise) return imageModelPromise;
imageModelPromise=fetch('/health',{cache:'no-store'}).then(response=>readJsonResponse(response,'/health')).then(payload=>{
imageModelOptions=arr(payload.imageModels).map(item=>typeof item==='string'?{id:item,label:item}:item).filter(item=>item?.id);
imageModelOptions.forEach(item=>{
if (!selectedImageQualities.has(item.id)) selectedImageQualities.set(item.id,item.quality||'');
});
if (!imageModelOptions.length&&payload.imageModel) imageModelOptions=[{id:payload.imageModel,label:payload.imageModel}];
if (!selectedImageModels.size&&imageModelOptions[0]) selectedImageModels.add(imageModelOptions[0].id);
selectedImageModels=new Set([...selectedImageModels].filter(id=>imageModelOptions.some(item=>item.id===id)));
if (!selectedImageModels.size&&imageModelOptions[0]) selectedImageModels.add(imageModelOptions[0].id);
renderImageModelControls();
syncFissionControl();
refreshGenerationConfirmation();
}).catch(()=>{
imageModelOptions=[{id:'',label:'默认生图模型'}];
selectedImageQualities.set('','');
selectedImageModels=new Set(['']);
renderImageModelControls();
refreshGenerationConfirmation();
});
return imageModelPromise;
};
const userDirection=()=>String(userDirectionInput?.value||'').trim();
const fissionEnabled=()=>!!fissionInput?.checked&&!uploadedReferences.length&&!matchReferenceShooting()&&!comparisonMode();
const matchReferenceShooting=()=>!!matchReferenceInput?.checked;
const matchReferenceDescription=()=>matchReferenceShooting()
? '已开启:第1张上传产品图只锁定商品本身;第2张采集参考图只提供展示状态;视角、动作、朝向、展开/折叠、摆放、支撑和部件关系跟随采集商品对应图片;不复制采集图中的其他商品、颜色、花型、品牌或文字。'
: '未开启:产品图只用于商品身份,采集图的拍摄方式、具体动作和摆放状态不继承;按每张图任务重新设计。';
const syncMatchReferenceControl=plan=>{
if (!matchReferenceInput) return;
const matchReferenceLabel=matchReferenceWrap?.querySelector('b');
if (matchReferenceLabel) {
if (matchReferenceShooting()) matchReferenceLabel.textContent='产品展示状态跟随采集商品对应图片';
else matchReferenceLabel.textContent='产品图身份 + 采集参考图展示状态';
}
const referenceHeaderLabel=dialog.querySelector('.reference-upload header b');
if (referenceHeaderLabel) referenceHeaderLabel.textContent=uploadedReferences.length?'产品图(商品身份)':'采集参考图(默认身份参考)';
const fallback=fallbackReferences(plan||{});
const hasReference=uploadedReferences.length>0&&fallback.length>0;
matchReferenceInput.disabled=!hasReference;
if (!hasReference) matchReferenceInput.checked=false;
if (matchReferenceNote) matchReferenceNote.textContent=hasReference?matchReferenceDescription():'请同时准备产品图和采集商品对应参考图,才能启用展示状态一致。';
if (matchReferenceWrap) matchReferenceWrap.title=hasReference?matchReferenceDescription():'需要产品图和采集参考图';
};
const ensureFissionBaseSelected=()=>{
if (!fissionEnabled()) return;
const hasAnySelection=previewSlots.some(slot=>selectedSlotKeys.has(slotKey(slot)));
const base=previewSlots.find(slot=>slot.assetType==='main'&&slot.index===1);
if (hasAnySelection&&base) selectedSlotKeys.add(slotKey(base));
};
const syncFissionControl=()=>{
if (!fissionWrap||!fissionInput) return;
const lockedByReferenceMatch=matchReferenceShooting();
const lockedByComparison=comparisonMode();
fissionWrap.hidden=!!uploadedReferences.length||lockedByReferenceMatch;
fissionInput.disabled=!!uploadedReferences.length||lockedByReferenceMatch||lockedByComparison;
if (uploadedReferences.length||lockedByReferenceMatch||lockedByComparison) fissionInput.checked=false;
if (lockedByComparison&&fissionWrap) fissionWrap.title='多模型对比使用同一参考图,暂不启用裂变基准。';
};
const directionNote=direction=>{
if (!direction?.raw) return '';
const accepted=arr(direction.accepted), rejected=arr(direction.rejected);
const ok=accepted.length?`已采纳:${accepted.join(' / ')}`:'未采纳可执行补充';
const no=rejected.length?`;已忽略:${rejected.join(' / ')}`:'';
return `${ok}${no}`;
};
const syncTypeSwitches=()=>{
if (!generationControls) return;
generationControls.querySelectorAll('[data-select-type]').forEach(input=>{
const slots=previewSlots.filter(slot=>slot.assetType===input.dataset.selectType);
const selected=slots.filter(slot=>selectedSlotKeys.has(slotKey(slot))).length;
input.checked=!!slots.length&&selected===slots.length;
input.indeterminate=selected>0&&selected<slots.length;
input.disabled=!slots.length;
const count=input.closest('label')?.querySelector('span');
if (count) count.textContent=`${selected}/${slots.length}`;
});
};
const fallbackReferences=plan=>{
const firstMain=[...evidenceIndex.values()].find(item=>item?.type==='image'&&item?.meta?.group==='main');
return [firstMain].filter(item=>Boolean(item&&imageUrl(item)));
};
const renderReferences=plan=>{
const fallback=fallbackReferences(plan), urls=uploadedReferences.length?uploadedReferences:fallback.map(imageUrl).filter(Boolean);
if (referenceList) {
const uploadRoot=referenceList.closest('.reference-upload');
uploadRoot?.classList.toggle('has-reference', urls.length>0);
referenceList.innerHTML=urls.map((url,index)=>`<figure><img src="${esc(url)}" alt="${uploadedReferences.length?'产品图':'采集参考图'} ${index+1}">${uploadedReferences.length?`<button type="button" data-remove-reference="${index}" aria-label="删除产品图">×</button>`:''}</figure>`).join('');
}
syncFissionControl();
syncMatchReferenceControl(plan);
if (referenceHint) referenceHint.textContent=uploadedReferences.length?`已上传 ${uploadedReferences.length} 张产品图;第一张是整套图片唯一商品身份基准,其他上传图不会混入生图请求。开启展示状态一致后,采集商品对应图片只负责视角和摆放。`:(fallback.length?(fissionEnabled()?'默认先裂变生成相近花型基准图,后续主图/详情围绕该基准图生成。':'当前将直接参考采集商品主图生成。'):'当前没有商品主图,请上传产品图后生成。');
};
const taskName=slot=>slot.role||`${slot.assetType==='detail'?'详情图':'主图'}任务 ${slot.index||''}`;
const taskCard=(slot,result,status='queued')=>{
const label=status==='complete'?'已完成':status==='generating'?'生成中':status==='error'?'生成失败':'等待生成';
const key=slotKey(slot), hasUserEdit=Object.prototype.hasOwnProperty.call(promptOverrides,key);
const promptText=hasUserEdit?promptOverrides[key]:'';
const mergeMode=slot.promptMerge?.mode;
const globalDirection=slot.userDirection||{};
const hasGlobalAccepted=arr(globalDirection.accepted).length>0;
const hasGlobalIgnored=arr(globalDirection.ignored).length>0;
const hasGlobalRejected=arr(globalDirection.rejected).length>0;
const mergeNote=mergeMode==='applied'?'本图已关联并优先融合本图编辑':
hasGlobalAccepted?'本图已关联并优先融合全局用户要求':
mergeMode==='ignored_unrelated'||hasGlobalIgnored||hasGlobalRejected?'本图未关联或存在不可执行要求,继续执行内置策划':
'本图执行内置策划';
const planContext=compact([slot.planProductAction,slot.planPageAction]);
const planContextHtml=planContext?`<p class="generation-plan-context"><b>${esc(slot.planName||'当前开品方案')}</b> · ${esc(planContext)}</p>`:'';
const media=result?.url?`<button class="generation-preview-btn" type="button" data-preview-url="${esc(result.url)}" data-preview-title="${esc(taskName(slot))}"><img loading="lazy" src="${esc(result.url)}" alt="${esc(taskName(slot))}"></button>`:`<div class="generation-task-placeholder"><span>${slot.assetType==='detail'?'DETAIL':'MAIN'}</span><b>${String(slot.index||1).padStart(2,'0')}</b></div>`;
const referencePreview=slot.displayReferenceUrl||slot.identityReferenceUrl;
const referenceLabel=slot.displayReferenceUrl?'展示状态参考图':'产品身份参考图';
const referenceThumb=referencePreview?`<div class="generation-reference-preview"><img loading="lazy" src="${esc(referencePreview)}" alt="${esc(referenceLabel)}"><span>${esc(referenceLabel)}</span></div>`:'';
const resultSpec=result?`<small class="generation-task-spec">${esc(modelLabel(result))} · 质量 ${esc(modelQuality(result))}</small>`:'';
const visibleResultSpec=result?`<div class="generation-task-result-meta"><b>本次实际请求</b>${resultSpec}</div>`:'';
const choose=status==='queued'?`<label class="generation-slot-select"><input type="checkbox" data-select-slot="${esc(key)}" ${selectedSlotKeys.has(key)?'checked':''}><span>生成此图</span></label>`:'';
return `<article class="generation-task-card generation-task-${status} ${selectedSlotKeys.has(key)?'is-selected':''}" data-task-card="${esc(key)}"><div class="generation-task-media">${media}<i>${esc(label)}</i><div class="generation-task-overlay"><small>${slot.assetType==='detail'?'详情图':'主图'} ${String(slot.index||1).padStart(2,'0')}</small><h4>${esc(taskName(slot))}</h4>${planContextHtml}${resultSpec}${arr(slot.task).length?`<p>${arr(slot.task).map(esc).join(' · ')}</p>`:''}<p class="generation-prompt-status">${esc(mergeNote)}</p>${choose}<details class="task-prompt"><summary>编辑本张提示词</summary><em>商品材质、颜色、结构等修改会同步整套图片;视觉要求仅在关联本图时融合。</em><textarea data-prompt-slot="${esc(key)}" placeholder="例如:改成天丝棉材质;或背景留白更大、改为面料微距特写">${esc(promptText)}</textarea><details class="task-final-prompt"><summary>查看最终提示词</summary><pre>${esc(slot.prompt||'')}</pre></details></details></div></div>${referenceThumb}${visibleResultSpec}</article>`;
};
const openPreview=button=>{
const buttons=[...generateResults.querySelectorAll('[data-preview-url]')];
openImagePreview(buttons.map(node=>({url:node.dataset.previewUrl,title:node.dataset.previewTitle||'生成图片'})),buttons.indexOf(button));
};
const comparisonCell=(slot,result,status,model)=>{
const media=result?.url
? `<button class="generation-preview-btn" type="button" data-preview-url="${esc(result.url)}" data-preview-title="${esc(`${modelLabel(model)} · ${taskName(slot)}`)}"><img loading="lazy" src="${esc(result.url)}" alt="${esc(`${modelLabel(model)} · ${taskName(slot)}`)}"></button>`
: `<div class="generation-task-placeholder"><span>${status==='error'?'FAILED':status==='generating'?'RUNNING':slot.assetType==='detail'?'DETAIL':'MAIN'}</span><b>${String(slot.index||1).padStart(2,'0')}</b></div>`;
const error=result?.error||'';
return `<article class="generation-comparison-cell generation-task-${status}"><header><b>${esc(modelLabel(model))}<small>${esc(modelQuality(model))}</small></b><i>${status==='complete'?'已完成':status==='generating'?'生成中':status==='error'?'失败':'等待生成'}</i></header><div class="generation-comparison-media">${media}</div>${error?`<p class="generation-error">${esc(error)}</p>`:''}</article>`;
};
const renderComparisonBoard=(slots,results=[],jobStatus='queued',failedSlots=[],models=selectedModelOptions())=>{
const modelList=models.length?models:imageModelOptions;
const resultKey=(model,slot)=>`${modelKey(model)}:${slot.assetType}:${slot.index}`;
const resultMap=new Map(arr(results).map(item=>[`${modelKey(item)}:${item.assetType}:${item.slotIndex}`,item]));
const failedMap=new Map(arr(failedSlots).map(item=>[`${modelKey(item)}:${item.assetType}:${item.slotIndex}`,item]));
const row=(type,title)=>{
const items=arr(slots).filter(slot=>slot.assetType===type);
if (!items.length) return '';
const completed=items.reduce((sum,slot)=>sum+modelList.filter(model=>resultMap.has(resultKey(model,slot))).length,0);
const modelHeaders=modelList.map(model=>`<div class="generation-comparison-model"><b>${esc(modelLabel(model))}</b><small>${esc(modelQuality(model))}</small></div>`).join('');
const taskRows=items.map(slot=>{
const slotKeyValue=slotKey(slot);
const choose=jobStatus==='queued'
? `<label class="generation-slot-select"><input type="checkbox" data-select-slot="${esc(slotKeyValue)}" ${selectedSlotKeys.has(slotKeyValue)?'checked':''}><span>生成此图</span></label>`
: '';
const ref=slot.displayReferenceUrl||slot.identityReferenceUrl;
const refLabel=slot.displayReferenceUrl?'展示状态参考图':'产品身份参考图';
const refThumb=ref?`<div class="generation-reference-preview"><img loading="lazy" src="${esc(ref)}" alt="${esc(refLabel)}"><span>${esc(refLabel)}</span></div>`:'';
const planContext=compact([slot.planProductAction,slot.planPageAction]);
const planContextHtml=planContext?`<span class="generation-plan-context"><b>${esc(slot.planName||'当前开品方案')}</b> · ${esc(planContext)}</span>`:'';
const taskCell=`<div class="generation-comparison-task"><small>${slot.assetType==='detail'?'详情图':'主图'} ${String(slot.index||1).padStart(2,'0')}</small><strong>${esc(taskName(slot))}</strong>${planContextHtml}${refThumb}${choose}</div>`;
const modelCells=modelList.map(model=>{
const key=resultKey(model,slot);
const result=resultMap.get(key);
const failure=failedMap.get(key);
let status=result?'complete':'queued';
if (!result&&jobStatus==='running') status='generating';
if (!result&&(jobStatus==='error'||failure)) status='error';
return comparisonCell(slot,result||failure,status,model);
}).join('');
return taskCell+modelCells;
}).join('');
return `<section class="generation-comparison-row"><header><div><span>${type==='main'?'MAIN IMAGE TASKS':'DETAIL IMAGE TASKS'}</span><h3>${title}</h3></div><b>${completed} / ${items.length*modelList.length}</b></header><div class="generation-comparison-table" style="--comparison-columns:${Math.max(1,modelList.length)}"><div class="generation-comparison-corner">图片任务</div>${modelHeaders}${taskRows}</div></section>`;
};
generateResults.innerHTML=row('main','主图模型对比')+row('detail','详情图模型对比');
};
const renderTaskBoard=(slots,results=[],jobStatus='queued',failedSlots=[])=>{
if (!generateResults) return;
if (selectedImageModels.size>1) {
renderComparisonBoard(slots,results,jobStatus,failedSlots,selectedModelOptions());
return;
}
const selected=arr(slots);
const resultMap=new Map(arr(results).map(item=>[`${item.assetType}:${item.slotIndex}`,item]));
const failedKeys=new Set(arr(failedSlots).map(item=>`${item.assetType}:${item.slotIndex}`));
const row=(type,title)=>{
const items=selected.filter(slot=>slot.assetType===type);
if (!items.length) return '';
const heading=type==='detail'?`${title}(${items.length})`:`${title}(${items.length})`;
const right=type==='detail'?`<span class="generation-task-hint">左右滑动查看全部 ${items.length} 张</span>`:`<b>${items.filter(slot=>resultMap.has(`${slot.assetType}:${slot.index}`)).length} / ${items.length}</b>`;
return `<section class="generation-task-row generation-task-row-${type}"><header><div><span>${type==='main'?'MAIN IMAGE TASKS':'DETAIL IMAGE TASKS'}</span><h3>${heading}</h3></div>${right}</header><div class="generation-task-track" style="--task-count:${items.length}">${items.map(slot=>{const key=`${slot.assetType}:${slot.index}`;const result=resultMap.get(key);let status=result?'complete':'queued';if(!result&&jobStatus==='running')status='generating';if(!result&&(jobStatus==='error'||failedKeys.has(key)))status='error';return taskCard(slot,result,status)}).join('')}</div></section>`;
};
generateResults.innerHTML=row('main','主图生成任务')+row('detail','详情图生成任务');
};
const refreshGenerationConfirmation=()=>{
ensureFissionBaseSelected();
const counts=generationCounts(), total=counts.main+counts.detail;
const modelCount=Math.max(1,selectedImageModels.size);
const requestTotal=total*modelCount;
// Keep the count calculation in one place; the single-model label follows the modal spec.
// 兼容旧版校验:确认生成:${countText(counts)}
if (generateButton) { generateButton.disabled=!total||!selectedImageModels.size; generateButton.textContent=requestTotal?(modelCount>1?`${modelCount} 个模型 × ${countText(counts)} = ${requestTotal} 张`:`确认生成:${countText(counts)}`):'请选择要生成的图片类型'; }
if (generateStatus && previewSlots.length) generateStatus.textContent=requestTotal?`本次计划生成 ${countText(counts)};${modelCount>1?`将按 ${modelCount} 个模型并排对比,共 `:''} ${requestTotal} 张。${fissionEnabled()&&counts.main?'裂变模式已计入 main:1 基准图。':''}${modelCount>1?'多模型对比使用同一参考图和同一份提示词。':''}点击按钮后还需确认。`:'请至少选择一种图片类型和一个生图模型。';
syncTypeSwitches();
if (previewSlots.length) renderTaskBoard(previewSlots);
};
dialog.addEventListener('change',event=>{
const input=event.target.closest('[data-image-model]');
const qualityInput=event.target.closest('[data-image-quality-for]');
if (qualityInput) {
selectedImageQualities.set(qualityInput.dataset.imageQualityFor,qualityInput.value);
refreshGenerationConfirmation();
return;
}
if (!input) return;
if (input.checked) selectedImageModels.add(input.dataset.imageModel);
else selectedImageModels.delete(input.dataset.imageModel);
if (!selectedImageModels.size) input.checked=true,selectedImageModels.add(input.dataset.imageModel);
syncFissionControl();
refreshGenerationConfirmation();
});
generateResults?.addEventListener('click',event=>{
const button=event.target.closest('[data-preview-url]');if(button){openPreview(button);return;}
if (event.target.closest('input,textarea,summary,details,label,button')) return;
const card=event.target.closest('[data-task-card]');
if (!card||!card.classList.contains('generation-task-queued')) return;
if (selectedSlotKeys.has(card.dataset.taskCard)) selectedSlotKeys.delete(card.dataset.taskCard);
else selectedSlotKeys.add(card.dataset.taskCard);
refreshGenerationConfirmation();
});
const refreshPromptPreview=()=>{
if (!previewSlots.length) return Promise.resolve();
const previewBody={planIndex:activePlanIndex,assetTypes:['main','detail'],referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
if (context.source) previewBody.source=context.source; else if (context.data) previewBody.reportData=context.data;
return fetch('/api/image-prompt-preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(previewBody)}).then(response=>readJsonResponse(response,'/api/image-prompt-preview')).then(payload=>{
if (!payload.ok) throw new Error(payload.error||'提示词融合失败');
const promptIndexes={main:0,detail:0};
previewSlots=arr(payload.slots).map(slot=>{
const index=promptIndexes[slot.assetType]++;
const key=`${slot.assetType}:${slot.index}`;
return {...slot,prompt:arr(payload.prompts?.[slot.assetType])[index]||'',promptMerge:payload.promptMerges?.[key],userDirection:payload.userDirectionBySlot?.[key]};
});
if (generateStatus && payload.userDirection?.raw) generateStatus.textContent=directionNote(payload.userDirection);
});
};
generateResults?.addEventListener('change',event=>{
const textarea=event.target.closest('[data-prompt-slot]');
if (textarea) {
promptOverrides[textarea.dataset.promptSlot]=textarea.value;
refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'提示词融合失败';});
return;
}
const input=event.target.closest('[data-select-slot]');
if(!input)return;if(input.checked)selectedSlotKeys.add(input.dataset.selectSlot);else selectedSlotKeys.delete(input.dataset.selectSlot);refreshGenerationConfirmation();
});
generateResults?.addEventListener('input',event=>{const textarea=event.target.closest('[data-prompt-slot]');if(!textarea)return;promptOverrides[textarea.dataset.promptSlot]=textarea.value;});
generationControls?.addEventListener('change',event=>{
const input=event.target.closest('[data-select-type]');
if (!input) return;
previewSlots.filter(slot=>slot.assetType===input.dataset.selectType).forEach(slot=>{
if (input.checked) selectedSlotKeys.add(slotKey(slot));
else selectedSlotKeys.delete(slotKey(slot));
});
refreshGenerationConfirmation();
});
const pollJob = async jobId => {
let networkErrors=0;
for (let i=0;i<240;i++) {
const endpoint='/api/image-job/'+encodeURIComponent(jobId);
let state;
try {
const r=await fetch(endpoint,{cache:'no-store'});
state=await readJsonResponse(r,endpoint);
networkErrors=0;
} catch (error) {
networkErrors++;
if (generateStatus) generateStatus.textContent=`本地服务短暂断开,正在自动重连(${networkErrors}/5)…`;
if (networkErrors>=5) throw new Error('无法连接本地生图服务,请确认服务仍在运行后重试。');
await new Promise(resolve=>setTimeout(resolve,Math.min(2000*networkErrors,6000)));
continue;
}
// A fission run may inject main:1 as the shared product baseline even
// when the user selected detail-only slots. Keep that implicit asset in
// the board so the UI mirrors the manifest and does not hide the image
// that all follow-up slots used as their reference.
const expected=arr(state.expectedSlots);
if (arr(state.comparisonModels).length) {
const runModels=arr(state.comparisonModels).map(item=>({id:item.id||'',label:item.label||item.id||'默认生图模型',quality:item.quality||'',modelKey:item.modelKey}));
imageModelOptions=runModels;
selectedImageModels=new Set(imageModelOptions.map(item=>item.id));
const preservedQualities=new Map(selectedImageQualities);
runModels.forEach(item=>{
if (item.quality || !preservedQualities.has(item.id)) preservedQualities.set(item.id,item.quality||'');
});
selectedImageQualities=preservedQualities;
renderImageModelControls();
submittedModelSpecs=runModels.map(item=>({...item,quality:selectedImageQualities.get(item.id) ?? item.quality ?? ''}));
renderGenerationRunSummary(state.status,submittedModelSpecs);
}
if (expected.length) {
const known=new Set(previewSlots.map(slotKey));
const generatedKnown=new Set(generatedTaskSlots.map(slotKey));
expected.forEach(slot=>{
const key=slotKey(slot);
if (!known.has(key)) { previewSlots.push({...slot}); known.add(key); }
if (!generatedKnown.has(key)) { generatedTaskSlots.push({...slot}); generatedKnown.add(key); }
if (state.fissionPattern && slot.assetType==='main' && slot.index===1) selectedSlotKeys.add(key);
});
}
const displayResults=mergeResults(retainedResults,state.results);
const boardSlots=generatedTaskSlots.length?generatedTaskSlots:expected.length?expected:previewSlots;
if (state.status==='complete') { const gate=state.consistencyGate||{}; const shootPolicy=state.matchReferenceShooting?'已按参考图拍摄与产品展示状态生成。':'按每张图片任务重新设计拍摄和展示状态。'; const failedSlots=arr(state.failedSlots), failed=failedSlots.length; retainedResults=displayResults; if(failed){selectedSlotKeys=new Set(failedSlots.map(item=>`${item.assetType}:${item.slotIndex}`));const counts=generationCounts();if(generateButton){generateButton.disabled=false;generateButton.textContent=`仅重试失败:${countText(counts)}`;}syncTypeSwitches();} if(generateStatus) generateStatus.textContent=failed?`部分完成:累计成功 ${displayResults.length} 张,失败 ${failed} 张。点击按钮仅重试失败槽位。`:`已生成 ${displayResults.length} 张图片。${shootPolicy}${gate.status==='passed'?'一致性校验通过。':'已共享产品身份锁,发布前仍需完成一致性复核。'}`; renderTaskBoard(boardSlots,displayResults,'complete',failedSlots); return; }
if (state.status==='error') { renderTaskBoard(boardSlots,displayResults,'error',state.failedSlots); throw new Error(state.error||'生图失败'); }
renderTaskBoard(boardSlots,displayResults,'running',state.failedSlots);
const counts=generationCounts(); const expectedTotal=(counts.main+counts.detail)*Math.max(1,selectedImageModels.size); if (generateStatus) generateStatus.textContent=`正在并发生成:已完成 ${arr(state.results).length} / ${expectedTotal} 张 · ${state.progress||0}%`;
await new Promise(resolve=>setTimeout(resolve,1200));
}
throw new Error('生图任务等待超时,请稍后刷新报告查看结果。');
};
generateButton?.addEventListener('click',async event=>{
ensureFissionBaseSelected();
const types=selectedTypes(), counts=generationCounts(), total=counts.main+counts.detail;
if (!types.length) { if(generateStatus) generateStatus.textContent='至少选择主图或详情图一种类型。'; return; }
if (!total) { if(generateStatus) generateStatus.textContent='尚未完成生成数量计算,请稍后再试。'; return; }
const modelSpecs=selectedModelOptions(), requestTotal=total*Math.max(1,modelSpecs.length);
submittedModelSpecs=modelSpecs.map(item=>({...item}));
renderGenerationRunSummary('running',submittedModelSpecs);
const modelSummary=modelSpecs.map(item=>`${item.label||item.id}(${modelQuality(item)})`).join('、');
if (!window.confirm(`确认开始生成?\n\n模型:${modelSummary}\n主图:${counts.main} 张\n详情图:${counts.detail} 张\n合计:${requestTotal} 张`)) {
renderGenerationRunSummary('queued',[]);
if (generateStatus) generateStatus.textContent=`已取消。本次原计划生成 ${modelSpecs.length} 个模型 × ${countText(counts)}。`;
return;
}
const submittedSlots=previewSlots.filter(slot=>selectedSlotKeys.has(slotKey(slot)));
event.currentTarget.disabled=true; if(generateStatus) generateStatus.textContent='正在提交生图任务…'; renderTaskBoard(submittedSlots,retainedResults,'running');
try {
const body={planIndex:activePlanIndex,assetTypes:types,selectedSlots:[...selectedSlotKeys],completeSet:true,imageModels:modelSpecs.map(item=>({id:item.id,quality:item.quality||''})),referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
if (context.source) body.source=context.source; else if (context.data) body.reportData=context.data;
const response=await fetch('/api/generate-images',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}), payload=await readJsonResponse(response,'/api/generate-images');
if (!response.ok || !payload.ok) throw new Error(payload.error||'无法提交生图任务');
window.dispatchEvent(new CustomEvent('image-job-submitted',{detail:{jobId:payload.jobId}}));
await pollJob(payload.jobId);
window.dispatchEvent(new CustomEvent('image-job-finished',{detail:{jobId:payload.jobId}}));
} catch (error) { renderGenerationRunSummary('error',submittedModelSpecs); if(generateStatus) generateStatus.textContent=error.message||'生图失败'; }
finally { event.currentTarget.disabled=false; }
});
const referenceUpload=dialog.querySelector('.reference-upload');
const referenceUploadButton=dialog.querySelector('.reference-upload-btn');
const referenceEmpty=dialog.querySelector('.reference-empty');
const addReferenceFiles=async fileList=>{
const files=[...fileList].slice(0,Math.max(0,4-uploadedReferences.length));
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
if (file.size>9*1024*1024) { if(generateStatus) generateStatus.textContent='单张参考图不能超过 9MB。'; continue; }
try {
const dataUrl=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.onerror=reject;reader.readAsDataURL(file)});
uploadedReferences.push(dataUrl);
} catch (_) {
if (generateStatus) generateStatus.textContent='参考图读取失败,请重新选择图片。';
}
}
renderReferences(arr(plans)[activePlanIndex]||{});
if (previewSlots.length) await refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'参考图提示词刷新失败';});
if(generateStatus) generateStatus.textContent=uploadedReferences.length?`已使用上传产品图;生成时会保持产品身份。${matchReferenceShooting()?' '+matchReferenceDescription():''}`:`将使用默认采集参考图。${matchReferenceShooting()?' '+matchReferenceDescription():''}`;
};
referenceInput?.addEventListener('change',async event=>{
await addReferenceFiles(event.target.files);
event.target.value='';
});
// Explicitly proxy the visible button to the file input. This remains
// reliable when modal CSS or browser label activation suppresses the native
// label-to-input click behavior.
referenceUploadButton?.addEventListener('click',event=>{
if (event.target===referenceInput) return;
event.preventDefault();
referenceInput?.click();
});
referenceEmpty?.addEventListener('click',()=>referenceInput?.click());
referenceUpload?.addEventListener('dragenter',event=>{
event.preventDefault();
referenceUpload.classList.add('is-dragging');
});
referenceUpload?.addEventListener('dragover',event=>event.preventDefault());
referenceUpload?.addEventListener('dragleave',event=>{
if (!referenceUpload.contains(event.relatedTarget)) referenceUpload.classList.remove('is-dragging');
});
referenceUpload?.addEventListener('drop',async event=>{
event.preventDefault();
referenceUpload.classList.remove('is-dragging');
await addReferenceFiles(event.dataTransfer?.files||[]);
});
referenceList?.addEventListener('click',event=>{
const button=event.target.closest('[data-remove-reference]'); if(!button)return;
uploadedReferences.splice(Number(button.dataset.removeReference),1);
if (!uploadedReferences.length&&fissionInput) fissionInput.checked=true;
renderReferences(arr(plans)[activePlanIndex]||{});
if (previewSlots.length) refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'参考图提示词刷新失败';});
});
fissionInput?.addEventListener('change',()=>{
renderReferences(arr(plans)[activePlanIndex]||{});
if (previewSlots.length) refreshPromptPreview().then(refreshGenerationConfirmation).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'裂变提示词刷新失败';});
});
matchReferenceInput?.addEventListener('change',async()=>{
syncMatchReferenceControl(arr(plans)[activePlanIndex]||{});
syncFissionControl();
if (!previewSlots.length) {
if (generateStatus) generateStatus.textContent=matchReferenceDescription();
return;
}
try {
await refreshPromptPreview();
refreshGenerationConfirmation();
if (generateStatus) generateStatus.textContent=matchReferenceDescription();
} catch(error) {
if (generateStatus) generateStatus.textContent=error.message||'参考图拍摄方式提示词刷新失败';
}
});
document.querySelectorAll('.generate-plan-btn').forEach(button=>button.addEventListener('click',()=>{ document.querySelectorAll('.generate-plan-btn').forEach(button=>button.addEventListener('click',()=>{
loadImageModels();
const plan = arr(plans)[Number(button.dataset.planIndex)] || {}; const plan = arr(plans)[Number(button.dataset.planIndex)] || {};
activePlanIndex=Number(button.dataset.planIndex)||0;
if (userDirectionInput) userDirectionInput.value='';
if (fissionInput) fissionInput.checked=true;
if (matchReferenceInput) matchReferenceInput.checked=false;
uploadedReferences=[]; renderReferences(plan);
previewSlots=[]; selectedSlotKeys=new Set(); promptOverrides={}; retainedResults=[]; generatedTaskSlots=[];
if (generateButton) { generateButton.disabled=true; generateButton.textContent='正在计算生成数量…'; }
submittedModelSpecs=[];
renderGenerationRunSummary('queued',submittedModelSpecs);
title.textContent = plan.name || '快速开品方案'; title.textContent = plan.name || '快速开品方案';
reason.textContent = plan.whyThisPlan || ''; reason.hidden = !hasContent(plan.whyThisPlan); if (reason) { reason.textContent = plan.whyThisPlan || ''; reason.hidden = !hasContent(plan.whyThisPlan); }
[[product,plan.productAction],[page,plan.pageAction],[validation,plan.validation]].forEach(([node,value])=>{node.textContent=value||'';node.closest('div').hidden=!hasContent(value)}); [[product,plan.productAction],[page,plan.pageAction],[validation,plan.validation]].forEach(([node,value])=>{if(!node)return;node.textContent=value||'';node.closest('div').hidden=!hasContent(value)});
currentText = [[`快速开品任务|${plan.name||''}`,plan.name],['依据:'+(plan.whyThisPlan||''),plan.whyThisPlan],['产品动作:'+(plan.productAction||''),plan.productAction],['页面动作:'+(plan.pageAction||''),plan.pageAction],['验证指标:'+(plan.validation||''),plan.validation]].filter(([,value])=>hasContent(value)).map(([line])=>line).join('\n'); if (generateStatus) generateStatus.textContent='已按此方案准备生图提示词。请先选择要生成的图片类型。';
if (generateResults) generateResults.replaceChildren();
const previewBody={planIndex:activePlanIndex,assetTypes:['main','detail'],referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
if (context.source) previewBody.source=context.source; else if (context.data) previewBody.reportData=context.data;
fetch('/api/image-prompt-preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(previewBody)}).then(response=>readJsonResponse(response,'/api/image-prompt-preview')).then(payload=>{
if (!payload.ok) throw new Error(payload.error||'提示词预览失败');
const promptIndexes={main:0,detail:0};
previewSlots=arr(payload.slots).map(slot=>{
const index=promptIndexes[slot.assetType]++;
const key=`${slot.assetType}:${slot.index}`;
return {...slot,prompt:arr(payload.prompts?.[slot.assetType])[index]||'',promptMerge:payload.promptMerges?.[key],userDirection:payload.userDirectionBySlot?.[key]};
});
// 默认保留完整主图链和前六个详情证明位;详情 7-15 可按需勾选。
selectedSlotKeys=new Set(previewSlots.filter(slot=>slot.assetType==='main'||slot.index<=6).map(slotKey));
refreshGenerationConfirmation();
}).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'生成数量计算失败,请重新打开方案。'});
if (typeof dialog.showModal === 'function') dialog.showModal(); else dialog.setAttribute('open',''); if (typeof dialog.showModal === 'function') dialog.showModal(); else dialog.setAttribute('open','');
})); }));
dialog.querySelector('[data-close-plan]')?.addEventListener('click',()=>dialog.close ? dialog.close() : dialog.removeAttribute('open')); userDirectionInput?.addEventListener('change',()=>{
dialog.querySelector('[data-copy-plan]')?.addEventListener('click',async event=>{ if (!previewSlots.length) return;
try { await navigator.clipboard.writeText(currentText); event.currentTarget.textContent='已复制任务'; } const previewBody={planIndex:activePlanIndex,assetTypes:['main','detail'],referenceImages:uploadedReferences,userDirection:userDirection(),fissionPattern:fissionEnabled(),matchReferenceShooting:matchReferenceShooting(),promptOverrides};
catch { event.currentTarget.textContent='复制失败,请手动复制'; } if (context.source) previewBody.source=context.source; else if (context.data) previewBody.reportData=context.data;
setTimeout(()=>event.currentTarget.textContent='复制开品任务',1600); fetch('/api/image-prompt-preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(previewBody)}).then(response=>readJsonResponse(response,'/api/image-prompt-preview')).then(payload=>{
if (!payload.ok) throw new Error(payload.error||'用户补充判断失败');
const promptIndexes={main:0,detail:0};
previewSlots=arr(payload.slots).map(slot=>{
const index=promptIndexes[slot.assetType]++;
const key=`${slot.assetType}:${slot.index}`;
return {...slot,prompt:arr(payload.prompts?.[slot.assetType])[index]||'',promptMerge:payload.promptMerges?.[key],userDirection:payload.userDirectionBySlot?.[key]};
}); });
refreshGenerationConfirmation();
if (generateStatus && payload.userDirection?.raw) generateStatus.textContent=directionNote(payload.userDirection);
}).catch(error=>{if(generateStatus) generateStatus.textContent=error.message||'用户补充判断失败';});
});
dialog.querySelector('[data-close-plan]')?.addEventListener('click',()=>dialog.close ? dialog.close() : dialog.removeAttribute('open'));
loadImageModels();
}
function openImagePreview(items,startIndex=0) {
const list=arr(items).filter(item=>item?.url);
if (!list.length) return;
let index=Math.max(0,Math.min(startIndex,list.length-1));
let dialog=document.querySelector('[data-image-preview-dialog]');
if (!dialog) {
dialog=document.createElement('dialog');
dialog.className='image-preview-dialog';
dialog.setAttribute('data-image-preview-dialog','');
dialog.innerHTML='<button class="image-preview-close" type="button" data-preview-close aria-label="关闭">×</button><button class="image-preview-nav image-preview-prev" type="button" data-preview-prev aria-label="上一张">‹</button><figure><img data-preview-image alt=""><figcaption><b data-preview-title></b><span data-preview-count></span></figcaption></figure><button class="image-preview-nav image-preview-next" type="button" data-preview-next aria-label="下一张">›</button>';
document.body.append(dialog);
dialog.addEventListener('click',event=>{if(event.target===dialog||event.target.closest('[data-preview-close]')) dialog.close();});
dialog.addEventListener('keydown',event=>{if(event.key==='ArrowLeft'){event.preventDefault();dialog.querySelector('[data-preview-prev]')?.click();}if(event.key==='ArrowRight'){event.preventDefault();dialog.querySelector('[data-preview-next]')?.click();}});
}
const render=()=>{
const item=list[index]||list[0], img=dialog.querySelector('[data-preview-image]');
img.src=item.url; img.alt=item.title||'生成图片';
dialog.querySelector('[data-preview-title]').textContent=item.title||'生成图片';
dialog.querySelector('[data-preview-count]').textContent=`${index+1} / ${list.length}`;
};
dialog.querySelector('[data-preview-prev]').onclick=()=>{index=(index-1+list.length)%list.length;render();};
dialog.querySelector('[data-preview-next]').onclick=()=>{index=(index+1)%list.length;render();};
render();
if (typeof dialog.showModal === 'function') dialog.showModal(); else dialog.setAttribute('open','');
}
function initGenerationHistory(context={}) {
const root=document.querySelector('[data-generation-history]');
if (!root) return;
const endpoint='/api/image-jobs'+(context.source?'?source='+encodeURIComponent(context.source):'');
const dialog=document.querySelector('[data-generation-history-dialog]');
const trigger=document.querySelector('[data-generation-history-open]');
const countNode=document.querySelector('[data-generation-history-count]');
let timer=null;
const openHistory=()=>{
if (dialog && typeof dialog.showModal === 'function') dialog.showModal();
else if (dialog) dialog.setAttribute('open','');
};
trigger?.addEventListener('click',openHistory);
dialog?.addEventListener('click',event=>{
if (event.target===dialog || event.target.closest('[data-generation-history-close]')) {
if (typeof dialog.close === 'function') dialog.close();
else dialog.removeAttribute('open');
}
});
const render=records=>{
const items=arr(records);
if (countNode) {
countNode.textContent=items.length>99?'99+':String(items.length);
countNode.hidden=!items.length;
}
if (!items.length) { root.innerHTML='<p class="generation-history-empty">还没有生成记录。选择具体开品方案后,可异步生成主图与详情图。</p>'; return; }
root.innerHTML=items.map(job=>{
const failedCount=arr(job.failedSlots).length;
const status=job.status==='complete'?(failedCount?`部分完成 · 失败 ${failedCount} 张`:'已完成'):job.status==='error'?'失败':`生成中 ${Number(job.progress||0)}%`;
const thumbs=orderedResults(job.results).slice(0,15).map(item=>`<button type="button" data-history-preview-url="${esc(item.url)}" data-history-preview-title="${esc(`${modelLabel(item)} · ${item.assetType==='detail'?'详情图':'主图'} ${String(item.slotIndex||'').padStart(2,'0')}`)}"><img loading="lazy" src="${esc(item.url)}" alt="${esc(`${modelLabel(item)} · ${job.planName||'开品方案'}生成图`)}"></button>`).join('');
const date=job.createdAt?new Date(Number(job.createdAt)*1000).toLocaleString('zh-CN',{hour12:false}):'';
const shootPolicy=job.matchReferenceShooting?'已按参考图拍摄与展示状态一致':'按任务重新设计拍摄与展示';
const download=job.jobId && arr(job.results).length ? `<a class="generation-download" href="/api/image-job/${encodeURIComponent(job.jobId)}/download" download>打包下载</a>` : '';
const resultCount=arr(job.results).length;
const modelSummary=arr(job.comparisonModels).map(item=>`${item.label||item.id}(质量:${modelQuality(item)})`).filter(Boolean).join('、');
return `<article class="generation-record"><header><div><span>${job.comparisonMode?'模型对比任务':'开品方案'}</span><h3>${esc(job.planName||'未命名开品方案')}</h3><p>${esc(job.productName||'')} ${date?'· '+esc(date):''}</p></div><div class="generation-record-actions"><b class="generation-status generation-status-${esc(job.status||'queued')}">${esc(status)}</b>${download}</div></header><p class="generation-record-policy">${modelSummary?`对比模型:${esc(modelSummary)} · `:''}${shootPolicy} · ${job.referenceShootingPolicy==='match_reference'?'锁定机位/构图/光线/动作/摆放':'仅锁定产品身份'}</p>${job.error?`<p class="generation-error">${esc(job.error)}</p>`:''}${thumbs?`<div class="generation-thumbs">${thumbs}</div>`:''}<div class="generation-record-meta"><small>记录编号:${esc(job.jobId||'')}</small>${resultCount?`<small>已保存 ${resultCount} 张图片</small>`:''}</div></article>`;
}).join('');
};
root.addEventListener('click',event=>{
const button=event.target.closest('[data-history-preview-url]');
if (!button) return;
const record=button.closest('.generation-record')||root;
const buttons=[...record.querySelectorAll('[data-history-preview-url]')];
openImagePreview(buttons.map(node=>({url:node.dataset.historyPreviewUrl,title:node.dataset.historyPreviewTitle||'生成图片'})),buttons.indexOf(button));
});
const load=async()=>{
clearTimeout(timer);
try {
const response=await fetch(endpoint,{cache:'no-store'}), payload=await readJsonResponse(response,endpoint);
if (!response.ok||!payload.ok) throw new Error(payload.error||'生成记录读取失败');
render(payload.records);
if (arr(payload.records).some(x=>!['complete','error'].includes(x.status))) timer=setTimeout(load,1800);
} catch(error) {
if (countNode) countNode.hidden=true;
root.innerHTML=`<p class="generation-history-empty">${esc(error.message||'生成记录读取失败')}</p>`;
}
};
load();
window.addEventListener('image-job-submitted',load);
window.addEventListener('image-job-finished',load);
} }
async function main() { async function main() {
const query = new URLSearchParams(location.search); const query = new URLSearchParams(location.search);
@@ -306,7 +954,7 @@ async function main() {
else { else {
const response = await fetch(source,{cache:'no-store'}); const response = await fetch(source,{cache:'no-store'});
if (!response.ok) throw new Error(`报告数据读取失败(${response.status})`); if (!response.ok) throw new Error(`报告数据读取失败(${response.status})`);
data = await response.json(); data = await readJsonResponse(response,String(source));
} }
evidenceIndex = new Map(arr(data.evidenceLedger).map(item=>[item.id,item])); evidenceIndex = new Map(arr(data.evidenceLedger).map(item=>[item.id,item]));
const exp = data.experienceSolution || {}, roles = data.roleOutputs || {}, facts = data.facts || {}; const exp = data.experienceSolution || {}, roles = data.roleOutputs || {}, facts = data.facts || {};
@@ -342,10 +990,48 @@ async function main() {
const navNames = {decision:'老板速览',detail:'详情',review:'评论',verification:'验证闭环',roles:'完整分析'}; const navNames = {decision:'老板速览',detail:'详情',review:'评论',verification:'验证闭环',roles:'完整分析'};
const navLinks = bodies.filter(([id])=>navNames[id]).map(([id])=>`<a href="#${id}">${navNames[id]}</a>`).join(''); const navLinks = bodies.filter(([id])=>navNames[id]).map(([id])=>`<a href="#${id}">${navNames[id]}</a>`).join('');
const hasPlans = bodies.some(([id])=>id==='plan'); const hasPlans = bodies.some(([id])=>id==='plan');
const heroStats = [['当前价格',sales.currentPrice,'price'],['评论样本',data.baseline?.reviewCount,'sample'],['数据质量',data.dataQuality?.grade,'status']].filter(([,value])=>hasContent(value)); const heroStats = [['当前价格',sales.currentPrice,'price'],['评论样本',data.baseline?.reviewCount,'sample'],['状态',data.dataQuality?.grade,'status']].filter(([,value])=>hasContent(value));
document.title = summary.title || product.title || '商品开品分析报告'; document.title = summary.title || product.title || '商品开品分析报告';
document.getElementById('app').innerHTML = `<div class="report-top"><nav class="report-nav"><div class="wrap"><a class="brand" href="#top">三笙 · 商品开品分析</a><div class="report-nav-actions">${navLinks}${hasPlans?'<a class="quick-product-btn" href="#plan">快速开品 <span>→</span></a>':''}</div></div></nav><header id="top" class="report-hero hero-carousel" tabindex="0">${heroSlides?`<div class="hero-slides">${heroSlides}</div><div class="hero-shade"></div>`:''}<div class="wrap hero-overlay"><div class="hero-copy"><div class="report-kicker">PRODUCT DECISION REPORT · EVIDENCE BASED</div><h1>${esc(summary.title || product.title || '商品分析报告')}</h1>${hasContent(summary.verdict)?`<p>${esc(summary.verdict)}</p>`:''}${auditNotice?`<p class="report-audit-notice" role="status">${esc(auditNotice)}</p>`:''}${heroStats.length?`<div class="hero-meta">${heroStats.map(([label,value,tone])=>`<span class="hero-stat hero-stat-${tone}"><small>${label}</small><b>${esc(value)}</b></span>`).join('')}</div>`:''}</div>${heroDeck?`<div class="hero-deck" aria-label="商品主图牌组">${heroDeck}</div>`:''}${orderedMain.length>1?`<div class="hero-carousel-ui"><button type="button" data-carousel-prev aria-label="上一张主图">←</button><div class="hero-dots">${heroDots}</div><button type="button" data-carousel-next aria-label="下一张主图">→</button></div>`:''}</div></header></div>${sectionHtml}${hasPlans?'<dialog id="quick-plan-dialog" class="quick-plan-dialog"><button class="dialog-close" type="button" data-close-plan aria-label="关闭">×</button><span>QUICK PRODUCT BRIEF</span><h2 data-plan-title>快速开品方案</h2><p data-plan-reason></p><dl><div><dt>产品动作</dt><dd data-plan-product></dd></div><div><dt>页面动作</dt><dd data-plan-page></dd></div><div><dt>验证指标</dt><dd data-plan-validation></dd></div></dl><button class="copy-plan-btn" type="button" data-copy-plan>复制开品任务</button></dialog>':''}<footer class="report-footer"><div class="wrap"><b>数据边界</b> 本报告仅使用本次采集证据;评论主题可以重叠,未完成采集时不宣称平台全量。</div></footer>`; document.getElementById('app').innerHTML = `<div class="report-top"><nav class="report-nav"><div class="wrap"><a class="brand" href="#top">三笙 · 商品开品分析</a><div class="report-nav-actions">${navLinks}${hasPlans?'<a class="quick-product-btn" href="#plan">快速开品 <span>→</span></a>':''}</div></div></nav><header id="top" class="report-hero hero-carousel" tabindex="0">${heroSlides?`<div class="hero-slides">${heroSlides}</div><div class="hero-shade"></div>`:''}<div class="wrap hero-overlay"><div class="hero-copy"><div class="report-kicker">PRODUCT DECISION REPORT · EVIDENCE BASED</div><h1>${esc(summary.title || product.title || '商品分析报告')}</h1>${hasContent(summary.verdict)?`<p>${esc(summary.verdict)}</p>`:''}${auditNotice?`<p class="report-audit-notice" role="status">${esc(auditNotice)}</p>`:''}${heroStats.length?`<div class="hero-meta">${heroStats.map(([label,value,tone])=>`<span class="hero-stat hero-stat-${tone}"><small>${label}</small><b>${esc(value)}</b></span>`).join('')}</div>`:''}</div>${heroDeck?`<div class="hero-deck" aria-label="商品主图牌组">${heroDeck}</div>`:''}${orderedMain.length>1?`<div class="hero-carousel-ui"><button type="button" data-carousel-prev aria-label="上一张主图">←</button><div class="hero-dots">${heroDots}</div><button type="button" data-carousel-next aria-label="下一张主图">→</button></div>`:''}</div></header></div>${sectionHtml}${hasPlans?'<dialog id="quick-plan-dialog" class="quick-plan-dialog"><button class="dialog-close" type="button" data-close-plan aria-label="关闭弹窗">×</button><div class="quick-plan-top"><section class="product-brief"><span>PRODUCT LAUNCH BRIEF · 产品开品说明</span><h2 data-plan-title>快速开品方案</h2><dl><div><dt>产品开品</dt><dd data-plan-product></dd></div><div><dt>主图 / 详情打造</dt><dd data-plan-page></dd></div></dl><div class="brief-controls"><label class="fission-pattern-toggle match-reference-shooting-toggle" data-match-reference-wrap><input type="checkbox" data-match-reference-shooting><span><b>产品图身份 + 采集参考图展示状态(产品展示与参考图拍摄一致)</b><small data-match-reference-note>请同时准备产品图和采集商品对应参考图,才能启用展示状态一致。</small></span></label><label class="fission-pattern-toggle" data-fission-wrap><input type="checkbox" data-fission-pattern checked> 默认裂变花型,先生成相近商品基准图</label><label class="image-user-direction"><span>用户补充要求</span><textarea data-user-direction rows="3" placeholder="例如:更偏高端酒店感、浅色背景、突出面料垂坠;不要填写价格、品牌Logo、认证或改商品结构。"></textarea></label></div></section><section class="reference-upload" aria-label="商品主图参考"><header><b>产品图(商品身份)</b><span>本地上传 · 最多 4 张</span></header><div class="reference-list" data-reference-list></div><div class="reference-empty"><span>+</span><b>点击或拖拽上传参考图</b><small>本地上传 · 最多 4 张</small></div><label class="reference-upload-btn">+ 上传产品图<input type="file" accept="image/*" multiple data-reference-input></label><p data-reference-hint></p><em>提示:上传主体清晰、结构完整的产品图;采集商品对应图片自动作为展示状态参考,两者不是同一张图。</em></section></div><section class="image-generation-panel"><header><div><span>AI VISUAL PRODUCTION</span><h3>AI 生图任务</h3><p>勾选任意图片任务,可横向滑动查看;主图最多 5 张,详情图最多 15 张。</p></div><button class="generate-images-btn" type="button" data-generate-images disabled>正在计算生成数量…</button></header><div class="image-generation-controls" data-generation-controls><b>默认勾选:5 张主图 + 6 张详情图。可按需增减。</b><label><input type="checkbox" data-select-type="main"> 生主图 <span>0/0</span></label><label><input type="checkbox" data-select-type="detail"> 生详情 <span>0/0</span></label></div><p class="image-generation-status" data-generate-status aria-live="polite"></p><div class="generated-image-results" data-generate-results></div></section></dialog>':''}<footer class="report-footer"><div class="wrap"><b>数据边界</b> 本报告仅使用本次采集证据;评论主题可以重叠,未准备采集证据时不宣称页面全量。</div></footer>`;
const modelControls=document.createElement('div');
modelControls.className='image-model-controls';
modelControls.setAttribute('data-image-model-controls','');
modelControls.innerHTML='<span class="image-model-loading">正在读取生图模型…</span>';
const generationControlsNode=document.querySelector('[data-generation-controls]');
generationControlsNode?.after(modelControls);
const generationHeading=document.querySelector('#quick-plan-dialog .image-generation-panel h3');
if (generationHeading) generationHeading.textContent='选择要生成的图片';
const generationButton=document.querySelector('#quick-plan-dialog [data-generate-images]');
const generationStatus=document.querySelector('#quick-plan-dialog [data-generate-status]');
const generationRunSummary=document.createElement('div');
generationRunSummary.className='generation-run-summary';
generationRunSummary.setAttribute('data-generation-run-summary','');
generationRunSummary.hidden=true;
generationStatus?.after(generationRunSummary);
const historyTrigger=document.createElement('button');
historyTrigger.className='generation-history-fab';
historyTrigger.type='button';
historyTrigger.setAttribute('data-generation-history-open','');
historyTrigger.setAttribute('aria-label','打开生成记录');
historyTrigger.innerHTML='<span class="generation-history-fab-icon" aria-hidden="true">↺</span><span>生成记录</span><b data-generation-history-count hidden>0</b>';
const historyDialog=document.createElement('dialog');
historyDialog.className='generation-history-dialog';
historyDialog.setAttribute('data-generation-history-dialog','');
historyDialog.innerHTML='<header class="generation-history-dialog-head"><div><span>AI VISUAL PRODUCTION</span><h2>生成记录</h2><p>按任务查看生成结果;点击图片查看原图,已有成果可直接打包下载。</p></div><button class="dialog-close" type="button" data-generation-history-close aria-label="关闭生成记录">×</button></header><div class="generation-history" data-generation-history><p class="generation-history-empty">正在读取生成记录…</p></div>';
document.body.append(historyTrigger,historyDialog);
initHeroCarousel(); initHeroCarousel();
initPlanActions(plans); const userDirectionField=document.querySelector('[data-user-direction]');
if (userDirectionField) {
userDirectionField.previousElementSibling.textContent='用户最终要求';
userDirectionField.placeholder='例如:改成天丝棉材质、颜色改为浅灰;或更偏高端酒店感、背景留白更大。不要填写价格、品牌Logo、认证、二维码等平台禁区。';
}
const footer=document.querySelector('.report-footer .wrap');
if (footer) {
const brand=document.createElement('div'); brand.className='report-footer-brand'; brand.innerHTML='<b>三笙AI</b><span>商品开品分析</span>';
footer.prepend(brand);
footer.append(document.createElement('br'),'参数事实、验证门与验证闭环统一收录在底部完整分析区。');
}
initPlanActions(plans,{source:typeof source==='string'?source:'',data:typeof source==='object'?data:null});
initGenerationHistory({source:typeof source==='string'?source:'',data:typeof source==='object'?data:null});
} }
main().catch(error => { document.getElementById('app').innerHTML = `<div class="report-error"><h1>报告加载失败</h1><p>${esc(error.message)}</p></div>`; }); main().catch(error => { document.getElementById('app').innerHTML = `<div class="report-error"><h1>报告加载失败</h1><p>${esc(error.message)}</p></div>`; });
+1806 -22
View File
File diff suppressed because one or more lines are too long
+23 -8
View File
@@ -1,6 +1,7 @@
import sys import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'server')) sys.path.insert(0, str(ROOT / 'server'))
@@ -47,13 +48,13 @@ class EvidenceOnlyReportTest(unittest.TestCase):
'meta': {}, 'meta': {},
} }
def test_empty_model_result_still_generates_reviewable_report(self): def test_empty_model_result_repairs_core_modules_before_report(self):
result = ensure_experience_solution(self.analysis) result = ensure_experience_solution(self.analysis)
self.assertFalse(result['meta']['fallbackReport']) self.assertFalse(result['meta']['fallbackReport'])
self.assertTrue(result['meta']['reportReady']) self.assertTrue(result['meta']['reportReady'])
self.assertFalse(result['meta']['reportAuditPassed']) self.assertTrue(result['meta']['reportAuditPassed'])
self.assertEqual('needs_review', result['meta']['reportQuality']) self.assertEqual('passed', result['meta']['reportQuality'])
self.assertIn('审核未通过', result['meta']['reportNotice']) self.assertEqual([], result['meta']['reportValidation']['missing'])
self.assertEqual('', result['facts']['product']['title']) self.assertEqual('', result['facts']['product']['title'])
self.assertEqual(['面料支数', '床单面料材质', '适用床尺寸'], [x['name'] for x in result['facts']['attributes']]) self.assertEqual(['面料支数', '床单面料材质', '适用床尺寸'], [x['name'] for x in result['facts']['attributes']])
self.assertEqual(2, result['baseline']['reviewCount']) self.assertEqual(2, result['baseline']['reviewCount'])
@@ -62,9 +63,20 @@ class EvidenceOnlyReportTest(unittest.TestCase):
self.assertTrue(cards) self.assertTrue(cards)
self.assertEqual('下一款明确卖点', cards[0]['label']) self.assertEqual('下一款明确卖点', cards[0]['label'])
self.assertIn('ATTR_0003', cards[0]['evidenceIds']) self.assertIn('ATTR_0003', cards[0]['evidenceIds'])
self.assertEqual([], result['experienceSolution']['productExperience']['gates']) self.assertGreaterEqual(len(result['experienceSolution']['productExperience']['parameterFacts']), 2)
self.assertGreaterEqual(len(result['experienceSolution']['productExperience']['gates']), 2)
self.assertGreaterEqual(len(result['experienceSolution']['validationLoop']['rows']), 3)
html = render(result, 'fixture') html = render(result, 'fixture')
self.assertIn('审核未通过', html) self.assertNotIn('审核未通过', html)
def test_unrepairable_core_audit_blocks_report(self):
with patch('analysis.repair_core_solution_with_evidence', side_effect=lambda draft, raw, base=None: draft):
result = ensure_experience_solution(self.analysis)
self.assertFalse(result['meta']['reportReady'])
self.assertFalse(result['meta']['reportAuditPassed'])
self.assertEqual('blocked', result['meta']['reportQuality'])
self.assertIn('已停止生成', result['meta']['reportBlockedReason'])
self.assertNotIn('reportNotice', result['meta'])
def test_analysis_runs_without_model_channel_uses_evidence_fallback(self): def test_analysis_runs_without_model_channel_uses_evidence_fallback(self):
raw = dict(self.analysis['facts']) raw = dict(self.analysis['facts'])
@@ -194,7 +206,10 @@ class EvidenceOnlyReportTest(unittest.TestCase):
self.assertIn('材质 / 成分', labels) self.assertIn('材质 / 成分', labels)
self.assertEqual([], signals['risks']) self.assertEqual([], signals['risks'])
self.assertTrue(signals['questions'][0]['label'].startswith('购买前关注:')) self.assertTrue(signals['questions'][0]['label'].startswith('购买前关注:'))
self.assertEqual(2, len(result['experienceSolution']['newProductPlans']['plans'])) plans = result['experienceSolution']['newProductPlans']['plans']
self.assertEqual(3, len(plans))
self.assertEqual(['证据驱动优化', '反馈驱动升级', '探索性方向'], [x['sourceType'] for x in plans])
self.assertTrue(all(x['name'].endswith('款') for x in plans))
self.assertNotIn('无证据升级', output) self.assertNotIn('无证据升级', output)
self.assertNotIn('项目负责人', output) self.assertNotIn('项目负责人', output)
self.assertNotIn('客户为什么购买', output) self.assertNotIn('客户为什么购买', output)
@@ -204,7 +219,7 @@ class EvidenceOnlyReportTest(unittest.TestCase):
doc = render(result, 'evidence-only') doc = render(result, 'evidence-only')
self.assertIn('window.REPORT_DATA=', doc) self.assertIn('window.REPORT_DATA=', doc)
self.assertIn('Sansong Product Intelligence V9.3.0', doc) self.assertIn('Sansong Product Intelligence V9.3.0', doc)
self.assertIn('<script src="/report.js"></script>', doc) self.assertIn('<script src="/report.js?v=', doc)
self.assertNotIn('项目负责人', doc) self.assertNotIn('项目负责人', doc)
def test_incomplete_final_editor_output_is_repaired_from_real_evidence(self): def test_incomplete_final_editor_output_is_repaired_from_real_evidence(self):
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
import copy
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'server'))
import server as app
class ImageUserIntentTest(unittest.TestCase):
def setUp(self):
app.IMAGE_INTENT_CACHE.clear()
self.report={'facts':{'product':{'title':'被子','category':'床品'},'attributes':{'尺寸':'200x230'}}}
self.plan={'name':'方案内部名称','productAction':'舒适睡眠','pageAction':'保持信息层级'}
self.slots=[{'assetType':'main','index':i,'task':['默认槽位任务'],
'referenceTextInfo':'被子尺寸200x230',
'referenceAnalysis':{'theme':'舒适支撑','composition':'上方文字、下方商品','imageObservation':{'textLayout':'左对齐'}}}
for i in (1,2)]
self.response={'productUpdates':[{'field':'category','value':'枕头','sourceText':'品类换成枕头'}],
'globalInstructions':['整套产品换成枕头,保留舒适睡眠方案方向'],
'perSlot':{f'main:{i}':{'instructions':['文案随枕头调整'], 'theme':'枕头舒适支撑',
'display':'保持参考构图,枕头不执行被子折叠动作',
'copy':'生成枕头支撑文案,保持参考文字位置大小,不沿用被子尺寸'} for i in (1,2)}}
def resolve(self,text='品类换成枕头,文案跟随改变',edits=None):
return app._resolve_image_user_intent(self.report,self.plan,self.slots,app._image_user_direction(text),edits or {},[],True)
def test_empty_input_uses_plan_without_analysis(self):
direction=app._image_user_direction('')
with patch.object(app,'chat_json') as model:
self.assertIs(direction,app._resolve_image_user_intent(self.report,self.plan,self.slots,direction,{},[],True))
model.assert_not_called()
prompt=app.build_image_prompt(self.report,self.plan,slot=self.slots[0],user_direction=direction)
self.assertIn('舒适睡眠',prompt)
self.assertNotIn('统一产品—方案—参考关系',prompt)
def test_category_copy_and_reference_are_resolved_for_every_image(self):
original=copy.deepcopy(self.report)
with patch.object(app,'chat_json',return_value=self.response) as model:
direction=self.resolve()
request=json.loads(model.call_args.args[1])
self.assertEqual(self.plan,request['plan'])
self.assertEqual(self.slots[0]['referenceAnalysis'],request['images'][0]['referenceAnalysis'])
lock=app.build_identity_lock(self.report,self.plan,direction,True)
self.assertEqual('枕头',lock['productName'])
for slot in self.slots:
per_slot=app._user_direction_for_slot(direction,slot)
prompt=app.build_image_prompt(self.report,self.plan,slot=slot,user_direction=per_slot,
identity_lock=lock,product_reference_mode='uploaded_identity_collected_reference',match_reference_shooting=True)
self.assertIn('当前品类以用户要求为准:枕头',prompt)
self.assertIn('舒适睡眠',prompt)
self.assertIn('生成枕头支撑文案',prompt)
self.assertIn('不沿用被子尺寸',prompt)
self.assertNotIn('商品外观完全由第1张上传产品图决定',prompt)
self.assertNotIn('商品品类完全一致',prompt)
self.assertEqual(original,self.report)
def test_preview_generation_share_result_but_changed_input_is_reinterpreted(self):
with patch.object(app,'chat_json',return_value=self.response) as model:
preview=self.resolve()
execution=self.resolve()
self.assertEqual(preview,execution)
model.assert_called_once()
self.resolve('品类换成枕头,标题改为舒适支撑')
self.assertEqual(2,model.call_count)
def test_long_global_and_per_image_input_arrives_complete(self):
text='品类换成枕头;'+('要求保持每张参考图的文字位置并调整文案;'*100)
edit='标题写成舒适支撑;'+('保留文字层级和完整商品展示;'*600)
with patch.object(app,'chat_json',return_value=self.response) as model:
self.resolve(text,{'main:1':edit})
request=json.loads(model.call_args.args[1])
self.assertEqual(text,request['userInput'])
self.assertEqual(edit,request['perImageInput']['main:1'])
self.assertEqual(text,app._image_user_direction(text)['raw'])
def test_per_image_visual_edit_is_not_promoted_to_other_images(self):
response=copy.deepcopy(self.response)
response['productUpdates']=[]
response['globalInstructions']=[]
response['perSlot']['main:1']['instructions']=['标题写成舒适支撑']
response['perSlot']['main:2']['instructions']=[]
with patch.object(app,'chat_json',return_value=response):
direction=self.resolve('',{'main:1':'标题写成舒适支撑'})
self.assertEqual(['标题写成舒适支撑'],app._user_direction_for_slot(direction,self.slots[0])['accepted'])
self.assertEqual([],app._user_direction_for_slot(direction,self.slots[1])['accepted'])
base='已经完成统一判断的提示词'
result,_=app.merge_image_prompt_with_user_edit(base,'标题写成舒适支撑',self.slots[0],direction)
self.assertEqual(base,result)
def test_product_change_in_single_image_input_is_shared(self):
with patch.object(app,'chat_json',return_value=self.response):
direction=self.resolve('',{'main:1':'品类换成枕头'})
for slot in self.slots:
self.assertEqual('枕头',app._user_direction_for_slot(direction,slot)['productUpdates'][0]['value'])
def test_missing_image_or_invented_update_preserves_input_without_adopting_invention(self):
for invalid in ('missing_slot','invented_update'):
response=copy.deepcopy(self.response)
if invalid=='missing_slot':del response['perSlot']['main:2']
else:response['productUpdates'][0]['sourceText']='用户没有要求的新品'
with self.subTest(invalid=invalid),patch.object(app,'chat_json',return_value=response):
result=self.resolve()
self.assertEqual('品类换成枕头,文案跟随改变',result['raw'])
self.assertIn('main:2',result['resolvedBySlot'])
if invalid=='invented_update':self.assertEqual([],result['productUpdates'])
app.IMAGE_INTENT_CACHE.clear()
def test_expression_request_and_analysis_failure_never_block_generation_prompt(self):
for response in ({'productUpdates':[{'field':'text','value':'无字','sourceText':'图片上不要有文字'}]},None):
app.IMAGE_INTENT_CACHE.clear()
with patch.object(app,'chat_json',return_value=response):
direction=self.resolve('图片上不要有文字')
slot=self.slots[0]
per_slot=app._user_direction_for_slot(direction,slot)
prompt=app.build_image_prompt(self.report,self.plan,slot=slot,user_direction=per_slot)
self.assertIn('图片上不要有文字',prompt)
self.assertNotIn('生成并真实绘制当前产品中文文案',prompt)
self.assertNotIn('Render Chinese copy',prompt)
self.assertLess(prompt.index('本图用户完整输入'),prompt.index('对应参考图职责'))
app.IMAGE_INTENT_CACHE.clear()
with patch.object(app,'chat_json',side_effect=RuntimeError('分析接口不可用')):
direction=self.resolve('背景换成室内,主体不变')
self.assertEqual('背景换成室内,主体不变',direction['raw'])
self.assertEqual('分析接口不可用',direction['analysisRecord']['error'])
def test_rejected_identity_inference_cannot_leak_into_reference_display(self):
response=copy.deepcopy(self.response)
response['productUpdates']=[{'field':'category','value':'四件套','sourceText':'图片是床品套装'}]
response['perSlot']['main:1']['display']='被套和枕套铺展在床上'
response['globalInstructions']=['保留床铺场景']
self.slots[0]['referenceAnalysis']['composition']='折叠商品居中,背景纯色'
with patch.object(app,'chat_json',return_value=response):
direction=self.resolve('图片上不要有文字')
per_slot=app._user_direction_for_slot(direction,self.slots[0])
prompt=app.build_image_prompt(self.report,self.plan,slot=self.slots[0],user_direction=per_slot,match_reference_shooting=True)
self.assertIn('折叠商品居中,背景纯色',prompt)
self.assertIn('图片上不要有文字',prompt)
self.assertNotIn('被套和枕套铺展在床上',prompt)
self.assertNotIn('保留床铺场景',prompt)
self.assertEqual(response,direction['analysisRecord']['response'])
self.assertLess(prompt.index('对应图展示状态执行依据'),prompt.index('辅助解析资料'))
def test_spatial_relationships_are_shared_by_default_and_user_paths(self):
self.slots[0]['referenceAnalysis']['displayRelations']={
'visibleParts':[{'id':'a','description':'悬挂主体'},{'id':'b','description':'支撑位置'}],
'relations':[{'type':'接触','from':'a','to':'b','observation':'顶部接触支撑,右侧遮挡'}],
'uncertainAreas':['背面连接不可见']}
with patch.object(app,'chat_json',return_value={}):
direction=self.resolve('保持产品,调整文字')
for d in (app._image_user_direction(''),app._user_direction_for_slot(direction,self.slots[0])):
prompt=app.build_image_prompt(self.report,self.plan,slot=self.slots[0],user_direction=d,match_reference_shooting=True)
self.assertIn('顶部接触支撑,右侧遮挡',prompt)
self.assertIn('背面连接不可见',prompt)
self.assertIn('可见层数不等于商品件数',prompt)
disabled=app.build_image_prompt(self.report,self.plan,slot=self.slots[0],match_reference_shooting=False)
self.assertNotIn('可见展示空间关系|对应原图约束',disabled)
if __name__=='__main__':unittest.main()
+121
View File
@@ -0,0 +1,121 @@
import base64
import json
import sys
import unittest
from email import policy
from email.parser import BytesParser
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'server'))
import server as app
import ai_client
class ReferenceContractTest(unittest.TestCase):
def test_original_analysis_survives_rewritten_report_roles(self):
report = {
'facts': {'images': {'main': ['https://example.test/1.png', 'https://example.test/2.png']}},
'evidenceLedger': [
{'id': f'IMG_MAIN_{i:04d}', 'type': 'image', 'value': f'https://example.test/{i}.png', 'meta': {'group': 'main'}}
for i in (1, 2)
],
'experienceSolution': {'visualCommerce': {'items': [
{'imageEvidenceId': 'IMG_MAIN_0002', 'imageRole': '使用场景', 'visualSignals': ['四季通用']}
], 'evidenceDetail': []}},
'visualDecision': {
'imageRoles': [
{'imageEvidenceId': 'IMG_MAIN_0002', 'imageRole': '材质触感', 'visualSignals': ['面料细节'], 'businessMeaning': '面料特写'}
],
'evidenceDetail': [
{'imageEvidenceId': 'IMG_MAIN_0002', 'composition': '面料特写背景,中央信息卡片', 'textInfo': '面料细节'}
],
},
}
observation = report['visualDecision']['evidenceDetail'][0]
observation['textInfo'] = '面料细节' + '完整参考文字' * 60
observation['composition'] += '保持原始布局' * 100
observation['textLayout'] = {'position': '中央', 'alignment': 'left', 'lines': 3}
observation['displayRelations'] = {'visibleParts':[{'id':'a','description':'上层可见区域'},{'id':'b','description':'回折区域'}],
'relations':[{'type':'连接','from':'a','to':'b','observation':'左侧连续回折,不是独立叠放'}]}
role = report['visualDecision']['imageRoles'][0]
role['visualSignals'] = ['面料细节'] + ['完整分析' * 30] * 6
slot = app.build_generation_slots(report, {})[1]
self.assertEqual('材质触感', slot['referenceAnalysis']['theme'])
self.assertEqual(observation['textInfo'], slot['referenceTextInfo'])
self.assertEqual(observation['composition'], slot['referenceAnalysis']['composition'])
self.assertEqual(observation, slot['referenceAnalysis']['imageObservation'])
self.assertEqual(role, slot['referenceAnalysis']['imageRoleAnalysis'])
self.assertEqual(role['visualSignals'], slot['referenceAnalysis']['signals'])
self.assertEqual(observation['displayRelations'],slot['referenceAnalysis']['displayRelations'])
prompt = app.build_image_prompt(report, {}, slot=slot, variant_index=1,
product_reference_mode='uploaded_identity_collected_reference', match_reference_shooting=True)
self.assertIn('原始图片主题:材质触感', prompt)
self.assertIn('面料特写背景,中央信息卡片', prompt)
self.assertNotIn('唯一任务:', prompt)
self.assertIn(observation['textInfo'], prompt)
self.assertIn(observation['composition'], prompt)
self.assertIn('左侧连续回折,不是独立叠放',prompt)
def test_reference_download_uses_certificate_context_and_exact_bytes(self):
image = b'\x89PNG\r\n\x1a\nimage-content'
with patch.object(app, '_download_image', return_value=(image, 'image/png')):
data_url = app._reference_data_url('https://example.test/reference.png')
self.assertEqual(image, base64.b64decode(data_url.split(',', 1)[1]))
response = MagicMock()
response.__enter__.return_value = response
response.read.return_value = image
response.headers.get.return_value = 'image/png'
with patch.object(app, '_ssl_context', return_value='trusted-context'), \
patch.object(app, 'load_config', return_value={}), \
patch.object(app.urllib.request, 'urlopen', return_value=response) as open_url:
self.assertEqual(image, app._download_image('https://example.test/reference.png')[0])
self.assertEqual('trusted-context', open_url.call_args.kwargs['context'])
def test_apiyi_request_contains_ordered_image_files_and_complete_prompt(self):
images = [b'\x89PNG\r\n\x1a\nproduct-image', b'\x89PNG\r\n\x1a\ncollected-image']
refs = ['data:image/png;base64,' + base64.b64encode(data).decode() for data in images]
prompt = '图1提供产品身份;图2为展示母版。\n保持折叠状态和文字排版。'
config = {'api_base': 'https://api.apiyi.com/v1', 'api_key': 'test-key',
'image_model': 'gpt-image-2', 'image_path': '/images/generations',
'timeout': 10, 'retries': 0}
response = MagicMock()
response.__enter__.return_value = response
response.read.return_value = json.dumps({'data': [{'b64_json': 'result'}]}).encode()
with patch.object(ai_client, 'image_channel', return_value=config), \
patch.object(ai_client, '_ssl_context', return_value='trusted-context'), \
patch.object(ai_client.urllib.request, 'urlopen', return_value=response) as open_url:
result = ai_client.image_generate(prompt, model='gpt-image-2', size='1024x1024', quality='high', reference_images=refs)
self.assertEqual('result', result['data'][0]['b64_json'])
request = open_url.call_args.args[0]
self.assertEqual('https://api.apiyi.com/v1/images/edits', request.full_url)
content_type = request.get_header('Content-type')
message = BytesParser(policy=policy.default).parsebytes(
('Content-Type: ' + content_type + '\r\nMIME-Version: 1.0\r\n\r\n').encode() + request.data)
fields = {}
files = []
for part in message.iter_parts():
name = part.get_param('name', header='content-disposition')
data = part.get_payload(decode=True)
if name == 'image[]':
files.append(data)
else:
fields[name] = data.decode('utf-8')
self.assertEqual(images, files)
self.assertEqual(prompt, fields['prompt'])
self.assertEqual({'model': 'gpt-image-2', 'size': '1024x1024', 'quality': 'high', 'n': '1'},
{key: value for key, value in fields.items() if key != 'prompt'})
def test_invalid_reference_never_sends_request(self):
config = {'api_base': 'https://api.apiyi.com/v1', 'api_key': 'test-key',
'image_model': 'gpt-image-2', 'image_path': '/images/generations',
'timeout': 10, 'retries': 0}
with patch.object(ai_client, 'image_channel', return_value=config), \
patch.object(ai_client.urllib.request, 'urlopen') as open_url:
with self.assertRaises(RuntimeError):
ai_client.image_generate('保留展示状态', reference_images=['data:image/png;base64,aW52YWxpZA=='])
open_url.assert_not_called()
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -34,7 +34,7 @@ class V920ImageDrivenReportTest(unittest.TestCase):
def test_renderer_is_image_driven_and_professional(self): def test_renderer_is_image_driven_and_professional(self):
doc=render(self.fixture(),'v920') doc=render(self.fixture(),'v920')
self.assertIn('window.REPORT_DATA=',doc) self.assertIn('window.REPORT_DATA=',doc)
self.assertIn('<script src="/report.js"></script>',doc) self.assertIn('<script src="/report.js?v=',doc)
self.assertIn('IMG_MAIN_0001',doc) self.assertIn('IMG_MAIN_0001',doc)
self.assertIn('IMG_DETAIL_0001',doc) self.assertIn('IMG_DETAIL_0001',doc)
self.assertNotIn('v92-main-grid',doc) self.assertNotIn('v92-main-grid',doc)