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

@@ -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