From 13c53904c5ddea86c077a057d5987f732e8fc974 Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 12:20:41 +0800
Subject: [PATCH] feat: explore 命令(字节预算 + 段落级原文直出 + 关联清单 + 悬空链接提示)

---
 internal/index/fts.go |  150 +++++++++++++++++++++++++++++++++++++++++++++-----
 1 files changed, 135 insertions(+), 15 deletions(-)

diff --git a/internal/index/fts.go b/internal/index/fts.go
index 07bc115..552b428 100644
--- a/internal/index/fts.go
+++ b/internal/index/fts.go
@@ -12,27 +12,50 @@
 	Title   string
 	Section string
 	Rank    float64
+	Aliases []string // 别名(JSON 解析)
+	Status  string   // 状态(草稿/待确认/跟进中 等参与降权)
 }
 
-// CreateFTS 创建 FTS5 虚拟表
+// CreateFTS 创建 FTS5 虚拟表(external-content 模式,由触发器增量维护)
+// 注意:external-content 模式下 FTS 列名必须与 content 表列名一致,
+// 故 content 列对应 nodes 表的 content_fts 列
 func (s *Store) CreateFTS() error {
 	_, err := s.db.Exec(`
 		CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
-			title,
-			content,
-			tags,
-			entities
-		)
-	`)
-	return err
+			title, content_fts, tags, aliases,
+			content='nodes', content_rowid='id'
+		)`)
+	if err != nil {
+		return err
+	}
+	// 触发器:nodes 的增删改同步维护 FTS
+	triggers := []string{
+		`CREATE TRIGGER IF NOT EXISTS nodes_fts_ai AFTER INSERT ON nodes BEGIN
+			INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
+			VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
+		END`,
+		`CREATE TRIGGER IF NOT EXISTS nodes_fts_ad AFTER DELETE ON nodes BEGIN
+			INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
+			VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
+		END`,
+		`CREATE TRIGGER IF NOT EXISTS nodes_fts_au AFTER UPDATE ON nodes BEGIN
+			INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
+			VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
+			INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
+			VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
+		END`,
+	}
+	for _, t := range triggers {
+		if _, err := s.db.Exec(t); err != nil {
+			return err
+		}
+	}
+	return nil
 }
 
-// PopulateFTS 填充 FTS 索引
+// PopulateFTS 全量重灌 FTS(external-content 模式专用语法)
 func (s *Store) PopulateFTS() error {
-	_, err := s.db.Exec(`
-		INSERT INTO nodes_fts(rowid, title, content, tags, entities)
-		SELECT id, title, content_fts, tags, entities FROM nodes
-	`)
+	_, err := s.db.Exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`)
 	return err
 }
 
@@ -49,7 +72,7 @@
 
 	for _, kw := range keywords {
 		rows, err := s.db.Query(`
-			SELECT n.id, n.path, n.title, n.section, fts.rank
+			SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.status
 			FROM nodes_fts fts
 			JOIN nodes n ON n.id = fts.rowid
 			WHERE nodes_fts MATCH ?
@@ -63,10 +86,14 @@
 
 		for rows.Next() {
 			var r FTSResult
-			if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil {
+			var aliasesJSON string
+			if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &r.Status); err != nil {
 				rows.Close()
 				continue
 			}
+			if aliasesJSON != "" {
+				_ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+			}
 			// 去重
 			if !seen[r.ID] {
 				seen[r.ID] = true
@@ -89,6 +116,99 @@
 	return allResults, nil
 }
 
+// containsCJK 是否含 CJK 统一表意文字
+func containsCJK(s string) bool {
+	for _, r := range s {
+		if r >= 0x4E00 && r <= 0x9FFF {
+			return true
+		}
+	}
+	return false
+}
+
+// KeywordSearch 双通道关键词检索:ASCII 词走 FTS5 MATCH,CJK 词走 LIKE(title/aliases/content/tags)。
+// FTS5 unicode61 把连续中文当整串单 token,多字符 CJK 词 MATCH 匹配不到,必须走 LIKE。
+// 结果按"首命中顺序"排列(FTS 通道按 rank,LIKE 通道补在尾部),engine 再按双信号重排。
+func (s *Store) KeywordSearch(keywords []string, limit int) ([]FTSResult, error) {
+	if len(keywords) == 0 {
+		return nil, nil
+	}
+	seen := make(map[int64]*FTSResult)
+	var order []int64
+
+	// 通道 1: FTS(ASCII 词)
+	var ascii []string
+	for _, kw := range keywords {
+		if !containsCJK(kw) {
+			ascii = append(ascii, kw)
+		}
+	}
+	for _, kw := range ascii {
+		rows, err := s.db.Query(`
+			SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.status
+			FROM nodes_fts fts JOIN nodes n ON n.id = fts.rowid
+			WHERE nodes_fts MATCH ? ORDER BY fts.rank LIMIT ?`, kw, limit)
+		if err != nil {
+			continue // 语法异常词跳过
+		}
+		for rows.Next() {
+			var r FTSResult
+			var aliasesJSON string
+			if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &r.Status); err != nil {
+				continue
+			}
+			if aliasesJSON != "" {
+				_ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+			}
+			if _, ok := seen[r.ID]; !ok {
+				seen[r.ID] = &r
+				order = append(order, r.ID)
+			}
+		}
+		rows.Close()
+	}
+
+	// 通道 2: LIKE(CJK 词)
+	for _, kw := range keywords {
+		if !containsCJK(kw) {
+			continue
+		}
+		pat := "%" + kw + "%"
+		rows, err := s.db.Query(`
+			SELECT id, path, title, section, aliases, status FROM nodes
+			WHERE title LIKE ? OR aliases LIKE ? OR content_fts LIKE ? OR tags LIKE ?`,
+			pat, pat, pat, pat)
+		if err != nil {
+			continue
+		}
+		for rows.Next() {
+			var r FTSResult
+			var aliasesJSON string
+			if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &aliasesJSON, &r.Status); err != nil {
+				continue
+			}
+			if aliasesJSON != "" {
+				_ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
+			}
+			r.Rank = 0 // LIKE 无 rank
+			if _, ok := seen[r.ID]; !ok {
+				seen[r.ID] = &r
+				order = append(order, r.ID)
+			}
+		}
+		rows.Close()
+	}
+
+	results := make([]FTSResult, 0, len(order))
+	for _, id := range order {
+		results = append(results, *seen[id])
+	}
+	if limit > 0 && len(results) > limit {
+		results = results[:limit]
+	}
+	return results, nil
+}
+
 // GetNodeContent 获取节点内容
 func (s *Store) GetNodeContent(id int64) (string, []string, []string, error) {
 	var content, tagsJSON, entitiesJSON string

--
Gitblit v1.10.0