feat: genasset 剧本管线——--only=scripts 草稿生成 + --import-scripts 校验注音回填
This commit is contained in:
+17
-4
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 字应报错")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user