diff --git a/README.md b/README.md index 1e19015..dc82e35 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - 精准名词直接生成快速总结;未知名词会尝试从中文维基百科获取有来源的入门资料。 - 模糊名词先澄清含义,再按所选方向学习。 - 公开网页提取标题、摘要、章节与正文要点。 -- 围绕当前主题继续询问用法、示例和误区。 +- 围绕当前主题继续询问用法、示例和误区;百科主题优先依据已取得的来源摘录回答。 - 浮窗可以隐藏为右下角入口并随时恢复。 ## 运行 diff --git a/app.js b/app.js index fc2b3dd..2110f36 100644 --- a/app.js +++ b/app.js @@ -85,6 +85,10 @@ function renderSummary(result) { const source = result.source ? `` : ""; const concepts = result.concepts.slice(0, 3).map(([name, detail], index) => `
  • ${escapeHtml(name)} · ${escapeHtml(detail)}
  • `).join(""); const steps = result.steps.slice(0, 3).map((step, index) => `
  • ${index + 1}${escapeHtml(step)}
  • `).join(""); + const questions = result.source?.provider === "维基百科" + ? [["请用简单的话解释", "简单解释"], ["再讲详细一点", "再讲详细"], ["还有什么重要背景?", "重要背景"]] + : [["如何使用?", "如何使用?"], ["给我一个简单的例子", "给我例子"], ["有什么常见误区?", "常见误区"]]; + const quickQuestions = questions.map(([question, label]) => ``).join(""); $("#conversation").innerHTML = `
    @@ -94,9 +98,7 @@ function renderSummary(result) {
    核心理解

    它是什么,为什么值得了解

    ${escapeHtml(result.definition)}

    马上上手
    - - - + ${quickQuestions}
    `; showView("conversation"); diff --git a/server.mjs b/server.mjs index 2ea8fc5..2cfbee4 100644 --- a/server.mjs +++ b/server.mjs @@ -309,12 +309,48 @@ const exampleLibrary = new Map([ ["ai 智能体", ["做一个只读日程助手:它读取今天的日程,判断冲突,再给出调整建议;真正改动日程前必须请用户确认。", "观察:读取日程", "判断:识别冲突", "行动:确认后再修改"]] ]); +const sourceIntents = [ + { question: /怎么用|如何用|如何使用|应用|用途|上手/, facts: /应用|用于|用来|领域|技术|实践/, title: "来源中的应用" }, + { question: /例子|示例|案例|举例/, facts: /例如|比如|包括|像|譬如|举例/, title: "来源中的例子" }, + { question: /历史|起源|发明|提出|发现|谁|何时|哪年/, facts: /年|世纪|提出|发现|发明|创立|发展|历史/, title: "背景与历史" }, + { question: /原理|机制|为什么|如何工作|怎么工作/, facts: /由于|通过|因此|原理|机制|作用|导致|关系/, title: "原理与机制" }, + { question: /限制|风险|缺点|争议|问题|误区/, facts: /限制|风险|缺点|争议|问题|误解|错误/, title: "边界与限制" }, + { question: /简单|没懂|解释|白话|是什么|什么意思/, title: "根据来源解释", start: 0 }, + { question: /更多|详细|深入|背景|继续/, title: "进一步理解", start: 2 } +]; + +function sourceFollowUp(question, context) { + const excerpts = Array.isArray(context?.source?.excerpts) + ? context.source.excerpts + .filter((item) => typeof item === "string") + .map((item) => item.trim().slice(0, 280)) + .filter(Boolean) + .slice(0, 6) + : []; + if (context?.source?.provider !== "维基百科" || !excerpts.length) return null; + + const intent = sourceIntents.find((item) => item.question.test(question)); + const matches = intent?.facts + ? excerpts.filter((item) => intent.facts.test(item)) + : intent ? excerpts.slice(intent.start ?? 0) : []; + if (!intent || !matches.length) { + return { + title: "现有资料还不够", + text: `当前来源摘要没有足够信息可靠回答“${question}”。我不会根据缺失内容补写结论。`, + bullets: excerpts.slice(0, 2).map((item) => `已确认:${item}`) + }; + } + return { title: intent.title, text: matches[0], bullets: matches.slice(1, 4) }; +} + function followUpResult(question, context) { const topic = context?.title || "这个主题"; const concepts = Array.isArray(context?.concepts) ? context.concepts : []; const steps = Array.isArray(context?.steps) ? context.steps : []; const takeaways = Array.isArray(context?.takeaways) ? context.takeaways : []; const normalized = question.toLowerCase(); + const sourced = sourceFollowUp(question, context); + if (sourced) return sourced; if (/怎么用|如何用|如何使用|上手|开始/.test(normalized)) { return { diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index 31a3d58..038fda8 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -87,6 +87,32 @@ test("answers usage and example follow-ups", async () => { assert.match(example.text, /MCP Server/); }); +test("grounds Wikipedia follow-ups in bounded source excerpts", async () => { + const context = { + title: "量子纠缠", + source: { + provider: "维基百科", + excerpts: [ + "量子纠缠是多个量子系统之间无法独立描述的关联现象。", + "量子纠缠可以用于量子通信、量子计算和量子密码学等技术领域。", + "例如,纠缠光子可以用来检验两个测量结果之间的统计关联。" + ] + } + }; + + const usage = await post("/api/ask", { question: "它有哪些应用?", context }); + assert.equal(usage.title, "来源中的应用"); + assert.match(usage.text, /量子通信/); + + const example = await post("/api/ask", { question: "再给我一个例子", context }); + assert.equal(example.title, "来源中的例子"); + assert.match(example.text, /纠缠光子/); + + const unsupported = await post("/api/ask", { question: "它在火星上的售价是多少?", context }); + assert.equal(unsupported.title, "现有资料还不够"); + assert.match(unsupported.text, /不会根据缺失内容补写结论/); +}); + test("does not fetch non-public network pages", async () => { const blockedUrls = [ "http://127.0.0.1:4173", diff --git a/test/wikipedia.test.mjs b/test/wikipedia.test.mjs index 266cf81..63eb1d1 100644 --- a/test/wikipedia.test.mjs +++ b/test/wikipedia.test.mjs @@ -37,6 +37,8 @@ test("builds a sourced learning result from the most relevant page", async () => assert.equal(result.steps.length, 3); assert.equal(result.source.provider, "维基百科"); assert.equal(result.source.license, "CC BY-SA 4.0"); + assert.ok(result.source.excerpts.length >= 3); + assert.ok(result.source.excerpts.every((item) => item.length <= 280)); assert.match(result.source.url, /%E9%87%8F%E5%AD%90%E7%B3%BE%E7%BA%8F/); assert.equal(requestedUrl.searchParams.get("gsrsearch"), "量子纠缠"); assert.equal(requestedUrl.searchParams.get("gsrnamespace"), "0"); diff --git a/wikipedia.mjs b/wikipedia.mjs index 11eec43..7a88e79 100644 --- a/wikipedia.mjs +++ b/wikipedia.mjs @@ -79,7 +79,8 @@ function learningResult(page) { host: "zh.wikipedia.org", title: page.title, provider: "维基百科", - license: "CC BY-SA 4.0" + license: "CC BY-SA 4.0", + excerpts: facts.map((item) => item.slice(0, 280)).slice(0, 6) } }; }