mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-28 23:02:03 +08:00
feat: add three-layer rule architecture with genre profiles
- 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
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkos",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "Multi-agent novel production system",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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章必须打破一次已建立的模式(规则矛盾、可信来源说谎、安全区失效),避免机械重复。
|
||||
@@ -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]
|
||||
---
|
||||
|
||||
## 题材禁忌
|
||||
|
||||
- 无逻辑的巧合推进剧情
|
||||
- 配角降智配合主角
|
||||
- 无铺垫的高潮
|
||||
|
||||
## 叙事指导
|
||||
|
||||
根据具体题材调整叙事重心。
|
||||
保持因果逻辑链完整。
|
||||
人物行为由动机驱动,不由剧情需要驱动。
|
||||
@@ -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个时代锚点(物价、新闻事件、流行用语)增强年代沉浸感。
|
||||
@@ -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]
|
||||
---
|
||||
|
||||
## 题材禁忌
|
||||
|
||||
- 主角为推剧情突然仁慈、犯蠢
|
||||
- 修为无铺垫跳跃式突破
|
||||
- 法宝凭空出现解决危机
|
||||
- 天道规则前后矛盾
|
||||
- 用"大道无形""天道感应"跳过具体修炼过程
|
||||
- 同质资源不写衰减默认全额结算
|
||||
- 风格混入都市腔、游戏系统播报腔
|
||||
|
||||
## 修炼规则
|
||||
|
||||
- 境界突破必须有积累过程:悟道、丹药、战斗领悟、机缘
|
||||
- 同质资源重复炼化必须写明衰减
|
||||
- 法宝体系分品级,使用有代价(灵力、寿元、因果)
|
||||
- 金手指/功法四维约束:
|
||||
- 能力上限:有明确的境界/品阶天花板
|
||||
- 附加代价:修炼/使用伴随代价(寿元、因果、心魔)
|
||||
- 触发条件:突破/觉醒需要特定条件(悟道、机缘、天劫)
|
||||
- 成长路径:功法随修为递进,不可跳阶获得
|
||||
- 天道规则一旦设定不可违反,除非有明确的特殊机制
|
||||
- 期初修为/资源从账本取,增量逐笔列出
|
||||
- 跨大境界突破需要天劫或特殊条件
|
||||
|
||||
## 叙事指导
|
||||
|
||||
修炼与悟道是叙事核心,但必须融入剧情而非独立说教。
|
||||
悟道场景用五感描写,不用抽象哲理灌输。
|
||||
仙侠世界的规则感要强:因果、天劫、气运都是叙事工具。
|
||||
|
||||
人情债与道义约束是仙侠特有的驱动力。
|
||||
门派政治、宗门博弈是重要的布局手段。
|
||||
战斗以法术、法宝、阵法为核心,注重空间感和规模感。
|
||||
@@ -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缕" → ✓"手臂比先前有力了,握拳时指骨发紧"
|
||||
- 同一高潮段(如吞火/突破/觉醒)中,同一意象域的渲染不超过两轮,第三轮必须切入新信息或新动作
|
||||
- 搜尸/清点/装备段落禁止清单式列举,必须带入角色判断或取舍:✗"他翻出粗盐、水囊、黑面饼" → ✓"水囊最值钱,剩下那点水比命轻不了多少"
|
||||
|
||||
## 叙事指导
|
||||
|
||||
以战斗和资源获取驱动剧情。主角行为由利益驱动,杀伐果断。
|
||||
金手指/能力系统必须有限制:使用频率、范围限制或使用代价。
|
||||
设定不可吃书:前文确立的数值后文不可无升级过程地随意改变。
|
||||
|
||||
三章内应有明确反馈,但反馈可以是打脸、收益兑现、信息反转、地位变化,不限于杀人。
|
||||
涉及吞噬时,收益必须同时落到资源说明与具体增量,不能只写抽象提升。
|
||||
小冲突尽快兑现反馈;不要把爽点无限后置。
|
||||
|
||||
核心对手必须有脑子,有试探、有误判、有反扑。
|
||||
可以留人、钓鱼、示弱、借刀杀人,但前提只能是利益更大,绝不能是心软。
|
||||
|
||||
用动作、伤势、声音、重量、冲击、温度来落地"强",少用空泛判断。
|
||||
每个场景至少推进一项:信息、地位、资源、伤亡、仇恨、境界。
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<ParsedGenreProfile> {
|
||||
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<ReadonlyArray<{ readonly id: string; readonly name: string; readonly source: "project" | "builtin" }>> {
|
||||
const results = new Map<string, { id: string; name: string; source: "project" | "builtin" }>();
|
||||
|
||||
// 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<ParsedBookRules | null> {
|
||||
const raw = await tryReadFile(join(bookDir, "story/book_rules.md"));
|
||||
if (!raw) return null;
|
||||
return parseBookRules(raw);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof BookRulesSchema>;
|
||||
|
||||
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<string, unknown>;
|
||||
const rules = BookRulesSchema.parse(frontmatter);
|
||||
const body = fmMatch[2].trim();
|
||||
|
||||
return { rules, body };
|
||||
}
|
||||
@@ -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<typeof GenreProfileSchema>;
|
||||
|
||||
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<string, unknown>;
|
||||
const profile = GenreProfileSchema.parse(frontmatter);
|
||||
const body = fmMatch[2].trim();
|
||||
|
||||
return { profile, body };
|
||||
}
|
||||
Generated
+24
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user