feat(aia): Integrate PromptService for 10 AI agents

Features:
- Migrate 10 agent prompts from hardcoded to database
- Add grayscale preview support (DRAFT/ACTIVE distribution)
- Implement 3-tier fallback (DB -> Cache -> Hardcoded)
- Add version management and rollback capability

Files changed:
- backend/scripts/migrate-aia-prompts.ts (new migration script)
- backend/src/common/prompt/prompt.fallbacks.ts (add AIA fallbacks)
- backend/src/modules/aia/services/agentService.ts (integrate PromptService)
- backend/src/modules/aia/services/conversationService.ts (pass userId)
- backend/src/modules/aia/types/index.ts (fix AgentStage type)

Documentation:
- docs/03-业务模块/AIA-AI智能问答/06-开发记录/2026-01-18-Prompt管理系统集成.md
- docs/02-通用能力层/00-通用能力层清单.md (add FileCard, Prompt management)
- docs/00-系统总体设计/00-系统当前状态与开发指南.md (update to v3.6)

Prompt codes:
- AIA_SCIENTIFIC_QUESTION, AIA_PICO_ANALYSIS, AIA_TOPIC_EVALUATION
- AIA_OUTCOME_DESIGN, AIA_CRF_DESIGN, AIA_SAMPLE_SIZE
- AIA_PROTOCOL_WRITING, AIA_METHODOLOGY_REVIEW
- AIA_PAPER_POLISH, AIA_PAPER_TRANSLATE

Tested: Migration script executed, all 10 prompts inserted successfully
This commit is contained in:
2026-01-18 15:48:53 +08:00
parent 66255368b7
commit 57fdc6ef00
290 changed files with 2950 additions and 106 deletions

View File

@@ -233,3 +233,6 @@ async function matchIntent(query: string): Promise<{

View File

@@ -0,0 +1,92 @@
/**
* AIA 智能问答模块 - 附件控制器
* @module aia/controllers/attachmentController
*
* API 端点:
* - POST /api/v1/aia/conversations/:id/attachments 上传附件
*/
import type { FastifyRequest, FastifyReply } from 'fastify';
import { logger } from '../../../common/logging/index.js';
import * as attachmentService from '../services/attachmentService.js';
/**
* 从 JWT Token 获取用户 ID
*/
function getUserId(request: FastifyRequest): string {
const userId = (request as any).user?.userId;
if (!userId) {
throw new Error('User not authenticated');
}
return userId;
}
/**
* 上传附件
* POST /api/v1/aia/conversations/:id/attachments
*/
export async function uploadAttachment(
request: FastifyRequest<{
Params: { id: string };
}>,
reply: FastifyReply
) {
try {
const userId = getUserId(request);
const { id: conversationId } = request.params;
// 获取上传的文件
const data = await request.file();
if (!data) {
return reply.status(400).send({
code: -1,
error: {
code: 'VALIDATION_ERROR',
message: '请上传文件',
},
});
}
const buffer = await data.toBuffer();
logger.info('[AIA:AttachmentController] 上传附件', {
userId,
conversationId,
filename: data.filename,
mimetype: data.mimetype,
size: buffer.length,
});
const attachment = await attachmentService.uploadAttachment(
userId,
conversationId,
{
filename: data.filename,
mimetype: data.mimetype,
buffer,
}
);
return reply.send({
code: 0,
data: attachment,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('[AIA:AttachmentController] 上传附件失败', {
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
});
return reply.status(500).send({
code: -1,
error: {
code: 'INTERNAL_ERROR',
message: errorMessage,
},
});
}
}

View File

@@ -135,22 +135,16 @@ export async function createConversation(
export async function getConversationById(
request: FastifyRequest<{
Params: { id: string };
Querystring: { limit?: string };
}>,
reply: FastifyReply
) {
try {
const userId = getUserId(request);
const { id } = request.params;
const { limit } = request.query;
logger.info('[AIA:Controller] 获取对话详情', { userId, conversationId: id });
const conversation = await conversationService.getConversationById(
userId,
id,
limit ? parseInt(limit) : 50
);
const conversation = await conversationService.getConversationById(userId, id);
if (!conversation) {
return reply.status(404).send({
@@ -178,6 +172,54 @@ export async function getConversationById(
}
}
/**
* 更新对话(标题等)
* PATCH /api/v1/aia/conversations/:id
*/
export async function updateConversation(
request: FastifyRequest<{
Params: { id: string };
Body: { title?: string };
}>,
reply: FastifyReply
) {
try {
const userId = getUserId(request);
const { id } = request.params;
const { title } = request.body;
logger.info('[AIA:Controller] 更新对话', { userId, conversationId: id, title });
if (!title || title.trim().length === 0) {
return reply.status(400).send({
code: -1,
error: {
code: 'VALIDATION_ERROR',
message: '标题不能为空',
},
});
}
const conversation = await conversationService.updateConversation(userId, id, {
title: title.trim(),
});
return reply.send({
code: 0,
data: conversation,
});
} catch (error) {
logger.error('[AIA:Controller] 更新对话失败', { error });
return reply.status(500).send({
code: -1,
error: {
code: 'INTERNAL_ERROR',
message: error instanceof Error ? error.message : '服务器内部错误',
},
});
}
}
/**
* 删除对话
* DELETE /api/v1/aia/conversations/:id
@@ -222,7 +264,57 @@ export async function deleteConversation(
}
}
// ==================== 消息发送 ====================
// ==================== 消息管理 ====================
/**
* 获取对话消息列表(历史消息)
* GET /api/v1/aia/conversations/:id/messages
*/
export async function getMessages(
request: FastifyRequest<{
Params: { id: string };
Querystring: {
page?: string;
pageSize?: string;
};
}>,
reply: FastifyReply
) {
try {
const userId = getUserId(request);
const { id } = request.params;
const { page, pageSize } = request.query;
logger.info('[AIA:Controller] 获取消息列表', { userId, conversationId: id });
const result = await conversationService.getMessages(userId, id, {
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 50,
});
return reply.send({
code: 0,
data: {
messages: result.messages,
pagination: {
total: result.total,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 50,
totalPages: Math.ceil(result.total / (pageSize ? parseInt(pageSize) : 50)),
},
},
});
} catch (error) {
logger.error('[AIA:Controller] 获取消息列表失败', { error });
return reply.status(500).send({
code: -1,
error: {
code: 'INTERNAL_ERROR',
message: error instanceof Error ? error.message : '服务器内部错误',
},
});
}
}
/**
* 发送消息(流式输出)

View File

@@ -16,3 +16,6 @@ export { aiaRoutes };

View File

@@ -8,6 +8,7 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import * as conversationController from '../controllers/conversationController.js';
import * as agentController from '../controllers/agentController.js';
import * as attachmentController from '../controllers/attachmentController.js';
import { authenticate } from '../../../common/auth/auth.middleware.js';
export default async function aiaRoutes(fastify: FastifyInstance) {
@@ -53,17 +54,37 @@ export default async function aiaRoutes(fastify: FastifyInstance) {
return conversationController.getConversationById(request as any, reply);
});
// 更新对话(标题等)
// PATCH /api/v1/aia/conversations/:id
fastify.patch('/conversations/:id', { preHandler: [authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => {
return conversationController.updateConversation(request as any, reply);
});
// 删除对话
// DELETE /api/v1/aia/conversations/:id
fastify.delete('/conversations/:id', { preHandler: [authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => {
return conversationController.deleteConversation(request as any, reply);
});
// ==================== 消息发送 ====================
// ==================== 消息管理 ====================
// 获取对话消息列表(历史消息)
// GET /api/v1/aia/conversations/:id/messages
fastify.get('/conversations/:id/messages', { preHandler: [authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => {
return conversationController.getMessages(request as any, reply);
});
// 发送消息(流式输出)
// POST /api/v1/aia/conversations/:id/messages/stream
fastify.post('/conversations/:id/messages/stream', { preHandler: [authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => {
return conversationController.sendMessageStream(request as any, reply);
});
// ==================== 附件管理 ====================
// 上传附件
// POST /api/v1/aia/conversations/:id/attachments
fastify.post('/conversations/:id/attachments', { preHandler: [authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => {
return attachmentController.uploadAttachment(request as any, reply);
});
}

View File

@@ -4,12 +4,38 @@
*
* 负责智能体配置管理、Prompt 获取
* 12个智能体配置对应前端 AgentHub
*
* Phase 3.5.6 改造:使用 PromptService 替代硬编码
* - 支持灰度预览(调试者看 DRAFT普通用户看 ACTIVE
* - 三级容灾(数据库→缓存→兜底)
* - 在管理端可配置和调试
*/
import { logger } from '../../../common/logging/index.js';
import { cache } from '../../../common/cache/index.js';
import { prisma } from '../../../config/database.js';
import { getPromptService } from '../../../common/prompt/index.js';
import type { Agent, AgentStage } from '../types/index.js';
// ==================== 智能体 ID 到 Prompt Code 映射 ====================
/**
* 智能体 ID → Prompt Code 映射表
* 用于从 PromptService 获取对应的提示词
*/
const AGENT_TO_PROMPT_CODE: Record<string, string> = {
'TOPIC_01': 'AIA_SCIENTIFIC_QUESTION',
'TOPIC_02': 'AIA_PICO_ANALYSIS',
'TOPIC_03': 'AIA_TOPIC_EVALUATION',
'DESIGN_04': 'AIA_OUTCOME_DESIGN',
'DESIGN_05': 'AIA_CRF_DESIGN',
'DESIGN_06': 'AIA_SAMPLE_SIZE',
'DESIGN_07': 'AIA_PROTOCOL_WRITING',
'REVIEW_08': 'AIA_METHODOLOGY_REVIEW',
'WRITING_11': 'AIA_PAPER_POLISH',
'WRITING_12': 'AIA_PAPER_TRANSLATE',
};
// ==================== 智能体配置 ====================
/**
@@ -303,9 +329,25 @@ export async function getAgentsByStage(stage: AgentStage): Promise<Agent[]> {
}
/**
* 获取智能体的系统提示词
* 获取智能体的系统提示词(使用 PromptService
*
* 支持灰度预览:
* - 调试者看 DRAFT 版本
* - 普通用户看 ACTIVE 版本
*
* 三级容灾:
* 1. 数据库PromptService
* 2. 缓存
* 3. 兜底(硬编码的 systemPrompt
*
* @param agentId 智能体 ID
* @param userId 用户 ID用于灰度预览判断
* @returns { content: 提示词内容, isDraft: 是否为草稿版本 }
*/
export async function getAgentSystemPrompt(agentId: string): Promise<string> {
export async function getAgentSystemPrompt(
agentId: string,
userId?: string
): Promise<{ content: string; isDraft: boolean }> {
const agent = await getAgentById(agentId);
if (!agent) {
@@ -316,11 +358,53 @@ export async function getAgentSystemPrompt(agentId: string): Promise<string> {
throw new Error(`智能体 ${agentId} 是工具类,不支持对话`);
}
// 获取 Prompt Code
const promptCode = AGENT_TO_PROMPT_CODE[agentId];
if (promptCode) {
// 使用 PromptService 获取(支持灰度预览)
try {
const promptService = getPromptService(prisma);
const result = await promptService.get(promptCode, {}, { userId });
if (result.isDraft) {
logger.info('[AIA:AgentService] 使用 DRAFT 版本 Prompt调试模式', {
agentId,
promptCode,
userId
});
} else {
logger.debug('[AIA:AgentService] 使用 ACTIVE 版本 Prompt', {
agentId,
promptCode,
version: result.version
});
}
return {
content: result.content,
isDraft: result.isDraft,
};
} catch (error) {
// PromptService 获取失败,降级到硬编码
logger.warn('[AIA:AgentService] PromptService 获取失败,使用兜底', {
agentId,
promptCode,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
// 兜底:使用硬编码的 systemPrompt
if (!agent.systemPrompt) {
throw new Error(`智能体 ${agentId} 未配置系统提示词`);
}
return agent.systemPrompt;
logger.debug('[AIA:AgentService] 使用硬编码 Prompt', { agentId });
return {
content: agent.systemPrompt,
isDraft: false,
};
}
/**

View File

@@ -10,9 +10,15 @@
import { logger } from '../../../common/logging/index.js';
import { storage } from '../../../common/storage/index.js';
import { cache } from '../../../common/cache/index.js';
import { ExtractionClient } from '../../../common/document/ExtractionClient.js';
import type { Attachment } from '../types/index.js';
// 附件缓存前缀和过期时间2小时
const ATTACHMENT_CACHE_PREFIX = 'aia:attachment:text:';
const ATTACHMENT_INFO_CACHE_PREFIX = 'aia:attachment:info:';
const ATTACHMENT_CACHE_TTL = 2 * 60 * 60; // 2小时
// ==================== 常量配置 ====================
const MAX_ATTACHMENTS = 5;
@@ -41,20 +47,51 @@ export async function uploadAttachment(
// 2. 上传到存储服务
const storageKey = `aia/${userId}/${conversationId}/${Date.now()}_${file.filename}`;
const url = await storage.upload(storageKey, file.buffer, {
contentType: file.mimetype,
});
const url = await storage.upload(storageKey, file.buffer);
logger.info('[AIA:AttachmentService] 附件上传成功', {
filename: file.filename,
url,
});
// 3. 提取文本内容(异步处理)
// 3. 提取文本内容
let extractedText = '';
try {
const extractionClient = new ExtractionClient();
extractedText = await extractionClient.extractText(file.buffer, ext);
// 对于 txt 文件,直接读取内容(不依赖 Python 服务)
if (ext === 'txt') {
extractedText = file.buffer.toString('utf-8');
logger.info('[AIA:AttachmentService] TXT文件直接读取成功', {
filename: file.filename,
charCount: extractedText.length,
});
} else {
// 其他文件类型调用 Python 提取服务
const extractionClient = new ExtractionClient();
let result;
if (ext === 'pdf') {
result = await extractionClient.extractPdf(file.buffer, file.filename);
} else if (ext === 'docx' || ext === 'doc') {
result = await extractionClient.extractDocx(file.buffer, file.filename);
} else {
result = await extractionClient.extractDocument(file.buffer, file.filename);
}
if (result.success && result.text) {
extractedText = result.text;
logger.info('[AIA:AttachmentService] 文本提取成功', {
filename: file.filename,
method: result.method,
charCount: result.text.length,
});
} else {
logger.warn('[AIA:AttachmentService] 文本提取返回空', {
filename: file.filename,
error: result.error,
});
extractedText = '[文档内容为空或无法提取]';
}
}
// 4. Token 截断控制
const tokens = estimateTokens(extractedText);
@@ -78,16 +115,48 @@ export async function uploadAttachment(
}
// 5. 构建附件对象
const attachmentId = `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const tokenCount = estimateTokens(extractedText);
const truncated = tokenCount > MAX_TOKENS_PER_ATTACHMENT;
const attachment: Attachment = {
id: `att-${Date.now()}`,
name: file.filename,
url,
size: file.buffer.length,
id: attachmentId,
filename: file.filename,
mimeType: file.mimetype,
extractedText,
tokens: estimateTokens(extractedText),
size: file.buffer.length,
ossUrl: url,
textContent: extractedText,
tokenCount,
truncated,
createdAt: new Date().toISOString(),
};
// 6. 将提取的文本存储到缓存(供后续发送消息时使用)
if (extractedText && extractedText !== '[文档内容提取失败]' && extractedText !== '[文档内容为空或无法提取]') {
await cache.set(
`${ATTACHMENT_CACHE_PREFIX}${attachmentId}`,
extractedText,
ATTACHMENT_CACHE_TTL
);
logger.info('[AIA:AttachmentService] 附件文本已缓存', {
attachmentId,
textLength: extractedText.length,
tokenCount,
});
}
// 7. 存储附件基本信息到缓存(供发送消息时保存到数据库)
const attachmentInfo = {
id: attachmentId,
filename: file.filename,
size: file.buffer.length,
};
await cache.set(
`${ATTACHMENT_INFO_CACHE_PREFIX}${attachmentId}`,
JSON.stringify(attachmentInfo),
ATTACHMENT_CACHE_TTL
);
return attachment;
}
@@ -95,11 +164,79 @@ export async function uploadAttachment(
* 批量获取附件文本内容
*/
export async function getAttachmentsText(attachmentIds: string[]): Promise<string> {
// TODO: 从存储中获取附件并提取文本
// 当前版本:简化实现,假设附件文本已在消息的 attachments 字段中
logger.debug('[AIA:AttachmentService] 获取附件文本', { attachmentIds });
return '';
if (!attachmentIds || attachmentIds.length === 0) {
return '';
}
logger.info('[AIA:AttachmentService] 获取附件文本', {
attachmentIds,
count: attachmentIds.length,
});
const texts: string[] = [];
for (const attachmentId of attachmentIds) {
try {
const cacheKey = `${ATTACHMENT_CACHE_PREFIX}${attachmentId}`;
const text = await cache.get(cacheKey);
if (text) {
texts.push(`【附件: ${attachmentId}\n${text}`);
logger.debug('[AIA:AttachmentService] 从缓存获取附件文本成功', {
attachmentId,
textLength: text.length,
});
} else {
logger.warn('[AIA:AttachmentService] 附件文本不在缓存中', { attachmentId });
texts.push(`【附件: ${attachmentId}\n[附件内容已过期或不存在]`);
}
} catch (error) {
logger.error('[AIA:AttachmentService] 获取附件文本失败', {
attachmentId,
error
});
}
}
return texts.join('\n\n---\n\n');
}
/**
* 获取附件详情(从缓存)
* 用于发送消息时保存附件信息到数据库
*/
export async function getAttachmentDetails(
attachmentIds: string[]
): Promise<Array<{ id: string; filename: string; size: number }>> {
if (!attachmentIds || attachmentIds.length === 0) {
return [];
}
const details: Array<{ id: string; filename: string; size: number }> = [];
for (const attachmentId of attachmentIds) {
try {
const cacheKey = `${ATTACHMENT_INFO_CACHE_PREFIX}${attachmentId}`;
const infoJson = await cache.get(cacheKey);
if (infoJson) {
const info = JSON.parse(infoJson);
details.push(info);
} else {
logger.warn('[AIA:AttachmentService] 附件信息不在缓存中', { attachmentId });
// 如果缓存中没有,添加一个占位信息
details.push({
id: attachmentId,
filename: '未知文件',
size: 0,
});
}
} catch (error) {
logger.error('[AIA:AttachmentService] 获取附件信息失败', { attachmentId, error });
}
}
return details;
}
/**
@@ -113,3 +250,5 @@ function estimateTokens(text: string): number {

View File

@@ -16,6 +16,7 @@ import { prisma } from '../../../config/database.js';
import { streamChat, createStreamingService } from '../../../common/streaming/index.js';
import type { OpenAIMessage, StreamOptions } from '../../../common/streaming/index.js';
import * as agentService from './agentService.js';
import * as attachmentService from './attachmentService.js';
import type {
Conversation,
Message,
@@ -178,16 +179,17 @@ export async function updateConversation(
export async function deleteConversation(
userId: string,
conversationId: string
): Promise<void> {
): Promise<boolean> {
const result = await prisma.conversation.deleteMany({
where: { id: conversationId, userId },
});
if (result.count === 0) {
throw new Error('对话不存在');
return false;
}
logger.info('[AIA:ConversationService] 删除对话', { conversationId });
return true;
}
// ==================== 消息管理 ====================
@@ -222,17 +224,25 @@ export async function getMessages(
]);
return {
messages: messages.map(m => ({
id: m.id,
conversationId: m.conversationId,
role: m.role as 'user' | 'assistant',
content: m.content,
thinkingContent: m.thinkingContent || undefined,
attachments: (m.attachments as any)?.ids as string[] | undefined,
model: m.model || undefined,
tokens: m.tokens || undefined,
createdAt: m.createdAt.toISOString(),
})),
messages: messages.map(m => {
const attachmentsJson = m.attachments as any;
const attachmentIds = attachmentsJson?.ids as string[] | undefined;
// 直接从 JSON 字段读取附件详情(不查询数据库)
const attachmentDetails = attachmentsJson?.details as Array<{ id: string; filename: string; size: number }> | undefined;
return {
id: m.id,
conversationId: m.conversationId,
role: m.role as 'user' | 'assistant',
content: m.content,
thinkingContent: m.thinkingContent || undefined,
attachments: attachmentIds,
attachmentDetails: attachmentDetails && attachmentDetails.length > 0 ? attachmentDetails : undefined,
model: m.model || undefined,
tokens: m.tokens || undefined,
createdAt: m.createdAt.toISOString(),
};
}),
total,
};
}
@@ -259,16 +269,36 @@ export async function sendMessageStream(
throw new Error('对话不存在');
}
// 2. 获取智能体系统提示词
const systemPrompt = await agentService.getAgentSystemPrompt(conversation.agentId);
// 2. 获取智能体系统提示词(支持灰度预览)
const { content: systemPrompt, isDraft } = await agentService.getAgentSystemPrompt(
conversation.agentId,
userId // 传递 userId 以支持灰度预览
);
if (isDraft) {
logger.info('[AIA:Conversation] 使用 DRAFT 版本 Prompt调试模式', {
userId,
agentId: conversation.agentId
});
}
// 3. 保存用户消息
// 3. 保存用户消息(包含附件详情)
let attachmentsData = undefined;
if (attachmentIds && attachmentIds.length > 0) {
// 从缓存获取附件详情
const attachmentDetails = await attachmentService.getAttachmentDetails(attachmentIds);
attachmentsData = {
ids: attachmentIds,
details: attachmentDetails,
};
}
await prisma.message.create({
data: {
conversationId,
role: 'user',
content,
attachments: attachmentIds ? { ids: attachmentIds } : undefined,
attachments: attachmentsData,
},
});
@@ -380,12 +410,11 @@ async function buildContextMessages(
/**
* 获取附件文本内容
* TODO: 对接文档处理服务
* 从缓存中获取上传时提取的文本
*/
async function getAttachmentText(attachmentIds: string[]): Promise<string> {
// 预留:从文档处理引擎获取附件文本
logger.debug('[AIA:ConversationService] 获取附件文本', { attachmentIds });
return '';
logger.info('[AIA:ConversationService] 获取附件文本', { attachmentIds });
return attachmentService.getAttachmentsText(attachmentIds);
}
/**

View File

@@ -7,8 +7,13 @@
/**
* 智能体阶段
* - topic: 选题优化
* - design: 方案设计
* - review: 方案预评审
* - data: 数据处理
* - writing: 论文写作
*/
export type AgentStage = 'design' | 'data' | 'analysis' | 'write' | 'publish';
export type AgentStage = 'topic' | 'design' | 'review' | 'data' | 'writing';
/**
* 智能体配置
@@ -201,3 +206,6 @@ export interface PaginatedResponse<T> {