feat(admin): wall 重组辅助纯函数(场景卡/语言并集/模板变量保护)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)/
|
||||
|
||||
Reference in New Issue
Block a user