feat(asl): Add DeepSearch smart literature retrieval MVP
Features: - Integrate unifuncs DeepSearch API (OpenAI compatible protocol) - SSE real-time streaming for AI thinking process display - Natural language input, auto-generate PubMed search strategy - Extract and display PubMed literature links - Database storage for task records (asl_research_tasks) Backend: - researchService.ts - Core business logic with SSE streaming - researchController.ts - SSE stream endpoint - researchWorker.ts - Async task worker (backup mode) - schema.prisma - AslResearchTask model Frontend: - ResearchSearch.tsx - Search page with unified content stream - ResearchSearch.css - Styling (unifuncs-inspired simple design) - ASLLayout.tsx - Enable menu item - api/index.ts - Add research API functions API Endpoints: - POST /api/v1/asl/research/stream - SSE streaming search - POST /api/v1/asl/research/tasks - Async task creation - GET /api/v1/asl/research/tasks/:taskId/status - Task status Documentation: - Development record for DeepSearch integration - Update ASL module status (v1.5) - Update system status (v3.7) Known limitations: - SSE mode, task interrupts when leaving page - Cost ~0.3 RMB per search (unifuncs API)
This commit is contained in:
BIN
backend/AIclinicalresearch/backend/backup_20260118_172651.sql
Normal file
BIN
backend/AIclinicalresearch/backend/backup_20260118_172651.sql
Normal file
Binary file not shown.
22
backend/package-lock.json
generated
22
backend/package-lock.json
generated
@@ -30,6 +30,7 @@
|
||||
"jsonrepair": "^3.13.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jspdf": "^3.0.3",
|
||||
"openai": "^6.16.0",
|
||||
"p-queue": "^9.0.1",
|
||||
"pg-boss": "^12.5.2",
|
||||
"prisma": "^6.17.0",
|
||||
@@ -3790,6 +3791,27 @@
|
||||
"fn.name": "1.x.x"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/openai/-/openai-6.16.0.tgz",
|
||||
"integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/p-queue": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/p-queue/-/p-queue-9.0.1.tgz",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"jsonrepair": "^3.13.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jspdf": "^3.0.3",
|
||||
"openai": "^6.16.0",
|
||||
"p-queue": "^9.0.1",
|
||||
"pg-boss": "^12.5.2",
|
||||
"prisma": "^6.17.0",
|
||||
|
||||
@@ -476,6 +476,50 @@ model AslFulltextScreeningTask {
|
||||
@@schema("asl_schema")
|
||||
}
|
||||
|
||||
/// 智能文献检索任务(DeepSearch)
|
||||
model AslResearchTask {
|
||||
id String @id @default(uuid())
|
||||
|
||||
// 关联
|
||||
projectId String @map("project_id")
|
||||
userId String @map("user_id")
|
||||
|
||||
// 检索输入
|
||||
query String // 用户的自然语言查询
|
||||
filters Json? // 🔜 后续:高级筛选 { yearFrom, yearTo, articleTypes }
|
||||
|
||||
// unifuncs 任务
|
||||
externalTaskId String? @map("external_task_id")
|
||||
|
||||
// 状态
|
||||
status String @default("pending") // pending/processing/completed/failed
|
||||
errorMessage String? @map("error_message")
|
||||
|
||||
// 结果
|
||||
resultCount Int? @map("result_count")
|
||||
rawResult String? @map("raw_result") @db.Text
|
||||
reasoningContent String? @map("reasoning_content") @db.Text // AI思考过程
|
||||
literatures Json? // 解析后的文献列表
|
||||
|
||||
// 统计(🔜 后续展示)
|
||||
tokenUsage Json? @map("token_usage")
|
||||
searchCount Int? @map("search_count")
|
||||
readCount Int? @map("read_count")
|
||||
iterations Int?
|
||||
|
||||
// 时间
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
@@index([projectId], map: "idx_research_tasks_project_id")
|
||||
@@index([userId], map: "idx_research_tasks_user_id")
|
||||
@@index([status], map: "idx_research_tasks_status")
|
||||
@@index([createdAt], map: "idx_research_tasks_created_at")
|
||||
@@map("research_tasks")
|
||||
@@schema("asl_schema")
|
||||
}
|
||||
|
||||
model AslFulltextScreeningResult {
|
||||
id String @id @default(uuid())
|
||||
taskId String @map("task_id")
|
||||
|
||||
120
backend/scripts/test-unifuncs-deepsearch.ts
Normal file
120
backend/scripts/test-unifuncs-deepsearch.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* unifuncs DeepSearch API 快速验证脚本
|
||||
*
|
||||
* 运行方式:
|
||||
* cd backend
|
||||
* npx tsx scripts/test-unifuncs-deepsearch.ts
|
||||
*/
|
||||
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// ========== 配置 ==========
|
||||
const UNIFUNCS_API_KEY = 'sk-2fNwqUH73elGq0aDKJEM4ReqP7Ry0iqHo4OXyidDe2WpQ9XQ';
|
||||
const UNIFUNCS_BASE_URL = 'https://api.unifuncs.com/deepsearch/v1';
|
||||
|
||||
// ========== 测试用例 ==========
|
||||
const TEST_QUERIES = [
|
||||
// 简单测试
|
||||
'糖尿病 SGLT2抑制剂 心血管 RCT',
|
||||
|
||||
// 复杂临床问题
|
||||
// '乳腺癌免疫治疗最新系统综述,近3年的研究进展',
|
||||
];
|
||||
|
||||
// ========== 主函数 ==========
|
||||
async function testDeepSearch() {
|
||||
console.log('🚀 unifuncs DeepSearch API 验证测试\n');
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
const client = new OpenAI({
|
||||
baseURL: UNIFUNCS_BASE_URL,
|
||||
apiKey: UNIFUNCS_API_KEY,
|
||||
});
|
||||
|
||||
for (const query of TEST_QUERIES) {
|
||||
console.log(`\n📝 测试查询: "${query}"\n`);
|
||||
console.log('-'.repeat(60));
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 方式1: 流式响应(推荐用于验证)
|
||||
const stream = await client.chat.completions.create({
|
||||
model: 's2',
|
||||
messages: [{ role: 'user', content: query }],
|
||||
stream: true,
|
||||
// @ts-ignore - unifuncs 扩展参数
|
||||
introduction: '你是一名专业的临床研究文献检索专家,请在 PubMed 中检索相关文献。输出每篇文献的 PMID、标题、作者、期刊、发表年份、研究类型。',
|
||||
max_depth: 10, // 验证时用较小的深度,加快速度
|
||||
domain_scope: ['https://pubmed.ncbi.nlm.nih.gov/'],
|
||||
domain_blacklist: ['wanfang.com', 'cnki.net'],
|
||||
reference_style: 'link',
|
||||
} as any);
|
||||
|
||||
let thinking = false;
|
||||
let thinkingContent = '';
|
||||
let responseContent = '';
|
||||
|
||||
console.log('📡 流式响应中...\n');
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta;
|
||||
|
||||
// 处理思考过程 (reasoning_content)
|
||||
if ((delta as any)?.reasoning_content) {
|
||||
if (!thinking) {
|
||||
console.log('💭 [思考过程]');
|
||||
thinking = true;
|
||||
}
|
||||
const content = (delta as any).reasoning_content;
|
||||
thinkingContent += content;
|
||||
process.stdout.write(content);
|
||||
}
|
||||
// 处理正式回答 (content)
|
||||
else if (delta?.content) {
|
||||
if (thinking) {
|
||||
console.log('\n\n📄 [检索结果]');
|
||||
thinking = false;
|
||||
}
|
||||
responseContent += delta.content;
|
||||
process.stdout.write(delta.content);
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = ((endTime - startTime) / 1000).toFixed(2);
|
||||
|
||||
console.log('\n\n' + '='.repeat(60));
|
||||
console.log(`✅ 测试完成!耗时: ${duration} 秒`);
|
||||
console.log(`📊 思考过程长度: ${thinkingContent.length} 字符`);
|
||||
console.log(`📊 回答内容长度: ${responseContent.length} 字符`);
|
||||
|
||||
// 尝试提取 PMID
|
||||
const pmidMatches = responseContent.match(/PMID[:\s]*(\d+)/gi) || [];
|
||||
const pubmedLinks = responseContent.match(/pubmed\.ncbi\.nlm\.nih\.gov\/(\d+)/gi) || [];
|
||||
const totalPmids = new Set([
|
||||
...pmidMatches.map(m => m.replace(/PMID[:\s]*/i, '')),
|
||||
...pubmedLinks.map(m => m.replace(/pubmed\.ncbi\.nlm\.nih\.gov\//i, '')),
|
||||
]);
|
||||
|
||||
console.log(`📚 检索到的文献数量: ${totalPmids.size} 篇`);
|
||||
if (totalPmids.size > 0) {
|
||||
console.log(`📚 PMID 列表: ${[...totalPmids].slice(0, 10).join(', ')}${totalPmids.size > 10 ? '...' : ''}`);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ 测试失败:', error.message);
|
||||
if (error.response) {
|
||||
console.error('响应状态:', error.response.status);
|
||||
console.error('响应数据:', error.response.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('🏁 所有测试完成!');
|
||||
}
|
||||
|
||||
// ========== 运行 ==========
|
||||
testDeepSearch().catch(console.error);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { authRoutes, registerAuthPlugin } from './common/auth/index.js';
|
||||
import { promptRoutes } from './common/prompt/index.js';
|
||||
import { registerTestRoutes } from './test-platform-api.js';
|
||||
import { registerScreeningWorkers } from './modules/asl/services/screeningWorker.js';
|
||||
import { registerResearchWorker } from './modules/asl/workers/researchWorker.js';
|
||||
import { registerExtractionWorkers } from './modules/dc/tool-b/workers/extractionWorker.js';
|
||||
import { registerParseExcelWorker } from './modules/dc/tool-c/workers/parseExcelWorker.js';
|
||||
import { registerReviewWorker } from './modules/rvw/workers/reviewWorker.js';
|
||||
@@ -206,6 +207,10 @@ const start = async () => {
|
||||
registerScreeningWorkers();
|
||||
logger.info('✅ ASL screening workers registered');
|
||||
|
||||
// 注册ASL智能文献检索Worker
|
||||
registerResearchWorker();
|
||||
logger.info('✅ ASL research worker registered');
|
||||
|
||||
// 注册DC提取Workers
|
||||
registerExtractionWorkers();
|
||||
logger.info('✅ DC extraction workers registered');
|
||||
|
||||
137
backend/src/modules/asl/controllers/researchController.ts
Normal file
137
backend/src/modules/asl/controllers/researchController.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 智能文献检索 Controller
|
||||
*
|
||||
* SSE 流式 + PubMed 链接提取
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { researchService } from '../services/researchService.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
interface SearchBody {
|
||||
projectId: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/asl/research/stream
|
||||
* SSE 实时流式检索
|
||||
*/
|
||||
export async function streamSearch(
|
||||
request: FastifyRequest<{ Body: SearchBody }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { projectId, query } = request.body;
|
||||
const userId = request.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ success: false, error: '用户未认证' });
|
||||
}
|
||||
|
||||
if (!query?.trim()) {
|
||||
return reply.code(400).send({ success: false, error: '请输入检索问题' });
|
||||
}
|
||||
|
||||
logger.info('[ResearchController] Starting SSE stream', { userId, queryLength: query.length });
|
||||
|
||||
// 设置 SSE 响应头
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
|
||||
try {
|
||||
const task = await researchService.createTaskRecord(projectId, userId, query);
|
||||
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'task_created', taskId: task.id })}\n\n`);
|
||||
|
||||
await researchService.executeStreamSearch(
|
||||
task.id,
|
||||
query,
|
||||
// 思考过程(统一追加)
|
||||
(reasoning: string) => {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'reasoning', content: reasoning })}\n\n`);
|
||||
},
|
||||
// 结果内容(统一追加)
|
||||
(content: string) => {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'content', content })}\n\n`);
|
||||
},
|
||||
// 完成(返回链接列表)
|
||||
(result: { links: string[] }) => {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'completed', links: result.links })}\n\n`);
|
||||
reply.raw.end();
|
||||
},
|
||||
// 错误
|
||||
(error: string) => {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`);
|
||||
reply.raw.end();
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.error('[ResearchController] Stream search failed', { error: error.message });
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`);
|
||||
reply.raw.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/asl/research/tasks
|
||||
* 创建检索任务(异步模式)
|
||||
*/
|
||||
export async function createTask(
|
||||
request: FastifyRequest<{ Body: SearchBody }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId, query } = request.body;
|
||||
const userId = request.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ success: false, error: '用户未认证' });
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return reply.code(400).send({ success: false, error: '缺少 projectId' });
|
||||
}
|
||||
|
||||
if (!query?.trim()) {
|
||||
return reply.code(400).send({ success: false, error: '请输入检索问题' });
|
||||
}
|
||||
|
||||
const task = await researchService.createTask({ projectId, userId, query: query.trim() });
|
||||
return reply.send({ success: true, data: task });
|
||||
} catch (error: any) {
|
||||
logger.error('[ResearchController] Create task failed', { error: error.message });
|
||||
return reply.code(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/asl/research/tasks/:taskId/status
|
||||
* 获取任务状态
|
||||
*/
|
||||
export async function getTaskStatus(
|
||||
request: FastifyRequest<{ Params: { taskId: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { taskId } = request.params;
|
||||
|
||||
if (!taskId) {
|
||||
return reply.code(400).send({ success: false, error: '缺少 taskId' });
|
||||
}
|
||||
|
||||
const status = await researchService.getTaskStatus(taskId);
|
||||
|
||||
if (!status) {
|
||||
return reply.code(404).send({ success: false, error: '任务不存在' });
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: status });
|
||||
} catch (error: any) {
|
||||
logger.error('[ResearchController] Get task status failed', { error: error.message });
|
||||
return reply.code(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import * as projectController from '../controllers/projectController.js';
|
||||
import * as literatureController from '../controllers/literatureController.js';
|
||||
import * as screeningController from '../controllers/screeningController.js';
|
||||
import * as fulltextScreeningController from '../fulltext-screening/controllers/FulltextScreeningController.js';
|
||||
import * as researchController from '../controllers/researchController.js';
|
||||
import { authenticate, requireModule } from '../../../common/auth/auth.middleware.js';
|
||||
|
||||
export async function aslRoutes(fastify: FastifyInstance) {
|
||||
@@ -77,6 +78,17 @@ export async function aslRoutes(fastify: FastifyInstance) {
|
||||
|
||||
// 导出Excel
|
||||
fastify.get('/fulltext-screening/tasks/:taskId/export', { preHandler: [authenticate, requireModule('ASL')] }, fulltextScreeningController.exportExcel);
|
||||
|
||||
// ==================== 智能文献检索路由 (DeepSearch) ====================
|
||||
|
||||
// SSE 流式检索(推荐,实时显示思考过程)
|
||||
fastify.post('/research/stream', { preHandler: [authenticate, requireModule('ASL')] }, researchController.streamSearch);
|
||||
|
||||
// 创建检索任务(异步模式,备用)
|
||||
fastify.post('/research/tasks', { preHandler: [authenticate, requireModule('ASL')] }, researchController.createTask);
|
||||
|
||||
// 获取任务状态(轮询)
|
||||
fastify.get('/research/tasks/:taskId/status', { preHandler: [authenticate, requireModule('ASL')] }, researchController.getTaskStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
309
backend/src/modules/asl/services/researchService.ts
Normal file
309
backend/src/modules/asl/services/researchService.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 智能文献检索服务(DeepSearch)
|
||||
*
|
||||
* SSE 流式 + 提取 PubMed 链接
|
||||
*/
|
||||
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { jobQueue } from '../../../common/jobs/index.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const UNIFUNCS_API_KEY = process.env.UNIFUNCS_API_KEY;
|
||||
const UNIFUNCS_BASE_URL = 'https://api.unifuncs.com/deepsearch/v1';
|
||||
|
||||
class ResearchService {
|
||||
private client: OpenAI;
|
||||
|
||||
constructor() {
|
||||
this.client = new OpenAI({
|
||||
baseURL: UNIFUNCS_BASE_URL,
|
||||
apiKey: UNIFUNCS_API_KEY || '',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务记录
|
||||
*/
|
||||
async createTaskRecord(projectId: string, userId: string, query: string) {
|
||||
const task = await prisma.aslResearchTask.create({
|
||||
data: {
|
||||
projectId,
|
||||
userId,
|
||||
query,
|
||||
status: 'processing',
|
||||
},
|
||||
});
|
||||
logger.info('[ResearchService] Task record created', { taskId: task.id });
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 流式检索
|
||||
* 统一内容流 + 提取 PubMed 链接
|
||||
*/
|
||||
async executeStreamSearch(
|
||||
taskId: string,
|
||||
query: string,
|
||||
onReasoning: (content: string) => void,
|
||||
onContent: (content: string) => void,
|
||||
onComplete: (result: { links: string[] }) => void,
|
||||
onError: (error: string) => void
|
||||
) {
|
||||
if (!UNIFUNCS_API_KEY) {
|
||||
onError('UNIFUNCS_API_KEY 未配置');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const systemPrompt = this.buildSystemPrompt();
|
||||
let fullContent = '';
|
||||
|
||||
await prisma.aslResearchTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'searching' },
|
||||
});
|
||||
|
||||
const stream = await (this.client.chat.completions.create as any)({
|
||||
model: 's2',
|
||||
messages: [{ role: 'user', content: query }],
|
||||
stream: true,
|
||||
introduction: systemPrompt,
|
||||
max_depth: 15,
|
||||
domain_scope: ['https://pubmed.ncbi.nlm.nih.gov/'],
|
||||
domain_blacklist: ['wanfang.com', 'cnki.net'],
|
||||
reference_style: 'link',
|
||||
generate_summary: true,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta;
|
||||
if (!delta) continue;
|
||||
|
||||
// 思考过程
|
||||
const reasoning = (delta as any).reasoning_content;
|
||||
if (reasoning) {
|
||||
fullContent += reasoning;
|
||||
onReasoning(reasoning);
|
||||
}
|
||||
|
||||
// 结果内容
|
||||
if (delta.content) {
|
||||
fullContent += delta.content;
|
||||
onContent(delta.content);
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 PubMed 链接
|
||||
const links = this.extractPubMedLinks(fullContent);
|
||||
|
||||
// 更新数据库
|
||||
await prisma.aslResearchTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'completed',
|
||||
rawResult: fullContent,
|
||||
resultCount: links.length,
|
||||
literatures: links.map(link => ({ url: link })) as any,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('[ResearchService] Stream search completed', {
|
||||
taskId,
|
||||
linkCount: links.length
|
||||
});
|
||||
|
||||
onComplete({ links });
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[ResearchService] Stream search failed', { taskId, error: error.message });
|
||||
|
||||
await prisma.aslResearchTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
|
||||
onError(error.message || '检索失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 PubMed 链接
|
||||
*/
|
||||
private extractPubMedLinks(content: string): string[] {
|
||||
const linkSet = new Set<string>();
|
||||
|
||||
// 匹配 PubMed URL(各种格式)
|
||||
const patterns = [
|
||||
/https?:\/\/pubmed\.ncbi\.nlm\.nih\.gov\/(\d+)\/?/gi,
|
||||
/pubmed\.ncbi\.nlm\.nih\.gov\/(\d+)/gi,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
const pmid = match[1];
|
||||
linkSet.add(`https://pubmed.ncbi.nlm.nih.gov/${pmid}/`);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(linkSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建异步任务(备用)
|
||||
*/
|
||||
async createTask(params: { projectId: string; userId: string; query: string }) {
|
||||
const { projectId, userId, query } = params;
|
||||
|
||||
if (!UNIFUNCS_API_KEY) {
|
||||
throw new Error('UNIFUNCS_API_KEY 未配置');
|
||||
}
|
||||
|
||||
const task = await prisma.aslResearchTask.create({
|
||||
data: {
|
||||
projectId,
|
||||
userId,
|
||||
query,
|
||||
status: 'processing',
|
||||
},
|
||||
});
|
||||
|
||||
await jobQueue.push('asl_research_execute', {
|
||||
taskId: task.id,
|
||||
query,
|
||||
});
|
||||
|
||||
return { id: task.id, status: task.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步执行(Worker 调用)
|
||||
*/
|
||||
async executeSearch(taskId: string, query: string) {
|
||||
try {
|
||||
const systemPrompt = this.buildSystemPrompt();
|
||||
let fullContent = '';
|
||||
|
||||
await prisma.aslResearchTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'searching' },
|
||||
});
|
||||
|
||||
const stream = await (this.client.chat.completions.create as any)({
|
||||
model: 's2',
|
||||
messages: [{ role: 'user', content: query }],
|
||||
stream: true,
|
||||
introduction: systemPrompt,
|
||||
max_depth: 15,
|
||||
domain_scope: ['https://pubmed.ncbi.nlm.nih.gov/'],
|
||||
domain_blacklist: ['wanfang.com', 'cnki.net'],
|
||||
reference_style: 'link',
|
||||
generate_summary: true,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta;
|
||||
if (!delta) continue;
|
||||
const reasoning = (delta as any).reasoning_content;
|
||||
if (reasoning) fullContent += reasoning;
|
||||
if (delta.content) fullContent += delta.content;
|
||||
}
|
||||
|
||||
const links = this.extractPubMedLinks(fullContent);
|
||||
|
||||
await prisma.aslResearchTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'completed',
|
||||
rawResult: fullContent,
|
||||
resultCount: links.length,
|
||||
literatures: links.map(link => ({ url: link })) as any,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, linkCount: links.length };
|
||||
|
||||
} catch (error: any) {
|
||||
await prisma.aslResearchTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'failed', errorMessage: error.message },
|
||||
});
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务状态
|
||||
*/
|
||||
async getTaskStatus(taskId: string) {
|
||||
const task = await prisma.aslResearchTask.findUnique({
|
||||
where: { id: taskId },
|
||||
});
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
let frontendStatus: 'processing' | 'ready' | 'error';
|
||||
let progress = 0;
|
||||
|
||||
switch (task.status) {
|
||||
case 'processing':
|
||||
case 'searching':
|
||||
frontendStatus = 'processing';
|
||||
progress = task.status === 'searching' ? 50 : 10;
|
||||
break;
|
||||
case 'completed':
|
||||
frontendStatus = 'ready';
|
||||
progress = 100;
|
||||
break;
|
||||
case 'failed':
|
||||
frontendStatus = 'error';
|
||||
break;
|
||||
default:
|
||||
frontendStatus = 'processing';
|
||||
progress = 10;
|
||||
}
|
||||
|
||||
// 提取链接
|
||||
const links: string[] = [];
|
||||
if (task.literatures && Array.isArray(task.literatures)) {
|
||||
for (const item of task.literatures as any[]) {
|
||||
if (item.url) links.push(item.url);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
status: frontendStatus,
|
||||
progress,
|
||||
query: task.query,
|
||||
resultCount: task.resultCount,
|
||||
links,
|
||||
errorMessage: task.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建系统提示词
|
||||
*/
|
||||
private buildSystemPrompt(): string {
|
||||
return `你是一名专业的临床研究文献检索专家。请在 PubMed 中检索相关文献。
|
||||
|
||||
检索要求:
|
||||
1. 优先检索高质量研究:系统综述、Meta分析、RCT
|
||||
2. 关注 PICOS 要素
|
||||
3. 优先近5年的研究
|
||||
|
||||
输出要求:
|
||||
1. 返回每篇文献的 PubMed 链接
|
||||
2. 按研究类型分组
|
||||
3. 按相关性排序`;
|
||||
}
|
||||
}
|
||||
|
||||
export const researchService = new ResearchService();
|
||||
86
backend/src/modules/asl/workers/researchWorker.ts
Normal file
86
backend/src/modules/asl/workers/researchWorker.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 智能文献检索 Worker
|
||||
*
|
||||
* ✅ 使用 OpenAI 兼容协议(已验证成功)
|
||||
* ✅ 严格遵循 Postgres-Only 异步任务处理指南
|
||||
*/
|
||||
|
||||
import { jobQueue } from '../../../common/jobs/index.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { researchService } from '../services/researchService.js';
|
||||
import type { Job } from '../../../common/jobs/types.js';
|
||||
|
||||
/**
|
||||
* 检索任务数据结构
|
||||
*/
|
||||
interface ResearchExecuteJob {
|
||||
taskId: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册智能文献检索 Worker
|
||||
*/
|
||||
export function registerResearchWorker() {
|
||||
logger.info('[ResearchWorker] Registering worker');
|
||||
|
||||
// 注册执行任务的 Worker
|
||||
jobQueue.process<ResearchExecuteJob>('asl_research_execute', async (job: Job<ResearchExecuteJob>) => {
|
||||
const { taskId, query } = job.data;
|
||||
|
||||
logger.info('[ResearchWorker] Starting search', {
|
||||
jobId: job.id,
|
||||
taskId,
|
||||
queryLength: query.length,
|
||||
});
|
||||
|
||||
try {
|
||||
// 执行检索(使用 OpenAI 兼容协议 streaming)
|
||||
const result = await researchService.executeSearch(taskId, query);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('[ResearchWorker] ✅ Search completed', {
|
||||
taskId,
|
||||
resultCount: result.resultCount,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskId,
|
||||
resultCount: result.resultCount,
|
||||
};
|
||||
} else {
|
||||
logger.error('[ResearchWorker] ❌ Search failed', {
|
||||
taskId,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
taskId,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('[ResearchWorker] ❌ Unexpected error', {
|
||||
taskId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// 更新任务状态为失败
|
||||
try {
|
||||
await researchService.executeSearch(taskId, query);
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
taskId,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('[ResearchWorker] ✅ Worker registered: asl_research_execute');
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
# AIclinicalresearch 系统当前状态与开发指南
|
||||
|
||||
> **文档版本:** v3.6
|
||||
> **文档版本:** v3.7
|
||||
> **创建日期:** 2025-11-28
|
||||
> **维护者:** 开发团队
|
||||
> **最后更新:** 2026-01-18
|
||||
> **重大进展:** 🎉 **AIA V2.1 完成!Prompt管理系统集成!**
|
||||
> - 🆕 AIA 10个智能体 Prompt 迁移到数据库
|
||||
> - 🏆 支持管理端在线配置和调试提示词
|
||||
> - ✅ 灰度预览(调试者看DRAFT,普通用户看ACTIVE)
|
||||
> - ✅ 三级容灾(数据库→缓存→兜底)
|
||||
> **重大进展:** 🎉 **ASL 智能文献检索(DeepSearch)MVP 完成!**
|
||||
> - 🆕 集成 unifuncs DeepSearch API,AI 驱动的 PubMed 自动检索
|
||||
> - ✅ SSE 实时流式显示 AI 思考过程
|
||||
> - ✅ 自然语言输入,自动生成检索策略
|
||||
> **部署状态:** ✅ 生产环境运行中 | 公网地址:http://8.140.53.236/
|
||||
> **文档目的:** 快速了解系统当前状态,为新AI助手提供上下文
|
||||
|
||||
@@ -44,7 +43,7 @@
|
||||
|---------|---------|---------|---------|---------|--------|
|
||||
| **AIA** | AI智能问答 | 12个智能体(选题→方案→评审→写作) | ⭐⭐⭐⭐⭐ | 🎉 **V2.1完成(90%)** - Prompt管理集成 | **P0** |
|
||||
| **PKB** | 个人知识库 | RAG问答、私人文献库 | ⭐⭐⭐ | ✅ **核心功能完成(90%)** | P1 |
|
||||
| **ASL** | AI智能文献 | 文献筛选、Meta分析、证据图谱 | ⭐⭐⭐⭐⭐ | 🚧 **正在开发** | **P0** |
|
||||
| **ASL** | AI智能文献 | 文献筛选、Meta分析、证据图谱 | ⭐⭐⭐⭐⭐ | 🎉 **智能检索MVP完成(60%)** - DeepSearch集成 | **P0** |
|
||||
| **DC** | 数据清洗整理 | ETL + 医学NER(百万行级数据) | ⭐⭐⭐⭐⭐ | ✅ **Tool B完成 + Tool C 99%(异步架构+性能优化-99%+多指标转换+7大功能)** | **P0** |
|
||||
| **IIT** | IIT Manager Agent | AI驱动IIT研究助手 - 智能质控+REDCap集成 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 1.5完成(60%)- AI对话+REDCap数据集成** | **P0** |
|
||||
| **SSA** | 智能统计分析 | 队列/预测模型/RCT分析 | ⭐⭐⭐⭐⭐ | 📋 规划中 | P2 |
|
||||
@@ -157,6 +156,39 @@
|
||||
|
||||
---
|
||||
|
||||
### 🆕 ASL 智能文献检索 DeepSearch MVP(2026-01-18)
|
||||
|
||||
#### ✅ 功能完成
|
||||
|
||||
**核心功能:**
|
||||
- ✅ 集成 unifuncs DeepSearch API(OpenAI 兼容协议)
|
||||
- ✅ 自然语言输入研究问题,AI 自动生成 PubMed 检索策略
|
||||
- ✅ SSE 实时流式显示 AI 思考过程
|
||||
- ✅ 提取并展示 PubMed 文献链接
|
||||
- ✅ 数据库存储任务记录
|
||||
|
||||
**技术实现:**
|
||||
- 后端:`researchService.ts` + `researchController.ts`(SSE 流式接口)
|
||||
- 前端:`ResearchSearch.tsx`(统一内容流展示)
|
||||
- 数据库:`asl_schema.asl_research_tasks`
|
||||
|
||||
**API 端点:**
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/v1/asl/research/stream` | SSE 流式检索 |
|
||||
| POST | `/api/v1/asl/research/tasks` | 异步任务(备用) |
|
||||
| GET | `/api/v1/asl/research/tasks/:taskId/status` | 任务状态 |
|
||||
|
||||
**前端入口:**
|
||||
- 路由:`/literature/research/search`
|
||||
- 菜单:AI智能文献 → 2. 智能文献检索
|
||||
|
||||
**已知限制:**
|
||||
- ⚠️ SSE 模式,离开页面任务中断
|
||||
- ⚠️ 每次检索成本约 0.3 元(unifuncs API)
|
||||
|
||||
---
|
||||
|
||||
### 🏆 历史进展:通用能力层重大升级 + AIA V2.0(2026-01-14)
|
||||
|
||||
#### ✅ Phase 1: 通用流式响应服务(OpenAI Compatible)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# AI智能文献模块 - 当前状态与开发指南
|
||||
|
||||
> **文档版本:** v1.4
|
||||
> **文档版本:** v1.5
|
||||
> **创建日期:** 2025-11-21
|
||||
> **维护者:** AI智能文献开发团队
|
||||
> **最后更新:** 2025-12-13 🏆 **Postgres-Only 架构改造完成**
|
||||
> **重大进展:** Platform-Only 架构改造 - 智能双模式处理、任务拆分、断点续传
|
||||
> **最后更新:** 2026-01-18 🆕 **智能文献检索(DeepSearch)MVP完成**
|
||||
> **重大进展:** unifuncs DeepSearch API 集成 - AI驱动的 PubMed 自动检索
|
||||
> **文档目的:** 反映模块真实状态,帮助新开发人员快速上手
|
||||
|
||||
---
|
||||
@@ -27,15 +27,43 @@
|
||||
AI智能文献模块是一个基于大语言模型(LLM)的文献筛选系统,用于帮助研究人员根据PICOS标准自动筛选文献。
|
||||
|
||||
### 当前状态
|
||||
- **开发阶段**:🚧 标题摘要初筛MVP已完成,全文复筛后端已完成,待前端开发
|
||||
- **开发阶段**:🚧 标题摘要初筛MVP已完成,全文复筛后端已完成,智能文献检索MVP已完成
|
||||
- **已完成功能**:
|
||||
- ✅ 标题摘要初筛(Title & Abstract Screening)- 完整流程
|
||||
- ✅ 全文复筛后端(Day 2-5)- LLM服务 + API + Excel导出
|
||||
- ✅ **智能文献检索(DeepSearch)MVP** - unifuncs API 集成,SSE 实时流式
|
||||
- **开发中功能**:
|
||||
- 🚧 全文复筛前端UI(Day 6-8,预计2.5天)
|
||||
- **模型支持**:DeepSeek-V3 + Qwen-Max 双模型筛选
|
||||
- **模型支持**:DeepSeek-V3 + Qwen-Max 双模型筛选 + unifuncs DeepSearch
|
||||
- **部署状态**:✅ 本地开发环境运行正常
|
||||
|
||||
### 🆕 智能文献检索 DeepSearch(2026-01-18 MVP完成)
|
||||
|
||||
**功能概述:**
|
||||
- AI 驱动的自动化 PubMed 文献检索
|
||||
- 自然语言输入研究问题,AI 自动生成检索策略
|
||||
- 实时显示 AI 思考过程和检索进展
|
||||
- 提取并展示 PubMed 文献链接
|
||||
|
||||
**技术实现:**
|
||||
- 集成 unifuncs DeepSearch API(OpenAI 兼容协议)
|
||||
- Server-Sent Events (SSE) 实时流式通信
|
||||
- 数据库存储:`asl_schema.asl_research_tasks`
|
||||
|
||||
**API 端点:**
|
||||
- `POST /api/v1/asl/research/stream` - SSE 流式检索
|
||||
- `POST /api/v1/asl/research/tasks` - 异步任务创建(备用)
|
||||
- `GET /api/v1/asl/research/tasks/:taskId/status` - 任务状态查询
|
||||
|
||||
**前端入口:**
|
||||
- 路由:`/literature/research/search`
|
||||
- 菜单:AI智能文献 → 2. 智能文献检索
|
||||
|
||||
**已知限制:**
|
||||
- ⚠️ SSE 模式,离开页面任务中断
|
||||
- ⚠️ 每次检索成本约 0.3 元(unifuncs API)
|
||||
- ⏳ 搜索历史、高级筛选等功能待开发
|
||||
|
||||
### 🏆 Postgres-Only 架构改造(2025-12-13完成)
|
||||
|
||||
**改造目标:**
|
||||
|
||||
313
docs/03-业务模块/ASL-AI智能文献/00-系统设计/unifuncs API接入文档.md
Normal file
313
docs/03-业务模块/ASL-AI智能文献/00-系统设计/unifuncs API接入文档.md
Normal file
@@ -0,0 +1,313 @@
|
||||
unifuncsAPI接入文档
|
||||
|
||||
两种模式:深度研究报告和深度搜索。
|
||||
|
||||
一、 深度研究报告模式
|
||||
Model :选择请求模型。S2
|
||||
API Key:sk-2fNwqUH73elGq0aDKJEM4ReqP7Ry0iqHo4OXyidDe2WpQ9XQ
|
||||
Introduction:设定研究员的角色和口吻
|
||||
Plan Approval:执行研究前,是否生成一份研究计划,并等待用户批准或修改。默认关闭。
|
||||
Reference Style:指定引用文献的输出格式,默认为 link,MarkDown
|
||||
Max Depth:研究的最大深度,建议在25轮为最佳
|
||||
Domain Scope:自定义搜索网站,限定范围内研究(英文逗号分隔)
|
||||
Domain Blacklist:搜索网站黑名单,排除特定网站的内容(英文逗号分隔)。
|
||||
Output Type:预期输出的文体类型,默认为report
|
||||
Output Prompt:自定义输出提示词,覆盖output_type的默认提示词。支持嵌入 {{introduction}} / {{output_length}} / {{content}} 占位符,content为用户提问。
|
||||
Output Length:预期输出内容的长度(模型不一定遵守)
|
||||
Stream:是否流式响应
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
二、 深度搜索模式
|
||||
|
||||
[https://unifuncs.com/api\#deepsearch](https://unifuncs.com/api#deepsearch)
|
||||
|
||||
参数说明:
|
||||
|
||||
Model: S2
|
||||
|
||||
API Key: sk-2fNwqUH73elGq0aDKJEM4ReqP7Ry0iqHo4OXyidDe2WpQ9XQ
|
||||
|
||||
Introduction:设定研究员的角色和口吻
|
||||
|
||||
Reference Style: link 和 character
|
||||
|
||||
指定引用文献的输出格式,默认为 link
|
||||
|
||||
Max Depth:研究的最大深度,建议在25轮为最佳
|
||||
|
||||
Domain Scope:自定义搜索网站,限定范围内研究(英文逗号分隔)。
|
||||
|
||||
Domain Blacklist:搜索网站黑名单,排除特定网站的内容(英文逗号分隔)
|
||||
|
||||
Output Prompt:自定义输出提示词,覆盖output\_type的默认提示词。支持嵌入 {{introduction}} / {{output\_length}} / {{content}} 占位符,content为用户提问。
|
||||
|
||||
Generate Summary: 是否开启异步任务完成后自动生成标题和摘要,默认关闭 (只有异步模式才有)
|
||||
|
||||
Stream:是否流式响应(只有**Open AI 兼容协议模式才有**)
|
||||
|
||||
2种调用方式:
|
||||
|
||||
**一、Open AI兼容协议:**
|
||||
|
||||
适用于直接接入到支持 OpenAI 协议的客户端,如 Cherry Studio / Dify 或 OpenAI SDK等。
|
||||
|
||||
// Please install OpenAI SDK first: \`npm install openai\`
|
||||
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const client \= new OpenAI({
|
||||
|
||||
baseURL: 'https://api.unifuncs.com/deepsearch/v1',
|
||||
|
||||
apiKey: 'sk-2fNwqUH73elGq0aDKJEM4ReqP7Ry0iqHo4OXyidDe2WpQ9XQ',
|
||||
|
||||
});
|
||||
|
||||
async function main() {
|
||||
|
||||
const stream \= await client.chat.completions.create({
|
||||
|
||||
model: 's2',
|
||||
|
||||
messages: \[{ role: 'user', content: 'hi' }\],
|
||||
|
||||
stream: true,
|
||||
|
||||
introduction: "你是一名医学研究专家,负责检索文献",
|
||||
|
||||
max\_depth: 25,
|
||||
|
||||
domain\_scope: \["https://pubmed.ncbi.nlm.nih.gov/"\],
|
||||
|
||||
domain\_blacklist: \["wanfang.com"\],
|
||||
|
||||
output\_prompt: "输出文献内容",
|
||||
|
||||
reference\_style: "link"
|
||||
|
||||
});
|
||||
|
||||
let thinking \= false;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
|
||||
if (chunk.choices\[0\]?.delta?.reasoning\_content) {
|
||||
|
||||
if (\!thinking) {
|
||||
|
||||
process.stdout.write('\<think\>\\n');
|
||||
|
||||
thinking \= true;
|
||||
|
||||
}
|
||||
|
||||
process.stdout.write(chunk.choices\[0\]?.delta?.reasoning\_content);
|
||||
|
||||
} else {
|
||||
|
||||
if (thinking) {
|
||||
|
||||
process.stdout.write('\\n\</think\>\\n\\n');
|
||||
|
||||
thinking \= false;
|
||||
|
||||
}
|
||||
|
||||
process.stdout.write(chunk.choices\[0\]?.delta?.content || '');
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
**二、 异步模式:**
|
||||
|
||||
####
|
||||
|
||||
#### **1\. 创建任务 /v1/create\_task**
|
||||
|
||||
提交研究需求后立即返回 task\_id,后端在后台执行。
|
||||
|
||||
// Node 18+ has fetch built-in
|
||||
|
||||
const payload \= {
|
||||
|
||||
"model": "s2",
|
||||
|
||||
"messages": \[
|
||||
|
||||
{
|
||||
|
||||
"role": "user",
|
||||
|
||||
"content": "hi"
|
||||
|
||||
}
|
||||
|
||||
\],
|
||||
|
||||
"introduction": "你是一名医学研究专家,负责检索文献",
|
||||
|
||||
"max\_depth": 25,
|
||||
|
||||
"domain\_scope": \[
|
||||
|
||||
"https://pubmed.ncbi.nlm.nih.gov/"
|
||||
|
||||
\],
|
||||
|
||||
"domain\_blacklist": \[
|
||||
|
||||
"wanfang.com"
|
||||
|
||||
\],
|
||||
|
||||
"output\_prompt": "输出文献内容",
|
||||
|
||||
"reference\_style": "link"
|
||||
|
||||
};
|
||||
|
||||
const res \= await fetch("https://api.unifuncs.com/deepsearch/v1/create\_task", {
|
||||
|
||||
method: "POST",
|
||||
|
||||
headers: {
|
||||
|
||||
"Authorization": "Bearer sk-2fNwqUH73elGq0aDKJEM4ReqP7Ry0iqHo4OXyidDe2WpQ9XQ",
|
||||
|
||||
"Content-Type": "application/json"
|
||||
|
||||
},
|
||||
|
||||
body: JSON.stringify(payload)
|
||||
|
||||
});
|
||||
|
||||
console.log(await res.json());
|
||||
|
||||
示例响应:
|
||||
|
||||
{
|
||||
|
||||
"code": 0,
|
||||
|
||||
"message": "OK",
|
||||
|
||||
"data": {
|
||||
|
||||
"task\_id": "3aff2a91-7795-4b73-8dab-0593551a27a1",
|
||||
|
||||
"status": "pending",
|
||||
|
||||
"created\_at": "2025-12-09T03:52:40.771Z"
|
||||
|
||||
},
|
||||
|
||||
"requestId": "cd17faad-7310-4370-ba0c-0c2af6bc0597"
|
||||
|
||||
}
|
||||
|
||||
#### **2\. 查询任务 /v1/query\_task**
|
||||
|
||||
支持 GET / POST,传入 task\_id 即可轮询状态;完成后会返回摘要与最终回答。
|
||||
|
||||
// Node 18+ has fetch built-in
|
||||
|
||||
const params \= new URLSearchParams({ task\_id: "3aff2a91-7795-4b73-8dab-0593551a27a1" });
|
||||
|
||||
const res \= await fetch("https://api.unifuncs.com/deepsearch/v1/query\_task?" \+ params.toString(), {
|
||||
|
||||
headers: {
|
||||
|
||||
"Authorization": "Bearer sk-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
console.log(await res.json());
|
||||
|
||||
示例响应,已完成:
|
||||
|
||||
{
|
||||
|
||||
"code": 0,
|
||||
|
||||
"message": "OK",
|
||||
|
||||
"data": {
|
||||
|
||||
"task\_id": "3aff2a91-7795-4b73-8dab-0593551a27a1",
|
||||
|
||||
"status": "completed",
|
||||
|
||||
"result": {
|
||||
|
||||
"content": "你好!我是U深搜,一个专业的网络信息深度搜索专家 🤖\\n\\n我可以帮助你:\\n- 🔍 精准搜索和提取关键信息\\n- 📊 进行深度分析和多源验证\\n- 🕒 获取最新、最可靠的信息\\n- 💡 提供结构化的洞察和建议\\n\\n有什么问题想要我帮你深入搜索和分析吗?无论是技术资讯、市场动态、学术研究还是其他任何话题,我都能为你提供专业、准确的信息服务!",
|
||||
|
||||
"reasoning\_content": "用户只是简单地说了\\"hi\\",这是一个普通的问候。根据指导原则,如果是普通聊天或无法回答的问题,我应该使用友好的语气直接回复用户问题或介绍自己。\\n\\n这不需要进行任何搜索,我应该直接友好地回复,并简单介绍一下自己是U深搜这个深度搜索专家。"
|
||||
|
||||
},
|
||||
|
||||
"created\_at": "2025-12-09T03:52:40.771Z",
|
||||
|
||||
"updated\_at": "2025-12-09T03:52:45.912Z",
|
||||
|
||||
"progress": {
|
||||
|
||||
"current": 100,
|
||||
|
||||
"total": 100,
|
||||
|
||||
"message": "任务已完成"
|
||||
|
||||
},
|
||||
|
||||
"statistics": {
|
||||
|
||||
"iterations": 1,
|
||||
|
||||
"search\_count": 0,
|
||||
|
||||
"read\_count": 0,
|
||||
|
||||
"token\_usage": {
|
||||
|
||||
"prompt\_tokens": 4381,
|
||||
|
||||
"completion\_tokens": 167,
|
||||
|
||||
"total\_tokens": 4548
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
"session": {
|
||||
|
||||
"session\_id": "d3bee7f1-a44e-48b0-9283-a6866de723c3",
|
||||
|
||||
"status": "finished",
|
||||
|
||||
"model": "s2",
|
||||
|
||||
"title": "开启generate\_summary时生成标题",
|
||||
|
||||
"summary": "开启generate\_summary时生成摘要",
|
||||
|
||||
"question": "hi"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
242
docs/03-业务模块/ASL-AI智能文献/04-开发计划/06-智能文献检索DeepSearch集成方案.md
Normal file
242
docs/03-业务模块/ASL-AI智能文献/04-开发计划/06-智能文献检索DeepSearch集成方案.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 智能文献检索 DeepSearch 集成方案(MVP)
|
||||
|
||||
> **文档版本:** v1.2 MVP
|
||||
> **创建日期:** 2026-01-18
|
||||
> **维护者:** 开发团队
|
||||
> **模块位置:** ASL 模块 → 智能文献检索
|
||||
> **技术验证:** ✅ unifuncs DeepSearch API 已验证通过
|
||||
> **预计工期:** 3天
|
||||
|
||||
---
|
||||
|
||||
## 📋 MVP 范围
|
||||
|
||||
### 策略
|
||||
|
||||
| 层面 | 策略 | 理由 |
|
||||
|------|------|------|
|
||||
| **数据库** | 完整设计,所有字段都留 | 一次性到位,避免后续迁移 |
|
||||
| **功能开发** | 只做核心,其他先不做 | 快速验证,减少工作量 |
|
||||
|
||||
### ✅ 本次开发
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 搜索输入框 | 自然语言输入 |
|
||||
| 开始检索按钮 | 触发 unifuncs API |
|
||||
| **思考过程展示** | **重点功能** - 实时展示 AI 检索思路 |
|
||||
| 检索进度条 | 状态反馈 |
|
||||
| 结果列表 | PMID、标题、作者、期刊、年份 |
|
||||
| PubMed 链接 | 跳转原文 |
|
||||
|
||||
### 🔜 后续迭代
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 左侧检索历史 | 数据库已存,UI后做 |
|
||||
| 导入到初筛 | 后续开发 |
|
||||
| 高级筛选 | 年份、研究类型等 |
|
||||
| 导出功能 | Excel、BibTeX |
|
||||
| Token 统计展示 | 使用量统计 |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 前端:React + Ant Design + React Query │
|
||||
│ - ResearchSearch.tsx(主页面) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓ HTTP
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 后端:Fastify + Prisma │
|
||||
│ - researchService.ts │
|
||||
│ - researchWorker.ts │
|
||||
│ - researchController.ts │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓ pg-boss
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ unifuncs DeepSearch API(异步模式) │
|
||||
│ - POST /v1/create_task │
|
||||
│ - GET /v1/query_task │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 数据库设计(完整保留)
|
||||
|
||||
```prisma
|
||||
// prisma/schema.prisma
|
||||
|
||||
/// 智能文献检索任务
|
||||
model AslResearchTask {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// 关联
|
||||
projectId String @map("project_id")
|
||||
userId String @map("user_id")
|
||||
|
||||
// 检索输入
|
||||
query String // 用户的自然语言查询
|
||||
filters Json? // 🔜 后续:高级筛选
|
||||
|
||||
// unifuncs 任务
|
||||
externalTaskId String? @map("external_task_id")
|
||||
|
||||
// 状态
|
||||
status String @default("pending") // pending/processing/completed/failed
|
||||
errorMessage String? @map("error_message")
|
||||
|
||||
// 结果
|
||||
resultCount Int? @map("result_count")
|
||||
rawResult String? @map("raw_result") @db.Text
|
||||
reasoningContent String? @map("reasoning_content") @db.Text // 思考过程
|
||||
literatures Json? // 解析后的文献列表
|
||||
|
||||
// 统计(🔜 后续展示)
|
||||
tokenUsage Json? @map("token_usage")
|
||||
searchCount Int? @map("search_count")
|
||||
readCount Int? @map("read_count")
|
||||
iterations Int?
|
||||
|
||||
// 时间
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
@@map("asl_research_tasks")
|
||||
@@schema("asl_schema")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API 设计
|
||||
|
||||
### 本次开发(2个)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/v1/asl/research/tasks` | 创建检索任务 |
|
||||
| `GET` | `/api/v1/asl/research/tasks/:taskId/status` | 获取状态+思考过程+结果 |
|
||||
|
||||
### 后续开发
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/v1/asl/research/history` | 🔜 检索历史 |
|
||||
| `POST` | `/api/v1/asl/research/tasks/:taskId/import` | 🔜 导入到初筛 |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 前端设计
|
||||
|
||||
### 页面布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 智能文献检索 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ 🔍 输入您的研究问题,AI将自动在PubMed中检索... ││
|
||||
│ │ ││
|
||||
│ │ [ ] ││
|
||||
│ │ ││
|
||||
│ │ [🚀 开始检索] ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ 💭 AI 思考过程 [展开 ▼] ││
|
||||
│ ├─────────────────────────────────────────────────────────┤│
|
||||
│ │ 用户查询:"糖尿病 SGLT2抑制剂 心血管 RCT" ││
|
||||
│ │ 这是一个关于糖尿病药物的学术文献查询... ││
|
||||
│ │ ││
|
||||
│ │ 📊 检索策略: ││
|
||||
│ │ 1. 核心关键词:SGLT2 inhibitors, cardiovascular ││
|
||||
│ │ 2. MeSH术语:Sodium-Glucose Transporter 2 Inhibitors ││
|
||||
│ │ ││
|
||||
│ │ 🔍 正在执行第 5/15 轮检索... ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ ⏳ 检索进度 │
|
||||
│ ████████████████████░░░░░░░░░░ 65% │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ 📚 检索结果 (15篇) ││
|
||||
│ ├─────────────────────────────────────────────────────────┤│
|
||||
│ │ 1. PMID: 26378978 [PubMed ↗] ││
|
||||
│ │ Empagliflozin, Cardiovascular Outcomes... ││
|
||||
│ │ Zinman B, et al. | NEJM | 2015 ││
|
||||
│ ├─────────────────────────────────────────────────────────┤│
|
||||
│ │ 2. PMID: 28605608 [PubMed ↗] ││
|
||||
│ │ Canagliflozin and Cardiovascular... ││
|
||||
│ │ Neal B, et al. | NEJM | 2017 ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
### 后端
|
||||
|
||||
```
|
||||
backend/src/modules/asl/
|
||||
├── controllers/
|
||||
│ └── researchController.ts # 2个API
|
||||
├── services/
|
||||
│ └── researchService.ts # 核心逻辑
|
||||
├── workers/
|
||||
│ └── researchWorker.ts # unifuncs轮询
|
||||
└── routes/
|
||||
└── research.ts # 路由配置
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
```
|
||||
frontend-v2/src/modules/asl/
|
||||
├── pages/
|
||||
│ └── ResearchSearch.tsx # 主页面
|
||||
└── api/
|
||||
└── research.ts # API函数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 开发计划(3天)
|
||||
|
||||
| 天数 | 任务 | 产出 |
|
||||
|------|------|------|
|
||||
| **Day 1** | 数据库 + 后端 Service + Worker | Schema迁移 + 核心逻辑 |
|
||||
| **Day 2** | 后端 Controller + 前端页面 | API + 页面框架 |
|
||||
| **Day 3** | 思考过程展示 + 联调测试 | **重点:思考过程UI** |
|
||||
|
||||
---
|
||||
|
||||
## ✅ MVP 验收标准
|
||||
|
||||
- [ ] 用户可输入研究问题
|
||||
- [ ] 点击"开始检索"后显示进度
|
||||
- [ ] **思考过程实时展示**
|
||||
- [ ] 检索完成后显示文献列表
|
||||
- [ ] 文献可跳转到 PubMed
|
||||
|
||||
---
|
||||
|
||||
## 🔧 环境变量
|
||||
|
||||
```bash
|
||||
# .env
|
||||
UNIFUNCS_API_KEY=sk-xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档维护者**: 开发团队
|
||||
**最后更新**: 2026-01-18
|
||||
**文档状态**: ✅ MVP方案确认,开始开发
|
||||
174
docs/03-业务模块/ASL-AI智能文献/05-开发记录/2026-01-18_智能文献检索DeepSearch集成.md
Normal file
174
docs/03-业务模块/ASL-AI智能文献/05-开发记录/2026-01-18_智能文献检索DeepSearch集成.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 智能文献检索(DeepSearch)集成开发记录
|
||||
|
||||
> **开发日期:** 2026-01-18
|
||||
> **开发者:** AI 开发助手
|
||||
> **状态:** ✅ MVP 功能完成
|
||||
> **模块:** ASL - AI智能文献
|
||||
|
||||
---
|
||||
|
||||
## 📋 功能概述
|
||||
|
||||
### 需求背景
|
||||
临床研究者需要从 PubMed 检索高质量文献,传统方式需要手动构建检索式,效率低下。通过集成 unifuncs DeepSearch API,实现 AI 驱动的自动化深度文献检索。
|
||||
|
||||
### 核心功能
|
||||
- 自然语言输入研究问题
|
||||
- AI 自动生成专业检索策略
|
||||
- 实时显示 AI 思考过程
|
||||
- 深度检索 PubMed 数据库
|
||||
- 提取并展示 PubMed 文献链接
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术实现
|
||||
|
||||
### 架构方案
|
||||
|
||||
```
|
||||
用户输入 → 前端 SSE 请求 → 后端调用 unifuncs API → 流式返回 → 实时展示
|
||||
```
|
||||
|
||||
**关键技术选型:**
|
||||
- **API 协议**:unifuncs OpenAI 兼容协议(Streaming 模式)
|
||||
- **前后端通信**:Server-Sent Events (SSE)
|
||||
- **数据存储**:PostgreSQL (asl_schema.asl_research_tasks)
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/modules/asl/
|
||||
│ ├── controllers/researchController.ts # SSE 流式接口
|
||||
│ ├── services/researchService.ts # 核心业务逻辑
|
||||
│ ├── workers/researchWorker.ts # 异步任务处理(备用)
|
||||
│ └── routes/index.ts # 路由注册
|
||||
├── prisma/schema.prisma # 数据库模型
|
||||
|
||||
frontend-v2/
|
||||
└── src/modules/asl/
|
||||
├── pages/ResearchSearch.tsx # 检索页面
|
||||
├── pages/ResearchSearch.css # 样式
|
||||
└── api/index.ts # API 函数
|
||||
```
|
||||
|
||||
### API 端点
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/v1/asl/research/stream` | SSE 流式检索(推荐) |
|
||||
| POST | `/api/v1/asl/research/tasks` | 创建异步任务(备用) |
|
||||
| GET | `/api/v1/asl/research/tasks/:taskId/status` | 查询任务状态 |
|
||||
|
||||
### 数据库 Schema
|
||||
|
||||
```prisma
|
||||
model AslResearchTask {
|
||||
id String @id @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
userId String @map("user_id")
|
||||
query String
|
||||
filters Json?
|
||||
externalTaskId String? @map("external_task_id")
|
||||
status String @default("pending")
|
||||
errorMessage String? @map("error_message")
|
||||
resultCount Int? @map("result_count")
|
||||
rawResult String? @map("raw_result") @db.Text
|
||||
reasoningContent String? @map("reasoning_content") @db.Text
|
||||
literatures Json?
|
||||
tokenUsage Json? @map("token_usage")
|
||||
searchCount Int? @map("search_count")
|
||||
readCount Int? @map("read_count")
|
||||
iterations Int?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
@@map("asl_research_tasks")
|
||||
@@schema("asl_schema")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 开发过程
|
||||
|
||||
### Phase 1:API 验证
|
||||
- 创建测试脚本 `scripts/test-unifuncs-deepsearch.ts`
|
||||
- 验证 unifuncs DeepSearch API 可用性
|
||||
- 确认 OpenAI 兼容协议(Streaming)可行
|
||||
|
||||
### Phase 2:后端开发
|
||||
1. 数据库 Schema 设计与迁移
|
||||
2. ResearchService 核心逻辑
|
||||
3. ResearchController API 接口
|
||||
4. 路由注册
|
||||
|
||||
### Phase 3:前端开发
|
||||
1. 检索页面 UI 设计(参考 unifuncs 简洁风格)
|
||||
2. SSE 流式接收实现
|
||||
3. 实时内容展示
|
||||
4. PubMed 链接列表
|
||||
|
||||
### Phase 4:联调优化
|
||||
1. 修复 userId 获取问题
|
||||
2. 从异步轮询改为 SSE 实时流式
|
||||
3. UI 合并为统一内容流
|
||||
4. 链接提取逻辑优化
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 已知问题与遗留
|
||||
|
||||
### 当前限制
|
||||
1. **非真正异步**:离开页面任务会中断(SSE 连接断开)
|
||||
2. **成本较高**:每次检索约 0.3 元(unifuncs API 费用)
|
||||
3. **格式待优化**:思考过程和结果的排版可进一步美化
|
||||
|
||||
### 后续改进方向
|
||||
- [ ] 真正的异步任务(用户可离开页面)
|
||||
- [ ] 搜索历史记录 UI
|
||||
- [ ] 高级筛选(年份、研究类型)
|
||||
- [ ] 导出功能(Excel/BibTeX)
|
||||
- [ ] Token 消耗统计展示
|
||||
- [ ] 一键导入到标题摘要初筛
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试结果
|
||||
|
||||
### 功能测试
|
||||
| 测试项 | 状态 | 备注 |
|
||||
|--------|------|------|
|
||||
| 创建检索任务 | ✅ | 正常 |
|
||||
| SSE 实时流式 | ✅ | 思考过程实时显示 |
|
||||
| PubMed 链接提取 | ✅ | 正确提取 |
|
||||
| 数据库存储 | ✅ | 任务记录保存 |
|
||||
| 错误处理 | ✅ | 网络错误、API 错误 |
|
||||
|
||||
### 性能数据
|
||||
- 平均检索时间:1-3 分钟
|
||||
- 返回文献数量:10-20 篇(视查询复杂度)
|
||||
|
||||
---
|
||||
|
||||
## 📝 配置说明
|
||||
|
||||
### 环境变量
|
||||
```bash
|
||||
# backend/.env
|
||||
UNIFUNCS_API_KEY=sk-xxxx
|
||||
```
|
||||
|
||||
### 前端入口
|
||||
- 路由:`/literature/research/search`
|
||||
- 菜单:AI智能文献 → 2. 智能文献检索
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考资料
|
||||
|
||||
- [unifuncs DeepSearch API 文档](https://unifuncs.com/docs)
|
||||
- [Postgres-Only 异步任务处理指南](../../02-通用能力层/Postgres-Only异步任务处理指南.md)
|
||||
- [数据库开发规范](../../04-开发规范/09-数据库开发规范.md)
|
||||
|
||||
@@ -436,6 +436,48 @@ export async function exportFulltextResults(
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// ==================== 智能文献检索API (DeepSearch) ====================
|
||||
|
||||
/**
|
||||
* 创建智能文献检索任务
|
||||
*/
|
||||
export async function createResearchTask(data: {
|
||||
projectId: string;
|
||||
query: string;
|
||||
}): Promise<ApiResponse<{
|
||||
id: string;
|
||||
status: string;
|
||||
}>> {
|
||||
return request('/research/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取智能文献检索任务状态
|
||||
*/
|
||||
export async function getResearchTaskStatus(
|
||||
taskId: string
|
||||
): Promise<ApiResponse<{
|
||||
taskId: string;
|
||||
status: 'processing' | 'ready' | 'error';
|
||||
progress: number;
|
||||
query?: string;
|
||||
resultCount?: number;
|
||||
reasoningContent?: string;
|
||||
literatures?: Array<{
|
||||
pmid: string;
|
||||
title: string;
|
||||
authors: string;
|
||||
journal: string;
|
||||
year: number;
|
||||
}>;
|
||||
errorMessage?: string;
|
||||
}>> {
|
||||
return request(`/research/tasks/${taskId}/status`);
|
||||
}
|
||||
|
||||
// ==================== 统一导出API对象 ====================
|
||||
|
||||
/**
|
||||
@@ -482,4 +524,8 @@ export const aslApi = {
|
||||
|
||||
// 健康检查
|
||||
healthCheck,
|
||||
|
||||
// 智能文献检索 (DeepSearch)
|
||||
createResearchTask,
|
||||
getResearchTaskStatus,
|
||||
};
|
||||
|
||||
@@ -39,11 +39,9 @@ const ASLLayout = () => {
|
||||
title: '敬请期待'
|
||||
},
|
||||
{
|
||||
key: 'literature-search',
|
||||
key: '/literature/research/search',
|
||||
icon: <SearchOutlined />,
|
||||
label: '2. 智能文献检索',
|
||||
disabled: true,
|
||||
title: '敬请期待'
|
||||
},
|
||||
{
|
||||
key: 'literature-management',
|
||||
@@ -131,6 +129,9 @@ const ASLLayout = () => {
|
||||
};
|
||||
const openKeys = getOpenKeys();
|
||||
|
||||
// 智能文献检索页面使用全屏布局(无左侧导航栏装饰)
|
||||
const isResearchPage = currentPath.includes('/research/');
|
||||
|
||||
return (
|
||||
<Layout className="h-screen">
|
||||
{/* 左侧导航栏 */}
|
||||
@@ -162,7 +163,7 @@ const ASLLayout = () => {
|
||||
|
||||
{/* 右侧内容区 */}
|
||||
<Layout>
|
||||
<Content className="bg-white overflow-auto">
|
||||
<Content className={isResearchPage ? "overflow-auto" : "bg-white overflow-auto"}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
@@ -19,6 +19,9 @@ const FulltextProgress = lazy(() => import('./pages/FulltextProgress'));
|
||||
const FulltextWorkbench = lazy(() => import('./pages/FulltextWorkbench'));
|
||||
const FulltextResults = lazy(() => import('./pages/FulltextResults'));
|
||||
|
||||
// 智能文献检索页面
|
||||
const ResearchSearch = lazy(() => import('./pages/ResearchSearch'));
|
||||
|
||||
const ASLModule = () => {
|
||||
return (
|
||||
<Suspense
|
||||
@@ -32,6 +35,9 @@ const ASLModule = () => {
|
||||
<Route path="" element={<ASLLayout />}>
|
||||
<Route index element={<Navigate to="screening/title/settings" replace />} />
|
||||
|
||||
{/* 智能文献检索 */}
|
||||
<Route path="research/search" element={<ResearchSearch />} />
|
||||
|
||||
{/* 标题摘要初筛 */}
|
||||
<Route path="screening/title">
|
||||
<Route index element={<Navigate to="settings" replace />} />
|
||||
|
||||
230
frontend-v2/src/modules/asl/pages/ResearchSearch.css
Normal file
230
frontend-v2/src/modules/asl/pages/ResearchSearch.css
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 智能文献检索页面样式
|
||||
*/
|
||||
|
||||
.research-page {
|
||||
min-height: 100%;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ==================== 搜索区域 ==================== */
|
||||
|
||||
.search-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition: all 0.3s ease;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.search-section-center {
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
.search-section-top {
|
||||
padding-top: 40px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.search-section-top .search-title {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #e8e8e8;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 16px !important;
|
||||
resize: none !important;
|
||||
padding: 8px 0 !important;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #999 !important;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
height: 40px !important;
|
||||
padding: 0 24px !important;
|
||||
font-size: 15px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* ==================== 结果卡片 ==================== */
|
||||
|
||||
.result-card {
|
||||
max-width: 900px;
|
||||
margin: 0 auto 24px;
|
||||
width: calc(100% - 48px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.result-card .ant-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 状态栏 */
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.result-header .success-icon {
|
||||
color: #52c41a;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.result-header strong {
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ==================== 统一内容流 ==================== */
|
||||
|
||||
.content-stream {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.content-stream pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.content-stream::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.content-stream::-webkit-scrollbar-track {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.content-stream::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ==================== PubMed 链接列表 ==================== */
|
||||
|
||||
.links-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.links-header {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.links-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
color: #1890ff;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.link-item:hover {
|
||||
background: #e6f7ff;
|
||||
color: #096dd9;
|
||||
}
|
||||
|
||||
/* ==================== 错误状态 ==================== */
|
||||
|
||||
.error-card {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 48px;
|
||||
color: #ff4d4f;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ==================== 响应式 ==================== */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.search-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.content-stream {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
228
frontend-v2/src/modules/asl/pages/ResearchSearch.tsx
Normal file
228
frontend-v2/src/modules/asl/pages/ResearchSearch.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 智能文献检索页面(DeepSearch)
|
||||
*
|
||||
* SSE 实时显示,统一文档流
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Input, Button, Card, message } from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
LinkOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAccessToken } from '../../../framework/auth/api';
|
||||
import './ResearchSearch.css';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const ResearchSearch = () => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [content, setContent] = useState(''); // 统一的内容流
|
||||
const [links, setLinks] = useState<string[]>([]); // PubMed 链接列表
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自动滚动
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTop = contentRef.current.scrollHeight;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
// SSE 流式检索
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) {
|
||||
message.warning('请输入检索问题');
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
setIsSearching(true);
|
||||
setContent('');
|
||||
setLinks([]);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
|
||||
const response = await fetch('/api/v1/asl/research/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: 'default',
|
||||
query: query.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('无法读取响应流');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
switch (data.type) {
|
||||
case 'reasoning':
|
||||
case 'content':
|
||||
// 统一追加到内容流
|
||||
setContent(prev => prev + data.content);
|
||||
break;
|
||||
case 'completed':
|
||||
setLinks(data.links || []);
|
||||
setIsSearching(false);
|
||||
message.success(`检索完成,找到 ${data.links?.length || 0} 个 PubMed 链接`);
|
||||
break;
|
||||
case 'error':
|
||||
setError(data.error);
|
||||
setIsSearching(false);
|
||||
message.error(data.error);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsSearching(false);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || '检索失败');
|
||||
setIsSearching(false);
|
||||
message.error(err.message || '检索失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 重新检索
|
||||
const handleRetry = () => {
|
||||
setQuery('');
|
||||
setContent('');
|
||||
setLinks([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const hasContent = content.length > 0;
|
||||
const isCompleted = !isSearching && hasContent && !error;
|
||||
|
||||
return (
|
||||
<div className="research-page">
|
||||
{/* 搜索区域 */}
|
||||
<div className={`search-section ${hasContent ? 'search-section-top' : 'search-section-center'}`}>
|
||||
<h1 className="search-title">智能文献检索</h1>
|
||||
<div className="search-box">
|
||||
<TextArea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="今天想检索点啥?"
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
disabled={isSearching}
|
||||
className="search-input"
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="search-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={isSearching ? <LoadingOutlined /> : <SearchOutlined />}
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || !query.trim()}
|
||||
className="search-btn"
|
||||
>
|
||||
{isSearching ? '检索中' : 'Go'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统一内容流 */}
|
||||
{hasContent && (
|
||||
<Card className="result-card">
|
||||
{/* 状态栏 */}
|
||||
<div className="result-header">
|
||||
{isSearching ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ color: '#1890ff' }} />
|
||||
<span>AI 正在深度检索...</span>
|
||||
</>
|
||||
) : isCompleted ? (
|
||||
<>
|
||||
<CheckCircleFilled className="success-icon" />
|
||||
<span>检索完成,找到 <strong>{links.length}</strong> 个 PubMed 链接</span>
|
||||
<Button onClick={handleRetry} size="small" style={{ marginLeft: 'auto' }}>新建检索</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 实时内容 */}
|
||||
<div className="content-stream" ref={contentRef}>
|
||||
<pre>{content}</pre>
|
||||
</div>
|
||||
|
||||
{/* PubMed 链接列表 */}
|
||||
{links.length > 0 && (
|
||||
<div className="links-section">
|
||||
<div className="links-header">
|
||||
<LinkOutlined /> PubMed 文献链接({links.length})
|
||||
</div>
|
||||
<div className="links-list">
|
||||
{links.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link-item"
|
||||
>
|
||||
{index + 1}. {link}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{error && !isSearching && (
|
||||
<Card className="result-card error-card">
|
||||
<CloseCircleFilled className="error-icon" />
|
||||
<div className="error-text">检索失败:{error}</div>
|
||||
<Button type="primary" onClick={handleRetry}>重新检索</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchSearch;
|
||||
Reference in New Issue
Block a user