Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 46 additions & 17 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,6 @@ function scoreEntry(userTokens, questionText) {
return (matchedWeight / totalWeight) * 100;
}

/**
* 从 QA 知识库中检索最相关的 topN 条目
*/
function retrieveQA(question, topN = 5) {
const tokens = tokenize(question);
const scored = qaEntries.map(entry => ({
Expand All @@ -117,9 +114,6 @@ function retrieveQA(question, topN = 5) {
return scored.filter(s => s.score > 0).slice(0, topN);
}

/**
* 根据用户问题和建筑信息,进行关键词匹配建筑名称/描述
*/
function retrieveBuildingInfo(question, buildingId) {
const tokens = tokenize(question);
const results = [];
Expand Down Expand Up @@ -152,9 +146,9 @@ function buildSystemPrompt() {
请基于提供的知识库(QA 问答、建筑信息)回答用户关于南航天目湖校区的各种问题。
请严格遵守以下规则:
1. 优先使用提供的知识库内容回答,不要编造信息。
2. 如果知识库中没有相关信息,可以结合自身知识尽力回答;
但涉及报到、缴费、考试、报销等关键流程时,应注明信息可能变化,建议咨询学校相关部门确认。
对自身知识也不确定的内容,必须明确标注「不确定」,不得编造具体细节
2. 如果知识库中没有相关信息,可以结合自身知识和联网搜索结果回答,
并注明信息来源;涉及报到、缴费、考试、报销等关键流程时,
建议用户咨询学校相关部门确认
3. 回答要简洁、准确,符合学生助手语境。
4. 回答中可以适当引导用户(如"建议你咨询师生服务大厅X号窗口办理")。
5. 涉及时间、地点、办事流程的信息时,直接给出明确答案。
Expand Down Expand Up @@ -198,13 +192,42 @@ function buildUserPrompt(question, qaResults, buildingResults, contextText) {
return `${contextBlock}用户问题:${question}\n\n请根据以上信息回答用户的问题。`;
}

async function searchWeb(query, maxResults = 3) {
const apiKey = process.env.TAVILY_API_KEY;
if (!apiKey) return [];

try {
const resp = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: apiKey,
query,
max_results: maxResults,
search_depth: 'basic',
}),
});
if (!resp.ok) return [];

const data = await resp.json();
return (data.results || []).map(r => ({
title: r.title || '',
url: r.url || '',
content: r.content || '',
}));
} catch (err) {
console.error('Tavily search error:', err);
return [];
}
}

/* =============================================================
* LLM 调用
* ============================================================= */

const LLM_API_URL = process.env.LLM_API_URL || 'https://api.deepseek.com/v1/chat/completions';
const LLM_API_KEY = process.env.LLM_API_KEY || '';
const LLM_MODEL = process.env.LLM_MODEL || 'deepseek-v4-pro';
const LLM_MODEL = process.env.LLM_MODEL || 'deepseek-chat';

async function callLLM(systemPrompt, userPrompt) {
if (!LLM_API_KEY) {
Expand Down Expand Up @@ -300,7 +323,6 @@ app.post('/api/chat', async (req, res) => {
const body = req.body || {};
const { question, buildingId, context, messages, building_id, stream } = body;

// 兼容两种前端协议:question(单轮)+ messages(带历史)
const lastUserMsg = question
? question
: (Array.isArray(messages)
Expand All @@ -315,6 +337,7 @@ app.post('/api/chat', async (req, res) => {
const effectiveBuildingId = buildingId || building_id;
const qaResults = retrieveQA(trimmed, 5);
const buildingResults = retrieveBuildingInfo(trimmed, effectiveBuildingId);
const bestScore = qaResults.length > 0 ? qaResults[0].score : 0;

let buildingContext = context || '';
if (effectiveBuildingId && !buildingContext) {
Expand All @@ -327,7 +350,18 @@ app.post('/api/chat', async (req, res) => {
}

const systemPrompt = buildSystemPrompt();
const userPrompt = buildUserPrompt(trimmed, qaResults, buildingResults, buildingContext);

// 本地知识库匹配分数低或无结果,调用 Tavily 联网搜索补充上下文
let userPrompt = buildUserPrompt(trimmed, qaResults, buildingResults, buildingContext);
if ((qaResults.length === 0 || bestScore < 30) && process.env.TAVILY_API_KEY) {
const webResults = await searchWeb(trimmed, 3);
if (webResults.length > 0) {
const webText = webResults.map((r, i) =>
`来源 ${i + 1}:${r.title}\n链接:${r.url}\n内容:${r.content}`
).join('\n\n');
userPrompt += `\n\n--- 联网搜索结果 ---\n${webText}\n--- 结束 ---`;
}
}

if (!LLM_API_KEY) {
return res.status(500).json({
Expand All @@ -338,7 +372,6 @@ app.post('/api/chat', async (req, res) => {

const useStream = stream === true;

// 带历史时保留最近几轮;最后一条 user 消息替换为带 RAG 上下文的 userPrompt
const llmMessages = [{ role: 'system', content: systemPrompt }];
if (Array.isArray(messages) && messages.length > 0) {
const history = [...messages].slice(-4);
Expand Down Expand Up @@ -372,7 +405,6 @@ app.post('/api/chat', async (req, res) => {
}

if (useStream) {
// SSE 流式输出:逐行解析上游事件并转发
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
Expand All @@ -384,7 +416,6 @@ app.post('/api/chat', async (req, res) => {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream: true 保持跨 chunk 的多字节字符完整,避免中文乱码
buffer += decoder.decode(value, { stream: true });
let nl;
while ((nl = buffer.indexOf('\n')) >= 0) {
Expand Down Expand Up @@ -415,7 +446,6 @@ app.post('/api/chat', async (req, res) => {
}
});

// 仅当直接运行时监听端口(被测试 import 时不监听,测试用 supertest 驱动 app)
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
app.listen(PORT, () => {
Expand All @@ -426,6 +456,5 @@ if (isDirectRun) {
});
}

// 导出 app 供测试(supertest)使用
export { app };
export { tokenize, scoreEntry, retrieveQA, retrieveBuildingInfo, buildSystemPrompt, buildUserPrompt };
8 changes: 4 additions & 4 deletions backend/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,11 @@ describe('buildSystemPrompt / buildUserPrompt', () => {
assert.ok(p.includes('中文'));
});

test('系统提示:关键流程允许结合自身知识但要求提示咨询', () => {
test('系统提示:允许结合自身知识与联网搜索但要求提示咨询', () => {
const p = buildSystemPrompt();
assert.ok(p.includes('可以结合自身知识尽力回答'));
assert.ok(p.includes('建议咨询学校相关部门确认'));
assert.ok(p.includes('不确定'));
assert.ok(p.includes('可以结合自身知识和联网搜索结果回答'));
assert.ok(p.includes('注明信息来源'));
assert.ok(p.includes('咨询学校相关部门确认'));
});

test('用户提示包含参考知识库', () => {
Expand Down
63 changes: 52 additions & 11 deletions functions/api/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,22 @@ export async function onRequestPost(context) {
.slice(0, 5)
.map(s => s.row);

bestScore = scored[0]?.score || 0;
if (scored[0] && scored[0].score >= 30 && scored[0].row.answer) {
sources = [scored[0].row.id];
bestScore = scored.length > 0 ? scored[0].score : 0;

if (bestScore >= 30 && scored[0].row.answer) {
sources = [scored[0].row.id];
}
}
}

if (bestScore >= 60 && qaContext.length > 0) {
answer = qaContext[0].answer;
} else if (env.LLM_API_KEY && env.LLM_API_URL) {
answer = await callLLM(env, question, buildingId, buildingName, buildingCtx, qaContext);
// 本地匹配分数过低或没有匹配时,才联网搜索,节省 Tavily 额度
let webResults = [];
if ((qaContext.length === 0 || bestScore < 30) && env.TAVILY_API_KEY) {
webResults = await searchWeb(env, question, 3);
}

if (env.LLM_API_KEY && env.LLM_API_URL) {
answer = await callLLM(env, question, buildingId, buildingName, buildingCtx, qaContext, webResults);
} else if (qaContext.length > 0) {
answer = qaContext[0].answer;
}
Expand Down Expand Up @@ -141,8 +147,36 @@ function scoreMatch(keywords, questionText, answerText) {
return totalWeight === 0 ? 0 : (matchedWeight / totalWeight) * 100;
}

async function callLLM(env, question, buildingId, buildingName, buildingCtx, qaContext) {
const systemPrompt = '你是南京航空航天大学校园地图智能问答助手。\n请基于提供的知识库信息回答用户关于南航天目湖校区的各种问题。\n请严格遵守以下规则:\n1. 优先使用提供的知识库内容回答,不要编造信息。\n2. 如果知识库中没有相关信息,坦诚告知用户"该信息尚未记录,请咨询学校相关部门"。\n3. 回答要简洁、准确,符合学生助手语境。\n4. 涉及时间、地点、办事流程的信息时,直接给出明确答案。\n5. 使用中文回答。\n6. 可以适当使用 Markdown 格式提升可读性,但不要使用标题(#)或图片。';
async function searchWeb(env, query, maxResults = 3) {
if (!env.TAVILY_API_KEY) return [];

try {
const resp = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: env.TAVILY_API_KEY,
query,
max_results: maxResults,
search_depth: 'basic',
}),
});
if (!resp.ok) return [];

const data = await resp.json();
return (data.results || []).map(r => ({
title: r.title || '',
url: r.url || '',
content: r.content || '',
}));
} catch (err) {
console.error('Tavily search error:', err);
return [];
}
}

async function callLLM(env, question, buildingId, buildingName, buildingCtx, qaContext, webResults = []) {
const systemPrompt = '你是南京航空航天大学校园地图智能问答助手。\n请基于提供的知识库信息回答用户关于南航天目湖校区的各种问题。\n请严格遵守以下规则:\n1. 优先使用提供的知识库内容回答,不要编造信息。\n2. 如果知识库中没有相关信息,可以结合联网搜索结果回答,并注明信息来自网络、可能变化;涉及报到、缴费、考试、报销等关键流程时,建议用户咨询学校相关部门确认。\n3. 回答要简洁、准确,符合学生助手语境。\n4. 涉及时间、地点、办事流程的信息时,直接给出明确答案。\n5. 使用中文回答。\n6. 可以适当使用 Markdown 格式提升可读性,但不要使用标题(#)或图片。';

const contextSections = [];

Expand All @@ -161,6 +195,13 @@ async function callLLM(env, question, buildingId, buildingName, buildingCtx, qaC
contextSections.push(`以下是从校园知识库中检索到的相关问答:\n${qaText}`);
}

if (webResults.length > 0) {
const webText = webResults.map((r, i) =>
`来源 ${i + 1}:${r.title}\n链接:${r.url}\n内容:${r.content}`
).join('\n\n');
contextSections.push(`以下是从互联网检索到的信息:\n${webText}`);
}

const contextBlock = contextSections.length > 0
? `\n\n--- 参考知识库信息 ---\n${contextSections.join('\n\n')}\n--- 结束 ---\n\n`
: '';
Expand All @@ -174,7 +215,7 @@ async function callLLM(env, question, buildingId, buildingName, buildingCtx, qaC
'Authorization': `Bearer ${env.LLM_API_KEY}`,
},
body: JSON.stringify({
model: env.LLM_MODEL || 'deepseek-v4-pro',
model: env.LLM_MODEL || 'deepseek-v4-flash',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
Expand All @@ -187,4 +228,4 @@ async function callLLM(env, question, buildingId, buildingName, buildingCtx, qaC
if (!resp.ok) throw new Error(`LLM API error: ${resp.status}`);
const data = await resp.json();
return data.choices?.[0]?.message?.content || null;
}
}
Loading