fix(ai): 模型解析 - 兼容多种 JSON 顶层形态

This commit is contained in:
ddCat
2026-09-01 20:57:36 +08:00
6 changed files with 368 additions and 5 deletions

View File

@@ -5,6 +5,7 @@
## v1.2.4.1
- 修复资源上传幂等字段未纳入独立数据库迁移的问题,升级已有 v3 数据库时自动补齐 `resources.upload_key` 及用户维度唯一索引,避免生成内容写入资源存储失败。
- 修复角色卡提取在模型直接返回角色数组、数组外再包一层、每个角色单独包一层 `characters` 或正文先列出角色名数组时被判为 JSON 解析失败的问题,前后端统一按形态归一处理;结果确实为空仍提示未识别到角色,字段缺失仍报出具体角色序号与字段名。
## v1.2.4

View File

@@ -1,6 +1,7 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -378,13 +379,20 @@ func validatePromptTemplateResult(operation string, result map[string]interface{
return nil
}
text, _ := result["text"].(string)
jsonText, err := extractJSONText(text)
extractor := extractJSONText
// 角色卡契约要求顶层对象,模型正文里若先出现数组(例如先列一遍角色名)会让通用提取命中错误片段,
// 因此这里优先挑出带 characters 键的对象候选。
if operation == promptOperationCharacterExtract {
extractor = func(raw string) (string, error) { return extractPreferredJSONText(raw, "characters") }
}
jsonText, err := extractor(text)
if err != nil {
return fmt.Errorf("%s 返回内容不符合受保护 JSON 契约:%w", definition.Label, err)
}
if operation != promptOperationCharacterExtract {
return nil
}
jsonText = normalizeCharacterBreakdownRootJSON(jsonText)
var payload struct {
Characters *[]map[string]interface{} `json:"characters"`
}
@@ -395,6 +403,9 @@ func validatePromptTemplateResult(operation string, result map[string]interface{
if payload.Characters == nil {
return errors.New("角色卡提取结果缺少 characters 数组")
}
if len(*payload.Characters) == 0 {
return errors.New("角色卡提取结果没有识别到任何角色,请调整正文或重新提取")
}
for index, character := range *payload.Characters {
for _, field := range required {
if _, exists := character[field]; !exists {
@@ -405,6 +416,131 @@ func validatePromptTemplateResult(operation string, result map[string]interface{
return nil
}
// normalizeCharacterBreakdownRootJSON 把模型返回的角色卡结果收敛为 character-breakdown/v1 的
// {"characters": [...]} 形态。模型并不总是遵守顶层 object 契约,实测出现的偏离包括:
// - 顶层直接返回角色数组([ { name, role, ... }, ... ]);
// - 数组外再包一层([[ {...} ]]);
// - 每个角色单独包一层 characters([ {"characters":[...]}, {"characters":[...]} ]);
// - 数组里混入非角色对象或 prose 片段([ {"index":1}, {...} ])。
//
// 这里只做形态归一,字段完整性仍由 validatePromptTemplateResult 的必填校验负责,
// 因此收集阶段按宽松规则保留候选,再由必填校验给出“第 N 个角色缺少字段 X”的可读提示。
func normalizeCharacterBreakdownRootJSON(jsonText string) string {
trimmed := strings.TrimSpace(jsonText)
if trimmed == "" {
return trimmed
}
if trimmed[0] != '[' {
return normalizeCharacterBreakdownObject(trimmed)
}
var root []json.RawMessage
if err := json.Unmarshal([]byte(trimmed), &root); err != nil {
return trimmed
}
return marshalCharacterBreakdown(collectCharacterCards(root))
}
// normalizeCharacterBreakdownObject 处理顶层已是对象的情况,只把 characters 收敛成数组。
func normalizeCharacterBreakdownObject(jsonText string) string {
var obj map[string]json.RawMessage
if err := json.Unmarshal([]byte(jsonText), &obj); err != nil {
return jsonText
}
raw, ok := obj["characters"]
if !ok {
return jsonText
}
var cards []json.RawMessage
if err := json.Unmarshal(raw, &cards); err == nil {
// characters 已经位于契约位置,这里只摊平嵌套,不过滤元素:
// 缺少 name 的角色要留给必填校验报出具体字段,而不是被静默丢弃。
obj["characters"] = marshalRawMessageArray(flattenCharacterCardValues(cards))
} else {
// characters 被写成以角色名为键的对象时降级为取值列表。
var indexed map[string]json.RawMessage
if err := json.Unmarshal(raw, &indexed); err != nil {
return jsonText
}
list := make([]json.RawMessage, 0, len(indexed))
for _, value := range indexed {
list = append(list, value)
}
obj["characters"] = marshalRawMessageArray(list)
}
encoded, err := json.Marshal(obj)
if err != nil {
return jsonText
}
return string(encoded)
}
// collectCharacterCards 递归展开顶层数组,收集所有可辨认的角色卡对象。
// 顶层数组无法区分“角色卡列表”和模型正文里的旁枝数组,因此只保留带 name 的候选。
func collectCharacterCards(values []json.RawMessage) []json.RawMessage {
result := make([]json.RawMessage, 0, len(values))
for _, raw := range flattenCharacterCardValues(values) {
if looksLikeCharacterCard(raw) {
result = append(result, raw)
}
}
return result
}
// flattenCharacterCardValues 递归摊平嵌套数组与 {"characters": [...]} 包装,不判断元素是否是角色。
func flattenCharacterCardValues(values []json.RawMessage) []json.RawMessage {
result := make([]json.RawMessage, 0, len(values))
for _, raw := range values {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 {
continue
}
switch trimmed[0] {
case '[':
var nested []json.RawMessage
if json.Unmarshal(trimmed, &nested) == nil {
result = append(result, flattenCharacterCardValues(nested)...)
}
case '{':
var obj map[string]json.RawMessage
if json.Unmarshal(trimmed, &obj) != nil {
continue
}
if inner, ok := obj["characters"]; ok {
var cards []json.RawMessage
if json.Unmarshal(inner, &cards) == nil {
result = append(result, flattenCharacterCardValues(cards)...)
continue
}
}
result = append(result, trimmed)
}
}
return result
}
func marshalCharacterBreakdown(cards []json.RawMessage) string {
return `{"characters":` + string(marshalRawMessageArray(cards)) + `}`
}
func marshalRawMessageArray(values []json.RawMessage) json.RawMessage {
encoded, err := json.Marshal(values)
if err != nil {
return json.RawMessage("[]")
}
return encoded
}
// looksLikeCharacterCard 只判断“是否为一个角色对象”的最小特征。带 name 的对象即视为候选,
// 缺少其余字段时交由必填校验报出具体字段名,而不是在形态归一阶段被静默丢弃。
func looksLikeCharacterCard(raw json.RawMessage) bool {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return false
}
_, hasName := obj["name"]
return hasName
}
func uniqueStrings(values []string) []string {
result := make([]string, 0, len(values))
for _, value := range values {

View File

@@ -1,6 +1,9 @@
package service
import "testing"
import (
"strings"
"testing"
)
func TestExtractJSONTextSkipsProseAndMarkdownFence(t *testing.T) {
raw := "先说明一下,{这不是有效 JSON}。\n```json\n{\"characters\":[{\"name\":\"林夏\",\"aliases\":[]}]}\n```\n"
@@ -25,3 +28,67 @@ func TestExtractJSONTextHandlesBracesInJSONString(t *testing.T) {
t.Fatalf("extractJSONText() = %q, want %q", got, want)
}
}
const characterCardJSON = `{"name":"林夏","aliases":["夏夏"],"role":"女主角","appearance":"短发","clothing":"白衬衫","physique":"纤细","personality":"冷静","props":"小夜灯","consistencyPrompt":"c","multiViewPrompt":"m","voiceLanguage":"中文","voiceAge":"青年","voiceTimbre":"清亮"}`
// TestValidatePromptTemplateResultCharacterRootShapes 覆盖模型返回角色卡时的各种顶层形态。
// 这些形态都违反 character-breakdown/v1 的顶层 object 契约,但内容本身可用,应当被接受;
// 真正无内容或缺字段的输入则必须报出可读的业务错误,而不是 Go 的类型错误。
func TestValidatePromptTemplateResultCharacterRootShapes(t *testing.T) {
cases := []struct {
name string
text string
wantErr string
}{
{name: "标准对象形态", text: `{"characters":[` + characterCardJSON + `]}`},
{name: "顶层角色卡数组", text: `[` + characterCardJSON + `]`},
{name: "单元素包 characters", text: `[{"characters":[` + characterCardJSON + `]}]`},
{name: "多元素各包 characters", text: `[{"characters":[` + characterCardJSON + `]},{"characters":[` + characterCardJSON + `]}]`},
{name: "数组外再包一层", text: `[[` + characterCardJSON + `]]`},
{name: "数组混入非角色对象", text: `[{"index":1},` + characterCardJSON + `]`},
{name: "markdown 代码块包裹数组", text: "```json\n[" + characterCardJSON + "]\n```"},
{name: "正文先列角色名数组", text: "本章角色:[\"林夏\",\"陈默\"]\n\n{\"characters\":[" + characterCardJSON + "]}"},
{name: "characters 写成以角色名为键的对象", text: `{"characters":{"林夏":` + characterCardJSON + `}}`},
{name: "空数组", text: `[]`, wantErr: "没有识别到任何角色"},
{name: "不相关数组", text: `["林夏","陈默"]`, wantErr: "没有识别到任何角色"},
{name: "角色缺少必填字段", text: `[{"name":"林夏"}]`, wantErr: "第 1 个角色缺少字段 aliases"},
// characters 位于契约位置时不做元素过滤,缺 name 也要报出具体字段而不是被当成空结果。
{name: "契约位置内角色缺少 name", text: `{"characters":[{"role":"女主角"}]}`, wantErr: "第 1 个角色缺少字段 name"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validatePromptTemplateResult(promptOperationCharacterExtract, map[string]interface{}{"text": tc.text})
if tc.wantErr == "" {
if err != nil {
t.Fatalf("validatePromptTemplateResult() error = %v, want nil", err)
}
return
}
if err == nil {
t.Fatalf("validatePromptTemplateResult() error = nil, want %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("validatePromptTemplateResult() error = %q, want containing %q", err.Error(), tc.wantErr)
}
// 形态问题必须收敛成业务语言,不能把 Go 的类型错误抛给用户。
if strings.Contains(err.Error(), "cannot unmarshal") {
t.Fatalf("validatePromptTemplateResult() leaked Go type error: %v", err)
}
})
}
}
// TestValidatePromptTemplateResultScopesCharacterRelaxation 确认形态放宽只作用于角色卡提取。
// 其余 JSON 操作只要求能取出一个合法 JSON 值,不套用 characters 契约,因此顶层数组对它们依旧无意义。
func TestValidatePromptTemplateResultScopesCharacterRelaxation(t *testing.T) {
if err := validatePromptTemplateResult(promptOperationStoryboardPlan, map[string]interface{}{"text": `[{"name":"林夏"}]`}); err != nil {
t.Fatalf("validatePromptTemplateResult() error = %v, want nil for non-character JSON operation", err)
}
if err := validatePromptTemplateResult(promptOperationStoryboardPlan, map[string]interface{}{"text": "完全没有 JSON 内容"}); err == nil {
t.Fatal("validatePromptTemplateResult() error = nil, want contract error for non-character operation")
}
err := validatePromptTemplateResult(promptOperationCharacterExtract, map[string]interface{}{"text": "完全没有 JSON 内容"})
if err == nil || !strings.Contains(err.Error(), "受保护 JSON 契约") {
t.Fatalf("validatePromptTemplateResult() error = %v, want contract error for character extract", err)
}
}

View File

@@ -96,6 +96,7 @@ func parseAgentStoryboardPlan(raw string) (agentStoryboardPlan, error) {
if err != nil {
return agentStoryboardPlan{}, err
}
jsonText = normalizeStoryboardRootJSON(jsonText)
if err := validateStoryboardJSONFields(jsonText); err != nil {
return agentStoryboardPlan{}, err
}
@@ -144,6 +145,43 @@ func parseAgentStoryboardPlan(raw string) (agentStoryboardPlan, error) {
return plan, nil
}
// normalizeStoryboardRootJSON 兼容部分模型不遵守 object schema、把结果作为顶层数组返回的情况。
// - 单元素对象数组(如 [ { ...plan } ])解包为对象;
// - 直接返回镜头对象数组(如 [ { ...shot }, ... ])包装成完整 plan,缺省字段由后续解析兜底填充。
func normalizeStoryboardRootJSON(jsonText string) string {
trimmed := strings.TrimSpace(jsonText)
if trimmed == "" || trimmed[0] != '[' {
return trimmed
}
var arr []json.RawMessage
if err := json.Unmarshal([]byte(trimmed), &arr); err != nil || len(arr) == 0 {
return trimmed
}
// 优先识别镜头对象数组(无论单/多元素)→ 包装成完整 plan,缺省字段由后续解析兜底。
if looksLikeStoryboardShot(arr[0]) {
return `{"title":"","logline":"","styleGuide":"","characters":[],"locations":[],"shots":` + trimmed + `}`
}
// 单元素对象数组(完整 plan 对象)→ 解包。
if len(arr) == 1 {
elem := strings.TrimSpace(string(arr[0]))
if strings.HasPrefix(elem, "{") {
return elem
}
}
return trimmed
}
func looksLikeStoryboardShot(raw json.RawMessage) bool {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return false
}
_, hasVisual := obj["visualPrompt"]
_, hasDuration := obj["durationSeconds"]
_, hasDescription := obj["description"]
return hasVisual || (hasDuration && hasDescription)
}
func validateStoryboardJSONFields(jsonText string) error {
var root map[string]json.RawMessage
if err := json.Unmarshal([]byte(jsonText), &root); err != nil {
@@ -491,6 +529,40 @@ func extractJSONText(raw string) (string, error) {
return "", errors.New("模型返回的不是 JSON")
}
// extractPreferredJSONText 与 extractJSONText 一样逐个扫描完整的 JSON 值,但会优先返回顶层对象且
// 包含 preferKey 的候选。模型常在给出契约对象前先用正文列举一遍内容(例如先写一段角色名数组),
// 只取第一个可解析值会命中这些旁枝片段,导致后续校验拿到完全无关的结构。
// 找不到偏好候选时回退到第一个可解析值,保证与 extractJSONText 的默认行为一致。
func extractPreferredJSONText(raw string, preferKey string) (string, error) {
fallback := ""
for start := 0; start < len(raw); start++ {
if raw[start] != '{' && raw[start] != '[' {
continue
}
end := jsonValueEnd(raw, start)
if end < start {
continue
}
candidate := raw[start : end+1]
var decoded interface{}
if json.Unmarshal([]byte(candidate), &decoded) != nil {
continue
}
if obj, ok := decoded.(map[string]interface{}); ok {
if _, ok := obj[preferKey]; ok {
return candidate, nil
}
}
if fallback == "" {
fallback = candidate
}
}
if fallback != "" {
return fallback, nil
}
return "", errors.New("模型返回的不是 JSON")
}
func jsonValueEnd(source string, start int) int {
stack := make([]byte, 0, 8)
inString := false

View File

@@ -108,6 +108,30 @@ function findJsonValueEnd(source: string, start: number) {
return -1;
}
// 角色卡数组的最小特征:至少有一个元素带 name 字段。用于把真正的角色数组和模型正文里的
// 旁枝数组(角色名列表、aliases 片段等)区分开,避免提取阶段命中错误片段。
function isCharacterCardArray(value: unknown): value is unknown[] {
if (!Array.isArray(value) || !value.length) return false;
return value.some((item) => Boolean(item) && typeof item === "object" && typeof (item as Record<string, unknown>).name === "string");
}
// 模型偶尔把角色数组再包一层,或把 characters 写成以角色名为键的对象,这里统一摊平成候选列表。
function flattenCharacterCandidates(value: unknown): unknown[] | undefined {
if (Array.isArray(value)) {
const result: unknown[] = [];
value.forEach((item) => {
if (Array.isArray(item)) {
result.push(...(flattenCharacterCandidates(item) ?? []));
return;
}
result.push(item);
});
return result;
}
if (value && typeof value === "object") return Object.values(value as Record<string, unknown>);
return undefined;
}
function extractCharacterBreakdownJson(raw: string) {
for (let start = 0; start < raw.length; start += 1) {
if (raw[start] !== "{" && raw[start] !== "[") continue;
@@ -115,9 +139,11 @@ function extractCharacterBreakdownJson(raw: string) {
if (end < start) continue;
try {
const parsed: unknown = JSON.parse(raw.slice(start, end + 1));
// 顶层角色数组是常见的契约偏离,先判数组再判对象,两者都视为合法载荷。
if (isCharacterCardArray(parsed)) return parsed;
// 模型的推理文字可能包含 aliases: [] 等合法 JSON 片段;角色契约只接受带 characters 字段的对象。
const candidates = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as { characters?: unknown }).characters : undefined;
if (Array.isArray(candidates)) return parsed;
const candidates = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? flattenCharacterCandidates((parsed as { characters?: unknown }).characters) : undefined;
if (Array.isArray(candidates) && candidates.length) return parsed;
} catch {
// Ignore unrelated braces in model prose and continue to the next complete JSON value.
}
@@ -128,7 +154,7 @@ function extractCharacterBreakdownJson(raw: string) {
export function parseCharacterBreakdown(raw: string): CharacterBreakdown[] {
const unfenced = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
const parsed = extractCharacterBreakdownJson(unfenced);
const candidates = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" ? (parsed as { characters?: unknown }).characters : undefined;
const candidates = Array.isArray(parsed) ? flattenCharacterCandidates(parsed) : flattenCharacterCandidates((parsed as { characters?: unknown }).characters);
if (!Array.isArray(candidates)) throw new Error("角色拆解结果缺少 characters 数组");
const seen = new Set<string>();

View File

@@ -43,3 +43,64 @@ describe("parseCharacterBreakdown", () => {
expect(parseCharacterBreakdown(result).map((character) => character.name)).toEqual(["林夏", "吴奶奶"]);
});
});
const characterCard = {
name: "林夏",
aliases: ["夏夏"],
role: "女主角,加班晚归的都市上班族",
appearance: "26岁东亚女性,短发",
clothing: "米色风衣",
physique: "年轻女性常规体态",
personality: "善良内敛",
props: "磁吸小夜灯",
consistencyPrompt: "短发,米色风衣",
multiViewPrompt: "正面、侧面、背面保持一致",
voiceLanguage: "中文普通话",
voiceAge: "26岁青年女性",
voiceTimbre: "温和",
};
describe("parseCharacterBreakdown 顶层形态兼容", () => {
test("接受顶层角色数组", () => {
expect(parseCharacterBreakdown(JSON.stringify([characterCard])).map((character) => character.name)).toEqual(["林夏"]);
});
test("接受数组外再包一层", () => {
expect(parseCharacterBreakdown(JSON.stringify([[characterCard]])).map((character) => character.name)).toEqual(["林夏"]);
});
test("接受每个角色单独包一层 characters", () => {
const payload = [{ characters: [characterCard] }];
expect(parseCharacterBreakdown(JSON.stringify(payload)).map((character) => character.name)).toEqual(["林夏"]);
});
test("接受 characters 写成以角色名为键的对象", () => {
const payload = { characters: { 林夏: characterCard } };
expect(parseCharacterBreakdown(JSON.stringify(payload)).map((character) => character.name)).toEqual(["林夏"]);
});
test("跳过数组里的非角色元素", () => {
const payload = [{ index: 1 }, characterCard];
expect(parseCharacterBreakdown(JSON.stringify(payload)).map((character) => character.name)).toEqual(["林夏"]);
});
test("正文先列角色名数组时仍取到最终契约对象", () => {
const result = `本章角色:["林夏","陈默"]\n\n${JSON.stringify({ characters: [characterCard] })}`;
expect(parseCharacterBreakdown(result).map((character) => character.name)).toEqual(["林夏"]);
});
test("顶层数组被 markdown 代码块包裹", () => {
const result = "```json\n" + JSON.stringify([characterCard]) + "\n```";
expect(parseCharacterBreakdown(result).map((character) => character.name)).toEqual(["林夏"]);
});
test("角色名单数组不是角色卡,继续向后寻找契约对象", () => {
const result = `旁白提到了名字:["林夏"]`;
expect(() => parseCharacterBreakdown(result)).toThrow(/符合契约的 JSON/);
});
test("角色缺少设定字段时报出角色名", () => {
const payload = [{ name: "林夏", role: "女主角" }];
expect(() => parseCharacterBreakdown(JSON.stringify(payload))).toThrow(/林夏/);
});
});