harden(extract): 提取加写死的 JSON 输出契约兜底,消除"过度依赖 skill 文件"脆点

承上次定位:脚本生成空 skill 也能出 JSON,是因其 system 带写死的 _OUTPUT_PROTOCOL(进镜像、
永远在);提取没有这层兜底,只靠 skill 文件 → skill 一空就吐散文解析失败。本次对齐脚本做法:
给提取也加 _EXTRACT_OUTPUT_CONTRACT(写死的 entities/segments JSON 契约 + 不提商品等铁律),
拼在 skill 正文之后。即便将来 skill 再丢,提取也仍收到「只输出 JSON」硬指令,不会再退化。

真验证:用 dev 库里那条真实失败脚本,system 仅留写死契约(模拟 skill 全空)真调豆包 →
仍稳定出 JSON(角色/场景齐、商品不提)。

测试:152/152 全绿(新增契约兜底单测)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-23 00:24:29 +08:00
co-authored by Claude Opus 4.8
parent 8dab8b4921
commit 66778fa2d3
2 changed files with 33 additions and 1 deletions
+24 -1
View File
@@ -374,6 +374,28 @@ _EXTRACT_INFLIGHT = (
)
# 写死的提取输出契约兜底(对齐脚本 agent 的 _OUTPUT_PROTOCOL):随代码进镜像、永远在。
# 即便 skill 文件丢失/为空,模型也仍收到「只输出这套 JSON」的硬指令 → 不会再退化成吐散文 → 解析失败。
# 这是历史事故(skills 没打进镜像 → 提取 system 全空 → 解析失败)的根治性兜底,与 skill 正文不冲突(互相强化)。
_EXTRACT_OUTPUT_CONTRACT = """
---
## 输出契约(硬性 · 优先级最高)
你是电商短视频「实体提取」agent。读【商品信息】+【分镜脚本】,认出**角色(出镜的人)**和**场景(地点/环境)**,
**只输出且仅输出一个 JSON 对象**(UTF-8,无注释,不要 ```json 代码块外的任何散文/解释/markdown):
{
"entities": [
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "一句生图描述(20-45字,只写人物本身:年龄/外形/气质/整套穿搭;空手、不带与商品同类的物件)"},
{"id": "s1", "type": "scene", "name": "地点名", "visual_prompt": "一句空镜环境描述(20-40字,不写人)"}
],
"segments": [{"index": 0, "entity_refs": ["c1", "s1"]}]
}
硬性规则:① **绝不输出商品/product 实体**(商品由系统用真实主图注入);② 角色 id 用 c1/c2…、场景 id 用 s1/s2…,各最多 6 个、严格去重;
③ segments 必须覆盖输入每一镜,index 从 0 开始,每镜列出该镜出现的角色/场景的 id;④ 整个回复就是那个 JSON,不要任何前后缀。
"""
def get_inflight_extraction(project):
"""本项目正在跑的实体提取任务(最近一条优先);无则 None。"""
return (
@@ -428,7 +450,8 @@ def submit_extract_entities(*, project, user) -> AITask:
user_msg = (
f"商品信息:\n{prod_line}\n\n分镜脚本(共 {len(segments)} 镜,index 从 0 开始):\n" + "\n".join(seg_lines)
)
system = _load_skill_system_prompt("ecommerce-entity-extract")
# skill 正文(领域功力)+ 写死的输出契约兜底(保证「只输出 JSON」永远在,即便 skill 丢失)
system = _load_skill_system_prompt("ecommerce-entity-extract") + _EXTRACT_OUTPUT_CONTRACT
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_msg}]
try:
+9
View File
@@ -29,6 +29,15 @@ class SkillPromptBundlingTests(SimpleTestCase):
self.assertIn("JSON", prompt)
self.assertIn("entities", prompt)
def test_extract_output_contract_is_hardcoded_safety_net(self):
"""写死的提取输出契约(随代码进镜像)必须存在且含 JSON 铁律 —— 这是 skill 丢失时的兜底,
保证提取永远收到「只输出 JSON」指令(对齐脚本 agent 的 _OUTPUT_PROTOCOL,根治"系统提示词为空"事故)。"""
from apps.ai.services import _EXTRACT_OUTPUT_CONTRACT
self.assertGreater(len(_EXTRACT_OUTPUT_CONTRACT), 200)
for marker in ("entities", "segments", "character", "scene", "JSON"):
self.assertIn(marker, _EXTRACT_OUTPUT_CONTRACT)
class NormalizeDraftTests(SimpleTestCase):
"""normalize_draft 对模型不按契约输出的容错(防「旁白/画面全空」回归)。"""