fix(core): 对抗式交叉验证发现的 4 个 critical + 关键 major

- 额度泄漏(critical):流式 agent 客户端断连抛 GeneratorExit(BaseException)逃过 except,
  预扣额度冻结。改 try/finally + settled 标志兜底释放。
- 默认图像模型(critical):0006 只停用 yunqi,没停火山 Seedream(bootstrap 种入 active 且更早),
  get_default_model 仍选 Seedream → 参考图分镜/模特库被架空。改为停用所有非 tokenssr 图像模型 +
  provider down→up 自愈置 active。
- 故事板参考提示词(critical):image_edit 分支复用 request_payload['prompt'](恒真)→ refs 版
  锁脸锁商品提示词成死代码,参考图白传。改为强制用 refs 版提示词。
- 凭证解析(major):build_provider 官方直连分支丢了 DB api_key,与中转站路径优先级不一致。
  统一走 resolve_provider_credentials。
- JSON 抽取(major):_extract_json 取首个围栏块(易抓示例块)+ rfind('}') 越界。改为取末个
  合法草稿块 + 字符串感知的括号配平扫描。
- 前端兜底(major):流式已成功(saved)但收尾抖动时 catch 仍回退旧端点 → 重复生成+扣费。
  改为按 ok/收到事件 判定,只在零事件时回退。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-17 03:21:06 +08:00
