feat: KBDB-graph 插件獨立 — 全面改寫成走基本盤 API(API-as-Wall)
按 leo 鐵律(2026-06-14)把插件從「直接 SQL 操作基本盤表」改寫成 「只透過基本盤 arcrun/kbdb HTTP API 讀寫」。零建表、零 migration、零 SQL。 - 新增 src/lib/kbdb-client.ts:唯一對外通道,封裝 entries/templates/records API - 新增 src/lib/templates.ts:triplet/entity template 定義(替代建表) - 改寫 21 個違規 action(triplet/graph/entity/search)→ 走 client,圖在插件層記憶體組裝 - 移除所有 migrations、D1/Vectorize/AI 綁定;embedding/語意搜尋歸基本盤 optional 模組 - index.ts 只掛 triplets/graph/entities/search 路由;基本盤路由歸 arcrun/kbdb - 測試改走 mock client(純 node);裁剪 CLAUDE.md 只留 graph 插件 + 鐵律 - 修正 SDD design.md「讀現狀推翻鐵律」的錯誤判斷(共用 D1 → API-as-Wall) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# /sdd-check — 確認當前任務有沒有對應 SDD
|
||||
|
||||
動手前執行。確保 CC 有全局觀,不會在沒有設計文件的情況下猛衝。
|
||||
|
||||
---
|
||||
|
||||
## 執行流程
|
||||
|
||||
### 第一步:理解任務
|
||||
|
||||
確認使用者要做什麼:
|
||||
- 涉及哪個子系統?
|
||||
- 是新功能還是修改現有功能?
|
||||
- 影響範圍?
|
||||
|
||||
### 第二步:尋找對應 SDD
|
||||
|
||||
在 `docs/3-specs/` 下尋找對應的子系統目錄,確認有沒有:
|
||||
- `design.md`(設計文件)
|
||||
- `tasks.md`(任務清單)
|
||||
|
||||
### 第三步:根據結果回應
|
||||
|
||||
**情況 A:找到對應 SDD**
|
||||
```
|
||||
✅ 找到 SDD:docs/3-specs/[子系統]/
|
||||
📋 design.md:[確認]
|
||||
📋 tasks.md:[確認,列出相關 task]
|
||||
🎯 對應 task:[編號和描述]
|
||||
繼續嗎?
|
||||
```
|
||||
|
||||
**情況 B:找不到 SDD,任務明確**
|
||||
```
|
||||
⚠️ 找不到對應 SDD
|
||||
任務:[描述]
|
||||
建議在 docs/3-specs/[建議子系統名]/ 建立 SDD
|
||||
|
||||
要我幫你起草 design.md 嗎?(需要你確認後才動手)
|
||||
```
|
||||
|
||||
**情況 C:找不到 SDD,任務模糊**
|
||||
```
|
||||
⚠️ 找不到對應 SDD,而且任務範圍不夠清楚
|
||||
請先回答:
|
||||
1. 這個功能屬於哪個子系統?
|
||||
2. 完成的標準是什麼?
|
||||
3. 有沒有不能動的邊界?
|
||||
```
|
||||
|
||||
### 注意
|
||||
|
||||
- 找不到 SDD **不等於可以直接動手**
|
||||
- 小修改(修 bug、改文字)可以豁免,但要明確說「這是小修改,範圍是 X」
|
||||
- 新功能、架構變動、跨模組的修改 → 一定要有 SDD
|
||||
@@ -0,0 +1,61 @@
|
||||
# /wiki-capture — 把對話結論存進 wiki
|
||||
|
||||
把這次對話中產生的決策、誤解釐清、或重要結論存入 wiki。
|
||||
解決「討論過了但知識消失」的問題。
|
||||
|
||||
---
|
||||
|
||||
## 執行流程
|
||||
|
||||
### 第一步:辨識對話中的可記錄內容
|
||||
|
||||
掃描當前對話,找出:
|
||||
|
||||
| 類型 | 判斷標準 | 存到哪 |
|
||||
|------|---------|-------|
|
||||
| 架構決策 | 「為什麼選A不選B」「我們決定用X」 | `decisions-summary.md` + `docs/2-architecture/decisions/` |
|
||||
| CC 的誤解被糾正 | CC 說了某件事,使用者說「不是,是...」 | `mistakes.md` |
|
||||
| 重要狀態更新 | 完成了某件事、阻擋了某件事 | `status.md` |
|
||||
| 技術發現 | 踩到坑、找到解法、重要行為確認 | `mistakes.md` 或對應 SDD |
|
||||
|
||||
### 第二步:列出清單給使用者確認
|
||||
|
||||
格式:
|
||||
```
|
||||
這次對話我整理了以下內容要存入 wiki:
|
||||
|
||||
1. [MISTAKE] CC 誤解了 X,正確是 Y
|
||||
2. [DECISION] 決定用 A 不用 B,原因是 C
|
||||
3. [STATUS] 完成了 task 2.3,下一步是 2.4
|
||||
|
||||
確認後存入,有需要修改的嗎?
|
||||
```
|
||||
|
||||
**停下來等確認。**
|
||||
|
||||
### 第三步:寫入
|
||||
|
||||
確認後,依照格式寫入對應檔案:
|
||||
|
||||
**mistakes.md 格式:**
|
||||
```
|
||||
⚠️ MISTAKE: [錯誤描述]
|
||||
症狀: [CC 的表現]
|
||||
正確做法: [應該怎麼做]
|
||||
原因: [背景]
|
||||
日期: [YYYY-MM-DD]
|
||||
```
|
||||
|
||||
**decisions-summary.md 格式:**
|
||||
```
|
||||
## [主題] — [YYYY-MM-DD]
|
||||
**結論**:[一句話]
|
||||
**原因**:[簡短說明]
|
||||
**詳細**:docs/2-architecture/decisions/[檔名]
|
||||
```
|
||||
|
||||
重大決策同時在 `docs/2-architecture/decisions/` 建立 ADR 檔案。
|
||||
|
||||
### 第四步:確認
|
||||
|
||||
告知存到哪些檔案,共幾條記錄。
|
||||
@@ -0,0 +1,77 @@
|
||||
# /wiki-init — 初始化或接入 LLM Wiki 系統
|
||||
|
||||
初始化這個專案的 LLM Wiki 記憶系統。
|
||||
新專案建立空白結構,已有專案掃描現有文件並建立 wiki。
|
||||
|
||||
---
|
||||
|
||||
## 執行流程
|
||||
|
||||
### 第一步:偵測專案狀態
|
||||
|
||||
檢查以下項目,判斷是新專案還是已有專案:
|
||||
- 根目錄有沒有 `.claude/wiki/`
|
||||
- 根目錄有沒有 `docs/`
|
||||
- 有沒有散落的 `.md` 檔案
|
||||
|
||||
**新專案**(幾乎空的)→ 直接建立結構,跳到第三步
|
||||
**已有專案**(有文件)→ 執行第二步
|
||||
|
||||
### 第二步:已有專案的掃描(已有專案才執行)
|
||||
|
||||
1. 遞迴找出所有 `.md` 檔案
|
||||
2. 對每個檔案標注建議位置和信心度
|
||||
3. 列出清單給使用者確認,**停下來等確認**
|
||||
|
||||
分類規則:
|
||||
```
|
||||
有明確子系統 + 設計內容 → docs/3-specs/[子系統]/
|
||||
解釋為什麼做某個決定 → docs/2-architecture/decisions/
|
||||
說明怎麼操作 → docs/4-guides/
|
||||
記錄發生過的事 → docs/5-records/
|
||||
給外部使用者看的 → docs/6-user/
|
||||
不確定 → 列為「待確認」,問使用者
|
||||
```
|
||||
|
||||
### 第三步:建立缺少的結構
|
||||
|
||||
只建立不存在的目錄和檔案,**已有的一律不動**:
|
||||
|
||||
目錄:
|
||||
```
|
||||
docs/{1-vision,2-architecture/decisions,3-specs,4-guides,5-records/{incidents,test-reports},6-user}
|
||||
.claude/wiki/
|
||||
```
|
||||
|
||||
檔案(不存在才建):
|
||||
- `.claude/wiki/INDEX.md`
|
||||
- `.claude/wiki/status.md`
|
||||
- `.claude/wiki/mistakes.md`
|
||||
- `.claude/wiki/decisions-summary.md`
|
||||
- `docs/README.md`
|
||||
|
||||
### 第四步:訪談(每次一個問題)
|
||||
|
||||
依序問:
|
||||
1. 這個專案做什麼?(一句話)
|
||||
2. 有哪些絕對不能違反的限制?(技術棧、架構原則等)
|
||||
3. 現在進行到哪個階段?
|
||||
4. 有沒有 CC 曾經犯過的錯要先記下來?
|
||||
|
||||
把答案填進 `CLAUDE.md`(如果存在)或建立新的。
|
||||
|
||||
### 第五步:已有專案的文件歸檔
|
||||
|
||||
(第二步確認後執行)
|
||||
|
||||
按照確認好的分類移動檔案,完成後更新 `CLAUDE.md` 的路徑引用。
|
||||
|
||||
### 第六步:完成報告
|
||||
|
||||
告知:
|
||||
```
|
||||
✅ wiki-init 完成
|
||||
建立了:[列出新建的目錄和檔案]
|
||||
跳過了:[列出已有因此不動的]
|
||||
下一步:用 /wiki-capture 把重要決策存進 wiki
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
# /wiki-update — Session 結束,更新狀態
|
||||
|
||||
每次 session 結束時執行。更新 status.md,確保下次 session 能無縫接上。
|
||||
|
||||
---
|
||||
|
||||
## 執行流程
|
||||
|
||||
### 第一步:整理這次 session 的結果
|
||||
|
||||
從對話中提取:
|
||||
- 完成了哪些 tasks(標記為 [x])
|
||||
- 進行中但未完成的(標記為 [🔄])
|
||||
- 遇到什麼問題或阻擋
|
||||
- 下次應該從哪裡開始
|
||||
|
||||
### 第二步:更新 tasks.md
|
||||
|
||||
把對應 SDD 的 tasks.md 狀態更新(如果這次有動到的話)。
|
||||
|
||||
### 第三步:更新 status.md
|
||||
|
||||
用以下格式覆蓋 status.md:
|
||||
|
||||
```markdown
|
||||
# 當前狀態
|
||||
> 更新時間:[YYYY-MM-DD]
|
||||
|
||||
## 正在做
|
||||
- [🔄] [task 描述] — 阻擋點:[如果有]
|
||||
|
||||
## 下次 session 第一件事
|
||||
[具體的第一個動作,越具體越好]
|
||||
|
||||
## 待負責人確認
|
||||
- [描述] — 等待:[什麼決定]
|
||||
|
||||
## 已知問題
|
||||
| 問題 | 優先級 | 狀態 |
|
||||
|------|--------|------|
|
||||
| [問題] | 🔴/🟡/⚪ | [狀態] |
|
||||
```
|
||||
|
||||
### 第四步:如果有新的誤解或決策
|
||||
|
||||
順帶執行 `/wiki-capture` 的邏輯,把這次的誤解和決策也存進去。
|
||||
|
||||
### 第五步:確認
|
||||
|
||||
告知 status.md 更新完成,下次 session 從哪裡開始。
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# .claude/hooks/pre-bash-guard-no-table.sh
|
||||
# KBDB-graph PreToolUse guard for Bash
|
||||
#
|
||||
# 鐵律:任何人都不准動表。擋命令列層的動表(wrangler d1 execute CREATE TABLE 等)。
|
||||
# 退出 code:0 = 允許 / 2 = 擋下
|
||||
# 依賴:jq
|
||||
|
||||
set -o pipefail
|
||||
INPUT=$(cat)
|
||||
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
|
||||
|
||||
block() {
|
||||
cat >&2 <<EOF
|
||||
❌ BLOCKED by KBDB 鐵律:任何人都不准動表
|
||||
指令:${CMD}
|
||||
違反:${1}
|
||||
正確做法:${2}
|
||||
參考:InkStoneCo/.agents/specs/matrix-rearrange/DECISION-kbdb-v3-baseplane.md
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
# ── wrangler d1 / sqlite 執行含 CREATE/ALTER/DROP TABLE ─────────────────
|
||||
if echo "$CMD" | grep -iqE '(wrangler[[:space:]]+d1|sqlite3|d1[[:space:]]+execute)' \
|
||||
&& echo "$CMD" | grep -iqE '(CREATE|ALTER|DROP)[[:space:]]+TABLE'; then
|
||||
block "命令列動表(wrangler d1 / sqlite)" \
|
||||
"新資料類型=建 template(調基本盤 API);schema 由基本盤維護者管,插件不碰。"
|
||||
fi
|
||||
|
||||
# ── 套用 .sql migration 檔(插件不該有 migration)──────────────────────
|
||||
if echo "$CMD" | grep -iqE 'wrangler[[:space:]]+d1[[:space:]]+(migrations[[:space:]]+apply|execute[[:space:]]+--file)'; then
|
||||
block "套用 migration(插件不建表、不該有 migration)" \
|
||||
"插件零 migration。DB schema 屬基本盤 arcrun/kbdb,插件透過 API 存取。"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# .claude/hooks/pre-write-guard-no-table.sh
|
||||
# KBDB-graph PreToolUse guard for Write / Edit / MultiEdit
|
||||
#
|
||||
# 鐵律(leo 2026-06-14):任何人都不准動表。插件不准直接接觸表,全走基本盤 API,禁 SQL。
|
||||
# 退出 code:0 = 允許 / 2 = 擋下(stderr 回傳給 CC)
|
||||
# 依賴:jq
|
||||
|
||||
set -o pipefail
|
||||
INPUT=$(cat)
|
||||
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
|
||||
CONTENT=$(echo "$INPUT" | jq -r '
|
||||
.tool_input.content
|
||||
// .tool_input.new_string
|
||||
// (.tool_input.edits // [] | map(.new_string // "") | join("\n"))
|
||||
// ""
|
||||
')
|
||||
|
||||
block() {
|
||||
cat >&2 <<EOF
|
||||
❌ BLOCKED by KBDB 鐵律:任何人都不准動表
|
||||
檔案:${FILE_PATH}
|
||||
違反:${1}
|
||||
原因:${2}
|
||||
正確做法:${3}
|
||||
參考:InkStoneCo/.agents/specs/matrix-rearrange/DECISION-kbdb-v3-baseplane.md
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
# 跳過文件/markdown(規則說明本身會出現這些字)
|
||||
case "$FILE_PATH" in
|
||||
*.md|*/docs/*|*/.claude/*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# ── 規則 1:禁 CREATE / ALTER / DROP TABLE ──────────────────────────────
|
||||
if echo "$CONTENT" | grep -iqE '(CREATE|ALTER|DROP)[[:space:]]+TABLE'; then
|
||||
block "CREATE/ALTER/DROP TABLE" \
|
||||
"任何人都不准動表。3 張基本盤表鎖死,只有基本盤維護者能改。" \
|
||||
"新資料類型=建 template(調基本盤 templates API),永不建表。"
|
||||
fi
|
||||
|
||||
# ── 規則 2:禁插件直接寫表(INSERT/UPDATE/DELETE 基本盤表)─────────────
|
||||
if echo "$CONTENT" | grep -iqE '(INSERT[[:space:]]+INTO|UPDATE|DELETE[[:space:]]+FROM)[[:space:]]+(entries|templates|entry_values|blocks|triplets)'; then
|
||||
block "直接寫基本盤表" \
|
||||
"插件不准直接接觸表(API-as-Wall)。" \
|
||||
"改調基本盤 API:寫 entry/record 走 POST /entries、/records(帶 template)。"
|
||||
fi
|
||||
|
||||
# ── 規則 3:禁插件直接讀表(SELECT/JOIN 基本盤表)──────────────────────
|
||||
if echo "$CONTENT" | grep -iqE '(SELECT[[:space:]].*FROM|JOIN)[[:space:]]+(entries|templates|entry_values|blocks|triplets)'; then
|
||||
block "直接讀基本盤表" \
|
||||
"插件不准直接接觸表(API-as-Wall),讀也要走 API。" \
|
||||
"改調基本盤 API:查 records/search 端點取回資料,在插件層組裝成圖。"
|
||||
fi
|
||||
|
||||
# ── 規則 4:禁 D1 .prepare(...sql...) 這類繞 API 的直接 SQL ─────────────
|
||||
if echo "$CONTENT" | grep -iqE '\.(prepare|exec)\([^)]*(entries|templates|entry_values|blocks|triplets)'; then
|
||||
block "繞過 API 直接 D1 SQL" \
|
||||
"插件層全程禁 SQL。" \
|
||||
"所有 DB 操作走基本盤 HTTP API,插件不直連 D1。"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/pre-write-guard-no-table.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/pre-bash-guard-no-table.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# .claude/wiki/ — LLM 記憶系統
|
||||
|
||||
> 新 session 開始時從這裡導航。
|
||||
> 目的:讓 CC 不需要重新學習已知的事。
|
||||
> 維護者:CC(人不手動編輯這裡)
|
||||
|
||||
---
|
||||
|
||||
## 核心檔案
|
||||
|
||||
| 檔案 | 何時讀 | 內容 |
|
||||
|------|-------|------|
|
||||
| `status.md` | session 開始第一件事 | 當前進度、下一步 |
|
||||
| `mistakes.md` | 做新功能前 | 已知誤解、快速檢查清單 |
|
||||
| `decisions-summary.md` | 遇到設計判斷時 | 架構決策摘要 |
|
||||
|
||||
---
|
||||
|
||||
## 維護規則
|
||||
|
||||
1. 只增不刪——記錄 append,決策改了加新條目說明「舊決策已更新」
|
||||
2. status.md 每次 session 結束更新
|
||||
3. mistakes.md 每次被糾正後 append
|
||||
4. 發現新的重要決策 → 同時更新 decisions-summary.md 和 docs/2-architecture/decisions/
|
||||
|
||||
---
|
||||
|
||||
## 快速導航
|
||||
|
||||
**這個專案是什麼**:KBDB-graph —— KBDB 的 graph 插件(triplet 採集 + graph 查詢),類比 Apache AGE 之於 Postgres。要抽成獨立 repo(leo 產權)。基本盤(block CRUD)在 `arcrun/kbdb`,不在這。
|
||||
|
||||
**動工前必讀**:
|
||||
- `docs/HANDOFF-kbdb-plugin.md` —— 本目錄專屬交棒(待辦:確認邊界 / 改名+git init+推 GitHub / 定義掛載介面)。
|
||||
- 上游約束見 `CLAUDE.md` 最頂 + `github.com/uncle6me-web/InkStoneCo`。
|
||||
|
||||
**文件去哪找**:
|
||||
- SDD(design+tasks)→ `docs/3-specs/`(現有:arcrun-key-auth、blocks-edit-api)
|
||||
- 歷史記錄 / bug 復盤 → `docs/5-records/`(現有:PATCH 403 bug、upsert feature request)
|
||||
- 分類規則全表 → `docs/README.md`
|
||||
|
||||
**絕對限制**:本目錄只做 graph 插件 / 萬物皆 Block(禁 CREATE/ALTER TABLE)/ API-as-Wall / 部署繞開 GitHub、禁跨 repo Actions / 樂高法(actions < 100 行)。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 架構決策摘要
|
||||
|
||||
> 遇到設計判斷時查這裡。
|
||||
> 完整脈絡在 docs/2-architecture/decisions/。
|
||||
|
||||
---
|
||||
|
||||
## KBDB-graph 定位 — 2026-06-13
|
||||
**結論**:本目錄 = KBDB 的 graph 插件(triplet 採集 + graph 查詢),獨立成 repo,類比 Apache AGE 之於 Postgres。
|
||||
**原因**:graph 能力較龐大、非基本儲存功能、leo 產權、較複雜 → 不留 arcrun。基本盤(block CRUD,D1 三表)= `arcrun/kbdb`,不在本目錄。
|
||||
**詳細**:`docs/HANDOFF-kbdb-plugin.md`;來源 InkStoneCo 頂層 `matrix-rearrange` Phase 2 (R2)。
|
||||
|
||||
## 獨立 repo 名 + 邊界乾淨度 — 2026-06-14
|
||||
**結論**:獨立 repo = 新 repo `uncle6me-web/kbdb-graph-plugin`(leo 拍板,沿用目錄名)。grep 證實插件與基本盤 action 層零耦合,耦合只在 DB 層(讀 blocks/entry_values + triplets VIEW)。
|
||||
**原因**:乾淨的 AGE-on-Postgres,插件抽出無程式碼牽連;唯一要設計的是 DB 掛載介面。
|
||||
**詳細**:`docs/3-specs/kbdb-graph-extraction/design.md`(灰色地帶結論 + 掛載介面)。
|
||||
|
||||
## 萬物皆 Block(KBDB v3)— 2026-02-28
|
||||
**結論**:一張 Block 表 + type 欄位,禁 CREATE/ALTER TABLE,新資料類型用 template;API-as-Wall。
|
||||
**原因**:零 migration 擴充;所有讀寫經 API 不繞過直打 D1。
|
||||
**詳細**:`CLAUDE.md` 下半(v3 全規範,含基本盤,待裁剪只留 graph 插件相關)。
|
||||
|
||||
## 避免再被 GitHub flag(上游鐵律)— 沿用
|
||||
**結論**:禁跨 repo 自動同步 Actions;部署繞開 GitHub(wrangler / scp);新 repo 預設不開 Actions。
|
||||
**原因**:當初 monorepo→多 worker 的 Actions 自動同步 + 高頻 API 害帳號被 flag。
|
||||
**詳細**:InkStoneCo 頂層 CLAUDE.md。
|
||||
|
||||
---
|
||||
|
||||
格式:
|
||||
## [主題] — [YYYY-MM-DD]
|
||||
**結論**:[一句話]
|
||||
**原因**:[簡短說明]
|
||||
**詳細**:docs/2-architecture/decisions/[對應檔案]
|
||||
@@ -0,0 +1,49 @@
|
||||
# CC 已知誤解 + 避坑方法
|
||||
|
||||
> 做新功能前讀一遍。
|
||||
> 格式:每條必須有症狀 + 正確做法 + 原因。
|
||||
|
||||
---
|
||||
|
||||
## 快速檢查清單(做任何事前)
|
||||
|
||||
- [ ] 有對應 SDD 嗎?沒有 → 停手
|
||||
- [ ] 這次修改會影響哪些模組?有沒有連帶破壞?
|
||||
- [ ] 驗收標準是什麼?有客觀證據嗎?
|
||||
|
||||
---
|
||||
|
||||
## 誤解記錄
|
||||
|
||||
⚠️ MISTAKE: 把基本盤 block CRUD 當成本目錄的職責
|
||||
症狀: 在本目錄改/實作 block CRUD、migration 0001/0002、整套 v3 規範。
|
||||
正確做法: 本目錄只做 graph 插件(triplet/graph/entity)。基本盤歸 arcrun/kbdb。動手前先讀 docs/HANDOFF-kbdb-plugin.md 確認邊界。
|
||||
原因: 2026-06-13 定調 KBDB-graph = 插件(AGE-on-Postgres 模式),但舊 CLAUDE.md 下半仍是整套 v3 規範,易誤導。
|
||||
日期: 2026-06-13
|
||||
|
||||
⚠️ MISTAKE: 以為本目錄在 git 版控內、用 git mv 搬檔
|
||||
症狀: git mv 報 "not under version control" / "source directory is empty"。
|
||||
正確做法: 本目錄無獨立 git(matrix 降級後脫離,且被頂層 gitignore)→ 用普通 mv。改名 KBDB-graph 後才 git init。
|
||||
原因: git repo 是 InkStoneCo 頂層,本目錄被 gitignore。
|
||||
日期: 2026-06-14
|
||||
|
||||
⚠️ MISTAKE: 部署或同步走 GitHub Actions
|
||||
症狀: 設計依賴 GitHub API 列檔、push 觸發跨 repo 自動同步。
|
||||
正確做法: 部署繞開 GitHub(wrangler 直推 / scp);讀檔走本地 fs;新 repo 不開 Actions。
|
||||
原因: 上游鐵律——當初正是這模式害帳號被 flag。
|
||||
日期: 2026-06-14
|
||||
|
||||
⚠️ MISTAKE: 假設「核心已在 arcrun」是既成事實
|
||||
症狀: 照 HANDOFF 字面以為 arcrun/kbdb 已是 v3 基本盤、插件直接掛上去、共用同一 D1。
|
||||
正確做法: 讀真身——arcrun/kbdb 其實還是 v2(entries,無 blocks/0005/0007/block-crud),與本插件是不同 D1 庫(arcrun-kbdb vs inkstone-kbdb)。v3 基本盤真身其實在本目錄。動工前用 ls/grep 對真身,不信 HANDOFF 字面。
|
||||
原因: HANDOFF 寫的是「意圖/計劃」,未必已落地;跨 repo 重整時尤其要核對現況。
|
||||
日期: 2026-06-14
|
||||
|
||||
---
|
||||
|
||||
格式:
|
||||
⚠️ MISTAKE: [錯誤描述,一句話]
|
||||
症狀: [CC 通常怎麼表現這個錯]
|
||||
正確做法: [應該怎麼做]
|
||||
原因: [為什麼會錯]
|
||||
日期: [YYYY-MM-DD]
|
||||
@@ -0,0 +1,31 @@
|
||||
# 當前狀態
|
||||
|
||||
> 更新時間:2026-06-14
|
||||
> 每次 session 結束必須更新此檔(用 /wiki-update)。
|
||||
|
||||
---
|
||||
|
||||
## 正在做
|
||||
|
||||
- 知識庫骨架建立完成(system-dev-template 接入:SDD + LLM Wiki)。
|
||||
- **HANDOFF 已讀完並整進 SDD**:`docs/3-specs/kbdb-graph-extraction/`(requirements + design + tasks)。
|
||||
- R-EXT-1 邊界初步分類完成(插件 / 基本盤 / 灰色地帶),見該 design.md。
|
||||
|
||||
## 下次 session 第一件事
|
||||
|
||||
讀 `docs/3-specs/kbdb-graph-extraction/tasks.md`。R-EXT-1 邊界 + grep + 讀 arcrun 全局核對都做完。**主阻擋 = 前置議題定案(task 1.4b)**,等 leo/arcrun 答覆三問(見下)。定案後:升 arcrun v3 / 定掛載形態 → 移交 → 裁剪 CLAUDE.md → git init 推 `uncle6me-web/kbdb-graph-plugin`。
|
||||
|
||||
**讀 arcrun 後的關鍵翻盤**:兩份 HANDOFF 假設「核心已在 arcrun」其實**尚未落地**——`arcrun/kbdb` 還是 v2(entries,無 blocks/0005/0007/block-crud),且與本插件是**不同 D1 庫**(arcrun-kbdb vs inkstone-kbdb)。**v3 基本盤真身其實在本目錄**。詳見 design.md「全局核對發現」。
|
||||
|
||||
## 待總管決策(三問已上呈,不是本子 repo 能拍板)
|
||||
|
||||
leo 指示「整理成 wiki、由總管決策」。已寫成頂層決策文件:
|
||||
📍 `InkStoneCo/.agents/specs/matrix-rearrange/DECISION-kbdb-v3-baseplane.md`(頂層 tasks Phase 2 task 2.5 已連結)
|
||||
|
||||
三問:(1) v3 基本盤怎麼進 arcrun (2) 掛載形態共用 D1 vs 走 API (3) 0005 歸屬。總管答覆後本 repo 才能解除 1.4b / R-EXT-3 阻擋。
|
||||
|
||||
## 已知問題
|
||||
|
||||
- 本目錄目前**無獨立 git**(matrix 降級後脫離),改名後需 `git init`。
|
||||
- `CLAUDE.md` 下半部仍是整套 KBDB v3 規範(含基本盤 Block CRUD),與「只做 graph 插件」新定位不符,待裁剪(HANDOFF 待辦 1 的一部分)。
|
||||
- 上游 bug 未解:`PATCH /blocks/:id` 回 403(見 `docs/5-records/incidents/BUG-2026-05-29-...`)——屬 arcrun/kbdb 基本盤端,非本插件。
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
.swarm/
|
||||
.env
|
||||
.dev.vars
|
||||
*.bak
|
||||
*.db
|
||||
ruvector.db
|
||||
finally.click
|
||||
.DS_Store
|
||||
/tmp/
|
||||
@@ -0,0 +1,89 @@
|
||||
# CLAUDE.md — KBDB-graph 插件開發規範
|
||||
|
||||
> **上游約束(InkStoneCo 總管)**:此目錄(原 matrix/kbdb,已改名 kbdb-graph-plugin)是 InkStoneCo 子專案,受頂層知識庫約束。
|
||||
> 動工前讀 `github.com/uncle6me-web/InkStoneCo` 的 CLAUDE.md + `docs/3-specs/matrix-rearrange/`。
|
||||
> **定位(leo 2026-06-13)**:此 repo = **KBDB-graph 插件**(triplet 採集 + graph 查詢),類比 **Apache AGE 之於 Postgres**。基本盤 = `arcrun/kbdb`(D1 三表 + CRUD API),**不在這裡、不動它**。
|
||||
> **本目錄專屬交棒見 `docs/HANDOFF-kbdb-plugin.md`**,SDD 見 `docs/3-specs/kbdb-graph-extraction/`。
|
||||
|
||||
> 本檔案由 Claude Code 自動讀取。所有在此目錄下的開發必須遵守以下規則。
|
||||
|
||||
---
|
||||
|
||||
## 🔒 KBDB 鐵律(leo 2026-06-14 拍板,最高原則,違反會被 hook exit 2 擋下)
|
||||
|
||||
決策全文:`InkStoneCo/docs/3-specs/matrix-rearrange/DECISION-kbdb-v3-baseplane.md`。
|
||||
|
||||
1. **任何人不准動表** — 禁 `CREATE/ALTER/DROP TABLE`。那 3 表只有基本盤維護者(leo)能改。
|
||||
2. **插件不准直接接觸表** — 禁 `SELECT/INSERT/UPDATE/DELETE`、禁 `JOIN`、禁 `.prepare(...sql...)`、禁綁 D1/Vectorize/AI。
|
||||
3. **讀寫全走基本盤 API/CLI/MCP** — 插件與 AI/人同一條路(薄殼原則)。base URL = `KBDB_BASE_URL` env var。
|
||||
4. **新資料類型 = 建 template + 填 slot,永不建表** — triplet=`template='triplet'`、entity=`template='entity'`,走 `POST /templates`+`POST /records`。
|
||||
5. **零 migration、零 SQL** — 插件目錄無 `migrations/`。SQL 只存在於基本盤 worker 內部。
|
||||
|
||||
> 比 AGE-on-Postgres 更嚴:AGE 能讀 Postgres 表,KBDB 插件連表都不許碰,必須透過基本盤 API。真正的 API-as-Wall。
|
||||
> **誠實限制**:hook 擋語法層明顯 SQL;藏在 helper 裡的繞道擋不了 → 文檔(本檔)+ hook 都不可省。想到「建表」時只准 template/slot。
|
||||
|
||||
### 掛載架構
|
||||
|
||||
```
|
||||
基本盤 arcrun/kbdb(不動) KBDB-graph 插件(本 repo)
|
||||
─ entries / templates / entry_values ─ triplet template 定義 + graph 查詢函式
|
||||
─ CRUD API: ─ 寫 triplet → POST /records (template=triplet)
|
||||
POST /entries POST /templates ─ 查圖 → GET /records/by-template/triplet
|
||||
POST /records GET /entries/search → 插件層【記憶體】組鄰接表跑圖演算法
|
||||
GET /records/by-template/:tpl ─ entity 正規化 → template='entity'(exact match)
|
||||
─ 唯一對外通道 = src/lib/kbdb-client.ts
|
||||
```
|
||||
基本盤 API 契約詳見 `docs/3-specs/kbdb-graph-extraction/design.md`。
|
||||
**基本盤缺口**(base 無 `PUT/DELETE /records/:id`、無 vectorize 語意搜尋)標 `[→arcrun]`,不得為此自建表。embedding/語意搜尋屬基本盤 optional embed 模組,**不是插件職責**。
|
||||
|
||||
---
|
||||
|
||||
## Wiki 讀取順序(LLM 記憶系統,CC 維護)
|
||||
|
||||
| 檔案 | 時機 | 用途 |
|
||||
|------|------|------|
|
||||
| `.claude/wiki/status.md` | session 開始第一件事 | 當前進度、下一步 |
|
||||
| `.claude/wiki/mistakes.md` | 做新功能前 | 已知誤解、避坑清單 |
|
||||
| `.claude/wiki/decisions-summary.md` | 設計判斷時 | 架構決策摘要 |
|
||||
|
||||
文件分類規則見 `docs/README.md`;SDD 在 `docs/3-specs/[子系統]/`(design.md + tasks.md),動手前必須有這兩個檔案。
|
||||
session 結束用 `/wiki-update` 更新 status.md,被糾正後 `/wiki-capture` 寫進 mistakes.md。
|
||||
|
||||
---
|
||||
|
||||
## 樂高法硬性限制(Layer 1)
|
||||
|
||||
- `src/actions/` 目錄下的檔案**嚴禁超過 100 行**,建議 50-80 行
|
||||
- **一檔一事**:每個 action 只做一件具體的事
|
||||
- **無狀態**:Action 不保存記憶體狀態,所有狀態透過參數傳遞或走基本盤 API 持久化(**不碰 D1/Vectorize**)
|
||||
- Route 檔案(`src/routes/`)**不含業務邏輯**,僅驗證參數 + `makeKbdbClient(c.env)` + 呼叫 action
|
||||
|
||||
## 目錄結構
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/ ← kbdb-client.ts(唯一對外 API 通道)、templates.ts(插件 template 定義)
|
||||
├── actions/ ← 核心業務邏輯(純函數,< 100 行,第一參數收 KbdbClient)
|
||||
├── routes/ ← HTTP 入口(Hono route,只做驗證 + 呼叫 action)
|
||||
├── types.ts ← 型別定義(= contracts)
|
||||
└── index.ts ← Worker 進入點(只掛 triplets/graph/entities/search 路由)
|
||||
```
|
||||
|
||||
## 開發流程
|
||||
|
||||
1. 在 `types.ts` 定義輸入/輸出型別
|
||||
2. 在 `tests/` 寫測試(走 `tests/mock-client.ts` 的 mock KbdbClient,不打真網路)
|
||||
3. 在 `actions/` 實作邏輯(透過 `KbdbClient` 讀寫,零 SQL)
|
||||
4. 在 `routes/` 建立 HTTP 入口
|
||||
|
||||
---
|
||||
|
||||
## 技術棧
|
||||
|
||||
- **Framework**: Hono(OpenAPIHono)
|
||||
- **資料層**: 全走基本盤 arcrun/kbdb HTTP API(`KBDB_BASE_URL`)。**插件本身無 D1/Vectorize/AI 綁定。**
|
||||
- **Validation**: Zod
|
||||
- **Testing**: Vitest(純 node + mock client)
|
||||
- **部署**: wrangler 直推 Cloudflare,**不開 Actions**(避免再被 flag,見頂層鐵律)
|
||||
|
||||
> 2026-06-14:按 leo 鐵律改寫完成。21 個違規直接 SQL action 全改走基本盤 API;刪除所有 migrations(插件零建表);移除 D1/Vectorize/AI 綁定。基本盤規範歸 `arcrun/kbdb`。
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Triplet",
|
||||
"description": "知識圖譜三元組:Subject > Predicate > Object",
|
||||
"type": "object",
|
||||
"required": ["subject", "predicate", "object"],
|
||||
"properties": {
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "主詞(實體名稱)"
|
||||
},
|
||||
"predicate": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "謂詞(關係類型)"
|
||||
},
|
||||
"object": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "受詞(目標實體或值)"
|
||||
},
|
||||
"source_block_id": {
|
||||
"type": "string",
|
||||
"description": "來源 Block ID(Logseq 對應)"
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"default": 1.0,
|
||||
"description": "可信度(0-1)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
# KBDB — Arcrun Key Auth
|
||||
|
||||
> 建立:2026-05-05
|
||||
> 狀態:草稿,待 richblack review
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
KBDB 是 Arcrun 平台的子服務(對外統稱 Arcrun)。目前 KBDB 有兩種身份:
|
||||
|
||||
- **Internal**:後端機器使用 `KBDB_INTERNAL_TOKEN`(hex secret)
|
||||
- **Partner**:外部系統使用 `pk_live_xxx` API Key,需先由 internal 建立 partner 記錄
|
||||
|
||||
問題:Arcrun 用戶(人類或 AI agent)登入 arcrun.dev 取得的 `ak_xxx` Key 無法直接存取 KBDB。要存 KBDB 目前沒有自助路徑。
|
||||
|
||||
## 目標
|
||||
|
||||
讓 Arcrun 用戶的 `ak_xxx` Key **直接可用於 KBDB**,不需要額外申請第二把 Key。
|
||||
|
||||
---
|
||||
|
||||
## 設計決策
|
||||
|
||||
### Key 格式不變
|
||||
|
||||
Arcrun Key 格式維持 `ak_` 前綴(32 char hex),KBDB 新增對這個前綴的識別邏輯。
|
||||
不引入新的 Key 格式,不改 Arcrun 的 Key 產生邏輯。
|
||||
|
||||
### KBDB 驗證路徑新增 `ak_` 支援
|
||||
|
||||
現有 `index.ts` auth middleware 的 Partner 驗證區塊(line 103)僅接受 `pk_` 前綴。
|
||||
改為同時接受 `pk_` 和 `ak_`,查詢同一張 partner 表(tpl-partner)。
|
||||
|
||||
```
|
||||
if (effectiveToken.startsWith('pk_') || effectiveToken.startsWith('ak_')) {
|
||||
const partner = await lookupPartner(c.env.DB, tokenHash);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Arcrun 登入時寫入 KBDB partner 記錄
|
||||
|
||||
Arcrun `cypher-executor/src/routes/auth.ts` 的 OAuth callback(`/auth/callback`)
|
||||
在建立 UserRecord 後,呼叫 KBDB `POST /partners` 建立對應的 partner 記錄。
|
||||
|
||||
寫入時機:
|
||||
- 新用戶首次登入 → 建立 partner 記錄(`upsert` 語意:若已存在則跳過)
|
||||
- Key rotate(`PUT /me/api-key/rotate`)→ 舊 partner revoke,建新 partner 記錄
|
||||
- Key revoke(`DELETE /me/api-key`)→ KBDB partner 設為 revoked
|
||||
|
||||
寫入內容:
|
||||
```json
|
||||
{
|
||||
"name": "arcrun:{email}",
|
||||
"org_namespace": "arcrun:{email}",
|
||||
"api_key_hash": "SHA-256(ak_xxx)",
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
`org_namespace` 用 `arcrun:{email}` 格式,與 KBDB 原有的 partner namespace 不衝突。
|
||||
|
||||
### Namespace 隔離
|
||||
|
||||
用戶只能存取自己 `org_namespace` 下的 KBDB 資料,與其他用戶完全隔離。
|
||||
`org_namespace = 'arcrun:{email}'`,不是 admin,不會被提升為 internal。
|
||||
|
||||
### 服務啟用 UI(Dashboard /keys)
|
||||
|
||||
初期:Arcrun 用戶登入即自動建立 KBDB partner 記錄(無需勾選),
|
||||
因為「KBDB 是 Arcrun 的捆綁服務」,就像 n8n 登進去 Data Table 就在那裡。
|
||||
|
||||
未來(有計費需求時):Dashboard `/keys` 頁面可加 toggle 控制,切換時
|
||||
呼叫 `PATCH /me/kbdb` → cypher-executor 再去 KBDB 啟用/停用 partner。
|
||||
|
||||
---
|
||||
|
||||
## 實作範圍(本 SDD)
|
||||
|
||||
### KBDB 側(`matrix/kbdb/`)
|
||||
|
||||
**修改** `src/index.ts`:
|
||||
- auth middleware Partner 驗證區塊:將 `effectiveToken.startsWith('pk_')` 改為同時接受 `ak_`
|
||||
|
||||
**不改**:
|
||||
- `src/actions/partner-auth.ts`(`hashToken` / `lookupPartner` 邏輯不變)
|
||||
- `src/routes/partners.ts`(`POST /partners` 介面不變,Arcrun 呼叫此 endpoint 建記錄)
|
||||
- D1 schema(`tpl-partner` template 不變)
|
||||
|
||||
### Arcrun 側(`matrix/arcrun/cypher-executor/`)
|
||||
|
||||
屬於 `frontend-redesign` SDD 範圍內的後端補充,見該 SDD tasks.md。
|
||||
本 SDD **不**負責 Arcrun 側實作,僅說明預期行為:
|
||||
|
||||
1. OAuth callback 成功後,以 `KBDB_INTERNAL_TOKEN` 呼叫 KBDB `POST /partners`
|
||||
2. Key rotate 時:先 KBDB `DELETE /admin/partners/{id}` (revoke),再建新記錄
|
||||
3. Key revoke 時:KBDB `DELETE /admin/partners/{id}`
|
||||
|
||||
---
|
||||
|
||||
## 不做的事
|
||||
|
||||
- 不改 Key 格式(`ak_` 保留)
|
||||
- 不合併 Arcrun USERS_KV 和 KBDB partner 表(兩邊各自維護)
|
||||
- 不做跨 namespace 的資料共享
|
||||
- 不做 KBDB 側的 OAuth 驗證(KBDB 永遠只驗 token hash)
|
||||
|
||||
---
|
||||
|
||||
## 風險
|
||||
|
||||
| 風險 | 緩解 |
|
||||
|------|------|
|
||||
| Arcrun 寫 KBDB 失敗(KBDB 暫時不可用) | 登入仍成功;寫 KBDB 失敗靜默 log,用戶下次 rotate key 時重建 |
|
||||
| `ak_` Key 被猜測 | 32 char hex,entropy 足夠;與 `pk_` 同等安全 |
|
||||
| email 含特殊字元破壞 namespace | `org_namespace` 用 `arcrun:` + raw email,D1 存 TEXT 無問題 |
|
||||
@@ -0,0 +1,60 @@
|
||||
# KBDB Arcrun Key Auth — Tasks
|
||||
|
||||
> 建立:2026-05-05
|
||||
> 權威進度來源:本檔。完成一項立刻 `[x]`,不批次。
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — SDD 建立
|
||||
|
||||
- [x] 撰寫 `design.md`
|
||||
- [x] 撰寫 `tasks.md`(本檔)
|
||||
- [ ] richblack review + 認可 → 開 Phase 1
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — KBDB auth middleware 接受 `ak_` Key
|
||||
|
||||
**修改檔案**:`matrix/kbdb/src/index.ts`
|
||||
|
||||
- [x] 1.1 將 line 103 的 `effectiveToken.startsWith('pk_')` 改為
|
||||
`effectiveToken.startsWith('pk_') || effectiveToken.startsWith('ak_')`
|
||||
- [ ] 1.2 本地跑現有測試確認不 break:`pnpm test`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Arcrun OAuth callback 寫入 KBDB partner 記錄
|
||||
|
||||
**修改檔案**:`matrix/arcrun/cypher-executor/src/routes/auth.ts`
|
||||
|
||||
> 注意:此 Phase 需要 Arcrun 側有 `KBDB_INTERNAL_TOKEN` 和 `KBDB_BASE_URL` 兩個 env binding。
|
||||
|
||||
- [x] 2.1 在 `Bindings` type(`types.ts`)加入 `KBDB_INTERNAL_TOKEN?: string` 和 `KBDB_BASE_URL?: string`
|
||||
- [x] 2.2 建立 helper `src/lib/kbdb-partner.ts`:
|
||||
- `ensureKbdbPartner(env, email, apiKey)` → PUT /admin/partners/by-key-hash,失敗靜默 log
|
||||
- `revokeKbdbPartner(env, oldApiKey)` → DELETE /admin/partners/{id},失敗靜默 log
|
||||
- [x] 2.3 在 OAuth callback(UserRecord 建立/取得後)呼叫 `ensureKbdbPartner`(fire-and-forget)
|
||||
- [x] 2.4 在 `PUT /me/api-key/rotate` 呼叫:`revokeKbdbPartner(oldKey)` + `ensureKbdbPartner(newKey)`
|
||||
- [x] 2.5 在 `DELETE /me/api-key` 呼叫 `revokeKbdbPartner`
|
||||
- [ ] 2.6 `wrangler secret put KBDB_INTERNAL_TOKEN`(cypher-executor Worker)← 需要人工執行
|
||||
- [x] 2.7 在 `wrangler.toml` 加 `KBDB_BASE_URL = "https://kbdb.finally.click"`
|
||||
|
||||
另外:KBDB `admin.ts` 新增 `PUT /admin/partners/by-key-hash` endpoint(upsert by hash,不產生新 key)。
|
||||
KBDB `types.ts` 加入 `KBDB_INTERNAL_TOKEN` 到 Bindings。
|
||||
KBDB `admin.ts` 放寬 `org_namespace` regex(允許 `arcrun:email@domain` 格式)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — 驗證
|
||||
|
||||
- [ ] 3.1 新用戶 OAuth 登入 → 確認 KBDB partner 記錄建立(`GET /admin/partners` 查詢)
|
||||
- [ ] 3.2 用 `ak_xxx` Key 直接打 KBDB `GET /blocks` → 確認 200(非 401)
|
||||
- [ ] 3.3 Key rotate → 確認舊 Key 401,新 Key 200
|
||||
- [ ] 3.4 Key revoke → 確認舊 Key 401
|
||||
|
||||
---
|
||||
|
||||
## 目前狀態
|
||||
|
||||
- Phase 0 已完成(等 richblack 認可)
|
||||
- Phase 1–3 全部 `[ ]`,等認可後動工
|
||||
@@ -0,0 +1,227 @@
|
||||
# KBDB — Blocks Edit API
|
||||
|
||||
> **建立**:2026-05-06
|
||||
> **狀態**:草稿,待 richblack review
|
||||
> **依賴**:`matrix/kbdb/CLAUDE.md`(萬物皆 Block 架構,2026-02-28 鎖定)
|
||||
> **驅動需求**:`polaris/mira/.agents/specs/mira-app/design.md`(前端 inline edit 直寫 KBDB 的需求)
|
||||
|
||||
---
|
||||
|
||||
## 0. 背景
|
||||
|
||||
KBDB v3「萬物皆 Block」架構鎖定後,現役 routes 涵蓋 Block CRUD 多數操作,但**缺少編輯既有 block 的 PATCH endpoints**。具體缺口:
|
||||
|
||||
| 操作 | 現役狀態 |
|
||||
|---|---|
|
||||
| `POST /blocks/ingest`(建立) | ✅ 已有 |
|
||||
| `GET /blocks/{id}`(讀取單筆) | ✅ 已有 |
|
||||
| `GET /blocks/`(列表 / 查詢) | ✅ 已有 |
|
||||
| `DELETE /blocks/{id}`(刪除) | ✅ 已有 |
|
||||
| **`PATCH /blocks/{id}`(部分更新)** | ❌ **缺** |
|
||||
| `POST /triplets/`(建立) | ✅ 已有 |
|
||||
| **`PATCH /triplets/{id}` / `DELETE /triplets/{id}`** | ❌ **缺** |
|
||||
| `PUT /templates/{name}` | ✅ 已有 |
|
||||
| `PATCH /tasks/{id}/status` | ✅ 已有 |
|
||||
|
||||
→ 沒有 PATCH/UPDATE block 內容的 API,前端「inline edit 寫回 KBDB」做不了。
|
||||
|
||||
> **註**:`src/routes/blocks.ts.bak` 有 PUT /:id 的舊實作可參考,但已被 v3 取代並停用。**不直接複用 .bak 檔案**,按 v3 規範重寫。
|
||||
|
||||
---
|
||||
|
||||
## 1. 範圍
|
||||
|
||||
本 SDD 涵蓋兩件事:
|
||||
|
||||
1. **補三組 endpoint**:`PATCH /blocks/{id}`, `PATCH /triplets/{id}`, `DELETE /triplets/{id}`
|
||||
2. **建三個 templates**:`data-source-config`, `source-skill`, `wiki-page`(為 mira-app 準備)
|
||||
|
||||
**不在範圍內**:
|
||||
- 改 schema(v3 鎖定,禁止 ALTER TABLE)
|
||||
- 改既有 endpoint 行為(純加新 endpoint)
|
||||
- 多用戶權限細分(partner key 已能做 org 隔離,沿用即可)
|
||||
- 編輯歷史 / undo(未來 SDD)
|
||||
|
||||
---
|
||||
|
||||
## 2. PATCH /blocks/{id}
|
||||
|
||||
### 2.1 用途
|
||||
|
||||
部分更新一個既有 block。前端 inline edit 場景:使用者點 edit icon,改 content / refs / tags,按儲存 → 送 PATCH。
|
||||
|
||||
### 2.2 規格
|
||||
|
||||
```
|
||||
PATCH /blocks/{id}
|
||||
Authorization: Bearer <api_key>
|
||||
Content-Type: application/json
|
||||
|
||||
Body (所有欄位皆 optional,至少要有一個):
|
||||
{
|
||||
"content": "新的內容",
|
||||
"tags": ["tag1", "tag2"], // 完整覆寫 tags 陣列
|
||||
"refs": ["block-id-1", "block-id-2"], // 完整覆寫 refs 陣列
|
||||
"slots": { "key": "value" }, // 完整覆寫 slots 物件
|
||||
"source": "...", // 通常不改,但允許
|
||||
"metadata_json": { ... } // 完整覆寫
|
||||
}
|
||||
|
||||
Response 200:
|
||||
{
|
||||
"id": "...",
|
||||
"content": "...",
|
||||
"updated_at": "2026-05-06T...",
|
||||
...完整 block 欄位
|
||||
}
|
||||
|
||||
Response 400:
|
||||
{ "error": "no fields to update" }
|
||||
|
||||
Response 403:
|
||||
{ "error": "block belongs to different org" }
|
||||
|
||||
Response 404:
|
||||
{ "error": "block not found" }
|
||||
```
|
||||
|
||||
### 2.3 行為
|
||||
|
||||
- 只更新 body 內提供的欄位
|
||||
- 自動更新 `updated_at`
|
||||
- 自動重新計算 `content_hash`(如 content 變動)
|
||||
- 自動觸發 embedding 重算(如 content 變動,async)
|
||||
- **權限**:partner key 只能改自己 org 內的 block(透過 user_id 對應),internal token 可改任何 block
|
||||
- **content_hash 衝突**:partner key 不可修改 v3「`source` 為 `system` 的 admin 標記資料」(沿用既有 admin-preservation 規則)
|
||||
|
||||
### 2.4 實作位置
|
||||
|
||||
- Action: `src/actions/update-block.ts`(< 100 行,按 KBDB CLAUDE.md 樂高法)
|
||||
- Route: `src/routes/blocks.ts` 加新 OpenAPI route
|
||||
- Test: `tests/blocks-update.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## 3. Triplet 編輯:使用既有 `PUT /records/:id` + `DELETE /records/:id`
|
||||
|
||||
**設計修正(2026-05-06 實作時發現)**:
|
||||
|
||||
v3 萬物皆 Block 架構下,triplet 是 `tpl-triplet` template 的 record(用 entry_values 存 subject/predicate/object slots)。**既有 `PUT /records/:id` 跟 `DELETE /records/:id` 已涵蓋編輯/刪除需求**,無需新增 `PATCH /triplets/:id`。
|
||||
|
||||
→ 前端編輯異見牆上的 triplet:
|
||||
- 編輯:`PUT /records/{triplet_record_id}` body `{ values: { subject, predicate, object } }`
|
||||
- 刪除:`DELETE /records/{triplet_record_id}`
|
||||
|
||||
→ **本 SDD 不再規劃新 triplet endpoint**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 三個新 Templates
|
||||
|
||||
按 KBDB v3 規範,新資料類型透過 template 定義,**不動 schema**。
|
||||
|
||||
### 5.1 template: `data-source-config`
|
||||
|
||||
每個資料源實例對應一個此 template 的 block。Mira 的「來源篩選」、cron workflow 的「每天去抓什麼」都讀這個。
|
||||
|
||||
```yaml
|
||||
template_name: data-source-config
|
||||
slots:
|
||||
- name: string # "電子時報"、"我的 Logseq"
|
||||
- channel: string # rss / telegram / km-writer / voice-stt / ai-comment / ai-canon
|
||||
- config: object # channel-specific (e.g., rss: {url, schedule})
|
||||
- skill_id: string? # 連到 source-skill block 的 id
|
||||
- enabled: bool
|
||||
- ai_comment: bool # 是否需要 AI 加註解
|
||||
- ai_comment_style: string? # 提示給 claude_api 的風格
|
||||
```
|
||||
|
||||
### 5.2 template: `source-skill`
|
||||
|
||||
每個 source 累積的「分析配方」(prompt + few-shot)。可在前端編輯、版本化。
|
||||
|
||||
```yaml
|
||||
template_name: source-skill
|
||||
slots:
|
||||
- name: string # 例 "電子時報科技類分析"
|
||||
- prompt: text # system_prompt 內容
|
||||
- examples: text? # few-shot examples(markdown)
|
||||
- version: int
|
||||
- based_on: string? # 上一版的 block id
|
||||
```
|
||||
|
||||
### 5.3 template: `wiki-page`
|
||||
|
||||
AI 從河道對話合成的定稿。
|
||||
|
||||
```yaml
|
||||
template_name: wiki-page
|
||||
slots:
|
||||
- entity_name: string
|
||||
- summary: text # markdown
|
||||
- key_blocks: array<string> # 引用的 source block ids
|
||||
- conflicts: array<string>? # 標記為矛盾的 block ids
|
||||
- generated_at: timestamp
|
||||
- version: int
|
||||
- based_on: string?
|
||||
```
|
||||
|
||||
### 5.4 建立方式
|
||||
|
||||
不寫 SQL migration(v3 規範禁止)。改用 KBDB 既有的 `POST /templates`:
|
||||
|
||||
```bash
|
||||
curl -X POST https://kbdb.finally.click/templates \
|
||||
-H "Authorization: Bearer <internal_token>" \
|
||||
-d '{"name": "data-source-config", "slots": [...]}'
|
||||
```
|
||||
|
||||
→ tasks.md 列為 P0 任務(用 internal token 一次性建好)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 實作步驟
|
||||
|
||||
### Phase 1:補 endpoints
|
||||
|
||||
1. 寫 `src/actions/update-block.ts`(純函數,< 100 行,含權限檢查)
|
||||
2. 寫 `tests/blocks-update.test.ts`(含 happy path、403、404、no-fields 三案)
|
||||
3. 寫 `src/routes/blocks.ts` 的 PATCH route(OpenAPI 定義 + 呼叫 action)
|
||||
4. 補 `src/routes/triplets.ts` PATCH(wrapper)+ DELETE(alias)
|
||||
5. 部署 + smoke test
|
||||
|
||||
### Phase 2:建 templates
|
||||
|
||||
6. 用 internal token 呼叫 `POST /templates` 建三個 template
|
||||
7. 驗證:用 partner key (mira 用的) 創建一個 `data-source-config` block 看能否寫成功
|
||||
|
||||
### Phase 3:補 OpenAPI spec
|
||||
|
||||
8. 確認新 routes 自動進 swagger.json(OpenAPIHono 應該自動,需驗證)
|
||||
|
||||
---
|
||||
|
||||
## 7. 風險
|
||||
|
||||
- **embedding 重算成本**:PATCH content 會觸發 vectorize 重算,頻繁 inline edit 可能拖慢。**對策**:embedding 改為 async(行為已是 async,需確認)。
|
||||
- **content_hash 計算遺漏**:忘記重算會讓查重失效。**對策**:在 action 內統一處理,不讓 route 層管。
|
||||
- **partner key 越權**:必須驗 user_id 對應,不能讓 partner A 改 partner B 的 block。**對策**:write tests 涵蓋此案。
|
||||
- **三個 templates 命名衝突**:若 KBDB 已有同名 template 會 fail。**對策**:建立前先 GET /templates/{name} 檢查。
|
||||
|
||||
---
|
||||
|
||||
## 8. 不在範圍內
|
||||
|
||||
- 編輯歷史 / undo / version diff(未來 SDD)
|
||||
- Block soft delete(v3 已有 hard delete,softdelete 是 enhancement)
|
||||
- Bulk PATCH(一次改多個 block,未來看需求)
|
||||
- Field-level permissions(特定欄位只能某些 user 改)
|
||||
- WebSocket 通知 block 改了(即時協作)
|
||||
|
||||
---
|
||||
|
||||
## 9. 變更紀錄
|
||||
|
||||
| 版本 | 日期 | 內容 |
|
||||
|---|---|---|
|
||||
| v0 | 2026-05-06 | 初稿。對應 mira-app 的 inline edit 需求。 |
|
||||
@@ -0,0 +1,68 @@
|
||||
# KBDB Blocks Edit API — Tasks
|
||||
|
||||
> 對應 SDD:[design.md](design.md)
|
||||
> 上次更新:2026-05-06(Phase 1 + Phase 2 完成)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1:補 PATCH endpoint ✅ 完成
|
||||
|
||||
### 1. PATCH /blocks/{id}
|
||||
|
||||
- [x] 1.1 zod schema 直接放在 route 檔(既有 pattern)
|
||||
- [x] 1.2 寫 `src/actions/block-update.ts`(96 行,符合樂高法 < 100)
|
||||
- 取既有 block + getBlock fallback(id 或 logseq_uuid)
|
||||
- 權限檢查:partner key 比對 user_id 前綴
|
||||
- 自動重算 content_hash(如 content 變)
|
||||
- 觸發 embedding async 重算(不阻塞 PATCH 回應)
|
||||
- 寫回 D1
|
||||
- 回傳更新後的 block
|
||||
- [x] 1.3 寫 `tests/blocks-update.test.ts`(**7 case 全通過**)
|
||||
- happy: content + content_hash 重算
|
||||
- happy: tags + refs 同改
|
||||
- 400: 無欄位
|
||||
- 404: 不存在
|
||||
- 403: partner 越權
|
||||
- 200: partner 改自己 namespace
|
||||
- content_hash 在只改 tags 時不變
|
||||
- [x] 1.4 在 `src/routes/blocks.ts` 加 PATCH route(OpenAPI)
|
||||
- [x] 1.5 部署到 prod(kbdb.finally.click)+ smoke test 4 case 通過
|
||||
|
||||
### 2. Triplet 編輯:使用既有 `PUT/DELETE /records/:id`
|
||||
|
||||
- [x] 2.1 設計修正:v3 萬物皆 Block,triplet 是 record,既有 endpoints 已涵蓋。本任務組無需新增 endpoint。
|
||||
- [ ] 2.2 在 mira-app 前端「異見牆」實作呼叫 `PUT /records/:id`(待 mira 階段 3)
|
||||
|
||||
### 3. OpenAPI spec 同步
|
||||
|
||||
- [x] 3.1 OpenAPIHono 自動產 swagger.json(route 用 createRoute 已自動納入)
|
||||
- [ ] 3.2 部署後驗證 swagger UI 顯示新 route(待手動驗證)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2:建三個 templates ✅ 完成
|
||||
|
||||
- [x] 4.1 確認 KBDB 內無同名 template(透過 GET /templates 確認)
|
||||
- [x] 4.2 用 internal token POST /templates 建 `data-source-config`(id: `tpl-data-source-config`)
|
||||
- [x] 4.3 用 internal token POST /templates 建 `source-skill`(id: `tpl-source-skill`)
|
||||
- [x] 4.4 用 internal token POST /templates 建 `wiki-page`(id: `tpl-wiki-page`)
|
||||
- [x] 4.5 驗證:3/3 templates 在 GET /templates 列表內
|
||||
|
||||
---
|
||||
|
||||
## 風險追蹤
|
||||
|
||||
- ~~風險 1:partner key 跨 org 越權~~ — ✅ unit test 已涵蓋(403 partner 越權)
|
||||
- ~~風險 2:embedding 重算造成 D1 寫入 spike~~ — ✅ 改成 fire-and-forget(不 await),不阻塞 PATCH
|
||||
- ~~風險 3:content_hash 不一致~~ — ✅ unit test 驗證 hash 重算對應內容
|
||||
|
||||
## Known Issues(不在本 SDD 範圍,待另開)
|
||||
|
||||
- **`POST /blocks/ingest` 不寫入 `source` 欄位**:input 接受 `source` 參數但僅用於 id slug 生成,未寫進 block 的 source 欄位(block-ingest.ts:84 的 INSERT 缺欄位)。對 mira 影響:所有 source 區分目前無效,需等 KBDB 修復或直接走 `POST /blocks` + slots。建議下一份 KBDB SDD `block-ingest-source-fix` 處理。
|
||||
|
||||
---
|
||||
|
||||
## 部署紀錄
|
||||
|
||||
- 2026-05-06: Worker version `b7df3c38-e138-41fb-a16c-cc9d2dfeebea` 部署上線
|
||||
- Smoke test 通過:content 改寫 + hash 重算、tags 改寫、400 空 body、404 不存在
|
||||
@@ -0,0 +1,145 @@
|
||||
# KBDB-graph 抽出 — Design
|
||||
|
||||
> 建立:2026-06-14
|
||||
> 大改:2026-06-14(leo 拍板鐵律後,推翻原「共用 D1 / 直接 SQL」判斷)
|
||||
> 對應 requirements.md 的 R-EXT-1/2/3
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 修正:原 design 的錯誤判斷(2026-06-14)
|
||||
|
||||
**本檔原版犯了「讀現狀推翻鐵律」的錯**,必須記下來避免重犯:
|
||||
|
||||
- 原版看到插件現狀「直接 SQL 讀 `blocks`/`entry_values`」(28×/31× SQL 引用),**把它當成「AGE-on-Postgres 訊號」當設計依據**,跑去問「要不要共用同一個 D1、直接 SQL 掛在基本盤表上」。
|
||||
- **這是錯的**:現狀那 21 個直接 SQL 的 action 是**違規的歷史產物**(違反本 repo CLAUDE.md「禁止繞過 API 直接 D1 操作 — API-as-Wall」),不是設計依據。讀違規現狀去推翻規則,正是 leo 點名的反例。
|
||||
- leo 2026-06-14 拍板 KBDB 鐵律(見 `InkStoneCo/.../DECISION-kbdb-v3-baseplane.md`):**插件絕不碰表,讀寫全走基本盤 API。零建表、零 migration、零 SQL。** 比 AGE-on-Postgres 更嚴——AGE 能讀 Postgres 表,KBDB 插件連表都不許碰。
|
||||
|
||||
### 連帶修正的次要誤判
|
||||
|
||||
- **「v3 基本盤真身在本目錄」**:原版以為 arcrun/kbdb 是「v2 落後版」、本目錄的 blocks 表才是 v3 真身。讀 `arcrun/kbdb/0001_base.sql` 註釋確認**前提倒反**:arcrun 的 3 表(`entries`/`templates`/`entry_values`,`entry_type='block'`)**是刻意設計的基本盤**,明寫 plugin model(core + AGE)、"Table never changes"。本目錄帶獨立 `blocks` 表 + 0001/0005 CREATE TABLE 的那套「v3」**才是長歪的違規殘留,要刪**。
|
||||
- **「共用 D1 vs 走 API」**:選走 API(鐵律 3)。兩 repo 不同 D1 庫不是問題——插件本來就不該碰基本盤的 D1。
|
||||
- **「0005 歸屬」**:問題消解。插件不該有任何 migration。`entry_values` 屬基本盤、基本盤已有。
|
||||
|
||||
---
|
||||
|
||||
## 基本盤 API 契約(arcrun/kbdb,已存在,不動)
|
||||
|
||||
讀 `arcrun/kbdb/src/` 確認的真實端點(插件改寫的目標介面):
|
||||
|
||||
| 端點 | 用途 | 備註 |
|
||||
|---|---|---|
|
||||
| `POST /entries` | 建 atomic entry | body 用 **`entry_type`**(block/value/...)、**`owner_id`**;回 `{success, entry}` |
|
||||
| `GET /entries` | list(filter: entry_type/owner_id/parent_id/page_name/limit/offset) | |
|
||||
| `GET /entries/search?q=&owner_id=` | D1 LIKE keyword search | base 只有 keyword;語意搜尋是 optional embed module |
|
||||
| `GET/PATCH/DELETE /entries/:id` | 單筆 CRUD | |
|
||||
| `POST /templates` | 建 template(name+slots[])= **替代建表** | |
|
||||
| `GET /templates`、`GET /templates/:name`、`PUT /templates/:name` | template CRUD | |
|
||||
| `POST /records` | 建 record(`{template, values:{slot:content}, owner_id?}`)= 填 slot | 回 `{success, record}` |
|
||||
| `GET /records/by-template/:template?owner_id=` | 列某 template 的所有 record | |
|
||||
| `GET /records/:recordId` | 取單筆 record 的 slot values | |
|
||||
|
||||
> ⚠️ **與插件本地舊 copy 的差異**(改寫時務必對齊):欄位是 `entry_type`/`owner_id`(不是本地的 `type`/`user_id`);回應包在 `{success, ...}`;基本盤**無** `PUT/DELETE /records/:id`、**無** `entity_type` 欄位、**無** vectorize 綁定(語意搜尋與 embedding 屬 optional 模組,不在 base)。
|
||||
|
||||
### 插件專屬狀態怎麼存(leo 2026-06-14 釘正:不准建表)
|
||||
|
||||
triplet 的 `clusters`/`bridge_score`/`confidence`/`source_block_id`、entity 正規化的 `canonical`/`alias`、`entity_type`——**全部是「新資料類型 = 建 template + 填 slot」**,不是建表:
|
||||
|
||||
| 插件狀態 | 存法(純 API) |
|
||||
|---|---|
|
||||
| triplet | `template='triplet'`,slots: subject/predicate/object/source_block_id/confidence/clusters_json/bridge_score → `POST /records` |
|
||||
| entity 正規化 | `template='entity'`,slots: canonical/aliases_json/entity_type/owner → `POST /records`;查重靠 `GET /records/by-template/entity` + (語意比對走 optional embed,base 沒有就降級 exact match) |
|
||||
| entity_type | 不再是 blocks 欄位(基本盤無此欄)→ 收進 entity record 的 slot |
|
||||
|
||||
「插件自建獨立 D1(triplet_clusters 等)」**不是選項**——那仍是建表,違反鐵律 1。問「狀態存哪」時若想到建表,只准 template/slot。
|
||||
|
||||
## 邊界分類(R-EXT-1)
|
||||
|
||||
依 HANDOFF 資產清單 + 實際 `ls` 比對(2026-06-14)。三類:**插件留** / **基本盤走** / **灰色地帶待確認**。
|
||||
(註:下表「基本盤走」的前提是 arcrun 升 v3——見上「前置議題」未解前不要實際搬。)
|
||||
|
||||
### 插件(graph)— 留本目錄
|
||||
|
||||
| 類型 | 檔案 |
|
||||
|---|---|
|
||||
| actions | `triplet-{crud,embed,entities,extract,stats,syntax,update}` (7)、`graph-{nodes,path,traverse}` (3)、`entity-{crud,graph-embed,normalize}` (3)、`predicate-normalize`、`search-{embed,query,suggest}` (3) |
|
||||
| routes | `triplets.ts`、`graph.ts`、`entities.ts`、`search.ts` |
|
||||
| migrations | `0003_triplet_user_id`、`0005_universal_table`、`0006_triplet_clusters`、`0008_entity_type` |
|
||||
| contracts | `triplet.json` |
|
||||
|
||||
> 註:HANDOFF 同時列 `search-*` 與 `entity-graph-embed` 為插件資產,與此處一致。`0005_universal_table` 雖名「universal」(看似基本盤),但 HANDOFF 明列為 triplet/graph 相關 → 暫歸插件,待 R-EXT-3 釐清(見灰色地帶)。
|
||||
|
||||
### 基本盤(block CRUD)— 走 arcrun/kbdb
|
||||
|
||||
| 類型 | 檔案 |
|
||||
|---|---|
|
||||
| actions | `block-{crud,embed,import,ingest,process,update}` (6)、`tag-crud`、`profile-crud` |
|
||||
| routes | `blocks.ts`(+ `blocks.ts.bak` 清掉)、`tags.ts`、`templates.ts`、`profiles.ts` |
|
||||
| migrations | `0001_init`、`0002_block_indexing` |
|
||||
|
||||
### 灰色地帶 — grep 調查結論(2026-06-14)
|
||||
|
||||
調查方法:`src/index.ts` route 掛載、route↔action import、plugin action 的相依、DB 表引用。
|
||||
|
||||
**關鍵發現(耦合面)**:
|
||||
- **插件與基本盤在 action 層完全解耦**:所有 plugin action(triplet/graph/entity/search)**不 import 任何**基本盤或灰色地帶 action;plugin route 也只 import plugin action。`src/lib`、`src/models` 都是空的,無共用程式碼耦合。
|
||||
- **耦合只在 DB 層**:plugin action 直接以 SQL 讀 `blocks`(28×)、`entry_values`(31×)、`triplets`(23×)。→ 這正是 AGE-on-Postgres 訊號:插件靠共用 D1 掛在基本盤表之上。
|
||||
- `triplets` 演進:0001 是 TABLE → 0005/0006/0007 改成 VIEW(疊在 universal table 上)。`entry_values` 是 0005 定義的 universal 儲存**表**。
|
||||
|
||||
**逐檔歸屬建議(附證據)**:
|
||||
|
||||
| 檔案 | 證據 | 建議歸屬 |
|
||||
|---|---|---|
|
||||
| `0005_universal_table` | 定義 `entry_values`(v3 slots 儲存表)= 基本盤核心基礎設施,非純插件 | **基本盤(arcrun)**。⚠️ 與 HANDOFF 列為插件相反——需與 arcrun 對齊 |
|
||||
| `0007_v3_rename_and_cleanup` | 同時 rename 基本盤 `blocks` 表 + 重建 `triplets`/`user_profiles` VIEW | **基本盤(arcrun)**做 rename;插件只依賴「基本盤已是 v3 schema + 有 triplets VIEW」 |
|
||||
| `entry-crud` | **無人 import**(route/index 都沒引用)= dead code | **刪**(v2 legacy) |
|
||||
| `record-crud` + `records.ts` | 被 admin/templates/records 用,非任何 graph route | **基本盤/arcrun**(非 graph) |
|
||||
| `0004_task_status` + `tasks.ts` | `/tasks` 掛載,與 block 儲存/graph 都無關 | **arcrun 其他子系統**(非本插件) |
|
||||
| `block-documents`、`convertPdf` + `convert.ts` | 被 `blocks.ts` 用 / `/convert` 掛載;PDF 轉換 | **基本盤/arcrun**(非 graph) |
|
||||
| `partner-auth` + `partners.ts` | 被 admin/partners/index 用;partner API key 認證 | **基本盤/arcrun 認證層** |
|
||||
| `admin.ts`、`personality.ts` | `/admin`、`/personality` 掛載;與 graph 無關 | **arcrun 其他子系統**(非本插件) |
|
||||
|
||||
**結論**:graph 插件邊界乾淨(action 層零耦合)。耦合**只在 DB 層**——而那層正是要拆掉的違規。掛載介面不是「DB 掛載」,是 **API 掛載**(見下)。
|
||||
|
||||
## 掛載介面(R-EXT-3)= 基本盤 API(API-as-Wall,非共用 D1)
|
||||
|
||||
> 推翻原「AGE-on-Postgres 共用 D1 + triplets VIEW」設計。leo 鐵律:插件不碰表。
|
||||
|
||||
- **掛載 = HTTP API**:插件不共用 D1、不自建表、不建 VIEW。插件**只能用基本盤 API / CLI / MCP 去建與讀**(leo 2026-06-14 釘正),與 AI/人同一條路。連 `triplets` VIEW 都不做——「圖」在**插件層的記憶體裡**從 record 組裝,不靠 DB VIEW。
|
||||
- **插件依賴的基本盤介面** = 上節「基本盤 API 契約」那張表(已存在於 arcrun/kbdb,不需 arcrun 升級、不需合庫)。
|
||||
- **base URL** = `KBDB_BASE_URL` env var(leo 2026-06-14:做成可設定,先留空)。插件透過 `src/lib/kbdb-client.ts` 打它。本地測試用 mock / 本地 base worker,部署時填真網址。
|
||||
- **禁止繞道**:不准把 SQL 藏在 helper 裡假裝走 API。client 只發 HTTP,零 `.prepare`。hook 擋語法層,這條是設計層補強。
|
||||
|
||||
### 改寫對照(21 個違規 action → API)
|
||||
|
||||
| 現狀(違規 SQL) | 改寫成(基本盤 API) |
|
||||
|---|---|
|
||||
| `triplet-crud` `INSERT INTO entry_values` + value blocks | `POST /templates`(確保 triplet template 存在) + `POST /records`(template=triplet, 填 slot) |
|
||||
| `triplet-crud` `queryTriplets` 大 JOIN | `GET /records/by-template/triplet?owner_id=` → 插件層 filter/組裝 |
|
||||
| `triplet-crud` `getTriplet`/`updateTriplet`/`deleteTriplet` | `GET /records/:id`;update/delete 受限於 base 無 PUT/DELETE record(見「缺口」) |
|
||||
| `triplet-stats` 聚合 SQL | `GET /records/by-template/triplet` 後在插件層 reduce 統計 |
|
||||
| `triplet-extract`/`triplet-entities` 讀 blocks | `GET /entries`/`GET /entries/search` |
|
||||
| `graph-{nodes,path,traverse}` 讀 `triplets` 表 | 先 `GET /records/by-template/triplet` 取全部 triplet → 插件層建鄰接表跑圖演算法 |
|
||||
| `entity-crud`/`entity-normalize` 讀寫 entity 表 | `template='entity'` + `POST/GET records`;語意比對降級 exact(base 無 vectorize) |
|
||||
| `search-query` SQL | `GET /entries/search?q=`(keyword);語意搜尋待 optional embed 模組 |
|
||||
| `predicate-normalize` | 純函式(若有 SQL 一併改 API) |
|
||||
|
||||
### 基本盤缺口(改寫時誠實標記,不偷建表補)
|
||||
|
||||
base 目前**無** `PUT/DELETE /records/:id`、**無** entity_type 欄位、**無** vectorize。影響:
|
||||
- triplet/entity 的 **update/delete** → base 缺端點。對策:(a) 標記為 `[→arcrun]` 缺口待基本盤補端點;(b) 暫以「建新 record + 標記舊 record 作廢」soft-delete,**仍走 API**。不得為此自建表或直連 D1。
|
||||
- **語意搜尋 / entity embedding 比對** → 屬 optional embed 模組(不在 base)。base 沒有時降級成 exact match / keyword。embedding 不是插件職責,不在插件建 vectorize。
|
||||
|
||||
## 改寫 task(落到 tasks.md R-EXT-4)
|
||||
|
||||
見 tasks.md 新增的 R-EXT-4「改寫成走 API」區塊。
|
||||
|
||||
## 獨立成 repo(R-EXT-2)
|
||||
|
||||
1. 確認 R-EXT-1 邊界、清掉基本盤檔案(移交 arcrun)後,本目錄只剩插件。
|
||||
2. 改名 KBDB-graph、`git init`、設 remote(帳號問 leo)。
|
||||
3. 部署繞開 GitHub:wrangler 直推 CF;不開 Actions。
|
||||
4. 推 GitHub(由本 CC 自己推)。
|
||||
|
||||
## CLAUDE.md 裁剪
|
||||
|
||||
移除整套 KBDB v3 基本盤規範(萬物皆 Block 全文、50 endpoints、Block CRUD 細節),保留:樂高法、graph 插件定位、掛載介面、上游約束、wiki 讀取順序。基本盤規範移交 arcrun/kbdb 的 CLAUDE.md。
|
||||
@@ -0,0 +1,38 @@
|
||||
# KBDB-graph 抽出 — Requirements
|
||||
|
||||
> 建立:2026-06-14
|
||||
> 來源:InkStoneCo 頂層 `matrix-rearrange` R2 + 本目錄 `docs/HANDOFF-kbdb-plugin.md`
|
||||
> 定調:leo 2026-06-13
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
本目錄(`matrix/kbdb-graph-plugin`,原 `matrix/kbdb`)原是「整套 KBDB」。leo 2026-06-13 拍板拆分:
|
||||
- **基本盤** = `arcrun/kbdb`:D1 三表(blocks/templates/slots)的基本存儲讀寫,已併進 arcrun。
|
||||
- **本目錄(KBDB-graph)** = 掛在基本盤之上的 triplet 採集 + graph 查詢插件,**類比 Apache AGE 之於 Postgres**。
|
||||
|
||||
為何獨立:graph 能力較龐大、非基本存儲、leo 產權較複雜 → 獨立成 repo 不留 arcrun。
|
||||
|
||||
## 需求
|
||||
|
||||
1. **R-EXT-1 確認邊界**:把現有 `src/actions/`、`src/routes/`、`migrations/`、`contracts/` 逐一分類為「插件(triplet/graph/entity/search)」或「基本盤(block CRUD/template/tag/profile)」。基本盤的歸 arcrun/kbdb,插件的留本目錄。順便裁剪 CLAUDE.md(移除基本盤規範,只留 graph 插件相關)。
|
||||
2. **R-EXT-2 獨立成 repo**:改名 KBDB-graph,`git init` + 設 remote(帳號問 leo)+ 推 GitHub。由本 CC 自己推,不經總管。
|
||||
3. **R-EXT-3 定義掛載介面**:KBDB-graph 如何掛在 arcrun/kbdb 基本盤上(AGE-on-Postgres 模式)——插件怎麼讀基本盤的 blocks 表、怎麼宣告自己的 triplet/entity/graph schema。
|
||||
|
||||
## 約束(硬性)
|
||||
|
||||
- **修改不是重建**:在現有實作上改,不重寫。
|
||||
- **部署繞開 GitHub**:wrangler 直推 Cloudflare,禁跨 repo 同步 Actions(當初害帳號被 flag 的模式)。新 repo 預設不開 Actions。
|
||||
- **本目錄現無獨立 git**(matrix 降級後脫離、被 InkStoneCo 頂層 gitignore)→ R-EXT-2 才 git init。在那之前用普通 mv 不是 git mv。
|
||||
- **API-as-Wall / 萬物皆 Block** 仍適用於基本盤;插件對 graph 資料同樣經 API。
|
||||
- **樂高法**:`src/actions/` 單檔 < 100 行、一檔一事、無狀態。
|
||||
|
||||
## leo 已拍板(2026-06-14)
|
||||
|
||||
- 獨立 repo = **新 repo `uncle6me-web/kbdb-graph-plugin`**(沿用現目錄名,非「KBDB-graph」字面)。
|
||||
- 灰色地帶處理方式 = 先 grep 查引用再提建議(已完成,見 design.md)。
|
||||
|
||||
## 仍待確認(與 arcrun 對齊,非 leo)
|
||||
|
||||
- 0005/0007 等基本盤 migration 歸屬與 HANDOFF 清單有出入,移交前對齊 arcrun。
|
||||
@@ -0,0 +1,60 @@
|
||||
# KBDB-graph 抽出 — Tasks
|
||||
|
||||
> 唯一進度來源,不靠對話記憶。完成即時更新。
|
||||
> 狀態:[ ] 未開始 [🔄] 進行中 [x] 完成 [⏸] 卡住/待確認
|
||||
|
||||
---
|
||||
|
||||
## R-EXT-1 確認邊界
|
||||
|
||||
- [x] 1.1 inventory:列出現有 actions/routes/migrations/contracts(2026-06-14 完成,見 design.md)
|
||||
- [x] 1.2 初步分類:插件 / 基本盤 / 灰色地帶(2026-06-14,見 design.md 邊界分類表)
|
||||
- [x] 1.3 灰色地帶 grep 調查:證實插件 action 層零耦合、耦合只在 DB 層;逐檔附證據歸屬(2026-06-14,見 design.md 灰色地帶結論)。⚠️ 0005/0007 歸屬與 HANDOFF 有出入,仍需與 arcrun 對齊(屬 1.4)
|
||||
- [x] 1.4a 讀 arcrun 端真身對齊(2026-06-14):**發現 arcrun/kbdb 還是 v2(entries,無 blocks/0005/0007/block-crud),且兩 repo 是不同 D1 庫**。v3 基本盤真身其實在本目錄。見 design.md「全局核對發現」
|
||||
- [x] 1.4b 前置議題**總管已答覆**(leo 2026-06-14):→ `InkStoneCo/docs/3-specs/matrix-rearrange/DECISION-kbdb-v3-baseplane.md`。三問消解:基本盤已在 arcrun/kbdb 且設計正確、掛載走 API(非共用 D1)、插件零 migration。**阻擋解除。**
|
||||
- [x] 1.4c 不需移交/升級 arcrun——基本盤已正確。插件改寫成走 API 即可(見 R-EXT-4)
|
||||
- [ ] 1.5 裁剪 CLAUDE.md:移除基本盤規範,只留 graph 插件相關
|
||||
- [ ] 1.6 清掉殘留:`blocks.ts.bak`、誤入 repo 的 `ruvector.db`(根 + src/routes/ 各一份)、`finally.click` 空檔、`.swarm`
|
||||
|
||||
## R-EXT-3 定義掛載介面(已定案 2026-06-14)
|
||||
|
||||
- [x] 3.1 確認基本盤 API 契約(讀 arcrun/kbdb src,見 design.md「基本盤 API 契約」表)
|
||||
- [x] 3.2 掛載方式定案:**API-as-Wall**(HTTP API,非共用 D1、非 VIEW、非附加表)。圖在插件層記憶體組裝
|
||||
- [x] 3.3 寫進 design.md 定稿(「掛載介面 = 基本盤 API」節)
|
||||
|
||||
## R-EXT-4 改寫成走 API(核心,2026-06-14 新增)
|
||||
|
||||
> 鐵律:插件零建表、零 migration、零 SQL,只用 API/CLI/MCP。
|
||||
|
||||
- [ ] 4.1 寫 `src/lib/kbdb-client.ts`:封裝基本盤 HTTP API(entries/templates/records),指向 `KBDB_BASE_URL` env var。零 `.prepare`
|
||||
- [ ] 4.2 wrangler.toml:移除 D1/Vectorize 綁定(插件不碰 DB),加 `KBDB_BASE_URL` var(先留空)
|
||||
- [ ] 4.3 改寫 `triplet-crud`(拆 < 100 行):create/query/get/update/delete/stats → API
|
||||
- [ ] 4.4 改寫 `triplet-extract`/`triplet-entities`/`triplet-stats`/`triplet-update` → API
|
||||
- [ ] 4.5 改寫 `graph-{nodes,path,traverse}`:取 triplet records → 插件層組圖
|
||||
- [ ] 4.6 改寫 `entity-{crud,normalize,graph-embed}`:template='entity' + records API;無 vectorize 時降級 exact
|
||||
- [ ] 4.7 改寫 `search-query`/`search-*`:`GET /entries/search`(keyword);語意搜尋標記待 embed 模組
|
||||
- [ ] 4.8 刪違規 migrations(0001/0002/0005/0007 等含 CREATE TABLE)+ 清基本盤 action/route(block-*/entry-crud/record-crud/tag/profile/admin/...)
|
||||
- [ ] 4.9 改測試走 mock client;標記 base 缺口(PUT/DELETE record、vectorize)為 `[→arcrun]`
|
||||
|
||||
## R-EXT-2 獨立成 repo(最後做,依賴 1.4/1.5 完成)
|
||||
|
||||
- [x] 2.1 GitHub 帳號 + repo 名:**leo 拍板 = 新 repo `uncle6me-web/kbdb-graph-plugin`**(2026-06-14)
|
||||
- [ ] 2.2 `git init` + `.gitignore`(排除 ruvector.db、.env、node_modules、.wrangler、*.bak)
|
||||
- [ ] 2.3 設 remote、首次 commit、推 GitHub(本 CC 自己推,不經總管)
|
||||
- [ ] 2.4 部署驗證:wrangler 直推 CF,確認不開 Actions
|
||||
|
||||
---
|
||||
|
||||
## 阻擋項彙整(更新 2026-06-14)
|
||||
|
||||
1. ✅ repo 已定:`uncle6me-web/kbdb-graph-plugin`(解除 2.1)
|
||||
2. ✅ 灰色地帶已 grep 調查完,附證據建議(解除 1.3)
|
||||
3. ⏸ **前置議題(讀 arcrun 後升級為主阻擋)**:arcrun/kbdb 還是 v2、與插件不同 D1 庫。三問待 leo/arcrun 定案:
|
||||
- (1) v3 基本盤(blocks/0005/0007/block-crud)由誰、怎麼進 arcrun?(arcrun 升 v3 vs 本目錄整理好再交)
|
||||
- (2) 掛載形態:共用同一 D1(需合庫)還是插件透過基本盤 **API** 取 block(不共用 D1)?
|
||||
- (3) `0005_universal_table` 歸基本盤(它定義 entry_values)——與 HANDOFF 列為插件矛盾,需 arcrun 確認。
|
||||
|
||||
## 注意
|
||||
|
||||
- arcrun 端對應交棒:`arcrun docs/HANDOFF-matrix-rearrange.md §2`,移交基本盤前先與其對齊。
|
||||
- 在 2.2 git init 前,本目錄無版控 → 搬檔用 `mv` 不是 `git mv`。
|
||||
@@ -0,0 +1,57 @@
|
||||
# FEATURE REQUEST: KBDB 應提供 upsert block endpoint(目前只能 client 端拼 GET+PATCH/POST)
|
||||
|
||||
> 日期:2026-05-29
|
||||
> 來源:arcrun Phase 2(降級假零件成 recipe)
|
||||
> 類型:API 缺口 —— upsert 語義目前不存在於 KBDB,被迫由 client 端拼湊
|
||||
> 關聯:[BUG-2026-05-29-patch-blocks-403-different-org.md](./BUG-2026-05-29-patch-blocks-403-different-org.md)(拼湊路徑的 PATCH 那段還壞著)
|
||||
|
||||
---
|
||||
|
||||
## 問題
|
||||
|
||||
arcrun 原本有一個 `kbdb_upsert_block` 零件,行為是「依 page_name + user_id 查找,有就更新、沒有就新建」。但它**完全是 client 端拼湊**:
|
||||
|
||||
```
|
||||
GET /blocks?page_name=X&limit=10 # 查找
|
||||
→ client 端 filter user_id 找第一筆
|
||||
→ 找到:PATCH /blocks/:id # 更新
|
||||
→ 沒找到:POST /blocks # 新建
|
||||
```
|
||||
|
||||
KBDB **沒有** upsert 語義的 endpoint。實測(同一把 key):
|
||||
|
||||
| 探測 | HTTP |
|
||||
|---|---|
|
||||
| `PUT /blocks` | 404 |
|
||||
| `POST /blocks/upsert` | 404 |
|
||||
| `PUT /blocks/upsert` | 404 |
|
||||
|
||||
## 為什麼這是 KBDB 該補的、不是 arcrun 該拼的
|
||||
|
||||
arcrun 的設計原則是**薄殼 / 薄 API**:arcrun 只幫既有 API 套一層 recipe(endpoint + auth),**不無中生有功能**。
|
||||
|
||||
「先查再分支寫」這套 upsert 邏輯,是在 client 端**變出 KBDB 沒有的功能**。這有幾個壞處:
|
||||
|
||||
1. **競態(race condition)**:GET 和後續 PATCH/POST 之間,別人可能插入同 page_name 的 block,造成重複或覆寫。只有 KBDB server 端用單一交易(upsert / `ON CONFLICT`)才能正確。
|
||||
2. **語義碎裂**:每個 client(arcrun / 其他 SDK)各自拼一套 upsert,filter 規則(怎麼算「同一筆」)可能不一致。
|
||||
3. **拼湊路徑現在還壞著**:它依賴 PATCH /blocks/:id,而那個 endpoint 目前回 403(見關聯 bug)。
|
||||
|
||||
## 建議
|
||||
|
||||
KBDB 提供一個原生 upsert endpoint,例如:
|
||||
|
||||
```
|
||||
POST /blocks/upsert
|
||||
Body: { page_name, user_id, content, type, source, tags, ... }
|
||||
語義:依 (page_name, user_id) 找唯一 block —— 存在則更新、不存在則建立(單一交易,server 端 ON CONFLICT)
|
||||
回應:{ id, action: "created" | "updated" }
|
||||
```
|
||||
|
||||
有了它之後:
|
||||
- arcrun 端只要建一個 `recipe:kbdb_upsert` 指向 `POST /blocks/upsert`,套殼即可,跟其他 5 個 KBDB recipe 一致。
|
||||
- 競態由 KBDB server 端交易保證,client 不再拼湊。
|
||||
|
||||
## arcrun 端現狀(等 KBDB)
|
||||
|
||||
- 其餘 5 個 KBDB 操作已降級成 recipe 並驗收:`kbdb_get`(200) / `kbdb_create_block`(201) / `kbdb_ingest`(201) / `kbdb_delete`(200) 綠;`kbdb_patch_block` 因上述 403 bug 待驗。
|
||||
- `kbdb_upsert_block` **暫不降級、源碼暫留**,等 KBDB 出 `POST /blocks/upsert` 後改建 `recipe:kbdb_upsert` 套殼。
|
||||
@@ -0,0 +1,68 @@
|
||||
# BUG: PATCH /blocks/:id 回 403 "block belongs to different org"(同一把 key 能 create/get/delete 卻不能 patch)
|
||||
|
||||
> 回報日期:2026-05-29
|
||||
> 回報來源:arcrun Phase 2(把 kbdb_* 零件降級成 recipe,逐個驗收時發現)
|
||||
> 嚴重度:中高 —— PATCH endpoint 對「自己剛建、且能刪的 block」拒絕更新,等於 update 能力全壞
|
||||
> 影響:任何「查→改」或 upsert 流程(先 GET 找到 block,再 PATCH 更新)都無法完成
|
||||
|
||||
---
|
||||
|
||||
## 症狀
|
||||
|
||||
用**同一把 API key**、對**同一個 block**,四個操作的結果不一致:
|
||||
|
||||
| 操作 | endpoint | HTTP | 結果 |
|
||||
|---|---|---|---|
|
||||
| 建立 | `POST /blocks` | **201** | ✅ 建出 block,回 id |
|
||||
| 讀取 | `GET /blocks/:id` | **200** | ✅ 讀得到 |
|
||||
| **更新** | **`PATCH /blocks/:id`** | **403** | ❌ `{"error":"block belongs to different org"}` |
|
||||
| 刪除 | `DELETE /blocks/:id` | **200** | ✅ `{"deleted":true}` |
|
||||
|
||||
**矛盾點**:同一把 key 能 create / get / delete 這個 block —— 代表 KBDB 認定我擁有它(org 一致)。但 PATCH 卻說「belongs to different org」。**create 寫進去的 org 判定,和 patch 讀出來比對的 org 判定不一致**,這是 KBDB 內部 org 歸屬邏輯的 bug。
|
||||
|
||||
## 重現(裸 curl,不經 arcrun)
|
||||
|
||||
為排除是 arcrun 注入問題,直接用裸 curl + Bearer token 打 `https://kbdb.finally.click`:
|
||||
|
||||
```bash
|
||||
TOKEN="Bearer ak_402d…" # 同一把 key,全程不變
|
||||
BASE=https://kbdb.finally.click
|
||||
|
||||
# 1. 建立 → 201
|
||||
curl -X POST $BASE/blocks -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"content":"...","type":"note","page_name":"kbdb_bug_repro","source":"...","user_id":"arcrun_phase2"}'
|
||||
# → {"id":"f39ea877-...","action":"created"} HTTP 201
|
||||
|
||||
# 2. 讀取 → 200
|
||||
curl $BASE/blocks/f39ea877-... -H "Authorization: $TOKEN"
|
||||
# → HTTP 200
|
||||
|
||||
# 3. 更新 → 403 ★ BUG
|
||||
curl -X PATCH $BASE/blocks/f39ea877-... -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"content":"patched"}'
|
||||
# → {"error":"block belongs to different org"} HTTP 403
|
||||
|
||||
# 4. 刪除 → 200(證明我擁有此 block)
|
||||
curl -X DELETE $BASE/blocks/f39ea877-... -H "Authorization: $TOKEN"
|
||||
# → {"deleted":true} HTTP 200
|
||||
```
|
||||
|
||||
經 arcrun(cypher-executor → auth_static_key 注入同一把 token → recipe 轉發)也是完全相同結果,所以**確定是 KBDB server 端 PATCH 路徑的問題,不是 client / arcrun 的問題**。
|
||||
|
||||
## 推測方向(給 KBDB 排查)
|
||||
|
||||
create / get / delete 的 org 判定路徑,和 PATCH 的 org 判定路徑不一致。可能:
|
||||
|
||||
1. **PATCH 用了不同的 org 解析來源**:例如 create 用 token → org_id 的某種映射寫入 block,但 PATCH 的 org-check 從另一個欄位 / 另一張表讀,兩邊算出的 org 不同。
|
||||
2. **block 落地時的 org_id 與 token 的 org_id 不一致**:create 時可能用了 default org 或 null org 寫入,PATCH 的 ownership 檢查卻嚴格比對 token org,導致「自己建的卻不是自己 org」。
|
||||
3. **org-check 是 PATCH 獨有、其他三個 verb 沒做**:所以只有 PATCH 露出這個不一致。
|
||||
|
||||
建議從「create 時 block 實際寫入的 org_id」對比「PATCH org-check 讀的 org_id」兩個值下手,它們應該相等卻不等。
|
||||
|
||||
## 對 arcrun 的影響(已隔離,不阻擋 arcrun Phase 2)
|
||||
|
||||
- arcrun 已把 `kbdb_patch_block` 降級成 recipe,recipe 的轉發 + auth 注入經驗證**正確無誤**(請求成功打到 KBDB 的 PATCH handler,非 401)。
|
||||
- 403 屬 KBDB 端行為,依 arcrun 原則「能不能打通由發 key 的服務裁決」,這不是 recipe 的 bug。
|
||||
- 但 arcrun 的 `kbdb_upsert_block`(GET 查找 → 分支 PATCH/POST)會用到 PATCH,**此 bug 未解前,upsert 的 PATCH 分支無法驗收 2xx**。arcrun 端會把該分支標「未驗收:阻擋於 KBDB PATCH 403」。
|
||||
|
||||
KBDB 修好後請通知 arcrun,重跑 `kbdb_patch_block` recipe 驗收即可。
|
||||
@@ -0,0 +1,40 @@
|
||||
# HANDOFF: KBDB triplet/graph → 改名 **KBDB-graph**,作為 KBDB 插件獨立(2026-06-13)
|
||||
|
||||
來源:InkStoneCo 頂層 `.agents/specs/matrix-rearrange/` Phase 2(R2)。
|
||||
**指針式考古**:相關歷史素材在 InkStoneCo `_archive/`,照路徑挖,不複製。
|
||||
|
||||
## 定位(leo 2026-06-13 澄清)
|
||||
|
||||
此目錄 `matrix/kbdb-graph-plugin`(原 matrix/kbdb,已改名)作為 **KBDB-graph**,是**原有 KBDB 的插件**獨立:
|
||||
- **原有 KBDB(基本盤)** = `arcrun/kbdb`:D1 三表(blocks/templates/slots)的基本儲存讀寫,已併進 arcrun。
|
||||
- **KBDB-graph(本目錄→獨立 repo)** = 掛在基本盤之上的 triplet 採集 + graph 查詢能力。**類比 Apache AGE 之於 Postgres**——插件掛在基本儲存上,不取代它。
|
||||
- **為何獨立**:它比較龐大、不是基本存儲功能;且 leo 產權、較複雜。故獨立成 repo,不留在 arcrun。
|
||||
- **由此目錄的 CC 自行 `git init` + 推 GitHub**(命名 `KBDB-graph`,帳號 leo 定/uncle6me-web)。
|
||||
|
||||
## ⚠️ 重大修正(leo 2026-06-14):插件現狀違反 KBDB 鐵律,要改寫
|
||||
|
||||
**先讀總管決策 `InkStoneCo/.agents/specs/matrix-rearrange/DECISION-kbdb-v3-baseplane.md`。**
|
||||
|
||||
你之前的 design.md 把「現狀代碼直接 SQL 讀 entry_values/blocks」當成「AGE-on-Postgres 訊號」、跑去問「要不要共用 D1 直接 SQL」——**這是讀現狀推翻鐵律的錯**。你自己的 CLAUDE.md 第 83 行就寫了「禁止繞過 API 直接 D1 操作 — API-as-Wall」。現狀代碼是違規的歷史產物,不是設計依據。
|
||||
|
||||
### KBDB 鐵律(leo 拍板,最高原則)
|
||||
1. **任何人都不准動表**(CREATE/ALTER/DROP)。只有基本盤維護者能改那 3 表,你不行。
|
||||
2. 新資料類型=建 template,永不建表。triplet=`template='triplet'` 寫進 entry_values。
|
||||
3. 插件**不准直接接觸表**(禁 SELECT/INSERT/JOIN),讀寫**全走基本盤 API**。
|
||||
4. 插件層全程禁 SQL。
|
||||
|
||||
### 正確形態
|
||||
- 基本盤=arcrun/kbdb(3 表 + CRUD API:templates/entries/records/search 端點,已存在),**不動**。它用 `entry_type='block'` 表達 block,無獨立 blocks 表。
|
||||
- 你=`triplet` template 定義(`contracts/triplet.json`)+ graph 查詢函式。寫 triplet→調基本盤 records/entries API;查圖→調基本盤 search API 取 entry_values 在插件層組裝。**零建表、零 migration、零 SQL。**
|
||||
|
||||
## 待辦(由本目錄 CC 執行)
|
||||
|
||||
1. **改寫成走 API/薄殼**(核心):`triplet-crud` 的 `INSERT INTO entry_values`、graph/entity 的 `SELECT FROM ...` → 改調基本盤 API(或 MCP/CLI 薄殼,與 AI/人同一條路)。寫 triplet=建 `template='triplet'` + 填 slot(調 `POST /templates`、`POST /records`);查圖=調 search/records API 取回再組裝。**注意**:arcrun 的 MCP/CLI 目前還沒 KBDB tool(要 arcrun 端先補,見 arcrun HANDOFF §2),在那之前先直調基本盤 HTTP API。
|
||||
2. **刪除違規 migrations**:`0001_init`/`0005_universal_table` 等含 `CREATE TABLE` 的全刪(插件不建表)。triplets VIEW 改用 API 查 + 插件層組裝。
|
||||
3. **改名 `KBDB-graph` + `git init` + 推 GitHub**(帳號 leo 定)。由本 CC 自己推。
|
||||
4. **裁剪 CLAUDE.md**:移除整套 KBDB v3 基本盤規範(那屬 arcrun/kbdb),只留 graph 插件相關 + 上面鐵律。
|
||||
5. 部署繞開 GitHub(wrangler 直推),禁跨 repo 同步 Actions([[github-flagged-risk]])。
|
||||
|
||||
## 注意
|
||||
- 本目錄現**無獨立 git**。改名後 `git init` + remote(帳號 leo 定),由本 CC 推。
|
||||
- arcrun 端基本盤已正確(3 表 + API),不需升 v3、不需搬基本盤。對應交棒見 arcrun `docs/HANDOFF-matrix-rearrange.md` §2。
|
||||
@@ -0,0 +1,56 @@
|
||||
# 文件分類索引
|
||||
|
||||
> CC 整理文件時的分類依據。找不到分類就問,不要猜。
|
||||
|
||||
---
|
||||
|
||||
## 分類規則
|
||||
|
||||
| 目錄 | 放什麼 | 判斷標準 |
|
||||
|------|--------|---------|
|
||||
| **1-vision/** | 為什麼做這個 | 產品願景、北極星、設計哲學 |
|
||||
| **2-architecture/** | 系統怎麼設計的 | 架構圖、技術棧、元件關係 |
|
||||
| **2-architecture/decisions/** | 為什麼這樣設計 | ADR,選A不選B的原因 |
|
||||
| **3-specs/** | 要做什麼 | SDD,每個子系統一個目錄 |
|
||||
| **4-guides/** | 怎麼做 | 部署、開發流程、CLI 用法 |
|
||||
| **5-records/** | 發生過什麼 | 歷史記錄,不修改只增加 |
|
||||
| **5-records/incidents/** | 生產問題復盤 | 故障原因、時間線、改進方案 |
|
||||
| **5-records/test-reports/** | 測試結果 | 壓測報告、驗收記錄 |
|
||||
| **6-user/** | 給使用者看的 | README、安裝教學、FAQ |
|
||||
|
||||
---
|
||||
|
||||
## CC 整理文件時的判斷流程
|
||||
|
||||
```
|
||||
這個文件是...
|
||||
├── 有明確子系統 + 設計內容? → docs/3-specs/[子系統]/
|
||||
├── 解釋為什麼做某個決定? → docs/2-architecture/decisions/
|
||||
├── 說明怎麼操作? → docs/4-guides/
|
||||
├── 記錄發生過的事? → docs/5-records/
|
||||
├── 給外部使用者看的? → docs/6-user/
|
||||
└── 以上都不確定? → 列為「待確認」,問負責人
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SDD 結構(docs/3-specs/ 下每個子系統)
|
||||
|
||||
```
|
||||
docs/3-specs/[子系統名]/
|
||||
├── design.md ← 設計文件(要做什麼、怎麼做、邊界在哪)
|
||||
└── tasks.md ← 任務清單([ ] 未開始 [🔄] 進行中 [x] 完成)
|
||||
```
|
||||
|
||||
CC 動手前必須有這兩個檔案。找不到就停手。
|
||||
|
||||
---
|
||||
|
||||
## .claude/wiki/ — CC 的記憶空間(CC 維護,人不手動編輯)
|
||||
|
||||
| 檔案 | 用途 | 更新時機 |
|
||||
|------|------|---------|
|
||||
| `INDEX.md` | wiki 導引 | 新增 wiki 檔案時 |
|
||||
| `mistakes.md` | CC 已知誤解 + 避坑 | 每次被糾正後 |
|
||||
| `status.md` | 當前進度 + 下一步 | 每次 session 結束 |
|
||||
| `decisions-summary.md` | 架構決策摘要 | 重大決策後 |
|
||||
Generated
+1522
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "kbdb-graph-plugin",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "KBDB-graph 插件:triplet 採集 + graph 查詢,掛在基本盤 arcrun/kbdb 之上(API-as-Wall,零建表/零 SQL)",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/swagger-ui": "^0.6.1",
|
||||
"@hono/zod-openapi": "^1.2.4",
|
||||
"hono": "^4.7.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250219.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
Generated
+2784
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
// Entity CRUD — 零 SQL / 零 D1 版。全走基本盤 API(KbdbClient)。
|
||||
// Entity = 基本盤 record(template=entity),填 slot 不建表。Pending alias 拆到 entity-pending.ts。
|
||||
//
|
||||
// base 缺口 [→arcrun]:無 PUT /records/:id → record 無法原地更新;
|
||||
// addAlias 改「重建一筆新 entity record」覆寫(補上 PUT 後可改原地 patch aliases_json)。
|
||||
|
||||
import type { Entity } from '../types';
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
import { TPL_ENTITY, ensurePluginTemplates, recordToEntity } from '../lib/templates';
|
||||
|
||||
const norm = (s: string): string => s.toLowerCase().trim();
|
||||
|
||||
// ─── Entity ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 建立 Entity(canonical name)。底層 = 一筆 entity template record。 */
|
||||
export async function createEntity(client: KbdbClient, canonical: string, owner?: string): Promise<Entity> {
|
||||
await ensurePluginTemplates(client);
|
||||
const id = await client.createRecord(
|
||||
TPL_ENTITY,
|
||||
{ canonical, aliases_json: '[]', entity_type: '', owner: owner ?? '' },
|
||||
owner,
|
||||
);
|
||||
return { id, canonical, aliases: [] };
|
||||
}
|
||||
|
||||
/** exact match 查找 Entity(小寫去空白比對 canonical 與 aliases)。語意相似度屬基本盤 embed 模組,不在此。 */
|
||||
export async function findEntityByName(client: KbdbClient, name: string, owner?: string): Promise<Entity | null> {
|
||||
const target = norm(name);
|
||||
const records = await client.listRecordsByTemplate(TPL_ENTITY, owner);
|
||||
for (const rec of records) {
|
||||
const ent = recordToEntity(rec);
|
||||
if (norm(ent.canonical) === target) return ent;
|
||||
if (ent.aliases.some(a => norm(a) === target)) return ent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 列出所有 Entity。 */
|
||||
export async function listEntities(client: KbdbClient, limit = 100, owner?: string): Promise<Entity[]> {
|
||||
const records = await client.listRecordsByTemplate(TPL_ENTITY, owner);
|
||||
return records.map(recordToEntity).filter(e => e.canonical).slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增 alias。base 無 PUT /records/:id → 改「重建一筆新 entity record」覆寫(含舊 canonical + 既有 aliases + 新 alias)。
|
||||
* [→arcrun] base 缺 PUT /records/:id:補上後改為原地 patch aliases_json,省一次重建。
|
||||
*/
|
||||
export async function addAlias(client: KbdbClient, entityId: string, alias: string, owner?: string): Promise<void> {
|
||||
const rec = await client.getRecord(entityId);
|
||||
if (!rec) throw new Error(`Entity ${entityId} not found`);
|
||||
const ent = recordToEntity(rec);
|
||||
if (ent.aliases.includes(alias)) return;
|
||||
const aliases = [...ent.aliases, alias];
|
||||
await ensurePluginTemplates(client);
|
||||
await client.createRecord(
|
||||
TPL_ENTITY,
|
||||
{
|
||||
canonical: ent.canonical,
|
||||
aliases_json: JSON.stringify(aliases),
|
||||
entity_type: rec.values.entity_type ?? '',
|
||||
owner: rec.values.owner ?? owner ?? '',
|
||||
},
|
||||
owner,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Entity Graph Embedding — [→arcrun embed 模組]
|
||||
//
|
||||
// 處置決定(2026-06-14):整檔原本是 embedding 邏輯(聚合 entity 所有 triplet 的
|
||||
// bge-m3 向量、加權平均成認知向量、upsert 到 Vectorize namespace 'entity-graph')。
|
||||
// 依鐵律 4:embedding / 語意搜尋【不是插件職責】,屬基本盤 optional embed 模組。
|
||||
// 插件不綁 AI/Vectorize,故移除全部 D1 + Vectorize 實作,僅留薄殼標記去向。
|
||||
//
|
||||
// 若日後需要 entity graph embedding:在基本盤 embed 模組實作(讀 triplet record 走 API +
|
||||
// base 內部 AI/Vectorize),插件這層不碰。此函式維持簽名相容,永遠回 updated:false。
|
||||
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
|
||||
/**
|
||||
* [→arcrun embed 模組] no-op 薄殼。
|
||||
* graph embedding 已移出插件職責;保留簽名供 caller 不需改動,永遠回 { updated: false }。
|
||||
*/
|
||||
export async function recalcEntityGraph(
|
||||
_client: KbdbClient,
|
||||
_entityName: string,
|
||||
): Promise<{ updated: boolean; triplet_count: number }> {
|
||||
// 插件不做 embedding。實作搬到基本盤 optional embed 模組。
|
||||
return { updated: false, triplet_count: 0 };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Entity 正規化 — 純 exact match 版(零 AI / 零 Vectorize)。
|
||||
//
|
||||
// 鐵律:embedding / 語意相似度合併【不是插件職責】,屬基本盤 optional embed 模組。
|
||||
// 插件只做 exact match(小寫去空白比對 canonical / aliases):
|
||||
// 命中 → 回 canonical;未命中 → 建新 entity 後回 rawName。
|
||||
// 原本的 cosine 門檻(0.92 merge / 0.75 pending)與 pending 流程,
|
||||
// 待基本盤 embed 模組上線後在 base 層處理;插件不綁 AI/Vectorize。
|
||||
|
||||
import { findEntityByName, createEntity } from './entity-crud';
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
|
||||
/**
|
||||
* 正規化 rawName(純 exact):
|
||||
* 1. exact match 命中 → 回傳 canonical
|
||||
* 2. 未命中 → 建新 entity → 回傳 rawName
|
||||
* 任何錯誤靜默降級,回傳 rawName。
|
||||
*/
|
||||
export async function normalizeEntity(
|
||||
client: KbdbClient,
|
||||
rawName: string,
|
||||
owner?: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const exact = await findEntityByName(client, rawName, owner);
|
||||
if (exact) return exact.canonical;
|
||||
|
||||
await createEntity(client, rawName, owner);
|
||||
return rawName;
|
||||
} catch (err) {
|
||||
console.error('[normalizeEntity] error, fallback to rawName:', err);
|
||||
return rawName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Pending Alias — 零 SQL / 零 D1。全走基本盤 API(template=entity_pending record)。
|
||||
//
|
||||
// base 缺口 [→arcrun]:無 DELETE /records/:id → pending record 無法硬刪。
|
||||
// confirm/reject 採 soft:執行動作但不刪 pending(待 base 補 DELETE)。
|
||||
// 故 getPendingAliases 須由 caller 自行過濾已處理者,或待 DELETE 補上後在此硬刪。
|
||||
|
||||
import type { Entity, PendingAlias } from '../types';
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
import { TPL_ENTITY_PENDING, ensurePluginTemplates } from '../lib/templates';
|
||||
import { createEntity, addAlias } from './entity-crud';
|
||||
|
||||
/** 建立 Pending Alias 記錄(一筆 entity_pending record)。 */
|
||||
export async function createPendingAlias(
|
||||
client: KbdbClient,
|
||||
rawName: string,
|
||||
candidateEntityId: string,
|
||||
candidateCanonical: string,
|
||||
similarity: number,
|
||||
owner?: string,
|
||||
): Promise<PendingAlias> {
|
||||
await ensurePluginTemplates(client);
|
||||
const id = await client.createRecord(
|
||||
TPL_ENTITY_PENDING,
|
||||
{
|
||||
raw_name: rawName,
|
||||
candidate_entity_id: candidateEntityId,
|
||||
candidate_canonical: candidateCanonical,
|
||||
similarity: String(similarity),
|
||||
},
|
||||
owner,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
raw_name: rawName,
|
||||
candidate_entity_id: candidateEntityId,
|
||||
candidate_canonical: candidateCanonical,
|
||||
similarity,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/** 列出所有 Pending Aliases。 */
|
||||
export async function getPendingAliases(client: KbdbClient, limit = 100, owner?: string): Promise<PendingAlias[]> {
|
||||
const records = await client.listRecordsByTemplate(TPL_ENTITY_PENDING, owner);
|
||||
return records
|
||||
.filter((r) => r.values.raw_name)
|
||||
.map((r) => ({
|
||||
id: r.record_id,
|
||||
raw_name: r.values.raw_name,
|
||||
candidate_entity_id: r.values.candidate_entity_id ?? '',
|
||||
candidate_canonical: r.values.candidate_canonical ?? '',
|
||||
similarity: parseFloat(r.values.similarity ?? '0'),
|
||||
created_at: 0,
|
||||
}))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/** 確認 → addAlias 到候選 entity。pending soft 保留([→arcrun] base 缺 DELETE record)。 */
|
||||
export async function confirmPendingAlias(client: KbdbClient, pendingId: string, owner?: string): Promise<void> {
|
||||
const rec = await client.getRecord(pendingId);
|
||||
if (!rec || !rec.values.raw_name) throw new Error(`Pending alias ${pendingId} not found`);
|
||||
await addAlias(client, rec.values.candidate_entity_id, rec.values.raw_name, owner);
|
||||
}
|
||||
|
||||
/** 拒絕 → 以 raw_name 建新 entity。pending soft 保留([→arcrun] base 缺 DELETE record)。 */
|
||||
export async function rejectPendingAlias(client: KbdbClient, pendingId: string, owner?: string): Promise<Entity> {
|
||||
const rec = await client.getRecord(pendingId);
|
||||
if (!rec || !rec.values.raw_name) throw new Error(`Pending alias ${pendingId} not found`);
|
||||
return createEntity(client, rec.values.raw_name, owner);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 圖節點操作 — 零 SQL、零 D1,全走基本盤 API
|
||||
// 取全部 triplet 一次(queryTriplets),在插件層記憶體 group/filter。
|
||||
|
||||
import type { Triplet } from '../types';
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
import { queryTriplets } from './triplet-crud';
|
||||
|
||||
/** 取全部 triplet(一次 API 呼叫,limit 拉滿上限)。 */
|
||||
async function loadAllTriplets(client: KbdbClient): Promise<Triplet[]> {
|
||||
const { triplets } = await queryTriplets(client, { limit: 2000 });
|
||||
return triplets;
|
||||
}
|
||||
|
||||
export async function listNodes(
|
||||
client: KbdbClient,
|
||||
options: { search?: string; limit?: number },
|
||||
): Promise<Array<{ node: string; edge_count: number }>> {
|
||||
const limit = Math.min(options.limit ?? 100, 500);
|
||||
const triplets = await loadAllTriplets(client);
|
||||
|
||||
// subject ∪ object,記憶體 group 計 edge_count
|
||||
const counts = new Map<string, number>();
|
||||
for (const t of triplets) {
|
||||
counts.set(t.subject, (counts.get(t.subject) ?? 0) + 1);
|
||||
counts.set(t.object, (counts.get(t.object) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let nodes = Array.from(counts.entries()).map(([node, edge_count]) => ({ node, edge_count }));
|
||||
if (options.search) {
|
||||
const q = options.search.toLowerCase();
|
||||
nodes = nodes.filter((n) => n.node.toLowerCase().includes(q));
|
||||
}
|
||||
nodes.sort((a, b) => b.edge_count - a.edge_count);
|
||||
return nodes.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function getNodeEdges(
|
||||
client: KbdbClient,
|
||||
name: string,
|
||||
): Promise<Triplet[]> {
|
||||
const triplets = await loadAllTriplets(client);
|
||||
return triplets.filter((t) => t.subject === name || t.object === name);
|
||||
}
|
||||
|
||||
export async function getNeighbors(
|
||||
client: KbdbClient,
|
||||
name: string,
|
||||
): Promise<string[]> {
|
||||
const edges = await getNodeEdges(client, name);
|
||||
const neighbors = new Set<string>();
|
||||
for (const t of edges) {
|
||||
if (t.subject !== name) neighbors.add(t.subject);
|
||||
if (t.object !== name) neighbors.add(t.object);
|
||||
}
|
||||
return Array.from(neighbors);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// 最短路徑(BFS)— 零 SQL、零 D1。
|
||||
// 取全部 triplet 一次建無向鄰接表,記憶體 BFS 求最短路。
|
||||
|
||||
import type { Triplet } from '../types';
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
import { queryTriplets } from './triplet-crud';
|
||||
|
||||
type PathResult = {
|
||||
path: string[] | null;
|
||||
edges: Triplet[];
|
||||
hops: number;
|
||||
};
|
||||
|
||||
/** node → 與之相連的所有 triplet(無向鄰接表)。 */
|
||||
function buildAdjacency(triplets: Triplet[]): Map<string, Triplet[]> {
|
||||
const adj = new Map<string, Triplet[]>();
|
||||
const push = (node: string, t: Triplet) => {
|
||||
const list = adj.get(node);
|
||||
if (list) list.push(t);
|
||||
else adj.set(node, [t]);
|
||||
};
|
||||
for (const t of triplets) {
|
||||
push(t.subject, t);
|
||||
if (t.object !== t.subject) push(t.object, t);
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
export async function findShortestPath(
|
||||
client: KbdbClient,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<PathResult> {
|
||||
if (from === to) return { path: [from], edges: [], hops: 0 };
|
||||
|
||||
const maxDepth = 6;
|
||||
const { triplets } = await queryTriplets(client, { limit: 2000 });
|
||||
const adj = buildAdjacency(triplets);
|
||||
|
||||
const visited = new Set<string>([from]);
|
||||
const parent = new Map<string, { node: string; edge: Triplet }>();
|
||||
const queue: Array<{ node: string; depth: number }> = [{ node: from, depth: 0 }];
|
||||
let found = false;
|
||||
|
||||
while (queue.length > 0 && !found) {
|
||||
const { node: current, depth } = queue.shift()!;
|
||||
if (depth >= maxDepth) continue;
|
||||
|
||||
for (const t of adj.get(current) ?? []) {
|
||||
const next = t.subject === current ? t.object : t.subject;
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
parent.set(next, { node: current, edge: t });
|
||||
queue.push({ node: next, depth: depth + 1 });
|
||||
if (next === to) { found = true; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) return { path: null, edges: [], hops: -1 };
|
||||
|
||||
// 回溯路徑
|
||||
const path: string[] = [to];
|
||||
const pathEdges: Triplet[] = [];
|
||||
let current = to;
|
||||
while (parent.has(current)) {
|
||||
const { node, edge } = parent.get(current)!;
|
||||
path.unshift(node);
|
||||
pathEdges.unshift(edge);
|
||||
current = node;
|
||||
}
|
||||
|
||||
return { path, edges: pathEdges, hops: path.length - 1 };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// 圖遍歷 + 關係查詢 — 零 SQL、零 D1。
|
||||
// 取全部 triplet 一次建鄰接表,BFS / filter 在記憶體跑。
|
||||
|
||||
import type { Triplet, GraphNode } from '../types';
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
import { queryTriplets } from './triplet-crud';
|
||||
|
||||
/** node → 與之相連的所有 triplet(無向鄰接表)。 */
|
||||
function buildAdjacency(triplets: Triplet[]): Map<string, Triplet[]> {
|
||||
const adj = new Map<string, Triplet[]>();
|
||||
const push = (node: string, t: Triplet) => {
|
||||
const list = adj.get(node);
|
||||
if (list) list.push(t);
|
||||
else adj.set(node, [t]);
|
||||
};
|
||||
for (const t of triplets) {
|
||||
push(t.subject, t);
|
||||
if (t.object !== t.subject) push(t.object, t);
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
export async function traverseGraph(
|
||||
client: KbdbClient,
|
||||
start: string,
|
||||
maxDepth: number,
|
||||
): Promise<GraphNode[]> {
|
||||
const depth = Math.min(maxDepth, 5);
|
||||
const { triplets } = await queryTriplets(client, { limit: 2000 });
|
||||
const adj = buildAdjacency(triplets);
|
||||
|
||||
const visited = new Set<string>();
|
||||
const queue: Array<{ node: string; level: number }> = [{ node: start, level: 0 }];
|
||||
const results: GraphNode[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { node, level } = queue.shift()!;
|
||||
if (visited.has(node) || level > depth) continue;
|
||||
visited.add(node);
|
||||
|
||||
const edges = adj.get(node) ?? [];
|
||||
results.push({ node, level, edges });
|
||||
|
||||
for (const t of edges) {
|
||||
const next = t.subject === node ? t.object : t.subject;
|
||||
if (!visited.has(next)) queue.push({ node: next, level: level + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function queryRelation(
|
||||
client: KbdbClient,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<Triplet[]> {
|
||||
const { triplets } = await queryTriplets(client, { limit: 2000 });
|
||||
return triplets.filter(
|
||||
(t) =>
|
||||
(t.subject === from && t.object === to) ||
|
||||
(t.subject === to && t.object === from),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Predicate 正規化(插件層):純字串正規化,零 embedding。
|
||||
//
|
||||
// 鐵律:插件零 SQL / 零 D1 / 零 Vectorize。
|
||||
// [→arcrun embed 模組] 原本用 bge-m3 embedding cosine 比對近義 predicate(>0.90 use_existing /
|
||||
// 0.85~0.90 pending / <0.85 new)。語意比對屬基本盤 embed 模組,不是 graph 插件職責。
|
||||
// 此處降級為純字串正規化(trim + 小寫 + 收斂空白),任何近義收斂交給基本盤 embed 模組。
|
||||
// 任何錯誤靜默降級,不擋寫入。
|
||||
|
||||
export type PredicateNormalizeResult =
|
||||
| { action: 'use_existing'; canonical: string; score: number }
|
||||
| { action: 'pending'; similar: Array<{ canonical: string; score: number }> }
|
||||
| { action: 'new'; predicate: string };
|
||||
|
||||
/**
|
||||
* 正規化 predicate(純字串):trim + 收斂連續空白。回傳 { action: 'new', predicate }。
|
||||
* [→arcrun embed 模組] 近義收斂(use_existing / pending)交由基本盤 embed 模組處理,插件不做向量比對。
|
||||
* 任何錯誤靜默降級,回傳原始 predicate。
|
||||
*/
|
||||
export function normalizePredicateOnWrite(predicate: string): PredicateNormalizeResult {
|
||||
try {
|
||||
const canonical = predicate.trim().replace(/\s+/g, ' ');
|
||||
return { action: 'new', predicate: canonical };
|
||||
} catch (err) {
|
||||
console.error('[normalizePredicateOnWrite] error, fallback to raw:', err);
|
||||
return { action: 'new', predicate };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// [→arcrun embed 模組] 手動 embedding 管理(embedAndStore / getVector / deleteVector)
|
||||
// 已移出插件:embedding / Vectorize 屬基本盤的 optional embed 模組,不是 graph 插件職責。
|
||||
//
|
||||
// 鐵律:插件零 SQL / 零 D1 / 零 Vectorize。此檔不再持有任何向量邏輯。
|
||||
// 保留薄殼 stub 以維持匯出相容;呼叫端(route)應改打基本盤 embed 模組,或停用此端點。
|
||||
|
||||
const NOT_IN_PLUGIN =
|
||||
'embedding 屬基本盤 optional embed 模組([→arcrun embed 模組]),不在 graph 插件實作';
|
||||
|
||||
export function embedNotSupported(): never {
|
||||
throw new Error(NOT_IN_PLUGIN);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// 搜尋(插件層):只做基本盤 keyword 搜尋(D1 LIKE,走 GET /entries/search)。
|
||||
//
|
||||
// 鐵律:插件零 SQL / 零 D1 / 零 Vectorize。讀寫只透過 KbdbClient。
|
||||
// [→arcrun embed 模組] 語意搜尋 / embedding 是基本盤的 optional embed 模組,不是插件職責。
|
||||
// 插件不綁 AI/Vectorize;「語意搜尋」在此降級為 keyword 搜尋。
|
||||
|
||||
import type { KbdbClient, BaseEntry } from '../lib/kbdb-client';
|
||||
|
||||
export type SearchMatch = {
|
||||
score: number;
|
||||
metadata: Record<string, unknown>;
|
||||
type: 'block' | 'triplet';
|
||||
triplet: Record<string, unknown> | null;
|
||||
block: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type KeywordSearchOptions = {
|
||||
limit?: number;
|
||||
owner_id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 基本盤 keyword 搜尋。打 GET /entries/search(D1 LIKE),把 BaseEntry 包成相容的 SearchMatch。
|
||||
* [→arcrun embed 模組] 若日後要真語意搜尋,由基本盤 embed 模組提供,不在插件實作。
|
||||
*/
|
||||
export async function keywordSearch(
|
||||
client: KbdbClient,
|
||||
query: string,
|
||||
options: KeywordSearchOptions = {},
|
||||
): Promise<SearchMatch[]> {
|
||||
const { limit = 10, owner_id } = options;
|
||||
const entries = await client.searchEntries(query, owner_id);
|
||||
|
||||
const matches: SearchMatch[] = entries.map((e: BaseEntry) => ({
|
||||
score: 0, // keyword 搜尋無相似度分數;語意分數待 embed 模組
|
||||
metadata: {},
|
||||
type: 'block' as const,
|
||||
triplet: null,
|
||||
block: e as unknown as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
return matches.slice(0, limit);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// 搜尋推薦(插件層):用基本盤 keyword 搜尋結果組模板。
|
||||
//
|
||||
// 鐵律:插件零 SQL / 零 D1 / 零 Vectorize。讀寫只透過 KbdbClient。
|
||||
// [→arcrun embed 模組] 語意分數 / 相似度排序屬基本盤 embed 模組;此處只做 keyword 搜尋 + 零幻覺模板。
|
||||
// 零幻覺:只用知識庫資料組模板,不經 LLM。
|
||||
|
||||
import { keywordSearch } from './search-query';
|
||||
import type { SearchMatch } from './search-query';
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
|
||||
type SuggestResult = {
|
||||
suggestion: string;
|
||||
matches: SearchMatch[];
|
||||
count: number;
|
||||
};
|
||||
|
||||
export async function suggestKnowledge(
|
||||
client: KbdbClient,
|
||||
query: string,
|
||||
limit: number = 10,
|
||||
owner_id?: string,
|
||||
): Promise<SuggestResult> {
|
||||
const allMatches = await keywordSearch(client, query, { limit, owner_id });
|
||||
|
||||
const goodMatches = allMatches.filter(m => m.triplet !== null || m.block !== null).filter(m => {
|
||||
// 過濾純 ref 類 block:((uuid))、{{embed ((uuid))}} 等對用戶無意義
|
||||
if (m.type === 'block' && m.block) {
|
||||
const content = (m.block as Record<string, string>).content || '';
|
||||
if (/^\s*\(\([a-f0-9-]+\)\)\s*$/.test(content)) return false;
|
||||
if (/^\s*\{\{embed\s+\(\([a-f0-9-]+\)\)\}\}\s*$/.test(content)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (goodMatches.length === 0) {
|
||||
return { suggestion: '', matches: [], count: 0 };
|
||||
}
|
||||
|
||||
const suggestion = buildFallback(goodMatches);
|
||||
|
||||
return { suggestion, matches: goodMatches, count: goodMatches.length };
|
||||
}
|
||||
|
||||
// 親切口語化回應(零幻覺,只引用知識庫資料)
|
||||
function buildFallback(matches: SearchMatch[]): string {
|
||||
const blockMatches = matches.filter(m => m.type === 'block');
|
||||
const tripletMatches = matches.filter(m => m.type === 'triplet');
|
||||
|
||||
if (blockMatches.length > 0 && tripletMatches.length === 0) {
|
||||
const first = blockMatches[0].block as Record<string, string> | null;
|
||||
const pageName = first?.page_name || '筆記';
|
||||
if (blockMatches.length === 1) {
|
||||
return `你之前在「${pageName}」寫過相關的內容,幫你找出來了~`;
|
||||
}
|
||||
return `嘿,你之前寫過 ${blockMatches.length} 筆相關筆記,幫你撈出來了~`;
|
||||
}
|
||||
|
||||
if (tripletMatches.length > 0 && blockMatches.length === 0) {
|
||||
const first = tripletMatches[0].triplet as Record<string, string> | null;
|
||||
if (!first) return `欸,找到 ${matches.length} 筆你之前寫過的東西,看看有沒有幫助?`;
|
||||
if (tripletMatches.length === 1) {
|
||||
return `你之前有寫到「${first.subject} ${first.predicate} ${first.object}」,是不是跟這個有關?`;
|
||||
}
|
||||
return `嘿,你之前寫過 ${tripletMatches.length} 筆跟「${first.subject}」相關的筆記,幫你撈出來了~`;
|
||||
}
|
||||
|
||||
return `嘿,找到 ${matches.length} 筆相關資料(${blockMatches.length} 筆筆記、${tripletMatches.length} 筆知識關聯),幫你整理好了~`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Triplet 知識分群 — 純函式
|
||||
//
|
||||
// 原版用 Workers AI 自動判斷 cluster。插件已不綁 AI(embedding/LLM 屬基本盤 optional 模組)。
|
||||
// 分群改為「由呼叫端(基本盤 embed 模組 / 上游 ingest)算好後傳入」;這裡只做正規化與 bridge_score 計算。
|
||||
|
||||
/** 正規化 cluster 標籤:小寫、去空白、最多 3 個、去重。 */
|
||||
export function classifyClusters(clusters: string[] | undefined): { clusters: string[]; bridgeScore: number } {
|
||||
const norm = Array.from(
|
||||
new Set(
|
||||
(clusters ?? [])
|
||||
.filter((c) => typeof c === 'string' && c.trim())
|
||||
.map((c) => c.trim().toLowerCase()),
|
||||
),
|
||||
).slice(0, 3);
|
||||
return { clusters: norm, bridgeScore: Math.max(0, norm.length - 1) };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// 三元組 CRUD — 走基本盤 API(API-as-Wall,零 SQL)
|
||||
// 寫 triplet = 確保 template='triplet' + POST /records 填 slot。
|
||||
// 查 triplet = GET /records/by-template/triplet → 插件層 filter/組裝。
|
||||
|
||||
import type { Triplet } from '../types';
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
import { TPL_TRIPLET, ensurePluginTemplates, recordToTriplet } from '../lib/templates';
|
||||
import { classifyClusters } from './triplet-cluster';
|
||||
|
||||
export type CreateTripletData = {
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
source_block_id?: string;
|
||||
confidence?: number;
|
||||
owner_id?: string;
|
||||
clusters?: string[];
|
||||
bridge_score?: number;
|
||||
subject_entity_type?: string;
|
||||
object_entity_type?: string;
|
||||
};
|
||||
|
||||
/** 建立三元組 → POST /records(template=triplet)。 */
|
||||
export async function createTriplet(
|
||||
client: KbdbClient,
|
||||
data: CreateTripletData,
|
||||
): Promise<{ id: string; subject: string; predicate: string; object: string }> {
|
||||
await ensurePluginTemplates(client);
|
||||
|
||||
const clusters = data.clusters ?? [];
|
||||
const bridgeScore = data.bridge_score ?? Math.max(0, clusters.length - 1);
|
||||
|
||||
const values: Record<string, string> = {
|
||||
subject: data.subject,
|
||||
predicate: data.predicate,
|
||||
object: data.object,
|
||||
confidence: String(data.confidence ?? 1.0),
|
||||
clusters_json: JSON.stringify(clusters),
|
||||
bridge_score: String(bridgeScore),
|
||||
};
|
||||
if (data.source_block_id) values.source_block_id = data.source_block_id;
|
||||
if (data.subject_entity_type) values.subject_entity_type = data.subject_entity_type;
|
||||
if (data.object_entity_type) values.object_entity_type = data.object_entity_type;
|
||||
|
||||
const id = await client.createRecord(TPL_TRIPLET, values, data.owner_id);
|
||||
return { id, subject: data.subject, predicate: data.predicate, object: data.object };
|
||||
}
|
||||
|
||||
export type TripletFilters = {
|
||||
subject?: string;
|
||||
predicate?: string;
|
||||
object?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
owner_id?: string;
|
||||
entity_type?: string;
|
||||
};
|
||||
|
||||
/** 查三元組 → 取 template 全部 record,插件層 filter(base 無複合 slot 查詢)。 */
|
||||
export async function queryTriplets(
|
||||
client: KbdbClient,
|
||||
filters: TripletFilters,
|
||||
): Promise<{ triplets: Triplet[]; count: number }> {
|
||||
const records = await client.listRecordsByTemplate(TPL_TRIPLET, filters.owner_id);
|
||||
let triplets = records.map(recordToTriplet);
|
||||
|
||||
if (filters.subject) triplets = triplets.filter((t) => t.subject === filters.subject);
|
||||
if (filters.predicate) triplets = triplets.filter((t) => t.predicate === filters.predicate);
|
||||
if (filters.object) triplets = triplets.filter((t) => t.object === filters.object);
|
||||
if (filters.entity_type) {
|
||||
triplets = triplets.filter(
|
||||
(t) => t.subject_entity_type === filters.entity_type || t.object_entity_type === filters.entity_type,
|
||||
);
|
||||
}
|
||||
|
||||
const offset = filters.offset ?? 0;
|
||||
const limit = Math.min(filters.limit ?? 50, 2000);
|
||||
const page = triplets.slice(offset, offset + limit);
|
||||
return { triplets: page, count: page.length };
|
||||
}
|
||||
|
||||
/** 取單一三元組 → GET /records/:id。 */
|
||||
export async function getTriplet(client: KbdbClient, id: string): Promise<Triplet | null> {
|
||||
const rec = await client.getRecord(id);
|
||||
if (!rec) return null;
|
||||
return recordToTriplet({ ...rec, template: TPL_TRIPLET });
|
||||
}
|
||||
|
||||
// re-export clusters helper(AI 分群,純函式 + 走 client 無關)
|
||||
export { classifyClusters };
|
||||
@@ -0,0 +1,10 @@
|
||||
// 三元組 embedding — [→arcrun embed]
|
||||
//
|
||||
// 鐵律:embedding / Vectorize 屬基本盤/上游(arcrun embed 模組),插件不綁 Vectorize、不碰向量索引。
|
||||
// 本檔精簡為薄殼:只保留純字串組裝 helper(tripletToText,供別處組 query 文字用)。
|
||||
// 真正的向量生成/upsert/delete 由基本盤負責;插件層不再持有 Ai 或向量索引繫結。
|
||||
|
||||
/** 把 S-P-O 組成一段可餵 embedding 的文字(純函式,無 DB / 無 Vectorize)。 */
|
||||
export function tripletToText(subject: string, predicate: string, object: string): string {
|
||||
return `${subject} ${predicate} ${object}`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// 列出所有唯一實體(subject ∪ object)— 零 SQL / 零 D1
|
||||
// 取全部 triplet record(走基本盤 API),在記憶體統計 as_subject/as_object/total。
|
||||
// GET /entities
|
||||
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
import { queryTriplets } from './triplet-crud';
|
||||
|
||||
export interface EntityStat {
|
||||
name: string;
|
||||
as_subject: number;
|
||||
as_object: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface EntityListResult {
|
||||
entities: EntityStat[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export async function listTripletEntities(
|
||||
client: KbdbClient,
|
||||
{ limit = 200, offset = 0, q }: { limit?: number; offset?: number; q?: string },
|
||||
): Promise<EntityListResult> {
|
||||
const safeLimit = Math.min(limit, 500);
|
||||
const { triplets } = await queryTriplets(client, { limit: 2000 });
|
||||
|
||||
const stats = new Map<string, EntityStat>();
|
||||
const bump = (name: string, role: 'as_subject' | 'as_object') => {
|
||||
if (!name) return;
|
||||
let s = stats.get(name);
|
||||
if (!s) {
|
||||
s = { name, as_subject: 0, as_object: 0, total: 0 };
|
||||
stats.set(name, s);
|
||||
}
|
||||
s[role] += 1;
|
||||
s.total += 1;
|
||||
};
|
||||
|
||||
for (const t of triplets) {
|
||||
bump(t.subject, 'as_subject');
|
||||
bump(t.object, 'as_object');
|
||||
}
|
||||
|
||||
let entities = [...stats.values()];
|
||||
if (q) {
|
||||
const needle = q.toLowerCase();
|
||||
entities = entities.filter((e) => e.name.toLowerCase().includes(needle));
|
||||
}
|
||||
entities.sort((a, b) => b.total - a.total);
|
||||
|
||||
const total = entities.length;
|
||||
const page = entities.slice(offset, offset + safeLimit);
|
||||
|
||||
return { entities: page, total, limit: safeLimit, offset };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// triplet-extract.ts — LLM 三元組萃取 + 寫入工具函數
|
||||
// 萃取=純 LLM(不碰 DB);寫入=走基本盤 API(零 SQL / 零 D1)。
|
||||
// 供 block-ingest.ts 和 block-process.ts 共用
|
||||
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
import { createTriplet, queryTriplets } from './triplet-crud';
|
||||
|
||||
const EXTRACT_PROMPT = `你是知識萃取助手,從文章中萃取知識三元組。
|
||||
只萃取:性格特質、關鍵經歷精華、核心觀點信念、說話方式與風格。
|
||||
禁止:具體年份日期、純事實統計、流水帳年表。
|
||||
格式:[{"subject":"...","predicate":"2-6字","object":"15-50字","confidence":0.8}]
|
||||
直接輸出 JSON Array,第一字元 [,最後字元 ]。不要其他文字。`;
|
||||
|
||||
export interface LLMTriplet {
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/** Workers AI 萃取三元組,每段獨立呼叫,單段失敗不中斷 */
|
||||
export async function extractTripletsViaLLM(ai: Ai, chunks: string[]): Promise<LLMTriplet[]> {
|
||||
const results: LLMTriplet[] = [];
|
||||
for (const chunk of chunks) {
|
||||
if (!chunk.trim()) continue;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const raw = await (ai as any).run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', {
|
||||
messages: [
|
||||
{ role: 'system', content: EXTRACT_PROMPT },
|
||||
{ role: 'user', content: `萃取三元組:\n${chunk}` },
|
||||
],
|
||||
max_tokens: 512,
|
||||
temperature: 0.1,
|
||||
});
|
||||
const text = (typeof raw === 'string' ? raw : (raw?.response ?? ''))
|
||||
.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
const m = text.match(/\[[\s\S]*\]/);
|
||||
if (!m) continue;
|
||||
const parsed = JSON.parse(m[0]);
|
||||
if (!Array.isArray(parsed)) continue;
|
||||
for (const t of parsed) {
|
||||
const s = String(t?.subject ?? '').trim();
|
||||
const p = String(t?.predicate ?? '').trim();
|
||||
const o = String(t?.object ?? '').trim();
|
||||
if (s && p && o) results.push({ subject: s, predicate: p, object: o, confidence: Number(t?.confidence) || 0.8 });
|
||||
}
|
||||
} catch { /* 單段失敗跳過 */ }
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** 寫入一條三元組(走基本盤 API),已存在回傳 false,新寫入回傳 true。
|
||||
* 查重 + 寫入全走 KbdbClient → triplet-crud,零 SQL / 零 D1。 */
|
||||
export async function writeTripletToDb(
|
||||
client: KbdbClient,
|
||||
t: { subject: string; predicate: string; object: string; confidence?: number },
|
||||
owner: string | null,
|
||||
): Promise<boolean> {
|
||||
// 查重:以 S-P-O 三欄精確比對(queryTriplets 取 template record 後在插件層 filter)
|
||||
const { count } = await queryTriplets(client, {
|
||||
subject: t.subject,
|
||||
predicate: t.predicate,
|
||||
object: t.object,
|
||||
limit: 1,
|
||||
});
|
||||
if (count > 0) return false;
|
||||
|
||||
await createTriplet(client, {
|
||||
subject: t.subject,
|
||||
predicate: t.predicate,
|
||||
object: t.object,
|
||||
confidence: t.confidence ?? 0.8,
|
||||
owner_id: owner ?? undefined,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Triplet 統計 action — 知識圖譜規模統計
|
||||
// 零 SQL / 零 D1:取全部 triplet record(走基本盤 API),在記憶體 reduce 出統計。
|
||||
// GET /triplets/stats
|
||||
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
import { queryTriplets } from './triplet-crud';
|
||||
|
||||
export interface TripletStats {
|
||||
total: number;
|
||||
// 保留 by_owner_id(原 by_user_id 改名,對齊基本盤 owner_id 欄位)。
|
||||
// 注:基本盤 record 不回傳 owner_id / 時間戳,故 by_owner_id/recent 在純插件層無法填,
|
||||
// 待 [→arcrun] base 於 record 回應帶上 owner_id + created_at 後補。
|
||||
by_owner_id: Record<string, number>;
|
||||
recent: { today: number; this_week: number };
|
||||
top_subjects: { subject: string; count: number }[];
|
||||
top_predicates: { predicate: string; count: number }[];
|
||||
}
|
||||
|
||||
export async function getTripletStats(client: KbdbClient): Promise<TripletStats> {
|
||||
const { triplets } = await queryTriplets(client, { limit: 2000 });
|
||||
|
||||
const subjectCounts = new Map<string, number>();
|
||||
const predicateCounts = new Map<string, number>();
|
||||
const ownerCounts: Record<string, number> = {};
|
||||
|
||||
for (const t of triplets) {
|
||||
subjectCounts.set(t.subject, (subjectCounts.get(t.subject) ?? 0) + 1);
|
||||
predicateCounts.set(t.predicate, (predicateCounts.get(t.predicate) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const top = (m: Map<string, number>) =>
|
||||
[...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||||
|
||||
return {
|
||||
total: triplets.length,
|
||||
by_owner_id: ownerCounts, // base record 暫無 owner_id,待上游補
|
||||
recent: { today: 0, this_week: 0 }, // base record 暫無 created_at,待上游補
|
||||
top_subjects: top(subjectCounts).map(([subject, count]) => ({ subject, count })),
|
||||
top_predicates: top(predicateCounts).map(([predicate, count]) => ({ predicate, count })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 三元組 >> 語法解析器
|
||||
// 掃描 block content 裡的「A >> B >> C」格式,回傳解析結果
|
||||
|
||||
export type ParsedTriplet = {
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 掃描 content 裡所有符合「詞 >> 詞 >> 詞」的行
|
||||
* 一個 block 可包含多行 >> 語法
|
||||
* 前後空白 trim,空的部分跳過
|
||||
*
|
||||
* 範例:
|
||||
* parseTripletSyntax('書僮採集 >> 指向北極星 >> 異見三元組累積')
|
||||
* // → [{ subject: '書僮採集', predicate: '指向北極星', object: '異見三元組累積' }]
|
||||
*/
|
||||
export function parseTripletSyntax(content: string): ParsedTriplet[] {
|
||||
const results: ParsedTriplet[] = [];
|
||||
for (const line of content.split('\n')) {
|
||||
const parts = line.split('>>').map(p => p.trim());
|
||||
if (parts.length === 3 && parts.every(p => p.length > 0)) {
|
||||
results.push({ subject: parts[0], predicate: parts[1], object: parts[2] });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 三元組 partial update action
|
||||
//
|
||||
// [→arcrun] base 缺 PUT /records/:id(也無 DELETE):基本盤目前只支援建/查 record,
|
||||
// 不支援就地更新一筆 record 的 slot 值。插件不准用 SQL 直改表,故更新暫不支援。
|
||||
//
|
||||
// 暫時策略:回 not-supported(null),由 route 轉成 4xx/501。
|
||||
// 待上游補 PUT /records/:id 後,這裡改成 client.req('PUT', ...) 或薄殼呼叫。
|
||||
// 不得以 SQL/D1 繞過。
|
||||
|
||||
import type { Triplet } from '../types';
|
||||
import type { KbdbClient } from '../lib/kbdb-client';
|
||||
|
||||
export async function patchTriplet(
|
||||
_client: KbdbClient,
|
||||
_id: string,
|
||||
_data: {
|
||||
subject?: string; predicate?: string; object?: string; confidence?: number;
|
||||
subject_alias?: string; predicate_alias?: string; object_alias?: string;
|
||||
},
|
||||
): Promise<{ ok: true; triplet: Triplet } | null> {
|
||||
// base 無 PUT /records/:id → 無法就地更新;回 null(not-supported)。
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// KBDB-graph 插件 Worker 進入點
|
||||
//
|
||||
// 插件 = triplet 採集 + graph 查詢,掛在基本盤 arcrun/kbdb 之上(類比 AGE 之於 Postgres)。
|
||||
// 鐵律:插件不碰表、零 SQL、零 migration。讀寫全走基本盤 API(KBDB_BASE_URL)。
|
||||
// 因此本 worker 無 D1/Vectorize/AI 綁定;語意搜尋/embedding 屬基本盤 optional 模組。
|
||||
|
||||
import { OpenAPIHono } from '@hono/zod-openapi';
|
||||
import { swaggerUI } from '@hono/swagger-ui';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { Bindings, Variables } from './types';
|
||||
import { tripletRoutes } from './routes/triplets';
|
||||
import { searchRoutes } from './routes/search';
|
||||
import { entityRoutes } from './routes/entities';
|
||||
import { graphRoutes } from './routes/graph';
|
||||
|
||||
const app = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>();
|
||||
|
||||
app.onError((err, c) => {
|
||||
console.error(err);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
message: err.message,
|
||||
stack: c.env.ENVIRONMENT === 'development' ? err.stack : undefined,
|
||||
},
|
||||
500,
|
||||
);
|
||||
});
|
||||
|
||||
app.use('*', cors({
|
||||
origin: '*',
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
}));
|
||||
|
||||
// Auth:僅 bearer token 比對 KBDB_INTERNAL_TOKEN(partner/DB 認證屬基本盤,插件不做)。
|
||||
// namespace 由 owner_id query/body 帶;插件本身不持有用戶資料。
|
||||
const apiPaths = ['/triplets', '/graph', '/search', '/entities'];
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const path = c.req.path;
|
||||
const isApiPath = apiPaths.some((p) => path === p || path.startsWith(p + '/'));
|
||||
c.set('namespace', null);
|
||||
c.set('partner_id', 'internal');
|
||||
if (!isApiPath) return next();
|
||||
|
||||
const secret = (c.env.KBDB_INTERNAL_TOKEN || c.env.API_KEY)?.trim();
|
||||
if (!secret) return next(); // 未設 secret = 開放(本地/內部)
|
||||
|
||||
const auth = c.req.header('Authorization');
|
||||
const token = auth?.startsWith('Bearer ') ? auth.slice(7).trim() : c.req.query('key')?.trim();
|
||||
if (token && token === secret) return next();
|
||||
return c.json({ error: 'Unauthorized', path }, 401);
|
||||
});
|
||||
|
||||
// Swagger UI + OpenAPI spec
|
||||
app.get('/ui', (c) => swaggerUI({ url: '/doc' })(c));
|
||||
app.get('/doc', (c) => {
|
||||
const doc = app.getOpenAPIDocument({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'KBDB-graph Plugin API', version: '0.6.0' },
|
||||
});
|
||||
if (!doc.openapi) doc.openapi = '3.0.0';
|
||||
return c.json(doc);
|
||||
});
|
||||
|
||||
app.get('/health', (c) =>
|
||||
c.json({
|
||||
service: 'kbdb-graph',
|
||||
status: 'ok',
|
||||
base_url_set: Boolean(c.env.KBDB_BASE_URL),
|
||||
}),
|
||||
);
|
||||
app.get('/', (c) => c.json({ service: 'kbdb-graph', tier: 'plugin', status: 'ok' }));
|
||||
|
||||
// 插件路由(只有 graph 相關;基本盤路由屬 arcrun/kbdb)
|
||||
app.route('/triplets', tripletRoutes);
|
||||
app.route('/graph', graphRoutes);
|
||||
app.route('/search', searchRoutes);
|
||||
app.route('/entities', entityRoutes);
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,163 @@
|
||||
// KBDB 基本盤 API client — 唯一對外通道(API-as-Wall)
|
||||
//
|
||||
// 鐵律:插件不碰表、零 SQL。所有讀寫只透過基本盤 HTTP API(arcrun/kbdb)。
|
||||
// 這裡只發 fetch,絕無 .prepare / D1。base URL 由 KBDB_BASE_URL env var 注入(可留空於本地測試以 mock 替代)。
|
||||
//
|
||||
// 對齊基本盤真實契約(讀 arcrun/kbdb/src 2026-06-14):
|
||||
// 欄位用 entry_type / owner_id(非 type / user_id);回應包在 { success, ... }。
|
||||
|
||||
export type BaseEntry = {
|
||||
id: string;
|
||||
content: string | null;
|
||||
entry_type: string;
|
||||
owner_id: string | null;
|
||||
parent_id?: string | null;
|
||||
page_name?: string | null;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
};
|
||||
|
||||
export type BaseRecord = {
|
||||
record_id: string;
|
||||
template: string;
|
||||
values: Record<string, string>;
|
||||
};
|
||||
|
||||
export type CreateEntryInput = {
|
||||
content: string | null;
|
||||
entry_type: string;
|
||||
owner_id?: string;
|
||||
parent_id?: string;
|
||||
page_name?: string;
|
||||
};
|
||||
|
||||
/** 基本盤 API client。所有方法 = 一個 HTTP 呼叫,零 SQL。 */
|
||||
export class KbdbClient {
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
private readonly token?: string,
|
||||
) {
|
||||
if (!baseUrl) {
|
||||
throw new Error('KBDB_BASE_URL 未設定:插件需指向基本盤 API(不可直連 D1)');
|
||||
}
|
||||
}
|
||||
|
||||
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
|
||||
const res = await fetch(this.baseUrl.replace(/\/$/, '') + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok || (json && json.success === false)) {
|
||||
const msg = json?.error ?? `${res.status} ${res.statusText}`;
|
||||
throw new Error(`[kbdb-base] ${method} ${path}: ${msg}`);
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
// --- entries ---
|
||||
|
||||
async createEntry(input: CreateEntryInput): Promise<BaseEntry> {
|
||||
const { entry } = await this.req<{ entry: BaseEntry }>('POST', '/entries', input);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<BaseEntry | null> {
|
||||
try {
|
||||
const { entry } = await this.req<{ entry: BaseEntry }>('GET', `/entries/${encodeURIComponent(id)}`);
|
||||
return entry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async listEntries(filters: {
|
||||
entry_type?: string;
|
||||
owner_id?: string;
|
||||
parent_id?: string;
|
||||
page_name?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}): Promise<BaseEntry[]> {
|
||||
const { entries } = await this.req<{ entries: BaseEntry[] }>('GET', '/entries' + qs(filters));
|
||||
return entries ?? [];
|
||||
}
|
||||
|
||||
/** 基本盤 D1 LIKE keyword 搜尋(語意搜尋屬 optional embed 模組,base 沒有)。 */
|
||||
async searchEntries(q: string, owner_id?: string): Promise<BaseEntry[]> {
|
||||
const { entries } = await this.req<{ entries: BaseEntry[] }>(
|
||||
'GET',
|
||||
'/entries/search' + qs({ q, owner_id }),
|
||||
);
|
||||
return entries ?? [];
|
||||
}
|
||||
|
||||
async updateEntry(id: string, patch: Partial<CreateEntryInput>): Promise<BaseEntry | null> {
|
||||
try {
|
||||
const { entry } = await this.req<{ entry: BaseEntry }>('PATCH', `/entries/${encodeURIComponent(id)}`, patch);
|
||||
return entry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteEntry(id: string): Promise<void> {
|
||||
await this.req('DELETE', `/entries/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
// --- templates(= 替代建表;插件要新類型只能建 template) ---
|
||||
|
||||
async ensureTemplate(name: string, slots: string[], description?: string): Promise<void> {
|
||||
const existing = await this.req<{ id?: string } | { error: string }>(
|
||||
'GET',
|
||||
`/templates/${encodeURIComponent(name)}`,
|
||||
).catch(() => null);
|
||||
if (existing && (existing as any).id) return;
|
||||
await this.req('POST', '/templates', { name, slots, description, created_by: 'kbdb-graph' });
|
||||
}
|
||||
|
||||
// --- records(= template 實例,填 slot) ---
|
||||
|
||||
async createRecord(template: string, values: Record<string, string>, owner_id?: string): Promise<string> {
|
||||
const { record } = await this.req<{ record: { record_id: string } }>('POST', '/records', {
|
||||
template,
|
||||
values,
|
||||
owner_id,
|
||||
});
|
||||
return record.record_id;
|
||||
}
|
||||
|
||||
async getRecord(recordId: string): Promise<BaseRecord | null> {
|
||||
try {
|
||||
const { record } = await this.req<{ record: BaseRecord }>('GET', `/records/${encodeURIComponent(recordId)}`);
|
||||
return record;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async listRecordsByTemplate(template: string, owner_id?: string): Promise<BaseRecord[]> {
|
||||
const { records } = await this.req<{ records: BaseRecord[] }>(
|
||||
'GET',
|
||||
`/records/by-template/${encodeURIComponent(template)}` + qs({ owner_id }),
|
||||
);
|
||||
return records ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
function qs(params: Record<string, string | number | undefined>): string {
|
||||
const parts = Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined && v !== '')
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
||||
return parts.length ? `?${parts.join('&')}` : '';
|
||||
}
|
||||
|
||||
/** 從 Bindings 建 client。KBDB_BASE_URL 未設時拋錯(不准 fallback 直連 D1)。 */
|
||||
export function makeKbdbClient(env: { KBDB_BASE_URL?: string; KBDB_INTERNAL_TOKEN?: string }): KbdbClient {
|
||||
return new KbdbClient(env.KBDB_BASE_URL ?? '', env.KBDB_INTERNAL_TOKEN);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// 插件用到的基本盤 template 定義(= 替代建表)。
|
||||
// 鐵律:插件新類型只能建 template,不建表。這裡集中宣告 slot schema,
|
||||
// 任何寫入前先 client.ensureTemplate 確保存在。
|
||||
|
||||
import type { KbdbClient } from './kbdb-client';
|
||||
import type { Triplet, Entity } from '../types';
|
||||
import type { BaseRecord } from './kbdb-client';
|
||||
|
||||
export const TPL_TRIPLET = 'triplet';
|
||||
export const TPL_ENTITY = 'entity';
|
||||
export const TPL_ENTITY_PENDING = 'entity_pending';
|
||||
|
||||
export const TRIPLET_SLOTS = [
|
||||
'subject', 'predicate', 'object', 'source_block_id',
|
||||
'confidence', 'clusters_json', 'bridge_score',
|
||||
'subject_entity_type', 'object_entity_type',
|
||||
];
|
||||
export const ENTITY_SLOTS = ['canonical', 'aliases_json', 'entity_type', 'owner'];
|
||||
export const ENTITY_PENDING_SLOTS = [
|
||||
'raw_name', 'candidate_entity_id', 'candidate_canonical', 'similarity',
|
||||
];
|
||||
|
||||
/** 確保插件三個 template 存在(idempotent,走 API)。 */
|
||||
export async function ensurePluginTemplates(client: KbdbClient): Promise<void> {
|
||||
await client.ensureTemplate(TPL_TRIPLET, TRIPLET_SLOTS, 'knowledge graph triplet (S-P-O)');
|
||||
await client.ensureTemplate(TPL_ENTITY, ENTITY_SLOTS, 'normalized entity (canonical + aliases)');
|
||||
await client.ensureTemplate(TPL_ENTITY_PENDING, ENTITY_PENDING_SLOTS, 'pending entity alias for review');
|
||||
}
|
||||
|
||||
/** 基本盤 record → 插件 Triplet 型別。 */
|
||||
export function recordToTriplet(rec: BaseRecord): Triplet {
|
||||
const v = rec.values;
|
||||
return {
|
||||
id: rec.record_id,
|
||||
subject: v.subject ?? '',
|
||||
predicate: v.predicate ?? '',
|
||||
object: v.object ?? '',
|
||||
source_block_id: v.source_block_id ?? null,
|
||||
confidence: parseFloat(v.confidence ?? '1.0'),
|
||||
clusters: safeArr(v.clusters_json),
|
||||
bridge_score: parseInt(v.bridge_score ?? '0', 10),
|
||||
subject_entity_type: (v.subject_entity_type as Triplet['subject_entity_type']) || null,
|
||||
object_entity_type: (v.object_entity_type as Triplet['object_entity_type']) || null,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 基本盤 record → 插件 Entity 型別。 */
|
||||
export function recordToEntity(rec: BaseRecord): Entity {
|
||||
return {
|
||||
id: rec.record_id,
|
||||
canonical: rec.values.canonical ?? '',
|
||||
aliases: safeArr(rec.values.aliases_json),
|
||||
};
|
||||
}
|
||||
|
||||
function safeArr(json?: string): string[] {
|
||||
try {
|
||||
const p = JSON.parse(json || '[]');
|
||||
return Array.isArray(p) ? p : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
||||
import type { Bindings } from '../types';
|
||||
import {
|
||||
getPendingAliases,
|
||||
confirmPendingAlias,
|
||||
rejectPendingAlias,
|
||||
} from '../actions/entity-pending';
|
||||
import { listTripletEntities } from '../actions/triplet-entities';
|
||||
import { makeKbdbClient } from '../lib/kbdb-client';
|
||||
|
||||
const entityRoutes = new OpenAPIHono<{ Bindings: Bindings }>();
|
||||
|
||||
// GET / (列出 entities)
|
||||
const listEntitiesRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/',
|
||||
request: {
|
||||
query: z.object({
|
||||
limit: z.string().optional(),
|
||||
offset: z.string().optional(),
|
||||
q: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'List of entities' },
|
||||
},
|
||||
tags: ['Entities'],
|
||||
});
|
||||
|
||||
entityRoutes.openapi(listEntitiesRoute, async (c) => {
|
||||
const limit = parseInt(c.req.query('limit') || '100');
|
||||
const offset = parseInt(c.req.query('offset') || '0');
|
||||
const q = c.req.query('q') || '';
|
||||
|
||||
const result = await listTripletEntities(makeKbdbClient(c.env), { limit, offset, q: q || undefined });
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// GET /pending (列出待確認)
|
||||
const listPendingRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/pending',
|
||||
responses: {
|
||||
200: { description: 'List of pending aliases' },
|
||||
},
|
||||
tags: ['Entities'],
|
||||
});
|
||||
|
||||
entityRoutes.openapi(listPendingRoute, async (c) => {
|
||||
const limit = parseInt(c.req.query('limit') || '100');
|
||||
const pending = await getPendingAliases(makeKbdbClient(c.env), limit);
|
||||
return c.json(pending);
|
||||
});
|
||||
|
||||
entityRoutes.post('/pending/:id/confirm', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
await confirmPendingAlias(makeKbdbClient(c.env), id);
|
||||
return c.json({ success: true, action: 'confirmed', id });
|
||||
});
|
||||
|
||||
entityRoutes.post('/pending/:id/reject', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const newEntity = await rejectPendingAlias(makeKbdbClient(c.env), id);
|
||||
return c.json({ success: true, action: 'rejected', newEntity });
|
||||
});
|
||||
|
||||
export { entityRoutes };
|
||||
@@ -0,0 +1,49 @@
|
||||
// 圖遍歷路由入口
|
||||
// 僅驗證參數,呼叫 actions(actions 走基本盤 API,圖在插件層組裝)
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { traverseGraph, queryRelation } from '../actions/graph-traverse';
|
||||
import { getNodeEdges, getNeighbors } from '../actions/graph-nodes';
|
||||
import { findShortestPath } from '../actions/graph-path';
|
||||
import { makeKbdbClient } from '../lib/kbdb-client';
|
||||
|
||||
const graphRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
graphRoutes.get('/traverse', async (c) => {
|
||||
const start = c.req.query('start');
|
||||
if (!start) return c.json({ error: 'start parameter required' }, 400);
|
||||
const depth = Number(c.req.query('depth') ?? 2);
|
||||
const results = await traverseGraph(makeKbdbClient(c.env), start, depth);
|
||||
return c.json({ start, depth, nodes: results, nodeCount: results.length });
|
||||
});
|
||||
|
||||
graphRoutes.get('/relation', async (c) => {
|
||||
const from = c.req.query('from');
|
||||
const to = c.req.query('to');
|
||||
if (!from || !to) return c.json({ error: 'from and to parameters required' }, 400);
|
||||
const relations = await queryRelation(makeKbdbClient(c.env), from, to);
|
||||
return c.json({ from, to, relations, count: relations.length });
|
||||
});
|
||||
|
||||
// GET /graph/neighbors/:name — 合併原 nodes/:name 的 edges + neighbors
|
||||
graphRoutes.get('/neighbors/:name', async (c) => {
|
||||
const name = decodeURIComponent(c.req.param('name'));
|
||||
const client = makeKbdbClient(c.env);
|
||||
const [edges, neighbors] = await Promise.all([
|
||||
getNodeEdges(client, name),
|
||||
getNeighbors(client, name),
|
||||
]);
|
||||
return c.json({ node: name, edges, neighbors, edgeCount: edges.length, neighborCount: neighbors.length });
|
||||
});
|
||||
|
||||
// GET /graph/path — 取代 /shortest-path,行為不變
|
||||
graphRoutes.get('/path', async (c) => {
|
||||
const from = c.req.query('from');
|
||||
const to = c.req.query('to');
|
||||
if (!from || !to) return c.json({ error: 'from and to parameters required' }, 400);
|
||||
const result = await findShortestPath(makeKbdbClient(c.env), from, to);
|
||||
return c.json({ from, to, ...result });
|
||||
});
|
||||
|
||||
export { graphRoutes };
|
||||
@@ -0,0 +1,43 @@
|
||||
// 搜尋路由入口 — 僅驗證參數,呼叫 actions
|
||||
//
|
||||
// 插件層只做基本盤 keyword 搜尋(D1 LIKE,走 GET /entries/search)。
|
||||
// 語意搜尋 / embedding 屬基本盤 optional embed 模組,不在插件 → 已移除 /search/embed。
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { Bindings, Variables } from '../types';
|
||||
import { keywordSearch } from '../actions/search-query';
|
||||
import { suggestKnowledge } from '../actions/search-suggest';
|
||||
import { makeKbdbClient } from '../lib/kbdb-client';
|
||||
|
||||
const searchRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>();
|
||||
|
||||
const UnifiedSearchSchema = z.object({
|
||||
query: z.string().min(1),
|
||||
type: z.enum(['keyword', 'suggest']).optional().default('keyword'),
|
||||
topK: z.number().min(1).max(20).optional(),
|
||||
owner_id: z.string().optional(),
|
||||
});
|
||||
|
||||
// 統一搜尋入口:POST /search
|
||||
searchRoutes.post('/', async (c) => {
|
||||
const parsed = UnifiedSearchSchema.safeParse(await c.req.json());
|
||||
if (!parsed.success) return c.json({ error: parsed.error.flatten() }, 400);
|
||||
const { query, type, topK, owner_id } = parsed.data;
|
||||
|
||||
// Namespace 讀取過濾:partner token 只能搜到自己 namespace 的資料
|
||||
const namespace = c.get('namespace');
|
||||
const effectiveOwner = namespace ?? owner_id;
|
||||
|
||||
const client = makeKbdbClient(c.env);
|
||||
|
||||
if (type === 'suggest') {
|
||||
const result = await suggestKnowledge(client, query, topK, effectiveOwner);
|
||||
return c.json(result);
|
||||
}
|
||||
|
||||
const matches = await keywordSearch(client, query, { limit: topK, owner_id: effectiveOwner });
|
||||
return c.json({ matches, count: matches.length, mode: 'keyword' });
|
||||
});
|
||||
|
||||
export { searchRoutes };
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
||||
import type { Bindings, Variables } from '../types';
|
||||
import { createTriplet, queryTriplets } from '../actions/triplet-crud';
|
||||
import { getTripletStats } from '../actions/triplet-stats';
|
||||
import { makeKbdbClient } from '../lib/kbdb-client';
|
||||
|
||||
const tripletRoutes = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>();
|
||||
|
||||
const TripletSchema = z.object({
|
||||
id: z.string(),
|
||||
subject: z.string(),
|
||||
predicate: z.string(),
|
||||
object: z.string(),
|
||||
owner_id: z.string().optional(),
|
||||
});
|
||||
|
||||
// GET /triplets/stats (統計)
|
||||
const statsRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/stats',
|
||||
responses: {
|
||||
200: { description: 'Statistics of the knowledge graph' },
|
||||
},
|
||||
tags: ['Triplets'],
|
||||
});
|
||||
|
||||
tripletRoutes.openapi(statsRoute, async (c) => {
|
||||
const stats = await getTripletStats(makeKbdbClient(c.env));
|
||||
return c.json(stats, 200);
|
||||
});
|
||||
|
||||
// GET /triplets (查詢)
|
||||
const queryRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/',
|
||||
request: {
|
||||
query: z.object({
|
||||
subject: z.string().optional(),
|
||||
predicate: z.string().optional(),
|
||||
object: z.string().optional(),
|
||||
limit: z.string().optional().describe('Maximum number of results (default 50)'),
|
||||
offset: z.string().optional().describe('Pagination offset'),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ triplets: z.array(TripletSchema), count: z.number() }),
|
||||
},
|
||||
},
|
||||
description: 'Query results from KBDB Graph',
|
||||
},
|
||||
},
|
||||
tags: ['Triplets'],
|
||||
});
|
||||
|
||||
tripletRoutes.openapi(queryRoute, async (c) => {
|
||||
const query = c.req.valid('query');
|
||||
const limit = query.limit ? parseInt(query.limit, 10) : undefined;
|
||||
const offset = query.offset ? parseInt(query.offset, 10) : undefined;
|
||||
|
||||
const results = await queryTriplets(makeKbdbClient(c.env), { ...query, limit, offset });
|
||||
return c.json(results as any, 200);
|
||||
});
|
||||
|
||||
// POST /triplets (建立)
|
||||
const createRouteDefinition = createRoute({
|
||||
method: 'post',
|
||||
path: '/',
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
subject: z.string().min(1),
|
||||
predicate: z.string().min(1),
|
||||
object: z.string().min(1),
|
||||
owner_id: z.string().optional(),
|
||||
source_block_id: z.string().optional(),
|
||||
confidence: z.number().optional(),
|
||||
clusters: z.array(z.string()).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: { description: 'Triplet created successfully' },
|
||||
},
|
||||
tags: ['Triplets'],
|
||||
});
|
||||
|
||||
tripletRoutes.openapi(createRouteDefinition, async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
await createTriplet(makeKbdbClient(c.env), data);
|
||||
return c.json({ ok: true }, 201);
|
||||
});
|
||||
|
||||
export { tripletRoutes };
|
||||
@@ -0,0 +1,57 @@
|
||||
// KBDB Worker 型別定義
|
||||
|
||||
export type Bindings = {
|
||||
// 插件不碰 DB/Vectorize/AI — 全走基本盤 API(API-as-Wall)。
|
||||
// 語意搜尋/embedding 屬基本盤 optional embed 模組,不在插件。
|
||||
KBDB_BASE_URL?: string; // 基本盤 arcrun/kbdb API 網址(leo: 可設定,先留空)
|
||||
KBDB_INTERNAL_TOKEN?: string;
|
||||
ENVIRONMENT: string;
|
||||
API_KEY?: string;
|
||||
};
|
||||
|
||||
// Hono Context 變數:auth middleware 設定,route handler 使用
|
||||
export type Variables = {
|
||||
namespace: string | null; // null = internal(無限制),string = partner 的 org_namespace
|
||||
partner_id: string; // 'internal' 或 partner record_id
|
||||
};
|
||||
|
||||
export type EntityType = 'person' | 'event' | 'product' | 'market' | 'org';
|
||||
|
||||
export type Triplet = {
|
||||
id: string;
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
source_block_id: string | null;
|
||||
confidence: number;
|
||||
clusters: string[]; // 所屬的知識群集,用於跨領域偵測
|
||||
bridge_score: number; // 跨越的 cluster 數量,Scout 發現指標
|
||||
subject_entity_type: EntityType | null; // 主體 entity 類型(人格疊加局勢分析用)
|
||||
object_entity_type: EntityType | null; // 客體 entity 類型
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
// 圖遍歷結果
|
||||
export type GraphNode = {
|
||||
node: string;
|
||||
level: number;
|
||||
edges: Triplet[];
|
||||
};
|
||||
|
||||
// --- Entity Normalization 型別 ---
|
||||
|
||||
export type Entity = {
|
||||
id: string;
|
||||
canonical: string;
|
||||
aliases: string[];
|
||||
};
|
||||
|
||||
export type PendingAlias = {
|
||||
id: string;
|
||||
raw_name: string;
|
||||
candidate_entity_id: string;
|
||||
candidate_canonical: string;
|
||||
similarity: number;
|
||||
created_at: number;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// entity 正規化 — exact match(語意合併屬基本盤 embed 模組),走 mock client。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createEntity, findEntityByName, listEntities } from '../src/actions/entity-crud';
|
||||
import { normalizeEntity } from '../src/actions/entity-normalize';
|
||||
import { mockClient } from './mock-client';
|
||||
|
||||
describe('entity-crud', () => {
|
||||
it('建立後可 exact 查回(大小寫不敏感)', async () => {
|
||||
const c = mockClient();
|
||||
await createEntity(c, 'InkStone');
|
||||
const found = await findEntityByName(c, 'inkstone');
|
||||
expect(found?.canonical).toBe('InkStone');
|
||||
});
|
||||
|
||||
it('listEntities 列出', async () => {
|
||||
const c = mockClient();
|
||||
await createEntity(c, 'A');
|
||||
await createEntity(c, 'B');
|
||||
const all = await listEntities(c);
|
||||
expect(all.map((e) => e.canonical).sort()).toEqual(['A', 'B']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeEntity', () => {
|
||||
it('已存在回 canonical,不存在建新回原值', async () => {
|
||||
const c = mockClient();
|
||||
await createEntity(c, 'InkStone');
|
||||
expect(await normalizeEntity(c, 'INKSTONE')).toBe('InkStone');
|
||||
expect(await normalizeEntity(c, '新公司')).toBe('新公司');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// graph 查詢 — 圖在插件層記憶體組裝(不靠 DB VIEW),走 mock client。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTriplet } from '../src/actions/triplet-crud';
|
||||
import { listNodes, getNodeEdges, getNeighbors } from '../src/actions/graph-nodes';
|
||||
import { findShortestPath } from '../src/actions/graph-path';
|
||||
import { mockClient } from './mock-client';
|
||||
import type { KbdbClient } from '../src/lib/kbdb-client';
|
||||
|
||||
async function seed(c: KbdbClient) {
|
||||
// A — B — C,D 孤立連 A
|
||||
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'B' });
|
||||
await createTriplet(c, { subject: 'B', predicate: 'r', object: 'C' });
|
||||
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'D' });
|
||||
}
|
||||
|
||||
describe('graph-nodes', () => {
|
||||
it('listNodes 統計 edge_count', async () => {
|
||||
const c = mockClient();
|
||||
await seed(c);
|
||||
const nodes = await listNodes(c, {});
|
||||
const a = nodes.find((n) => n.node === 'A');
|
||||
expect(a?.edge_count).toBe(2);
|
||||
});
|
||||
|
||||
it('getNeighbors 收鄰居', async () => {
|
||||
const c = mockClient();
|
||||
await seed(c);
|
||||
const neighbors = await getNeighbors(c, 'A');
|
||||
expect(neighbors.sort()).toEqual(['B', 'D']);
|
||||
});
|
||||
|
||||
it('getNodeEdges 取邊', async () => {
|
||||
const c = mockClient();
|
||||
await seed(c);
|
||||
const edges = await getNodeEdges(c, 'B');
|
||||
expect(edges.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graph-path', () => {
|
||||
it('A→C 最短路經過 B', async () => {
|
||||
const c = mockClient();
|
||||
await seed(c);
|
||||
const res = await findShortestPath(c, 'A', 'C');
|
||||
expect(res.path).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// 記憶體版 KbdbClient — 模擬基本盤 API,供插件單元測試(不打真網路、不碰 D1)。
|
||||
// 只實作插件實際用到的方法,行為對齊 arcrun/kbdb 契約。
|
||||
|
||||
import type { KbdbClient, BaseEntry, BaseRecord } from '../src/lib/kbdb-client';
|
||||
|
||||
export class MockKbdbClient {
|
||||
private entries = new Map<string, BaseEntry>();
|
||||
private templates = new Map<string, string[]>();
|
||||
private records = new Map<string, { template: string; values: Record<string, string>; owner_id?: string }>();
|
||||
private seq = 0;
|
||||
|
||||
private id(prefix: string): string {
|
||||
this.seq += 1;
|
||||
return `${prefix}-${this.seq}`;
|
||||
}
|
||||
|
||||
async createEntry(input: any): Promise<BaseEntry> {
|
||||
const id = this.id('entry');
|
||||
const entry: BaseEntry = { id, content: input.content ?? null, entry_type: input.entry_type, owner_id: input.owner_id ?? null };
|
||||
this.entries.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<BaseEntry | null> {
|
||||
return this.entries.get(id) ?? null;
|
||||
}
|
||||
|
||||
async listEntries(filters: any = {}): Promise<BaseEntry[]> {
|
||||
return [...this.entries.values()].filter(
|
||||
(e) => (!filters.entry_type || e.entry_type === filters.entry_type) && (!filters.owner_id || e.owner_id === filters.owner_id),
|
||||
);
|
||||
}
|
||||
|
||||
async searchEntries(q: string, owner_id?: string): Promise<BaseEntry[]> {
|
||||
const needle = q.toLowerCase();
|
||||
return [...this.entries.values()].filter(
|
||||
(e) => (e.content ?? '').toLowerCase().includes(needle) && (!owner_id || e.owner_id === owner_id),
|
||||
);
|
||||
}
|
||||
|
||||
async updateEntry(id: string, patch: any): Promise<BaseEntry | null> {
|
||||
const e = this.entries.get(id);
|
||||
if (!e) return null;
|
||||
Object.assign(e, patch);
|
||||
return e;
|
||||
}
|
||||
|
||||
async deleteEntry(id: string): Promise<void> {
|
||||
this.entries.delete(id);
|
||||
}
|
||||
|
||||
async ensureTemplate(name: string, slots: string[]): Promise<void> {
|
||||
if (!this.templates.has(name)) this.templates.set(name, slots);
|
||||
}
|
||||
|
||||
async createRecord(template: string, values: Record<string, string>, owner_id?: string): Promise<string> {
|
||||
const id = this.id('rec');
|
||||
this.records.set(id, { template, values, owner_id });
|
||||
return id;
|
||||
}
|
||||
|
||||
async getRecord(recordId: string): Promise<BaseRecord | null> {
|
||||
const r = this.records.get(recordId);
|
||||
if (!r) return null;
|
||||
return { record_id: recordId, template: r.template, values: r.values };
|
||||
}
|
||||
|
||||
async listRecordsByTemplate(template: string, owner_id?: string): Promise<BaseRecord[]> {
|
||||
return [...this.records.entries()]
|
||||
.filter(([, r]) => r.template === template && (!owner_id || r.owner_id === owner_id))
|
||||
.map(([record_id, r]) => ({ record_id, template: r.template, values: r.values }));
|
||||
}
|
||||
}
|
||||
|
||||
/** 轉成 KbdbClient 型別供 action 接受。 */
|
||||
export function mockClient(): KbdbClient {
|
||||
return new MockKbdbClient() as unknown as KbdbClient;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// triplet CRUD — 走 mock KbdbClient(API-as-Wall),零 SQL。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTriplet, queryTriplets, getTriplet } from '../src/actions/triplet-crud';
|
||||
import { mockClient } from './mock-client';
|
||||
|
||||
describe('createTriplet → records API', () => {
|
||||
it('建立後可由 id 取回', async () => {
|
||||
const c = mockClient();
|
||||
const r = await createTriplet(c, { subject: 'InkStone', predicate: '是', object: '創業 OS' });
|
||||
expect(r.id).toBeDefined();
|
||||
expect(r.subject).toBe('InkStone');
|
||||
|
||||
const got = await getTriplet(c, r.id);
|
||||
expect(got?.predicate).toBe('是');
|
||||
expect(got?.object).toBe('創業 OS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryTriplets → 插件層 filter', () => {
|
||||
it('by subject 過濾', async () => {
|
||||
const c = mockClient();
|
||||
await createTriplet(c, { subject: 'KBDB', predicate: '使用', object: 'D1' });
|
||||
await createTriplet(c, { subject: 'Other', predicate: '使用', object: 'X' });
|
||||
|
||||
const { triplets, count } = await queryTriplets(c, { subject: 'KBDB' });
|
||||
expect(count).toBe(1);
|
||||
expect(triplets[0].object).toBe('D1');
|
||||
});
|
||||
|
||||
it('limit/offset 分頁', async () => {
|
||||
const c = mockClient();
|
||||
for (let i = 0; i < 5; i++) await createTriplet(c, { subject: `s${i}`, predicate: 'p', object: 'o' });
|
||||
const { triplets } = await queryTriplets(c, { limit: 2, offset: 1 });
|
||||
expect(triplets.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// 插件不綁 D1/Vectorize/Workers,測試走純 node + mock KbdbClient(API-as-Wall)。
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
name = "kbdb-graph-plugin"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
workers_dev = true
|
||||
|
||||
# KBDB-graph 插件 = triplet 採集 + graph 查詢,掛在基本盤 arcrun/kbdb 之上。
|
||||
# 鐵律:插件不碰表、零 SQL、零 migration。讀寫全走基本盤 API(API-as-Wall)。
|
||||
# 因此這裡【無】D1 / Vectorize 綁定 — 那些屬基本盤,插件不直連。
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "development"
|
||||
# 基本盤 arcrun/kbdb API 網址(leo 2026-06-14:做成可設定,先留空)。
|
||||
# 部署前用 `wrangler secret put` 或在此填入,例如 https://arcrun-kbdb.<acct>.workers.dev
|
||||
KBDB_BASE_URL = ""
|
||||
|
||||
[alias]
|
||||
"zod/v3" = "zod"
|
||||
"zod/v4" = "zod"
|
||||
"zod/v4-mini" = "zod"
|
||||
|
||||
[[routes]]
|
||||
pattern = "kbdb-graph.finally.click"
|
||||
custom_domain = true
|
||||
Reference in New Issue
Block a user