fix(skills): skills 随后端打进镜像,根治线上提取「未返回有效 JSON」(系统提示词为空)

真因(查 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) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-23 00:12:46 +08:00
co-authored by Claude Opus 4.8
parent c8cb6d3d51
commit 8dab8b4921
12 changed files with 62 additions and 7 deletions
+28 -5
View File
@@ -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/<name>/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/<name>/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)