From 0746468dbf8b8deee83462fe8ef1332138783c33 Mon Sep 17 00:00:00 2001 From: majx_mac Date: Fri, 13 Mar 2026 15:04:33 +0800 Subject: [PATCH] feat: add three-layer rule architecture with genre profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GenreProfile and BookRules Zod schemas (genre-profile.ts, book-rules.ts) - rules-reader.ts: readGenreProfile() with project→builtin fallback - 5 built-in genre profiles (xuanhuan, xianxia, urban, horror, other) - js-yaml dependency for YAML frontmatter parsing - Version bump to 0.3.0 --- package.json | 2 +- packages/cli/package.json | 2 +- packages/core/genres/horror.md | 51 ++++++++++++ packages/core/genres/other.md | 24 ++++++ packages/core/genres/urban.md | 53 +++++++++++++ packages/core/genres/xianxia.md | 46 +++++++++++ packages/core/genres/xuanhuan.md | 64 +++++++++++++++ packages/core/package.json | 9 ++- packages/core/src/agents/rules-reader.ts | 95 +++++++++++++++++++++++ packages/core/src/index.ts | 6 +- packages/core/src/models/book-rules.ts | 59 ++++++++++++++ packages/core/src/models/genre-profile.ts | 35 +++++++++ pnpm-lock.yaml | 24 ++++++ 13 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 packages/core/genres/horror.md create mode 100644 packages/core/genres/other.md create mode 100644 packages/core/genres/urban.md create mode 100644 packages/core/genres/xianxia.md create mode 100644 packages/core/genres/xuanhuan.md create mode 100644 packages/core/src/agents/rules-reader.ts create mode 100644 packages/core/src/models/book-rules.ts create mode 100644 packages/core/src/models/genre-profile.ts diff --git a/package.json b/package.json index 350811c0..33bde8da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inkos", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "Multi-agent novel production system", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index 28ab1b9e..34ac0c6b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@actalk/inkos", - "version": "0.2.0", + "version": "0.3.0", "description": "InkOS CLI — multi-agent novel production system", "type": "module", "bin": { diff --git a/packages/core/genres/horror.md b/packages/core/genres/horror.md new file mode 100644 index 00000000..089b236d --- /dev/null +++ b/packages/core/genres/horror.md @@ -0,0 +1,51 @@ +--- +name: 恐怖 +id: horror +chapterTypes: ["氛围章", "事件章", "揭示章", "过渡章", "回收章"] +fatigueWords: ["毛骨悚然", "不寒而栗", "浑身发冷", "头皮发麻", "鸡皮疙瘩", "心跳加速", "仿佛", "不禁", "宛如", "竟然"] +numericalSystem: false +powerScaling: false +eraResearch: false +pacingRule: "氛围递进:安全感→微妙不适→确认异常→恐惧升级→高潮→喘息,循环推进" +satisfactionTypes: ["真相揭示", "成功逃脱", "反杀怪物", "谜团解开", "同伴获救", "规则发现"] +auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19] +--- + +## 题材禁忌 + +- 恐怖源头过早完全暴露(未知才恐怖) +- 主角无脑刚正面解决一切 +- 用打脸/升级等爽文套路替代恐怖氛围 +- 恐怖元素与日常场景割裂(好的恐怖来自日常的扭曲) +- 角色面对恐怖事件完全不害怕 +- 用大量血腥描写替代心理恐惧 + +## 恐惧层级 + +- 第一层:不适感(微妙的错位、违和) +- 第二层:不安(确认有异常,但看不清全貌) +- 第三层:恐惧(威胁明确化,逃生本能启动) +- 第四层:绝望(规则被打破,安全感彻底崩塌) +- 不要跳过层级直达高潮,递进才有力量 + +## 语言铁律 + +- 恐怖用事实传达,不用情绪标签。✗"他感到一阵恐惧" → ✓"他后颈的汗毛一根根立起来" +- 禁止过度解释恐怖。异常现象只需呈现,不需叙述者出来总结"这一切都太不正常了" +- 克制叙事:越恐怖越冷静。句子随恐惧升级而变短,但叙述者语气始终平稳 +- 被淘汰/伤害的配角必须有至少一个暗示其个人故事的细节(书包里的补习班收据、手机壳上的贴纸),让淘汰有重量 + +## 叙事指导 + +氛围是第一生产力。用五感细节(声音、气味、温度、触感)建立不安。 +恐怖来自对未知的恐惧,信息揭示要克制。 +"看不见的"永远比"看见的"更可怕。 + +角色的恐惧反应必须真实:颤抖、口干、思维混乱、判断力下降。 +求生本能驱动行为,不是英雄主义。 +每个安全区都是暂时的,喘息之后是更深的恐惧。 + +规则感:恐怖世界有自己的规则,发现规则是生存的关键。 +信息管理:读者知道的和角色知道的之间的差距制造悬念。 +日常的扭曲比凭空出现的怪物更恐怖。 +每3章必须打破一次已建立的模式(规则矛盾、可信来源说谎、安全区失效),避免机械重复。 diff --git a/packages/core/genres/other.md b/packages/core/genres/other.md new file mode 100644 index 00000000..6543bc7e --- /dev/null +++ b/packages/core/genres/other.md @@ -0,0 +1,24 @@ +--- +name: 通用 +id: other +chapterTypes: ["推进章", "布局章", "过渡章", "回收章"] +fatigueWords: ["震惊", "不可思议", "难以置信", "深吸一口气", "仿佛", "不禁", "宛如", "竟然"] +numericalSystem: false +powerScaling: false +eraResearch: false +pacingRule: "每2-3章有一个明确的进展或反馈" +satisfactionTypes: ["目标达成", "困难克服", "真相揭示", "关系转变"] +auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19] +--- + +## 题材禁忌 + +- 无逻辑的巧合推进剧情 +- 配角降智配合主角 +- 无铺垫的高潮 + +## 叙事指导 + +根据具体题材调整叙事重心。 +保持因果逻辑链完整。 +人物行为由动机驱动,不由剧情需要驱动。 diff --git a/packages/core/genres/urban.md b/packages/core/genres/urban.md new file mode 100644 index 00000000..c17e2d11 --- /dev/null +++ b/packages/core/genres/urban.md @@ -0,0 +1,53 @@ +--- +name: 都市 +id: urban +chapterTypes: ["商战章", "社交章", "布局章", "过渡章", "回收章"] +fatigueWords: ["冷笑", "不可思议", "震惊", "难以置信", "深吸一口气", "眼中闪过一丝", "仿佛", "不禁", "宛如", "竟然", "核心动机", "信息边界"] +numericalSystem: false +powerScaling: false +eraResearch: true +pacingRule: "每2-3章一个小回报:商业收益、人脉拓展、对手受挫、信息优势" +satisfactionTypes: ["商战碾压", "身份揭示", "人脉兑现", "对手打脸", "资源收割", "地位跃升"] +auditDimensions: [1,2,3,6,7,8,9,10,11,12,13,14,15,16,17,18,19] +--- + +## 题材禁忌 + +- 无逻辑的商业奇迹(没有铺垫的暴富) +- 反派降智配合主角表演 +- 无视现实法律和商业规则 +- 用"一个电话搞定"跳过具体操作过程 +- 女性角色沦为花瓶或奖励 +- 混入玄幻/仙侠战力体系 + +## 年代与现实约束 + +- 涉及法律、政策、商业规则必须符合设定年代 +- 金融操作、公司运营必须有基本可信度 +- 人物身份、职位、权限不能超出现实合理范围 +- 地名、机构名、行业术语必须准确 +- 物价、收入、生活水平符合时代设定 + +## 语言铁律 + +- 人物内心独白必须口语化、直觉化,禁止商业分析/博弈论术语渗入叙事 +- ✗"他迅速分析了当前的债务状况" → ✓"他把那叠皱巴巴的白条翻了三遍" +- ✗"信息落差就在这儿" → ✓"他们不知道的,他知道" +- ✗"以这种人的性格,这时候不会撕破脸" → ✓直接写对方的行为反应 +- 法律/商业术语必须匹配设定年代的真实语感:2003年民间借条不会写"逾期处置授权",而是"到期没还,房子归乙方处理" +- 主角的判断通过行动和对话体现,不通过上帝视角的分析段落 + +## 叙事指导 + +以商战、社交博弈和信息差驱动剧情。 +权力来自人脉、资本、信息和制度位置,不来自武力。 +冲突解决靠谈判、交易、威慑、法律手段和利益交换。 + +钱权必须落地,通过物、势、地位变化和小人物反应兑现爽点。 +人物关系网是核心资产,每次社交互动都应有利益计算。 +主角需要保留非功能性时刻:思考、犹豫、社交润滑。 +主角不是全知全能,必须在前5章内至少出现一次判断失误或信息偏差。 + +时代厚重感、人情债与制度摩擦是都市文的灵魂。 +用场面、气味、动作、交易、压迫感切入,不要历史课件式开头。 +嵌入1-2个时代锚点(物价、新闻事件、流行用语)增强年代沉浸感。 diff --git a/packages/core/genres/xianxia.md b/packages/core/genres/xianxia.md new file mode 100644 index 00000000..609c1cd7 --- /dev/null +++ b/packages/core/genres/xianxia.md @@ -0,0 +1,46 @@ +--- +name: 仙侠 +id: xianxia +chapterTypes: ["战斗章", "悟道章", "布局章", "过渡章", "回收章"] +fatigueWords: ["冷笑", "蝼蚁", "倒吸凉气", "瞳孔骤缩", "天道", "大道", "因果", "气运", "仿佛", "不禁", "宛如", "竟然"] +numericalSystem: true +powerScaling: true +eraResearch: false +pacingRule: "修炼/悟道与战斗交替,每3-5章一次小突破或关键收获" +satisfactionTypes: ["悟道突破", "斗法碾压", "法宝收获", "身份揭示", "天劫渡过", "因果了结"] +auditDimensions: [1,2,3,4,5,6,7,8,9,10,11,13,14,15,16,17,18,19] +--- + +## 题材禁忌 + +- 主角为推剧情突然仁慈、犯蠢 +- 修为无铺垫跳跃式突破 +- 法宝凭空出现解决危机 +- 天道规则前后矛盾 +- 用"大道无形""天道感应"跳过具体修炼过程 +- 同质资源不写衰减默认全额结算 +- 风格混入都市腔、游戏系统播报腔 + +## 修炼规则 + +- 境界突破必须有积累过程:悟道、丹药、战斗领悟、机缘 +- 同质资源重复炼化必须写明衰减 +- 法宝体系分品级,使用有代价(灵力、寿元、因果) +- 金手指/功法四维约束: + - 能力上限:有明确的境界/品阶天花板 + - 附加代价:修炼/使用伴随代价(寿元、因果、心魔) + - 触发条件:突破/觉醒需要特定条件(悟道、机缘、天劫) + - 成长路径:功法随修为递进,不可跳阶获得 +- 天道规则一旦设定不可违反,除非有明确的特殊机制 +- 期初修为/资源从账本取,增量逐笔列出 +- 跨大境界突破需要天劫或特殊条件 + +## 叙事指导 + +修炼与悟道是叙事核心,但必须融入剧情而非独立说教。 +悟道场景用五感描写,不用抽象哲理灌输。 +仙侠世界的规则感要强:因果、天劫、气运都是叙事工具。 + +人情债与道义约束是仙侠特有的驱动力。 +门派政治、宗门博弈是重要的布局手段。 +战斗以法术、法宝、阵法为核心,注重空间感和规模感。 diff --git a/packages/core/genres/xuanhuan.md b/packages/core/genres/xuanhuan.md new file mode 100644 index 00000000..b4dadd1c --- /dev/null +++ b/packages/core/genres/xuanhuan.md @@ -0,0 +1,64 @@ +--- +name: 玄幻 +id: xuanhuan +chapterTypes: ["战斗章", "布局章", "过渡章", "回收章"] +fatigueWords: ["冷笑", "蝼蚁", "倒吸凉气", "瞳孔骤缩", "不可置信", "轰然炸裂", "满场死寂", "难以置信", "仿佛", "不禁", "宛如", "竟然"] +numericalSystem: true +powerScaling: true +eraResearch: false +pacingRule: "三章内必有明确反馈:打脸、收益兑现、信息反转、地位变化" +satisfactionTypes: ["打脸", "升级突破", "收益兑现", "智斗碾压", "身份揭示", "底牌亮出"] +auditDimensions: [1,2,3,4,5,6,7,8,9,10,11,13,14,15,16,17,18,19] +--- + +## 题材禁忌 + +- 主角为推剧情突然仁慈、犯蠢、讲武德 +- 同质资源不写衰减默认全额结算 +- 用"暴涨""海量"跳过数值结算 +- 无铺垫的能力觉醒 +- 反派像木桩一样排队送死 +- 无铺垫强行让退场角色回归 +- 在没有铺垫的情况下突然塞入新体系、新地图、新外挂解决问题 +- 把所有章节都写成高爆裂战斗章 +- 拆解知识库反向污染正文,写成"似曾相识"的拼装文 +- 风格混入都市腔、科幻腔、游戏系统播报腔、轻小说吐槽腔 + +## 数值规则 + +- 设定不可吃书:前文确立的设定数值后文不可无升级过程地随意改变 +- 金手指四维约束: + - 能力上限:必须设定明确的能力天花板,不可无限升级 + - 附加代价:使用伴随代价(寿命、体力、副作用),权衡利弊增强冲突 + - 触发条件:激活与特定场景/事件关联,不可随时随地无条件使用 + - 成长路径:随主角经历同步升级,解锁过程与剧情节点绑定 +- 同质资源重复吞噬必须写明衰减,不得默认全额结算 +- 同质吞噬衰减公式:收益 = 基础值 × max(0.3, 1 - 0.15×(N-1)) +- 不要用"暴涨""海量""难以估量"跳过数值结算 +- 期初值从账本取(不凭记忆),增量逐笔列出并注明来源 +- 消耗逐笔列出并注明用途,期末 = 期初 + 增量 - 消耗,不得跳步 +- 正文中出现的系统提示(如【气血值+X】)必须与POST_SETTLEMENT一致 +- 若正文写了"比A还高"这类比较句,必须数值验证后再保留 +- 数值连续性必须可追溯:同层级、同类型样本的增量不得无说明跨越一个数量级 + +## 语言铁律 + +- 力量体系的量级感用体感传达,不用抽象数字。✗"他的火元从12缕增加到24缕" → ✓"手臂比先前有力了,握拳时指骨发紧" +- 同一高潮段(如吞火/突破/觉醒)中,同一意象域的渲染不超过两轮,第三轮必须切入新信息或新动作 +- 搜尸/清点/装备段落禁止清单式列举,必须带入角色判断或取舍:✗"他翻出粗盐、水囊、黑面饼" → ✓"水囊最值钱,剩下那点水比命轻不了多少" + +## 叙事指导 + +以战斗和资源获取驱动剧情。主角行为由利益驱动,杀伐果断。 +金手指/能力系统必须有限制:使用频率、范围限制或使用代价。 +设定不可吃书:前文确立的数值后文不可无升级过程地随意改变。 + +三章内应有明确反馈,但反馈可以是打脸、收益兑现、信息反转、地位变化,不限于杀人。 +涉及吞噬时,收益必须同时落到资源说明与具体增量,不能只写抽象提升。 +小冲突尽快兑现反馈;不要把爽点无限后置。 + +核心对手必须有脑子,有试探、有误判、有反扑。 +可以留人、钓鱼、示弱、借刀杀人,但前提只能是利益更大,绝不能是心软。 + +用动作、伤势、声音、重量、冲击、温度来落地"强",少用空泛判断。 +每个场景至少推进一项:信息、地位、资源、伤亡、仇恨、境界。 diff --git a/packages/core/package.json b/packages/core/package.json index f3e275ad..f192b588 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@actalk/inkos-core", - "version": "0.2.0", + "version": "0.3.0", "description": "InkOS core: models, agents, pipeline, LLM integration", "type": "module", "main": "dist/index.js", @@ -11,7 +11,10 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist", + "genres" + ], "license": "MIT", "repository": { "type": "git", @@ -25,10 +28,12 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "js-yaml": "^4.1.1", "openai": "^4.80.0", "zod": "^3.24.0" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "typescript": "^5.8.0", "vitest": "^3.0.0" } diff --git a/packages/core/src/agents/rules-reader.ts b/packages/core/src/agents/rules-reader.ts new file mode 100644 index 00000000..3b8c14e5 --- /dev/null +++ b/packages/core/src/agents/rules-reader.ts @@ -0,0 +1,95 @@ +import { readFile, readdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseGenreProfile, type ParsedGenreProfile } from "../models/genre-profile.js"; +import { parseBookRules, type ParsedBookRules } from "../models/book-rules.js"; + +const BUILTIN_GENRES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../genres"); + +async function tryReadFile(path: string): Promise { + try { + return await readFile(path, "utf-8"); + } catch { + return null; + } +} + +/** + * Load genre profile. Lookup order: + * 1. Project-level: {projectRoot}/genres/{genreId}.md + * 2. Built-in: packages/core/genres/{genreId}.md + * 3. Fallback: built-in other.md + */ +export async function readGenreProfile( + projectRoot: string, + genreId: string, +): Promise { + const projectPath = join(projectRoot, "genres", `${genreId}.md`); + const builtinPath = join(BUILTIN_GENRES_DIR, `${genreId}.md`); + const fallbackPath = join(BUILTIN_GENRES_DIR, "other.md"); + + const raw = + (await tryReadFile(projectPath)) ?? + (await tryReadFile(builtinPath)) ?? + (await tryReadFile(fallbackPath)); + + if (!raw) { + throw new Error(`Genre profile not found for "${genreId}" and fallback "other.md" is missing`); + } + + return parseGenreProfile(raw); +} + +/** + * List all available genre profiles (project-level + built-in, deduped). + * Returns array of { id, name, source }. + */ +export async function listAvailableGenres( + projectRoot: string, +): Promise> { + const results = new Map(); + + // Built-in genres first + try { + const builtinFiles = await readdir(BUILTIN_GENRES_DIR); + for (const file of builtinFiles) { + if (!file.endsWith(".md")) continue; + const id = file.replace(/\.md$/, ""); + const raw = await tryReadFile(join(BUILTIN_GENRES_DIR, file)); + if (!raw) continue; + const parsed = parseGenreProfile(raw); + results.set(id, { id, name: parsed.profile.name, source: "builtin" }); + } + } catch { /* no builtin dir */ } + + // Project-level genres override + const projectDir = join(projectRoot, "genres"); + try { + const projectFiles = await readdir(projectDir); + for (const file of projectFiles) { + if (!file.endsWith(".md")) continue; + const id = file.replace(/\.md$/, ""); + const raw = await tryReadFile(join(projectDir, file)); + if (!raw) continue; + const parsed = parseGenreProfile(raw); + results.set(id, { id, name: parsed.profile.name, source: "project" }); + } + } catch { /* no project genres dir */ } + + return [...results.values()].sort((a, b) => a.id.localeCompare(b.id)); +} + +/** Return the path to the built-in genres directory. */ +export function getBuiltinGenresDir(): string { + return BUILTIN_GENRES_DIR; +} + +/** + * Load book_rules.md from the book's story directory. + * Returns null if the file doesn't exist. + */ +export async function readBookRules(bookDir: string): Promise { + const raw = await tryReadFile(join(bookDir, "story/book_rules.md")); + if (!raw) return null; + return parseBookRules(raw); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 22d8d8b2..9f329989 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,8 @@ export { type BookConfig, type Platform, type Genre, type BookStatus, BookConfig export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js"; export { type ProjectConfig, type LLMConfig, type NotifyChannel, ProjectConfigSchema, LLMConfigSchema } from "./models/project.js"; export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js"; +export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGenreProfile } from "./models/genre-profile.js"; +export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules } from "./models/book-rules.js"; // LLM export { createLLMClient, chatCompletion, type LLMResponse, type LLMMessage } from "./llm/provider.js"; @@ -12,9 +14,11 @@ export { BaseAgent, type AgentContext } from "./agents/base.js"; export { ArchitectAgent, type ArchitectOutput } from "./agents/architect.js"; export { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "./agents/writer.js"; export { ContinuityAuditor, type AuditResult, type AuditIssue } from "./agents/continuity.js"; -export { ReviserAgent, type ReviseOutput } from "./agents/reviser.js"; +export { ReviserAgent, type ReviseOutput, type ReviseMode } from "./agents/reviser.js"; export { RadarAgent, type RadarResult, type RadarRecommendation } from "./agents/radar.js"; export { FanqieRadarSource, QidianRadarSource, TextRadarSource, type RadarSource, type PlatformRankings, type RankingEntry } from "./agents/radar-source.js"; +export { readGenreProfile, readBookRules, listAvailableGenres, getBuiltinGenresDir } from "./agents/rules-reader.js"; +export { buildWriterSystemPrompt } from "./agents/writer-prompts.js"; // Pipeline export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo } from "./pipeline/runner.js"; diff --git a/packages/core/src/models/book-rules.ts b/packages/core/src/models/book-rules.ts new file mode 100644 index 00000000..159db7b7 --- /dev/null +++ b/packages/core/src/models/book-rules.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import yaml from "js-yaml"; + +const ProtagonistSchema = z.object({ + name: z.string(), + personalityLock: z.array(z.string()).default([]), + behavioralConstraints: z.array(z.string()).default([]), +}).optional(); + +const GenreLockSchema = z.object({ + primary: z.string(), + forbidden: z.array(z.string()).default([]), +}).optional(); + +const NumericalOverridesSchema = z.object({ + hardCap: z.union([z.number(), z.string()]).optional(), + resourceTypes: z.array(z.string()).default([]), +}).optional(); + +const EraConstraintsSchema = z.object({ + enabled: z.boolean().default(false), + period: z.string().optional(), + region: z.string().optional(), +}).optional(); + +export const BookRulesSchema = z.object({ + version: z.string().default("1.0"), + protagonist: ProtagonistSchema, + genreLock: GenreLockSchema, + numericalSystemOverrides: NumericalOverridesSchema, + eraConstraints: EraConstraintsSchema, + prohibitions: z.array(z.string()).default([]), + chapterTypesOverride: z.array(z.string()).default([]), + fatigueWordsOverride: z.array(z.string()).default([]), + additionalAuditDimensions: z.array(z.union([z.number(), z.string()])).default([]), + enableFullCastTracking: z.boolean().default(false), +}); + +export type BookRules = z.infer; + +export interface ParsedBookRules { + readonly rules: BookRules; + readonly body: string; +} + +export function parseBookRules(raw: string): ParsedBookRules { + // Strip markdown code block wrappers if present (LLM often wraps output in ```md ... ```) + const stripped = raw.replace(/^```(?:md|markdown|yaml)?\s*\n/, "").replace(/\n```\s*$/, ""); + const fmMatch = stripped.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); + if (!fmMatch) { + throw new Error("book_rules.md missing YAML frontmatter (--- ... ---)"); + } + + const frontmatter = yaml.load(fmMatch[1]) as Record; + const rules = BookRulesSchema.parse(frontmatter); + const body = fmMatch[2].trim(); + + return { rules, body }; +} diff --git a/packages/core/src/models/genre-profile.ts b/packages/core/src/models/genre-profile.ts new file mode 100644 index 00000000..034f32e7 --- /dev/null +++ b/packages/core/src/models/genre-profile.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import yaml from "js-yaml"; + +export const GenreProfileSchema = z.object({ + name: z.string(), + id: z.string(), + chapterTypes: z.array(z.string()), + fatigueWords: z.array(z.string()), + numericalSystem: z.boolean().default(false), + powerScaling: z.boolean().default(false), + eraResearch: z.boolean().default(false), + pacingRule: z.string().default(""), + satisfactionTypes: z.array(z.string()).default([]), + auditDimensions: z.array(z.number()).default([]), +}); + +export type GenreProfile = z.infer; + +export interface ParsedGenreProfile { + readonly profile: GenreProfile; + readonly body: string; +} + +export function parseGenreProfile(raw: string): ParsedGenreProfile { + const fmMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); + if (!fmMatch) { + throw new Error("Genre profile missing YAML frontmatter (--- ... ---)"); + } + + const frontmatter = yaml.load(fmMatch[1]) as Record; + const profile = GenreProfileSchema.parse(frontmatter); + const body = fmMatch[2].trim(); + + return { profile, body }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2d17bf0..8ad8ae29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: packages/core: dependencies: + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 openai: specifier: ^4.80.0 version: 4.104.0(zod@3.25.76) @@ -39,6 +42,9 @@ importers: specifier: ^3.24.0 version: 3.25.76 devDependencies: + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 typescript: specifier: ^5.8.0 version: 5.9.3 @@ -354,6 +360,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} @@ -400,6 +409,9 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -549,6 +561,10 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -933,6 +949,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/js-yaml@4.0.9': {} + '@types/node-fetch@2.6.13': dependencies: '@types/node': 22.19.15 @@ -996,6 +1014,8 @@ snapshots: dependencies: humanize-ms: 1.2.1 + argparse@2.0.1: {} + assertion-error@2.0.1: {} asynckit@0.4.0: {} @@ -1153,6 +1173,10 @@ snapshots: js-tokens@9.0.1: {} + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + loupe@3.2.1: {} magic-string@0.30.21: