Skip to content

Commit e6d5d9d

Browse files
authored
feat: ground unknown term summaries (#7)
## What changed - Query the Chinese Wikipedia MediaWiki API when an unknown term is not covered by built-in knowledge. - Turn the most relevant result into a sourced quick summary, three concepts, takeaways, and learning steps. - Detect likely disambiguation pages and ask the user to choose among up to three meanings. - Fall back to the existing local template after timeout, API failure, malformed data, or no useful result. - Document the external term-query data flow and expose Wikipedia / CC BY-SA attribution on the source control. Closes #6 ## Risk - [ ] Low: copy, styles, documentation, or isolated cleanup - [ ] Medium: interaction, API response, or shared behavior - [x] High: fetching, security boundary, deployment, or data compatibility ## Verification - [x] `npm run ci` passes locally - [x] I tested the main user path affected by this change - [ ] I checked desktop/mobile layout when UI changed - [x] I added or updated tests for behavior changes - [x] I documented anything intentionally not tested Verification notes: All 15 local checks pass. Real API and service-level smoke tests covered `量子纠缠` as a sourced result, `Mercury` as a dynamic clarification, and `MCP` as an unchanged built-in result. CI uses mocked MediaWiki responses and does not require external network access. The only UI change is source tooltip/accessibility metadata; layout is unchanged. ## Review and release - [x] The diff is focused and contains no unrelated changes - [x] Error, empty, loading, and recovery states were considered - [x] Security and privacy impact was considered - [x] Rollback is understood Rollback plan: Revert this PR. Unknown terms will return to the existing local generic template; no data migration or dependency rollback is required. Co-authored-by: fly1d <309400591+fly1d@users.noreply.github.com>
1 parent 9c041a7 commit e6d5d9d

6 files changed

Lines changed: 235 additions & 8 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
## 功能
66

7-
- 精准名词直接生成快速总结。
7+
- 精准名词直接生成快速总结;未知名词会尝试从中文维基百科获取有来源的入门资料
88
- 模糊名词先澄清含义,再按所选方向学习。
99
- 公开网页提取标题、摘要、章节与正文要点。
1010
- 围绕当前主题继续询问用法、示例和误区。
@@ -18,7 +18,7 @@ npm start
1818

1919
打开 `http://127.0.0.1:4173`
2020

21-
网页分析只允许公开的 HTTP/HTTPS 地址,并限制抓取时间和正文大小。
21+
网页分析只允许公开的 HTTP/HTTPS 地址,并限制抓取时间和正文大小。未知名词查询会将该名词发送给中文维基百科;返回的百科摘要带原文链接和 CC BY-SA 4.0 标识。查询超时、失败或没有可靠结果时会回退到本地学习模板。
2222

2323
## 质量检查
2424

@@ -34,4 +34,4 @@ npm run ci
3434
- 只想在浏览器所有页面中唤起:适合做浏览器扩展。
3535
- 希望跨应用悬浮、置顶、托盘常驻或用全局快捷键唤起:需要桌面客户端,建议使用 Tauri 封装当前界面。
3636

37-
当前轻量版的名词澄清与追问使用本地知识和规则,不需要模型密钥。若要对任意陌生领域进行更深入的自由问答,可以在 `/api/analyze``/api/ask` 后接模型服务。
37+
当前轻量版的内置主题、澄清和追问使用本地知识与规则,未知名词通过中文维基百科补充公开资料,不需要模型密钥。若要对任意陌生领域进行更深入的自由问答,可以在 `/api/analyze``/api/ask` 后接模型服务。

app.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ function renderClarification(result) {
8181
}
8282

8383
function renderSummary(result) {
84-
const source = result.source ? `<a class="source-button" href="${escapeHtml(result.source.url)}" target="_blank" rel="noreferrer" aria-label="打开原文" title="打开原文"><i data-lucide="external-link"></i></a>` : "";
84+
const sourceName = result.source ? [result.source.provider, result.source.license].filter(Boolean).join(" · ") || "原文" : "";
85+
const source = result.source ? `<a class="source-button" href="${escapeHtml(result.source.url)}" target="_blank" rel="noreferrer" aria-label="打开来源:${escapeHtml(sourceName)}" title="${escapeHtml(sourceName)}"><i data-lucide="external-link"></i></a>` : "";
8586
const concepts = result.concepts.slice(0, 3).map(([name, detail], index) => `<li data-index="${String(index + 1).padStart(2, "0")}"><span><strong>${escapeHtml(name)}</strong> · ${escapeHtml(detail)}</span></li>`).join("");
8687
const steps = result.steps.slice(0, 3).map((step, index) => `<li><span>${index + 1}</span>${escapeHtml(step)}</li>`).join("");
8788

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"scripts": {
77
"start": "node server.mjs",
88
"dev": "node --watch server.mjs",
9-
"lint": "node --check server.mjs && node --check url-fetch.mjs && node --check app.js",
9+
"lint": "node --check server.mjs && node --check url-fetch.mjs && node --check wikipedia.mjs && node --check app.js",
1010
"test": "node --test test/*.test.mjs",
1111
"smoke": "npm test",
1212
"ci": "npm run lint && npm run smoke"

server.mjs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
33
import { extname, join, normalize } from "node:path";
44
import { fileURLToPath } from "node:url";
55
import { fetchPage } from "./url-fetch.mjs";
6+
import { lookupWikipedia } from "./wikipedia.mjs";
67

78
const root = fileURLToPath(new URL(".", import.meta.url));
89
const port = Number(process.env.PORT || 4173);
@@ -203,15 +204,18 @@ const topicProfiles = new Map([
203204
}]
204205
]);
205206

206-
function termResult(input) {
207+
async function termResult(input) {
207208
const normalized = input.toLowerCase().trim();
208209
const found = knowledge.find((item) => item.match.some((key) => normalized === key || normalized.includes(key)));
209210
if (found) {
210211
const { match, ...result } = found;
211212
return result;
212213
}
213214
const profile = topicProfiles.get(normalized);
214-
return profile ? { ...genericResult(input), ...profile } : genericResult(input);
215+
if (profile) return { ...genericResult(input), ...profile };
216+
const wikipedia = await lookupWikipedia(input);
217+
if (!wikipedia || wikipedia.needsClarification) return wikipedia || genericResult(input);
218+
return { ...genericResult(input), ...wikipedia };
215219
}
216220

217221
async function urlResult(input) {
@@ -285,7 +289,10 @@ async function handleAnalyze(req, res) {
285289
if (clarification) {
286290
return sendJson(res, 200, { needsClarification: true, input: value, ...clarification });
287291
}
288-
const result = isUrl ? await urlResult(value) : termResult(value);
292+
const result = isUrl ? await urlResult(value) : await termResult(value);
293+
if (result.needsClarification) {
294+
return sendJson(res, 200, { input: value, ...result });
295+
}
289296
sendJson(res, 200, { ...result, input: value, kind: isUrl ? "url" : "term", createdAt: new Date().toISOString() });
290297
} catch (error) {
291298
sendJson(res, 400, { error: error.message || "分析失败,请稍后重试" });

test/wikipedia.test.mjs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { lookupWikipedia } from "../wikipedia.mjs";
4+
5+
function responseWith(data, ok = true) {
6+
return { ok, json: async () => data };
7+
}
8+
9+
test("builds a sourced learning result from the most relevant page", async () => {
10+
let requestedUrl;
11+
let requestedOptions;
12+
const fetchImpl = async (url, options) => {
13+
requestedUrl = url;
14+
requestedOptions = options;
15+
return responseWith({
16+
query: {
17+
pages: [
18+
{ ns: 0, index: 2, title: "量子力学", extract: "量子力学是研究微观世界的基础理论。它包含多个重要分支。" },
19+
{
20+
ns: 0,
21+
index: 1,
22+
title: "量子糾纏",
23+
description: "量子力學概念",
24+
extract: "量子糾纏是多个量子系统之间无法独立描述的关联现象。它只发生在量子系统中,在经典力学中没有直接对应现象。测量其中一个系统时,可以观察到与另一个系统相关的统计结果。"
25+
}
26+
]
27+
}
28+
});
29+
};
30+
31+
const result = await lookupWikipedia("量子纠缠", { fetchImpl });
32+
33+
assert.equal(result.title, "量子糾纏");
34+
assert.equal(result.category, "维基百科速览");
35+
assert.match(result.summary, //);
36+
assert.equal(result.concepts.length, 3);
37+
assert.equal(result.steps.length, 3);
38+
assert.equal(result.source.provider, "维基百科");
39+
assert.equal(result.source.license, "CC BY-SA 4.0");
40+
assert.match(result.source.url, /%E9%87%8F%E5%AD%90%E7%B3%BE%E7%BA%8F/);
41+
assert.equal(requestedUrl.searchParams.get("gsrsearch"), "量子纠缠");
42+
assert.equal(requestedUrl.searchParams.get("gsrnamespace"), "0");
43+
assert.match(requestedOptions.headers["User-Agent"], /quicklearn-agent/);
44+
});
45+
46+
test("asks for clarification when search results contain a likely disambiguation", async () => {
47+
const fetchImpl = async () => responseWith({
48+
query: {
49+
pages: [
50+
{ ns: 0, index: 4, title: "二甲基汞", description: "化合物", extract: "二甲基汞是一种含汞的有机化合物,具有很强的毒性。" },
51+
{ ns: 0, index: 1, title: "Mercury", description: "维基媒体消歧义页", pageprops: { disambiguation: "" }, extract: "Mercury可以指多个不同主题。" },
52+
{ ns: 0, index: 3, title: "弗雷迪·默丘里", description: "英国歌手", extract: "弗雷迪·默丘里是英国摇滚乐队皇后乐队的主唱。" },
53+
{ ns: 0, index: 2, title: "水星", description: "距离太阳最近的行星", extract: "水星是太阳系八大行星中距离太阳最近且最小的一颗行星。" }
54+
]
55+
}
56+
});
57+
58+
const result = await lookupWikipedia("Mercury", { fetchImpl });
59+
60+
assert.equal(result.needsClarification, true);
61+
assert.match(result.question, //);
62+
assert.deepEqual(result.options, ["水星", "弗雷迪·默丘里", "二甲基汞"]);
63+
});
64+
65+
test("returns null for API errors, malformed data, and no useful pages", async () => {
66+
const cases = [
67+
async () => responseWith({}, false),
68+
async () => responseWith({ query: { pages: "invalid" } }),
69+
async () => responseWith({ query: { pages: [{ ns: 1, index: 1, title: "Talk:主题" }] } }),
70+
async () => { throw new Error("network unavailable"); }
71+
];
72+
73+
for (const fetchImpl of cases) {
74+
assert.equal(await lookupWikipedia("未知主题", { fetchImpl }), null);
75+
}
76+
});
77+
78+
test("aborts slow lookups and falls back", async () => {
79+
const fetchImpl = async (_url, { signal }) => new Promise((resolve, reject) => {
80+
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
81+
});
82+
83+
assert.equal(await lookupWikipedia("超时主题", { fetchImpl, timeoutMs: 5 }), null);
84+
});

wikipedia.mjs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
const endpoint = "https://zh.wikipedia.org/w/api.php";
2+
const userAgent = "QuickLearn/1.0 (https://github.com/fly1d/quicklearn-agent)";
3+
4+
function cleanText(value = "") {
5+
return String(value).replace(/\s+/g, " ").trim();
6+
}
7+
8+
function sentenceList(value, limit = 6) {
9+
return cleanText(value)
10+
.split(/(?<=[.!?])\s*/)
11+
.map((item) => item.trim())
12+
.filter((item) => item.length >= 12)
13+
.slice(0, limit);
14+
}
15+
16+
function normalizeTerm(value = "") {
17+
return value.toLowerCase().replace(/[\s_()·.-]+/g, "");
18+
}
19+
20+
function isDisambiguation(page) {
21+
return Object.hasOwn(page.pageprops || {}, "disambiguation") || /[][]/.test(page.description || "");
22+
}
23+
24+
function sourceUrl(title) {
25+
return `https://zh.wikipedia.org/wiki/${encodeURIComponent(title.replace(/\s+/g, "_"))}`;
26+
}
27+
28+
function clarificationResult(input, pages) {
29+
const top = pages[0];
30+
const disambiguation = pages.find(isDisambiguation);
31+
if (!top || !disambiguation) return null;
32+
33+
const exactDisambiguation = normalizeTerm(disambiguation.title) === normalizeTerm(input);
34+
const topIsExact = normalizeTerm(top.title) === normalizeTerm(input);
35+
const likelyAmbiguous = top === disambiguation || exactDisambiguation || (!topIsExact && disambiguation.index <= 2);
36+
if (!likelyAmbiguous) return null;
37+
38+
const options = [...new Set(pages
39+
.filter((page) => !isDisambiguation(page) && !/|/.test(page.description || ""))
40+
.map((page) => page.title))]
41+
.slice(0, 3);
42+
if (options.length < 2) return null;
43+
44+
return {
45+
needsClarification: true,
46+
question: `“${input}”可能有几种意思,你想了解哪一个?`,
47+
options
48+
};
49+
}
50+
51+
function learningResult(page) {
52+
const facts = sentenceList(page.extract);
53+
const description = cleanText(page.description);
54+
const summary = (facts.slice(0, 2).join(" ") || description).slice(0, 360);
55+
const details = [
56+
facts[0] || description || `这是关于 ${page.title} 的百科条目。`,
57+
facts[1] || `${page.title} 的背景和适用语境值得结合原文继续确认。`,
58+
facts[2] || `可以通过相近概念和实际例子进一步理解 ${page.title}。`
59+
];
60+
const takeaways = facts.slice(0, 3).map((item) => item.slice(0, 92));
61+
while (takeaways.length < 3) {
62+
takeaways.push(["先掌握准确定义", "再确认背景与边界", "用例子验证理解"][takeaways.length]);
63+
}
64+
65+
return {
66+
title: page.title,
67+
category: "维基百科速览",
68+
summary,
69+
definition: details[0],
70+
why: description
71+
? `${page.title}通常被概括为“${description}”。理解它有助于建立相关主题的基础背景和概念边界。`
72+
: `理解 ${page.title} 的定义、背景与边界,可以为继续阅读专业资料建立稳定起点。`,
73+
concepts: [["基本定义", details[0]], ["关键背景", details[1]], ["延伸理解", details[2]]],
74+
takeaways,
75+
misconception: "百科摘要适合建立第一层认识,但条目可能持续更新;涉及专业判断时仍应核对原始资料。",
76+
steps: [`用一句话复述 ${page.title}`, `区分 ${page.title} 与相近概念`, "打开来源,重点查看定义、背景和示例"],
77+
source: {
78+
url: sourceUrl(page.title),
79+
host: "zh.wikipedia.org",
80+
title: page.title,
81+
provider: "维基百科",
82+
license: "CC BY-SA 4.0"
83+
}
84+
};
85+
}
86+
87+
export async function lookupWikipedia(input, options = {}) {
88+
const query = input.trim();
89+
if (!query || query.length > 120) return null;
90+
91+
const fetchImpl = options.fetchImpl || fetch;
92+
const controller = new AbortController();
93+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 3500);
94+
timeout.unref?.();
95+
96+
try {
97+
const url = new URL(endpoint);
98+
url.search = new URLSearchParams({
99+
action: "query",
100+
generator: "search",
101+
gsrsearch: query,
102+
gsrnamespace: "0",
103+
gsrlimit: "6",
104+
prop: "extracts|description|pageprops",
105+
exintro: "1",
106+
explaintext: "1",
107+
exsentences: "6",
108+
ppprop: "disambiguation",
109+
redirects: "1",
110+
format: "json",
111+
formatversion: "2"
112+
});
113+
const response = await fetchImpl(url, {
114+
signal: controller.signal,
115+
headers: { Accept: "application/json", "User-Agent": userAgent }
116+
});
117+
if (!response.ok) return null;
118+
const data = await response.json();
119+
const pages = Array.isArray(data?.query?.pages)
120+
? data.query.pages
121+
.filter((page) => page?.ns === 0 && typeof page.title === "string")
122+
.sort((a, b) => (a.index ?? Number.MAX_SAFE_INTEGER) - (b.index ?? Number.MAX_SAFE_INTEGER))
123+
: [];
124+
if (!pages.length) return null;
125+
126+
const clarification = clarificationResult(query, pages);
127+
if (clarification) return clarification;
128+
const page = pages.find((item) => !isDisambiguation(item) && cleanText(item.extract).length >= 30);
129+
return page ? learningResult(page) : null;
130+
} catch {
131+
return null;
132+
} finally {
133+
clearTimeout(timeout);
134+
}
135+
}

0 commit comments

Comments
 (0)