Files
jsc-anima/hooks/extract.ts
T

278 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
language?: 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']
language?: string
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":""},',
'"language":"回覆語言與口癖使用方式,例如:繁體中文,少量原作口癖",',
'"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()] }
}
// 引號內長句多半是原作台詞:角色包只放自己撰寫的概括。
const QUOTED = /[「『“"][^」』”"]{20,}[」』”"]/
export const validateCharacter = (c: CharacterPack): string[] => {
const errs: string[] = []
const texts = [...Object.values(c.persona ?? {}), ...Object.values(c.anime ?? {}), ...(c.timeline ?? []).flatMap(t => [t.mood, t.dialogueStyle, t.monologue])]
if (texts.some(t => typeof t === 'string' && QUOTED.test(t))) errs.push(`${c.name}:欄位含長段引號內容(疑似抄錄原作台詞)`)
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 (c.language !== undefined && (typeof c.language !== 'string' || c.language.length > MAX_FIELD)) errs.push(`${c.name}:language 必須是簡短字串`)
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, language: incoming.language, 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, language: incoming.language ?? existing.language }
} 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
}
export type WikiSource = { kind: 'list' | 'page'; url: string; origin: string; base: string; page?: string }
// 從自然語言參數中取出第一個 URL;Gitea wiki 頁面清單(?action=_pages 或 /wiki 首頁)視為整本逐章,單頁網址視為一章。
export const parseWikiSource = (args: string): WikiSource | undefined => {
const raw = /https?:\/\/[^\s<>"'「」『』()()]+/.exec(args)?.[0]?.replace(/[,。、;,.;:!?!?]+$/, '')
if (!raw) return undefined
let u: URL
try {
u = new URL(raw)
} catch {
return undefined
}
const m = /^(.*\/wiki)(?:\/(.*))?$/.exec(u.pathname.replace(/\/+$/, ''))
if (!m) return { kind: 'page', url: raw, origin: u.origin, base: raw }
const base = `${u.origin}${m[1]}`
const page = m[2] && !m[2].startsWith('raw/') && m[2] !== '_pages' ? decodeURIComponent(m[2]) : undefined
if (u.searchParams.get('action') === '_pages' || !page) return { kind: 'list', url: raw, origin: u.origin, base }
return { kind: 'page', url: raw, origin: u.origin, base, page }
}
const SKIP_PAGES = new Set(['home', 'sidebar', 'footer', '_sidebar', '_footer', '_pages', 'new'])
// 解析 Gitea wiki 頁面清單 HTML,依章節名稱自然排序(第2章 < 第10章)。
export const parseWikiPages = (html: string, base: string): string[] => {
const path = new URL(base).pathname
const names = new Set<string>()
for (const m of html.matchAll(/href="([^"#]+)"/g)) {
let p: string
try {
p = new URL(m[1]!.replace(/&amp;/g, '&'), base).pathname
} catch {
continue
}
if (!p.startsWith(`${path}/`)) continue
const rest = p.slice(path.length + 1)
if (!rest || rest.includes('/')) continue
let name: string
try {
name = decodeURIComponent(rest)
} catch {
continue
}
if (!SKIP_PAGES.has(name.toLowerCase())) names.add(name)
}
return [...names].sort((a, b) => a.localeCompare(b, 'zh-Hant', { numeric: true }))
}