fix(ai): 模型解析 - 兼容多种 JSON 顶层形态
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user