local-knowledge-graph 1.10.4 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HELP.md CHANGED
@@ -68,6 +68,10 @@ kg
68
68
 
69
69
  Key 与 Base URL 仅存本地数据目录的 settings.json,服务端永不回传真实值。不配置则自动退化为关键词检索,其余功能不受影响。改配置后需重新「构建全量向量」才能让新模型生效于语义检索。
70
70
  - **OpenCode(AI 对话建图 / 智能提问编译)**:使用 opencode CLI 自身的认证(`opencode auth login`),应用不经手密钥。未安装或未认证时仅这两个功能降级。
71
+ - **一键安装**:未检测到 opencode 时,「检索」页签会出现"OpenCode 一键安装"卡片(点击顶部"OpenCode 未安装"徽章也可直达),一键通过 npm 全局安装 opencode-ai(首次需下载较大二进制,约 1-6 分钟,安装过程有实时日志与耗时显示)。安装中右侧 AI 对话会友好提示等待,完成后立即可用,无需重启本应用。
72
+ - **自动模型配置**:安装完成后自动写入 opencode 全局配置(`~/.config/opencode/opencode.json`)——优先保留已有认证/已设模型;其次接入本机 Ollama(127.0.0.1:11434)或 LM Studio(127.0.0.1:1234)已拉起的模型(自动过滤 embedding 模型);都没有时默认使用 OpenCode Zen 免费模型 `opencode/grok-code`(无需登录,可随时在 opencode 配置中更换)。
73
+ - 启动时会自动识别 opencode 的安装位置:PATH 扫描(兼容 Windows 的 npm `.cmd` 垫片,自动还原真实 exe)、`~/.opencode/bin`(官方脚本安装)、Homebrew / WinGet / Scoop、npm 全局目录。识别结果在启动日志与 `/api/meta` 的 `agent_path` 中可见。
74
+ - 特殊安装位置可用环境变量 `KG_OPENCODE_PATH` 直接指定可执行文件完整路径(优先级最高)。
71
75
  - 更换数据目录(`kg --data <目录>`)会使用该目录下的独立配置,互不串扰。
72
76
 
73
77
  ## 关系编辑与关系图片
@@ -78,6 +82,24 @@ kg
78
82
  - **AI 编辑**:外部 Agent 通过 MCP 的 `kg_apply_ops` 可对关系做增删改(支持 `confidence` 置信度、`evidence_ref` 来源引用、按名称引用端点实体);文档批量入图抽取出的关系同样带置信度与来源,经审核勾选后入库。AI 写入与人工修改互相实时可见。
79
83
  - **关系图片**:选中关系后点「绑图片」上传本地图片(支持多选,jpg/png/gif/webp/bmp,单张 ≤10MB,自动生成缩略图)。图片在关系信息卡缩略图网格展示,点击进入灯箱翻看,灯箱内可删除。典型用途:史料扫描件、聊天记录截图、地图/图表、新闻照片等佐证材料。图片文件存于数据目录 `data/uploads/r<关系id>/`,删除关系时其图片一并清理。
80
84
 
85
+ ## 关系属性与重复三元组
86
+
87
+ - **关系属性**:点选图上关系,信息卡"关系属性"框内以 JSON 编辑(如 `{"开始时间":"2024-01","weight":4}`),与置信度/来源引用一起保存;AI 建图(kg-ops 的 add_relation / update_relation)与三元组导入均支持 attributes
88
+ - **weight 明暗映射**:关系属性中的数值 `weight` 会映射到线的明暗(1 最暗,5 最亮),用于表达关系强弱;WebGL 线宽不可靠,故用明暗表达
89
+ - **方向箭头**:关系线在终点端画箭头指示方向;A→B 与 B→A 存在同名关系(互惠对)时两端各画一个箭头
90
+ - **重复三元组**:同一起点、同一终点、同一关系名的组合只能存在一条,重复添加返回 409;同名不同向(A→B 与 B→A)允许并存;导入时遇到重复自动跳过并在结果中计数
91
+
92
+ ## 三元组导入导出(JSON / CSV)
93
+
94
+ - **导出**:文件菜单 →「导出三元组 JSON / CSV」;CSV 为中文表头 `种类,名称,大类,起点,终点,关系,关系大类,置信度,来源引用,属性`,属性列为 JSON 对象文本(UTF-8 BOM,Excel 直接打开不乱码)
95
+ - **导入**:文件菜单 →「导入三元组」,选 .json 或 .csv;同名实体自动复用(不重复创建),重复关系自动跳过;缺失的起点/终点实体若在文件内有"实体"行定义则自动建好,否则该条关系报错跳过(其余行继续);CSV 可省略表头(按默认列序解析)
96
+ - **子图导出**:工具栏「多选」→ 点选节点 →「导出JSON/CSV」直接导出选区子图;或「提取子图」后用子图条上的导出按钮。API 方式:`GET /api/export/json?ids=1,2,3`
97
+ - **导入 API**:`POST /api/import/triples`,body `{content, format, dry_run}`,dry_run=true 只校验返回计数不写库
98
+
99
+ ## 图统计(统计页签)
100
+
101
+ - 总量、平均度、最高度;度分布直方图;枢纽节点 Top10(出度/入度、常涉关系,点击飞行定位);实体大类分布条形图
102
+
81
103
  ## MCP 外部接入(外部 Agent 读写图谱)
82
104
 
83
105
  在 **MCP 页签** 开启"MCP 服务"后,外部 Agent(Claude Desktop、Cursor、Cline、opencode 等)即可通过标准 MCP 协议对本图谱做完整增删改查:
@@ -126,16 +148,18 @@ kg
126
148
 
127
149
  ## 功能速览
128
150
 
129
- - **AI 对话建图**:右侧输入自然语言指令(联网补全),或上传文档(md/txt/pdf/docx)批量抽取三元组
151
+ - **AI 对话建图**:右侧输入自然语言指令(联网补全),或上传文档(md/txt/pdf/docx)批量抽取三元组;AI 输出不合规时自动按修复回执重试(最多 2 轮),仍失败则给出完整问题清单
130
152
  - **智能提问**:检索页签一句话找关系(LLM 编译检索计划,本地只读执行,可生成带引用的综述)
131
153
  - **关系推荐**:共同邻居算法找"该连没连"的实体对,AI 判断后一键采纳入库(检索页签"关系推荐"卡 / 实体卡"推荐关系"按钮)
132
154
  - **语义检索**:配置硅基流动 API Key 后支持语义+关键词混合检索(未配置时自动退化为关键词模式)
133
155
  - **最短路径 / 中心子图 / 推理**:挖掘实体间关联链路、层级范围与隐性关系(方法体系见上文"知识推理"章节)
134
156
  - **撤销**:Ctrl+Z 或按钮,可连续撤销
135
157
  - **浅色主题**:视图设置 → 界面主题 → 浅色,整站配色与 3D 画布同步切换,选择本地记忆
136
- - **保存点与回溯**:版本页签打保存点,随时回滚;每次保存点自动滚动备份到 `data/backups/`
158
+ - **保存点与回溯**:版本页签打保存点,随时回滚;勾选两个保存点可对比版本差异(Diff:实体/关系增删改与属性字段级变化);每次保存点自动滚动备份到 `data/backups/`
159
+ - **视图分享**:视图菜单"复制视图链接"生成 `#entity=5` / `#ego=5&depth=2` / `#path=6-13` 深链,打开即恢复同一视图;单文件网页同样支持 `#entity=`
160
+ - **演示数据**:命令行 `kg --demo` 一键体验(独立临时目录,不影响真实数据);或帮助面板"载入演示数据"灌入太阳系示例(先自动打保存点备份现有数据)
137
161
  - **图片绑定**:实体与关系信息卡上传本地图片(缩略图+灯箱,佐证材料留存)
138
- - **导出**:RDF(Turtle)、整库 .db、单文件只读网页版
162
+ - **导出**:RDF(Turtle)、整库 .db、单文件只读网页版、三元组 JSON/CSV
139
163
 
140
164
  ## 键盘快捷键
141
165
 
@@ -152,7 +176,7 @@ kg
152
176
  用现代浏览器(Chrome/Edge/Safari 最新版);确认服务已启动且端口未被占用。
153
177
 
154
178
  **Q:OpenCode 检测未就绪?**
155
- 运行 `npm i -g opencode-ai` 安装后重启应用;OpenCode 仅用于自然语言补全与智能提问的编译环节。
179
+ 运行 `npm i -g opencode-ai` 安装后重启应用;OpenCode 仅用于自然语言补全与智能提问的编译环节。也可直接在「检索」页签使用"OpenCode 一键安装"自动完成安装与模型配置。若已安装仍提示未检测到,可用环境变量 `KG_OPENCODE_PATH` 指定 opencode 可执行文件完整路径后重启。
156
180
 
157
181
  **Q:数据会上传到云端吗?**
158
182
  图谱数据只存在本机。仅当你在"检索"页签配置了语义检索 API Key 时,检索向量化的文本片段才会发往你配置的模型服务;OpenCode 补全公开知识时按其自身机制联网。其余全部本地完成。
@@ -165,6 +189,8 @@ kg
165
189
 
166
190
  ## 版本历史摘要
167
191
 
192
+ - **v1.12.0**:四项交互升级——①AI 修复回执:kg-ops 校验失败时返回结构化回执(规则码+失败原因+证据+修正建议),OpenCode 自动按回执修正重试(最多 2 轮),拦截时前端展示完整问题清单 ②保存点 Diff:版本页签勾选两个保存点即可对比差异(实体/关系的增/删/改,属性字段级 before→after)③URL 深链:`#entity=5`、`#ego=5&depth=2`、`#path=6-13` 直达视图,视图菜单"复制视图链接"一键分享(确定性布局保证两端构图一致,单文件网页同样支持 `#entity=`)④演示画廊:`kg --demo` 一键体验(一次性数据目录+太阳系示例图谱),帮助面板"载入演示数据"按钮灌入现有图谱(先自动打保存点);三元组导入新增别名列支持
193
+ - **v1.11.0**:九项图谱能力升级——①关系属性(JSON 编辑/kg-ops/导入导出全链路,weight 数值映射线明暗)②三元组 JSON/CSV 导入导出(中文表头、同名复用、重复去重、支持子图 ids 参数)③重复三元组校验(同起点+终点+名称返回 409,前端友好提示)④关系方向箭头与互惠对双箭头 ⑤图例点击显隐 + 属性条件过滤 + 检索结果全部高亮 ⑥统计页签(度分布/枢纽 Top10/大类分布)⑦多选模式与子图提取/导出 ⑧PNG 截图(2 倍分辨率)⑨单文件网页导出补齐关系属性/置信度/来源引用展示与平行边标签错开;OpenCode 识别增强(PATH 扫描/常见位置/npm 全局四级定位)与检索页签一键安装(npm 全局安装 + 自动模型配置:本机 Ollama/LM Studio 优先,否则 Zen 免费模型)
168
194
  - **v1.10.0**:关系推荐——共同邻居算法(Adamic-Adar 打分,排除已有直接边)全图/中心实体两种扫描;AI 判断候选关系(LLM 给出关系名/大类/置信度/依据,人工确认后入库,不直接写库);检索页签新增"关系推荐"卡,实体信息卡新增"推荐关系"按钮
169
195
  - **v1.9.0**:检索配置增强(Base URL 页面可配 + 测试连接按钮,保存前即可验证密钥/地址/模型);关系图片绑定(信息卡上传/缩略图/灯箱,佐证材料与关系绑定,删关系连带清理);帮助文档全面增补(检索配置详解/关系编辑与图片/知识推理方法体系与应用场景)
170
196
  - **v1.8.0**:MCP 外部接入——设置新增 MCP 页签(服务开关/只读模式/令牌管理/三类客户端一键复制配置);`/mcp` Streamable HTTP 端点(令牌鉴权+CORS,12 个工具含完整 CRUD);`kg --mcp` 本机 stdio 接入;图谱变更实时同步(SSE 推送+跨进程探测,页面 3 秒内自动刷新)
package/README.md CHANGED
@@ -14,13 +14,16 @@
14
14
 
15
15
  | 模块 | 说明 |
16
16
  |------|------|
17
- | 3D 可视化 | three.js 力导向布局,实体按 4 大类区分形状/颜色,关系按 5 大类区分线型;支持中心层级视图(可调层数)、视图主题自定义、实体图片绑定与灯箱 |
17
+ | 3D 可视化 | three.js 力导向布局,实体按 4 大类区分形状/颜色,关系按 5 大类区分线型并带方向箭头(互惠关系双箭头、属性 weight 数值映射明暗);支持中心层级视图(可调层数)、多选提取子图、图例点击显隐、属性条件过滤、PNG 截图、视图主题自定义、实体图片绑定与灯箱 |
18
18
  | AI 文档导入 | 调用 OpenCode CLI 将 Markdown/PDF/Word 等文档抽取为三元组并自动导入(分块+保存点对齐,防重复导入) |
19
19
  | OWL 推理 | 按 传递/对称/逆 三类规则动态推导隐性关系(不入库),可配置 data/ontology.json,前端一键叠加显示并可查看推导链 |
20
- | 混合检索 | 语义向量(默认硅基流动 BAAI/bge-m3,可换任意 OpenAI 兼容接口)+ 关键词检索,RRF 融合排序 |
20
+ | 混合检索 | 语义向量(默认硅基流动 BAAI/bge-m3,可换任意 OpenAI 兼容接口)+ 关键词检索,RRF 融合排序,检索结果可一键全部高亮 |
21
21
  | 迷你 Cypher | 只读关系查询:`MATCH (a)-[r:互动]->(b) WHERE a.name contains 郑和 RETURN a.name, r.name LIMIT 10` |
22
- | 数据资产 | 实体图片(本地存储,独立表)、RDF Turtle 导出、SQLite 整库导出/导入(自动备份保存点)、单文件只读网页另存 |
23
- | 保存点 | 每次合规写入自动打 Git 保存点,违规写入自动回滚到上个合规版本;可一键回溯任意历史版本 |
22
+ | 图统计 | 度分布直方图、枢纽节点 Top10(点击飞行定位)、实体大类分布(统计页签) |
23
+ | 数据资产 | 实体图片(本地存储,独立表)、RDF Turtle 导出、SQLite 整库导出/导入(自动备份保存点)、三元组 JSON/CSV 导入导出(中文表头,同名实体复用、重复三元组自动去重)、单文件只读网页另存 |
24
+ | 保存点 | 每次合规写入自动打 Git 保存点,违规写入自动回滚到上个合规版本;可一键回溯任意历史版本,勾选两个保存点可对比版本差异(Diff) |
25
+ | 视图深链 | `#entity=5` / `#ego=5&depth=2` / `#path=6-13` URL 直达视图,"复制视图链接"一键分享,单文件网页同样支持 |
26
+ | 演示模式 | `kg --demo` 一次性数据目录+太阳系示例图谱一键体验;帮助面板可向现有图谱载入演示数据(先自动备份) |
24
27
  | MCP Server | 零依赖 stdio JSON-RPC,10 个工具可直接接入 Claude Desktop / OpenCode 等支持 MCP 的客户端 |
25
28
  | 双端适配 | 桌面端完整面板;手机端单行工具栏+抽屉面板+文件菜单 |
26
29
 
@@ -59,7 +62,7 @@ npm install
59
62
  npm start
60
63
  ```
61
64
 
62
- AI 导入功能需要安装 [OpenCode CLI](https://opencode.ai) 并配置可用模型;其余功能(可视化/推理/检索配置/导出/MCP)无外部依赖。
65
+ AI 导入功能需要安装 [OpenCode CLI](https://opencode.ai) 并配置可用模型;其余功能(可视化/推理/检索配置/导出/MCP)无外部依赖。应用会自动识别 opencode 的安装位置(PATH / `~/.opencode/bin` / Homebrew / WinGet / npm 全局,含 Windows npm 垫片还原);非标准位置可用环境变量 `KG_OPENCODE_PATH` 指定可执行文件完整路径。未安装时可在「检索」页签一键安装,并自动完成模型配置(优先本机 Ollama / LM Studio 已有模型,否则 OpenCode Zen 免费模型,无需登录)。
63
66
 
64
67
  ## 目录结构
65
68
 
@@ -68,6 +71,8 @@ server.js Express 服务与全部 API
68
71
  lib/db.js SQLite 三表核心(entities/relations/operation_logs)+ 图片表 + 向量表 + 迷你Cypher
69
72
  lib/validator.js 手工/AI 共用的合规校验(违规即整体拒绝,库保持上个合规版本)
70
73
  lib/agent.js OpenCode CLI 封装(普通问答与文档导入两种模式)
74
+ lib/ocbin.js opencode 可执行文件多路径定位(PATH/常见位置/npm 垫片还原/KG_OPENCODE_PATH)
75
+ lib/ocinstall.js OpenCode 一键安装与自动模型配置(npm 安装/本机模型接入/免费模型兜底)
71
76
  lib/git.js data/ 目录 Git 保存点(差量提交与回溯)
72
77
  lib/inference.js OWL 推理引擎(虚拟推导,不写库)
73
78
  lib/embeddings.js 向量构建/检索(data/settings.json 存配置,已被 gitignore)
@@ -111,6 +116,9 @@ GET /export/rdf RDF Turtle
111
116
  GET /export/db?name= SQLite 整库导出
112
117
  POST /graph/import SQLite 整库导入(自动备份+校验)
113
118
  GET /export/html 单文件只读网页
119
+ GET /export/json|csv?ids= 三元组 JSON/CSV 导出(ids 可选,传实体id集合即导出子图)
120
+ POST /import/triples 三元组 JSON/CSV 批量导入({content, format, dry_run},同名实体复用、重复关系跳过)
121
+ GET /stats 图统计(度分布、枢纽节点 Top10、大类分布)
114
122
  ```
115
123
 
116
124
  ## kg-ops 写入协议
package/bin/cli.js CHANGED
@@ -24,6 +24,7 @@ if (args.includes('-h') || args.includes('--help')) {
24
24
  --host <addr> 监听地址(默认 127.0.0.1,局域网访问用 0.0.0.0,环境变量 KG_HOST 同效)
25
25
  --data <dir> 数据目录(默认 ~/.local-knowledge-graph,环境变量 KG_DATA_DIR 同效)
26
26
  --mcp 以 MCP stdio 服务运行(供 Claude Desktop/Cursor 等客户端接入,不启动网页)
27
+ --demo 演示模式:使用一次性数据目录载入太阳系示例图谱并启动(不影响现有数据)
27
28
  --no-open 启动后不自动打开浏览器
28
29
  -v, --version 显示版本
29
30
  -h, --help 显示本帮助
@@ -44,17 +45,40 @@ if (args.includes('--mcp')) {
44
45
  require('../mcp/server.js');
45
46
  } else {
46
47
 
47
- const { ensureLegacyMigration, isDevRepo } = require('../lib/paths');
48
-
48
+ const demoMode = args.includes('--demo');
49
49
  const port = argValue('--port');
50
50
  const host = argValue('--host');
51
51
  const dataDir = argValue('--data');
52
52
  if (port) process.env.PORT = port;
53
53
  if (host) process.env.KG_HOST = host;
54
- if (dataDir) process.env.KG_DATA_DIR = dataDir;
54
+ if (demoMode && !dataDir) {
55
+ // 演示模式默认用一次性目录,绝不污染真实数据;必须在 require paths 前设置 env(DATA_DIR 在模块加载时固化)
56
+ const os = require('os');
57
+ process.env.KG_DATA_DIR = require('fs').mkdtempSync(require('path').join(os.tmpdir(), 'kg-demo-'));
58
+ } else if (dataDir) {
59
+ process.env.KG_DATA_DIR = dataDir;
60
+ }
61
+
62
+ const { ensureLegacyMigration, isDevRepo } = require('../lib/paths');
55
63
 
56
64
  ensureLegacyMigration();
57
65
 
66
+ // 演示模式:服务就绪后自动灌入太阳系示例数据
67
+ if (demoMode) {
68
+ const demoUrl = `http://localhost:${Number(process.env.PORT || 3000)}/api/demo/load`;
69
+ const started = Date.now();
70
+ const demoTimer = setInterval(() => {
71
+ fetch(demoUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(4000) })
72
+ .then((r) => r.json())
73
+ .then((j) => {
74
+ clearInterval(demoTimer);
75
+ if (j.ok) console.log(`演示数据已载入:实体 ${j.counts.entities} / 关系 ${j.counts.relations}(数据目录 ${process.env.KG_DATA_DIR})`);
76
+ else console.log('演示数据载入失败: ' + (j.error || '未知'));
77
+ })
78
+ .catch(() => { if (Date.now() - started > 25000) { clearInterval(demoTimer); console.log('演示数据载入超时,可稍后在网页「帮助」面板点击「载入演示数据」'); } });
79
+ }, 800);
80
+ }
81
+
58
82
  // 启动后轮询就绪,自动打开浏览器
59
83
  if (!args.includes('--no-open')) {
60
84
  const listenPort = Number(process.env.PORT || 3000);
package/lib/agent.js CHANGED
@@ -7,6 +7,7 @@ const { spawn } = require('child_process');
7
7
  const path = require('path');
8
8
  const fs = require('fs');
9
9
 
10
+ const ocbin = require('./ocbin');
10
11
  const { APP_ROOT, DATA_DIR } = require('./paths');
11
12
  const SESSION_FILE = path.join(DATA_DIR, '.agent_session');
12
13
  const TIMEOUT_MS = Number(process.env.AGENT_TIMEOUT_MS || 300000);
@@ -47,7 +48,7 @@ ${relLines || '(空)'}
47
48
  【RDF规范(必须严格遵守)】
48
49
  - 实体大类只能是: 物理实体 / 抽象实体 / 数值实体 / 时间实体
49
50
  - 关系大类只能是: 空间 / 互动 / 归属 / 时间 / 属性
50
- - 实体属性(attributes)必须是扁平JSON对象,值为字符串/数值/布尔,禁止嵌套对象或数组
51
+ - 实体属性(attributes)必须是扁平JSON对象,值为字符串/数值/布尔,禁止嵌套对象或数组;关系同样可选携带扁平attributes(如开始时间、合同编号、权重)
51
52
  - 关系的 source/target 只能引用已存在实体id,或用ref占位符引用本次新增的实体
52
53
  - 建模准则:数值实体/时间实体仅在"数值或时间作为某条关系的端点"时才创建(如"事件—发生于→744年");仅作描述说明的年份、日期、数量(如成立年份、测量年份、海拔、人口)一律写入所属实体的attributes,禁止为它们单独建节点
53
54
 
@@ -65,12 +66,12 @@ ${relLines || '(空)'}
65
66
  第一步:用简洁中文说明你的分析与结论(若涉及联网信息,注明来源可信度)。
66
67
  第二步:如需修改图谱,输出一个 \`\`\`kg-ops 代码块,内容为JSON数组(仅这一种操作格式),元素形如:
67
68
  {"op":"add_entity","ref":"A","name":"实体名","category":"物理实体","attributes":{"键":"值"}}
68
- {"op":"add_relation","source_ref":"A","target_id":3,"name":"关系名","category":"归属"}
69
+ {"op":"add_relation","source_ref":"A","target_id":3,"name":"关系名","category":"归属","attributes":{"开始时间":"2024年","合同编号":"HT-001"}}
69
70
  {"op":"update_entity","id":5,"attributes":{"键":"新值"}}
70
71
  {"op":"delete_entity","id":7}
71
- {"op":"update_relation","id":2,"name":"新名"}
72
+ {"op":"update_relation","id":2,"name":"新名","attributes":{"键":"新值"}}
72
73
  {"op":"delete_relation","id":4}
73
- (source_id/target_id用数字id引用已有实体;本次新增的实体用ref占位符,如"ref":"A"供后续关系引用)
74
+ (source_id/target_id用数字id引用已有实体;本次新增的实体用ref占位符,如"ref":"A"供后续关系引用;关系可选携带扁平attributes,如开始时间、合同编号、权重等)
74
75
  第三步:若无任何图谱修改,省略代码块。
75
76
 
76
77
  【用户指令】
@@ -147,8 +148,23 @@ function runOpenCodeRaw(preArgs, prompt, files, timeoutMs, envExtra) {
147
148
 
148
149
  let stdout = '', stderr = '', done = false;
149
150
  let child;
151
+
152
+ const bin = ocbin.findOpenCodeBin();
153
+ if (!bin) {
154
+ // 安装进行中时给出等待提示而非"未找到"
155
+ let installing = null;
156
+ try {
157
+ const ocs = require('./ocinstall').status();
158
+ if (ocs.running) {
159
+ const secs = ocs.started_at ? Math.round((Date.now() - ocs.started_at) / 1000) : 0;
160
+ installing = `opencode 正在后台安装中(已进行 ${secs >= 60 ? `${Math.floor(secs / 60)} 分 ${secs % 60} 秒` : `${secs} 秒`}),首次需下载较大二进制文件,完成后即可直接使用,请稍候再试`;
161
+ }
162
+ } catch (_) { /* 安装模块不可用时走通用提示 */ }
163
+ return resolve({ ok: false, error: installing || '未找到 opencode CLI。请安装(npm i -g opencode-ai 或官方安装脚本),或在「检索」页签使用一键安装;已安装但识别失败时可用环境变量 KG_OPENCODE_PATH 指定可执行文件完整路径' });
164
+ }
165
+ const { file, args: spawnArgs, opts: spawnOpts } = ocbin.buildLauncher(bin, args);
150
166
  try {
151
- child = spawn('opencode', args, { cwd: APP_ROOT, env: { ...process.env, ...(envExtra || {}) }, stdio: ['ignore', 'pipe', 'pipe'] });
167
+ child = spawn(file, spawnArgs, { cwd: APP_ROOT, env: { ...process.env, ...(envExtra || {}) }, stdio: ['ignore', 'pipe', 'pipe'], ...spawnOpts });
152
168
  } catch (e) {
153
169
  return resolve({ ok: false, error: '无法启动OpenCode进程: ' + e.message });
154
170
  }
@@ -161,7 +177,7 @@ function runOpenCodeRaw(preArgs, prompt, files, timeoutMs, envExtra) {
161
177
  child.stderr.on('data', (d) => { stderr += d; });
162
178
  child.on('error', (e) => {
163
179
  if (done) return; done = true; clearTimeout(timer);
164
- resolve({ ok: false, error: `OpenCode进程启动失败: ${e.message}(请确认 opencode CLI 已安装)` });
180
+ resolve({ ok: false, error: `OpenCode进程启动失败: ${e.message}(尝试执行: ${file},可用环境变量 KG_OPENCODE_PATH 修正路径)` });
165
181
  });
166
182
  child.on('close', (code) => {
167
183
  if (done) return; done = true; clearTimeout(timer);
@@ -195,6 +211,32 @@ function attempt(sessionId, instruction, files) {
195
211
  });
196
212
  }
197
213
 
214
+ // 修复回路提示词:带上一次的完整操作与结构化回执,要求输出修正后的完整 kg-ops 块
215
+ function buildRepairPrompt(ops, receipts) {
216
+ return `你上一次输出的 kg-ops 操作未通过图谱校验,全部改动已回滚。请根据以下修复回执修正操作,重新输出【完整的】修正后 kg-ops 代码块(包含全部操作,而非仅差异部分)。
217
+
218
+ 【你上次的操作】
219
+ ${JSON.stringify(ops, null, 1)}
220
+
221
+ 【修复回执】(index=操作序号,code=规则码,subject=出错主体,supportedFixes=仅允许的修复方式)
222
+ ${JSON.stringify(receipts, null, 1)}
223
+
224
+ 【修复要求】
225
+ 1. 严格按 supportedFixes 修复,禁止发明新的字段、大类或类型。
226
+ 2. 无法修复的操作直接删除(如重复三元组可改为 update_relation 或整条移除)。
227
+ 3. 重新输出完整 kg-ops 块;说明文字保持简短。`;
228
+ }
229
+
230
+ // 按回执修复:续用上次会话上下文,要求AI输出修正后的完整操作
231
+ async function runRepair(ops, receipts) {
232
+ const sessionId = readSession();
233
+ const preArgs = sessionId ? ['--session', sessionId] : [];
234
+ const r = await runOpenCodeRaw(preArgs, buildRepairPrompt(ops, receipts), [], TIMEOUT_MS);
235
+ if (!r.ok) return { ok: false, error: r.error };
236
+ const parsed = parseOps(r.texts);
237
+ return { ok: true, reply: parsed.reply || r.texts, ops: parsed.ops, parse_error: parsed.parse_error || null };
238
+ }
239
+
198
240
  // 纯文本问答模式(智能检索编译/综述用):无会话、无 kg-ops 解析,仅取回复文本
199
241
  async function runPlain(prompt, timeoutMs = 20000, envExtra) {
200
242
  const p = String(prompt || '').trim();
@@ -208,4 +250,4 @@ function clearSession() {
208
250
  try { fs.unlinkSync(SESSION_FILE); } catch (_) { /* 文件不存在则忽略 */ }
209
251
  }
210
252
 
211
- module.exports = { buildPrompt, runAgent, runPlain, parseOps, parseEventStream };
253
+ module.exports = { buildPrompt, runAgent, runRepair, runPlain, parseOps, parseEventStream };
package/lib/db.js CHANGED
@@ -50,6 +50,7 @@ function initSchema() {
50
50
  category TEXT NOT NULL CHECK(category IN ('空间','互动','归属','时间','属性')),
51
51
  confidence TEXT NOT NULL DEFAULT '确证' CHECK(confidence IN ('确证','推测','存疑')),
52
52
  source_ref TEXT NOT NULL DEFAULT '',
53
+ attributes TEXT NOT NULL DEFAULT '{}',
53
54
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
54
55
  source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','文档'))
55
56
  );
@@ -131,6 +132,9 @@ function migrateEvidence() {
131
132
  if (relCols.length && !relCols.includes('source_ref')) {
132
133
  db.exec("ALTER TABLE relations ADD COLUMN source_ref TEXT NOT NULL DEFAULT ''");
133
134
  }
135
+ if (relCols.length && !relCols.includes('attributes')) {
136
+ db.exec("ALTER TABLE relations ADD COLUMN attributes TEXT NOT NULL DEFAULT '{}'");
137
+ }
134
138
  // 2) source 枚举扩展"文档":检测建表语句,缺失则整表重建
135
139
  const ENTITIES_NEW = `CREATE TABLE entities__rebuild (
136
140
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -148,6 +152,7 @@ function migrateEvidence() {
148
152
  category TEXT NOT NULL CHECK(category IN ('空间','互动','归属','时间','属性')),
149
153
  confidence TEXT NOT NULL DEFAULT '确证' CHECK(confidence IN ('确证','推测','存疑')),
150
154
  source_ref TEXT NOT NULL DEFAULT '',
155
+ attributes TEXT NOT NULL DEFAULT '{}',
151
156
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
152
157
  source TEXT NOT NULL CHECK(source IN ('手工','OpenCode','文档'))
153
158
  )`;
@@ -155,7 +160,7 @@ function migrateEvidence() {
155
160
  rebuildTableWithEnum('entities', ENTITIES_NEW, ['id', 'name', 'category', 'attributes', 'created_at', 'source']);
156
161
  }
157
162
  if (!tableSql('relations').includes("'文档'")) {
158
- rebuildTableWithEnum('relations', RELATIONS_NEW, ['id', 'source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref', 'created_at', 'source']);
163
+ rebuildTableWithEnum('relations', RELATIONS_NEW, ['id', 'source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref', 'attributes', 'created_at', 'source']);
159
164
  }
160
165
  if (!tableSql('operation_logs').includes("'文档'")) {
161
166
  const LOGS_NEW = `CREATE TABLE operation_logs__rebuild (
@@ -261,8 +266,15 @@ function addRelationCore(input, source) {
261
266
  const check = V.validateRelationInput(input, db);
262
267
  if (!check.ok) throw writeErr(check);
263
268
  const v = check.value;
264
- const info = db.prepare('INSERT INTO relations (source_id, target_id, name, category, confidence, source_ref, source) VALUES (?, ?, ?, ?, ?, ?, ?)')
265
- .run(v.source_id, v.target_id, v.name, v.category, v.confidence, v.source_ref, source);
269
+ const dup = V.findDuplicateRelation(v, db);
270
+ if (dup) {
271
+ const e = new Error(`重复三元组:实体${v.source_id} →「${v.name}」→ 实体${v.target_id} 已存在(关系id=${dup.id}),如需修改请使用更新操作`);
272
+ e.status = 409;
273
+ e.duplicate_id = dup.id;
274
+ throw e;
275
+ }
276
+ const info = db.prepare('INSERT INTO relations (source_id, target_id, name, category, confidence, source_ref, attributes, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
277
+ .run(v.source_id, v.target_id, v.name, v.category, v.confidence, v.source_ref, JSON.stringify(v.attributes || {}), source);
266
278
  const id = Number(info.lastInsertRowid);
267
279
  const row = getRelation(id);
268
280
  logOp('ADD_RELATION', { relation: row }, source);
@@ -275,8 +287,15 @@ function updateRelationCore(id, patch, source) {
275
287
  const check = V.validateRelationPatch(patch, existing, db);
276
288
  if (!check.ok) throw writeErr(check);
277
289
  const v = check.value;
278
- db.prepare('UPDATE relations SET source_id = ?, target_id = ?, name = ?, category = ?, confidence = ?, source_ref = ? WHERE id = ?')
279
- .run(v.source_id, v.target_id, v.name, v.category, v.confidence, v.source_ref, id);
290
+ const dup = V.findDuplicateRelation(v, db, id);
291
+ if (dup) {
292
+ const e = new Error(`重复三元组:实体${v.source_id} →「${v.name}」→ 实体${v.target_id} 已存在(关系id=${dup.id})`);
293
+ e.status = 409;
294
+ e.duplicate_id = dup.id;
295
+ throw e;
296
+ }
297
+ db.prepare('UPDATE relations SET source_id = ?, target_id = ?, name = ?, category = ?, confidence = ?, source_ref = ?, attributes = ? WHERE id = ?')
298
+ .run(v.source_id, v.target_id, v.name, v.category, v.confidence, v.source_ref, JSON.stringify(v.attributes || {}), id);
280
299
  const row = getRelation(id);
281
300
  logOp('UPDATE_RELATION', { before: existing, after: row }, source);
282
301
  return row;
@@ -466,16 +485,16 @@ function undoLast(source) {
466
485
  case 'UPDATE_RELATION': {
467
486
  if (!getRelation(snap.before.id)) { const e = new Error('关系已不存在,无法撤销更新'); e.status = 409; throw e; }
468
487
  V.validateRelationInput({ source_id: snap.before.source_id, target_id: snap.before.target_id, name: snap.before.name, category: snap.before.category }, db);
469
- db.prepare('UPDATE relations SET source_id = ?, target_id = ?, name = ?, category = ? WHERE id = ?')
470
- .run(snap.before.source_id, snap.before.target_id, snap.before.name, snap.before.category, snap.before.id);
488
+ db.prepare('UPDATE relations SET source_id = ?, target_id = ?, name = ?, category = ?, confidence = ?, source_ref = ?, attributes = ? WHERE id = ?')
489
+ .run(snap.before.source_id, snap.before.target_id, snap.before.name, snap.before.category, snap.before.confidence || '确证', snap.before.source_ref || '', snap.before.attributes || '{}', snap.before.id);
471
490
  summary = `已撤销更新:关系#${snap.before.id} [${snap.before.name}] 还原为修改前数据`;
472
491
  break;
473
492
  }
474
493
  case 'DELETE_RELATION': {
475
494
  if (getRelation(snap.relation.id)) { summary = `关系#${snap.relation.id}已重新存在,无需撤销`; break; }
476
495
  V.validateRelationInput({ source_id: snap.relation.source_id, target_id: snap.relation.target_id, name: snap.relation.name, category: snap.relation.category }, db);
477
- db.prepare('INSERT INTO relations (id, source_id, target_id, name, category, source, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
478
- .run(snap.relation.id, snap.relation.source_id, snap.relation.target_id, snap.relation.name, snap.relation.category, snap.relation.source, snap.relation.created_at);
496
+ db.prepare('INSERT INTO relations (id, source_id, target_id, name, category, confidence, source_ref, attributes, source, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
497
+ .run(snap.relation.id, snap.relation.source_id, snap.relation.target_id, snap.relation.name, snap.relation.category, snap.relation.confidence || '确证', snap.relation.source_ref || '', snap.relation.attributes || '{}', snap.relation.source, snap.relation.created_at);
479
498
  summary = `已撤销删除:恢复关系「${entNameIn(snap.relation.source_id)} —[${snap.relation.name}]→ ${entNameIn(snap.relation.target_id)}」`;
480
499
  break;
481
500
  }
@@ -498,6 +517,7 @@ function entNameIn(id) {
498
517
  function applyAgentOps(ops) {
499
518
  const applied = [];
500
519
  const refMap = {};
520
+ const failures = []; // 修复回执:收集全部失败而非首个,AI可一轮拿到完整问题清单
501
521
  return tx(() => {
502
522
  for (let opIdx = 0; opIdx < ops.length; opIdx++) {
503
523
  const op = ops[opIdx];
@@ -533,7 +553,7 @@ function applyAgentOps(ops) {
533
553
  else sid = resolveRef(op.source_id, op.source_ref, refMap);
534
554
  if (op.target_name !== undefined && op.target_id === undefined && op.target_ref === undefined) tid = resolveEntityName(op.target_name);
535
555
  else tid = resolveRef(op.target_id, op.target_ref, refMap);
536
- const row = addRelationCore({ source_id: sid, target_id: tid, name: op.name, category: op.category, confidence: op.confidence, source_ref: op.evidence_ref }, 'OpenCode');
556
+ const row = addRelationCore({ source_id: sid, target_id: tid, name: op.name, category: op.category, confidence: op.confidence, source_ref: op.evidence_ref, attributes: op.attributes }, 'OpenCode');
537
557
  applied.push({ op: 'add_relation', id: row.id, name: row.name });
538
558
  break;
539
559
  }
@@ -551,7 +571,7 @@ function applyAgentOps(ops) {
551
571
  }
552
572
  case 'update_relation': {
553
573
  const patch = {};
554
- for (const k of ['source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref']) if (op[k] !== undefined) patch[k] = op[k];
574
+ for (const k of ['source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref', 'attributes']) if (op[k] !== undefined) patch[k] = op[k];
555
575
  const row = updateRelationCore(Number(op.id), patch, 'OpenCode');
556
576
  applied.push({ op: 'update_relation', id: row.id, name: row.name });
557
577
  break;
@@ -565,11 +585,15 @@ function applyAgentOps(ops) {
565
585
  throw badOp(`未知操作类型"${op.op}",仅支持 add_entity/add_relation/update_entity/delete_entity/update_relation/delete_relation`);
566
586
  }
567
587
  } catch (e) {
568
- const opKind = (op && op.op) || '未知';
569
- e.message = `第${opIdx + 1}条操作(${opKind})失败: ${e.message}`;
570
- throw e;
588
+ // 继续执行后续操作,让AI一次拿到全部问题;事务最终整体回滚,无部分写入
589
+ failures.push(V.buildReceipt(opIdx + 1, op, e));
571
590
  }
572
591
  }
592
+ if (failures.length) {
593
+ const err = new Error(`${failures.length}/${ops.length} 条操作未通过校验,已全部回滚`);
594
+ err.receipts = failures;
595
+ throw err;
596
+ }
573
597
  return applied;
574
598
  });
575
599
 
@@ -866,6 +890,118 @@ function miniCypher(q) {
866
890
  return out;
867
891
  }
868
892
 
893
+ // ---- 三元组批量导入:单事务,逐行容错,同名实体复用、纯数字引用优先按id解析 ----
894
+ function applyTriplesImport(parsed, source) {
895
+ const result = { entities_added: 0, entities_reused: 0, relations_added: 0, relations_skipped_duplicate: 0, errors: [], entity_ids: new Map() };
896
+ return tx(() => {
897
+ // 名称→id 映射:先收已有实体(重名取首个),再补本批新建
898
+ const nameMap = new Map();
899
+ for (const e of listEntities()) if (!nameMap.has(e.name)) nameMap.set(e.name, e.id);
900
+
901
+ for (const ent of parsed.entities) {
902
+ try {
903
+ const existingId = nameMap.get(String(ent.name).trim());
904
+ if (existingId) {
905
+ result.entities_reused++;
906
+ result.entity_ids.set(ent.name, existingId);
907
+ continue;
908
+ }
909
+ const row = addEntityCore({ name: ent.name, category: ent.category, attributes: ent.attributes }, source);
910
+ if (Array.isArray(ent.aliases) && ent.aliases.length) {
911
+ // 同事务内联写别名(addAlias 内部自带事务,嵌套会失败)
912
+ for (const raw of ent.aliases) {
913
+ const n = cleanAlias(raw);
914
+ if (!n || n.length > 200 || n === row.name) continue;
915
+ const dup = db.prepare('SELECT id FROM aliases WHERE entity_id = ? AND alias = ?').get(row.id, n);
916
+ if (dup) continue;
917
+ if (aliasConflict(n, row.id)) continue;
918
+ db.prepare('INSERT INTO aliases (entity_id, alias) VALUES (?, ?)').run(row.id, n);
919
+ }
920
+ }
921
+ nameMap.set(row.name, row.id);
922
+ if (ent.fromId) result.entity_ids.set('#' + ent.fromId, row.id);
923
+ result.entity_ids.set(row.name, row.id);
924
+ result.entities_added++;
925
+ } catch (e) {
926
+ result.errors.push(`实体"${ent.name}": ${e.message}`);
927
+ }
928
+ }
929
+ const resolveRef = (ref) => {
930
+ const s = String(ref).trim();
931
+ if (/^\d+$/.test(s)) {
932
+ const byId = getEntity(Number(s));
933
+ if (byId) return byId.id;
934
+ }
935
+ return nameMap.get(s) || null;
936
+ };
937
+ for (const rel of parsed.relations) {
938
+ try {
939
+ const sid = resolveRef(rel.source);
940
+ const tid = resolveRef(rel.target);
941
+ if (!sid) { result.errors.push(`关系"${rel.name}": 起点"${rel.source}"不存在且无法自动创建`); continue; }
942
+ if (!tid) { result.errors.push(`关系"${rel.name}": 终点"${rel.target}"不存在且无法自动创建`); continue; }
943
+ try {
944
+ addRelationCore({ source_id: sid, target_id: tid, name: rel.name, category: rel.category, confidence: rel.confidence, source_ref: rel.source_ref, attributes: rel.attributes }, source);
945
+ result.relations_added++;
946
+ } catch (e) {
947
+ if (e.status === 409) result.relations_skipped_duplicate++;
948
+ else result.errors.push(`关系"${rel.name}"(${rel.source}→${rel.target}): ${e.message}`);
949
+ }
950
+ } catch (e) {
951
+ result.errors.push(`关系"${rel.name}": ${e.message}`);
952
+ }
953
+ }
954
+ bumpVersion();
955
+ return result;
956
+ });
957
+ }
958
+
959
+ // ---- 图统计:度分布与核心枢纽节点(需求:统计指标)----
960
+ function graphStats() {
961
+ const ents = listEntities();
962
+ const rels = listRelations();
963
+ const degree = new Map(); // id → {out, in, total}
964
+ for (const e of ents) degree.set(e.id, { out: 0, in: 0, total: 0 });
965
+ for (const r of rels) {
966
+ const d = degree.get(r.source_id);
967
+ if (d) { d.out++; d.total++; }
968
+ const t = degree.get(r.target_id);
969
+ if (t) { t.in++; t.total++; }
970
+ }
971
+ const dist = new Map();
972
+ let maxDegree = 0;
973
+ for (const d of degree.values()) {
974
+ dist.set(d.total, (dist.get(d.total) || 0) + 1);
975
+ if (d.total > maxDegree) maxDegree = d.total;
976
+ }
977
+ const hubs = ents
978
+ .map((e) => {
979
+ const d = degree.get(e.id);
980
+ const relNames = {};
981
+ for (const r of rels) {
982
+ if (r.source_id === e.id || r.target_id === e.id) relNames[r.name] = (relNames[r.name] || 0) + 1;
983
+ }
984
+ return {
985
+ id: e.id, name: e.name, category: e.category,
986
+ degree: d.total, out: d.out, in: d.in,
987
+ relations: Object.entries(relNames).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([n]) => n),
988
+ };
989
+ })
990
+ .sort((a, b) => b.degree - a.degree)
991
+ .slice(0, 10);
992
+ const catCount = {};
993
+ for (const e of ents) catCount[e.category] = (catCount[e.category] || 0) + 1;
994
+ return {
995
+ entities: ents.length,
996
+ relations: rels.length,
997
+ avg_degree: ents.length ? Number((rels.length * 2 / ents.length).toFixed(2)) : 0,
998
+ max_degree: maxDegree,
999
+ degree_distribution: [...dist.entries()].sort((a, b) => a[0] - b[0]).map(([k, v]) => ({ degree: k, count: v })),
1000
+ hubs,
1001
+ categories: Object.entries(catCount).map(([category, count]) => ({ category, count })),
1002
+ };
1003
+ }
1004
+
869
1005
  module.exports = {
870
1006
  DATA_DIR, DB_PATH,
871
1007
  open, close, reopen, integrityCheck,
@@ -879,5 +1015,7 @@ module.exports = {
879
1015
  undoLast,
880
1016
  miniCypher,
881
1017
  applyAgentOps, getGraph, getLogs, maxLogId, counts, logRestore,
1018
+ applyTriplesImport,
1019
+ graphStats,
882
1020
  getVersion: () => version,
883
1021
  };
package/lib/demo.js ADDED
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ // 演示数据集:太阳系主题,供 kg --demo 与网页「载入演示数据」一键体验
4
+ // 结构与 triples_io.parseImport(JSON) 输出一致,直接喂 db.applyTriplesImport
5
+ // 覆盖:物理/抽象/数值/时间 4 实体大类,空间/互动/归属/时间/属性 5 关系大类,确证/存疑/推测 3 置信度,属性与别名
6
+
7
+ function demoData() {
8
+ const entities = [
9
+ { name: '太阳', category: '物理实体', attributes: { 类型: 'G型主序星', 质量: '1.989×10^30 kg', 表面温度: '约5500°C' }, aliases: ['Sol'] },
10
+ { name: '水星', category: '物理实体', attributes: { 直径: '4879 km', 公转周期: '88天' }, aliases: ['Mercury'] },
11
+ { name: '金星', category: '物理实体', attributes: { 直径: '12104 km', 公转周期: '225天' }, aliases: ['启明星', '长庚星'] },
12
+ { name: '地球', category: '物理实体', attributes: { 直径: '12742 km', 公转周期: '365.25天', 卫星数: 1 }, aliases: ['Earth', '蓝星'] },
13
+ { name: '火星', category: '物理实体', attributes: { 直径: '6779 km', 公转周期: '687天' }, aliases: ['红色星球'] },
14
+ { name: '木星', category: '物理实体', attributes: { 直径: '139820 km', 卫星数: 95 }, aliases: ['Jupiter'] },
15
+ { name: '月球', category: '物理实体', attributes: { 直径: '3475 km', 距地距离: '38.4万公里' }, aliases: ['Moon'] },
16
+ { name: '哈雷彗星', category: '物理实体', attributes: { 回归周期: '约76年', 上次回归: '1986年' }, aliases: ['1P/Halley'] },
17
+ { name: '太阳系', category: '抽象实体', attributes: { 行星数: 8, 年龄: '约46亿年' }, aliases: [] },
18
+ { name: '日心说', category: '抽象实体', attributes: { 提出时间: '1543年' }, aliases: ['太阳中心模型'] },
19
+ { name: '万有引力定律', category: '抽象实体', attributes: { 发表时间: '1687年', 著作: '《自然哲学的数学原理》' }, aliases: [] },
20
+ { name: '潮汐', category: '抽象实体', attributes: { 周期: '约12小时25分' }, aliases: ['潮汐现象'] },
21
+ { name: '日食', category: '抽象实体', attributes: {}, aliases: ['日蚀'] },
22
+ { name: '天文单位', category: '数值实体', attributes: { 数值: '约1.496亿公里', 符号: 'AU' }, aliases: ['AU'] },
23
+ { name: '光速', category: '数值实体', attributes: { 数值: '299792458 m/s', 符号: 'c' }, aliases: [] },
24
+ { name: '哥白尼', category: '物理实体', attributes: { 国籍: '波兰', 生卒: '1473-1543' }, aliases: ['Nicolaus Copernicus'] },
25
+ { name: '开普勒', category: '物理实体', attributes: { 国籍: '德意志', 生卒: '1571-1630' }, aliases: ['Johannes Kepler'] },
26
+ { name: '牛顿', category: '物理实体', attributes: { 国籍: '英国', 生卒: '1643-1727' }, aliases: ['Isaac Newton', '艾萨克·牛顿'] },
27
+ ];
28
+ const relations = [
29
+ { source: '水星', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: 'IAU 行星定义', attributes: {} },
30
+ { source: '金星', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: 'IAU 行星定义', attributes: {} },
31
+ { source: '地球', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: 'IAU 行星定义', attributes: { 周期: '恒星年365.256天' } },
32
+ { source: '火星', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: 'IAU 行星定义', attributes: {} },
33
+ { source: '木星', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: 'IAU 行星定义', attributes: {} },
34
+ { source: '月球', target: '地球', name: '绕行', category: '空间', confidence: '确证', source_ref: '天文学常识', attributes: {} },
35
+ { source: '地球', target: '太阳系', name: '属于', category: '归属', confidence: '确证', source_ref: '', attributes: {} },
36
+ { source: '哈雷彗星', target: '太阳', name: '绕行', category: '空间', confidence: '确证', source_ref: '《彗星轨道论》1705', attributes: { 轨道: '椭圆', 偏心率: 0.967 } },
37
+ { source: '月球', target: '潮汐', name: '引发', category: '互动', confidence: '确证', source_ref: '牛顿《原理》第一册', attributes: {} },
38
+ { source: '月球', target: '日食', name: '引发', category: '互动', confidence: '确证', source_ref: '', attributes: { 条件: '朔日且月球在黄白交点附近' } },
39
+ { source: '天文单位', target: '太阳', name: '度量', category: '属性', confidence: '确证', source_ref: 'IAU 2012 决议', attributes: { 定义: '精确149597870700米' } },
40
+ { source: '哥白尼', target: '日心说', name: '提出', category: '互动', confidence: '确证', source_ref: '《天体运行论》1543', attributes: {} },
41
+ { source: '开普勒', target: '日心说', name: '完善', category: '互动', confidence: '确证', source_ref: '《新天文学》1609', attributes: { 贡献: '行星运动三定律' } },
42
+ { source: '牛顿', target: '万有引力定律', name: '提出', category: '互动', confidence: '确证', source_ref: '《自然哲学的数学原理》1687', attributes: {} },
43
+ { source: '万有引力定律', target: '日心说', name: '解释', category: '互动', confidence: '确证', source_ref: '', attributes: {} },
44
+ { source: '万有引力定律', target: '潮汐', name: '解释', category: '互动', confidence: '确证', source_ref: '', attributes: {} },
45
+ { source: '牛顿', target: '开普勒', name: '继承发展', category: '互动', confidence: '确证', source_ref: '', attributes: {} },
46
+ { source: '哈雷彗星', target: '牛顿', name: '印证理论', category: '互动', confidence: '存疑', source_ref: '哈雷轨道计算1705', attributes: { 说明: '哈雷用引力定律推算彗星轨道' } },
47
+ { source: '火星', target: '生命', name: '可能孕育', category: '互动', confidence: '推测', source_ref: '机遇号/好奇号探测', attributes: { 证据: '古代河床与有机分子痕迹' } },
48
+ { source: '光速', target: '天文单位', name: '换算', category: '属性', confidence: '存疑', source_ref: '雷达测距', attributes: { 说明: '日地距离约合8分19秒光程' } },
49
+ ];
50
+ // 「生命」实体补进实体表(推测关系的端点,展示存疑淡化/推测虚线样式)
51
+ entities.push({ name: '生命', category: '抽象实体', attributes: {}, aliases: [] });
52
+ // 时间实体示例:《自然哲学的数学原理》出版年
53
+ entities.push({ name: '1687年', category: '时间实体', attributes: { 说明: '《自然哲学的数学原理》出版' }, aliases: [] });
54
+ relations.push({ source: '万有引力定律', target: '1687年', name: '发表于', category: '时间', confidence: '确证', source_ref: '', attributes: {} });
55
+ return { entities, relations };
56
+ }
57
+
58
+ module.exports = { demoData };