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
@@ -36,6 +36,11 @@ def seed(apps, schema_editor):
if not tokenssr.base_url:
tokenssr.base_url = TOKENSSR_BASE_URL
tokenssr.save(update_fields=["base_url"])
# down→up 循环后 get_or_create 命中已存在(可能 disabled)的 provider,不会用 defaults 覆盖 status,
# 必须显式确保 active,否则 get_default_model(要求 provider__status=active)选不到 tokenssr 模型。
if tokenssr.status != "active":
tokenssr.status = "active"
tokenssr.save(update_fields=["status"])
for name, display, meta in TEXT_MODELS:
ModelConfig.objects.update_or_create(
@@ -64,8 +69,10 @@ def seed(apps, schema_editor):
},
)
# 图像主力切到 tokenssr:gpt-image-2;停用只能纯文生图的 yunqi:gpt-image-2(参考图分镜要靠 tokenssr)
ModelConfig.objects.filter(provider__name="yunqi", capability="image").update(status="disabled")
# 图像主力切到 tokenssr:gpt-image-2(唯一 active 图像模型)。
# 必须停用**所有**非 tokenssr 图像模型 —— 否则 catalog bootstrap 种的火山 Seedream(active 且 created_at 更早)
# 会被 get_default_model(order_by created_at)选中,参考图分镜/模特库(需 image_edit)全部被架空。
ModelConfig.objects.filter(capability="image").exclude(provider__name="tokenssr").update(status="disabled")
def unseed(apps, schema_editor):
@@ -73,7 +80,8 @@ def unseed(apps, schema_editor):
ModelProvider = apps.get_model("ai", "ModelProvider")
ModelConfig = apps.get_model("ai", "ModelConfig")
ModelConfig.objects.filter(provider__name="tokenssr").update(status="disabled")
ModelConfig.objects.filter(provider__name="yunqi", capability="image").update(status="active")
# 恢复被本迁移停用的非 tokenssr 图像模型(火山 Seedream / yunqi)
ModelConfig.objects.filter(capability="image").exclude(provider__name="tokenssr").update(status="active")
ModelProvider.objects.filter(name="tokenssr").update(status="disabled")
+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:
+7 -7
View File
@@ -60,11 +60,12 @@ def resolve_provider_credentials(provider) -> tuple[str | None, str | None]:
def build_provider(model_config: ModelConfig):
"""按 provider.name 分流:火山官方直连 → VolcanoArkProvider;其余 → 通用 OpenAICompatibleProvider。"""
"""按 provider.name 分流:火山官方直连 → VolcanoArkProvider;其余 → 通用 OpenAICompatibleProvider。
两条路都走 resolve_provider_credentials,统一 DB→.env 优先级(官方直连的 None 再由 __post_init__ 回退 settings.VOLCANO)。"""
provider = model_config.provider
if provider.name in OFFICIAL_DIRECT_PROVIDERS:
return VolcanoArkProvider(base_url=provider.base_url or None)
base_url, api_key = resolve_provider_credentials(provider)
if provider.name in OFFICIAL_DIRECT_PROVIDERS:
return VolcanoArkProvider(base_url=base_url, api_key=api_key)
return OpenAICompatibleProvider(base_url=base_url, api_key=api_key)
@@ -768,10 +769,9 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
refs = _storyboard_reference_images(project, segment)
ref_urls = [r["url"] for r in refs]
if ref_urls and hasattr(provider, "image_edit"):
# gpt-image-2 多图参考:把角色/场景/商品合成进本镜(@图1@图2@图3),锁脸锁外观保一致
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt_refs(
project, version, segment, refs
)
# gpt-image-2 多图参考:必须用 refs 版提示词(点名「参考图N=角色/场景/商品」+锁脸锁商品),
# 不能复用 request_payload['prompt'](那是建任务时写死的基础提示词,恒为真值会架空一致性约束)。
frame_prompt = build_storyboard_frame_prompt_refs(project, version, segment, refs)
response = provider.image_edit(
model=model_config.name,
prompt=frame_prompt,