diff --git a/admin/src/api/__tests__/config.test.ts b/admin/src/api/__tests__/config.test.ts index bea1034..4795102 100644 --- a/admin/src/api/__tests__/config.test.ts +++ b/admin/src/api/__tests__/config.test.ts @@ -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' }) diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts index 2f17aac..fcdd87e 100644 --- a/admin/src/api/config.ts +++ b/admin/src/api/config.ts @@ -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> } -/** 词表试金石请求(POST .../wall/test) */ +/** 词表试金石请求(POST .../wall/test) + * wall 覆盖 = 编辑器当前表单值的预演(后端 compile_wall 校验 + 纯内存 + * 判定,不落库);缺省用当前生效墙。 */ export interface WallTestRequest { sentences: string[] + wall?: Record 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 { + return http.post(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 | null | undefined diff --git a/admin/src/types/import/components.d.ts b/admin/src/types/import/components.d.ts index 0a47716..4457df4 100644 --- a/admin/src/types/import/components.d.ts +++ b/admin/src/types/import/components.d.ts @@ -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'] diff --git a/admin/src/views/config/center/PromptsPane.vue b/admin/src/views/config/center/PromptsPane.vue new file mode 100644 index 0000000..c810763 --- /dev/null +++ b/admin/src/views/config/center/PromptsPane.vue @@ -0,0 +1,318 @@ + + + + + + diff --git a/admin/src/views/config/center/WallPane.vue b/admin/src/views/config/center/WallPane.vue new file mode 100644 index 0000000..df01e31 --- /dev/null +++ b/admin/src/views/config/center/WallPane.vue @@ -0,0 +1,925 @@ + + + + + + diff --git a/admin/src/views/config/center/__tests__/model.test.ts b/admin/src/views/config/center/__tests__/model.test.ts new file mode 100644 index 0000000..3b6bc15 --- /dev/null +++ b/admin/src/views/config/center/__tests__/model.test.ts @@ -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 { + 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)).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([]) + }) +}) diff --git a/admin/src/views/config/center/index.vue b/admin/src/views/config/center/index.vue index b123b39..1ca4fba 100644 --- a/admin/src/views/config/center/index.vue +++ b/admin/src/views/config/center/index.vue @@ -1,10 +1,19 @@ @@ -346,6 +355,48 @@ + + + + + + @@ -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(['brand', 'prompts', 'wall']) + type GroupKey = (typeof GROUP_NAV)[number]['key'] const activeGroup = ref('brand') @@ -423,12 +479,14 @@ } } - // ---- 配置(品牌组)状态 --------------------------------------------------------------- + // ---- 配置状态(品牌表单 + 整份快照——Task 7b 起提示词/词表 pane 消费快照组) ---------- const version = ref(null) const configLoading = ref(false) const configError = ref('') const configLoaded = ref(false) + /** 整份原样快照(prompts/wall pane 的组值来源;组保存成功后逐组回写) */ + const snapshot = ref> | 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 + }): 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; + } diff --git a/admin/src/views/config/center/model.ts b/admin/src/views/config/center/model.ts new file mode 100644 index 0000000..db1389f --- /dev/null +++ b/admin/src/views/config/center/model.ts @@ -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 + +/** 各件字节上限(UTF-8 口径;config_validation.PROMPTS_SIZE_CAPS 镜像) */ +export const PROMPTS_CAPS: Record = { + 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 | 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 | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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 | null { + if (!row.pattern) return null + if (!row.flags && !row.comment.trim()) return row.pattern + const out: Record = { 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> { + return rows + .map(rowToEntry) + .filter((e): e is string | Record => e !== null) +} + +/** 单条目字段(triggers 三键 / zero_number 两键)编辑行 → 条目: + * 无标志无备注且 pattern 非空 → 纯字符串(wall.json zero_number 出厂 + * 形态,编译等价、往返无损);否则 dict 只带非空键——空 pattern 也走 + * dict 分支,后端报「缺非空 pattern」字段路径(裸 '' 会编成匹配一切的 + * 空正则,必须显性失败) */ +export function rowToSingleEntry( + row: RegexRow +): string | Record { + if (row.pattern && !row.flags && !row.comment.trim()) return row.pattern + const out: Record = { 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 = { + 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 = { + 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 + 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> + noContact: Record<(typeof NO_CONTACT_KEYS)[number], string> + /** 六类 × 变体 → 话术文本(变体键封闭集合,缺项补空串交后端报路径) */ + reminders: Record> + /** 四组 × 语言 → 模板文本({intl}/{cc_script} 等占位符白名单由后端校验) */ + ccTemplates: Record> +} + +function clone(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 { + const source = asRecord(value) + if (!source) return {} + const out: Record = {} + 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 { + const source = asRecord(value) ?? {} + const out: Record = {} + for (const key of variants) { + out[key] = asStr(source[key]) + } + return out +} + +/** 结构化编辑接管的顶层键(其余透传) */ +const KNOWN_WALL_EDIT_KEYS = new Set([ + '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 | 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> = {} + const remindersSrc = asRecord(record.reminders) + for (const [cat, variants] of Object.entries(REMINDER_VARIANTS)) { + reminders[cat] = asVariantMap(remindersSrc?.[cat], variants) + } + + const ccTemplates: Record> = {} + 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): Record { + const out: Record = {} + 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 { + const triggers: Record = {} + for (const key of TRIGGER_KEYS) { + triggers[key] = rowToSingleEntry(model.singles[key]) + } + const zeroNumber: Record = {} + for (const key of ZERO_NUMBER_KEYS) { + zeroNumber[key] = rowToSingleEntry(model.singles[key]) + } + const pools: Record = {} + 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) +}