feat: 配置中心-提示词与词表(四件套编辑/结构化词表/试金石面板/保存前验证)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关)
|
||||
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石)
|
||||
*
|
||||
* 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与
|
||||
* upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http
|
||||
@@ -7,6 +7,8 @@
|
||||
* - GET config:快照 + version(原样组——缺键防御读取靠 normalizeBrandGroup)
|
||||
* - PUT config/brand:请求体是**组值本身**(整组替换语义,非 {note,value}
|
||||
* 包装),备注走 comment 查询参数,无备注时不带 params
|
||||
* - POST wall/test(Task 7b):句子列表 + wall 覆盖(当前表单值预演,
|
||||
* 不落库)+ has_contact/source 可选项——保存前自动验证复用本函数
|
||||
* - 头像上传:multipart file 字段(FormData)
|
||||
* - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项
|
||||
*/
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
fetchTenantConfig,
|
||||
normalizeBrandGroup,
|
||||
putConfigGroup,
|
||||
testWall,
|
||||
uploadBrandAvatar
|
||||
} from '@/api/config'
|
||||
|
||||
@@ -116,6 +119,81 @@ describe('配置中心 API(src/api/config.ts)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('试金石 API(testWall · Phase 3 Task 7b)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('POST wall/test:句子 + wall 覆盖(当前表单值预演)+ 判定选项', async () => {
|
||||
const response = {
|
||||
slug: 'jiancai',
|
||||
wall_source: 'override',
|
||||
results: [
|
||||
{
|
||||
sentence: 'The price is 500 USD per ton.',
|
||||
action: 'blocked',
|
||||
reason: 'boundary',
|
||||
replacement: 'Prices vary a lot by config...'
|
||||
},
|
||||
{ sentence: 'PPR pipes in stock?', action: 'pass', reason: null, replacement: null }
|
||||
]
|
||||
}
|
||||
vi.mocked(http.post).mockResolvedValue(response)
|
||||
const wallOverride = {
|
||||
price_patterns: ['\\$\\s?\\d'],
|
||||
deflections: { en: ['...'], zh: ['...'] }
|
||||
}
|
||||
|
||||
const res = await testWall(
|
||||
'jiancai',
|
||||
{
|
||||
sentences: ['The price is 500 USD per ton.', 'PPR pipes in stock?'],
|
||||
wall: wallOverride,
|
||||
has_contact: true,
|
||||
source: 'app'
|
||||
},
|
||||
{ showErrorMessage: false }
|
||||
)
|
||||
|
||||
expect(res).toEqual(response)
|
||||
expect(http.post).toHaveBeenCalledTimes(1)
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/api/platform/tenants/jiancai/wall/test',
|
||||
{
|
||||
sentences: ['The price is 500 USD per ton.', 'PPR pipes in stock?'],
|
||||
wall: wallOverride, // 覆盖墙原样直传——后端 compile_wall 校验后纯内存判定
|
||||
has_contact: true,
|
||||
source: 'app'
|
||||
},
|
||||
{ showErrorMessage: false } // 坏墙 400 字段路径由调用方内联展示
|
||||
)
|
||||
})
|
||||
|
||||
it('POST wall/test:最小 body(仅句子——缺省用当前生效墙)', async () => {
|
||||
const response = {
|
||||
slug: 'jiancai',
|
||||
wall_source: 'current',
|
||||
results: [{ sentence: 'Hello', action: 'pass', reason: null, replacement: null }]
|
||||
}
|
||||
vi.mocked(http.post).mockResolvedValue(response)
|
||||
|
||||
const res = await testWall('jiancai', { sentences: ['Hello'] })
|
||||
|
||||
expect(res).toEqual(response)
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/api/platform/tenants/jiancai/wall/test',
|
||||
{ sentences: ['Hello'] },
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('CONFIG_ENDPOINTS.wallTest:端点路径与后端 config_routes 一致', () => {
|
||||
expect(CONFIG_ENDPOINTS.wallTest('jiancai')).toBe(
|
||||
'/api/platform/tenants/jiancai/wall/test'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => {
|
||||
it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => {
|
||||
const out = normalizeBrandGroup({ title: 'HESUPIPE Support' })
|
||||
|
||||
+31
-3
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 租户配置中心 API(Phase 3 Task 3 骨架;Task 7a 实装品牌相关调用函数)
|
||||
* 租户配置中心 API(Phase 3 Task 3 骨架;Task 7a 实装品牌相关调用函数;
|
||||
* Task 7b 补词表试金石调用)
|
||||
*
|
||||
* 端点事实源:app/platform/routes/config_routes.py
|
||||
* (配置快照 / 分组编辑 / 版本历史 / 回滚 / 重置 / 试金石)
|
||||
@@ -16,6 +17,14 @@
|
||||
* - 版本行字段是 updated_by / comment(非骨架期误写的 created_by / note);
|
||||
* - 回滚请求体只认 version(note 不被后端读取);
|
||||
* - 试金石响应逐句为 {sentence, action, reason, replacement}。
|
||||
*
|
||||
* ## Task 7b 试金石契约(config_routes.test_wall)
|
||||
*
|
||||
* - body:sentences 必填;wall 可选覆盖(编辑器当前表单值的**预演**——
|
||||
* 后端先 compile_wall 校验后纯内存判定,不落库不 bump 版本),坏墙
|
||||
* 400 带字段路径;has_contact / source 可选(默认 false / web);
|
||||
* - 判定优先级与 conversation.py 执行侧一致:价格墙(blocked)→
|
||||
* 零号码墙(replaced)→ 再索要墙(仅 has_contact,blocked 无替换)。
|
||||
*/
|
||||
|
||||
import { http, type RequestOptions } from './client'
|
||||
@@ -94,9 +103,12 @@ export interface ConfigRollbackResponse {
|
||||
config: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** 词表试金石请求(POST .../wall/test) */
|
||||
/** 词表试金石请求(POST .../wall/test)
|
||||
* wall 覆盖 = 编辑器当前表单值的预演(后端 compile_wall 校验 + 纯内存
|
||||
* 判定,不落库);缺省用当前生效墙。 */
|
||||
export interface WallTestRequest {
|
||||
sentences: string[]
|
||||
wall?: Record<string, unknown>
|
||||
has_contact?: boolean
|
||||
source?: 'web' | 'app'
|
||||
}
|
||||
@@ -169,6 +181,20 @@ export function putConfigGroup(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 词表试金石(POST .../wall/test):逐句墙判定预览。传 wall 覆盖 =
|
||||
* 编辑器当前表单值的预演(不落库不 bump 版本)——保存词表前的自动
|
||||
* 验证复用本函数;坏墙(正则编译失败等)400 带字段路径,调用方需
|
||||
* showErrorMessage: false 自行内联展示。
|
||||
*/
|
||||
export function testWall(
|
||||
slug: string,
|
||||
body: WallTestRequest,
|
||||
config?: RequestOptions
|
||||
): Promise<WallTestResponse> {
|
||||
return http.post<WallTestResponse>(CONFIG_ENDPOINTS.wallTest(slug), body, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像上传(multipart file 字段):魔数校验 / 2MB 上限在后端
|
||||
* (upload_routes.py),前端做扩展名与大小预检提前拦截。
|
||||
@@ -190,7 +216,9 @@ export function uploadBrandAvatar(
|
||||
|
||||
/**
|
||||
* GET 的 brand 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。
|
||||
* 缺键补空串(与 DEFAULT_CONFIG.brand 的空串出厂一致);topics 只留字符串项。
|
||||
* 缺键补空串作表单中性值:提交空串 = 显式清空覆盖出厂(出厂
|
||||
* title/color/avatar 本非空——"AI Support"/"#066fc0"/内置头像),快照
|
||||
* 缺省键则由消费侧深合并回落出厂;UI 所见即所得。topics 只留字符串项。
|
||||
*/
|
||||
export function normalizeBrandGroup(
|
||||
value: Record<string, unknown> | null | undefined
|
||||
|
||||
+2
@@ -70,6 +70,8 @@ declare module 'vue' {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
<!--
|
||||
提示词四件套编辑(Phase 3 Task 7b;消费 config/center/model.ts 纯逻辑)
|
||||
- GET config 回显 prompts 组(四键 dict)→ 四个等宽 textarea 编辑 →
|
||||
PUT config/prompts 整组提交(prompts 是 AI 行为源头——保存备注**必填**)
|
||||
- 字节数实时提示(UTF-8 口径 TextEncoder,与后端 len(encode) 一致):
|
||||
company_facts 50KB / 其余 100KB;超限红显 + 禁存(后端同样硬拒)
|
||||
- system.md 编辑器旁固定 $DEFLECTION_POOL 说明卡:该占位符由安全词表
|
||||
迂回话术池自动注入,勿在提示词里手写话术清单(话术单一事实源)
|
||||
- 400 校验 detail(带字段路径)内联展示在保存按钮上方
|
||||
-->
|
||||
<template>
|
||||
<ElCard shadow="never" class="form-card prompts-pane">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">提示词(prompts 组 · 四件套整组替换)</span>
|
||||
<span class="card-sub">保存即写新版本并热生效——备注必填(AI 行为源头)</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElAlert
|
||||
v-if="actionError"
|
||||
type="error"
|
||||
:closable="true"
|
||||
:title="actionError"
|
||||
class="pane-error"
|
||||
@close="actionError = ''"
|
||||
/>
|
||||
|
||||
<!-- system.md + $DEFLECTION_POOL 说明卡 -->
|
||||
<div class="prompt-block">
|
||||
<div class="prompt-head">
|
||||
<span class="prompt-label mono">{{ meta.key }}.md</span>
|
||||
<span class="prompt-name">{{ meta.label }}</span>
|
||||
<span class="prompt-bytes" :class="{ over: byteInfos.system.over }">
|
||||
{{ byteInfos.system.label }}(UTF-8 字节)
|
||||
</span>
|
||||
</div>
|
||||
<p class="prompt-hint">{{ meta.hint }}</p>
|
||||
<div class="system-row">
|
||||
<ElInput
|
||||
v-model="draft.system"
|
||||
type="textarea"
|
||||
:rows="meta.rows"
|
||||
resize="vertical"
|
||||
class="mono-input"
|
||||
/>
|
||||
<div class="deflection-note">
|
||||
<p class="note-title">$DEFLECTION_POOL 占位符</p>
|
||||
<p class="note-text">
|
||||
此占位符自动注入安全词表(wall 组)deflections 迂回话术池,
|
||||
<b>勿在提示词里手写话术清单</b>——改话术请去「安全词表」tab,
|
||||
这里只保留 <span class="mono">$DEFLECTION_POOL</span> 占位符本身。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其余三件 -->
|
||||
<div v-for="item in restMeta" :key="item.key" class="prompt-block">
|
||||
<div class="prompt-head">
|
||||
<span class="prompt-label mono">{{ item.key }}.md</span>
|
||||
<span class="prompt-name">{{ item.label }}</span>
|
||||
<span class="prompt-bytes" :class="{ over: byteInfos[item.key].over }">
|
||||
{{ byteInfos[item.key].label }}(UTF-8 字节)
|
||||
</span>
|
||||
</div>
|
||||
<p class="prompt-hint">{{ item.hint }}</p>
|
||||
<ElInput
|
||||
v-model="draft[item.key]"
|
||||
type="textarea"
|
||||
:rows="item.rows"
|
||||
resize="vertical"
|
||||
class="mono-input"
|
||||
/>
|
||||
<ElAlert
|
||||
v-if="byteInfos[item.key].over"
|
||||
type="error"
|
||||
:closable="false"
|
||||
class="over-alert"
|
||||
:title="`${item.key} 超过 ${formatKB(byteInfos[item.key].cap)} 上限——每轮整段拼入 system prompt,请精炼(后端将拒绝保存)`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="save-bar">
|
||||
<ElInput
|
||||
v-model="saveComment"
|
||||
maxlength="200"
|
||||
placeholder="必填:本次提示词改了什么(写入版本历史 comment)"
|
||||
class="comment-input"
|
||||
/>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!canSave"
|
||||
@click="onSave"
|
||||
>
|
||||
保存提示词组(整组提交,热生效)
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PROMPTS_KEYS,
|
||||
PROMPTS_META,
|
||||
formatKB,
|
||||
normalizePromptsGroup,
|
||||
promptByteInfo,
|
||||
promptsOverCap,
|
||||
type PromptsDraft
|
||||
} from './model'
|
||||
import { putConfigGroup } from '@/api/config'
|
||||
|
||||
defineOptions({ name: 'PromptsPane' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 租户 slug(保存目标) */
|
||||
slug: string
|
||||
/** GET config 回显的 prompts 组(原样快照;null = 尚未加载) */
|
||||
group: Record<string, unknown> | null | undefined
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 保存成功:父层更新版本号与快照组 */
|
||||
(e: 'saved', payload: { version: number; group: 'prompts'; config: Record<string, unknown> }): void
|
||||
}>()
|
||||
|
||||
// ---- 编辑态(group 引用变化 = 租户切换/重载 → 重置表单) ----------------------------
|
||||
|
||||
const draft = reactive<PromptsDraft>(normalizePromptsGroup(undefined))
|
||||
const saveComment = ref('')
|
||||
const saving = ref(false)
|
||||
const actionError = ref('')
|
||||
|
||||
watch(
|
||||
() => props.group,
|
||||
(group) => {
|
||||
Object.assign(draft, normalizePromptsGroup(group))
|
||||
saveComment.value = ''
|
||||
actionError.value = ''
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// ---- 字节实时提示 -------------------------------------------------------------------
|
||||
|
||||
const byteInfos = computed(() => {
|
||||
const out = {} as Record<(typeof PROMPTS_KEYS)[number], ReturnType<typeof promptByteInfo>>
|
||||
for (const key of PROMPTS_KEYS) {
|
||||
out[key] = promptByteInfo(key, draft[key])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/** system 单列渲染(旁边挂 $DEFLECTION_POOL 说明卡),其余三件走列表 */
|
||||
const meta = PROMPTS_META[0]
|
||||
const restMeta = PROMPTS_META.slice(1)
|
||||
|
||||
const canSave = computed(
|
||||
() => saveComment.value.trim() !== '' && !promptsOverCap(draft)
|
||||
)
|
||||
|
||||
// ---- 保存(整组 PUT;备注必填) ------------------------------------------------------
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
if (!canSave.value || saving.value) return
|
||||
actionError.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await putConfigGroup(
|
||||
props.slug,
|
||||
'prompts',
|
||||
{
|
||||
system: draft.system,
|
||||
company_facts: draft.company_facts,
|
||||
extractor: draft.extractor,
|
||||
evaluator: draft.evaluator
|
||||
},
|
||||
saveComment.value.trim(),
|
||||
{ showErrorMessage: false }
|
||||
)
|
||||
saveComment.value = ''
|
||||
ElMessage.success(`提示词已保存(版本 v${res.version},热生效)`)
|
||||
emit('saved', { version: res.version, group: 'prompts', config: res.config })
|
||||
} catch (error) {
|
||||
// 400 校验 detail(带字段路径,如 "prompts.company_facts: 内容 … 超过上限 …")
|
||||
actionError.value = error instanceof Error ? error.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.prompts-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
}
|
||||
|
||||
.pane-error {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prompt-block {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.prompt-head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.prompt-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.prompt-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.prompt-bytes {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-400);
|
||||
|
||||
&.over {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-hint {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
|
||||
.system-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
|
||||
.mono-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deflection-note {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--el-color-warning-light-7);
|
||||
border-radius: 6px;
|
||||
background: var(--el-color-warning-light-9);
|
||||
|
||||
.note-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.note-text {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.over-alert {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
:deep(.mono-input textarea) {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
.comment-input {
|
||||
width: 360px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,925 @@
|
||||
<!--
|
||||
安全词表编辑 + 试金石(Phase 3 Task 7b;消费 config/center/model.ts 纯逻辑)
|
||||
- 结构化但务实:按 wall.json 结构分组(ElCollapse 可折叠区块),正则列表
|
||||
用可增删行编辑器(每条一行 + flags 标记 + comment 显示/编辑;双形态:
|
||||
纯字符串与 {pattern, flags, comment});语言池按语言增删句子行;
|
||||
no_contact 三句 / triggers·zero_number 单正则 / reminders 六类话术 /
|
||||
cc_templates 四组语言模板;未接管的顶层键(_meta 等)保存时原样透传
|
||||
- 试金石面板:句子列表(可增删,默认 3 示例句)→ POST wall/test(当前
|
||||
表单值作 wall 覆盖 = 不落库预演)→ 逐句结果:pass 绿 / blocked 红 /
|
||||
replaced 橙 + 替换文案;has_contact / source 开关影响再索要墙与替换句
|
||||
- 保存链:保存词表前自动先跑一次试金石(当前表单值预演)——有
|
||||
blocked/replaced 时二次确认「判定符合预期再保存」;PUT config/wall
|
||||
整组提交;正则编译错误 400 的字段路径展示在内联错误区;版本 +1 提示
|
||||
-->
|
||||
<template>
|
||||
<div class="wall-pane">
|
||||
<ElCard shadow="never" class="form-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">安全词表(wall 组 · 整组替换)</span>
|
||||
<span class="card-sub">保存即写新版本并热生效;保存前自动跑试金石</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElAlert
|
||||
v-if="actionError"
|
||||
type="error"
|
||||
:closable="true"
|
||||
class="pane-error"
|
||||
:title="actionError"
|
||||
@close="actionError = ''"
|
||||
/>
|
||||
|
||||
<ElEmpty
|
||||
v-if="!model"
|
||||
description="当前租户快照无 wall 组——词表随租户 seed(wall.json)创建,缺失时无从结构化编辑"
|
||||
:image-size="80"
|
||||
/>
|
||||
|
||||
<ElCollapse v-else v-model="openSections" class="wall-collapse">
|
||||
<!-- 正则列表区:价格墙 / 再索要墙 -->
|
||||
<ElCollapseItem
|
||||
v-for="section in patternListSections"
|
||||
:key="section.key"
|
||||
:name="section.key"
|
||||
:title="section.title"
|
||||
>
|
||||
<p class="section-note">{{ section.note }}</p>
|
||||
<div
|
||||
v-for="(row, i) in section.rows.value"
|
||||
:key="i"
|
||||
class="regex-row"
|
||||
>
|
||||
<div class="regex-line">
|
||||
<span class="row-index">{{ i + 1 }}</span>
|
||||
<ElInput
|
||||
v-model="row.pattern"
|
||||
placeholder="正则表达式(Python re 语法)"
|
||||
class="mono-input pattern-input"
|
||||
/>
|
||||
<ElSelect v-model="row.flags" class="flags-select" title="标志位">
|
||||
<ElOption value="" label="无" />
|
||||
<ElOption value="i" label="i 忽略大小写" />
|
||||
</ElSelect>
|
||||
<ElButton
|
||||
:type="row.comment ? 'primary' : 'default'"
|
||||
link
|
||||
title="设计备注(comment,随词表保存但不参与编译)"
|
||||
@click="row.showComment = !row.showComment"
|
||||
>
|
||||
备注{{ row.comment ? '●' : '○' }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="danger"
|
||||
link
|
||||
title="删除此条"
|
||||
@click="section.remove(i)"
|
||||
>
|
||||
删
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElInput
|
||||
v-if="row.showComment"
|
||||
v-model="row.comment"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||
placeholder="comment:设计说明(防误杀断言等),保存为条目 comment 字段"
|
||||
class="comment-input"
|
||||
/>
|
||||
<p v-else-if="row.comment" class="comment-view mono">{{ row.comment }}</p>
|
||||
</div>
|
||||
<ElButton size="small" @click="section.add()">+ 添加一条</ElButton>
|
||||
</ElCollapseItem>
|
||||
|
||||
<!-- 单正则区:triggers 三键 / zero_number 两键 -->
|
||||
<ElCollapseItem
|
||||
v-for="section in singlePatternSections"
|
||||
:key="section.key"
|
||||
:name="section.key"
|
||||
:title="section.title"
|
||||
>
|
||||
<p class="section-note">{{ section.note }}</p>
|
||||
<div class="regex-row">
|
||||
<div class="regex-line">
|
||||
<ElInput
|
||||
v-model="model.singles[section.key].pattern"
|
||||
placeholder="正则表达式(Python re 语法)"
|
||||
class="mono-input pattern-input"
|
||||
/>
|
||||
<ElSelect
|
||||
v-model="model.singles[section.key].flags"
|
||||
class="flags-select"
|
||||
title="标志位"
|
||||
>
|
||||
<ElOption value="" label="无" />
|
||||
<ElOption value="i" label="i 忽略大小写" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
<p
|
||||
v-if="model.singles[section.key].comment"
|
||||
class="comment-view mono"
|
||||
>
|
||||
{{ model.singles[section.key].comment }}
|
||||
</p>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
|
||||
<!-- 语言池区:四池 -->
|
||||
<ElCollapseItem
|
||||
v-for="field in POOL_FIELDS"
|
||||
:key="field"
|
||||
:name="`pool-${field}`"
|
||||
:title="POOL_META[field].title"
|
||||
>
|
||||
<p class="section-note">{{ POOL_META[field].note }}</p>
|
||||
<div v-for="(rows, lang) in model.pools[field]" :key="lang" class="pool-lang">
|
||||
<div class="pool-lang-head">
|
||||
<ElTag size="small" type="info">{{ langLabel(lang) }}</ElTag>
|
||||
<span v-if="isPoolRequiredLang(lang)" class="lang-required">
|
||||
必填语言(en/zh 为编译必需,不可删除)
|
||||
</span>
|
||||
<ElButton
|
||||
v-else
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click="removePoolLang(field, lang)"
|
||||
>
|
||||
删除语言
|
||||
</ElButton>
|
||||
</div>
|
||||
<div v-for="(row, i) in rows" :key="i" class="pool-row">
|
||||
<ElInput v-model="rows[i]" placeholder="话术句子" class="pool-input" />
|
||||
<ElButton type="danger" link title="删除此句" @click="rows.splice(i, 1)">
|
||||
删
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElButton size="small" class="pool-add" @click="rows.push('')">
|
||||
+ 添加一句
|
||||
</ElButton>
|
||||
</div>
|
||||
<div v-if="Object.keys(model.pools[field]).length === 0" class="pool-empty">
|
||||
该池暂无语言——添加必填语言 en / zh 后才能通过编译校验
|
||||
</div>
|
||||
<div class="pool-new-lang">
|
||||
<ElInput
|
||||
v-model="newLangInputs[field]"
|
||||
placeholder="语言代码(如 es / pt / ru)"
|
||||
class="new-lang-input"
|
||||
@keyup.enter="addPoolLang(field)"
|
||||
/>
|
||||
<ElButton size="small" @click="addPoolLang(field)">+ 添加语言</ElButton>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
|
||||
<!-- no_contact 三句 -->
|
||||
<ElCollapseItem name="no_contact" title="无联系方式应答 no_contact(web / app / already 三句)">
|
||||
<p class="section-note">
|
||||
零号码墙/索要联系方式时的替换句:web 指页面按钮 / app 无按钮引导留资 / already 已留资不再索要
|
||||
</p>
|
||||
<div v-for="key in NO_CONTACT_KEYS" :key="key" class="pool-row">
|
||||
<ElTag size="small" type="info" class="nc-tag">{{ key }}</ElTag>
|
||||
<ElInput v-model="model.noContact[key]" placeholder="替换句" class="pool-input" />
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
|
||||
<!-- reminders 六类话术 -->
|
||||
<ElCollapseItem name="reminders" title="轮中提醒 reminders(六类话术,{shown}/{intl} 等占位符由系统回填)">
|
||||
<div v-for="(variants, cat) in REMINDER_VARIANTS" :key="cat" class="reminder-cat">
|
||||
<p class="reminder-cat-title">{{ reminderCatLabel(cat) }}</p>
|
||||
<div v-for="variant in variants" :key="variant" class="reminder-variant">
|
||||
<span class="variant-label mono">{{ cat }}.{{ variant }}</span>
|
||||
<ElInput
|
||||
v-model="model.reminders[cat][variant]"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 8 }"
|
||||
:placeholder="variantLabel(cat, variant)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
|
||||
<!-- cc_templates 四组语言模板 -->
|
||||
<ElCollapseItem name="cc_templates" title="国家代码模板 cc_templates(四组 × 语言;{intl}/{cc_script} 占位符)">
|
||||
<div v-for="(langs, group) in CC_TEMPLATE_VARIANTS" :key="group" class="reminder-cat">
|
||||
<p class="reminder-cat-title">{{ ccGroupLabel(group) }}</p>
|
||||
<div v-for="lang in langs" :key="lang" class="pool-row">
|
||||
<ElTag size="small" type="info" class="nc-tag">{{ lang }}</ElTag>
|
||||
<ElInput
|
||||
v-model="model.ccTemplates[group][lang]"
|
||||
placeholder="模板文本"
|
||||
class="pool-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
|
||||
<!-- 保存条(词表备注可选,与品牌组一致) -->
|
||||
<div v-if="model" class="save-bar">
|
||||
<ElInput
|
||||
v-model="saveComment"
|
||||
maxlength="200"
|
||||
placeholder="可选;写入版本历史 comment"
|
||||
class="comment-input"
|
||||
/>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="testing"
|
||||
@click="onSave"
|
||||
>
|
||||
保存词表组(先自动试金石,再整组提交)
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 试金石面板 -->
|
||||
<ElCard shadow="never" class="touchstone-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">试金石(wall/test · 不落库预演)</span>
|
||||
<span class="card-sub">用当前表单值逐句判定——保存前会自动再跑一次</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="section-note">
|
||||
判定优先级与线上执行一致:价格墙(拦截+替换)→ 零号码墙(替换)→
|
||||
再索要墙(仅客户已留资时丢弃)。改完词表先在这里验判定再保存。
|
||||
</p>
|
||||
|
||||
<div class="ts-controls">
|
||||
<div class="ts-switch">
|
||||
<span class="ts-label">客户已留联系方式</span>
|
||||
<ElSwitch v-model="hasContact" />
|
||||
</div>
|
||||
<div class="ts-switch">
|
||||
<span class="ts-label">会话来源</span>
|
||||
<ElSelect v-model="source" class="ts-source">
|
||||
<ElOption value="web" label="web(网页挂件)" />
|
||||
<ElOption value="app" label="app(无页面按钮)" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(s, i) in sentences" :key="i" class="pool-row">
|
||||
<ElInput
|
||||
v-model="sentences[i]"
|
||||
placeholder="待判定句子(AI 输出侧视角)"
|
||||
class="pool-input"
|
||||
/>
|
||||
<ElButton type="danger" link title="删除此句" @click="sentences.splice(i, 1)">
|
||||
删
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElButton size="small" class="pool-add" @click="sentences.push('')">
|
||||
+ 添加一句
|
||||
</ElButton>
|
||||
|
||||
<div class="ts-run">
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="testing"
|
||||
:disabled="!model"
|
||||
@click="runTouchstone()"
|
||||
>
|
||||
跑一次试金石(当前表单值)
|
||||
</ElButton>
|
||||
<ElTag v-if="lastWallSource" size="small" type="info">
|
||||
{{ lastWallSource === 'override' ? '当前表单值预演' : '当前生效墙' }}
|
||||
</ElTag>
|
||||
</div>
|
||||
|
||||
<div v-if="testResults" class="ts-results">
|
||||
<div
|
||||
v-for="(r, i) in testResults"
|
||||
:key="i"
|
||||
class="ts-result"
|
||||
:class="`ts-${r.action}`"
|
||||
>
|
||||
<ElTag :type="actionTagType(r.action)" size="small" class="ts-tag">
|
||||
{{ actionLabel(r.action) }}
|
||||
</ElTag>
|
||||
<div class="ts-body">
|
||||
<p class="ts-sentence">{{ r.sentence }}</p>
|
||||
<p v-if="r.reason" class="ts-reason">
|
||||
命中:{{ reasonLabel(r.reason) }}
|
||||
</p>
|
||||
<p v-if="r.replacement" class="ts-replacement">
|
||||
替换文案:{{ r.replacement }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { putConfigGroup, testWall, type WallTestResponse } from '@/api/config'
|
||||
import {
|
||||
CC_TEMPLATE_VARIANTS,
|
||||
DEFAULT_TOUCHSTONE_SENTENCES,
|
||||
NO_CONTACT_KEYS,
|
||||
POOL_FIELDS,
|
||||
POOL_REQUIRED_LANGS,
|
||||
REMINDER_VARIANTS,
|
||||
buildWallPayload,
|
||||
initWallEdit,
|
||||
touchstoneSentences,
|
||||
type PoolField,
|
||||
type WallEditModel
|
||||
} from './model'
|
||||
|
||||
defineOptions({ name: 'WallPane' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 租户 slug(保存/试金石目标) */
|
||||
slug: string
|
||||
/** GET config 回显的 wall 组(原样快照;null = 缺失/未加载) */
|
||||
group: Record<string, unknown> | null | undefined
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'saved', payload: { version: number; group: 'wall'; config: Record<string, unknown> }): void
|
||||
}>()
|
||||
|
||||
// ---- 编辑态(group 引用变化 = 租户切换/重载 → 重建模型) ----------------------------
|
||||
// 注:watch immediate 在 setup 期同步跑,回调触达的全部状态必须先声明。
|
||||
|
||||
const model = ref<WallEditModel | null>(null)
|
||||
const saveComment = ref('')
|
||||
const actionError = ref('')
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResults = ref<WallTestResponse['results'] | null>(null)
|
||||
const lastWallSource = ref<WallTestResponse['wall_source'] | ''>('')
|
||||
|
||||
watch(
|
||||
() => props.group,
|
||||
(group) => {
|
||||
model.value = initWallEdit(group)
|
||||
saveComment.value = ''
|
||||
actionError.value = ''
|
||||
testResults.value = null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// ---- 区块展开态(默认收起——词表区块多,按需展开) -----------------------------------
|
||||
|
||||
const openSections = ref<string[]>([])
|
||||
|
||||
// ---- 正则列表区(价格墙 / 再索要墙) -------------------------------------------------
|
||||
|
||||
const patternListSections = computed(() => [
|
||||
{
|
||||
key: 'price_patterns',
|
||||
title: '价格墙 price_patterns(正则列表,命中即整句拦截)',
|
||||
note: '每条一行;flags 仅支持 i(忽略大小写);comment 为设计说明(不参与编译)。空 pattern 行保存时自动剔除。',
|
||||
rows: toRef(() => model.value?.priceRows ?? []),
|
||||
add: () => model.value?.priceRows.push(blankRegexRow()),
|
||||
remove: (i: number) => model.value?.priceRows.splice(i, 1)
|
||||
},
|
||||
{
|
||||
key: 'reask_patterns',
|
||||
title: '再索要墙 reask_patterns(正则列表,仅客户已留资时启用)',
|
||||
note: 'AI 输出侧防重复索要:命中且客户已留联系方式 → 静默丢弃该句(无替换文案)。',
|
||||
rows: toRef(() => model.value?.reaskRows ?? []),
|
||||
add: () => model.value?.reaskRows.push(blankRegexRow()),
|
||||
remove: (i: number) => model.value?.reaskRows.splice(i, 1)
|
||||
}
|
||||
])
|
||||
|
||||
function blankRegexRow() {
|
||||
return { pattern: '', flags: '' as const, comment: '', showComment: true }
|
||||
}
|
||||
|
||||
// ---- 单正则区(triggers / zero_number) ----------------------------------------------
|
||||
|
||||
const singlePatternSections = [
|
||||
{
|
||||
key: 'boundary',
|
||||
title: '边界触发器 triggers.boundary',
|
||||
note: '客户侧触发词大表(价格/折扣/交期/样品等)——命中注入边界提醒话术。'
|
||||
},
|
||||
{
|
||||
key: 'budget',
|
||||
title: '预算触发器 triggers.budget',
|
||||
note: '客户报预算触发词(与 reminders.boundary.budget 话术联动)。'
|
||||
},
|
||||
{
|
||||
key: 'contact_ask',
|
||||
title: '索要联系方式触发器 triggers.contact_ask',
|
||||
note: '客户索要我方联系方式(最高意向信号)——命中注入对应 source 话术。'
|
||||
},
|
||||
{
|
||||
key: 'long_number',
|
||||
title: '零号码墙 zero_number.long_number',
|
||||
note: '≥10 位数字串(号码红线)——命中整句替换为 no_contact 对应句。'
|
||||
},
|
||||
{
|
||||
key: 'any_email',
|
||||
title: '零号码墙 zero_number.any_email',
|
||||
note: '邮箱地址(号码红线同款)——命中整句替换。'
|
||||
}
|
||||
] as const
|
||||
|
||||
// ---- 语言池区 -------------------------------------------------------------------------
|
||||
|
||||
const POOL_META: Record<PoolField, { title: string; note: string }> = {
|
||||
deflections: {
|
||||
title: '迂回话术池 deflections($DEFLECTION_POOL 注入源)',
|
||||
note: '价格墙命中后的替换句池(按客户语言取句轮换)——system.md 的 $DEFLECTION_POOL 占位符注入的正是本池。'
|
||||
},
|
||||
deflections_contact: {
|
||||
title: '已留资话术池 deflections_contact',
|
||||
note: '客户已留联系方式时的价格墙替换句池(不得再索要——与再索要墙联动)。'
|
||||
},
|
||||
pivots: {
|
||||
title: '转向话术池 pivots',
|
||||
note: '句子粒度门控的过渡收尾句(前句干净已流出时接一句转向)。'
|
||||
},
|
||||
pivots_contact: {
|
||||
title: '已留资转向话术池 pivots_contact',
|
||||
note: '客户已留联系方式时的过渡收尾句池。'
|
||||
}
|
||||
}
|
||||
|
||||
const newLangInputs = reactive(
|
||||
Object.fromEntries(POOL_FIELDS.map((f) => [f, ''])) as Record<PoolField, string>
|
||||
)
|
||||
|
||||
const LANG_LABELS: Record<string, string> = {
|
||||
en: '英语 en',
|
||||
zh: '中文 zh',
|
||||
es: '西语 es',
|
||||
pt: '葡语 pt',
|
||||
ru: '俄语 ru',
|
||||
ar: '阿语 ar',
|
||||
fr: '法语 fr',
|
||||
de: '德语 de'
|
||||
}
|
||||
|
||||
function langLabel(lang: string): string {
|
||||
return LANG_LABELS[lang] ?? lang
|
||||
}
|
||||
|
||||
function isPoolRequiredLang(lang: string): boolean {
|
||||
return (POOL_REQUIRED_LANGS as readonly string[]).includes(lang)
|
||||
}
|
||||
|
||||
function addPoolLang(field: PoolField): void {
|
||||
const lang = newLangInputs[field].trim().toLowerCase()
|
||||
if (!/^[a-z][a-z0-9_-]{0,15}$/.test(lang)) {
|
||||
ElMessage.warning('语言代码须为 2-16 位小写字母开头(如 es / pt)')
|
||||
return
|
||||
}
|
||||
if (!model.value) return
|
||||
if (model.value.pools[field][lang]) {
|
||||
ElMessage.warning(`语言 ${lang} 已存在`)
|
||||
return
|
||||
}
|
||||
model.value.pools[field][lang] = ['']
|
||||
newLangInputs[field] = ''
|
||||
}
|
||||
|
||||
function removePoolLang(field: PoolField, lang: string): void {
|
||||
if (isPoolRequiredLang(lang)) {
|
||||
ElMessage.warning('en / zh 是编译必填语言,不可删除')
|
||||
return
|
||||
}
|
||||
delete model.value?.pools[field][lang]
|
||||
}
|
||||
|
||||
// ---- 提醒/模板标签 --------------------------------------------------------------------
|
||||
|
||||
const REMINDER_CAT_LABELS: Record<string, string> = {
|
||||
boundary: '职能边界',
|
||||
contact_ask: '索要联系方式',
|
||||
language_follow: '语言跟随',
|
||||
lead_given: '已留线索',
|
||||
cc_guide: '国家代码指引',
|
||||
email_light: '邮箱轻补'
|
||||
}
|
||||
|
||||
function reminderCatLabel(cat: string): string {
|
||||
return REMINDER_CAT_LABELS[cat] ?? cat
|
||||
}
|
||||
|
||||
function variantLabel(cat: string, variant: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'boundary.budget': '客户报预算求推荐的边界提醒',
|
||||
'boundary.normal': '常规价格/交期询问的边界提醒',
|
||||
'contact_ask.app_already': 'App 会话且已留资',
|
||||
'contact_ask.app': 'App 会话(无页面按钮)',
|
||||
'contact_ask.web': 'Web 会话(指路页面按钮)'
|
||||
}
|
||||
return map[`${cat}.${variant}`] ?? variant
|
||||
}
|
||||
|
||||
const CC_GROUP_LABELS: Record<string, string> = {
|
||||
ask: 'ask(无法补全时询问)',
|
||||
wall: 'wall(墙替换时询问)',
|
||||
note: 'note(轻补说明,en/zh)',
|
||||
echo: 'echo(回显确认 {intl})'
|
||||
}
|
||||
|
||||
function ccGroupLabel(group: string): string {
|
||||
return CC_GROUP_LABELS[group] ?? group
|
||||
}
|
||||
|
||||
// ---- 试金石 ---------------------------------------------------------------------------
|
||||
|
||||
const sentences = ref<string[]>([...DEFAULT_TOUCHSTONE_SENTENCES])
|
||||
const hasContact = ref(false)
|
||||
const source = ref<'web' | 'app'>('web')
|
||||
|
||||
function actionTagType(action: string): 'success' | 'danger' | 'warning' {
|
||||
return action === 'pass' ? 'success' : action === 'blocked' ? 'danger' : 'warning'
|
||||
}
|
||||
|
||||
function actionLabel(action: string): string {
|
||||
return action === 'pass' ? 'pass 放行' : action === 'blocked' ? 'blocked 拦截' : 'replaced 替换'
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string): string {
|
||||
const map: Record<string, string> = {
|
||||
boundary: '价格墙 boundary',
|
||||
no_contact: '零号码墙 no_contact',
|
||||
reask: '再索要墙 reask(丢弃,无替换)'
|
||||
}
|
||||
return map[reason] ?? reason
|
||||
}
|
||||
|
||||
/** 跑试金石(wall 覆盖 = 当前表单值的预演)。返回结果数组;失败返回 null。 */
|
||||
async function postTouchstone(): Promise<WallTestResponse['results'] | null> {
|
||||
if (!model.value) return null
|
||||
const list = touchstoneSentences(sentences.value)
|
||||
if (list.length === 0) {
|
||||
ElMessage.warning('请先填写至少一个试金石句子')
|
||||
return null
|
||||
}
|
||||
actionError.value = ''
|
||||
testing.value = true
|
||||
try {
|
||||
const res = await testWall(
|
||||
props.slug,
|
||||
{
|
||||
sentences: list,
|
||||
wall: buildWallPayload(model.value),
|
||||
has_contact: hasContact.value,
|
||||
source: source.value
|
||||
},
|
||||
{ showErrorMessage: false }
|
||||
)
|
||||
testResults.value = res.results
|
||||
lastWallSource.value = res.wall_source
|
||||
return res.results
|
||||
} catch (error) {
|
||||
// 坏墙(正则编译失败等)400 带字段路径——内联展示
|
||||
actionError.value = error instanceof Error ? error.message : '试金石调用失败'
|
||||
testResults.value = null
|
||||
return null
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runTouchstone(): Promise<void> {
|
||||
await postTouchstone()
|
||||
}
|
||||
|
||||
// ---- 保存链:先自动试金石 →(有拦截/替换时二次确认)→ 整组 PUT -----------------------
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
if (!model.value || saving.value || testing.value) return
|
||||
actionError.value = ''
|
||||
const payload = buildWallPayload(model.value)
|
||||
|
||||
// 保存前自动预演(有句子才跑;坏墙 400 在此即内联暴露,不会进确认流)
|
||||
const list = touchstoneSentences(sentences.value)
|
||||
if (list.length > 0) {
|
||||
const results = await postTouchstone()
|
||||
if (results === null) return // 调用失败(含编译错误)——错误已内联展示
|
||||
const hits = results.filter((r) => r.action !== 'pass')
|
||||
if (hits.length > 0) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`试金石判定:${hits.length}/${results.length} 句被拦截或替换。` +
|
||||
'请先核对上方逐句判定符合预期——确认后才写入新版本并热生效。',
|
||||
'保存前试金石确认',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '判定符合预期,保存',
|
||||
cancelButtonText: '回去检查'
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return // 用户取消——回去检查判定
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await putConfigGroup(
|
||||
props.slug,
|
||||
'wall',
|
||||
payload,
|
||||
saveComment.value.trim() || undefined,
|
||||
{ showErrorMessage: false }
|
||||
)
|
||||
saveComment.value = ''
|
||||
ElMessage.success(`词表已保存(版本 v${res.version},热生效)`)
|
||||
emit('saved', { version: res.version, group: 'wall', config: res.config })
|
||||
} catch (error) {
|
||||
// 正则编译错误等 400 的字段路径(wall_schema 报错形态如
|
||||
// "price_patterns[3]: regex 编译失败: ...")内联展示
|
||||
actionError.value = error instanceof Error ? error.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wall-pane {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-card,
|
||||
.touchstone-card {
|
||||
width: 100%;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pane-error {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-note {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
|
||||
// ---- 正则行 -------------------------------------------------------------------------
|
||||
|
||||
.regex-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.regex-line {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
.row-index {
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-400);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pattern-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flags-select {
|
||||
width: 130px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
margin: 4px 0 0 26px;
|
||||
}
|
||||
|
||||
.comment-view {
|
||||
margin: 4px 0 0 26px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--art-gray-500);
|
||||
background: var(--art-main-bg-color, var(--el-fill-color-lighter));
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
:deep(.mono-input textarea),
|
||||
:deep(.mono-input input) {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// ---- 语言池 -------------------------------------------------------------------------
|
||||
|
||||
.pool-lang {
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px dashed var(--el-border-color-lighter);
|
||||
|
||||
.pool-lang-head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.lang-required {
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pool-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.pool-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nc-tag {
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.pool-add {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.pool-empty {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.pool-new-lang {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.new-lang-input {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 提醒 / 模板 ----------------------------------------------------------------------
|
||||
|
||||
.reminder-cat {
|
||||
margin-bottom: 14px;
|
||||
|
||||
.reminder-cat-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.reminder-variant {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.variant-label {
|
||||
width: 190px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
|
||||
.comment-input {
|
||||
width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 试金石 ---------------------------------------------------------------------------
|
||||
|
||||
.ts-controls {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.ts-switch {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.ts-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-source {
|
||||
width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-run {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ts-results {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.ts-result {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--art-main-bg-color, var(--el-fill-color-lighter));
|
||||
|
||||
&.ts-blocked {
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
|
||||
&.ts-replaced {
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
&.ts-pass {
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
.ts-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ts-body {
|
||||
min-width: 0;
|
||||
|
||||
.ts-sentence,
|
||||
.ts-reason,
|
||||
.ts-replacement {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ts-reason {
|
||||
color: var(--art-gray-500);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ts-replacement {
|
||||
color: var(--el-color-warning);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 配置中心提示词/词表编辑模型单元测试(Phase 3 Task 7b)
|
||||
*
|
||||
* 钉住 src/views/config/center/model.ts 的纯逻辑契约(事实源:
|
||||
* app/platform/config_validation.py 的 PROMPTS_SIZE_CAPS 与
|
||||
* app/engine/wall_schema.py 的结构/双形态正则条目约定):
|
||||
* - 提示词:UTF-8 字节口径(中文 3 字节/字)、50/100KB 上限判定、
|
||||
* 原样快照 → 表单值的防御归一;
|
||||
* - 词表:正则条目双形态(纯字符串 | {pattern, flags, comment})的
|
||||
* 解析/序列化往返、空行剔除、语言池清洗、initWallEdit 对透传键的
|
||||
* 保留、buildWallPayload 的整组重组;
|
||||
* - 变体键镜像:reminders 六类 / cc_templates 四组与后端常量同构。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CC_TEMPLATE_VARIANTS,
|
||||
DEFAULT_TOUCHSTONE_SENTENCES,
|
||||
NO_CONTACT_KEYS,
|
||||
POOL_FIELDS,
|
||||
PROMPTS_CAPS,
|
||||
PROMPTS_KEYS,
|
||||
REMINDER_VARIANTS,
|
||||
TRIGGER_KEYS,
|
||||
ZERO_NUMBER_KEYS,
|
||||
buildWallPayload,
|
||||
formatKB,
|
||||
initWallEdit,
|
||||
normalizePromptsGroup,
|
||||
parsePatternEntry,
|
||||
promptByteInfo,
|
||||
promptsOverCap,
|
||||
rowToEntry,
|
||||
rowsToEntries,
|
||||
rowToSingleEntry,
|
||||
touchstoneSentences,
|
||||
utf8Bytes,
|
||||
type PromptsDraft
|
||||
} from '../model'
|
||||
|
||||
// ---- 提示词四件套 -------------------------------------------------------------------
|
||||
|
||||
describe('utf8Bytes / formatKB(UTF-8 字节口径)', () => {
|
||||
it('ASCII 1 字节/字符、中文 3 字节/字、空串为 0', () => {
|
||||
expect(utf8Bytes('')).toBe(0)
|
||||
expect(utf8Bytes('abc')).toBe(3)
|
||||
expect(utf8Bytes('中文')).toBe(6) // 后端 len('中文'.encode('utf-8')) == 6
|
||||
expect(utf8Bytes('价格500')).toBe(3 * 2 + 3)
|
||||
})
|
||||
|
||||
it('formatKB:KB 一位小数', () => {
|
||||
expect(formatKB(0)).toBe('0.0KB')
|
||||
expect(formatKB(50 * 1024)).toBe('50.0KB')
|
||||
expect(formatKB(1536)).toBe('1.5KB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('提示词字节上限(config_validation.PROMPTS_SIZE_CAPS 镜像)', () => {
|
||||
it('company_facts 50KB、其余 100KB', () => {
|
||||
expect(PROMPTS_CAPS.company_facts).toBe(50 * 1024)
|
||||
for (const key of PROMPTS_KEYS) {
|
||||
if (key !== 'company_facts') expect(PROMPTS_CAPS[key]).toBe(100 * 1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('边界:恰好等于上限不算超限,超 1 字节算', () => {
|
||||
expect(promptByteInfo('company_facts', '').over).toBe(false)
|
||||
expect(promptByteInfo('company_facts', '中'.repeat(50 * 1024 / 3)).over).toBe(false)
|
||||
expect(promptByteInfo('company_facts', `a`.repeat(50 * 1024 + 1)).over).toBe(true)
|
||||
expect(promptByteInfo('system', 'a'.repeat(100 * 1024)).over).toBe(false)
|
||||
expect(promptByteInfo('evaluator', 'a'.repeat(100 * 1024 + 1)).over).toBe(true)
|
||||
})
|
||||
|
||||
it('promptsOverCap:任一件超限即 true(禁存判定)', () => {
|
||||
const draft: PromptsDraft = {
|
||||
system: '',
|
||||
company_facts: 'a'.repeat(50 * 1024 + 1),
|
||||
extractor: '',
|
||||
evaluator: ''
|
||||
}
|
||||
expect(promptsOverCap(draft)).toBe(true)
|
||||
expect(promptsOverCap(normalizePromptsGroup({}))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizePromptsGroup(原样快照 → 表单值)', () => {
|
||||
it('缺键/非字符串补空串、字符串原样保留', () => {
|
||||
expect(
|
||||
normalizePromptsGroup({ system: 'S', company_facts: 123, extractor: null })
|
||||
).toEqual({ system: 'S', company_facts: '', extractor: '', evaluator: '' })
|
||||
expect(normalizePromptsGroup(null)).toEqual({
|
||||
system: '',
|
||||
company_facts: '',
|
||||
extractor: '',
|
||||
evaluator: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---- 正则条目双形态 -----------------------------------------------------------------
|
||||
|
||||
describe('parsePatternEntry(字符串/dict 双形态 → 编辑行)', () => {
|
||||
it('纯字符串条目:无 flags 无备注', () => {
|
||||
expect(parsePatternEntry('\\$\\s?\\d')).toEqual({
|
||||
pattern: '\\$\\s?\\d',
|
||||
flags: '',
|
||||
comment: '',
|
||||
showComment: false
|
||||
})
|
||||
})
|
||||
|
||||
it('dict 条目:pattern/flags/comment 各自归位', () => {
|
||||
expect(
|
||||
parsePatternEntry({ pattern: '\\d+', flags: 'i', comment: '# 时间承诺' })
|
||||
).toEqual({
|
||||
pattern: '\\d+',
|
||||
flags: 'i',
|
||||
comment: '# 时间承诺',
|
||||
showComment: false
|
||||
})
|
||||
})
|
||||
|
||||
it('dict 条目无 comment 键(wall.json 真实形态)与异常值防御', () => {
|
||||
expect(parsePatternEntry({ pattern: 'warranty', flags: 'i' }).comment).toBe('')
|
||||
expect(parsePatternEntry({ pattern: 'x' }).flags).toBe('')
|
||||
// 异常值:数字/null/缺 pattern → 归空行(存量脏数据防御)
|
||||
expect(parsePatternEntry(42).pattern).toBe('')
|
||||
expect(parsePatternEntry(null).pattern).toBe('')
|
||||
expect(parsePatternEntry({ flags: 'i' }).pattern).toBe('')
|
||||
expect(parsePatternEntry({ pattern: 1, flags: 'x', comment: 2 })).toEqual({
|
||||
pattern: '',
|
||||
flags: '',
|
||||
comment: '',
|
||||
showComment: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rowToEntry / rowsToEntries(编辑行 → 条目序列化)', () => {
|
||||
it('无 flags 无备注 → 纯字符串(与 wall.json 极简条目一致)', () => {
|
||||
expect(
|
||||
rowToEntry({ pattern: '\\$\\s?\\d', flags: '', comment: '', showComment: false })
|
||||
).toBe('\\$\\s?\\d')
|
||||
})
|
||||
|
||||
it('有 flags/备注 → dict 且只带非空键(comment-only 条目不带 flags 键)', () => {
|
||||
expect(
|
||||
rowToEntry({ pattern: '\\d+', flags: 'i', comment: '', showComment: false })
|
||||
).toEqual({ pattern: '\\d+', flags: 'i' })
|
||||
expect(
|
||||
rowToEntry({ pattern: 'x', flags: '', comment: '# 说明', showComment: false })
|
||||
).toEqual({ pattern: 'x', comment: '# 说明' })
|
||||
expect(
|
||||
rowToEntry({ pattern: 'x', flags: 'i', comment: '# 说明', showComment: false })
|
||||
).toEqual({ pattern: 'x', flags: 'i', comment: '# 说明' })
|
||||
})
|
||||
|
||||
it('空 pattern 行剔除、纯空白备注视为无备注', () => {
|
||||
expect(
|
||||
rowToEntry({ pattern: '', flags: 'i', comment: 'x', showComment: false })
|
||||
).toBeNull()
|
||||
expect(
|
||||
rowsToEntries([
|
||||
{ pattern: 'a', flags: '', comment: ' ', showComment: false },
|
||||
{ pattern: '', flags: '', comment: '', showComment: false },
|
||||
{ pattern: 'b', flags: 'i', comment: '', showComment: false }
|
||||
])
|
||||
).toEqual(['a', { pattern: 'b', flags: 'i' }])
|
||||
})
|
||||
|
||||
it('rowToSingleEntry(triggers/zero_number):无标志非空 → 纯字符串;空 pattern → dict 显性失败', () => {
|
||||
expect(
|
||||
rowToSingleEntry({ pattern: 'price', flags: 'i', comment: '', showComment: false })
|
||||
).toEqual({ pattern: 'price', flags: 'i' })
|
||||
// wall.json zero_number 出厂是纯字符串——无标志无备注保持字符串(往返无损)
|
||||
expect(
|
||||
rowToSingleEntry({ pattern: 'budget|预算', flags: '', comment: '', showComment: false })
|
||||
).toBe('budget|预算')
|
||||
// 空 pattern 绝不能出字符串形态('' 会编成匹配一切的正则)——dict 让后端报路径
|
||||
expect(
|
||||
rowToSingleEntry({ pattern: '', flags: '', comment: '', showComment: false })
|
||||
).toEqual({ pattern: '' })
|
||||
})
|
||||
})
|
||||
|
||||
// ---- 词表整体模型 -------------------------------------------------------------------
|
||||
|
||||
/** 最小但结构完整的 wall 快照(对照 seed_profiles/jiancai/wall.json 形态) */
|
||||
function miniWall(): Record<string, unknown> {
|
||||
return {
|
||||
price_patterns: [
|
||||
'\\$\\s?\\d',
|
||||
{ pattern: '\\d+\\s*天', comment: '# 中文时限' },
|
||||
{ pattern: 'warranty', flags: 'i' }
|
||||
],
|
||||
deflections: { en: ['A?'], zh: ['B?'], es: ['C?'] },
|
||||
deflections_contact: { en: ['A.'], zh: ['B.'] },
|
||||
pivots: { en: ['P'], zh: ['p'] },
|
||||
pivots_contact: { en: ['Q'], zh: ['q'] },
|
||||
no_contact: { web: 'W', app: 'A', already: 'D' },
|
||||
zero_number: {
|
||||
long_number: '\\+?\\d[\\d\\s\\-().]{8,17}\\d',
|
||||
any_email: '[a-z]+@[a-z]+\\.[a-z]{2,}'
|
||||
},
|
||||
reask_patterns: [{ pattern: 'your\\s+whatsapp', flags: 'i' }],
|
||||
triggers: {
|
||||
boundary: { pattern: 'price', flags: 'i' },
|
||||
budget: { pattern: 'budget|预算', flags: 'i' },
|
||||
contact_ask: { pattern: 'your\\s+number', flags: 'i' }
|
||||
},
|
||||
reminders: {
|
||||
boundary: { budget: 'r-b', normal: 'r-n' },
|
||||
contact_ask: { app_already: 'ca-aa', app: 'ca-a', web: 'ca-w' },
|
||||
language_follow: { zh: 'lf-zh', es: 'lf-es' },
|
||||
lead_given: {
|
||||
forbidden: 'lg-f',
|
||||
respond_script: 'lg-r',
|
||||
boundary_tail: 'lg-b',
|
||||
suspect: 'lg-s'
|
||||
},
|
||||
cc_guide: { echo: 'cg-e', ask: 'cg-a' },
|
||||
email_light: { en: 'el-en', zh: 'el-zh' }
|
||||
},
|
||||
cc_templates: {
|
||||
ask: { en: 't-a-en', zh: 't-a-zh', es: 't-a-es' },
|
||||
wall: { en: 't-w-en', zh: 't-w-zh', es: 't-w-es' },
|
||||
note: { en: 't-n-en', zh: 't-n-zh' },
|
||||
echo: { en: 't-e-en', zh: 't-e-zh', es: 't-e-es' }
|
||||
},
|
||||
_meta: { source: 'extract_config.py' }
|
||||
}
|
||||
}
|
||||
|
||||
describe('initWallEdit(快照 → 编辑模型)', () => {
|
||||
it('缺失/空对象/非对象 → null(面板空态)', () => {
|
||||
expect(initWallEdit(null)).toBeNull()
|
||||
expect(initWallEdit(undefined)).toBeNull()
|
||||
expect(initWallEdit({})).toBeNull()
|
||||
expect(initWallEdit('x' as unknown as Record<string, unknown>)).toBeNull()
|
||||
})
|
||||
|
||||
it('双形态正则解析 + 透传键保留 + 六类/四组变体补齐', () => {
|
||||
const model = initWallEdit(miniWall())!
|
||||
expect(model.priceRows).toHaveLength(3)
|
||||
expect(model.priceRows[0]).toMatchObject({ pattern: '\\$\\s?\\d', flags: '' })
|
||||
expect(model.priceRows[1]).toMatchObject({
|
||||
pattern: '\\d+\\s*天',
|
||||
flags: '',
|
||||
comment: '# 中文时限'
|
||||
})
|
||||
expect(model.priceRows[2]).toMatchObject({ pattern: 'warranty', flags: 'i' })
|
||||
expect(model.reaskRows).toHaveLength(1)
|
||||
expect(model.singles.boundary).toMatchObject({ pattern: 'price', flags: 'i' })
|
||||
expect(model.singles.long_number.pattern).toBe('\\+?\\d[\\d\\s\\-().]{8,17}\\d')
|
||||
expect(model.pools.deflections).toEqual({ en: ['A?'], zh: ['B?'], es: ['C?'] })
|
||||
expect(model.noContact).toEqual({ web: 'W', app: 'A', already: 'D' })
|
||||
expect(model.reminders.boundary.budget).toBe('r-b')
|
||||
expect(model.ccTemplates.note).toEqual({ en: 't-n-en', zh: 't-n-zh' })
|
||||
// 未接管键(含 "_" 前缀元信息)原样透传
|
||||
expect(model.passthrough).toEqual({ _meta: { source: 'extract_config.py' } })
|
||||
})
|
||||
|
||||
it('脏数据防御:非字符串池项剔除、非对象区补空、非列表正则区为空', () => {
|
||||
const model = initWallEdit({
|
||||
price_patterns: 'oops',
|
||||
deflections: { en: ['ok', 42, null], zh: 'oops', es: [] },
|
||||
triggers: 'oops',
|
||||
no_contact: 'oops',
|
||||
reminders: 'oops',
|
||||
cc_templates: 'oops'
|
||||
})!
|
||||
expect(model.priceRows).toEqual([])
|
||||
expect(model.pools.deflections).toEqual({ en: ['ok'] })
|
||||
expect(model.singles.boundary.pattern).toBe('')
|
||||
expect(model.noContact.web).toBe('')
|
||||
expect(model.reminders.boundary.budget).toBe('')
|
||||
expect(model.ccTemplates.ask.en).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWallPayload(编辑模型 → PUT config/wall 整组载荷)', () => {
|
||||
it('结构化区重组 + 透传键合入 + 序列化规则生效', () => {
|
||||
const model = initWallEdit(miniWall())!
|
||||
// 编辑:价格墙加一条(rich 形态)+ 留一条空行(应被剔除);
|
||||
// deflections.en 加一条空句(应被剔除);en 池清空句子(整键应被丢弃)
|
||||
model.priceRows.push({ pattern: '\\d+k\\b', flags: 'i', comment: '', showComment: false })
|
||||
model.priceRows.push({ pattern: '', flags: '', comment: '', showComment: false })
|
||||
model.pools.deflections.en.push('')
|
||||
model.pools.deflections_contact.en = ['']
|
||||
|
||||
const payload = buildWallPayload(model)
|
||||
|
||||
expect(payload.price_patterns).toEqual([
|
||||
'\\$\\s?\\d',
|
||||
{ pattern: '\\d+\\s*天', comment: '# 中文时限' },
|
||||
{ pattern: 'warranty', flags: 'i' },
|
||||
{ pattern: '\\d+k\\b', flags: 'i' }
|
||||
])
|
||||
expect(payload.reask_patterns).toEqual([{ pattern: 'your\\s+whatsapp', flags: 'i' }])
|
||||
expect(payload.triggers).toEqual({
|
||||
boundary: { pattern: 'price', flags: 'i' },
|
||||
budget: { pattern: 'budget|预算', flags: 'i' },
|
||||
contact_ask: { pattern: 'your\\s+number', flags: 'i' }
|
||||
})
|
||||
expect(payload.zero_number).toEqual({
|
||||
long_number: '\\+?\\d[\\d\\s\\-().]{8,17}\\d',
|
||||
any_email: '[a-z]+@[a-z]+\\.[a-z]{2,}'
|
||||
})
|
||||
expect(payload.deflections).toEqual({ en: ['A?'], zh: ['B?'], es: ['C?'] })
|
||||
// deflections_contact.en 清空后整键丢弃(zh 保留)
|
||||
expect(payload.deflections_contact).toEqual({ zh: ['B.'] })
|
||||
expect(payload.no_contact).toEqual({ web: 'W', app: 'A', already: 'D' })
|
||||
expect(payload.reminders).toEqual(miniWall().reminders)
|
||||
expect(payload.cc_templates).toEqual(miniWall().cc_templates)
|
||||
expect(payload._meta).toEqual({ source: 'extract_config.py' })
|
||||
})
|
||||
|
||||
it('initWallEdit → buildWallPayload 无编辑时结构等价往返(序列化形态按约定归一)', () => {
|
||||
const source = miniWall()
|
||||
const payload = buildWallPayload(initWallEdit(source)!)
|
||||
expect(payload.price_patterns).toEqual(source.price_patterns)
|
||||
expect(payload.triggers).toEqual(source.triggers)
|
||||
expect(payload.pivots).toEqual(source.pivots)
|
||||
expect(payload.reminders).toEqual(source.reminders)
|
||||
expect(payload.cc_templates).toEqual(source.cc_templates)
|
||||
expect(payload._meta).toEqual(source._meta)
|
||||
})
|
||||
})
|
||||
|
||||
// ---- 变体键镜像与试金石 -------------------------------------------------------------
|
||||
|
||||
describe('变体键镜像(wall_schema 常量同构)', () => {
|
||||
it('reminders 六类 / cc_templates 四组 / 池与单正则键清单', () => {
|
||||
expect(Object.keys(REMINDER_VARIANTS).sort()).toEqual(
|
||||
['boundary', 'cc_guide', 'contact_ask', 'email_light', 'language_follow', 'lead_given']
|
||||
)
|
||||
expect(Object.keys(CC_TEMPLATE_VARIANTS).sort()).toEqual(['ask', 'echo', 'note', 'wall'])
|
||||
expect(POOL_FIELDS).toEqual([
|
||||
'deflections',
|
||||
'deflections_contact',
|
||||
'pivots',
|
||||
'pivots_contact'
|
||||
])
|
||||
expect([...TRIGGER_KEYS]).toEqual(['boundary', 'budget', 'contact_ask'])
|
||||
expect([...ZERO_NUMBER_KEYS]).toEqual(['long_number', 'any_email'])
|
||||
expect([...NO_CONTACT_KEYS]).toEqual(['web', 'app', 'already'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('试金石句集', () => {
|
||||
it('默认 3 示例句(覆盖 blocked/replaced/pass 三态)且非空', () => {
|
||||
expect(DEFAULT_TOUCHSTONE_SENTENCES).toHaveLength(3)
|
||||
for (const s of DEFAULT_TOUCHSTONE_SENTENCES) expect(s.trim()).not.toBe('')
|
||||
})
|
||||
|
||||
it('touchstoneSentences:trim + 空行过滤', () => {
|
||||
expect(touchstoneSentences([' a ', '', ' ', 'b'])).toEqual(['a', 'b'])
|
||||
expect(touchstoneSentences([])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,19 @@
|
||||
<!--
|
||||
配置中心(Phase 3 Task 7a:品牌与嵌入代码;后续任务补提示词/词表/凭据/版本)
|
||||
- 页骨架:左侧组导航(品牌实装;提示词/安全词表/凭据/版本占位)
|
||||
配置中心(Phase 3 Task 7a:品牌与嵌入代码;Task 7b:提示词四件套与
|
||||
安全词表试金石;后续任务补凭据/版本)
|
||||
- 页骨架:左侧组导航(品牌/提示词/安全词表实装;凭据/版本占位)
|
||||
- 租户上下文:超管下拉切换(复用会话页模式);租户管理员固定自己
|
||||
- 品牌表单:GET config 整组读 → 表单编辑 → PUT config/brand 整组提交
|
||||
(整组替换语义——字段清单 title/color/avatar/welcome/topics/placeholder/
|
||||
whatsapp/email/privacy_url;保存走 comment 查询参数,版本 MAX+1 热生效)
|
||||
- 提示词四件套(Task 7b,PromptsPane):system/company_facts/extractor/
|
||||
evaluator 四编辑器;UTF-8 字节实时提示(50KB/100KB);$DEFLECTION_POOL
|
||||
说明卡;保存备注**必填**(AI 行为源头)——PUT config/prompts 整组
|
||||
- 安全词表(Task 7b,WallPane):按 wall.json 结构分组编辑(正则列表
|
||||
双形态行编辑器/语言池/三句/单正则/六类提醒/CC 模板;未接管键透传);
|
||||
试金石面板 POST wall/test(当前表单值预演,不落库);保存前自动跑
|
||||
试金石,有拦截/替换时二次确认——PUT config/wall 整组,400 字段路径
|
||||
内联展示
|
||||
- 挂件实时预览:**本地简化预览组件**(标题/颜色气泡/头像/话题/占位的
|
||||
静态模拟,零后端依赖、即时反映表单值;真机预览留给嵌入代码上真站)
|
||||
- 嵌入代码:域名取租户详情 sites[].domains(超管可枚举全租户,租户管理
|
||||
@@ -67,10 +76,10 @@
|
||||
<ElMenu :default-active="activeGroup" class="group-nav" @select="onGroupSelect">
|
||||
<ElMenuItem v-for="item in GROUP_NAV" :key="item.key" :index="item.key">
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
<span v-if="item.key === 'brand'" class="nav-badge">已实装</span>
|
||||
<span v-if="IMPLEMENTED_GROUPS.has(item.key)" class="nav-badge">已实装</span>
|
||||
</ElMenuItem>
|
||||
</ElMenu>
|
||||
<p class="nav-tip">品牌之外的组在后续任务实装(当前为占位)。</p>
|
||||
<p class="nav-tip">凭据与版本在后续任务实装(当前为占位)。</p>
|
||||
</ElCard>
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
@@ -346,6 +355,48 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 提示词 pane(Task 7b) -->
|
||||
<template v-else-if="activeGroup === 'prompts'">
|
||||
<div v-loading="configLoading" class="group-pane">
|
||||
<ElAlert
|
||||
v-if="configError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
:title="configError"
|
||||
class="pane-error"
|
||||
>
|
||||
<ElButton size="small" :loading="configLoading" @click="reloadConfig">重试</ElButton>
|
||||
</ElAlert>
|
||||
<PromptsPane
|
||||
v-else-if="activeSlug"
|
||||
:slug="activeSlug"
|
||||
:group="snapshot?.prompts"
|
||||
@saved="onPaneSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 安全词表 pane(Task 7b) -->
|
||||
<template v-else-if="activeGroup === 'wall'">
|
||||
<div v-loading="configLoading" class="group-pane">
|
||||
<ElAlert
|
||||
v-if="configError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
:title="configError"
|
||||
class="pane-error"
|
||||
>
|
||||
<ElButton size="small" :loading="configLoading" @click="reloadConfig">重试</ElButton>
|
||||
</ElAlert>
|
||||
<WallPane
|
||||
v-else-if="activeSlug"
|
||||
:slug="activeSlug"
|
||||
:group="snapshot?.wall"
|
||||
@saved="onPaneSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其余组占位 -->
|
||||
<ElCard v-else shadow="never" class="placeholder-card">
|
||||
<ElEmpty :description="`${activeGroupLabel}——后续任务实装`" />
|
||||
@@ -365,6 +416,8 @@
|
||||
uploadBrandAvatar,
|
||||
type BrandGroupValue
|
||||
} from '@/api/config'
|
||||
import PromptsPane from './PromptsPane.vue'
|
||||
import WallPane from './WallPane.vue'
|
||||
|
||||
defineOptions({ name: 'ConfigCenter' })
|
||||
|
||||
@@ -375,7 +428,7 @@
|
||||
const AVATAR_MAX_BYTES = 2 * 1024 * 1024
|
||||
const AVATAR_EXTS = ['png', 'jpg', 'jpeg', 'webp']
|
||||
|
||||
// ---- 左侧组导航(本任务实装品牌;其余占位) -------------------------------------------
|
||||
// ---- 左侧组导航(Task 7a 品牌 / Task 7b 提示词+词表实装;其余占位) --------------------
|
||||
|
||||
const GROUP_NAV = [
|
||||
{ key: 'brand', label: '品牌' },
|
||||
@@ -385,6 +438,9 @@
|
||||
{ key: 'versions', label: '版本' }
|
||||
] as const
|
||||
|
||||
/** 已实装组(导航徽标) */
|
||||
const IMPLEMENTED_GROUPS = new Set<GroupKey>(['brand', 'prompts', 'wall'])
|
||||
|
||||
type GroupKey = (typeof GROUP_NAV)[number]['key']
|
||||
|
||||
const activeGroup = ref<GroupKey>('brand')
|
||||
@@ -423,12 +479,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 配置(品牌组)状态 ---------------------------------------------------------------
|
||||
// ---- 配置状态(品牌表单 + 整份快照——Task 7b 起提示词/词表 pane 消费快照组) ----------
|
||||
|
||||
const version = ref<number | null>(null)
|
||||
const configLoading = ref(false)
|
||||
const configError = ref('')
|
||||
const configLoaded = ref(false)
|
||||
/** 整份原样快照(prompts/wall pane 的组值来源;组保存成功后逐组回写) */
|
||||
const snapshot = ref<Record<string, Record<string, unknown>> | null>(null)
|
||||
/** 加载世代(切租户时丢弃在途响应) */
|
||||
const configEpoch = ref(0)
|
||||
|
||||
@@ -459,6 +517,7 @@
|
||||
const res = await fetchTenantConfig(slug)
|
||||
if (epoch !== configEpoch.value || slug !== activeSlug.value) return
|
||||
version.value = res.version
|
||||
snapshot.value = res.config
|
||||
Object.assign(form, normalizeBrandGroup(res.config.brand))
|
||||
configLoaded.value = true
|
||||
avatarImgFailed.value = false
|
||||
@@ -470,6 +529,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** pane(提示词/词表)保存成功:回写版本号与快照组(pane 自持编辑态不重置) */
|
||||
function onPaneSaved(payload: {
|
||||
version: number
|
||||
group: string
|
||||
config: Record<string, unknown>
|
||||
}): void {
|
||||
version.value = payload.version
|
||||
if (snapshot.value) {
|
||||
snapshot.value = {
|
||||
...snapshot.value,
|
||||
[payload.group]: payload.config
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reloadConfig(): void {
|
||||
loadConfig(++configEpoch.value)
|
||||
}
|
||||
@@ -670,6 +744,7 @@
|
||||
configLoaded.value = false
|
||||
configError.value = ''
|
||||
saveComment.value = ''
|
||||
snapshot.value = null
|
||||
detail.value = null
|
||||
detailError.value = ''
|
||||
embedDomain.value = ''
|
||||
@@ -1096,4 +1171,13 @@
|
||||
.placeholder-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// ---- 提示词/词表 pane 容器(Task 7b) ------------------------------------------------
|
||||
|
||||
.group-pane {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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