From df2821e1f8ae00c3f1f5728dd7c7df559f535160 Mon Sep 17 00:00:00 2001 From: liang007 Date: Tue, 1 Sep 2026 17:32:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E5=85=BC=E5=AE=B9=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E7=9A=84=E5=A4=9A=E7=A7=8D=20JSON=20?= =?UTF-8?q?=E9=A1=B6=E5=B1=82=E5=BD=A2=E6=80=81=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E5=8D=A1=E4=B8=8E=E5=88=86=E9=95=9C=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E8=AF=AF=E6=8A=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部分模型不遵守 JSON Schema:直接返回顶层数组、数组外再包一层、每个角色单独包一层 characters、或正文先列出角色名数组再给出契约对象。原解析只取第一个可解析 JSON 值,容易命中旁枝片段并被判为解析失败。 - prompt_template.go: extractPreferredJSONText 优先选取包含 preferKey 的顶层对象候选;角色卡校验按形态归一处理,结果为空仍提示未识别到角色,字段缺失报出具体角色序号与字段名 - storyboard.go: normalizeStoryboardRootJSON 兼容单元素 plan 数组解包与镜头对象数组自动包装为完整 plan - canvas-character-reference.ts: 前端角色卡提取同步统一归一逻辑 - 补充前后端形态兼容回归测试与 CHANGELOG --- CHANGELOG.md | 2 + backend/internal/service/prompt_template.go | 138 +++++++++++++++++- .../service/prompt_template_json_test.go | 69 ++++++++- backend/internal/service/storyboard.go | 72 +++++++++ .../lib/canvas/canvas-character-reference.ts | 32 +++- web/test/canvas-character-reference.test.ts | 61 ++++++++ 6 files changed, 369 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1932c76c6..2adfe4247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 修复角色卡提取在模型直接返回角色数组、数组外再包一层、每个角色单独包一层 `characters` 或正文先列出角色名数组时被判为 JSON 解析失败的问题,前后端统一按形态归一处理;结果确实为空仍提示未识别到角色,字段缺失仍报出具体角色序号与字段名。 + ## v1.2.3.1 - 新增密码找回流程,支持忘记密码申请、邮件验证码校验与密码重置,并补齐后台邮件配置提示、接口、数据结构和自动化测试。 diff --git a/backend/internal/service/prompt_template.go b/backend/internal/service/prompt_template.go index 9793c9963..f2e095764 100644 --- a/backend/internal/service/prompt_template.go +++ b/backend/internal/service/prompt_template.go @@ -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 { diff --git a/backend/internal/service/prompt_template_json_test.go b/backend/internal/service/prompt_template_json_test.go index 79d76bf93..aeb14a60a 100644 --- a/backend/internal/service/prompt_template_json_test.go +++ b/backend/internal/service/prompt_template_json_test.go @@ -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) + } +} diff --git a/backend/internal/service/storyboard.go b/backend/internal/service/storyboard.go index 17bc87701..728904cf7 100644 --- a/backend/internal/service/storyboard.go +++ b/backend/internal/service/storyboard.go @@ -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 diff --git a/web/src/lib/canvas/canvas-character-reference.ts b/web/src/lib/canvas/canvas-character-reference.ts index c22aad1e8..1a3772efa 100644 --- a/web/src/lib/canvas/canvas-character-reference.ts +++ b/web/src/lib/canvas/canvas-character-reference.ts @@ -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).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); + 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(); diff --git a/web/test/canvas-character-reference.test.ts b/web/test/canvas-character-reference.test.ts index 26fd85406..9d55b9e5c 100644 --- a/web/test/canvas-character-reference.test.ts +++ b/web/test/canvas-character-reference.test.ts @@ -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(/林夏/); + }); +});