feat(admin): wall 重组辅助纯函数(场景卡/语言并集/模板变量保护)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-14 18:49:04 +08:00
parent d480a9f04e
commit 2e6811a55b
2 changed files with 151 additions and 0 deletions
@@ -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'])
})
})
+98
View File
@@ -418,6 +418,104 @@ export function buildWallPayload(model: WallEditModel): Record<string, unknown>
}
}
// ---- wall 重组辅助(场景卡 / 语言 tabs / 模板变量保护——spec §5.2) ------------------
/** 池语言并集(场景卡 tabs 数据源;必留语言在前、附加语言按字母序——排序稳定) */
export function poolLanguages(
pools: Record<string, Record<string, string[]>>
): string[] {
const set = new Set<string>()
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<ScenarioPool['when'], string> = {
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<string, unknown>
const a = after as Record<string, unknown>
for (const key of Object.keys(b)) collectRemovedStrings(b[key], a[key], out)
}
}
/** wall 整组文本区(池/no_contact/cc_templates/reminders)递归比对——被删变量去重清单 */
export function collectRemovedWallVariables(
before: Record<string, unknown> | null | undefined,
after: Record<string, unknown> | 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)/