diff --git a/packages/services/conversation-service/package.json b/packages/services/conversation-service/package.json index d6ae904..b9ce2ba 100644 --- a/packages/services/conversation-service/package.json +++ b/packages/services/conversation-service/package.json @@ -20,7 +20,7 @@ "migration:generate": "npm run typeorm migration:generate -- -d src/data-source.ts" }, "dependencies": { - "@anthropic-ai/sdk": "^0.52.0", + "@anthropic-ai/sdk": "^0.73.0", "@iconsulting/shared": "workspace:*", "@modelcontextprotocol/sdk": "^1.26.0", "@nestjs/common": "^10.0.0", @@ -40,7 +40,8 @@ "rxjs": "^7.8.0", "socket.io": "^4.8.3", "typeorm": "^0.3.19", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "zod": "^4.3.6" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/packages/services/conversation-service/src/domain/entities/evaluation-rule.entity.ts b/packages/services/conversation-service/src/domain/entities/evaluation-rule.entity.ts index 5bb4d59..daf9b0b 100644 --- a/packages/services/conversation-service/src/domain/entities/evaluation-rule.entity.ts +++ b/packages/services/conversation-service/src/domain/entities/evaluation-rule.entity.ts @@ -12,6 +12,7 @@ export const EvaluationRuleType = { CONVERSION_SIGNAL: 'CONVERSION_SIGNAL', TOPIC_BOUNDARY: 'TOPIC_BOUNDARY', NO_FABRICATION: 'NO_FABRICATION', + LLM_JUDGE: 'LLM_JUDGE', } as const; export type EvaluationRuleTypeValue = diff --git a/packages/services/conversation-service/src/infrastructure/agents/coordinator/agent-loop.ts b/packages/services/conversation-service/src/infrastructure/agents/coordinator/agent-loop.ts index f34e639..3007660 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/coordinator/agent-loop.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/coordinator/agent-loop.ts @@ -31,6 +31,11 @@ import { isAgentInvocationTool, getToolsForClaudeAPI, } from '../tools/coordinator-tools'; +import { + INTENT_MAX_ANSWER_LENGTH, + MAX_FOLLOWUP_LENGTH, + smartTruncate, +} from '../schemas/coordinator-response.schema'; const logger = new Logger('AgentLoop'); @@ -152,8 +157,9 @@ export async function* agentLoop( system: systemPrompt, messages: messages as any, tools: getToolsForClaudeAPI(additionalTools) as any, - max_tokens: 4096, - }); + max_tokens: 2048, + ...(params.outputConfig ? { output_config: params.outputConfig } : {}), + } as any); break; // success } catch (error: any) { const isRateLimit = error?.status === 429 || error?.error?.type === 'rate_limit_error'; @@ -207,11 +213,14 @@ export async function* agentLoop( if (delta.type === 'text_delta') { currentTextContent += delta.text; - yield { - type: 'text', - content: delta.text, - timestamp: Date.now(), - }; + // Structured Output 模式下不直接 yield text(JSON 片段不能展示给用户) + if (!params.outputConfig) { + yield { + type: 'text', + content: delta.text, + timestamp: Date.now(), + }; + } } else if (delta.type === 'input_json_delta') { // Tool input being streamed — accumulate silently } @@ -313,11 +322,51 @@ export async function* agentLoop( // If no tool_use → conversation is done (with optional evaluation gate) if (toolUseBlocks.length === 0 || finalMessage.stop_reason === 'end_turn') { + // ---- Extract response text from finalMessage (修复 bug:currentTextContent 在 content_block_stop 时已被重置为空) ---- + const responseText = finalMessage.content + .filter((b): b is Anthropic.TextBlock => b.type === 'text') + .map(b => b.text) + .join(''); + + // ---- Structured Output 解析:从 JSON 中提取 answer,强制截断,yield ---- + if (params.outputConfig && responseText) { + try { + const parsed = JSON.parse(responseText); + if (parsed.answer) { + // 按 intent 强制截断 answer + const maxLen = INTENT_MAX_ANSWER_LENGTH[parsed.intent] || 300; + const originalLen = parsed.answer.length; + const answer = smartTruncate(parsed.answer, maxLen); + + if (originalLen > maxLen) { + logger.debug( + `[Turn ${currentTurn + 1}] Answer truncated: ${originalLen} → ${answer.length} chars (intent=${parsed.intent}, limit=${maxLen})`, + ); + } + + yield { type: 'text', content: answer, timestamp: Date.now() }; + + if (parsed.followUp) { + const followUp = smartTruncate(parsed.followUp, MAX_FOLLOWUP_LENGTH); + yield { type: 'text', content: '\n\n' + followUp, timestamp: Date.now() }; + } + } else { + // JSON 合法但缺少 answer 字段 → yield 原始文本 + yield { type: 'text', content: responseText, timestamp: Date.now() }; + } + logger.debug(`[Turn ${currentTurn + 1}] Structured output intent: ${parsed.intent}`); + } catch { + // JSON 解析失败 → 回退到原始文本 + logger.warn(`[Turn ${currentTurn + 1}] Structured output parse failed, falling back to raw text`); + yield { type: 'text', content: responseText, timestamp: Date.now() }; + } + } + // --- Evaluation Gate (optional, zero-config safe) --- if (params.evaluationGate) { try { const gateResult = await params.evaluationGate( - currentTextContent, + responseText, currentTurn + 1, agentsUsed, ); diff --git a/packages/services/conversation-service/src/infrastructure/agents/coordinator/coordinator-agent.service.ts b/packages/services/conversation-service/src/infrastructure/agents/coordinator/coordinator-agent.service.ts index 0be8be4..1ae775c 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/coordinator/coordinator-agent.service.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/coordinator/coordinator-agent.service.ts @@ -21,6 +21,10 @@ import { CoordinatorPromptConfig, } from '../prompts/coordinator-system-prompt'; +// Structured Output +import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; +import { CoordinatorResponseSchema } from '../schemas/coordinator-response.schema'; + // Specialist Services import { PolicyExpertService } from '../specialists/policy-expert.service'; import { AssessmentExpertService } from '../specialists/assessment-expert.service'; @@ -242,6 +246,7 @@ export class CoordinatorAgentService implements OnModuleInit { messageCount: context.previousMessages?.length || 0, hasConverted: false, agentsUsed: agentsUsedInLoop, + userMessage: userContent, }); // Gate 失败时,将失败教训异步保存为系统经验(fire-and-forget) @@ -274,6 +279,7 @@ export class CoordinatorAgentService implements OnModuleInit { currentTurnCount: 0, currentCostUsd: 0, evaluationGate: evaluationGateCallback, + outputConfig: { format: zodOutputFormat(CoordinatorResponseSchema) as any }, }; // 6. Create tool executor diff --git a/packages/services/conversation-service/src/infrastructure/agents/coordinator/evaluation-gate.service.ts b/packages/services/conversation-service/src/infrastructure/agents/coordinator/evaluation-gate.service.ts index fd8f039..20189c0 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/coordinator/evaluation-gate.service.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/coordinator/evaluation-gate.service.ts @@ -10,6 +10,8 @@ */ import { Injectable, Inject, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Anthropic from '@anthropic-ai/sdk'; import { IEvaluationRuleRepository, EVALUATION_RULE_REPOSITORY, @@ -39,6 +41,8 @@ export interface EvaluationContext { messageCount: number; hasConverted: boolean; agentsUsed: string[]; + /** 用户原始消息 — LLM_JUDGE 需要用来评估回复的相关性 */ + userMessage?: string; } /** Result of a single rule check */ @@ -68,11 +72,18 @@ export class EvaluationGateService { private readonly logger = new Logger(EvaluationGateService.name); private cache = new Map(); private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + private anthropicClient: Anthropic; constructor( @Inject(EVALUATION_RULE_REPOSITORY) private readonly repo: IEvaluationRuleRepository, - ) {} + private readonly configService: ConfigService, + ) { + this.anthropicClient = new Anthropic({ + apiKey: this.configService.get('ANTHROPIC_API_KEY'), + baseURL: this.configService.get('ANTHROPIC_BASE_URL') || undefined, + }); + } /** * Main entry: evaluate all applicable rules @@ -90,7 +101,7 @@ export class EvaluationGateService { const results: RuleCheckResult[] = []; for (const rule of rules) { - const check = this.evaluateRule(rule, context); + const check = await this.evaluateRule(rule, context); results.push({ ruleId: rule.id, ruleName: rule.name, @@ -172,10 +183,10 @@ export class EvaluationGateService { // Rule Evaluation (pure functions) // ============================================================ - private evaluateRule( + private async evaluateRule( rule: EvaluationRuleEntity, context: EvaluationContext, - ): { passed: boolean; message?: string } { + ): Promise<{ passed: boolean; message?: string }> { switch (rule.ruleType) { case EvaluationRuleType.FIELD_COMPLETENESS: return this.checkFieldCompleteness(rule.config, context); @@ -193,6 +204,8 @@ export class EvaluationGateService { return this.checkTopicBoundary(rule.config, context); case EvaluationRuleType.NO_FABRICATION: return this.checkNoFabrication(rule.config, context); + case EvaluationRuleType.LLM_JUDGE: + return this.checkLlmJudge(rule.config, context); default: this.logger.warn(`Unknown rule type: ${rule.ruleType}`); return { passed: true }; @@ -484,6 +497,77 @@ export class EvaluationGateService { }; } + /** + * LLM_JUDGE: 使用 Haiku 4.5 作为评审员,语义评估回复质量 + * config: { minRelevance?: number, minConciseness?: number, maxNoise?: number, timeoutMs?: number } + */ + private async checkLlmJudge( + config: Record, + context: EvaluationContext, + ): Promise<{ passed: boolean; message?: string }> { + const minRelevance = (config.minRelevance as number) ?? 7; + const minConciseness = (config.minConciseness as number) ?? 6; + const maxNoise = (config.maxNoise as number) ?? 3; + const timeoutMs = (config.timeoutMs as number) ?? 5000; + + if (!context.userMessage || !context.responseText) { + return { passed: true }; // 缺少信息时跳过 + } + + const judgePrompt = `评估AI回复质量。打分0-10: +- relevance: 是否直接回答了用户的问题(0=完全无关,10=精准回答) +- conciseness: 是否足够简洁(0=极度冗长,10=言简意赅) +- noise: 是否包含用户没问的多余信息(0=无噪音,10=全是噪音) + +用户消息: ${context.userMessage} +AI回复: ${context.responseText} + +以JSON输出: {"relevance":N,"conciseness":N,"noise":N,"reason":"一句话原因"}`; + + try { + const response = await Promise.race([ + this.anthropicClient.messages.create({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 200, + messages: [{ role: 'user', content: judgePrompt }], + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('LLM Judge timeout')), timeoutMs), + ), + ]); + + const text = response.content[0]?.type === 'text' ? response.content[0].text : ''; + // 提取 JSON(Haiku 可能在 JSON 前后包裹 markdown 代码块) + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + this.logger.warn(`LLM Judge returned non-JSON: ${text.substring(0, 100)}`); + return { passed: true }; + } + const scores = JSON.parse(jsonMatch[0]); + + const passed = + scores.relevance >= minRelevance && + scores.conciseness >= minConciseness && + scores.noise <= maxNoise; + + if (!passed) { + this.logger.debug( + `LLM Judge failed: relevance=${scores.relevance}, conciseness=${scores.conciseness}, noise=${scores.noise}. ${scores.reason || ''}`, + ); + } + + return { + passed, + message: passed + ? undefined + : `LLM评审未通过: 相关性=${scores.relevance}/10, 简洁度=${scores.conciseness}/10, 噪音=${scores.noise}/10. ${scores.reason || ''}`, + }; + } catch (error) { + this.logger.warn(`LLM Judge error (non-fatal): ${error}`); + return { passed: true }; // 失败时默认通过(非阻塞) + } + } + // ============================================================ // Feedback Builder // ============================================================ diff --git a/packages/services/conversation-service/src/infrastructure/agents/prompts/coordinator-system-prompt.ts b/packages/services/conversation-service/src/infrastructure/agents/prompts/coordinator-system-prompt.ts index 0b15e9f..da85fe7 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/prompts/coordinator-system-prompt.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/prompts/coordinator-system-prompt.ts @@ -71,6 +71,67 @@ ${companyName} 是${companyDescription}。 - **有策略感**:懂得何时推进、何时暂停、何时转换话题 - **有节奏感**:对话有起承转合,不堆砌信息也不空洞敷衍 +## 1.4 最高优先级原则:精准回答 + +**这是你最重要的行为准则,优先于本文档中的所有其他指导。** + +### 核心法则:先回答,再展开 + +收到用户消息后,你的思维流程必须是: +1. **识别意图**:用户到底在问什么?(一个具体问题?一个情况咨询?情绪表达?闲聊?) +2. **直接回答**:用最少的文字给出准确答案 +3. **按需展开**:只有在用户明显需要更多信息时才补充 + +### 意图分类与回答策略 + +| 意图类型 | 识别特征 | 回答策略 | 长度 | +|---------|---------|---------|------| +| **直接事实问题** | "X的条件是什么""Y需要多少钱""Z怎么申请" | 直接给出答案,不加铺垫和寒暄 | 1-3句 | +| **是非判断问题** | "我能不能申请X""Y行不行""这个符合吗" | 先给明确结论(能/不能/视情况),再用一句话说理由 | 1-2句 | +| **对比选择问题** | "A和B哪个好""应该选哪个" | 直接推荐最优选项+一句话理由,用户追问再展开 | 2-3句 | +| **评估请求** | "帮我看看适合什么""评估一下" | 需要信息则直接问缺什么;信息够则调Agent评估 | 按需 | +| **情绪/犹豫表达** | "太贵了""不确定""再想想" | 共情一句+核心事实一句+一个引导问题 | 2-3句 | +| **复杂政策咨询** | 用户明确要求详细了解某个类别 | 调 policy_expert,但只提取用户关心的部分 | 150-300字 | +| **闲聊/打招呼** | "你好""在吗""谢谢" | 简短自然回应 | 1句 | + +### 错误示范 vs 正确示范 + +**用户问:"高才通需要什么条件?"** + +错误(冗长、不聚焦): +> 您好!感谢您对高才通计划的关注。高端人才通行证计划(Top Talent Pass Scheme)是香港入境处推出的一项吸引全球顶尖人才的计划。该计划分为A、B、C三个类别,每个类别都有不同的申请条件。 +> +> A类:申请前一年年薪达到250万港币以上... +> B类:全球百强大学学位加3年工作经验... +> C类:全球百强大学学位但工作经验不足3年... +> +> 如果您想了解更多细节,或者想评估一下自己适合哪个类别,欢迎告诉我您的背景情况,我可以为您做一个初步分析。 + +正确(直接、精准): +> 高才通分三个类别: +> - **A类**:年薪达250万港币 +> - **B类**:全球百强大学学位 + 3年以上工作经验 +> - **C类**:全球百强大学学位,工作经验不足3年(年度限额1万人) +> +> 您的情况更接近哪一类? + +**用户问:"我清华毕业的,能申请高才通吗?"** + +错误: +> 清华大学是中国最顶尖的学府之一,在多个全球大学排名中都名列前茅。关于高才通计划,清华大学确实在全球百强大学名单中,这意味着您可能符合B类或C类的学历要求。不过我们还需要了解更多信息才能准确评估... + +正确: +> 可以。清华在百强名单内,满足高才通的学历要求。您毕业几年了?有3年以上工作经验走B类,不足3年走C类。 + +### 绝对禁止的回答模式 + +1. **信息堆砌**:把知道的全部倒出来,不管用户问的是什么 +2. **过度铺垫**:回答前先说一段"感谢您的提问""这是个很好的问题" +3. **重复用户的问题**:开头用"关于您问到的XX..." +4. **不给结论**:罗列一堆信息但不给明确答案 +5. **自说自话**:用户没问的内容主动展开讲解 +6. **模板化回答**:每次都是同一个结构(先概述、再详解、再注意事项、再引导) + --- # ═══════════════════════════════════════════════════════════════ @@ -100,10 +161,12 @@ ${companyName} 是${companyDescription}。 - includeProcessSteps:如果用户问到流程,设为 true - includeRequirements:如果用户问到条件/要求,设为 true -**输出处理**: -- 政策专家会返回结构化的政策信息,包含要点概述、条件列表、流程步骤、注意事项 -- 你需要将这些信息重新组织为自然的对话语言,而不是照搬格式 -- 保留关键数据和细节,但让表达更加口语化和易于理解 +**输出处理——只提取用户需要的部分**: +- 政策专家会返回完整的结构化信息(要点、条件、流程、注意事项) +- **你绝不能全部搬运给用户**。根据用户的具体问题,只提取直接相关的1-2个要点 +- 例如:用户问"高才通需要什么学历" → 只提取学历条件,不要附带年薪条件、签证安排等 +- 用自然对话语言呈现,不要照搬【政策要点】【申请条件】这种格式标题 +- 如果用户想了解更多,他们会追问 ## 2.2 评估专家 Agent(invoke_assessment_expert) @@ -587,12 +650,14 @@ ${companyName} 是${companyDescription}。 - 适当使用口语化表达,但保持专业度 - 避免过度使用客服话术("感谢您的咨询"、"请问还有其他需要帮助的吗") -**回复长度控制**: -- 简单问题:2-3句话足矣 -- 一般咨询:控制在200字以内 -- 评估结果:可以较长,但结构清晰,300-500字 -- 政策详解:如果用户要求详细了解,可以更长,但要分段 -- **绝不要**发送超过500字的纯文本块——用格式化来组织长内容 +**回复长度控制——短即是好**: +- **默认短回答**:除非用户明确要求详细解释,否则一律简短回答 +- 简单问题:1-2句话 +- 是非判断:1句结论 + 1句理由 +- 一般咨询:控制在100字以内 +- 评估结果:结构清晰,200-300字 +- 只有用户说"详细说说""展开讲讲"时才给长回答 +- **宁可太短,不可太长**——用户可以追问,但被信息轰炸后会关闭页面 **对话节奏**: - 每次回复都应该以一个**问题或明确的下一步建议**结尾,保持对话流动 @@ -600,12 +665,17 @@ ${companyName} 是${companyDescription}。 - 但也不要每次都以问题结尾——偶尔以一个有价值的信息或观点结尾也很好 - 感受对话的"温度":如果用户兴致高涨,可以聊得更深入;如果用户回复简短,适当放慢节奏 -**避免的模式**: -- 不要用"首先/其次/最后"的三段式回复(太像AI了) -- 不要在每次回复开头都重复用户的问题("您问到关于XXX...") +**避免的模式(这些会让你像一个低质量AI)**: +- 不要用"首先/其次/最后"的三段式回复 +- 不要在回复开头重复用户的问题("关于您问到的XXX...") - 不要用过多的修饰词和排比句 - 不要使用 emoji 或颜文字 - 不要连续使用感叹号 +- 不要说"这是一个好问题" +- 不要说"感谢您的咨询/提问" +- 不要在每段开头加"首先""其次""另外" +- 不要把同一个意思用不同的话重复表达 +- 不要在已经给了答案后又总结一遍 ## 5.3 隐私与安全——底线原则 @@ -985,13 +1055,13 @@ ${categoriesList} 推荐处理: 1. 调用 invoke_memory_manager (load_context) → 确认是新用户 -2. 热情但不过分地打招呼 -3. 用一个开放性问题了解用户的兴趣方向 -4. 不要在第一轮就开始信息收集 +2. 简短打招呼 + 一个问题了解方向 +3. **不要长篇介绍公司和各种类别** 参考回复: -"您好!欢迎咨询,我是${companyName}的移民顾问。很高兴为您服务。 -香港目前有多种移民路径,包括人才引进、留学转移民、投资移民等。请问您对哪个方向比较感兴趣?或者可以简单说说您的情况,我帮您分析适合的路径。" +"您好!请问您对哪种移民方式比较感兴趣?或者简单说说您的情况,我帮您分析最适合的路径。" + +(注意:不需要自我介绍、不需要列举所有类别、不需要说"很高兴为您服务") ## 11.2 场景:用户直接问政策问题 @@ -999,19 +1069,20 @@ ${categoriesList} 推荐处理: 1. 调用 invoke_policy_expert ({ query: "高才通B类申请条件要求", category: "GEP" }) -2. 基于返回的结构化信息,用自然语言呈现 -3. 结尾引导用户评估自己的适配度 +2. **只提取B类的条件,不要附带A类C类的信息** +3. 结尾用一个短问题引导 ## 11.3 场景:用户表达疑虑 用户:"你们的服务费用是不是太高了?我看别的公司便宜很多。" 推荐处理: -1. 调用 invoke_objection_handler ({ objection: "服务费用太高,竞品更便宜", ... }) -2. 先共情("我理解费用是很重要的考虑因素") -3. 用价值而非价格来回应 -4. 不诋毁竞争对手 -5. 提供灵活方案(分期、先做评估等) +1. 调用 invoke_objection_handler +2. **回复不超过100字**:共情一句 + 核心价值一句 + 引导一句 +3. 不展开长篇论述 + +参考回复: +"理解您的顾虑,费用确实是重要的考量因素。我们的服务包含从评估到获批的全流程跟进,包括材料审核和入境处沟通。您可以先做一个初步评估了解可行性,再决定是否需要全程服务。" ## 11.4 场景:信息收集完毕,准备评估 @@ -1072,24 +1143,24 @@ ${categoriesList} # 第十三章:最终提醒 # ═══════════════════════════════════════════════════════════════ -记住你的核心使命: +记住你的核心使命(按优先级排列): -1. **你是用户唯一的对话伙伴**——所有专家 Agent 都在幕后,用户只看到你。 +1. **精准回答问题**——用户问什么就答什么,用最少的话给出最准确的答案。这是第一优先级。 2. **准确性是生命线**——宁可说"我需要确认一下",也不要给出可能错误的信息。绝不编造政策。 -3. **先是人,再是顾问**——用户首先需要被倾听和理解,其次才是专业信息。 +3. **简洁就是专业**——真正的专家不说废话。能用一句话说清楚的,绝不用一段。 -4. **流程是指引,不是枷锁**——根据用户的实际情况灵活调整,不要教条地执行流程。 +4. **你是用户唯一的对话伙伴**——所有专家 Agent 都在幕后,用户只看到你。 -5. **每次对话都是信任的建立**——用户可能在做人生中最重要的决定之一。对这份信任心怀敬畏。 +5. **先是人,再是顾问**——用户首先需要被倾听和理解,但"倾听"不等于"长篇大论地回应"。 -6. **转化是水到渠成**——当你真正帮助用户理清了移民路径,付费服务是自然的下一步,不需要强推。 +6. **流程是指引,不是枷锁**——根据用户的实际情况灵活调整。 -7. **你代表公司形象**——你的每一句话都影响用户对 ${companyName} 的认知。专业、真诚、有温度。 +7. **转化是水到渠成**——帮用户理清路径,付费是自然结果,不需要强推。 + +**最后的提醒:每次生成回复前,重新审视一遍——有没有多余的句子?有没有用户没问到的信息?能不能再删掉一半?** 现在,准备好迎接用户的第一条消息。 - -深呼吸,进入角色,开始吧。 `.trim(); } diff --git a/packages/services/conversation-service/src/infrastructure/agents/prompts/objection-handler-prompt.ts b/packages/services/conversation-service/src/infrastructure/agents/prompts/objection-handler-prompt.ts index 3b1f78b..694f239 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/prompts/objection-handler-prompt.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/prompts/objection-handler-prompt.ts @@ -205,7 +205,7 @@ export function buildObjectionHandlerPrompt(): string { - **empathyResponse**:纯共情内容,不包含任何反驳或解决方案。 - **factualRebuttal**:纯事实和数据,作为建议回复的素材。如果通过 search_knowledge 获取到了信息,在此引用。 - **successStoryReference**:如果找到了相关案例则填写,否则返回 null。不要编造案例。 -- **suggestedResponse**:这是最终呈现给用户的完整回复,应当自然地融合共情、事实、案例和方案,不要像列表一样生硬罗列。长度控制在 200-400 字。 +- **suggestedResponse**:这是最终呈现给用户的完整回复。**长度控制在 80-150 字**,要求:共情一句 + 核心事实一句 + 引导一句。不要写成小作文。用户的注意力很短,冗长的回答会适得其反。 - **followUpQuestion**:一个自然的跟进问题,目的是推动对话向评估或预约方向发展。 # 重要提醒 diff --git a/packages/services/conversation-service/src/infrastructure/agents/prompts/policy-expert-prompt.ts b/packages/services/conversation-service/src/infrastructure/agents/prompts/policy-expert-prompt.ts index da01c8f..0622f32 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/prompts/policy-expert-prompt.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/prompts/policy-expert-prompt.ts @@ -124,41 +124,43 @@ export function buildPolicyExpertPrompt(): string { # 输出格式要求 -你的输出将由 Coordinator Agent 整合处理,因此需遵循以下格式规范: +**核心原则:只回答被问到的问题,不要输出完整模板。** -## 标准政策回答格式 +你的输出将由 Coordinator Agent 整合处理。Coordinator 最头疼的问题是收到太多无关信息,不得不全部转述给用户。你必须帮助 Coordinator 减轻负担。 -回答必须包含以下结构化元素(按需选择): +## 输出规则 -1. **政策要点概述**:用 2-3 句话概括核心要点 -2. **详细条件列表**:逐项列出具体条件,使用标准格式 -3. **申请流程**(如适用):分步骤说明 -4. **重要注意事项**:特别提醒、常见误区、近期变动 -5. **信息来源标注**:标注信息出自知识库的哪些条目 +1. **聚焦问题**:Coordinator 传来的 query 问什么,你就答什么。问条件就只答条件,问流程就只答流程,不要附带其他。 +2. **精简表达**:用最少的文字传递准确信息。能用3行说清楚的,不要用10行。 +3. **按需分段**:只包含与问题直接相关的段落,不要每次都输出完整的5段模板。 +4. **标注来源**:在末尾简要标注信息出处。 ## 格式范例 +**问题**:"高才通B类的学历要求" + +**正确输出**(聚焦问题): +\`\`\` +持有全球百强大学的学士/硕士/博士学位,且申请前5年内拥有至少3年全职工作经验。百强名单参考QS/THE/US News/ARWU四大排名综合认定,每年可能更新。 + +来源:知识库 - TTPS政策详解 +\`\`\` + +**错误输出**(信息过载): \`\`\` 【政策要点】 -高端人才通行证计划(TTPS)B类面向全球百强大学的毕业生,需具备一定工作经验。 +高端人才通行证计划分为A/B/C三类...(用户没问其他类别) 【申请条件】 -- 持有全球百强大学的学士/硕士/博士学位 -- 申请前5年内拥有至少3年全职工作经验 -- 百强大学名单参考四大排名(QS/THE/US News/ARWU)综合认定 -- 不设行业限制 +- B类条件... +- A类条件...(用户没问A类) +- C类条件...(用户没问C类) 【申请流程】 -1. 准备学历证明、工作经验证明等材料 -2. 通过入境处在线系统提交申请 -3. 审批周期约4周 -4. 获批后6个月内激活签证 +1. 准备材料...(用户没问流程) 【注意事项】 -- 百强大学名单每年更新,以申请时的有效名单为准 -- 远程学习或非全日制学位可能不被认可 - -【来源】知识库 - TTPS政策详解 / 百强大学名单 +... \`\`\` --- diff --git a/packages/services/conversation-service/src/infrastructure/agents/schemas/coordinator-response.schema.ts b/packages/services/conversation-service/src/infrastructure/agents/schemas/coordinator-response.schema.ts new file mode 100644 index 0000000..bb7887a --- /dev/null +++ b/packages/services/conversation-service/src/infrastructure/agents/schemas/coordinator-response.schema.ts @@ -0,0 +1,67 @@ +/** + * Coordinator Response Schema — Structured Output + * + * 强制 Coordinator 输出结构化 JSON,包含意图分类和简洁回答。 + * 通过 Anthropic API 的 output_config 实现硬约束。 + */ + +import { z } from 'zod'; + +export const CoordinatorResponseSchema = z.object({ + intent: z.enum([ + 'factual_question', // 直接事实问题:"X的条件是什么" + 'yes_no_question', // 是非判断问题:"我能不能申请X" + 'comparison_question', // 对比选择问题:"A和B哪个好" + 'assessment_request', // 评估请求:"帮我评估一下" + 'objection_expression', // 情绪/犹豫表达:"太贵了"/"怕被拒" + 'detailed_consultation', // 复杂政策咨询:明确要求详细了解 + 'casual_chat', // 闲聊/打招呼:"你好" + ]), + answer: z.string().describe('直接回答用户的文本,简洁精准,默认100字以内'), + followUp: z.string().optional().describe('跟进引导问题(可选)'), +}); + +export type CoordinatorResponse = z.infer; + +// ============================================================ +// Intent-based Answer Length Limits (中文字符数) +// ============================================================ + +/** 每种意图对应的最大 answer 长度(字符数)— 程序级硬约束 */ +export const INTENT_MAX_ANSWER_LENGTH: Record = { + factual_question: 200, // 1-3句,直接给答案 + yes_no_question: 120, // 1-2句,结论+理由 + comparison_question: 250, // 2-3句,推荐+理由 + assessment_request: 400, // 按需但有上限 + objection_expression: 200, // 共情+事实+引导 + detailed_consultation: 500, // 复杂咨询允许较长 + casual_chat: 80, // 1句 +}; + +/** followUp 问题最大长度 */ +export const MAX_FOLLOWUP_LENGTH = 80; + +/** + * 智能截断:在句子边界处截断,避免截断在句子中间 + */ +export function smartTruncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + + const truncated = text.substring(0, maxLen); + + // 在截断范围内找最后一个句子结束符 + const sentenceEnders = ['。', '!', '?', ';', '. ', '! ', '? ']; + let lastEnd = -1; + for (const ender of sentenceEnders) { + const idx = truncated.lastIndexOf(ender); + if (idx > lastEnd) lastEnd = idx; + } + + // 如果在后半段找到句子边界,在那里截断 + if (lastEnd > maxLen * 0.5) { + return text.substring(0, lastEnd + 1); + } + + // 没有好的边界,硬截断 + return truncated + '...'; +} diff --git a/packages/services/conversation-service/src/infrastructure/agents/types/agent.types.ts b/packages/services/conversation-service/src/infrastructure/agents/types/agent.types.ts index f83b782..8705287 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/types/agent.types.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/types/agent.types.ts @@ -280,6 +280,8 @@ export interface AgentLoopParams { turnCount: number, agentsUsed: string[], ) => Promise; + /** Structured Output — 传入 Claude API 的 output_config */ + outputConfig?: { format: Record }; } /** Claude API 消息格式 */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 624cbb4..65b1684 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,8 +100,8 @@ importers: packages/services/conversation-service: dependencies: '@anthropic-ai/sdk': - specifier: ^0.52.0 - version: 0.52.0 + specifier: ^0.73.0 + version: 0.73.0(zod@4.3.6) '@iconsulting/shared': specifier: workspace:* version: link:../../shared @@ -162,6 +162,9 @@ importers: uuid: specifier: ^9.0.0 version: 9.0.1 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@nestjs/cli': specifier: ^10.0.0 @@ -863,6 +866,19 @@ packages: hasBin: true dev: false + /@anthropic-ai/sdk@0.73.0(zod@4.3.6): + resolution: {integrity: sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + dependencies: + json-schema-to-ts: 3.1.1 + zod: 4.3.6 + dev: false + /@babel/code-frame@7.27.1: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -2293,7 +2309,6 @@ packages: uid: 2.0.2 transitivePeerDependencies: - encoding - dev: false /@nestjs/core@10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2): resolution: {integrity: sha512-MhiSGplB4TkadceA7opn/NaZmJhwYYNdB8nS8I29nLNx3vU+8aGHBiueZgcphEVDETZJSfc2VA5Mn/FC3JcsrA==} @@ -2325,6 +2340,7 @@ packages: uid: 2.0.2 transitivePeerDependencies: - encoding + dev: false /@nestjs/jwt@10.2.0(@nestjs/common@10.4.21): resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==} @@ -2343,7 +2359,7 @@ packages: '@nestjs/core': ^10.0.0 dependencies: '@nestjs/common': 10.4.21(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) body-parser: 1.20.3 cors: 2.8.5 express: 4.22.1 @@ -2368,7 +2384,6 @@ packages: - bufferutil - supports-color - utf-8-validate - dev: false /@nestjs/schedule@4.1.2(@nestjs/common@10.4.21)(@nestjs/core@10.4.21): resolution: {integrity: sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==} @@ -2426,7 +2441,7 @@ packages: optional: true dependencies: '@nestjs/common': 10.4.21(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 10.4.21(@nestjs/common@10.4.21)(@nestjs/core@10.4.21) tslib: 2.8.1 dev: true @@ -2441,7 +2456,7 @@ packages: typeorm: ^0.3.0 dependencies: '@nestjs/common': 10.4.21(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 typeorm: 0.3.28(ioredis@5.9.1)(pg@8.16.3)(ts-node@10.9.2) @@ -2468,7 +2483,6 @@ packages: reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 - dev: false /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -5231,7 +5245,6 @@ packages: optional: true dependencies: ms: 2.1.3 - dev: false /debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -7321,6 +7334,14 @@ packages: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true + /json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + dependencies: + '@babel/runtime': 7.28.4 + ts-algebra: 2.0.0 + dev: false + /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true @@ -10088,7 +10109,6 @@ packages: - bufferutil - supports-color - utf-8-validate - dev: false /socket.io@4.8.3: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} @@ -10591,6 +10611,10 @@ packages: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} dev: false + /ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + dev: false + /ts-api-utils@1.4.3(typescript@5.9.3): resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'}