@sendevia/pi-ask-author 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sanitize.ts DELETED
@@ -1,279 +0,0 @@
1
- /**
2
- * @file sanitize.ts
3
- * @description 入参防御性清洗:`prepareArguments` 钩子与不可信工具边界的容错体系
4
- *
5
- * 核心架构特性:
6
- * - 容错范围:JSON 串包裹 / `raw` 包装键 / 字段别名 / 纯字符串选项 / 空选项兜底
7
- * - 白名单收敛:产物仅含 `RawParams` 声明字段(丢弃未知键),保障宿主 Schema 校验通过
8
- * - 防御纵深:本层做类型收敛 / 别名映射 / 结构规整 / 区间兜底,模型层再幂等兜底一次
9
- *
10
- * 依赖方向:sanitize.ts → format.ts / texts.ts / schema.ts(类型);仅由 index.ts 装配
11
- */
12
-
13
- import { cleanOptional, parseJson } from "./format.js";
14
- import { MAX_OPTIONS, MAX_QUESTIONS, TEXTS } from "./texts.js";
15
- import type { QuestionInput, QuestionOption, RawParams } from "./schema.js";
16
-
17
- /** 字段别名提取源对象形状(不可信边界,统一经 `pickString` 收敛) */
18
- type LooseRecord = Record<string, unknown>;
19
-
20
- /** `pickString` 别名表类型:按声明顺序获取第一个命中的字符串字段 */
21
- type StringAliases = readonly string[];
22
-
23
- /**
24
- * 判定不可信输入是否为宽松记录(非 null、非数组对象),工具入参边界的运行时守卫
25
- * @param value - 待检测值
26
- * @returns 非 null 的非数组对象时为 true(充当 `LooseRecord` 类型守卫)
27
- */
28
- function isPlainRecord(value: unknown): value is LooseRecord {
29
- return typeof value === "object" && value !== null && !Array.isArray(value);
30
- }
31
-
32
- /**
33
- * 解包 `raw` 包装键(载荷为可用对象时整体替换外层,否则在浅拷贝上摘除该键)
34
- * @param source - 外层参数对象浅拷贝
35
- * @returns 权威对象(`raw` 载荷或已摘除 `raw` 的外层对象)
36
- */
37
- function unwrapRawPayload(source: LooseRecord): LooseRecord {
38
- const raw = parseJson(source.raw);
39
- if (isPlainRecord(raw)) return raw;
40
- delete source.raw;
41
- return source;
42
- }
43
-
44
- /**
45
- * 依别名表提取第一个字符串型字段
46
- * @param o - 宽松源对象
47
- * @param aliases - 别名键列表(按声明顺序优先)
48
- * @returns 首个命中的字符串值(可为空串,不做空白清洗);全部未命中返回 undefined
49
- */
50
- function pickString(o: LooseRecord, aliases: StringAliases): string | undefined {
51
- for (const key of aliases) {
52
- const value = o[key];
53
- if (typeof value === "string") return value;
54
- }
55
- return undefined;
56
- }
57
-
58
- /**
59
- * 宽松解析布尔值(兼容布尔值 / 数字 `0`|`1` / 字符串 `"true"`|`"false"`|`"1"`|`"0"`,字符串大小写不敏感且忽略首尾空白)
60
- * @param val - 原始输入
61
- * @param defaultVal - 缺省默认值
62
- * @returns 解析结果;无法识别时返回 `defaultVal`
63
- */
64
- function parseBooleanValue(val: unknown, defaultVal: boolean): boolean {
65
- if (typeof val === "boolean") return val;
66
- if (val === 1) return true;
67
- if (val === 0) return false;
68
- if (typeof val === "string") {
69
- const s = val.trim().toLowerCase();
70
- if (s === "true" || s === "1") return true;
71
- if (s === "false" || s === "0") return false;
72
- }
73
- return defaultVal;
74
- }
75
-
76
- /**
77
- * 宽松解析整数(兼容数字字符串;非有限数值返回 undefined)
78
- * @param val - 原始输入
79
- * @returns 向下取整后的整数;数字非有限、字符串无法解析或类型不符时返回 undefined
80
- */
81
- function parseIntegerValue(val: unknown): number | undefined {
82
- if (typeof val === "number" && Number.isFinite(val)) return Math.floor(val);
83
- if (typeof val === "string") {
84
- const n = parseInt(val.trim(), 10);
85
- return Number.isFinite(n) ? n : undefined;
86
- }
87
- return undefined;
88
- }
89
-
90
- /**
91
- * 规整最少勾选数
92
- * @param val - 原始下限输入
93
- * @returns 负值收敛为 0;缺省、非整数或无法解析时返回 undefined(由 Schema 默认值裁决)
94
- */
95
- function sanitizeMinSelect(val: unknown): number | undefined {
96
- const n = parseIntegerValue(val);
97
- return n === undefined ? undefined : Math.max(0, n);
98
- }
99
-
100
- /**
101
- * 规整最多勾选数
102
- * @param val - 原始上限输入
103
- * @returns 不小于 1 的整数;缺省、非整数、无法解析或小于 1 时返回 undefined(由 Schema/模型裁决为选项总数)
104
- */
105
- function sanitizeMaxSelect(val: unknown): number | undefined {
106
- const n = parseIntegerValue(val);
107
- return n !== undefined && n >= 1 ? n : undefined;
108
- }
109
-
110
- /**
111
- * 规整选项列表为 `QuestionOption[]`(兼容纯字符串项、宽松对象项与字段别名,上限 `MAX_OPTIONS`)
112
- * @param value - 原始输入
113
- * @returns 规整后的选项数组;JSON 串解析失败或非数组输入返回空数组(调用方再经 `withDefaultOption` 兜底)
114
- */
115
- function coerceOptions(value: unknown): QuestionOption[] {
116
- const opts = parseJson(value);
117
- if (!Array.isArray(opts)) return [];
118
- return opts.slice(0, MAX_OPTIONS).map((item, idx): QuestionOption => {
119
- if (typeof item === "string") return { label: item.trim() || TEXTS.fallbacks.autoOptionLabel(idx + 1) };
120
- // 不可信边界收窄:守卫失败项回退自动标签,不做属性读取
121
- if (!isPlainRecord(item)) return { label: TEXTS.fallbacks.autoOptionLabel(idx + 1) };
122
- const o = item;
123
-
124
- // 声明式别名表:标签 / 推演 / 草稿 / 批注占位各按优先级命中
125
- return {
126
- label:
127
- cleanOptional(pickString(o, ["label", "name", "title", "text", "value", "choice"])) ??
128
- TEXTS.fallbacks.autoOptionLabel(idx + 1),
129
- description: cleanOptional(pickString(o, ["description", "desc", "reason", "rationale", "deduction"])),
130
- preview: cleanOptional(pickString(o, ["preview", "draft", "scene", "content"])),
131
- customPlaceholder: cleanOptional(pickString(o, ["customPlaceholder", "placeholder"])),
132
- };
133
- });
134
- }
135
-
136
- /**
137
- * 判定宽松输入是否形如单题(须含选项类字段 options/choices/items 或显式题目键 question/prompt;`questions` 批量容器一律排除)
138
- * @param value - 待检测输入
139
- * @returns 携带选项类字段或显式题目键时为 true;非对象、`null` 与含 `questions` 键的批量容器恒为 false(本函数不排除数组,调用方先 `Array.isArray` 分流)
140
- */
141
- function isQuestionLike(value: unknown): value is LooseRecord {
142
- if (typeof value !== "object" || value === null || "questions" in value) return false;
143
- return "options" in value || "choices" in value || "items" in value || "question" in value || "prompt" in value;
144
- }
145
-
146
- /**
147
- * 规整单题条目为符合 Schema 的输入结构(兼容别名字段;字符串条目直接充当标题,其余非对象条目回退自动标题 + 默认选项)
148
- * @param value - 单题输入
149
- * @param index - 题目顺序索引
150
- * @returns 字段集与 `QuestionSchema` 完全一致的单题对象(`options` 为空时至少含一个默认方案)
151
- */
152
- function sanitizeQuestionEntry(value: unknown, index: number): QuestionInput {
153
- if (typeof value === "string") {
154
- return { title: value, options: withDefaultOption([]) };
155
- }
156
- // 不可信边界收窄:经运行时守卫后按宽松记录读取别名,非对象条目回退自动标题 + 默认选项
157
- if (!isPlainRecord(value)) {
158
- return { title: TEXTS.fallbacks.autoQuestionTitle(index + 1), options: withDefaultOption([]) };
159
- }
160
- const o = value;
161
-
162
- return {
163
- id: pickString(o, ["id"]),
164
- title:
165
- cleanOptional(pickString(o, ["title", "question", "prompt", "query", "heading"])) ??
166
- TEXTS.fallbacks.autoQuestionTitle(index + 1),
167
- description: cleanOptional(pickString(o, ["description", "desc", "rationale"])),
168
- multiSelect: parseBooleanValue(o.multiSelect, false),
169
- minSelect: sanitizeMinSelect(o.minSelect),
170
- maxSelect: sanitizeMaxSelect(o.maxSelect),
171
- allowEmpty: parseBooleanValue(o.allowEmpty, false),
172
- options: withDefaultOption(coerceOptions(o.options ?? o.choices ?? o.items)),
173
- allowCustom: parseBooleanValue(o.allowCustom, true),
174
- customPlaceholder: pickString(o, ["customPlaceholder", "placeholder"]),
175
- };
176
- }
177
-
178
- /**
179
- * 归一化题目列表(兼容 JSON 串包裹 / 单题对象包裹 / 数组,上限 `MAX_QUESTIONS`)
180
- * 边界:空列表或无法识别为题目结构的输入视为未提供
181
- * @param value - `questions` / `form` / `survey` 容器键的原始值
182
- * @returns 规整后的题目数组(至少一项);入参为 undefined / 空列表 / 无法识别时返回 undefined
183
- */
184
- function normalizeQuestions(value: unknown): QuestionInput[] | undefined {
185
- if (value === undefined) return undefined;
186
- const parsed = parseJson(value);
187
- const questionList = Array.isArray(parsed) ? parsed : isQuestionLike(parsed) ? [parsed] : [];
188
- if (questionList.length === 0) return undefined;
189
- return questionList.slice(0, MAX_QUESTIONS).map((entry, index) => sanitizeQuestionEntry(entry, index));
190
- }
191
-
192
- /**
193
- * 空选项数组兜底为单个默认方案
194
- * @param options - 已规整的选项数组
195
- * @returns 原数组(非空时)或单元素默认方案数组(恒非空,满足 Schema `minItems: 1`)
196
- */
197
- function withDefaultOption(options: QuestionOption[]): QuestionOption[] {
198
- return options.length > 0 ? options : [{ label: TEXTS.fallbacks.defaultOptionLabel }];
199
- }
200
-
201
- /**
202
- * 解析单题平铺模式题干(三级回退:`question` → `prompt` → 携带候选选项时的 `title` / `taskTitle`)
203
- * @param rawQuestion - 顶层 `question` 原始值
204
- * @param rawPrompt - `prompt` 别名原始值
205
- * @param aliasFormTitle - 由 `title` / `taskTitle` 提取的别名标题
206
- * @param hasFlatOptions - 平铺模式是否携带候选选项
207
- * @returns 题干;三个来源均未命中为 undefined(Schema 缺省裁决)
208
- */
209
- function resolveFlatQuestion(
210
- rawQuestion: unknown,
211
- rawPrompt: unknown,
212
- aliasFormTitle: string | undefined,
213
- hasFlatOptions: boolean,
214
- ): string | undefined {
215
- if (typeof rawQuestion === "string") return rawQuestion;
216
- if (typeof rawPrompt === "string") return rawPrompt;
217
- return hasFlatOptions ? aliasFormTitle : undefined;
218
- }
219
-
220
- /**
221
- * 入参防御性清洗(`prepareArguments` 钩子);容错面见文件头,各字段先收敛为本地强类型 `const` 再组装为显式白名单产物
222
- * @param args - 工具调用原始入参(不可信边界)
223
- * @returns 仅含 Schema 声明字段的 `RawParams` 白名单对象;输入完全不可辨识时仅返回兜底 `formTitle`
224
- */
225
- export function sanitizeAskAuthorArgs(args: unknown): RawParams {
226
- const source = args && typeof args === "object" ? args : parseJson(args);
227
- if (!isPlainRecord(source)) {
228
- return { formTitle: TEXTS.fallbacks.defaultFormTitle };
229
- }
230
-
231
- // 0. 权威对象:浅拷贝外层后解开 `raw` 包装(载荷可用时整体替换外层)
232
- const p: LooseRecord = unwrapRawPayload({ ...source });
233
-
234
- // 1. 表单标题别名链:formTitle → title / taskTitle → 缺省兜底(aliasFormTitle 留作平铺题干回退)
235
- const aliasFormTitle: string | undefined = cleanOptional(pickString(p, ["title", "taskTitle"]));
236
- const formTitle: string =
237
- cleanOptional(pickString(p, ["formTitle"])) ?? aliasFormTitle ?? TEXTS.fallbacks.defaultFormTitle;
238
-
239
- // 2. 规范化 questions 数组(兼容 questions / form / survey 容器键;空列表视为未提供)
240
- const questions: QuestionInput[] | undefined = normalizeQuestions(p.questions ?? p.form ?? p.survey);
241
-
242
- // 3. 规范化单题平铺候选选项(兼容 options / choices / items 容器键,任一键存在即视为平铺模式)
243
- const rawFlatOptions: unknown = p.options ?? p.choices ?? p.items;
244
- const hasFlatOptions: boolean = p.options !== undefined || p.choices !== undefined || p.items !== undefined;
245
- const options: QuestionOption[] | undefined = hasFlatOptions
246
- ? withDefaultOption(coerceOptions(rawFlatOptions))
247
- : undefined;
248
-
249
- // 4. 题干回退链:question → prompt 别名 →(平铺模式下)title 别名
250
- const question: string | undefined = resolveFlatQuestion(p.question, p.prompt, aliasFormTitle, hasFlatOptions);
251
-
252
- // 5. 平铺模式标量字段收敛:缺省字段保持 undefined,可选性由 Schema 裁决
253
- const description: string | undefined = pickString(p, ["description"]);
254
- const multiSelect: boolean | undefined =
255
- p.multiSelect === undefined ? undefined : parseBooleanValue(p.multiSelect, false);
256
- const minSelect: number | undefined = p.minSelect === undefined ? undefined : sanitizeMinSelect(p.minSelect);
257
- const maxSelect: number | undefined = p.maxSelect === undefined ? undefined : sanitizeMaxSelect(p.maxSelect);
258
- const allowEmpty: boolean | undefined =
259
- p.allowEmpty === undefined ? undefined : parseBooleanValue(p.allowEmpty, false);
260
- const allowCustom: boolean | undefined =
261
- p.allowCustom === undefined ? undefined : parseBooleanValue(p.allowCustom, true);
262
- const customPlaceholder: string | undefined = pickString(p, ["customPlaceholder"]);
263
-
264
- // 6. 白名单收敛:仅保留 RawParams 声明的字段(键序与 Schema 一致),丢弃全部不可信残留键
265
- const sanitized: RawParams = {
266
- formTitle,
267
- description,
268
- questions,
269
- question,
270
- multiSelect,
271
- minSelect,
272
- maxSelect,
273
- allowEmpty,
274
- options,
275
- allowCustom,
276
- customPlaceholder,
277
- };
278
- return sanitized;
279
- }
package/src/schema.ts DELETED
@@ -1,108 +0,0 @@
1
- /**
2
- * @file schema.ts
3
- * @description `ask_author` 工具参数 Schema 与类型的单一源头(Single Source of Truth)
4
- *
5
- * 核心架构特性:
6
- * - 字段名称、可选性与区间约束(`minItems` / `maxItems` / `minimum`)仅在 TypeBox 定义一次,运行时类型经 `Static<typeof>` 派生
7
- * - 默认值不写入 Schema:题目内字段由 `sanitize.ts` 规整时填入(`multiSelect`→false、`allowCustom`→true),模型层再按同一语义兜底
8
- * - LLM 端字段描述保持单句浓缩英文(取自 `TEXTS.schema`)
9
- *
10
- * 依赖方向:schema.ts → typebox / texts.ts(上限常量与字段描述);被 sanitize / model / index 消费
11
- */
12
-
13
- import { Type, type Static } from "typebox";
14
- import { MAX_OPTIONS, MAX_QUESTIONS, TEXTS } from "./texts.js";
15
-
16
- /** 单个候选选项的参数 Schema */
17
- const OptionSchema = Type.Object({
18
- /** 选项简短标题/行动标签(必须填写;缺失时经 `sanitize.ts` → `model.ts` 两级兜底为自动占位标签) */
19
- label: Type.String({ description: TEXTS.schema.optionLabel }),
20
- /** 情节推演后果或设定阐述(可选) */
21
- description: Type.Optional(Type.String({ description: TEXTS.schema.optionDesc })),
22
- /** Markdown 正文草稿或分镜头试撰写(可选) */
23
- preview: Type.Optional(Type.String({ description: TEXTS.schema.optionPreview })),
24
- /** 该选项专属补充说明的占位引导文案(可选) */
25
- customPlaceholder: Type.Optional(Type.String({ description: TEXTS.schema.optionCustomPlaceholder })),
26
- });
27
-
28
- /** 单个问题的参数 Schema(批量问卷与平铺模式共用) */
29
- const QuestionSchema = Type.Object({
30
- /** 题目唯一标识符(可选;缺省由模型层按顺序生成,跨题重复由模型层去重) */
31
- id: Type.Optional(Type.String({ description: TEXTS.schema.questionId })),
32
- /** 题目标题(必须填写;缺失时两级兜底为自动占位标题) */
33
- title: Type.String({ description: TEXTS.schema.questionTitle }),
34
- /** 题目背景与语境(可选) */
35
- description: Type.Optional(Type.String({ description: TEXTS.schema.questionDesc })),
36
- /** 是否允许多选(可选;默认 false,即单选) */
37
- multiSelect: Type.Optional(Type.Boolean({ description: TEXTS.schema.questionMultiSelect })),
38
- /** 最小勾选数下限(可选;仅多选有效,默认 1) */
39
- minSelect: Type.Optional(Type.Integer({ description: TEXTS.schema.questionMinSelect, minimum: 0 })),
40
- /** 最大勾选数上限(可选;仅多选有效,默认全部选项数) */
41
- maxSelect: Type.Optional(Type.Integer({ description: TEXTS.schema.questionMaxSelect, minimum: 1 })),
42
- /** 是否允许零勾选直接提交(可选;默认 false) */
43
- allowEmpty: Type.Optional(Type.Boolean({ description: TEXTS.schema.questionAllowEmpty })),
44
- /** 候选选项列表(必须填写;1 项起、上限 `MAX_OPTIONS`,建议 2~10 项) */
45
- options: Type.Array(OptionSchema, { description: TEXTS.schema.questionOptions, minItems: 1, maxItems: MAX_OPTIONS }),
46
- /** 是否允许自由补充说明(可选;默认 true) */
47
- allowCustom: Type.Optional(Type.Boolean({ description: TEXTS.schema.questionAllowCustom })),
48
- /** 题目整体补充说明的占位引导词(可选) */
49
- customPlaceholder: Type.Optional(Type.String({ description: TEXTS.schema.questionCustomPlaceholder })),
50
- });
51
-
52
- /** `ask_author` 工具完整参数 Schema(必须填写 `formTitle`;批量问卷传递 `questions`,单题平铺传递顶层 `question` + `options`) */
53
- export const AskAuthorParams = Type.Object({
54
- /** 问卷总任务标题(必须填写;简明概括请示主题) */
55
- formTitle: Type.String({ description: TEXTS.schema.formTitle }),
56
- /** 问卷全局背景或前述情境(可选;平铺单题模式下同时充当该题描述) */
57
- description: Type.Optional(Type.String({ description: TEXTS.schema.formDesc })),
58
-
59
- // ===== 批量问卷模式 =====
60
- /** 关联多题批量问卷列表(可选;批量模式使用,上限 `MAX_QUESTIONS` 题) */
61
- questions: Type.Optional(
62
- Type.Array(QuestionSchema, { description: TEXTS.schema.formQuestions, minItems: 1, maxItems: MAX_QUESTIONS }),
63
- ),
64
-
65
- // ===== 单题平铺模式(描述统一带 flat-mode 区分前缀) =====
66
- /** 单题平铺模式题目标题(可选;仅在携带非空 `options` 时参与选题,缺省回退 `TEXTS.fallbacks.defaultQuestionTitle`) */
67
- question: Type.Optional(Type.String({ description: TEXTS.schema.formSingleQuestion })),
68
- /** 单题平铺模式是否允许多选(可选;默认 false) */
69
- multiSelect: Type.Optional(
70
- Type.Boolean({ description: `${TEXTS.schema.flatPrefix}${TEXTS.schema.questionMultiSelect}` }),
71
- ),
72
- /** 单题平铺模式最少勾选数下限(可选;默认 1) */
73
- minSelect: Type.Optional(
74
- Type.Integer({ description: `${TEXTS.schema.flatPrefix}${TEXTS.schema.questionMinSelect}`, minimum: 0 }),
75
- ),
76
- /** 单题平铺模式最多勾选数上限(可选;默认全部选项数) */
77
- maxSelect: Type.Optional(
78
- Type.Integer({ description: `${TEXTS.schema.flatPrefix}${TEXTS.schema.questionMaxSelect}`, minimum: 1 }),
79
- ),
80
- /** 单题平铺模式是否允许零勾选提交(可选;默认 false) */
81
- allowEmpty: Type.Optional(
82
- Type.Boolean({ description: `${TEXTS.schema.flatPrefix}${TEXTS.schema.questionAllowEmpty}` }),
83
- ),
84
- /** 单题平铺模式候选选项列表(可选;1~`MAX_OPTIONS` 项) */
85
- options: Type.Optional(
86
- Type.Array(OptionSchema, { description: TEXTS.schema.flatQuestionOptions, minItems: 1, maxItems: MAX_OPTIONS }),
87
- ),
88
- /** 单题平铺模式是否允许自由补充说明(可选;默认 true) */
89
- allowCustom: Type.Optional(
90
- Type.Boolean({ description: `${TEXTS.schema.flatPrefix}${TEXTS.schema.questionAllowCustom}` }),
91
- ),
92
- /** 单题平铺模式补充说明的占位引导词(可选) */
93
- customPlaceholder: Type.Optional(
94
- Type.String({ description: `${TEXTS.schema.flatPrefix}${TEXTS.schema.questionCustomPlaceholder}` }),
95
- ),
96
- });
97
-
98
- /**
99
- * `ask_author` 工具的运行时参数类型(`prepareArguments` 清洗后的白名单形状)
100
- * 由 {@link AskAuthorParams} 经 `Static<typeof>` 单一源头派生,可选性与区间约束以 Schema 为准
101
- */
102
- export type RawParams = Static<typeof AskAuthorParams>;
103
-
104
- /** 单个候选选项的运行时类型(由 `OptionSchema` 经 `Static<typeof>` 派生) */
105
- export type QuestionOption = Static<typeof OptionSchema>;
106
-
107
- /** 单个问题的运行时类型(由 `QuestionSchema` 经 `Static<typeof>` 派生,批量与平铺共用) */
108
- export type QuestionInput = Static<typeof QuestionSchema>;