@tbox.cn/app-module-customer-service 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +16 -0
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/chunk-PN4SPFWE.js +277 -0
- package/dist/client/index.d.ts +15 -0
- package/dist/client/index.js +86 -0
- package/dist/customer-service-UTQ5AVTL.css +141 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +12 -0
- package/dist/server/index.d.ts +106 -0
- package/dist/server/index.js +14 -0
- package/package.json +69 -0
- package/skills/customer-service/SKILL.md +82 -0
- package/src/client/cards/customer-service-faq/index.tsx +78 -0
- package/src/client/cards/index.ts +1 -0
- package/src/client/index.ts +21 -0
- package/src/client/styles/customer-service.css +141 -0
- package/src/index.ts +1 -0
- package/src/server/cards/customer-service-faq/meta.ts +16 -0
- package/src/server/index.ts +115 -0
- package/src/server/ports/index.ts +77 -0
- package/src/server/ports/local-adapters.ts +107 -0
- package/src/server/projections/index.ts +28 -0
- package/src/server/tools/index.ts +89 -0
- package/tbox.module.json +48 -0
- package/tests/cards-render.test.tsx +77 -0
- package/tests/config-merge.test.ts +50 -0
- package/tests/customer-service.test.ts +136 -0
- package/tests/skill.test.ts +48 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +35 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客服承接 Port 解析:本地知识库域恒本地装配(与 provider 供给面正交)。
|
|
3
|
+
* - app-config 服务(integrations v5 modules 节内存源)注入时按 install id 取资源
|
|
4
|
+
* (ref 展开已在 resolver 构造期完成——消费端只见直填形状);
|
|
5
|
+
* - 无服务/缺资源键 → 占位等值回退(模块单测与未装配环境不破坏,离线零行为变化)。
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
CustomerServiceQueryPort,
|
|
9
|
+
} from '@tbox.cn/app-contracts-mall';
|
|
10
|
+
import { parseMallScope } from '@tbox.cn/app-contracts-mall';
|
|
11
|
+
import type { ServerContext, ModuleResourceResolver, ResolvedResource } from '@tbox.cn/app-sdk/server';
|
|
12
|
+
import { createKnowledgeClient, KNOWLEDGE_PLACEHOLDER_DATASET_IDS } from '@tbox.cn/app-sdk/server';
|
|
13
|
+
import type { RequestContext } from '@tbox.cn/app-contracts';
|
|
14
|
+
import { EmptyCustomerServiceAdapter, KnowledgeSearchAdapter, type AssistanceKnowledgeConfig } from './local-adapters';
|
|
15
|
+
|
|
16
|
+
export interface CustomerServicePorts {
|
|
17
|
+
customerService: CustomerServiceQueryPort;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** mall-directory 服务消费接口(015 S7:模板注册,deployment.malls 单一来源;模块只依赖此结构类型) */
|
|
21
|
+
export interface MallDirectoryService {
|
|
22
|
+
contactPhone(mallId: string): string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 解析模板注入的 mall-directory 服务(未注册 → undefined) */
|
|
26
|
+
function resolveMallDirectory(ctx: ServerContext): MallDirectoryService | undefined {
|
|
27
|
+
return ctx.services.resolve?.('mall-directory') as MallDirectoryService | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** mall scope 的 contactPhone(联系客服卡拨打条目数据源;mallId 显式 reqCtx、目录经 ctx 解析) */
|
|
31
|
+
export function currentContactPhone(reqCtx: RequestContext | undefined, ctx: ServerContext): string | undefined {
|
|
32
|
+
const mallId = parseMallScope(reqCtx?.attributes)?.mallId;
|
|
33
|
+
if (!mallId) return undefined;
|
|
34
|
+
return resolveMallDirectory(ctx)?.contactPhone(mallId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 模块 install id 单源(resolver 键 = .tbox/app.json entry.id / packages/ 目录名——
|
|
38
|
+
* 与 declaration.moduleId 短名两词汇不同轴) */
|
|
39
|
+
const MODULE_ID = 'module-customer-service';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 占位等值回退(无 app-config 服务/缺资源键时——模块单测与未装配环境不破坏,离线零行为变化)。
|
|
43
|
+
* 真实 datasetId 由 integrations.json modules 节(create --config-dir 注入)提供;哨兵常量与 smoke R1 共用。
|
|
44
|
+
*/
|
|
45
|
+
const DEFAULT_FAQ_RESOURCE: ResolvedResource = { type: 'knowledge', datasetId: KNOWLEDGE_PLACEHOLDER_DATASET_IDS[0] };
|
|
46
|
+
const DEFAULT_KB_RESOURCE: ResolvedResource = { type: 'knowledge', datasetId: KNOWLEDGE_PLACEHOLDER_DATASET_IDS[1] };
|
|
47
|
+
|
|
48
|
+
/** 导出供单测(resources 取用语义锚定:缺键回退占位、在场透传) */
|
|
49
|
+
export function resolveAssistanceConfig(ctx: ServerContext, mallId: string | undefined): AssistanceKnowledgeConfig {
|
|
50
|
+
const resolver = ctx.services.resolve?.('app-config') as ModuleResourceResolver | undefined;
|
|
51
|
+
const resources = resolver ? resolver(MODULE_ID) : {};
|
|
52
|
+
if (!resolver) {
|
|
53
|
+
console.warn(`[customer-service] app-config 服务未注入 mallId=${mallId ?? 'none'},知识库使用占位 datasetId(召回必然为空)`);
|
|
54
|
+
} else if (!resources['customer-service-faq-kb']) {
|
|
55
|
+
console.warn(`[customer-service] app-config 无 customer-service-faq-kb 资源 mallId=${mallId ?? 'none'},知识库使用占位 datasetId`);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
faq: resources['customer-service-faq-kb'] ?? DEFAULT_FAQ_RESOURCE,
|
|
59
|
+
knowledge: resources['knowledge-assistance-kb'] ?? DEFAULT_KB_RESOURCE,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 解析客服承接 Port 集合(运行期调用;register 只注册不解析——铁律)。
|
|
65
|
+
* 恒本地知识库装配;TBOX_API_KEY 未配置 → 空 FAQ(静默降级)。
|
|
66
|
+
*/
|
|
67
|
+
export function resolveCustomerServicePorts(reqCtx: RequestContext, ctx: ServerContext): CustomerServicePorts {
|
|
68
|
+
const mallId = parseMallScope(reqCtx?.attributes)?.mallId;
|
|
69
|
+
const cfg = resolveAssistanceConfig(ctx, mallId);
|
|
70
|
+
try {
|
|
71
|
+
// knowledge client 按调用构造(无热路径缓存诉求——FAQ 检索频次低,构造轻量)
|
|
72
|
+
return { customerService: new KnowledgeSearchAdapter(createKnowledgeClient(), cfg) };
|
|
73
|
+
} catch (e) {
|
|
74
|
+
ctx.logger?.debug(`knowledge 未配置或构造失败,FAQ 空降级:err=${(e as Error).message}`);
|
|
75
|
+
return { customerService: new EmptyCustomerServiceAdapter() };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客服承接本地 Port 实现:FAQ/知识库走 SDK KnowledgeClient(恒本地装配——与 provider
|
|
3
|
+
* 远程检索面正交;自 shopping-guide 模块迁入,2026-08-31 客服模块拆分)。
|
|
4
|
+
*/
|
|
5
|
+
import type {
|
|
6
|
+
CustomerServiceQueryPort,
|
|
7
|
+
FaqEntry,
|
|
8
|
+
KnowledgeEntry,
|
|
9
|
+
ShoppingQueryContext,
|
|
10
|
+
} from '@tbox.cn/app-contracts-mall';
|
|
11
|
+
import type { KnowledgeClient, ResolvedResource } from '@tbox.cn/app-sdk/server';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 协助知识资源配置(integrations v5:app-config 服务注入;缺服务/缺资源键 → 占位等值回退)。
|
|
15
|
+
* customer-service-faq-kb / knowledge-assistance-kb 为模块 manifest contributes.resources 资源 id;
|
|
16
|
+
* 消费端恒见已展开 ResolvedResource(ref 声明期字段已被 resolver 构造期消解)。
|
|
17
|
+
*/
|
|
18
|
+
export interface AssistanceKnowledgeConfig {
|
|
19
|
+
faq: ResolvedResource;
|
|
20
|
+
knowledge: ResolvedResource;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class KnowledgeSearchAdapter implements CustomerServiceQueryPort {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly knowledge: KnowledgeClient,
|
|
26
|
+
private readonly cfg: AssistanceKnowledgeConfig,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
async searchFaq(
|
|
30
|
+
_ctx: ShoppingQueryContext,
|
|
31
|
+
query: string,
|
|
32
|
+
topK?: number,
|
|
33
|
+
): Promise<readonly FaqEntry[]> {
|
|
34
|
+
const results = await this.knowledge.search({
|
|
35
|
+
// cfg 来源(resolver 展开直填 / 模块占位常量)恒有 datasetId(ResolvedResource 必填字段)
|
|
36
|
+
datasetId: this.cfg.faq.datasetId,
|
|
37
|
+
query,
|
|
38
|
+
topK: topK ?? this.cfg.faq.topK ?? 3,
|
|
39
|
+
...(this.cfg.faq.scoreThreshold !== undefined ? { scoreThreshold: this.cfg.faq.scoreThreshold } : {}),
|
|
40
|
+
});
|
|
41
|
+
return results.map((hit) => {
|
|
42
|
+
const parsed = tryParseJsonFaqDoc(hit.content);
|
|
43
|
+
return {
|
|
44
|
+
question: parsed?.question ?? query,
|
|
45
|
+
answer: parsed?.answer ?? summarize(hit.content),
|
|
46
|
+
...(hit.originFileName ? { category: hit.originFileName } : {}),
|
|
47
|
+
relevanceScore: hit.score,
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async searchKnowledge(
|
|
53
|
+
_ctx: ShoppingQueryContext,
|
|
54
|
+
query: string,
|
|
55
|
+
topK?: number,
|
|
56
|
+
): Promise<readonly KnowledgeEntry[]> {
|
|
57
|
+
const results = await this.knowledge.search({
|
|
58
|
+
datasetId: this.cfg.knowledge.datasetId,
|
|
59
|
+
query,
|
|
60
|
+
topK: topK ?? this.cfg.knowledge.topK ?? 3,
|
|
61
|
+
...(this.cfg.knowledge.scoreThreshold !== undefined ? { scoreThreshold: this.cfg.knowledge.scoreThreshold } : {}),
|
|
62
|
+
});
|
|
63
|
+
return results.map((hit) => ({
|
|
64
|
+
content: hit.content,
|
|
65
|
+
score: hit.score,
|
|
66
|
+
...(hit.originFileName ? { title: hit.originFileName } : {}),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class EmptyCustomerServiceAdapter implements CustomerServiceQueryPort {
|
|
72
|
+
async searchFaq() {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
async searchKnowledge() {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 知识库文档若为 {question,answer,...} JSON 结构则解析,否则返回 undefined */
|
|
81
|
+
function tryParseJsonFaqDoc(content: string): { question: string; answer: string } | undefined {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(content) as unknown;
|
|
84
|
+
if (
|
|
85
|
+
typeof parsed === 'object' &&
|
|
86
|
+
parsed !== null &&
|
|
87
|
+
typeof (parsed as Record<string, unknown>).answer === 'string'
|
|
88
|
+
) {
|
|
89
|
+
const doc = parsed as Record<string, unknown>;
|
|
90
|
+
return {
|
|
91
|
+
question: typeof doc.question === 'string' ? doc.question : '',
|
|
92
|
+
answer: doc.answer as string,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// 非 JSON,走普通摘要路径
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function summarize(content: string): string {
|
|
102
|
+
return content
|
|
103
|
+
.replace(/^#+\s+/gm, '')
|
|
104
|
+
.replace(/\s+/g, ' ')
|
|
105
|
+
.trim()
|
|
106
|
+
.slice(0, 320);
|
|
107
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客服承接投影:FAQ 卡数据(自 shopping-guide 迁入,2026-08-31 客服模块拆分)。
|
|
3
|
+
*/
|
|
4
|
+
import type { CustomerServiceFaqCardData, FaqEntry } from '@tbox.cn/app-contracts-mall';
|
|
5
|
+
|
|
6
|
+
/** FAQ 卡答话事实(与原 summaries.faq 同构:整卡数据 JSON 单行,模型转述唯一依据) */
|
|
7
|
+
export function faqSummary(data: CustomerServiceFaqCardData): string {
|
|
8
|
+
return JSON.stringify(data);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function projectCustomerServiceFaq(
|
|
12
|
+
query: string,
|
|
13
|
+
faqs: readonly FaqEntry[],
|
|
14
|
+
noResultsReason?: string,
|
|
15
|
+
contactPhone?: string,
|
|
16
|
+
): CustomerServiceFaqCardData {
|
|
17
|
+
return {
|
|
18
|
+
query,
|
|
19
|
+
faqs: faqs.map((f) => ({
|
|
20
|
+
question: f.question,
|
|
21
|
+
answer: f.answer,
|
|
22
|
+
...(f.category ? { category: f.category } : {}),
|
|
23
|
+
relevanceScore: f.relevanceScore,
|
|
24
|
+
})),
|
|
25
|
+
...(contactPhone ? { contactPhone } : {}),
|
|
26
|
+
...(noResultsReason ? { noResultsReason } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客服承接 1 工具(011 K5:D11 字符串化 inputSchema——仅 string / map<string,string>)。
|
|
3
|
+
* customer-service-faq:FAQ 命中返回知识条目(作答素材——正文直接作答);无结果同样仅返回
|
|
4
|
+
* summary(正文如实说明)——客服域零出卡(2026-09-02:联系客服引导卡下线,转人工走正文话术)。
|
|
5
|
+
* 自 shopping-guide 模块迁入(2026-08-31 客服模块拆分)。
|
|
6
|
+
*/
|
|
7
|
+
import { createTool, requireToolRequestContext } from '@tbox.cn/app-sdk/server';
|
|
8
|
+
import type { ServerContext, ToolExecContextLike } from '@tbox.cn/app-sdk/server';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import type { RequestContext } from '@tbox.cn/app-contracts';
|
|
11
|
+
import { mallScopeOrEmpty } from '@tbox.cn/app-contracts-mall';
|
|
12
|
+
import type { ShoppingQueryContext } from '@tbox.cn/app-contracts-mall';
|
|
13
|
+
import { resolveCustomerServicePorts, currentContactPhone, type CustomerServicePorts } from '../ports';
|
|
14
|
+
import { projectCustomerServiceFaq, faqSummary } from '../projections';
|
|
15
|
+
|
|
16
|
+
export type CustomerServiceToolIds = 'customer-service-faq';
|
|
17
|
+
|
|
18
|
+
const str = (desc: string) => z.string().describe(desc);
|
|
19
|
+
const optionalStr = (desc: string) => z.string().optional().describe(desc);
|
|
20
|
+
|
|
21
|
+
/** execute 第二参 → reqCtx(require 变体:缺失即 fail-visible 报错) */
|
|
22
|
+
function reqCtxOf(execCtx: ToolExecContextLike): RequestContext {
|
|
23
|
+
return requireToolRequestContext(execCtx);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 宽松上下文:FAQ/知识库工具不依赖 mall scope——无 mall 时以空 scope 兜底(knowledge 不查 mall)。
|
|
27
|
+
* 保持无 subjectId(FAQ 域无主体语义),故用 mallScopeOrEmpty 原子而非 toMallQueryContext 组合子。 */
|
|
28
|
+
function toLooseContext(reqCtx: RequestContext) {
|
|
29
|
+
return {
|
|
30
|
+
scope: mallScopeOrEmpty(reqCtx),
|
|
31
|
+
requestContext: reqCtx,
|
|
32
|
+
} as ShoppingQueryContext;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createCustomerServiceTools(_opts: { getCtx: () => ServerContext }) {
|
|
36
|
+
const exec = <T>(
|
|
37
|
+
fn: (
|
|
38
|
+
ports: CustomerServicePorts,
|
|
39
|
+
ctx: ServerContext,
|
|
40
|
+
args: Record<string, unknown>,
|
|
41
|
+
execCtx: ToolExecContextLike,
|
|
42
|
+
) => Promise<T>,
|
|
43
|
+
) => {
|
|
44
|
+
return async (args: Record<string, unknown>, execCtx: ToolExecContextLike) => {
|
|
45
|
+
const ctx = _opts.getCtx();
|
|
46
|
+
try {
|
|
47
|
+
return await fn(resolveCustomerServicePorts(reqCtxOf(execCtx), ctx), ctx, args, execCtx);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
50
|
+
console.warn(`[customer-service] tool exec error: ${message}`);
|
|
51
|
+
return { found: false, message: '暂时无法查询,请稍后再试' } as unknown as T;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const customerServiceFaq = createTool({
|
|
57
|
+
id: 'customer-service-faq',
|
|
58
|
+
description:
|
|
59
|
+
'客服FAQ:搜索商场常见问题知识(服务政策、会员积分、停车、设施、办事指引等)。命中返回知识条目(作答素材——据此直接作答,重点信息用 **加粗**,步骤类内容分点列出);无结果返回 summary 未命中说明(正文如实告知,不出卡)。',
|
|
60
|
+
inputSchema: z.object({
|
|
61
|
+
query: str('用户问题'),
|
|
62
|
+
topK: optionalStr('返回条数(字符串,1-5,缺省 3)'),
|
|
63
|
+
}),
|
|
64
|
+
execute: exec(async (ports, _ctx, args: Record<string, unknown>, execCtx: ToolExecContextLike) => {
|
|
65
|
+
// 入参合法才 clamp(1-5);缺省/非法 → undefined,由 adapter 侧 cfg.topK ?? 3 兜底(配置生效链)
|
|
66
|
+
const n = Number(args.topK);
|
|
67
|
+
const topK = Number.isFinite(n) && n >= 1 ? Math.min(5, n) : undefined;
|
|
68
|
+
const faqs = await ports.customerService.searchFaq(toLooseContext(reqCtxOf(execCtx)), String(args.query), topK);
|
|
69
|
+
const data = projectCustomerServiceFaq(
|
|
70
|
+
String(args.query),
|
|
71
|
+
faqs,
|
|
72
|
+
faqs.length === 0 ? '未找到相关常见问题,请尝试人工客服。' : undefined,
|
|
73
|
+
currentContactPhone(reqCtxOf(execCtx), _opts.getCtx()),
|
|
74
|
+
);
|
|
75
|
+
// FAQ 召回结果作为事实注入 summary 供模型流式回复;命中/未命中均零出卡(2026-09-02
|
|
76
|
+
// 联系客服引导卡下线)——客服域出卡面归零,未命中转人工由正文话术承接(noResultsReason
|
|
77
|
+
// 仍进 summary 供模型转述,contactPhone 保留作数据面)。
|
|
78
|
+
return { summary: faqSummary(data) };
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// 客服域零出卡:不挂 withCardEmissionHint(description 不预告出卡——工具契约与预告知句一致)
|
|
83
|
+
return {
|
|
84
|
+
customerServiceFaq,
|
|
85
|
+
} as const;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type CustomerServiceTools = ReturnType<typeof createCustomerServiceTools>;
|
|
89
|
+
export type CustomerServiceTool = CustomerServiceTools[keyof CustomerServiceTools];
|
package/tbox.module.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "module-customer-service",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"kind": "business",
|
|
6
|
+
"risk": {
|
|
7
|
+
"level": "low",
|
|
8
|
+
"writeBoundary": "source"
|
|
9
|
+
},
|
|
10
|
+
"distribution": {
|
|
11
|
+
"defaultMode": "codegen"
|
|
12
|
+
},
|
|
13
|
+
"contributes": {
|
|
14
|
+
"handlers": [],
|
|
15
|
+
"tools": [
|
|
16
|
+
"customer-service-faq"
|
|
17
|
+
],
|
|
18
|
+
"cards": [
|
|
19
|
+
{
|
|
20
|
+
"cardType": "customer-service-faq",
|
|
21
|
+
"schemaVersion": 1
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"routes": [],
|
|
25
|
+
"pages": [],
|
|
26
|
+
"tabs": [],
|
|
27
|
+
"actions": [],
|
|
28
|
+
"resources": [
|
|
29
|
+
{
|
|
30
|
+
"id": "customer-service-faq-kb",
|
|
31
|
+
"type": "knowledge"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "knowledge-assistance-kb",
|
|
35
|
+
"type": "knowledge"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"modules": [
|
|
41
|
+
{
|
|
42
|
+
"id": "contracts-mall",
|
|
43
|
+
"required": true
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"env": []
|
|
48
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* module-customer-service 客户端卡片渲染测试(happy-dom)。
|
|
3
|
+
* 覆盖:FAQ 手风琴单展开 / referenceUrl 外链历史态放行(静态外链) / 空态 / 拨打客服。
|
|
4
|
+
* (自 module-shopping-guide tests/cards-render.test.tsx 迁入,2026-08-31 客服拆分)
|
|
5
|
+
*/
|
|
6
|
+
// @vitest-environment happy-dom
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { createRoot } from 'react-dom/client';
|
|
9
|
+
import { act } from 'react-dom/test-utils';
|
|
10
|
+
import { CustomerServiceFaqCard } from '../src/client/cards';
|
|
11
|
+
|
|
12
|
+
vi.mock('@tbox.cn/app-sdk/client', async (importOriginal) => {
|
|
13
|
+
const mod = await importOriginal<typeof import('@tbox.cn/app-sdk/client')>();
|
|
14
|
+
return { ...mod, sendCardAction: vi.fn() };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const mountedRoots = new Set<ReturnType<typeof createRoot>>();
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const root of mountedRoots) {
|
|
21
|
+
act(() => root.unmount());
|
|
22
|
+
}
|
|
23
|
+
mountedRoots.clear();
|
|
24
|
+
document.body.innerHTML = '';
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function render(ui: React.ReactElement) {
|
|
28
|
+
const host = document.createElement('div');
|
|
29
|
+
document.body.appendChild(host);
|
|
30
|
+
const root = createRoot(host);
|
|
31
|
+
act(() => {
|
|
32
|
+
root.render(ui);
|
|
33
|
+
});
|
|
34
|
+
mountedRoots.add(root);
|
|
35
|
+
return { host, root };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('CustomerServiceFaqCard 手风琴', () => {
|
|
39
|
+
const faqData = {
|
|
40
|
+
query: '发票',
|
|
41
|
+
faqs: [
|
|
42
|
+
{ question: 'Q1', answer: 'A1', relevanceScore: 0.9 },
|
|
43
|
+
{ question: 'Q2', answer: 'A2', relevanceScore: 0.8, referenceUrl: 'https://example.com/x' },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
it('默认展开第 0 条,点击切换单展开', () => {
|
|
48
|
+
const { host } = render(<CustomerServiceFaqCard data={faqData as never} cardId="c1" isHistory={false} />);
|
|
49
|
+
const answers = host.querySelectorAll('.cs-faq-item__a');
|
|
50
|
+
expect(answers.length).toBe(1);
|
|
51
|
+
const qs = host.querySelectorAll('.cs-faq-item__q');
|
|
52
|
+
act(() => (qs[1] as HTMLButtonElement).click());
|
|
53
|
+
expect(host.querySelectorAll('.cs-faq-item__a').length).toBe(1);
|
|
54
|
+
expect(host.querySelector('.cs-faq-item__a')!.textContent).toContain('A2');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('referenceUrl 渲染「查看来源」外链,历史态仍可点(静态外链放行)', () => {
|
|
58
|
+
const { host } = render(<CustomerServiceFaqCard data={faqData as never} cardId="c1" isHistory={true} />);
|
|
59
|
+
const qs = host.querySelectorAll('.cs-faq-item__q');
|
|
60
|
+
act(() => (qs[1] as HTMLButtonElement).click());
|
|
61
|
+
const ref = host.querySelector('.cs-faq-item__ref') as HTMLButtonElement;
|
|
62
|
+
expect(ref).not.toBeNull();
|
|
63
|
+
expect(ref.disabled).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('空态渲染 noResultsReason;contactPhone 渲染拨打客服', () => {
|
|
67
|
+
const { host } = render(
|
|
68
|
+
<CustomerServiceFaqCard data={{ query: 'q', faqs: [], noResultsReason: '未找到相关常见问题' } as never} cardId="c1" isHistory={false} />,
|
|
69
|
+
);
|
|
70
|
+
expect(host.querySelector('.cs-empty')!.textContent).toContain('未找到');
|
|
71
|
+
|
|
72
|
+
const { host: host2 } = render(
|
|
73
|
+
<CustomerServiceFaqCard data={{ query: 'q', faqs: faqData.faqs, contactPhone: '0571-88888888' } as never} cardId="c1" isHistory={false} />,
|
|
74
|
+
);
|
|
75
|
+
expect(host2.querySelector('.cs-contact-btn')!.textContent).toContain('0571-88888888');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客服承接资源配置取用语义(integrations v5:app-config 服务 = 模块资源 resolver)。
|
|
3
|
+
* - 资源在场 → 透传(ref 展开已在 resolver 构造期完成,消费端恒见 ResolvedResource);
|
|
4
|
+
* - 缺服务/缺资源键 → 占位等值回退(离线零行为变化;哨兵值与 smoke R1 判定共用)。
|
|
5
|
+
* (遗留 shim 资源级合并 2 例已随 shopping-guide 迁移退役——2026-09-10 单文件真源同批。)
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import type { ServerContext, ModuleResourceResolver, ResolvedResource } from '@tbox.cn/app-sdk/server';
|
|
9
|
+
import { resolveAssistanceConfig } from '../src/server/ports';
|
|
10
|
+
|
|
11
|
+
const fakeCtx = (resolver: ModuleResourceResolver | undefined): ServerContext =>
|
|
12
|
+
({
|
|
13
|
+
services: { resolve: (key: string) => (key === 'app-config' ? resolver : undefined) },
|
|
14
|
+
logger: undefined,
|
|
15
|
+
}) as unknown as ServerContext;
|
|
16
|
+
|
|
17
|
+
const MODULE_ID = 'module-customer-service';
|
|
18
|
+
|
|
19
|
+
describe('resolveAssistanceConfig 资源取用(v5 modules 节)', () => {
|
|
20
|
+
it('app-config 未注册 → 占位回退(faq/mall-knowledge)', () => {
|
|
21
|
+
const config = resolveAssistanceConfig(fakeCtx(undefined), '1000770');
|
|
22
|
+
expect(config.faq).toEqual({ type: 'knowledge', datasetId: 'faq' });
|
|
23
|
+
expect(config.knowledge).toEqual({ type: 'knowledge', datasetId: 'mall-knowledge' });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('资源缺席(resolver 返回 {})→ 占位回退', () => {
|
|
27
|
+
const resolver: ModuleResourceResolver = () => ({});
|
|
28
|
+
const config = resolveAssistanceConfig(fakeCtx(resolver), '1000770');
|
|
29
|
+
expect(config.faq).toEqual({ type: 'knowledge', datasetId: 'faq' });
|
|
30
|
+
expect(config.knowledge).toEqual({ type: 'knowledge', datasetId: 'mall-knowledge' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('资源在场(含 topK/scoreThreshold)→ 透传', () => {
|
|
34
|
+
const faq: ResolvedResource = { type: 'knowledge', datasetId: 'real-faq', topK: 5, scoreThreshold: 0.5 };
|
|
35
|
+
const kb: ResolvedResource = { type: 'knowledge', datasetId: 'real-kb' };
|
|
36
|
+
const resolver: ModuleResourceResolver = (moduleId) =>
|
|
37
|
+
moduleId === MODULE_ID ? { 'customer-service-faq-kb': faq, 'knowledge-assistance-kb': kb } : {};
|
|
38
|
+
const config = resolveAssistanceConfig(fakeCtx(resolver), '1000770');
|
|
39
|
+
expect(config.faq).toEqual(faq);
|
|
40
|
+
expect(config.knowledge).toEqual(kb);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('resolver 以 install id 键控(其他 moduleId 查询不串扰)', () => {
|
|
44
|
+
const resolver: ModuleResourceResolver = (moduleId) =>
|
|
45
|
+
moduleId === MODULE_ID ? { 'customer-service-faq-kb': { type: 'knowledge', datasetId: 'real-faq' } } : {};
|
|
46
|
+
const config = resolveAssistanceConfig(fakeCtx(resolver), undefined);
|
|
47
|
+
expect(config.faq.datasetId).toBe('real-faq');
|
|
48
|
+
expect(config.knowledge.datasetId).toBe('mall-knowledge'); // 缺键回退
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客服承接模块单测:FAQ 工具出参形态(命中/无召回均 summary 零出卡——2026-09-02
|
|
3
|
+
* 联系客服引导卡下线)、faqPreTool decide 操作型收窄、本地知识库 adapter 配置注入、
|
|
4
|
+
* 投影 contactPhone。
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
7
|
+
import { createServerContext, createToolExecContext } from '@tbox.cn/app-sdk/server';
|
|
8
|
+
import { createCustomerServiceTools } from '../src/server/tools';
|
|
9
|
+
import { KnowledgeSearchAdapter, EmptyCustomerServiceAdapter } from '../src/server/ports/local-adapters';
|
|
10
|
+
import { projectCustomerServiceFaq } from '../src/server/projections';
|
|
11
|
+
|
|
12
|
+
const testRequestContext = (userId = 'u_1') => ({
|
|
13
|
+
identity: { userId, source: 'dev' as const },
|
|
14
|
+
attributes: { mall: { miniAppId: 'app', mallId: 'mall-1' } },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('customer-service-faq 工具', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// FAQ 命中路径的映射断言在 KnowledgeSearchAdapter 用例覆盖(工具内 knowledge client
|
|
23
|
+
// 构造依赖平台凭据,命中形态不可离线确定性——无召回分支走 Empty 降级恒可断言)。
|
|
24
|
+
|
|
25
|
+
it('FAQ 无召回:零出卡(仅 summary,含 noResultsReason 供正文转述)', async () => {
|
|
26
|
+
const ctx = createServerContext();
|
|
27
|
+
const tools = createCustomerServiceTools({ getCtx: () => ctx });
|
|
28
|
+
const result = (await tools.customerServiceFaq.execute!(
|
|
29
|
+
{ query: '完全不相关的问题xyz' },
|
|
30
|
+
createToolExecContext(testRequestContext()) as never,
|
|
31
|
+
)) as { summary: string; cards?: unknown };
|
|
32
|
+
|
|
33
|
+
expect(result.cards).toBeUndefined();
|
|
34
|
+
expect(typeof result.summary).toBe('string');
|
|
35
|
+
expect(result.summary.length).toBeGreaterThan(0);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('faqPreTool decide:v3 三层门控(政策 run → 操作 skip → 默认 run)', async () => {
|
|
40
|
+
// decide 是模块内闭包(非导出)——经 serverModule 声明取回判定函数
|
|
41
|
+
const { serverModule } = await import('../src/server/index');
|
|
42
|
+
const decide = (serverModule.preTools!['customer-service-faq']!).decide;
|
|
43
|
+
|
|
44
|
+
it('操作型诉求(缴纳/我的/余额/进度)→ skip', async () => {
|
|
45
|
+
for (const text of ['帮我缴纳停车费', '我的停车费是多少', '我的积分余额还有多少', '退款申请进度']) {
|
|
46
|
+
const d = (await decide!({ text })) as { decision: string; reason?: string };
|
|
47
|
+
expect(d.decision, text).toBe('skip');
|
|
48
|
+
expect(d.reason).toContain('操作型');
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('政策型信号优先于操作型子串共存("停车缴费规则"含"缴费"仍 run)', async () => {
|
|
53
|
+
for (const text of ['停车缴费规则是什么', '退货政策', '会员权益有哪些', '发票办理条件和流程']) {
|
|
54
|
+
const d = (await decide!({ text })) as { decision: string; args?: { query: string; topK: string } };
|
|
55
|
+
expect(d.decision, text).toBe('run');
|
|
56
|
+
expect(d.args!.query).toBe(text);
|
|
57
|
+
expect(d.args!.topK).toBe('3');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('无信号词默认 run(宁多勿漏——FAQ 检索轻量空返无害)', async () => {
|
|
62
|
+
for (const text of ['积分相关咨询', '你好', '电影院能带外食吗', '宠物能进吗']) {
|
|
63
|
+
const d = (await decide!({ text })) as { decision: string };
|
|
64
|
+
expect(d.decision, text).toBe('run');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('空文本 → skip(回归)', async () => {
|
|
69
|
+
const d = (await decide!({ text: ' ' })) as { decision: string; reason?: string };
|
|
70
|
+
expect(d.decision).toBe('skip');
|
|
71
|
+
expect(d.reason).toBe('空文本');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('KnowledgeSearchAdapter:配置注入', () => {
|
|
76
|
+
it('datasetId/topK/scoreThreshold 按配置透传(faq/knowledge 各自键)', async () => {
|
|
77
|
+
const search = vi.fn(async () => [
|
|
78
|
+
{ content: JSON.stringify({ question: '如何开发票', answer: '1F 服务台' }), score: 0.92, originFileName: 'invoice.md' },
|
|
79
|
+
]);
|
|
80
|
+
const adapter = new KnowledgeSearchAdapter({ search } as never, {
|
|
81
|
+
faq: { datasetId: 'faq-real', topK: 4, scoreThreshold: 0.5 },
|
|
82
|
+
knowledge: { datasetId: 'kb-real', topK: 2 },
|
|
83
|
+
});
|
|
84
|
+
const faqs = await adapter.searchFaq({ scope: { miniAppId: '', mallId: '' } } as never, '怎么开发票');
|
|
85
|
+
expect(search).toHaveBeenCalledWith(expect.objectContaining({ datasetId: 'faq-real', topK: 4, scoreThreshold: 0.5 }));
|
|
86
|
+
expect(faqs[0]).toMatchObject({ question: '如何开发票', answer: '1F 服务台', category: 'invoice.md', relevanceScore: 0.92 });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('非 JSON 文档 → 摘要路径(summarize 截断 320)', async () => {
|
|
90
|
+
const long = '政策'.repeat(400);
|
|
91
|
+
const search = vi.fn(async () => [{ content: `# 政策说明\n${long}`, score: 0.8 }]);
|
|
92
|
+
const adapter = new KnowledgeSearchAdapter({ search } as never, {
|
|
93
|
+
faq: { datasetId: 'faq' },
|
|
94
|
+
knowledge: { datasetId: 'kb' },
|
|
95
|
+
});
|
|
96
|
+
const faqs = await adapter.searchFaq({ scope: { miniAppId: '', mallId: '' } } as never, 'q');
|
|
97
|
+
expect(faqs[0]!.answer.length).toBeLessThanOrEqual(320);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('EmptyCustomerServiceAdapter:未配置降级', () => {
|
|
102
|
+
it('searchFaq/searchKnowledge 恒空', async () => {
|
|
103
|
+
const adapter = new EmptyCustomerServiceAdapter();
|
|
104
|
+
expect(await adapter.searchFaq({} as never, 'q')).toHaveLength(0);
|
|
105
|
+
expect(await adapter.searchKnowledge({} as never, 'q')).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('projectCustomerServiceFaq', () => {
|
|
110
|
+
it('contactPhone 注入(缺省不渲染由组件层保证)', () => {
|
|
111
|
+
const withPhone = projectCustomerServiceFaq('q', [], undefined, '0571-88888888');
|
|
112
|
+
expect(withPhone.contactPhone).toBe('0571-88888888');
|
|
113
|
+
const without = projectCustomerServiceFaq('q', []);
|
|
114
|
+
expect(without.contactPhone).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('faqPreTool routing:词表对齐客服承接面(2026-09-02 扩充回归锁)', async () => {
|
|
119
|
+
const { selectPreTools } = await import('@tbox.cn/app-sdk/server');
|
|
120
|
+
const { serverModule: csModule } = await import('../src/server/index');
|
|
121
|
+
const faqDef = csModule.preTools!['customer-service-faq']!;
|
|
122
|
+
|
|
123
|
+
it('营业时间/投诉/客服类问句命中 routing(此前缺口:商场几点开门)', () => {
|
|
124
|
+
for (const text of ['商场几点开门', '营业时间到几点', '我要投诉', '找客服', '开发票']) {
|
|
125
|
+
const hit = selectPreTools(text, [faqDef]);
|
|
126
|
+
expect(hit.map((d) => d.tool.id), text).toContain('customer-service-faq');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('操作型问句(缴纳停车费)routing 命中但 decide skip——分层语义不回归', async () => {
|
|
131
|
+
const hit = selectPreTools('帮我缴纳停车费', [faqDef]);
|
|
132
|
+
expect(hit.map((d) => d.tool.id)).toContain('customer-service-faq');
|
|
133
|
+
const d = (await faqDef.decide({ text: '帮我缴纳停车费' })) as { decision: string };
|
|
134
|
+
expect(d.decision).toBe('skip');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客服承接文件 skill 装配验收——loadModuleSkills 经 workspace link 解析包根 skills/,
|
|
3
|
+
* 声明式通道填回 serverModule.skills;routingTerms 与导购域不相交(政策词归客服域)。
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { serverModule } from '../src/server/index';
|
|
7
|
+
|
|
8
|
+
type LoadedSkill = { name: string; description: string; routingTerms: string[]; instructions: string };
|
|
9
|
+
|
|
10
|
+
describe('customer-service skills 装配', () => {
|
|
11
|
+
const skills = ((serverModule as { skills?: Record<string, LoadedSkill> }).skills ?? {});
|
|
12
|
+
|
|
13
|
+
it('skills 含 customer-service(frontmatter name + 目录名一致)', () => {
|
|
14
|
+
expect(Object.keys(skills)).toContain('customer-service');
|
|
15
|
+
expect(skills['customer-service']!.description.length).toBeGreaterThan(0);
|
|
16
|
+
expect(skills['customer-service']!.instructions).toContain('客服');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('routingTerms 域内词齐备且不含导购词(门店/楼层归导购域)', () => {
|
|
20
|
+
expect(skills['customer-service']!.routingTerms).toEqual([
|
|
21
|
+
'客服',
|
|
22
|
+
'退换货',
|
|
23
|
+
'退货',
|
|
24
|
+
'退款',
|
|
25
|
+
'售后',
|
|
26
|
+
'发票',
|
|
27
|
+
'投诉',
|
|
28
|
+
'服务政策',
|
|
29
|
+
'营业时间',
|
|
30
|
+
'开门',
|
|
31
|
+
'关门',
|
|
32
|
+
'开闭店',
|
|
33
|
+
]);
|
|
34
|
+
expect(skills['customer-service']!.routingTerms.some((t) => t.includes('门店') || t.includes('楼层'))).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('SOP 含承接链与跨域直调(单轮单 skill 注入下的自含协作)', () => {
|
|
38
|
+
expect(skills['customer-service']!.instructions).toContain('客服域零出卡');
|
|
39
|
+
expect(skills['customer-service']!.instructions).toContain('customer-service-faq');
|
|
40
|
+
expect(skills['customer-service']!.instructions).toContain('queryParkingFee');
|
|
41
|
+
expect(skills['customer-service']!.instructions).toContain('queryPoints');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('SOP 含商场基础信息场景(营业时间不编造:无收录正文如实说明)', () => {
|
|
45
|
+
expect(skills['customer-service']!.instructions).toContain('商场基础信息');
|
|
46
|
+
expect(skills['customer-service']!.instructions).toContain('不按常识编造营业时间');
|
|
47
|
+
});
|
|
48
|
+
});
|