Files
ai-kefu-admin/admin/src/views/config/center/model.ts
T

678 lines
23 KiB
TypeScript
Raw Normal View History

/**
* 配置中心提示词/词表/凭据编辑模型(Phase 3 Task 7b + 7c)
*
* 消费方:config/center 的 PromptsPane.vue / WallPane.vue / CredsPane.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 四组的变体键为封闭集合(未知键编译期显性拒绝);
* - 凭据三组(Task 7c):DEFAULT_CONFIG 的 email/push/ai 组键 +
* config_validation 数值边界(push.timeout/backoff 正数、max_attempts
* 1-10 整数、notify_emails 项含 @)+ config_views 空槽回退语义
* (空串/None = 未设置 → 回退 env,表单提示「留空 = 平台默认」)。
* 密钥哨兵交互见 src/api/mask.ts(GET '__SET__' → 空输入 + 占位符;
* 不动保存回传哨兵保持库值;「清除」= 提交空串)。
*/
import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask'
// ---- 提示词四件套 -----------------------------------------------------------------
/** 四件套键名(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)
}
// ---- 凭据三组(email / push / ai,Phase 3 Task 7c) --------------------------------
/** 邮件凭据表单(数值/密钥输入均为字符串形态——空串 = 未设置回退 env) */
export interface EmailCredsForm {
smtpHost: string
smtpPort: string
smtpUser: string
/** 密钥输入:初始恒空,「已设置」态由占位符表达(见 mask.ts) */
smtpPassword: string
senderName: string
notifyEmails: string[]
}
/** 推送凭据表单(timeout/maxAttempts/backoffBase:'' = null = 平台默认) */
export interface PushCredsForm {
enabled: boolean
url: string
apiKey: string
prefix: string
timeout: string
maxAttempts: string
backoffBase: string
}
/** AI 凭据表单 */
export interface AiCredsForm {
provider: string
apiKey: string
baseUrl: string
model: string
}
/** GET 组 → 表单 + 密钥掩码状态(掩码值驱动占位符与 secretToSubmit) */
export interface EmailCredsState {
form: EmailCredsForm
passwordMasked: string
}
export interface PushCredsState {
form: PushCredsForm
apiKeyMasked: string
}
export interface AiCredsState {
apiKeyMasked: string
form: AiCredsForm
}
/**
* 任意非空字符串 → 掩码哨兵。比 isMaskedSentinel 更保守:即使后端掩码
* 失效漏出原始密钥(理论不可达),也按「已设置」处理——不动保存回传
* 哨兵由后端还原库值,绝不把原始值再提交回去;空串/null/undefined → ''
* (未设置)。
*/
export function asSecretMasked(value: unknown): string {
return typeof value === 'string' && value !== '' ? MASKED_SENTINEL : ''
}
/** 防御读取:数值 → 表单串(非有限数值/缺省 → '') */
function numberToForm(value: unknown): string {
return typeof value === 'number' && Number.isFinite(value) ? String(value) : ''
}
/** 防御读取:字符串数组(非字符串项剔除) */
function strList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((v): v is string => typeof v === 'string')
: []
}
/** 表单串 → 整数 | null(空串 = 未设置;非整数由 validate 前置拦截) */
function intOrNull(value: string): number | null {
const n = Number(value.trim())
return value.trim() !== '' && Number.isInteger(n) ? n : null
}
/** 表单串 → 数值 | null(空串 = 未设置;非数值由 validate 前置拦截) */
function numOrNull(value: string): number | null {
const n = Number(value.trim())
return value.trim() !== '' && Number.isFinite(n) ? n : null
}
/** GET 的 email 组 → 邮件表单(smtp 缺省/null → 全空槽,回退 env) */
export function normalizeEmailCredsGroup(
value: Record<string, unknown> | null | undefined
): EmailCredsState {
const source = value ?? {}
const smtp = asRecord(source.smtp) ?? {}
return {
form: {
smtpHost: asStr(smtp.host),
smtpPort: numberToForm(smtp.port),
smtpUser: asStr(smtp.user),
smtpPassword: '',
senderName: asStr(source.sender_name),
notifyEmails: strList(source.notify_emails)
},
passwordMasked: asSecretMasked(smtp.password)
}
}
/** GET 的 push 组 → 推送表单 */
export function normalizePushCredsGroup(
value: Record<string, unknown> | null | undefined
): PushCredsState {
const source = value ?? {}
return {
form: {
enabled: source.enabled === true,
url: asStr(source.url),
apiKey: '',
prefix: asStr(source.prefix),
timeout: numberToForm(source.timeout),
maxAttempts: numberToForm(source.max_attempts),
backoffBase: numberToForm(source.retry_backoff_base)
},
apiKeyMasked: asSecretMasked(source.api_key)
}
}
/** GET 的 ai 组 → AI 表单 */
export function normalizeAiCredsGroup(
value: Record<string, unknown> | null | undefined
): AiCredsState {
const source = value ?? {}
return {
form: {
provider: asStr(source.provider),
apiKey: '',
baseUrl: asStr(source.base_url),
model: asStr(source.model)
},
apiKeyMasked: asSecretMasked(source.api_key)
}
}
/**
* 邮件表单 → PUT config/email 整组载荷。密钥走 secretToSubmit(未动 →
* 哨兵保持库值;「清除」按钮已把 maskedState 置 '',提交空串 = 显式清空)。
* 数值/邮箱格式由 validateEmailCredsForm 前置把关(后端 400 二层兜底)。
*/
export function buildEmailCredsPayload(
form: EmailCredsForm,
passwordMasked: string
): Record<string, unknown> {
return {
smtp: {
host: form.smtpHost.trim(),
port: intOrNull(form.smtpPort),
user: form.smtpUser.trim(),
password: secretToSubmit(form.smtpPassword, passwordMasked)
},
sender_name: form.senderName.trim(),
notify_emails: form.notifyEmails
.map((e) => e.trim())
.filter((e) => e !== '')
}
}
/** 推送表单 → PUT config/push 整组载荷(prefix 冲突 400 由后端跨租户查重) */
export function buildPushCredsPayload(
form: PushCredsForm,
apiKeyMasked: string
): Record<string, unknown> {
return {
enabled: form.enabled,
url: form.url.trim(),
api_key: secretToSubmit(form.apiKey, apiKeyMasked),
prefix: form.prefix.trim(),
timeout: numOrNull(form.timeout),
max_attempts: intOrNull(form.maxAttempts),
retry_backoff_base: numOrNull(form.backoffBase)
}
}
/** AI 表单 → PUT config/ai 整组载荷 */
export function buildAiCredsPayload(
form: AiCredsForm,
apiKeyMasked: string
): Record<string, unknown> {
return {
provider: form.provider.trim(),
api_key: secretToSubmit(form.apiKey, apiKeyMasked),
base_url: form.baseUrl.trim(),
model: form.model.trim()
}
}
/** 邮件表单预检(空槽合法——回退 env;只查非空行的格式) */
export function validateEmailCredsForm(form: EmailCredsForm): string[] {
const errors: string[] = []
const port = form.smtpPort.trim()
if (port !== '') {
const n = Number(port)
if (!Number.isInteger(n) || n < 1 || n > 65535) {
errors.push('SMTP 端口:须为 1-65535 的整数(留空 = 平台默认)')
}
}
form.notifyEmails.forEach((row, i) => {
const t = row.trim()
if (t !== '' && !t.includes('@')) {
errors.push(`通知邮箱第 ${i + 1} 行:须为含 @ 的邮箱地址`)
}
})
return errors
}
/** 推送表单预检(与后端 _check_push 的数值边界同款:正数 / 1-10 整数) */
export function validatePushCredsForm(form: PushCredsForm): string[] {
const errors: string[] = []
const url = form.url.trim()
if (url !== '' && !/^https?:\/\//.test(url)) {
errors.push('推送 URL:须以 http:// 或 https:// 开头')
}
const timeout = form.timeout.trim()
if (timeout !== '') {
const n = Number(timeout)
if (!Number.isFinite(n) || n <= 0) {
errors.push('推送超时(秒):须为正数(留空 = 平台默认)')
}
}
const attempts = form.maxAttempts.trim()
if (attempts !== '') {
const n = Number(attempts)
if (!Number.isInteger(n) || n < 1 || n > 10) {
errors.push('最大尝试次数:须为 1-10 的整数(留空 = 平台默认)')
}
}
const backoff = form.backoffBase.trim()
if (backoff !== '') {
const n = Number(backoff)
if (!Number.isFinite(n) || n <= 0) {
errors.push('重试退避基数(秒):须为正数(留空 = 平台默认)')
}
}
return errors
}
/** 推送探测不可达原因 → 修复提示文案(后端 reason 分类的前端展示层) */
export const PUSH_PROBE_REASON_LABELS: Record<string, string> = {
dns: '域名解析失败(检查 URL 拼写)',
timeout: '连接超时',
connect: '连接被拒绝或网络不可达'
}