From 8dab8b492128a3a02a7519e97cc57fc33a15607d Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Tue, 23 Jun 2026 00:12:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(skills):=20skills=20=E9=9A=8F=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=89=93=E8=BF=9B=E9=95=9C=E5=83=8F,=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB=E7=BA=BF=E4=B8=8A=E6=8F=90=E5=8F=96=E3=80=8C=E6=9C=AA?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E6=9C=89=E6=95=88=20JSON=E3=80=8D(=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=B8=BA=E7=A9=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真因(查 dev 库 + 重放真实失败请求 + 真调 ARK 定位):线上提取的 system 提示词长度=0。 core-api 镜像由 `./core/backend` 构建,而 skills/ 在仓库根、不在构建上下文 → 镜像里没有 → _load_skill_system_prompt 返回空串 → 模型收不到「只输出 JSON」铁律 → 吐 markdown/散文 → 正则抽不到 {} → 「提取结果解析失败(模型未返回有效 JSON)」。本地有 skills 故一直没复现。 (脚本生成同样受影响,只是它有退化兜底提示词,质量打折但不报错。) 验证:用 dev 库里你那条失败请求的真实脚本重放——空 system→无 JSON;补上 skill 提示词 → 立即出 JSON(角色/场景齐、商品不提)。 改动: - skills/ → core/backend/skills/(git mv,进构建上下文,Dockerfile `COPY . .` 自动打包)。 - services._skills_root()/script_agent._skill_dir():优先 BASE_DIR/skills,回落仓库根(双兜底)。 - worker 失败时也落 response_payload(含 content / reasoning 预览 / system_chars), 以后再坏一眼就能定位(本次正是因为失败没存返回,白绕一圈)。 - 回归测试 2 条:skills 必须在 BASE_DIR 内(随镜像走)+ 提取提示词非空且含 JSON 铁律。 - CLAUDE.md 标注 skills 必须放 core/backend 内的原因。 验收:完整 Django 套件 151/151 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 3 +- core/backend/apps/ai/script_agent.py | 8 ++++- core/backend/apps/ai/services.py | 33 ++++++++++++++++--- core/backend/apps/ai/tests.py | 25 ++++++++++++++ .../skills}/ecommerce-entity-extract/SKILL.md | 0 .../references/examples.md | 0 .../skills}/ecommerce-video-script/SKILL.md | 0 .../references/category-playbook.md | 0 .../references/checklist.md | 0 .../references/hook-library.md | 0 .../references/methodology.md | 0 .../references/platform-tone.md | 0 12 files changed, 62 insertions(+), 7 deletions(-) rename {skills => core/backend/skills}/ecommerce-entity-extract/SKILL.md (100%) rename {skills => core/backend/skills}/ecommerce-entity-extract/references/examples.md (100%) rename {skills => core/backend/skills}/ecommerce-video-script/SKILL.md (100%) rename {skills => core/backend/skills}/ecommerce-video-script/references/category-playbook.md (100%) rename {skills => core/backend/skills}/ecommerce-video-script/references/checklist.md (100%) rename {skills => core/backend/skills}/ecommerce-video-script/references/hook-library.md (100%) rename {skills => core/backend/skills}/ecommerce-video-script/references/methodology.md (100%) rename {skills => core/backend/skills}/ecommerce-video-script/references/platform-tone.md (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 8ee721b..a95e2fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,8 @@ ### 脚本 Agent(流式 SSE) - 端点 `POST /api/projects/{id}/script-agent-stream/` → `text/event-stream`,事件 `tool`/`delta`/`draft`/`saved`/`done`/`error`。**DRF 必须挂 `ServerSentEventRenderer` 否则 406**。 -- 核心 [script_agent.py](core/backend/apps/ai/script_agent.py):加载 `skills/ecommerce-video-script/` 作系统提示词;3 模式(auto/theme/revise)+ **精准改一镜**(`target_index`,后端 `_merge_single_segment` 强制保留其余镜);多模型(`model_config_id`)。 +- 核心 [script_agent.py](core/backend/apps/ai/script_agent.py):加载 [core/backend/skills/ecommerce-video-script/](core/backend/skills/ecommerce-video-script/) 作系统提示词;3 模式(auto/theme/revise)+ **精准改一镜**(`target_index`,后端 `_merge_single_segment` 强制保留其余镜);多模型(`model_config_id`)。 + - ⚠️ **skills 必须放在 `core/backend/` 内**(随后端打进 Docker 镜像)。镜像由 `./core/backend` 构建,skills 若放仓库根则不在构建上下文 → 镜像里没有 → 提示词加载为空 → 提取吐散文解析失败 / 脚本退化。提取的 skill 同理:[core/backend/skills/ecommerce-entity-extract/](core/backend/skills/ecommerce-entity-extract/)。 - **结构化 ScriptDraft 契约**:`narration`(口播/旁白,扁平兼容下游)+ `dialogue:[{speaker,line}]`(剧情对白,默认空,用户聊天提要求才补)+ `role/speaker/product_exposure/entity_refs`。落 `ScriptVersion.metadata`(hook/tone/entities)+ `ScriptSegment` 字段,并回填 `project.metadata`(cast/scenes/script_entities)给下游。 - **改稿用基准稿时长**(`effective_duration`),别用请求默认 60 否则截掉 90s 稿尾镜。**所有 `target_index` 判断用 `is None` 不用真值**(0 是合法镜号)。 diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index d04b4e3..a35d887 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -42,7 +42,13 @@ def _skill_dir() -> Path: override = getattr(settings, "ECOMMERCE_SKILL_DIR", None) if override: return Path(override) - return Path(settings.BASE_DIR).parent.parent / "skills" / "ecommerce-video-script" + # skills 已随后端打进镜像(core/backend/skills);优先 BASE_DIR/skills,回落仓库根(本地/旧布局)。 + # 旧版只看仓库根 → 镜像里没有(构建上下文是 ./core/backend)→ 提示词为空、退化兜底。详见 services._skills_root。 + base = Path(settings.BASE_DIR) + for cand in (base / "skills", base.parent.parent / "skills"): + if (cand / "ecommerce-video-script").is_dir(): + return cand / "ecommerce-video-script" + return base / "skills" / "ecommerce-video-script" @lru_cache(maxsize=1) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index f05fca9..1f6977b 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -221,13 +221,27 @@ def extract_cast_and_scenes(*, project, user, content: str) -> dict: return empty -def _load_skill_system_prompt(name: str) -> str: - """读取 skills//SKILL.md + references/*.md 拼成系统提示词(领域知识)。缺文件返回空串,不致命。""" +def _skills_root() -> "Path": + """skills 根目录。优先 BASE_DIR/skills(= core/backend/skills,随后端打进 Docker 镜像); + 回落仓库根 BASE_DIR.parent.parent/skills(本地/旧布局)。 + + ⚠️ 历史坑:镜像由 `./core/backend` 构建,skills 旧在仓库根 → 不在构建上下文 → 镜像里没有 → + `_load_skill_system_prompt` 返回空串 → 提取拿不到「只输出 JSON」铁律 → 模型吐散文 → 解析失败。 + 现 skills 已挪进 core/backend 随镜像打包;此处仍保留双路径兜底。""" from pathlib import Path from django.conf import settings - skill_dir = Path(settings.BASE_DIR).parent.parent / "skills" / name + base = Path(settings.BASE_DIR) + for cand in (base / "skills", base.parent.parent / "skills"): + if cand.is_dir(): + return cand + return base / "skills" + + +def _load_skill_system_prompt(name: str) -> str: + """读取 skills//SKILL.md + references/*.md 拼成系统提示词(领域知识)。缺文件返回空串,不致命。""" + skill_dir = _skills_root() / name parts: list[str] = [] main = skill_dir / "SKILL.md" if main.exists(): @@ -340,7 +354,14 @@ def _collect_extract_text(provider, model_config, messages) -> tuple[str, dict]: content = "".join(content_parts).strip() reasoning = "".join(reasoning_parts).strip() text = content or reasoning - payload = {"streamed": True, "model": model_config.name, "content": content, "had_reasoning": bool(reasoning)} + payload = { + "streamed": True, + "model": model_config.name, + "content": content, + "reasoning_chars": len(reasoning), + "reasoning_preview": reasoning[:500], # 失败时看一眼模型在想啥/有没有跑题 + "system_chars": sum(len(m.get("content") or "") for m in messages if m.get("role") == "system"), + } return text, payload @@ -446,6 +467,7 @@ def run_extract_entities_task(*, task_id: str) -> None: messages = payload.get("messages") or [] model_config = task.model_config reservation = task.credit_reservation + response: dict = {} # 模型返回的精简存档;失败时也落库(便于事后定位"模型到底吐了啥") try: task.status = AITask.Status.SUBMITTED task.submitted_at = timezone.now() @@ -502,8 +524,9 @@ def run_extract_entities_task(*, task_id: str) -> None: with transaction.atomic(): task.status = AITask.Status.FAILED task.error_message = msg + task.response_payload = response # 即便失败也存下模型输出片段(content/reasoning 长度等),供事后定位 task.completed_at = timezone.now() - task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) + task.save(update_fields=["status", "response_payload", "error_message", "completed_at", "updated_at"]) release_credit(reservation=reservation, reason=msg) diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index f974e01..5f2375f 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -1,10 +1,35 @@ import json +from pathlib import Path +from django.conf import settings from django.test import SimpleTestCase from apps.ai.script_agent import normalize_draft +class SkillPromptBundlingTests(SimpleTestCase): + """守护「skills 必须随后端打进镜像」这条线 —— 历史上 skills 放仓库根、不在 Docker 构建上下文 + (`./core/backend`)→ 镜像里没有 → 提示词加载为空 → 提取模型收不到「只输出 JSON」铁律 → 吐散文 + → 「提取结果解析失败」。这两条断言锁死:① skills 落在 BASE_DIR 内(随镜像走);② 提示词非空且带铁律。""" + + def test_skills_dir_bundled_under_base_dir(self): + """skills 必须在 BASE_DIR(=core/backend)内,才会被 Dockerfile 的 `COPY . .` 打进镜像。""" + for name in ("ecommerce-entity-extract", "ecommerce-video-script"): + self.assertTrue( + (Path(settings.BASE_DIR) / "skills" / name / "SKILL.md").exists(), + f"skills/{name}/SKILL.md 不在 BASE_DIR 内 —— 镜像将丢失该提示词(详见 services._skills_root)", + ) + + def test_entity_extract_prompt_loads_nonempty_with_json_rule(self): + """提取系统提示词必须加载到非空内容,且含「只输出 JSON」铁律(空 → 模型不吐 JSON → 解析失败)。""" + from apps.ai.services import _load_skill_system_prompt + + prompt = _load_skill_system_prompt("ecommerce-entity-extract") + self.assertGreater(len(prompt), 1000, "提取提示词为空/过短 —— skills 没被正确加载") + self.assertIn("JSON", prompt) + self.assertIn("entities", prompt) + + class NormalizeDraftTests(SimpleTestCase): """normalize_draft 对模型不按契约输出的容错(防「旁白/画面全空」回归)。""" diff --git a/skills/ecommerce-entity-extract/SKILL.md b/core/backend/skills/ecommerce-entity-extract/SKILL.md similarity index 100% rename from skills/ecommerce-entity-extract/SKILL.md rename to core/backend/skills/ecommerce-entity-extract/SKILL.md diff --git a/skills/ecommerce-entity-extract/references/examples.md b/core/backend/skills/ecommerce-entity-extract/references/examples.md similarity index 100% rename from skills/ecommerce-entity-extract/references/examples.md rename to core/backend/skills/ecommerce-entity-extract/references/examples.md diff --git a/skills/ecommerce-video-script/SKILL.md b/core/backend/skills/ecommerce-video-script/SKILL.md similarity index 100% rename from skills/ecommerce-video-script/SKILL.md rename to core/backend/skills/ecommerce-video-script/SKILL.md diff --git a/skills/ecommerce-video-script/references/category-playbook.md b/core/backend/skills/ecommerce-video-script/references/category-playbook.md similarity index 100% rename from skills/ecommerce-video-script/references/category-playbook.md rename to core/backend/skills/ecommerce-video-script/references/category-playbook.md diff --git a/skills/ecommerce-video-script/references/checklist.md b/core/backend/skills/ecommerce-video-script/references/checklist.md similarity index 100% rename from skills/ecommerce-video-script/references/checklist.md rename to core/backend/skills/ecommerce-video-script/references/checklist.md diff --git a/skills/ecommerce-video-script/references/hook-library.md b/core/backend/skills/ecommerce-video-script/references/hook-library.md similarity index 100% rename from skills/ecommerce-video-script/references/hook-library.md rename to core/backend/skills/ecommerce-video-script/references/hook-library.md diff --git a/skills/ecommerce-video-script/references/methodology.md b/core/backend/skills/ecommerce-video-script/references/methodology.md similarity index 100% rename from skills/ecommerce-video-script/references/methodology.md rename to core/backend/skills/ecommerce-video-script/references/methodology.md diff --git a/skills/ecommerce-video-script/references/platform-tone.md b/core/backend/skills/ecommerce-video-script/references/platform-tone.md similarity index 100% rename from skills/ecommerce-video-script/references/platform-tone.md rename to core/backend/skills/ecommerce-video-script/references/platform-tone.md