diff --git a/admin/src/views/config/center/__tests__/model.test.ts b/admin/src/views/config/center/__tests__/model.test.ts index 816de42..50affbd 100644 --- a/admin/src/views/config/center/__tests__/model.test.ts +++ b/admin/src/views/config/center/__tests__/model.test.ts @@ -27,6 +27,7 @@ import { REMINDER_VARIANTS, RESET_GROUP_LABELS, TRIGGER_KEYS, + WALL_SCENARIOS, ZERO_NUMBER_KEYS, asSecretMasked, buildAiCredsPayload, @@ -34,6 +35,7 @@ import { buildPushCredsPayload, buildWallPayload, COMPARE_SCOPE_ALL, + collectRemovedWallVariables, descendingVersions, formatKB, formatVersionTime, @@ -46,14 +48,17 @@ import { normalizePushCredsGroup, parsePatternEntry, parseJsonGroupDraft, + poolLanguages, promptByteInfo, promptsOverCap, + removedTemplateVariables, rowToEntry, rowsToEntries, rowToSingleEntry, scopedConfigJson, secretFieldState, snapshotGroupKeys, + templateVariables, touchstoneSentences, utf8Bytes, validateEmailCredsForm, @@ -767,3 +772,51 @@ describe('secretFieldState(密钥四件套展示态)', () => { expect(s).toMatchObject({ badge: 'pending-clear', badgeType: 'warning', badgeLabel: '将清除 · 未保存' }) }) }) + +// ---- wall 重组辅助(场景卡 / 语言 tabs / 模板变量保护) ------------------------------- + +describe('poolLanguages(场景卡语言 tabs)', () => { + it('多池语言并集,必留语言在前、附加语言按序', () => { + expect(poolLanguages({ + deflections: { en: ['a'], zh: ['b'], es: ['c'] }, + deflections_contact: { en: ['a'], pt: ['d'] } + })).toEqual(['en', 'zh', 'es', 'pt']) + }) + it('空池 → 空数组', () => { + expect(poolLanguages({ deflections: {}, deflections_contact: {} })).toEqual([]) + }) +}) + +describe('模板变量保护', () => { + it('提取 {var} 变量', () => { + expect(templateVariables('到 {intl} 联系您,{shown} 已留')).toEqual(['intl', 'shown']) + }) + it('删除快照出现过的变量被检出', () => { + expect(removedTemplateVariables('发到 {intl} 联系', '发到手机联系')).toEqual(['intl']) + expect(removedTemplateVariables('发到 {intl}', '发到 {intl}')).toEqual([]) + }) + it('wall 整组递归检出(池+模板+提醒),同变量去重', () => { + const before = { + deflections: { en: ['call {intl}'] }, + cc_templates: { echo: { en: 'at {intl}' } }, + reminders: { cc_guide: { echo: '补全为 {intl}' } } + } + const after = { + deflections: { en: ['call me'] }, + cc_templates: { echo: { en: 'at {intl}' } }, + reminders: { cc_guide: { echo: '补全为 {intl}' } } + } + expect(collectRemovedWallVariables(before, after)).toEqual(['intl']) + }) + it('快照缺键不误报', () => { + expect(collectRemovedWallVariables({}, { deflections: { en: ['x'] } })).toEqual([]) + }) +}) + +describe('WALL_SCENARIOS(场景卡定义)', () => { + it('两池场景卡:同场景 × 未留资/已留资(boundary_filter has_contact 语义)', () => { + const price = WALL_SCENARIOS.find((s) => s.key === 'price') + expect(price?.pools.map((p) => p.field)).toEqual(['deflections', 'deflections_contact']) + expect(price?.pools.map((p) => p.when)).toEqual(['not_given', 'given']) + }) +}) diff --git a/admin/src/views/config/center/model.ts b/admin/src/views/config/center/model.ts index 6eff903..51ed961 100644 --- a/admin/src/views/config/center/model.ts +++ b/admin/src/views/config/center/model.ts @@ -418,6 +418,104 @@ export function buildWallPayload(model: WallEditModel): Record } } +// ---- wall 重组辅助(场景卡 / 语言 tabs / 模板变量保护——spec §5.2) ------------------ + +/** 池语言并集(场景卡 tabs 数据源;必留语言在前、附加语言按字母序——排序稳定) */ +export function poolLanguages( + pools: Record> +): string[] { + const set = new Set() + for (const pool of Object.values(pools)) { + for (const lang of Object.keys(pool)) set.add(lang) + } + const required = POOL_REQUIRED_LANGS.filter((l) => set.has(l)) + const extra = [...set] + .filter((l) => !(POOL_REQUIRED_LANGS as readonly string[]).includes(l)) + .sort() + return [...required, ...extra] +} + +/** 场景卡池位:field = wall 组键;when = 客户联系方式状态(boundary_filter has_contact) */ +export interface ScenarioPool { + field: PoolField + when: 'not_given' | 'given' +} + +export interface ScenarioDef { + key: 'price' | 'pivot' + title: string + note: string + pools: readonly ScenarioPool[] +} + +/** 两张双状态场景卡(no_contact / cc_templates 结构非池,模板层直接渲染不走本表) */ +export const WALL_SCENARIOS: readonly ScenarioDef[] = [ + { + key: 'price', + title: '问价格等敏感话题怎么回', + note: '客户问到价格/优惠/交付等不便直接回答的话题时,AI 用这些话术引导留联系方式', + pools: [ + { field: 'deflections', when: 'not_given' }, + { field: 'deflections_contact', when: 'given' } + ] + }, + { + key: 'pivot', + title: '怎么把话题引到要联系方式', + note: 'AI 主动转向时用的话术——先回应客户话题,再顺势要 WhatsApp 或邮箱', + pools: [ + { field: 'pivots', when: 'not_given' }, + { field: 'pivots_contact', when: 'given' } + ] + } +] + +/** 池位状态 → 卡内小节标题 */ +export const SCENARIO_WHEN_LABELS: Record = { + not_given: '客户还没留联系方式时', + given: '客户已经留过之后' +} + +// ---- 模板变量保护({intl}/{shown}/{no_contact_web} 等运行期回填占位符) --------------- + +/** 文本中的 {var} 占位符提取(字母/下划线开头,去重保序) */ +export function templateVariables(text: string): string[] { + return [...new Set([...text.matchAll(/\{([a-z_][a-z0-9_]*)\}/gi)].map((m) => m[1]))] +} + +/** before 出现过且 after 不再出现的变量(保存前警告清单) */ +export function removedTemplateVariables(before: string, after: string): string[] { + const afterSet = new Set(templateVariables(after)) + return templateVariables(before).filter((v) => !afterSet.has(v)) +} + +function collectRemovedStrings(before: unknown, after: unknown, out: string[]): void { + if (typeof before === 'string' && typeof after === 'string') { + out.push(...removedTemplateVariables(before, after)) + } else if ( + before !== null && after !== null && + typeof before === 'object' && typeof after === 'object' + ) { + const b = before as Record + const a = after as Record + for (const key of Object.keys(b)) collectRemovedStrings(b[key], a[key], out) + } +} + +/** wall 整组文本区(池/no_contact/cc_templates/reminders)递归比对——被删变量去重清单 */ +export function collectRemovedWallVariables( + before: Record | null | undefined, + after: Record | null | undefined +): string[] { + const out: string[] = [] + const b = before ?? {} + const a = after ?? {} + for (const key of [...POOL_FIELDS, 'no_contact', 'cc_templates', 'reminders']) { + collectRemovedStrings(b[key], a[key], out) + } + return [...new Set(out)] +} + // ---- 试金石 ------------------------------------------------------------------------ /** 试金石默认示例句:一句价格墙(blocked)/ 一句零号码墙(replaced)/