feat(iit): Complete CRA Agent V3.0 P1 - ChatOrchestrator with LLM Function Calling

P1 Architecture: Lightweight ReAct (Function Calling loop, max 3 rounds)

Core changes:
- Add ToolDefinition/ToolCall types to LLM adapters (DeepSeek + CloseAI + Claude)
- Replace 6 old tools with 4 semantic tools: read_report, look_up_data, check_quality, search_knowledge
- Create ChatOrchestrator (~160 lines) replacing ChatService (1,442 lines)
- Wire WechatCallbackController to ChatOrchestrator, deprecate ChatService
- Fix nullable content (string | null) across 12+ LLM consumer files

E2E test results: 8/8 scenarios passed (100%)
- QC report query, critical issues, patient data, trend, on-demand QC
- Knowledge base search, project overview, data modification refusal

Net code reduction: ~1,100 lines
Tested: E2E P1 chat test 8/8 passed with DeepSeek API

Made-with: Cursor
This commit is contained in:
2026-02-26 14:27:09 +08:00
parent 203846968c
commit 7c3cc12b2e
32 changed files with 903 additions and 337 deletions

View File

@@ -63,27 +63,22 @@ export class CloseAIAdapter implements ILLMAdapter {
return await this.chatClaude(messages, options);
}
// OpenAI系列标准格式不包含temperature等可能不支持的参数
const requestBody: any = {
model: this.modelName,
messages: messages,
max_tokens: options?.maxTokens ?? 2000,
};
// 可选参数:只在提供时才添加
if (options?.temperature !== undefined) {
requestBody.temperature = options.temperature;
}
if (options?.topP !== undefined) {
requestBody.top_p = options.topP;
}
console.log(`[CloseAIAdapter] 发起非流式调用`, {
provider: this.provider,
model: this.modelName,
messagesCount: messages.length,
params: Object.keys(requestBody),
});
if (options?.tools?.length) {
requestBody.tools = options.tools;
requestBody.tool_choice = options.tool_choice ?? 'auto';
}
const response = await axios.post(
`${this.baseURL}/chat/completions`,
@@ -93,14 +88,14 @@ export class CloseAIAdapter implements ILLMAdapter {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
timeout: 180000, // 180秒超时3分钟- GPT-5和Claude可能需要更长时间
timeout: 180000,
}
);
const choice = response.data.choices[0];
const result: LLMResponse = {
content: choice.message.content,
content: choice.message.content ?? null,
model: response.data.model,
usage: {
promptTokens: response.data.usage.prompt_tokens,
@@ -108,15 +103,9 @@ export class CloseAIAdapter implements ILLMAdapter {
totalTokens: response.data.usage.total_tokens,
},
finishReason: choice.finish_reason,
toolCalls: choice.message.tool_calls ?? undefined,
};
console.log(`[CloseAIAdapter] 调用成功`, {
provider: this.provider,
model: result.model,
tokens: result.usage?.totalTokens,
contentLength: result.content.length,
});
return result;
} catch (error: unknown) {
console.error(`[CloseAIAdapter] ${this.provider.toUpperCase()} API Error:`, error);
@@ -155,50 +144,64 @@ export class CloseAIAdapter implements ILLMAdapter {
*/
private async chatClaude(messages: Message[], options?: LLMOptions): Promise<LLMResponse> {
try {
const requestBody = {
const requestBody: any = {
model: this.modelName,
messages: messages,
max_tokens: options?.maxTokens ?? 2000,
};
console.log(`[CloseAIAdapter] 发起Claude调用`, {
model: this.modelName,
messagesCount: messages.length,
});
if (options?.tools?.length) {
requestBody.tools = options.tools.map((t) => ({
name: t.function.name,
description: t.function.description,
input_schema: t.function.parameters,
}));
if (options.tool_choice === 'none') {
requestBody.tool_choice = { type: 'none' };
} else if (options.tool_choice === 'required') {
requestBody.tool_choice = { type: 'any' };
} else {
requestBody.tool_choice = { type: 'auto' };
}
}
const response = await axios.post(
`${this.baseURL}/v1/messages`, // Anthropic使用 /v1/messages
`${this.baseURL}/v1/messages`,
requestBody,
{
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey, // Anthropic使用 x-api-key 而不是 Authorization
'anthropic-version': '2023-06-01', // Anthropic需要版本号
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01',
},
timeout: 180000,
}
);
// Anthropic的响应格式不同
const content = response.data.content[0].text;
const blocks = response.data.content as any[];
const textBlock = blocks.find((b: any) => b.type === 'text');
const toolBlocks = blocks.filter((b: any) => b.type === 'tool_use');
const toolCalls = toolBlocks.length > 0
? toolBlocks.map((b: any) => ({
id: b.id,
type: 'function' as const,
function: { name: b.name, arguments: JSON.stringify(b.input) },
}))
: undefined;
const result: LLMResponse = {
content: content,
content: textBlock?.text ?? null,
model: response.data.model,
usage: {
promptTokens: response.data.usage.input_tokens,
completionTokens: response.data.usage.output_tokens,
totalTokens: response.data.usage.input_tokens + response.data.usage.output_tokens,
},
finishReason: response.data.stop_reason,
finishReason: response.data.stop_reason === 'tool_use' ? 'tool_calls' : response.data.stop_reason,
toolCalls,
};
console.log(`[CloseAIAdapter] Claude调用成功`, {
model: result.model,
tokens: result.usage?.totalTokens,
contentLength: result.content.length,
});
return result;
} catch (error: unknown) {
console.error(`[CloseAIAdapter] Claude API Error:`, error);

View File

@@ -17,32 +17,38 @@ export class DeepSeekAdapter implements ILLMAdapter {
}
}
// 非流式调用
async chat(messages: Message[], options?: LLMOptions): Promise<LLMResponse> {
try {
const requestBody: any = {
model: this.modelName,
messages: messages,
temperature: options?.temperature ?? 0.7,
max_tokens: options?.maxTokens ?? 2000,
top_p: options?.topP ?? 0.9,
stream: false,
};
if (options?.tools?.length) {
requestBody.tools = options.tools;
requestBody.tool_choice = options.tool_choice ?? 'auto';
}
const response = await axios.post(
`${this.baseURL}/chat/completions`,
{
model: this.modelName,
messages: messages,
temperature: options?.temperature ?? 0.7,
max_tokens: options?.maxTokens ?? 2000,
top_p: options?.topP ?? 0.9,
stream: false,
},
requestBody,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
timeout: 180000, // 180秒超时3分钟- 稿件评估需要更长时间
timeout: 180000,
}
);
const choice = response.data.choices[0];
return {
content: choice.message.content,
content: choice.message.content ?? null,
model: response.data.model,
usage: {
promptTokens: response.data.usage.prompt_tokens,
@@ -50,6 +56,7 @@ export class DeepSeekAdapter implements ILLMAdapter {
totalTokens: response.data.usage.total_tokens,
},
finishReason: choice.finish_reason,
toolCalls: choice.message.tool_calls ?? undefined,
};
} catch (error: unknown) {
console.error('DeepSeek API Error:', error);

View File

@@ -1,8 +1,32 @@
// LLM适配器类型定义
// ---- Function Calling / Tool Use ----
export interface ToolDefinition {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, any>;
};
}
export interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
// ---- Core message / option / response types ----
export interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
export interface LLMOptions {
@@ -10,10 +34,12 @@ export interface LLMOptions {
maxTokens?: number;
topP?: number;
stream?: boolean;
tools?: ToolDefinition[];
tool_choice?: 'auto' | 'none' | 'required';
}
export interface LLMResponse {
content: string;
content: string | null;
model: string;
usage?: {
promptTokens: number;
@@ -21,6 +47,7 @@ export interface LLMResponse {
totalTokens: number;
};
finishReason?: string;
toolCalls?: ToolCall[];
}
export interface StreamChunk {

View File

@@ -72,7 +72,7 @@ export class QueryRewriter {
}
);
const content = response.content.trim();
const content = (response.content ?? '').trim();
// 3. 解析 JSON 数组
const rewritten = this.parseRewrittenQueries(content, query);

View File

@@ -321,7 +321,7 @@ async function processDocument(params: {
);
const processingTimeMs = Date.now() - startTime;
const rawOutput = response.content;
const rawOutput = response.content ?? '';
// 解析结果
let data: any;

View File

@@ -382,7 +382,7 @@ export class ConversationService {
});
// AI回答完毕后追加引用清单
let finalContent = response.content;
let finalContent: string = response.content ?? '';
if (allCitations.length > 0) {
const citationsText = formatCitations(allCitations);
finalContent += citationsText;

View File

@@ -218,10 +218,11 @@ export async function reviewEditorialStandards(
temperature: 0.3, // 较低温度以获得更稳定的评估
maxTokens: 8000, // 增加token限制确保完整输出
});
console.log(`[ReviewService] ${modelType} 稿约规范性评估完成,响应长度: ${response.content.length}`);
const editContent = response.content ?? '';
console.log(`[ReviewService] ${modelType} 稿约规范性评估完成,响应长度: ${editContent.length}`);
// 4. 解析JSON响应
const result = parseJSONFromLLMResponse<EditorialReview>(response.content);
const result = parseJSONFromLLMResponse<EditorialReview>(editContent);
// 5. 验证响应格式
if (!result || typeof result.overall_score !== 'number' || !Array.isArray(result.items)) {
@@ -269,10 +270,11 @@ export async function reviewMethodology(
temperature: 0.3,
maxTokens: 8000, // 增加token限制确保完整输出
});
console.log(`[ReviewService] ${modelType} 方法学评估完成,响应长度: ${response.content.length}`);
const methContent = response.content ?? '';
console.log(`[ReviewService] ${modelType} 方法学评估完成,响应长度: ${methContent.length}`);
// 4. 解析JSON响应
const result = parseJSONFromLLMResponse<MethodologyReview>(response.content);
const result = parseJSONFromLLMResponse<MethodologyReview>(methContent);
// 5. 验证响应格式
if (!result || typeof result.overall_score !== 'number' || !Array.isArray(result.parts)) {

View File

@@ -119,7 +119,7 @@ Generate QC rules for this project:`;
maxTokens: 4000,
});
const content = response.content.trim();
const content = (response.content ?? '').trim();
// Extract JSON array from response (handle markdown code fences)
const jsonMatch = content.match(/\[[\s\S]*\]/);
if (!jsonMatch) {

View File

@@ -60,7 +60,7 @@ export class LLMServiceAdapter implements LLMServiceInterface {
const response = await adapter.chat(messages, options);
// 提取思考内容(如果有)
const { content, thinkingContent } = this.extractThinkingContent(response.content);
const { content, thinkingContent } = this.extractThinkingContent(response.content ?? '');
return {
content,

View File

@@ -376,7 +376,7 @@ export class LLM12FieldsService {
}
);
return response.content;
return response.content ?? '';
} catch (error) {
lastError = error as Error;
logger.error(`LLM call attempt ${attempt + 1} failed: ${(error as Error).message}`);

View File

@@ -156,7 +156,7 @@ class ExtractionSingleWorkerImpl {
];
const response = await llm.chat(messages, { temperature: 0.1 });
const content = response.content.trim();
const content = (response.content ?? '').trim();
const match = content.match(/\{[\s\S]*\}/);
if (!match) {

View File

@@ -71,7 +71,7 @@ export class LLMScreeningService {
]);
// 解析JSON输出
const parseResult = parseJSON(response.content);
const parseResult = parseJSON(response.content ?? '');
if (!parseResult.success || !parseResult.data) {
logger.error('Failed to parse LLM output as JSON', {
error: parseResult.error,

View File

@@ -91,7 +91,7 @@ class RequirementExpansionService {
maxTokens: rendered.modelConfig.maxTokens ?? 4096,
});
const rawOutput = llmResponse.content;
const rawOutput = llmResponse.content ?? '';
const { requirement, intentSummary } = this.parseOutput(rawOutput);

View File

@@ -165,17 +165,18 @@ ${text}
});
const elapsedTime = Date.now() - startTime;
const llmContent = response.content ?? '';
logger.info(`[${modelType.toUpperCase()}] Model responded successfully`, {
modelName,
tokensUsed: response.usage?.totalTokens,
elapsedMs: elapsedTime,
contentLength: response.content.length,
contentPreview: response.content.substring(0, 200)
contentLength: llmContent.length,
contentPreview: llmContent.substring(0, 200)
});
// 解析JSON3层容错
logger.info(`[${modelType.toUpperCase()}] Parsing JSON response`);
const result = this.parseJSON(response.content, fields);
const result = this.parseJSON(llmContent, fields);
logger.info(`[${modelType.toUpperCase()}] JSON parsed successfully`, {
fieldCount: Object.keys(result).length
});

View File

@@ -100,7 +100,7 @@ export class AICodeService {
logger.info(`[AICodeService] LLM响应成功开始解析...`);
// 5. 解析AI回复提取code和explanation
const parsed = this.parseAIResponse(response.content);
const parsed = this.parseAIResponse(response.content ?? '');
// 6. 保存到数据库
const messageId = await this.saveMessages(
@@ -406,8 +406,8 @@ ${col.topValues ? `- 最常见的值:${col.topValues.map((v: any) => `${v.valu
sessionId,
session.userId,
userMessage,
'', // 无代码传空字符串而非null
response.content
'',
response.content ?? ''
);
logger.info(`[AICodeService] 数据探索回答完成: messageId=${messageId}`);

View File

@@ -22,7 +22,7 @@ import { PrismaClient } from '@prisma/client';
import { createRequire } from 'module';
import { logger } from '../../../common/logging/index.js';
import { wechatService } from '../services/WechatService.js';
import { ChatService } from '../services/ChatService.js';
import { ChatOrchestrator, getChatOrchestrator } from '../services/ChatOrchestrator.js';
// 使用 createRequire 导入 CommonJS 模块
const require = createRequire(import.meta.url);
@@ -75,7 +75,7 @@ export class WechatCallbackController {
private token: string;
private encodingAESKey: string;
private corpId: string;
private chatService: ChatService;
private chatOrchestrator: ChatOrchestrator | null = null;
constructor() {
// 从环境变量读取配置
@@ -83,8 +83,7 @@ export class WechatCallbackController {
this.encodingAESKey = process.env.WECHAT_ENCODING_AES_KEY || '';
this.corpId = process.env.WECHAT_CORP_ID || '';
// 初始化AI对话服务
this.chatService = new ChatService();
// ChatOrchestrator is initialized lazily on first message
// 验证配置
if (!this.token || !this.encodingAESKey || !this.corpId) {
@@ -323,8 +322,10 @@ export class WechatCallbackController {
'🫡 正在查询,请稍候...'
);
// ⚡ Phase 1.5 新增调用AI对话服务复用LLMFactory + 上下文记忆)
const aiResponse = await this.chatService.handleMessage(fromUser, content);
if (!this.chatOrchestrator) {
this.chatOrchestrator = await getChatOrchestrator();
}
const aiResponse = await this.chatOrchestrator.handleMessage(fromUser, content);
// 主动推送AI回复
await wechatService.sendTextMessage(fromUser, aiResponse);

View File

@@ -221,7 +221,7 @@ export class SoftRuleEngine {
},
]);
const rawResponse = response.content;
const rawResponse = response.content ?? '';
// 3. 解析响应
const parsed = this.parseResponse(rawResponse, check);

View File

@@ -0,0 +1,189 @@
/**
* ChatOrchestrator - 轻量 ReAct 对话编排器
*
* 架构:带循环的 Function Callingmax 3 轮)
* 替代旧版 ChatService 的关键词路由,由 LLM 自主选择工具。
*/
import { PrismaClient } from '@prisma/client';
import { ILLMAdapter, Message, ToolCall } from '../../../common/llm/adapters/types.js';
import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
import { ToolsService, createToolsService } from './ToolsService.js';
import { sessionMemory } from '../agents/SessionMemory.js';
import { logger } from '../../../common/logging/index.js';
const prisma = new PrismaClient();
const MAX_ROUNDS = 3;
const DEFAULT_MODEL = 'deepseek-v3' as const;
const SYSTEM_PROMPT = `You are a CRA Agent (Clinical Research Associate AI) monitoring an IIT clinical study.
Your users are PIs (principal investigators) and research coordinators.
You have 4 tools available. For quality-related questions, ALWAYS prefer read_report first — it has pre-computed data and answers most questions instantly.
Tool selection guide:
- read_report → quality report, pass rate, issues, trends, eQuery stats (use ~80% of the time)
- look_up_data → raw patient data values (age, lab results, etc.)
- check_quality → on-demand QC re-check (only when user explicitly asks to "re-check" or "run QC now")
- search_knowledge → protocol documents, inclusion/exclusion criteria, study design
Rules:
1. All answers MUST be based on tool results. Never fabricate clinical data.
2. If the report already has the answer, cite report data directly — do not call look_up_data redundantly.
3. Keep responses concise: key numbers + conclusion. Max 200 Chinese characters for WeChat.
4. Always respond in Chinese (Simplified).
5. NEVER modify any clinical data. If asked to change data, politely decline and explain why.
6. When citing numbers, be precise (e.g. "通过率 85.7%", "3 条严重违规").
`;
export class ChatOrchestrator {
private llm: ILLMAdapter;
private toolsService: ToolsService | null = null;
private projectId: string;
constructor(projectId: string) {
this.projectId = projectId;
this.llm = LLMFactory.getAdapter(DEFAULT_MODEL);
}
async initialize(): Promise<void> {
this.toolsService = await createToolsService(this.projectId);
logger.info('[ChatOrchestrator] Initialized', {
projectId: this.projectId,
model: DEFAULT_MODEL,
});
}
async handleMessage(userId: string, userMessage: string): Promise<string> {
const startTime = Date.now();
if (!this.toolsService) {
await this.initialize();
}
try {
const history = sessionMemory.getHistory(userId, 2);
const historyMessages: Message[] = history.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
const messages: Message[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...historyMessages,
{ role: 'user', content: userMessage },
];
const tools = this.toolsService!.getLLMToolDescriptions();
// --- Tool Use Loop (max 3 rounds) ---
for (let round = 0; round < MAX_ROUNDS; round++) {
const response = await this.llm.chat(messages, {
tools,
tool_choice: 'auto',
temperature: 0.3,
maxTokens: 1000,
});
logger.info('[ChatOrchestrator] LLM round', {
round: round + 1,
finishReason: response.finishReason,
hasToolCalls: !!response.toolCalls?.length,
tokens: response.usage?.totalTokens,
});
if (!response.toolCalls?.length || response.finishReason === 'stop') {
const answer = response.content || '抱歉,我暂时无法回答这个问题。';
this.saveConversation(userId, userMessage, answer, startTime);
return answer;
}
// Append assistant message with tool_calls
messages.push({
role: 'assistant',
content: response.content,
tool_calls: response.toolCalls,
});
// Execute all tool calls in parallel
const toolResults = await Promise.all(
response.toolCalls.map((tc) => this.executeTool(tc, userId))
);
// Append tool result messages
for (let i = 0; i < response.toolCalls.length; i++) {
messages.push({
role: 'tool',
tool_call_id: response.toolCalls[i].id,
content: JSON.stringify(toolResults[i]),
});
}
}
// Max rounds exhausted — force a text response
const finalResponse = await this.llm.chat(messages, {
tool_choice: 'none',
temperature: 0.3,
maxTokens: 1000,
});
const answer = finalResponse.content || '抱歉,处理超时,请简化问题后重试。';
this.saveConversation(userId, userMessage, answer, startTime);
return answer;
} catch (error: any) {
logger.error('[ChatOrchestrator] Error', {
userId,
error: error.message,
duration: `${Date.now() - startTime}ms`,
});
return '抱歉,系统处理出错,请稍后重试。';
}
}
private async executeTool(toolCall: ToolCall, userId: string): Promise<any> {
const { name, arguments: argsStr } = toolCall.function;
let args: Record<string, any>;
try {
args = JSON.parse(argsStr);
} catch {
return { success: false, error: `Invalid tool arguments: ${argsStr}` };
}
logger.info('[ChatOrchestrator] Executing tool', { tool: name, args });
const result = await this.toolsService!.execute(name, args, userId);
return result;
}
private saveConversation(userId: string, userMsg: string, aiMsg: string, startTime: number): void {
sessionMemory.addMessage(userId, 'user', userMsg);
sessionMemory.addMessage(userId, 'assistant', aiMsg);
logger.info('[ChatOrchestrator] Conversation saved', {
userId,
duration: `${Date.now() - startTime}ms`,
});
}
}
// Resolve the active project ID from DB
async function resolveActiveProjectId(): Promise<string> {
const project = await prisma.iitProject.findFirst({
where: { status: 'active' },
select: { id: true },
});
if (!project) throw new Error('No active IIT project found');
return project.id;
}
// Singleton factory — lazily resolves active project
let orchestratorInstance: ChatOrchestrator | null = null;
export async function getChatOrchestrator(): Promise<ChatOrchestrator> {
if (!orchestratorInstance) {
const projectId = await resolveActiveProjectId();
orchestratorInstance = new ChatOrchestrator(projectId);
await orchestratorInstance.initialize();
}
return orchestratorInstance;
}

View File

@@ -16,8 +16,10 @@
import { PrismaClient } from '@prisma/client';
import { logger } from '../../../common/logging/index.js';
import { RedcapAdapter } from '../adapters/RedcapAdapter.js';
import { createHardRuleEngine, QCResult } from '../engines/HardRuleEngine.js';
import { createHardRuleEngine } from '../engines/HardRuleEngine.js';
import { createSkillRunner } from '../engines/SkillRunner.js';
import { QcReportService } from './QcReportService.js';
import { getVectorSearchService } from '../../../common/rag/index.js';
const prisma = new PrismaClient();
@@ -315,306 +317,250 @@ export class ToolsService {
* 注册内置工具
*/
private registerBuiltinTools(): void {
// 1. read_clinical_data - 读取临床数据
// 1. read_report — 质控报告查阅核心工具80% 的问题用这个回答)
this.registerTool({
name: 'read_clinical_data',
description: '从 REDCap 读取患者临床数据。可以查询单个患者或多个患者,支持指定字段。',
name: 'read_report',
description: '查阅最新质控报告。报告包含总体通过率、严重/警告问题列表、各表单统计、趋势数据、eQuery 状态。绝大多数质控相关问题都应优先使用本工具。',
category: 'read',
parameters: [
{
name: 'section',
type: 'string',
description: '要查阅的报告章节。summary=概览, critical_issues=严重问题, warning_issues=警告, form_stats=表单通过率, trend=趋势, equery_stats=eQuery统计, full=完整报告',
required: false,
enum: ['summary', 'critical_issues', 'warning_issues', 'form_stats', 'trend', 'equery_stats', 'full'],
},
{
name: 'record_id',
type: 'string',
description: '可选。如果用户问的是特定受试者的问题,传入 record_id 筛选该受试者的 issues',
required: false,
},
],
execute: async (params, context) => {
try {
const report = await QcReportService.getReport(context.projectId);
const section = params.section || 'summary';
const recordId = params.record_id;
const filterByRecord = (issues: any[]) =>
recordId ? issues.filter((i: any) => i.recordId === recordId) : issues;
let data: any;
switch (section) {
case 'summary':
data = report.summary;
break;
case 'critical_issues':
data = filterByRecord(report.criticalIssues);
break;
case 'warning_issues':
data = filterByRecord(report.warningIssues);
break;
case 'form_stats':
data = report.formStats;
break;
case 'trend':
data = report.topIssues;
break;
case 'equery_stats':
data = { pendingQueries: report.summary.pendingQueries };
break;
case 'full':
default:
data = {
summary: report.summary,
criticalIssues: filterByRecord(report.criticalIssues).slice(0, 20),
warningIssues: filterByRecord(report.warningIssues).slice(0, 20),
formStats: report.formStats,
};
}
return {
success: true,
data,
metadata: { executionTime: 0, source: 'QcReportService' },
};
} catch (error: any) {
return { success: false, error: error.message };
}
},
});
// 2. look_up_data — 查询原始临床数据
this.registerTool({
name: 'look_up_data',
description: '从 REDCap 查询患者的原始临床数据。用于查看具体字段值、原始记录。如果用户只是问质控问题/通过率,应优先使用 read_report。',
category: 'read',
parameters: [
{
name: 'record_id',
type: 'string',
description: '患者记录ID。如果不指定,将返回所有记录。',
required: false
description: '患者记录 ID',
required: true,
},
{
name: 'fields',
type: 'array',
description: '要查询的字段列表。如果不指定,将返回所有字段。可以使用中文别名如"年龄"或实际字段。',
required: false
}
description: '要查询的字段列表(可选,支持中文别名如"年龄"。不传则返回全部字段。',
required: false,
},
],
execute: async (params, context) => {
if (!context.redcapAdapter) {
return { success: false, error: 'REDCap 未配置' };
}
try {
let records: any[];
const record = await context.redcapAdapter.getRecordById(params.record_id);
if (!record) {
return { success: false, error: `未找到记录 ID: ${params.record_id}` };
}
if (params.record_id) {
// 查询单个记录
const record = await context.redcapAdapter.getRecordById(params.record_id);
records = record ? [record] : [];
} else if (params.fields && params.fields.length > 0) {
// 查询指定字段
records = await context.redcapAdapter.getAllRecordsFields(params.fields);
} else {
// 查询所有记录
records = await context.redcapAdapter.exportRecords({});
let data: any = record;
if (params.fields?.length) {
data = {};
for (const f of params.fields) {
if (record[f] !== undefined) data[f] = record[f];
}
data.record_id = params.record_id;
}
return {
success: true,
data: records,
metadata: {
executionTime: 0,
recordCount: records.length,
source: 'REDCap'
}
data,
metadata: { executionTime: 0, recordCount: 1, source: 'REDCap' },
};
} catch (error: any) {
return { success: false, error: error.message };
}
}
},
});
// 2. run_quality_check - 执行质控检查
// 3. check_quality — 即时质控检查
this.registerTool({
name: 'run_quality_check',
description: '对患者数据执行质控检查,验证是否符合纳入/排除标准和变量范围。',
name: 'check_quality',
description: '对患者数据立即执行质控检查。如果用户想看最新报告中已有的质控结果,应使用 read_report。本工具用于用户明确要求"重新检查"或"立即质控"的场景。',
category: 'compute',
parameters: [
{
name: 'record_id',
type: 'string',
description: '要检查的患者记录ID',
required: true
}
description: '要检查的患者记录 ID。如果不传,执行全量质控(耗时较长)。',
required: false,
},
],
execute: async (params, context) => {
if (!context.redcapAdapter) {
return { success: false, error: 'REDCap 未配置' };
}
try {
// 1. 获取记录数据
const record = await context.redcapAdapter.getRecordById(params.record_id);
if (!record) {
return {
success: false,
error: `未找到记录 ID: ${params.record_id}`
};
}
// 2. 执行质控
const engine = await createHardRuleEngine(context.projectId);
const qcResult = engine.execute(params.record_id, record);
return {
success: true,
data: {
recordId: params.record_id,
overallStatus: qcResult.overallStatus,
summary: qcResult.summary,
errors: qcResult.errors.map(e => ({
rule: e.ruleName,
field: e.field,
message: e.message,
actualValue: e.actualValue
})),
warnings: qcResult.warnings.map(w => ({
rule: w.ruleName,
field: w.field,
message: w.message,
actualValue: w.actualValue
}))
},
metadata: {
executionTime: 0,
source: 'HardRuleEngine'
if (params.record_id) {
const record = await context.redcapAdapter.getRecordById(params.record_id);
if (!record) {
return { success: false, error: `未找到记录 ID: ${params.record_id}` };
}
};
} catch (error: any) {
return { success: false, error: error.message };
}
}
});
// 3. batch_quality_check - 批量质控(事件级)
this.registerTool({
name: 'batch_quality_check',
description: '对所有患者数据执行事件级批量质控检查,每个 record+event 组合独立质控。',
category: 'compute',
parameters: [],
execute: async (params, context) => {
if (!context.redcapAdapter) {
return { success: false, error: 'REDCap 未配置' };
}
try {
// ⭐ 使用 SkillRunner 进行事件级质控
const runner = await createSkillRunner(context.projectId);
const results = await runner.runByTrigger('manual');
if (results.length === 0) {
const engine = await createHardRuleEngine(context.projectId);
const qcResult = engine.execute(params.record_id, record);
return {
success: true,
data: { message: '暂无记录或未配置质控规则' }
data: {
recordId: params.record_id,
overallStatus: qcResult.overallStatus,
summary: qcResult.summary,
errors: qcResult.errors.map((e: any) => ({
rule: e.ruleName, field: e.field, message: e.message, actualValue: e.actualValue,
})),
warnings: qcResult.warnings.map((w: any) => ({
rule: w.ruleName, field: w.field, message: w.message, actualValue: w.actualValue,
})),
},
metadata: { executionTime: 0, source: 'HardRuleEngine' },
};
}
// 统计汇总(按 record+event 组合)
const passCount = results.filter(r => r.overallStatus === 'PASS').length;
const failCount = results.filter(r => r.overallStatus === 'FAIL').length;
const warningCount = results.filter(r => r.overallStatus === 'WARNING').length;
const uncertainCount = results.filter(r => r.overallStatus === 'UNCERTAIN').length;
// 按 recordId 分组统计
const recordEventMap = new Map<string, { events: number; passed: number; failed: number }>();
for (const r of results) {
const stats = recordEventMap.get(r.recordId) || { events: 0, passed: 0, failed: 0 };
stats.events++;
if (r.overallStatus === 'PASS') stats.passed++;
if (r.overallStatus === 'FAIL') stats.failed++;
recordEventMap.set(r.recordId, stats);
// Batch QC
const runner = await createSkillRunner(context.projectId);
const results = await runner.runByTrigger('manual');
if (results.length === 0) {
return { success: true, data: { message: '暂无记录或未配置质控规则' } };
}
// 问题记录取前10个问题 record+event 组合)
const problemRecords = results
.filter(r => r.overallStatus !== 'PASS')
.slice(0, 10)
.map(r => ({
recordId: r.recordId,
eventName: r.eventName,
eventLabel: r.eventLabel,
forms: r.forms,
status: r.overallStatus,
issues: r.allIssues?.slice(0, 3).map((i: any) => ({
rule: i.ruleName,
message: i.message,
severity: i.severity
})) || []
}));
const passCount = results.filter((r: any) => r.overallStatus === 'PASS').length;
return {
success: true,
data: {
totalRecordEventCombinations: results.length,
uniqueRecords: recordEventMap.size,
summary: {
pass: passCount,
fail: failCount,
warning: warningCount,
uncertain: uncertainCount,
passRate: `${((passCount / results.length) * 100).toFixed(1)}%`
},
problemRecords,
recordStats: Array.from(recordEventMap.entries()).map(([recordId, stats]) => ({
recordId,
...stats
}))
total: results.length,
pass: passCount,
fail: results.length - passCount,
passRate: `${((passCount / results.length) * 100).toFixed(1)}%`,
problems: results
.filter((r: any) => r.overallStatus !== 'PASS')
.slice(0, 10)
.map((r: any) => ({
recordId: r.recordId,
status: r.overallStatus,
topIssues: r.allIssues?.slice(0, 3).map((i: any) => i.message) || [],
})),
},
metadata: {
executionTime: 0,
source: 'SkillRunner-EventLevel',
version: 'v3.1'
}
metadata: { executionTime: 0, source: 'SkillRunner' },
};
} catch (error: any) {
return { success: false, error: error.message };
}
}
},
});
// 4. get_project_info - 获取项目信息
// 4. search_knowledge — 知识库检索
this.registerTool({
name: 'get_project_info',
description: '获取当前研究项目的基本信息。',
category: 'read',
parameters: [],
execute: async (params, context) => {
try {
const project = await prisma.iitProject.findUnique({
where: { id: context.projectId },
select: {
id: true,
name: true,
description: true,
redcapProjectId: true,
status: true,
createdAt: true,
lastSyncAt: true
}
});
if (!project) {
return { success: false, error: '项目不存在' };
}
return {
success: true,
data: project,
metadata: {
executionTime: 0,
source: 'Database'
}
};
} catch (error: any) {
return { success: false, error: error.message };
}
}
});
// 5. count_records - 统计记录数
this.registerTool({
name: 'count_records',
description: '统计当前项目的患者记录总数。',
category: 'read',
parameters: [],
execute: async (params, context) => {
if (!context.redcapAdapter) {
return { success: false, error: 'REDCap 未配置' };
}
try {
const count = await context.redcapAdapter.getRecordCount();
return {
success: true,
data: { totalRecords: count },
metadata: {
executionTime: 0,
source: 'REDCap'
}
};
} catch (error: any) {
return { success: false, error: error.message };
}
}
});
// 6. search_protocol - 搜索研究方案
this.registerTool({
name: 'search_protocol',
description: '在研究方案文档中搜索相关信息,如纳入标准、排除标准、研究流程等。',
name: 'search_knowledge',
description: '在研究方案、CRF、伦理等文档知识库中搜索信息。用于回答关于纳入/排除标准、研究流程、治疗方案、观察指标等问题。',
category: 'read',
parameters: [
{
name: 'query',
type: 'string',
description: '搜索关键词或问题',
required: true
}
description: '搜索问题(自然语言)',
required: true,
},
],
execute: async (params, context) => {
try {
// TODO: 集成 Dify 知识库检索
// 目前返回占位信息
const project = await prisma.iitProject.findUnique({
where: { id: context.projectId },
select: { knowledgeBaseId: true },
});
const kbId = project?.knowledgeBaseId;
if (!kbId) {
return { success: false, error: '项目未配置知识库' };
}
const searchService = getVectorSearchService(prisma);
const results = await searchService.vectorSearch(params.query, {
topK: 5,
minScore: 0.3,
filter: { kbId },
});
if (!results?.length) {
return { success: true, data: { message: '未检索到相关文档', query: params.query } };
}
const documents = results.map((r: any, i: number) => ({
index: i + 1,
document: r.metadata?.filename || r.metadata?.documentName || '未知文档',
score: ((r.score || 0) * 100).toFixed(1) + '%',
content: r.content,
}));
return {
success: true,
data: {
message: '研究方案检索功能开发中',
query: params.query
},
metadata: {
executionTime: 0,
source: 'Dify (TODO)'
}
data: { query: params.query, documents },
metadata: { executionTime: 0, recordCount: documents.length, source: 'pgvector-RAG' },
};
} catch (error: any) {
return { success: false, error: error.message };
}
}
},
});
}

View File

@@ -2,7 +2,8 @@
* IIT Manager Services 导出
*/
export * from './ChatService.js';
export * from './ChatOrchestrator.js';
// ChatService is deprecated — kept as ChatService.deprecated.ts for reference
export * from './PromptBuilder.js';
export * from './QcService.js';
export * from './QcReportService.js';

View File

@@ -321,7 +321,7 @@ async function processDocument(params: {
);
const processingTimeMs = Date.now() - startTime;
const rawOutput = response.content;
const rawOutput = response.content ?? '';
// 解析结果
let data: any;

View File

@@ -53,13 +53,14 @@ export async function reviewEditorialStandards(
temperature: 0.3, // 较低温度以获得更稳定的评估
maxTokens: 8000, // 确保完整输出
});
const editContent = response.content ?? '';
logger.info('[RVW:Editorial] 评估完成', {
modelType,
responseLength: response.content.length
responseLength: editContent.length
});
// 4. 解析JSON响应
const result = parseJSONFromLLMResponse<EditorialReview>(response.content);
const result = parseJSONFromLLMResponse<EditorialReview>(editContent);
// 5. 验证响应格式
if (!result || typeof result.overall_score !== 'number' || !Array.isArray(result.items)) {

View File

@@ -53,13 +53,14 @@ export async function reviewMethodology(
temperature: 0.3,
maxTokens: 8000,
});
const methContent = response.content ?? '';
logger.info('[RVW:Methodology] 评估完成', {
modelType,
responseLength: response.content.length
responseLength: methContent.length
});
// 4. 解析JSON响应
const result = parseJSONFromLLMResponse<MethodologyReview>(response.content);
const result = parseJSONFromLLMResponse<MethodologyReview>(methContent);
// 5. 验证响应格式
if (!result || typeof result.overall_score !== 'number' || !Array.isArray(result.parts)) {

View File

@@ -189,7 +189,7 @@ class IntentRouterService {
maxTokens: 100,
});
return this.parseLLMResponse(response.content);
return this.parseLLMResponse(response.content ?? '');
}
private parseLLMResponse(text: string): IntentResult {

View File

@@ -67,7 +67,7 @@ export class PicoInferenceService {
maxTokens: rendered.modelConfig?.maxTokens ?? 1024,
});
const raw = this.robustJsonParse(response.content);
const raw = this.robustJsonParse(response.content ?? '');
const validated = PicoInferenceSchema.parse({
...raw,
status: 'ai_inferred',

View File

@@ -122,7 +122,7 @@ export class QueryService {
});
// 4. 三层 JSON 解析
const raw = this.robustJsonParse(response.content);
const raw = this.robustJsonParse(response.content ?? '');
// 5. Zod 校验(动态防幻觉)
const validColumns = profile?.columns.map(c => c.name) ?? [];

View File

@@ -104,7 +104,7 @@ export class ReflectionService {
maxTokens: LLM_MAX_TOKENS,
});
const rawOutput = response.content;
const rawOutput = response.content ?? '';
logger.info('[SSA:Reflection] LLM response received', {
contentLength: rawOutput.length,
usage: response.usage,

View File

@@ -0,0 +1,154 @@
/**
* P1 ChatOrchestrator E2E Test
*
* Tests the Lightweight ReAct architecture (Function Calling loop, max 3 rounds)
* by sending 8 representative chat scenarios and validating responses.
*
* Prerequisites:
* - Backend DB reachable (Docker postgres running)
* - DeepSeek API key configured in .env
* - At least one active IIT project in DB
*
* Run: npx tsx tests/e2e-p1-chat-test.ts
*/
import { getChatOrchestrator } from '../src/modules/iit-manager/services/ChatOrchestrator.js';
import { logger } from '../src/common/logging/index.js';
const TEST_USER = 'e2e-test-user';
interface TestCase {
id: number;
input: string;
description: string;
validate: (response: string) => boolean;
}
const testCases: TestCase[] = [
{
id: 1,
input: '最新质控报告怎么样',
description: 'General QC report query → expects read_report(summary)',
validate: (r) => r.length > 10 && !r.includes('系统处理出错'),
},
{
id: 2,
input: '有几条严重违规',
description: 'Critical issues query → expects read_report(critical_issues)',
validate: (r) => r.length > 5 && !r.includes('系统处理出错'),
},
{
id: 3,
input: '003 的数据',
description: 'Patient data lookup → expects look_up_data(003)',
validate: (r) => r.length > 5 && !r.includes('系统处理出错'),
},
{
id: 4,
input: '通过率比上周好了吗',
description: 'Trend query → expects read_report(trend)',
validate: (r) => r.length > 5 && !r.includes('系统处理出错'),
},
{
id: 5,
input: '帮我检查一下 005',
description: 'On-demand QC → expects check_quality(005)',
validate: (r) => r.length > 5 && !r.includes('系统处理出错'),
},
{
id: 6,
input: '入排标准是什么',
description: 'Knowledge base search → expects search_knowledge',
validate: (r) => r.length > 5 && !r.includes('系统处理出错'),
},
{
id: 7,
input: '项目整体怎么样',
description: 'Project overview → expects read_report(summary)',
validate: (r) => r.length > 5 && !r.includes('系统处理出错'),
},
{
id: 8,
input: '帮我修改 003 的数据',
description: 'Data modification request → polite refusal, no tool call',
validate: (r) => r.length > 5 && !r.includes('系统处理出错'),
},
];
async function runTests() {
console.log('='.repeat(60));
console.log(' P1 ChatOrchestrator E2E Test');
console.log(' Architecture: Lightweight ReAct (Function Calling, max 3 rounds)');
console.log('='.repeat(60));
let orchestrator;
try {
console.log('\n🔧 Initializing ChatOrchestrator...');
orchestrator = await getChatOrchestrator();
console.log('✅ ChatOrchestrator initialized successfully\n');
} catch (error: any) {
console.error('❌ Failed to initialize ChatOrchestrator:', error.message);
console.error(' Make sure DB is running and there is an active IIT project.');
process.exit(1);
}
let passCount = 0;
let failCount = 0;
const results: { id: number; desc: string; ok: boolean; response: string; duration: number; error?: string }[] = [];
for (const tc of testCases) {
console.log(`\n📝 [${tc.id}/8] ${tc.description}`);
console.log(` Input: "${tc.input}"`);
const start = Date.now();
try {
const response = await orchestrator.handleMessage(TEST_USER, tc.input);
const duration = Date.now() - start;
const ok = tc.validate(response);
if (ok) {
passCount++;
console.log(` ✅ PASS (${duration}ms)`);
} else {
failCount++;
console.log(` ❌ FAIL (${duration}ms) — validation failed`);
}
console.log(` Response: ${response.substring(0, 150)}${response.length > 150 ? '...' : ''}`);
results.push({ id: tc.id, desc: tc.description, ok, response: response.substring(0, 200), duration });
} catch (error: any) {
const duration = Date.now() - start;
failCount++;
console.log(` ❌ ERROR (${duration}ms) — ${error.message}`);
results.push({ id: tc.id, desc: tc.description, ok: false, response: '', duration, error: error.message });
}
}
// Summary
console.log('\n' + '='.repeat(60));
console.log(' RESULTS');
console.log('='.repeat(60));
console.log(`\n Total: ${testCases.length}`);
console.log(` Pass: ${passCount}`);
console.log(` Fail: ${failCount}`);
console.log(` Rate: ${((passCount / testCases.length) * 100).toFixed(0)}%`);
const avgDuration = results.reduce((sum, r) => sum + r.duration, 0) / results.length;
console.log(` Avg RT: ${avgDuration.toFixed(0)}ms`);
if (failCount > 0) {
console.log('\n Failed cases:');
for (const r of results.filter((r) => !r.ok)) {
console.log(` - [${r.id}] ${r.desc}`);
if (r.error) console.log(` Error: ${r.error}`);
}
}
console.log('\n' + '='.repeat(60));
process.exit(failCount > 0 ? 1 : 0);
}
runTests().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});