From c48aa1f9fcc99f482f3ae869fb2de51af69e9fa2 Mon Sep 17 00:00:00 2001 From: fly1d <309400591+fly1d@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:06:48 +0800 Subject: [PATCH] feat: ground unknown term summaries --- README.md | 6 +- app.js | 3 +- package.json | 2 +- server.mjs | 13 +++- test/wikipedia.test.mjs | 84 +++++++++++++++++++++++++ wikipedia.mjs | 135 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 test/wikipedia.test.mjs create mode 100644 wikipedia.mjs diff --git a/README.md b/README.md index 7968cfe..1e19015 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## 功能 -- 精准名词直接生成快速总结。 +- 精准名词直接生成快速总结;未知名词会尝试从中文维基百科获取有来源的入门资料。 - 模糊名词先澄清含义,再按所选方向学习。 - 公开网页提取标题、摘要、章节与正文要点。 - 围绕当前主题继续询问用法、示例和误区。 @@ -18,7 +18,7 @@ npm start 打开 `http://127.0.0.1:4173`。 -网页分析只允许公开的 HTTP/HTTPS 地址,并限制抓取时间和正文大小。 +网页分析只允许公开的 HTTP/HTTPS 地址,并限制抓取时间和正文大小。未知名词查询会将该名词发送给中文维基百科;返回的百科摘要带原文链接和 CC BY-SA 4.0 标识。查询超时、失败或没有可靠结果时会回退到本地学习模板。 ## 质量检查 @@ -34,4 +34,4 @@ npm run ci - 只想在浏览器所有页面中唤起:适合做浏览器扩展。 - 希望跨应用悬浮、置顶、托盘常驻或用全局快捷键唤起:需要桌面客户端,建议使用 Tauri 封装当前界面。 -当前轻量版的名词澄清与追问使用本地知识和规则,不需要模型密钥。若要对任意陌生领域进行更深入的自由问答,可以在 `/api/analyze` 和 `/api/ask` 后接模型服务。 +当前轻量版的内置主题、澄清和追问使用本地知识与规则,未知名词通过中文维基百科补充公开资料,不需要模型密钥。若要对任意陌生领域进行更深入的自由问答,可以在 `/api/analyze` 和 `/api/ask` 后接模型服务。 diff --git a/app.js b/app.js index 900df09..fc2b3dd 100644 --- a/app.js +++ b/app.js @@ -81,7 +81,8 @@ function renderClarification(result) { } function renderSummary(result) { - const source = result.source ? `` : ""; + const sourceName = result.source ? [result.source.provider, result.source.license].filter(Boolean).join(" · ") || "原文" : ""; + 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(""); diff --git a/package.json b/package.json index bd4d912..fe55368 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "node server.mjs", "dev": "node --watch server.mjs", - "lint": "node --check server.mjs && node --check url-fetch.mjs && node --check app.js", + "lint": "node --check server.mjs && node --check url-fetch.mjs && node --check wikipedia.mjs && node --check app.js", "test": "node --test test/*.test.mjs", "smoke": "npm test", "ci": "npm run lint && npm run smoke" diff --git a/server.mjs b/server.mjs index 13e0437..2ea8fc5 100644 --- a/server.mjs +++ b/server.mjs @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import { extname, join, normalize } from "node:path"; import { fileURLToPath } from "node:url"; import { fetchPage } from "./url-fetch.mjs"; +import { lookupWikipedia } from "./wikipedia.mjs"; const root = fileURLToPath(new URL(".", import.meta.url)); const port = Number(process.env.PORT || 4173); @@ -203,7 +204,7 @@ const topicProfiles = new Map([ }] ]); -function termResult(input) { +async function termResult(input) { const normalized = input.toLowerCase().trim(); const found = knowledge.find((item) => item.match.some((key) => normalized === key || normalized.includes(key))); if (found) { @@ -211,7 +212,10 @@ function termResult(input) { return result; } const profile = topicProfiles.get(normalized); - return profile ? { ...genericResult(input), ...profile } : genericResult(input); + if (profile) return { ...genericResult(input), ...profile }; + const wikipedia = await lookupWikipedia(input); + if (!wikipedia || wikipedia.needsClarification) return wikipedia || genericResult(input); + return { ...genericResult(input), ...wikipedia }; } async function urlResult(input) { @@ -285,7 +289,10 @@ async function handleAnalyze(req, res) { if (clarification) { return sendJson(res, 200, { needsClarification: true, input: value, ...clarification }); } - const result = isUrl ? await urlResult(value) : termResult(value); + const result = isUrl ? await urlResult(value) : await termResult(value); + if (result.needsClarification) { + return sendJson(res, 200, { input: value, ...result }); + } sendJson(res, 200, { ...result, input: value, kind: isUrl ? "url" : "term", createdAt: new Date().toISOString() }); } catch (error) { sendJson(res, 400, { error: error.message || "分析失败,请稍后重试" }); diff --git a/test/wikipedia.test.mjs b/test/wikipedia.test.mjs new file mode 100644 index 0000000..266cf81 --- /dev/null +++ b/test/wikipedia.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { lookupWikipedia } from "../wikipedia.mjs"; + +function responseWith(data, ok = true) { + return { ok, json: async () => data }; +} + +test("builds a sourced learning result from the most relevant page", async () => { + let requestedUrl; + let requestedOptions; + const fetchImpl = async (url, options) => { + requestedUrl = url; + requestedOptions = options; + return responseWith({ + query: { + pages: [ + { ns: 0, index: 2, title: "量子力学", extract: "量子力学是研究微观世界的基础理论。它包含多个重要分支。" }, + { + ns: 0, + index: 1, + title: "量子糾纏", + description: "量子力學概念", + extract: "量子糾纏是多个量子系统之间无法独立描述的关联现象。它只发生在量子系统中,在经典力学中没有直接对应现象。测量其中一个系统时,可以观察到与另一个系统相关的统计结果。" + } + ] + } + }); + }; + + const result = await lookupWikipedia("量子纠缠", { fetchImpl }); + + assert.equal(result.title, "量子糾纏"); + assert.equal(result.category, "维基百科速览"); + assert.match(result.summary, /无法独立描述/); + assert.equal(result.concepts.length, 3); + assert.equal(result.steps.length, 3); + assert.equal(result.source.provider, "维基百科"); + assert.equal(result.source.license, "CC BY-SA 4.0"); + 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"); + assert.match(requestedOptions.headers["User-Agent"], /quicklearn-agent/); +}); + +test("asks for clarification when search results contain a likely disambiguation", async () => { + const fetchImpl = async () => responseWith({ + query: { + pages: [ + { ns: 0, index: 4, title: "二甲基汞", description: "化合物", extract: "二甲基汞是一种含汞的有机化合物,具有很强的毒性。" }, + { ns: 0, index: 1, title: "Mercury", description: "维基媒体消歧义页", pageprops: { disambiguation: "" }, extract: "Mercury可以指多个不同主题。" }, + { ns: 0, index: 3, title: "弗雷迪·默丘里", description: "英国歌手", extract: "弗雷迪·默丘里是英国摇滚乐队皇后乐队的主唱。" }, + { ns: 0, index: 2, title: "水星", description: "距离太阳最近的行星", extract: "水星是太阳系八大行星中距离太阳最近且最小的一颗行星。" } + ] + } + }); + + const result = await lookupWikipedia("Mercury", { fetchImpl }); + + assert.equal(result.needsClarification, true); + assert.match(result.question, /哪一个/); + assert.deepEqual(result.options, ["水星", "弗雷迪·默丘里", "二甲基汞"]); +}); + +test("returns null for API errors, malformed data, and no useful pages", async () => { + const cases = [ + async () => responseWith({}, false), + async () => responseWith({ query: { pages: "invalid" } }), + async () => responseWith({ query: { pages: [{ ns: 1, index: 1, title: "Talk:主题" }] } }), + async () => { throw new Error("network unavailable"); } + ]; + + for (const fetchImpl of cases) { + assert.equal(await lookupWikipedia("未知主题", { fetchImpl }), null); + } +}); + +test("aborts slow lookups and falls back", async () => { + const fetchImpl = async (_url, { signal }) => new Promise((resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + + assert.equal(await lookupWikipedia("超时主题", { fetchImpl, timeoutMs: 5 }), null); +}); diff --git a/wikipedia.mjs b/wikipedia.mjs new file mode 100644 index 0000000..11eec43 --- /dev/null +++ b/wikipedia.mjs @@ -0,0 +1,135 @@ +const endpoint = "https://zh.wikipedia.org/w/api.php"; +const userAgent = "QuickLearn/1.0 (https://github.com/fly1d/quicklearn-agent)"; + +function cleanText(value = "") { + return String(value).replace(/\s+/g, " ").trim(); +} + +function sentenceList(value, limit = 6) { + return cleanText(value) + .split(/(?<=[。!?.!?])\s*/) + .map((item) => item.trim()) + .filter((item) => item.length >= 12) + .slice(0, limit); +} + +function normalizeTerm(value = "") { + return value.toLowerCase().replace(/[\s_()()·.-]+/g, ""); +} + +function isDisambiguation(page) { + return Object.hasOwn(page.pageprops || {}, "disambiguation") || /消歧[义義][页頁]/.test(page.description || ""); +} + +function sourceUrl(title) { + return `https://zh.wikipedia.org/wiki/${encodeURIComponent(title.replace(/\s+/g, "_"))}`; +} + +function clarificationResult(input, pages) { + const top = pages[0]; + const disambiguation = pages.find(isDisambiguation); + if (!top || !disambiguation) return null; + + const exactDisambiguation = normalizeTerm(disambiguation.title) === normalizeTerm(input); + const topIsExact = normalizeTerm(top.title) === normalizeTerm(input); + const likelyAmbiguous = top === disambiguation || exactDisambiguation || (!topIsExact && disambiguation.index <= 2); + if (!likelyAmbiguous) return null; + + const options = [...new Set(pages + .filter((page) => !isDisambiguation(page) && !/列表条目|列表條目/.test(page.description || "")) + .map((page) => page.title))] + .slice(0, 3); + if (options.length < 2) return null; + + return { + needsClarification: true, + question: `“${input}”可能有几种意思,你想了解哪一个?`, + options + }; +} + +function learningResult(page) { + const facts = sentenceList(page.extract); + const description = cleanText(page.description); + const summary = (facts.slice(0, 2).join(" ") || description).slice(0, 360); + const details = [ + facts[0] || description || `这是关于 ${page.title} 的百科条目。`, + facts[1] || `${page.title} 的背景和适用语境值得结合原文继续确认。`, + facts[2] || `可以通过相近概念和实际例子进一步理解 ${page.title}。` + ]; + const takeaways = facts.slice(0, 3).map((item) => item.slice(0, 92)); + while (takeaways.length < 3) { + takeaways.push(["先掌握准确定义", "再确认背景与边界", "用例子验证理解"][takeaways.length]); + } + + return { + title: page.title, + category: "维基百科速览", + summary, + definition: details[0], + why: description + ? `${page.title}通常被概括为“${description}”。理解它有助于建立相关主题的基础背景和概念边界。` + : `理解 ${page.title} 的定义、背景与边界,可以为继续阅读专业资料建立稳定起点。`, + concepts: [["基本定义", details[0]], ["关键背景", details[1]], ["延伸理解", details[2]]], + takeaways, + misconception: "百科摘要适合建立第一层认识,但条目可能持续更新;涉及专业判断时仍应核对原始资料。", + steps: [`用一句话复述 ${page.title}`, `区分 ${page.title} 与相近概念`, "打开来源,重点查看定义、背景和示例"], + source: { + url: sourceUrl(page.title), + host: "zh.wikipedia.org", + title: page.title, + provider: "维基百科", + license: "CC BY-SA 4.0" + } + }; +} + +export async function lookupWikipedia(input, options = {}) { + const query = input.trim(); + if (!query || query.length > 120) return null; + + const fetchImpl = options.fetchImpl || fetch; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 3500); + timeout.unref?.(); + + try { + const url = new URL(endpoint); + url.search = new URLSearchParams({ + action: "query", + generator: "search", + gsrsearch: query, + gsrnamespace: "0", + gsrlimit: "6", + prop: "extracts|description|pageprops", + exintro: "1", + explaintext: "1", + exsentences: "6", + ppprop: "disambiguation", + redirects: "1", + format: "json", + formatversion: "2" + }); + const response = await fetchImpl(url, { + signal: controller.signal, + headers: { Accept: "application/json", "User-Agent": userAgent } + }); + if (!response.ok) return null; + const data = await response.json(); + const pages = Array.isArray(data?.query?.pages) + ? data.query.pages + .filter((page) => page?.ns === 0 && typeof page.title === "string") + .sort((a, b) => (a.index ?? Number.MAX_SAFE_INTEGER) - (b.index ?? Number.MAX_SAFE_INTEGER)) + : []; + if (!pages.length) return null; + + const clarification = clarificationResult(query, pages); + if (clarification) return clarification; + const page = pages.find((item) => !isDisambiguation(item) && cleanText(item.extract).length >= 30); + return page ? learningResult(page) : null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +}