diff --git a/cmd/genasset/main.go b/cmd/genasset/main.go index 5f7a431..8e50039 100644 --- a/cmd/genasset/main.go +++ b/cmd/genasset/main.go @@ -26,6 +26,8 @@ import ( // go run ./cmd/genasset --list # 打印生成清单 // go run ./cmd/genasset --only=comments # 只生成对比点评 // go run ./cmd/genasset --only=visuals --strategy=1 # 只生成第 1 计素材 +// go run ./cmd/genasset --only=scripts # 生成剧本草稿(workspace/scripts/) +// go run ./cmd/genasset --import-scripts # 草稿导入回填 scene_node.script // go run ./cmd/genasset --force # 覆盖已有产物 // // 幂等:产物存在即跳过(--force 覆盖);点评按 feedback_pros 是否为空判定。 @@ -37,10 +39,11 @@ var ( ) type genFlags struct { - listOnly bool - only string // comments | visuals - strategy int // 0 = 全部计策 - force bool + listOnly bool + only string // comments | visuals | scripts + strategy int // 0 = 全部计策 + force bool + importScripts bool } type visualItem struct { @@ -65,6 +68,14 @@ func main() { printInventory(fl) return } + if fl.importScripts { + importScripts(fl) + return + } + if fl.only == "scripts" { + genScripts(fl) + return + } if fl.only == "" || fl.only == "comments" { genComments(fl) } @@ -80,6 +91,7 @@ func parseFlags() genFlags { flag.StringVar(&fl.only, "only", "", "comments | visuals,默认都生成") flag.IntVar(&fl.strategy, "strategy", 0, "只处理指定计策(strategy_id),0=全部") flag.BoolVar(&fl.force, "force", false, "覆盖已存在产物") + flag.BoolVar(&fl.importScripts, "import-scripts", false, "从草稿导入剧本到数据库(--strategy 限定计策)") flag.Parse() return fl } @@ -89,6 +101,7 @@ func parseFlags() genFlags { func printInventory(fl genFlags) { comments := pendingComments(fl) fmt.Printf("待生成点评选项:%d(按节点分组 %d 个请求)\n", len(comments), distinctCount(comments)) + fmt.Printf("待生成剧本节点:%d\n", len(pendingScriptNodes(fl))) for _, it := range visualInventory(fl) { fmt.Printf("%-9s %-16s %s\n", it.kind, it.key, it.name) } diff --git a/cmd/genasset/prompts.go b/cmd/genasset/prompts.go index 3afa795..9007316 100644 --- a/cmd/genasset/prompts.go +++ b/cmd/genasset/prompts.go @@ -33,3 +33,17 @@ const commentUser = `情境:%s %s 请为每个选项写「好处 pros」(选它的收获、为什么好)与「坏处 cons」(它的不足;若它是最佳选择可留空或写小小的代价)。` + +const scriptSystem = `你是儿童故事编剧。把下面的闯关情境改写成一场 3-6 句的微型剧情台词流,语言儿童化、口语化、温和,每句 8-30 字(含标点)。 +规则: +1. 至少 1 句旁白交代情境(speaker 为「旁白」),其余为角色台词 +2. speaker 只能是「旁白」或角色名 +3. 决策节点:最后一句必须是角色向孩子提问,以「?」结尾 +4. 终局节点:最后一句是角色的总结或鼓励 +5. 每句可标注情绪 emotion(normal/happy/sad/think/surprise,可不标) +只输出 JSON:{"script": [{"speaker": "旁白", "text": "……"}]}` + +const scriptUser = `情境:%s + +角色:%s +%s` diff --git a/cmd/genasset/scripts.go b/cmd/genasset/scripts.go new file mode 100644 index 0000000..e4ab894 --- /dev/null +++ b/cmd/genasset/scripts.go @@ -0,0 +1,265 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "unicode/utf8" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" + + "36wisdom/biz/consts" + "36wisdom/common" +) + +// scriptLine 台词流单句(导入时补 pinyin) +type scriptLine struct { + Speaker string `json:"speaker"` + Text string `json:"text"` + Emotion string `json:"emotion"` +} + +// scriptDraft 草稿文件结构(人工精修对象;pinyin 不入草稿,导入时统一生成) +type scriptDraft struct { + LevelID int64 `json:"level_id"` + NodeID int64 `json:"node_id"` + Character string `json:"character"` + Decision bool `json:"decision"` + Content string `json:"content"` + Options []string `json:"options,omitempty"` + Script []scriptLine `json:"script"` +} + +func scriptDir(levelID int64) string { + return filepath.Join(assetDir, "scripts", fmt.Sprintf("%d", levelID)) +} + +func scriptPath(levelID, nodeID int64) string { + return filepath.Join(scriptDir(levelID), fmt.Sprintf("%d.json", nodeID)) +} + +// pendingScriptNodes 待生成/待导入剧本的节点(script 为空) +func pendingScriptNodes(fl genFlags) []gdb.Record { + m := g.DB().Model(consts.TableSceneNode).Where("script IS NULL OR script = ''") + if fl.strategy > 0 { + levelIDs, _ := strategyScope(fl) + m = m.WhereIn("level_id", levelIDs) + } + rows, err := m.All(ctx) + if err != nil { + glog.Fatal(ctx, err) + } + return rows +} + +// ---------- 生成 ---------- + +func genScripts(fl genFlags) { + rows := pendingScriptNodes(fl) + if len(rows) == 0 { + fmt.Println("[scripts] 无待生成节点") + return + } + charNames := scriptCharNames(rows) + optsByNode := scriptOptions(rows) + sem := make(chan struct{}, 2) + var wg sync.WaitGroup + for _, r := range rows { + wg.Add(1) + sem <- struct{}{} + go func(r gdb.Record) { + defer wg.Done() + defer func() { <-sem }() + if err := genNodeScript(r, charNames, optsByNode); err != nil { + glog.Errorf(ctx, "[scripts] node %d 失败: %v", r["id"].Int64(), err) + } + }(r) + } + wg.Wait() +} + +func scriptCharNames(rows []gdb.Record) map[int64]string { + charIDs := map[int64]bool{} + for _, r := range rows { + if cid := r["character_id"].Int64(); cid > 0 { + charIDs[cid] = true + } + } + names := map[int64]string{} + if len(charIDs) == 0 { + return names + } + ids := make([]int64, 0, len(charIDs)) + for id := range charIDs { + ids = append(ids, id) + } + recs, err := g.DB().Model(consts.TableElement).WhereIn("id", ids).All(ctx) + if err != nil { + glog.Fatal(ctx, err) + } + for _, r := range recs { + names[r["id"].Int64()] = r["name"].String() + } + return names +} + +func scriptOptions(rows []gdb.Record) map[int64][]string { + var nodeIDs []int64 + for _, r := range rows { + nodeIDs = append(nodeIDs, r["id"].Int64()) + } + byNode := map[int64][]string{} + if len(nodeIDs) == 0 { + return byNode + } + opts, err := g.DB().Model(consts.TableNodeOption).WhereIn("node_id", nodeIDs).All(ctx) + if err != nil { + glog.Fatal(ctx, err) + } + for _, o := range opts { + nid := o["node_id"].Int64() + byNode[nid] = append(byNode[nid], o["text"].String()) + } + return byNode +} + +func genNodeScript(node gdb.Record, charNames map[int64]string, optsByNode map[int64][]string) error { + nid := node["id"].Int64() + charName := charNames[node["character_id"].Int64()] + if charName == "" { + charName = "小军师" + } + decision := node["result_type"].Int() == 0 + var tail string + if decision { + opts := optsByNode[nid] + lines := make([]string, 0, len(opts)) + for i, o := range opts { + lines = append(lines, fmt.Sprintf("%d. %s", i+1, o)) + } + tail = "选项:\n" + strings.Join(lines, "\n") + } else { + tail = "这是结局节点:由角色说出结局与鼓励,不做提问。" + } + user := fmt.Sprintf(scriptUser, node["content"].String(), charName, tail) + out, err := genClient.chat(ctx, scriptSystem, user) + if err != nil { + return err + } + var resp struct { + Script []scriptLine `json:"script"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + return fmt.Errorf("剧本 JSON 解析失败: %v(输出: %s)", err, truncate(out, 200)) + } + if err := validateScript(resp.Script, decision, charName); err != nil { + return fmt.Errorf("剧本校验失败: %v(输出: %s)", err, truncate(out, 300)) + } + draft := scriptDraft{ + LevelID: node["level_id"].Int64(), NodeID: nid, Character: charName, + Decision: decision, Content: node["content"].String(), Script: resp.Script, + } + if decision { + draft.Options = optsByNode[nid] + } + b, _ := json.MarshalIndent(draft, "", " ") + if err := os.MkdirAll(scriptDir(node["level_id"].Int64()), 0o755); err != nil { + return err + } + if err := os.WriteFile(scriptPath(node["level_id"].Int64(), nid), b, 0o644); err != nil { + return err + } + fmt.Printf("[scripts] ok node %d(%d 句)\n", nid, len(resp.Script)) + return nil +} + +// validateScript 校验台词流结构(生成与导入共用) +func validateScript(lines []scriptLine, decision bool, charName string) error { + if len(lines) < 3 || len(lines) > 6 { + return fmt.Errorf("台词应为 3-6 句,实际 %d 句", len(lines)) + } + hasNar := false + last := lines[len(lines)-1] + for _, l := range lines { + if l.Speaker == "旁白" { + hasNar = true + } else if l.Speaker != charName { + return fmt.Errorf("speaker %q 非法(应为 旁白 或 %s)", l.Speaker, charName) + } + if n := utf8.RuneCountInString(l.Text); n < 8 || n > 30 { + return fmt.Errorf("台词应 8-30 字,实际 %d 字:%s", n, l.Text) + } + if l.Emotion != "" && !validEmotion(l.Emotion) { + return fmt.Errorf("emotion %q 非法", l.Emotion) + } + } + if !hasNar { + return fmt.Errorf("至少 1 句旁白") + } + if decision { + if last.Speaker != charName { + return fmt.Errorf("决策节点末句必须是 %s 提问", charName) + } + if !strings.HasSuffix(last.Text, "?") && !strings.HasSuffix(last.Text, "?") { + return fmt.Errorf("决策节点末句必须以问号结尾:%s", last.Text) + } + } else if last.Speaker == "旁白" { + return fmt.Errorf("终局节点末句应为角色总结") + } + return nil +} + +func validEmotion(e string) bool { + switch e { + case "normal", "happy", "sad", "think", "surprise": + return true + } + return false +} + +// ---------- 导入 ---------- + +func importScripts(fl genFlags) { + rows := pendingScriptNodes(fl) + imported := 0 + for _, r := range rows { + p := scriptPath(r["level_id"].Int64(), r["id"].Int64()) + b, err := os.ReadFile(p) + if err != nil { + continue // 无草稿跳过 + } + var d scriptDraft + if err := json.Unmarshal(b, &d); err != nil { + glog.Errorf(ctx, "[scripts] 草稿解析失败 %s: %v", p, err) + continue + } + if d.NodeID != r["id"].Int64() { + glog.Errorf(ctx, "[scripts] 草稿 node 不匹配 %s", p) + continue + } + if err := validateScript(d.Script, d.Decision, d.Character); err != nil { + glog.Errorf(ctx, "[scripts] 草稿校验失败 %s: %v", p, err) + continue + } + lines := make([]map[string]string, 0, len(d.Script)) + for _, l := range d.Script { + lines = append(lines, map[string]string{ + "speaker": l.Speaker, "text": l.Text, + "pinyin": common.AnnotatePinyin(l.Text), "emotion": l.Emotion, + }) + } + b2, _ := json.Marshal(lines) + if _, err := g.DB().Model(consts.TableSceneNode).Data(g.Map{"script": string(b2)}).Where("id", d.NodeID).Update(ctx); err != nil { + glog.Errorf(ctx, "[scripts] 导入失败 node %d: %v", d.NodeID, err) + continue + } + fmt.Printf("[scripts] 导入 node %d\n", d.NodeID) + imported++ + } + fmt.Printf("[scripts] 导入完成:%d 个节点\n", imported) +} diff --git a/cmd/genasset/scripts_test.go b/cmd/genasset/scripts_test.go new file mode 100644 index 0000000..fe76914 --- /dev/null +++ b/cmd/genasset/scripts_test.go @@ -0,0 +1,65 @@ +package main + +import "testing" + +func TestValidateScript_DecisionOK(t *testing.T) { + lines := []scriptLine{ + {Speaker: "旁白", Text: "夜深了,城里静悄悄的。"}, + {Speaker: "小军师", Text: "城门关得紧紧的。"}, + {Speaker: "小军师", Text: "我们怎么才能悄悄进城呢?", Emotion: "think"}, + } + if err := validateScript(lines, true, "小军师"); err != nil { + t.Fatalf("合法决策剧本应通过: %v", err) + } +} + +func TestValidateScript_FinalOK(t *testing.T) { + lines := []scriptLine{ + {Speaker: "旁白", Text: "天亮了,城门缓缓打开。"}, + {Speaker: "小军师", Text: "我们成功了,真是太好了!"}, + {Speaker: "小军师", Text: "我们一起进城啦!", Emotion: "happy"}, + } + if err := validateScript(lines, false, "小军师"); err != nil { + t.Fatalf("合法终局剧本应通过: %v", err) + } +} + +func TestValidateScript_TooFewLines(t *testing.T) { + lines := []scriptLine{{Speaker: "旁白", Text: "夜深了,城里静悄悄的。"}} + if err := validateScript(lines, true, "小军师"); err == nil { + t.Fatal("不足 3 句应报错") + } +} + +func TestValidateScript_MissingQuestion(t *testing.T) { + lines := []scriptLine{ + {Speaker: "旁白", Text: "夜深了,城里静悄悄的。"}, + {Speaker: "小军师", Text: "城门关得紧紧的。"}, + {Speaker: "小军师", Text: "我们悄悄等天亮吧。"}, + } + if err := validateScript(lines, true, "小军师"); err == nil { + t.Fatal("决策节点末句未提问应报错") + } +} + +func TestValidateScript_BadSpeaker(t *testing.T) { + lines := []scriptLine{ + {Speaker: "旁白", Text: "夜深了,城里静悄悄的。"}, + {Speaker: "路人甲", Text: "城门关得紧紧的。"}, + {Speaker: "小军师", Text: "我们怎么才能悄悄进城呢?"}, + } + if err := validateScript(lines, true, "小军师"); err == nil { + t.Fatal("非法 speaker 应报错") + } +} + +func TestValidateScript_BadLength(t *testing.T) { + lines := []scriptLine{ + {Speaker: "旁白", Text: "夜深了。"}, + {Speaker: "小军师", Text: "城门关得紧紧的。"}, + {Speaker: "小军师", Text: "我们怎么才能悄悄进城呢?"}, + } + if err := validateScript(lines, true, "小军师"); err == nil { + t.Fatal("台词不足 8 字应报错") + } +}