feat: 配置中心-提示词与词表(四件套编辑/结构化词表/试金石面板/保存前验证)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user