feat: 配置中心-提示词与词表(四件套编辑/结构化词表/试金石面板/保存前验证)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 20:05:42 +08:00
parent cac91e9e3e
commit 39d72c53aa
8 changed files with 2229 additions and 10 deletions
+425
View File
@@ -0,0 +1,425 @@
/**
* 配置中心提示词/词表编辑模型(Phase 3 Task 7b)
*
* 消费方:config/center 的 PromptsPane.vue / WallPane.vue。纯逻辑无 DOM
* 依赖,vitest 直接覆盖(__tests__/model.test.ts)。
*
* 事实源对齐:
* - 提示词四件套键名/字节上限:app/engine/services/ai_service.PROMPTS_KEYS
* 与 app/platform/config_validation.PROMPTS_SIZE_CAPS(company_facts
* 50KB 软上限 spec §5.7,其余 100KB;UTF-8 字节口径——与后端
* len(text.encode('utf-8')) 一致,用 TextEncoder 计数);
* - 词表结构:app/engine/wall_schema.py(seed_profiles/jiancai/wall.json
* 为真实形态样本)。正则条目双形态:纯字符串 | {pattern, flags?,
* comment?}(flags 仅支持 'i');PUT wall 整组提交,未结构化编辑的
* 顶层键(含 "_" 前缀元信息键)原样透传;
* - 语言池必填 en/zh(wall_schema._REQUIRED_POOLS),reminders 六类 /
* cc_templates 四组的变体键为封闭集合(未知键编译期显性拒绝)。
*/
// ---- 提示词四件套 -----------------------------------------------------------------
/** 四件套键名(ai_service.PROMPTS_KEYS 镜像;prompts 是整组 PUT 的四键 dict) */
export const PROMPTS_KEYS = [
'system',
'company_facts',
'extractor',
'evaluator'
] as const
export type PromptKey = (typeof PROMPTS_KEYS)[number]
export type PromptsDraft = Record<PromptKey, string>
/** 各件字节上限(UTF-8 口径;config_validation.PROMPTS_SIZE_CAPS 镜像) */
export const PROMPTS_CAPS: Record<PromptKey, number> = {
system: 100 * 1024,
company_facts: 50 * 1024,
extractor: 100 * 1024,
evaluator: 100 * 1024
}
/** 四件套编辑器元数据(label/hint 为 UI 展示;deflectionPoolNote 仅 system) */
export const PROMPTS_META: Array<{
key: PromptKey
label: string
rows: number
hint: string
}> = [
{
key: 'system',
label: '系统提示词 system.md',
rows: 14,
hint: 'AI 行为总纲(职能边界/语言跟随等)'
},
{
key: 'company_facts',
label: '公司事实卡 company_facts.md',
rows: 10,
hint: '每轮整段拼入 system prompt——保持精炼(50KB 软上限)'
},
{
key: 'extractor',
label: '线索提取器 extractor.md',
rows: 8,
hint: '$existing_info / $messages 占位符由运行时回填'
},
{
key: 'evaluator',
label: '意向评估器 evaluator.md',
rows: 8,
hint: '$profile / $recent_messages 占位符由运行时回填'
}
]
/** UTF-8 字节数(后端 len(text.encode('utf-8')) 同口径) */
export function utf8Bytes(text: string): number {
return new TextEncoder().encode(text).length
}
/** 字节数展示(KB 一位小数) */
export function formatKB(bytes: number): string {
return `${(bytes / 1024).toFixed(1)}KB`
}
/** 单件字节状态(实时提示用) */
export interface PromptByteInfo {
bytes: number
cap: number
over: boolean
label: string
}
export function promptByteInfo(key: PromptKey, text: string): PromptByteInfo {
const bytes = utf8Bytes(text)
const cap = PROMPTS_CAPS[key]
return {
bytes,
cap,
over: bytes > cap,
label: `${formatKB(bytes)} / ${formatKB(cap)}`
}
}
/** GET 的 prompts 组(原样快照,可能缺键/类型漂移)→ 四键字符串表单值 */
export function normalizePromptsGroup(
value: Record<string, unknown> | null | undefined
): PromptsDraft {
const source = value ?? {}
const out = {} as PromptsDraft
for (const key of PROMPTS_KEYS) {
const v = source[key]
out[key] = typeof v === 'string' ? v : ''
}
return out
}
/** 四件套是否有超限件(超限即禁存——后端同样硬拒,前移到按钮态) */
export function promptsOverCap(draft: PromptsDraft): boolean {
return PROMPTS_KEYS.some((key) => utf8Bytes(draft[key]) > PROMPTS_CAPS[key])
}
// ---- 词表(wall 组)---------------------------------------------------------------
/** 正则条目双形态(wall_schema._compile_pattern):纯字符串 | dict */
export type WallPatternEntry =
| string
| { pattern?: unknown; flags?: unknown; comment?: unknown }
/** 正则条目编辑行(双形态归一;flags 仅 '' | 'i'——_FLAG_MAP 只认 'i') */
export interface RegexRow {
pattern: string
flags: '' | 'i'
comment: string
/** 备注编辑展开态(瞬态 UI 状态,不参与序列化) */
showComment: boolean
}
function blankRow(): RegexRow {
return { pattern: '', flags: '', comment: '', showComment: false }
}
/** 防御读取:一层对象或 null */
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
/** 条目 → 编辑行(字符串/dict 双形态;异常值防御归空) */
export function parsePatternEntry(entry: unknown): RegexRow {
if (typeof entry === 'string') {
return { pattern: entry, flags: '', comment: '', showComment: false }
}
const dict = asRecord(entry)
if (dict) {
return {
pattern: asStr(dict.pattern),
flags: dict.flags === 'i' ? 'i' : '',
comment: asStr(dict.comment),
showComment: false
}
}
return blankRow()
}
/** 编辑行 → 条目:无 flags 无备注 → 纯字符串(两种形态对编译等价,
* 与 wall.json 的极简条目保持一致);否则 dict 且只带非空键。
* 空 pattern 行返回 null(编译必 400「缺非空 pattern」,序列化期剔除)。 */
export function rowToEntry(
row: RegexRow
): string | Record<string, unknown> | null {
if (!row.pattern) return null
if (!row.flags && !row.comment.trim()) return row.pattern
const out: Record<string, unknown> = { pattern: row.pattern }
if (row.flags) out.flags = row.flags
if (row.comment.trim()) out.comment = row.comment
return out
}
/** 正则列表序列化(空 pattern 行剔除;列表可为空——由后端编译校验报路径) */
export function rowsToEntries(
rows: RegexRow[]
): Array<string | Record<string, unknown>> {
return rows
.map(rowToEntry)
.filter((e): e is string | Record<string, unknown> => e !== null)
}
/** 单条目字段(triggers 三键 / zero_number 两键)编辑行 → 条目:
* 无标志无备注且 pattern 非空 → 纯字符串(wall.json zero_number 出厂
* 形态,编译等价、往返无损);否则 dict 只带非空键——空 pattern 也走
* dict 分支,后端报「缺非空 pattern」字段路径(裸 '' 会编成匹配一切的
* 空正则,必须显性失败) */
export function rowToSingleEntry(
row: RegexRow
): string | Record<string, unknown> {
if (row.pattern && !row.flags && !row.comment.trim()) return row.pattern
const out: Record<string, unknown> = { pattern: row.pattern }
if (row.flags) out.flags = row.flags
if (row.comment.trim()) out.comment = row.comment
return out
}
// ---- 语言池 / 话术区结构(wall_schema 变体键镜像;封闭集合) ------------------------
/** 四个语言池字段(en/zh 必填——_REQUIRED_POOLS) */
export const POOL_FIELDS = [
'deflections',
'deflections_contact',
'pivots',
'pivots_contact'
] as const
export type PoolField = (typeof POOL_FIELDS)[number]
/** 池必填语言(删除时前端拦截;后端编译同样拒绝缺失) */
export const POOL_REQUIRED_LANGS = ['en', 'zh'] as const
/** reminders 六类 × 变体(wall_schema._REMINDER_VARIANTS 镜像) */
export const REMINDER_VARIANTS: Record<string, string[]> = {
boundary: ['budget', 'normal'],
contact_ask: ['app_already', 'app', 'web'],
language_follow: ['zh', 'es'],
lead_given: ['forbidden', 'respond_script', 'boundary_tail', 'suspect'],
cc_guide: ['echo', 'ask'],
email_light: ['en', 'zh']
}
/** cc_templates 四组 × 语言(wall_schema._CC_TEMPLATE_VARIANTS 镜像) */
export const CC_TEMPLATE_VARIANTS: Record<string, string[]> = {
ask: ['en', 'zh', 'es'],
wall: ['en', 'zh', 'es'],
note: ['en', 'zh'],
echo: ['en', 'zh', 'es']
}
export const NO_CONTACT_KEYS = ['web', 'app', 'already'] as const
/** triggers 三键 / zero_number 两键(单条目正则字段) */
export const TRIGGER_KEYS = ['boundary', 'budget', 'contact_ask'] as const
export const ZERO_NUMBER_KEYS = ['long_number', 'any_email'] as const
// ---- 词表整体编辑模型 --------------------------------------------------------------
/** 词表编辑模型:结构化编辑区(全部归一为可 v-model 的形态)+ 透传区 */
export interface WallEditModel {
/** 未提供结构化编辑的顶层键原样透传(含 "_" 前缀元信息键) */
passthrough: Record<string, unknown>
priceRows: RegexRow[]
reaskRows: RegexRow[]
/** triggers.boundary/budget/contact_ask + zero_number.long_number/any_email */
singles: Record<(typeof TRIGGER_KEYS)[number] | (typeof ZERO_NUMBER_KEYS)[number], RegexRow>
/** 四池:语言 → 句子列表(可增删行;序列化时空行剔除) */
pools: Record<PoolField, Record<string, string[]>>
noContact: Record<(typeof NO_CONTACT_KEYS)[number], string>
/** 六类 × 变体 → 话术文本(变体键封闭集合,缺项补空串交后端报路径) */
reminders: Record<string, Record<string, string>>
/** 四组 × 语言 → 模板文本({intl}/{cc_script} 等占位符白名单由后端校验) */
ccTemplates: Record<string, Record<string, string>>
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
/** 防御读取:字符串或空串 */
function asStr(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/** 防御读取:语言池 → 语言到字符串数组的映射(非字符串项剔除) */
function asPool(value: unknown): Record<string, string[]> {
const source = asRecord(value)
if (!source) return {}
const out: Record<string, string[]> = {}
for (const [lang, items] of Object.entries(source)) {
if (Array.isArray(items)) {
const rows = items.filter((t): t is string => typeof t === 'string')
if (rows.length > 0) out[lang] = rows
}
}
return out
}
/** 防御读取:一层字符串映射 → 只保留 variant 集合内的键(缺项补空串) */
function asVariantMap(
value: unknown,
variants: string[]
): Record<string, string> {
const source = asRecord(value) ?? {}
const out: Record<string, string> = {}
for (const key of variants) {
out[key] = asStr(source[key])
}
return out
}
/** 结构化编辑接管的顶层键(其余透传) */
const KNOWN_WALL_EDIT_KEYS = new Set<string>([
'price_patterns',
'reask_patterns',
'triggers',
'zero_number',
...POOL_FIELDS,
'no_contact',
'reminders',
'cc_templates'
])
/**
* GET 的 wall 组 → 编辑模型。wall 缺失/非对象/空对象 → null(面板出空态
* ——wall 组来自租户 seed 的 wall.json,缺失时无从结构化编辑)。结构上
* 无法通过 PUT 校验的存量数据(理论不可达——PUT 前有编译校验)也归一为
* 可编辑形态,保存时由后端 400 带字段路径显性兜底。
*/
export function initWallEdit(
source: Record<string, unknown> | null | undefined
): WallEditModel | null {
const record = asRecord(source)
if (!record || Object.keys(record).length === 0) return null
const rest = Object.fromEntries(
Object.entries(record).filter(([k]) => !KNOWN_WALL_EDIT_KEYS.has(k))
)
const triggersSrc = asRecord(record.triggers)
const zeroSrc = asRecord(record.zero_number)
const singles = {} as WallEditModel['singles']
for (const key of TRIGGER_KEYS) {
singles[key] = parsePatternEntry(triggersSrc?.[key])
}
for (const key of ZERO_NUMBER_KEYS) {
singles[key] = parsePatternEntry(zeroSrc?.[key])
}
const pools = {} as WallEditModel['pools']
for (const field of POOL_FIELDS) {
pools[field] = asPool(record[field])
}
const reminders: Record<string, Record<string, string>> = {}
const remindersSrc = asRecord(record.reminders)
for (const [cat, variants] of Object.entries(REMINDER_VARIANTS)) {
reminders[cat] = asVariantMap(remindersSrc?.[cat], variants)
}
const ccTemplates: Record<string, Record<string, string>> = {}
const ccSrc = asRecord(record.cc_templates)
for (const [group, langs] of Object.entries(CC_TEMPLATE_VARIANTS)) {
ccTemplates[group] = asVariantMap(ccSrc?.[group], langs)
}
const noContact = asVariantMap(record.no_contact, [...NO_CONTACT_KEYS]) as WallEditModel['noContact']
return {
passthrough: clone(rest),
priceRows: parsePatternList(record.price_patterns),
reaskRows: parsePatternList(record.reask_patterns),
singles,
pools,
noContact,
reminders,
ccTemplates
}
}
function parsePatternList(value: unknown): RegexRow[] {
return Array.isArray(value) ? value.map(parsePatternEntry) : []
}
/** 池序列化:空行剔除;剔除后为空的语言整键丢弃(空列表必被编译拒绝,
* 丢弃后缺 en/zh 由后端报「缺语言」路径——同为显性失败,语义更准) */
function cleanPool(pool: Record<string, string[]>): Record<string, string[]> {
const out: Record<string, string[]> = {}
for (const [lang, rows] of Object.entries(pool)) {
const cleaned = rows.filter((t) => t.trim() !== '')
if (cleaned.length > 0) out[lang] = cleaned
}
return out
}
/**
* 编辑模型 → PUT config/wall 整组载荷:结构化区按编辑值重组,其余顶层键
* 原样透传。正则编译/结构问题不在前端造校验器——后端 compile_wall 的
* 400 自带字段路径(含试金石预演路径),内联展示即可。
*/
export function buildWallPayload(model: WallEditModel): Record<string, unknown> {
const triggers: Record<string, unknown> = {}
for (const key of TRIGGER_KEYS) {
triggers[key] = rowToSingleEntry(model.singles[key])
}
const zeroNumber: Record<string, unknown> = {}
for (const key of ZERO_NUMBER_KEYS) {
zeroNumber[key] = rowToSingleEntry(model.singles[key])
}
const pools: Record<string, unknown> = {}
for (const field of POOL_FIELDS) {
pools[field] = cleanPool(model.pools[field])
}
return {
...model.passthrough,
price_patterns: rowsToEntries(model.priceRows),
reask_patterns: rowsToEntries(model.reaskRows),
triggers,
zero_number: zeroNumber,
...pools,
no_contact: { ...model.noContact },
reminders: clone(model.reminders),
cc_templates: clone(model.ccTemplates)
}
}
// ---- 试金石 ------------------------------------------------------------------------
/** 试金石默认示例句:一句价格墙(blocked)/ 一句零号码墙(replaced)/
* 一句干净(pass)——三色判定一屏可见 */
export const DEFAULT_TOUCHSTONE_SENTENCES = [
'The price is 500 USD per ton.',
'You can reach me at john.doe@example.com.',
'What sizes of PPR pipes do you have in stock?'
]
/** 试金石请求句集:去空白过滤空行(后端要求非空字符串列表) */
export function touchstoneSentences(sentences: string[]): string[] {
return sentences.map((s) => s.trim()).filter(Boolean)
}