co-authored by Claude Opus 4.8
parent 6464001f84
commit a625f86b38
4 changed files with 155 additions and 83 deletions
+125 -70
View File
@@ -137,15 +137,59 @@ def build_agent_messages(
# --------------------------------------------------------------------------- #
# JSON 抽取 + 契约规范化(模型无关,后端兜底)
# --------------------------------------------------------------------------- #
def _extract_json(text: str) -> str | None:
fenced = re.search(r"```(?:json)?\s*(.+?)```", text, re.DOTALL)
candidate = fenced.group(1) if fenced else text
start, end = candidate.find("{"), candidate.rfind("}")
if start != -1 and end != -1 and end > start:
return candidate[start : end + 1]
def _balanced_object(text: str) -> str | None:
"""从首个 '{' 起按括号深度扫描,返回第一个配平的 {...}(忽略字符串内的括号)。
避免 rfind('}') 在 JSON 后还有含花括号的散文时越界截出非法片段。"""
start = text.find("{")
if start == -1:
return None
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
c = text[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
continue
if c == '"':
in_str = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return None
def _looks_like_draft(blob: str) -> bool:
try:
d = json.loads(blob)
except (ValueError, TypeError):
return False
return isinstance(d, dict) and ("segments" in d or "hook" in d)
def _extract_json(text: str) -> str | None:
"""抽取 ScriptDraft JSON。容错:模型可能先给示例 ```json 块再给正式块,
故取**最后一个**含 segments/hook 的合法围栏块;都不像草稿再退而取末个配平对象;无围栏再裸扫。"""
fences = re.findall(r"```(?:json)?\s*(.+?)```", text, re.DOTALL)
for block in reversed(fences):
obj = _balanced_object(block)
if obj and _looks_like_draft(obj):
return obj
for block in reversed(fences):
obj = _balanced_object(block)
if obj:
return obj
return _balanced_object(text)
def _nearest_duration(value) -> int:
try:
value = int(value)
@@ -381,69 +425,81 @@ def stream_script_agent(
yield _sse({"type": "error", "detail": f"任务创建失败(可能额度不足):{exc}"})
return
reservation = task.credit_reservation
yield _sse({"type": "tool", "id": "generate", "label": "按黄金结构生成分镜", "status": "running"})
full: list[str] = []
shown = 0
forwarding = True
# 额度是否已结算(charge 成功 / release 失败)。客户端中途断连时,生成器被 .close() 抛
# GeneratorExit —— 它是 BaseException 不是 Exception,普通 except 抓不到,会让预扣额度冻结。
# 故用 try/finally 兜底:任何未结算路径(含断连)都释放预扣。
settled = False
try:
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = build_provider(model_config)
for ev in provider.chat_completion_stream(
model=model_config.name,
endpoint=model_config.endpoint,
messages=messages,
temperature=0.85,
):
if ev.get("type") == "delta":
full.append(ev["text"])
if forwarding:
text = "".join(full)
cut = _visible_cut(text)
if cut < len(text):
forwarding = False
visible = text[:cut]
if len(visible) > shown:
piece = visible[shown:]
shown = len(visible)
if piece.strip():
yield _sse({"type": "delta", "text": piece})
elif ev.get("type") == "done":
break
raw = "".join(full)
draft = normalize_draft(raw, aspect_ratio=aspect_ratio, total_duration=total_duration)
except Exception as exc: # noqa: BLE001
_fail_task(task, reservation, str(exc))
yield _sse({"type": "tool", "id": "generate", "status": "error"})
yield _sse({"type": "error", "detail": f"脚本生成失败:{exc}"})
return
yield _sse({"type": "tool", "id": "generate", "label": "按黄金结构生成分镜", "status": "running"})
full: list[str] = []
shown = 0
forwarding = True
try:
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = build_provider(model_config)
for ev in provider.chat_completion_stream(
model=model_config.name,
endpoint=model_config.endpoint,
messages=messages,
temperature=0.85,
):
if ev.get("type") == "delta":
full.append(ev["text"])
if forwarding:
text = "".join(full)
cut = _visible_cut(text)
if cut < len(text):
forwarding = False
visible = text[:cut]
if len(visible) > shown:
piece = visible[shown:]
shown = len(visible)
if piece.strip():
yield _sse({"type": "delta", "text": piece})
elif ev.get("type") == "done":
break
raw = "".join(full)
draft = normalize_draft(raw, aspect_ratio=aspect_ratio, total_duration=total_duration)
except Exception as exc: # noqa: BLE001
_fail_task(task, reservation, str(exc))
settled = True
yield _sse({"type": "tool", "id": "generate", "status": "error"})
yield _sse({"type": "error", "detail": f"脚本生成失败:{exc}"})
return
yield _sse({"type": "tool", "id": "generate", "status": "done"})
yield _sse(
{
"type": "tool",
"id": "extract",
"label": f"提取实体 {len(draft['entities'])} 个 · {len(draft['segments'])} 镜",
"status": "done",
}
)
yield _sse({"type": "tool", "id": "check", "label": "自检:镜数 / ≤55字 / 违规词", "status": "done"})
yield _sse({"type": "draft", "draft": draft})
yield _sse({"type": "tool", "id": "generate", "status": "done"})
yield _sse(
{
"type": "tool",
"id": "extract",
"label": f"提取实体 {len(draft['entities'])} 个 · {len(draft['segments'])} 镜",
"status": "done",
}
)
yield _sse({"type": "tool", "id": "check", "label": "自检:镜数 / ≤55字 / 违规词", "status": "done"})
yield _sse({"type": "draft", "draft": draft})
try:
from django.db import transaction
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = {"raw": raw[:8000]}
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
source = "revise" if mode == "revise" else ("theme" if mode == "theme" else "ai")
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source=source)
try:
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = {"raw": raw[:8000]}
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
source = "revise" if mode == "revise" else ("theme" if mode == "theme" else "ai")
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source=source)
settled = True # charge 已提交
except Exception as exc: # noqa: BLE001 — 落库失败:atomic 已回滚 charge,补释放预留
_fail_task(task, reservation, f"保存脚本失败:{exc}")
settled = True
yield _sse({"type": "error", "detail": f"保存脚本失败:{exc}"})
return
from apps.projects.serializers import ScriptVersionSerializer
yield _sse(
@@ -453,12 +509,11 @@ def stream_script_agent(
"version": ScriptVersionSerializer(script).data,
}
)
except Exception as exc: # noqa: BLE001 — 落库失败:回滚已撤销扣费,补释放预留
_fail_task(task, reservation, f"保存脚本失败:{exc}")
yield _sse({"type": "error", "detail": f"保存脚本失败:{exc}"})
return
yield _sse({"type": "done"})
yield _sse({"type": "done"})
finally:
# 断连(GeneratorExit)或任何 settled=False 的退出路径:释放预扣,避免额度冻结
if not settled:
_fail_task(task, reservation, "stream aborted (client disconnected)")
def _fail_task(task, reservation, message: str) -> None: