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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## 功能

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

打开 `http://127.0.0.1:4173`。

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

## 质量检查

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

当前轻量版的名词澄清与追问使用本地知识和规则,不需要模型密钥。若要对任意陌生领域进行更深入的自由问答,可以在 `/api/analyze` 和 `/api/ask` 后接模型服务。
当前轻量版的内置主题、澄清和追问使用本地知识与规则,未知名词通过中文维基百科补充公开资料,不需要模型密钥。若要对任意陌生领域进行更深入的自由问答,可以在 `/api/analyze` 和 `/api/ask` 后接模型服务。
3 changes: 2 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ function renderClarification(result) {
}

function renderSummary(result) {
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>` : "";
const sourceName = result.source ? [result.source.provider, result.source.license].filter(Boolean).join(" · ") || "原文" : "";
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>` : "";
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("");
const steps = result.steps.slice(0, 3).map((step, index) => `<li><span>${index + 1}</span>${escapeHtml(step)}</li>`).join("");

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 10 additions & 3 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -203,15 +204,18 @@ 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) {
const { match, ...result } = found;
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) {
Expand Down Expand Up @@ -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 || "分析失败,请稍后重试" });
Expand Down
84 changes: 84 additions & 0 deletions test/wikipedia.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
135 changes: 135 additions & 0 deletions wikipedia.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
}