feat: genasset 剧本管线——--only=scripts 草稿生成 + --import-scripts 校验注音回填

This commit is contained in:
2026-08-13 16:25:40 +08:00
parent d19d14b2fc
commit c40d833cd1
4 changed files with 361 additions and 4 deletions
+265
View File
@@ -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)
}