feat: 工具一 anima-extract,從輕小說章節產生角色包並建立 PR

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 06:15:09 +00:00
co-authored by Claude Sonnet 5.5
parent c890059a50
commit e681e11ae9
5 changed files with 527 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
import type { Axes } from './characters'
export type Relationship = { with: string; kind: 'friend' | 'romantic' | 'family' | 'other'; note: string }
export type StateEntry = {
chapter: string
mood: string
dialogueStyle: string
monologue: string
relationships: Relationship[]
age: number | null
place: string
}
export type CharacterPack = {
slug: string
name: string
work: string
persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string }
axes: Axes
anime: { moe: string; speech: string; emotions: string; kaomoji: string; themeColor: string }
baseChapter: string
timeline: StateEntry[]
conflicts: Conflict[]
}
export type Conflict = { field: string; existing: string; proposed: string; chapter: string; status: '待確認' }
export type WorkPack = {
slug: string
name: string
timeline: { chapter: string; time: string; place: string }[]
terms: { term: string; definition: string; chapter: string }[]
}
export type Extracted = {
work: { name: string; nameEn: string; slug: string; timeline: { time: string; place: string }[]; terms: { term: string; definition: string }[] }
characters: {
name: string
nameEn: string
slug: string
persona: CharacterPack['persona']
axes: Axes
anime: CharacterPack['anime']
state: Omit<StateEntry, 'chapter'>
}[]
}
export const AXIS_KEYS: (keyof Axes)[] = ['gentle', 'tsundere', 'lively', 'formal', 'talkative', 'humor', 'proactive', 'expressive']
const PERSONA_KEYS = ['selfRef', 'address', 'tone', 'catchphrase', 'values', 'taboos'] as const
const ANIME_KEYS = ['moe', 'speech', 'emotions', 'kaomoji', 'themeColor'] as const
const MAX_FIELD = 400
export const CHUNK_SIZE = 4000
export const slugify = (s: string): string =>
s
.toLowerCase()
.normalize('NFKD')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
export const chunkText = (text: string, size = CHUNK_SIZE): string[] => {
const out: string[] = []
let cur = ''
for (const para of text.split(/\n\s*\n/)) {
if (cur && cur.length + para.length + 2 > size) {
out.push(cur)
cur = ''
}
let p = para
while (p.length > size) {
out.push(p.slice(0, size))
p = p.slice(size)
}
cur = cur ? `${cur}\n\n${p}` : p
}
if (cur.trim()) out.push(cur)
return out
}
export const extractPrompt = (chunk: string, index: number, total: number): string =>
[
`以下是輕小說章節的第 ${index + 1}/${total} 段。請萃取環境(時間、地點、用語)與人物(心情、對話風格、獨白、關係、年齡)。`,
'規則:所有欄位用你自己的話撰寫並簡短概括,不得抄錄原作台詞;每個欄位不超過 200 字;axes 為 0-10 的整數。',
'slug 與 workSlug 使用官方英文名或羅馬拼音的小寫英數與連字號。',
'只輸出一個 JSON,不要其他文字,格式:',
'{"work":{"name":"","nameEn":"","slug":"","timeline":[{"time":"","place":""}],"terms":[{"term":"","definition":""}]},',
'"characters":[{"name":"","nameEn":"","slug":"","persona":{"selfRef":"","address":"","tone":"","catchphrase":"","values":"","taboos":""},',
'"axes":{"gentle":0,"tsundere":0,"lively":0,"formal":0,"talkative":0,"humor":0,"proactive":0,"expressive":0},',
'"anime":{"moe":"","speech":"","emotions":"","kaomoji":"","themeColor":""},',
'"state":{"mood":"","dialogueStyle":"","monologue":"","relationships":[{"with":"","kind":"friend|romantic|family|other","note":""}],"age":null,"place":""}}]}',
'',
'章節內容:',
chunk,
].join('\n')
export const parseExtracted = (text: string): Extracted | undefined => {
const m = text.match(/\{[\s\S]*\}/)
if (!m) return undefined
try {
const v = JSON.parse(m[0]) as Extracted
return v && v.work && Array.isArray(v.characters) ? v : undefined
} catch {
return undefined
}
}
// 逐段結果合併:同一人物以英文名對應,後段的狀態蓋過前段(永遠採最新)。
export const mergeChunks = (parts: Extracted[]): Extracted => {
const first = parts[0]!
const work = { ...first.work, timeline: [...first.work.timeline], terms: [...first.work.terms] }
const chars = new Map<string, Extracted['characters'][number]>()
for (const p of parts) {
for (const t of p.work.terms ?? []) if (!work.terms.some(x => x.term === t.term)) work.terms.push(t)
for (const t of p.work.timeline ?? []) if (!work.timeline.some(x => x.time === t.time && x.place === t.place)) work.timeline.push(t)
for (const c of p.characters) {
const key = slugify(c.nameEn || c.slug || c.name) || c.name
chars.set(key, { ...(chars.get(key) ?? c), ...c, state: { ...(chars.get(key)?.state ?? c.state), ...c.state } })
}
}
return { work, characters: [...chars.values()] }
}
export const validateCharacter = (c: CharacterPack): string[] => {
const errs: string[] = []
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(c.slug)) errs.push(`${c.name}:角色代號格式不合法`)
if (!c.name) errs.push('缺少角色名稱')
for (const k of PERSONA_KEYS) {
const v = c.persona?.[k]
if (typeof v !== 'string') errs.push(`${c.name}:persona.${k} 必須是字串`)
else if (v.length > MAX_FIELD) errs.push(`${c.name}:persona.${k} 過長(疑似抄錄原文)`)
}
for (const k of ANIME_KEYS) {
const v = c.anime?.[k]
if (typeof v !== 'string') errs.push(`${c.name}:anime.${k} 必須是字串`)
else if (v.length > MAX_FIELD) errs.push(`${c.name}:anime.${k} 過長(疑似抄錄原文)`)
}
for (const k of AXIS_KEYS) {
const v = c.axes?.[k]
if (!Number.isInteger(v) || v < 0 || v > 10) errs.push(`${c.name}:axes.${k} 必須是 0-10 的整數`)
}
if (!Array.isArray(c.timeline) || c.timeline.length === 0) errs.push(`${c.name}:時間線不可為空`)
for (const t of c.timeline ?? []) {
if (!t.chapter) errs.push(`${c.name}:時間線缺少來源章節`)
if (t.age !== null && !(Number.isInteger(t.age) && t.age >= 0)) errs.push(`${c.name}:年齡必須是非負整數或 null`)
for (const f of [t.mood, t.dialogueStyle, t.monologue])
if (typeof f === 'string' && f.length > MAX_FIELD) errs.push(`${c.name}:時間線欄位過長(疑似抄錄原文)`)
}
return errs
}
export type MergeResult<T> = { pack: T; action: '新增' | '更新'; detail: string; conflicts: Conflict[] }
export const mergeCharacter = (
incoming: Extracted['characters'][number],
workSlug: string,
slug: string,
chapter: string,
existing: CharacterPack | undefined,
): MergeResult<CharacterPack> => {
const entry: StateEntry = {
chapter,
mood: incoming.state.mood ?? '',
dialogueStyle: incoming.state.dialogueStyle ?? '',
monologue: incoming.state.monologue ?? '',
relationships: incoming.state.relationships ?? [],
age: incoming.state.age ?? null,
place: incoming.state.place ?? '',
}
if (!existing) {
return {
action: '新增',
detail: `時間線 ${chapter}`,
conflicts: [],
pack: { slug, name: incoming.name, work: workSlug, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime, baseChapter: chapter, timeline: [entry], conflicts: [] },
}
}
const sameChapter = existing.timeline.some(t => t.chapter === chapter)
const timeline = sameChapter
? existing.timeline.map(t => (t.chapter === chapter ? entry : t))
: [...existing.timeline, entry]
// 重跑同一章節:該章節產生的基礎設定直接覆蓋;新章節若與既有基礎設定不同,標記待確認而不覆蓋。
const overwriteBase = existing.baseChapter === chapter
const conflicts: Conflict[] = existing.conflicts.filter(c => c.chapter !== chapter)
let base = existing
if (overwriteBase) {
base = { ...existing, name: incoming.name, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime }
} else {
const diffs: [string, string, string][] = []
for (const k of PERSONA_KEYS) if (existing.persona[k] !== incoming.persona[k]) diffs.push([`persona.${k}`, existing.persona[k], incoming.persona[k]])
for (const k of AXIS_KEYS) if (existing.axes[k] !== incoming.axes[k]) diffs.push([`axes.${k}`, String(existing.axes[k]), String(incoming.axes[k])])
for (const [field, a, b] of diffs) conflicts.push({ field, existing: a, proposed: b, chapter, status: '待確認' })
}
return {
action: '更新',
detail: `時間線 ${sameChapter ? '覆蓋' : '追加'} ${chapter}`,
conflicts: conflicts.filter(c => c.chapter === chapter),
pack: { ...base, timeline, conflicts },
}
}
export const mergeWork = (
incoming: Extracted['work'],
slug: string,
chapter: string,
existing: WorkPack | undefined,
): MergeResult<WorkPack> => {
const timeline = [...(existing?.timeline ?? []).filter(t => t.chapter !== chapter), ...(incoming.timeline ?? []).map(t => ({ chapter, ...t }))]
const terms = [...(existing?.terms ?? []).filter(t => t.chapter !== chapter), ...(incoming.terms ?? []).map(t => ({ chapter, ...t }))]
return {
action: existing ? '更新' : '新增',
detail: `時間線 ${incoming.timeline?.length ?? 0} 筆、用語 ${incoming.terms?.length ?? 0} 筆(${chapter})`,
conflicts: [],
pack: { slug, name: incoming.name, timeline, terms },
}
}
export const parseRemote = (url: string): { host: string; repo: string } | undefined => {
const m = url.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/]+@)([^/:]+)(?::\d+)?[/:](.+?)(?:\.git)?$/)
return m ? { host: m[1]!, repo: m[2]! } : undefined
}