From d931871baaa23af6bc7084a5c25fd57c96987186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=96=BB=E9=95=BF=E5=8F=91?= Date: Wed, 16 Sep 2026 08:55:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=20V9.3=20=E7=94=9F?= =?UTF-8?q?=E5=9B=BE=E5=B7=A5=E4=BD=9C=E6=B5=81=E3=80=81=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=84=8F=E5=9B=BE=E8=A7=A3=E6=9E=90=E5=92=8C=E5=8F=82=E8=80=83?= =?UTF-8?q?=E5=9B=BE=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROMPT_ENGINEERING_V9.3.0.md | 45 +- server/ai_client.py | 220 ++- server/analysis.py | 300 ++- server/config.example.json | 15 + server/prompt_templates.py | 147 ++ server/renderer.py | 14 +- server/report.css | 1306 ++++++++++++- server/report.html | 4 +- server/report.js | 726 +++++++- server/server.py | 1828 ++++++++++++++++++- tests/test_evidence_only_report.py | 31 +- tests/test_image_generation_flow.py | 1003 ++++++++++ tests/test_image_user_intent.py | 162 ++ tests/test_reference_generation_contract.py | 121 ++ tests/test_v920_image_driven_report.py | 2 +- 15 files changed, 5840 insertions(+), 84 deletions(-) create mode 100644 tests/test_image_generation_flow.py create mode 100644 tests/test_image_user_intent.py create mode 100644 tests/test_reference_generation_contract.py diff --git a/PROMPT_ENGINEERING_V9.3.0.md b/PROMPT_ENGINEERING_V9.3.0.md index ec0fe38..93983c7 100644 --- a/PROMPT_ENGINEERING_V9.3.0.md +++ b/PROMPT_ENGINEERING_V9.3.0.md @@ -22,7 +22,50 @@ - evidenceIds 只用于机器追溯,不作为用户可见说明。 - 未采集经营数据时,不推断市场规模、转化率、利润或竞品表现。 -## 文本长度 +## 参考图拍摄一致策略(`matchReferenceShooting`) + +该字段是一个显式的视觉策略开关,默认 `false`。它解决“产品要不要换”和“拍摄方式要不要跟参考图”被模型混为一谈的问题: + +- `false`:参考图只作为商品身份、结构、材质、颜色和比例锚点;机位、景别、背景、光线、具体动作和摆放状态按当前图片的业务目标与产品目标重新设计。 +- `true`:同时迁移参考图的拍摄语法与产品展示状态。除机位/视角、景别、透视、构图、背景、光线和留白外,还必须具体复现平铺/悬挂/折叠/展开/堆叠/铺设/穿着/手持/使用中等状态,以及朝向、折叠程度、垂落褶皱、落点、支撑/接触点、遮挡、部件关系和人物动作阶段;不复制品牌、文字、无关商品或未经证实的卖点。 + +提示词优先级为:平台合规与安全 > 用户明确商品设定 / `identityLock` > 方案产品目标与批准的产品变化 > 当前槽位业务目标和证据任务 > 勾选时的参考图拍摄语言 > 默认视觉风格。首张 `main:1` 以第一张参考图作为商品外观母版;只有产品目标或用户最终设定明确列出的字段可以改变,其余轮廓、比例、材质、颜色、件数、结构和配件必须冻结。中文约束和 English constraints 同时写入最终 prompt,预览与正式生成共享同一策略。 + +## 对应参考图文案生成补充 + +开启参考一致时,对应采集图提供表达主题、产品展示状态和文字版式;上传产品图提供当前产品身份。参考图原文和参数不能直接作为当前产品事实。 + +每张生图任务携带对应图片的主题、视觉信息与商业含义分析。用户未指定文字策略时,按方案与参考图表达生成当前产品文案;用户明确要求决定文字是否出现、具体内容及排版变化。文字识别缺失不等于参考图无字,须查看实际参考图。 + +展示状态和文字版式作为固定视觉约束,方案与默认槽位任务在其中表达,不得改换参考图主题。保留文字位置、大小占比、对齐、层级、换行、行距和留白;文案过长时精简内容,不能移动或缩小商品、改变动作或折叠状态腾出文字区域。不复制参考商品原文、品牌或未经确认的宣称。 + +本模式的执行顺序为:先确认当前产品身份与事实,锁定对应参考图展示状态和文字版式,再将方案与业务文案适配进去;默认风格不得改变固定视觉约束。上述补充取代旧版“不复制文字”的笼统限制及允许业务任务改动参考状态的排序:禁止照抄原文,不禁止生成当前产品文案。预览与正式生成共享相同规则。 + +## 参考数据实际传输 + +开启参考一致时,按对应图片证据 ID 读取原始图片角色分析和构图观察,不用页面固定槽位名称改写主题,也不套用页面标签长度限制截断原始文字、构图或分析字段。完整原始分析随该图提示词传递;其中采集商品参数仅是参考表达,不是当前产品事实。 + +当前 API易 GPT Image 渠道携带参考图时使用 `/images/edits` 的 multipart 请求:第一个 `image[]` 文件为上传产品身份图,第二个为本槽位对应采集图。发送真实图片字节,不以 URL 别名字段或图片数量记录代替图片输入。读取失败、参考绑定不准确或图片数据无效时终止该槽位,不降级为无参考生图或其他采集图。 + +## 用户输入、方案与对应参考图的统一判断 + +没有全局或单图输入时,沿用所选方案的默认生图流程,不增加意图分析调用。参考一致开关继续决定展示与排版策略。 + +有输入时,使用完整用户内容、所选方案、产品与品类背景以及逐图原始参考分析进行统一语义判断,不以固定槽位关键词决定是否丢弃输入。方案始终是底座;用户明确要求及必然关联内容覆盖对应默认字段,其他适用方向继续保留。换品类不自动清空方案、重做报告或要求补齐所有新品资料;文案与展示按新品适配,不把旧品专属参数冒充新品事实。 + +判断结果包含明确产品更新、整套执行要求和每张图的主题/展示/文案关系。单图视觉要求只作用对应图片,单图明确的产品更新作用整套商品。参考一致开启时保持对应图适用的状态及文字位置、大小、层级和排版;用户明确改变的项可覆盖,跨品类不强迫新品执行不适用的旧品动作。 + +预览与正式生成调用同一解析函数,对相同上下文在当前服务进程的有界缓存内复用结果;输入、方案、产品图片或参考分析变化后重新判断。解析格式或接口异常保留完整输入与上下文交由生图模型理解,不终止整套任务;没有原文依据的解析更新不当成确认事实。单图原始编辑不再额外追加“无条件最高优先”提示,避免覆盖已统一的关系。 + +## 生图有效指令组装 + +参考观察增加通用品类无关的displayRelations:可见局部、局部间连接/接触/覆盖/遮挡/方向/相对位置/形变、视角及不可确认区域。主图和详情分析按原图证据ID记录,逐图完整传递,不猜隐藏结构,不将层数当件数。参考一致开启时,有输入和无输入共用空间关系执行约束;产品固有组成与展示形变分离。旧报告没有该字段时,生成模型直接核对实际对应参考图,不因字段缺失阻断生成,也不填固定折叠模板。输入检查不等于生成结果已精确复现,结果仍需按可见关系对照。 + +用户输入只授权其涉及的变化,不能由辅助理解将局部调整扩大为重拍或品类重判。开启参考一致时,对应图实际展示状态及原始构图观察是未被覆盖的执行依据,不仅是低优先级资料;产品图只确定身份,不能覆盖参考姿态或场景。辅助解析位于有效约束之后,仅解释授权变化。商品解析更新被拒绝时,同批可能受其污染的逐图描述也退出生图输入;原始返回仍保存用于排查,完整用户输入继续执行,不终止整套生图。 + +当前产品身份不可擅自变化,用户明确要求其次,适用参考关系再次,方案补充未指定内容。参考排版只在仍适用时保持。统一判断结果为辅助理解,完整原文保留在最终请求;普通解析格式或接口异常不终止生图,未能验证的商品解析更新不提升为已确认事实。最终有效指令重新组装,参考观察和方案以资料上下文传递,不重复追加旧强制指令。原始解析返回及异常随任务用户上下文保存用于排查。 + +## 文本长度限制 - 核心判断与专业意见不超过55字。 - 老板结论不超过28字,补充说明不超过28字。 diff --git a/server/ai_client.py b/server/ai_client.py index ba3d064..5b2a47a 100644 --- a/server/ai_client.py +++ b/server/ai_client.py @@ -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 ROOT = Path(__file__).resolve().parent 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(): 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_key'] = os.getenv('AI_API_KEY', cfg.get('api_key','')) 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['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))) except Exception: cfg['temperature'] = 0.2 try: cfg['timeout'] = int(os.getenv('AI_TIMEOUT', cfg.get('timeout',120))) except Exception: cfg['timeout'] = 120 try: cfg['retries'] = max(0, int(cfg.get('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['ca_bundle'] = str(cfg.get('ca_bundle','') or '') cfg['ssl_verify'] = bool(cfg.get('ssl_verify', True)) @@ -68,6 +105,63 @@ def _headers(c): h.update(c.get('extra_headers',{})) 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): path='/' + str(path or '').lstrip('/') 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: 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)] - 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: body=e.read().decode('utf-8','ignore') 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: 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(('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): c=load_config() if c.get('_config_error'): diff --git a/server/analysis.py b/server/analysis.py index 890af97..dd56d4d 100644 --- a/server/analysis.py +++ b/server/analysis.py @@ -79,12 +79,12 @@ FINAL_REPORT_PROMPT='''任务G|V9.3.0 七角色总编报告编排器。你只 {"label":"下一款优先","headline":"<=18字","note":"<=22字","evidenceIds":[]} ]}, "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":[]}, - "detailCommerce":{"professionalOpinion":"<=45字","contentGroups":[{"type":"品牌服务|套件组成|材质品质|产品功能|使用便利|花型选择|用户验证|其他","representativeImageId":"IMG_DETAIL_0001","visualSignals":["<=10字"],"businessMeaning":"<=22字","nextAction":"<=18字","imageEvidenceIds":[],"evidenceIds":[]}],"pageSequence":[]}, + "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字","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}, "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":[]}]}, - "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":[]}, "validationLoop":{"rows":[]} } @@ -92,8 +92,10 @@ FINAL_REPORT_PROMPT='''任务G|V9.3.0 七角色总编报告编排器。你只 硬规则: - ownerOverview.cards 必须正好3项,且分别回答“为什么能卖 / 值得继承 / 下一款优先”。 - visualCommerce.items 中每张图必须优先给 visualSignals;不要写长 message。 -- detailCommerce.contentGroups 证据允许时输出4-6组。 -- newProductPlans.plans>=1;第三类创新若不是直接证据结论必须标 sourceType=探索性方向。 +- detailCommerce.contentGroups 证据允许时输出4-6组,顺序必须是场景 → 材质 → 结构/工艺 → 功能 → 规格 → 使用维护。 +- 主图与详情必须形成连续页面:主图先让用户看懂商品和使用入口,详情逐项证明;同一卖点只能在一个信息任务中承担主责。 +- 主图和详情只表达商品本身的结构、材质、成分、尺寸、件数、功能、工艺、触感、使用场景和洗护等内容。品牌、商标、专利、授权、认证、奖项、包邮、物流、价格、销量、促销、客服、售后、二维码、防伪和店铺信息不得进入 visualCommerce 或 detailCommerce。 +- newProductPlans.plans 必须正好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。只修复结构缺失和证据引用,不扩写正文。 必须保留短文本与图片信息优先规则。 -强制最小结构: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;无评论时允许为空。 所有 evidenceIds 只能来自 ACTUAL_EVIDENCE。不要输出 Markdown、解释或包装层。 可选字段没有真实内容时直接省略,不得填入“暂无、未采集、待补充”或空容器。 @@ -680,6 +682,69 @@ def _apply_selling_points(exp, raw, base=None): } 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): 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=[] @@ -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('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('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, ) 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), ('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), - ('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), ) checks=[] @@ -777,7 +842,7 @@ def repair_core_solution_with_evidence(draft, raw, base=None): list_rules=( ('ownerOverview','cards',3), ('productExperience','parameterFacts',2),('productExperience','gates',2), - ('newProductPlans','plans',1),('validationLoop','rows',3), + ('newProductPlans','plans',3),('validationLoop','rows',3), ) for section,key in scalar_rules: 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) 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