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);
+ }
+}