Files
sanguo_llmwiki/mcp_server/services/indexer.py
T
claude_dev dfd8421dc6 feat(§03): 实现 MCP Server 核心 - Storage/Service/Tool/MCP Protocol Layers
**Storage Layer:**
- Database 类(aiosqlite + WAL + 并发保护)
- WikiPage 数据模型
- fix_dirty_states 恢复机制
- FTS5 全文搜索支持
- 完整的 SQLite 表结构

**Service Layer:**
- CacheService(LRU 缓存 + TTL + 大小限制)
- QueryService(查询服务)
- ParserService(Markdown 解析)
- IndexerService(索引服务)
- GraphService(链接图服务)

**Tool Layer (8 个 MCP Tools):**
- wiki_query - FTS5 全文搜索 + 标签过滤
- memory_bridge - 按工具来源浏览
- wiki_status - 索引状态
- wiki_lint - 健康审计
- cross_linker - 缺失链接发现
- tag_taxonomy - 标签一致性
- wiki_synthesize - 跨概念综合分析
- daily_update - 日常维护 + hot.md 生成

**MCP Protocol Layer:**
- MCP 协议解析和封装
- 工具注册和路由
- 错误处理和日志
- stdio 模式支持

**配置和部署:**
- requirements.txt
- config.example.yaml
- ecosystem.config.cjs (PM2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:32:43 +08:00

227 lines
7.9 KiB
Python

"""
Service Layer - IndexerService(索引服务)
管理 wiki 页面索引,支持增量更新和全量重建。
参考设计文档:第 2.3 节
"""
import logging
import asyncio
from pathlib import Path
from typing import List, Set, Optional
from datetime import datetime
from ..storage import Database, WikiPage, compute_content_hash
from .parser import ParserService
logger = logging.getLogger(__name__)
class IndexerService:
"""索引服务 - 管理页面索引"""
def __init__(self, db: Database, parser: ParserService, wiki_vault_path: str):
self.db = db
self.parser = parser
self.wiki_vault_path = Path(wiki_vault_path)
self._lock = asyncio.Lock()
async def index_page(self, page_path: str) -> Optional[WikiPage]:
"""索引单个页面"""
try:
full_path = self.wiki_vault_path / page_path
# 读取文件内容
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
# 解析页面
parsed = self.parser.parse_wiki_page(page_path, content)
# 计算内容哈希
content_hash = compute_content_hash(content)
# 检查是否需要更新
stored_hash = await self.db.get_page_hash(page_path)
if stored_hash == content_hash:
logger.debug(f"Page unchanged, skipping: {page_path}")
return None
# 创建 WikiPage 对象
page = WikiPage(
path=parsed["path"],
title=parsed["title"],
category=parsed["category"],
tags=parsed["tags"],
summary=parsed["summary"],
content_hash=content_hash,
lifecycle="draft", # 默认为 draft,后续可通过 wiki_lint 提升
source_tool=parsed["source_tool"],
created_at=parsed["created_at"],
updated_at=parsed["updated_at"],
indexed_at=parsed["indexed_at"]
)
# 更新数据库
await self.db.upsert_page(page)
# 更新 FTS5 索引
await self.db.update_fts_content(
page_path,
page.title,
content,
page.summary
)
# 更新链接关系
await self.db.delete_links(page_path)
for link in parsed.get("links", []):
await self.db.upsert_link(page_path, link)
# 更新标签
for tag in page.tags:
await self.db.upsert_tag(tag)
logger.info(f"Indexed page: {page_path}")
return page
except Exception as e:
logger.error(f"Failed to index page {page_path}: {e}")
return None
async def index_batch(self, page_paths: List[str]) -> dict:
"""批量索引页面"""
results = {
"success": 0,
"failed": 0,
"skipped": 0,
"pages": []
}
for page_path in page_paths:
try:
page = await self.index_page(page_path)
if page:
results["success"] += 1
results["pages"].append(page_path)
else:
results["skipped"] += 1
except Exception as e:
logger.error(f"Failed to index {page_path}: {e}")
results["failed"] += 1
return results
async def get_dirty_pages(self) -> List[str]:
"""获取需要重新索引的页面(增量更新)"""
all_pages = self.scan_wiki_vault()
dirty_pages = []
for page_path in all_pages:
full_path = self.wiki_vault_path / page_path
try:
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
current_hash = compute_content_hash(content)
stored_hash = await self.db.get_page_hash(page_path)
if stored_hash != current_hash:
dirty_pages.append(page_path)
except Exception as e:
logger.warning(f"Failed to check {page_path}: {e}")
return dirty_pages
async def incremental_update(self) -> dict:
"""增量更新索引 - 只处理变化的页面"""
logger.info("Starting incremental update...")
async with self._lock:
# 1. 获取所有 wiki 页面
all_pages = self.scan_wiki_vault()
# 2. 检查每个页面的哈希
for page_path in all_pages:
try:
full_path = self.wiki_vault_path / page_path
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
current_hash = compute_content_hash(content)
stored_hash = await self.db.get_page_hash(page_path)
if stored_hash != current_hash:
await self.index_page(page_path)
except Exception as e:
logger.error(f"Failed to update {page_path}: {e}")
# 3. 处理删除的页面
indexed_paths = await self.db.get_all_indexed_paths()
for path in indexed_paths:
if path not in all_pages:
await self.db.delete_page(path)
await self.db.delete_fts_content(path)
logger.info(f"Deleted from index: {path}")
# 更新元数据
await self.db.set_meta("last_incremental_update", datetime.now().isoformat())
stats = await self.db.get_stats()
logger.info(f"Incremental update completed: {stats}")
return stats
async def rebuild_index(self) -> dict:
"""全量重建索引"""
logger.info("Starting full index rebuild...")
async with self._lock:
# 清空现有索引
await self.db.execute("DELETE FROM wiki_pages")
await self.db.execute("DELETE FROM wiki_content")
await self.db.execute("DELETE FROM wiki_fts")
await self.db.execute("DELETE FROM wiki_links")
await self.db.execute("DELETE FROM wiki_tags")
await self.db.execute("DELETE FROM wiki_page_tags")
logger.info("Cleared existing index")
# 扫描并索引所有页面
all_pages = self.scan_wiki_vault()
results = await self.index_batch(all_pages)
# 更新元数据
await self.db.set_meta("last_full_reindex", datetime.now().isoformat())
stats = await self.db.get_stats()
logger.info(f"Full rebuild completed: {stats}")
return {"results": results, "stats": stats}
def scan_wiki_vault(self) -> List[str]:
"""扫描 wiki vault,返回所有 .md 文件的相对路径"""
if not self.wiki_vault_path.exists():
logger.error(f"Wiki vault path does not exist: {self.wiki_vault_path}")
return []
markdown_files = list(self.wiki_vault_path.rglob("*.md"))
relative_paths = []
for md_file in markdown_files:
try:
relative_path = md_file.relative_to(self.wiki_vault_path).as_posix()
relative_paths.append(relative_path)
except ValueError:
logger.warning(f"Failed to get relative path for: {md_file}")
return sorted(relative_paths)
async def get_index_status(self) -> dict:
"""获取索引状态"""
stats = await self.db.get_stats()
dirty_pages = await self.get_dirty_pages()
return {
"total_pages": stats.get("total_pages", 0),
"total_links": stats.get("total_links", 0),
"total_tags": stats.get("total_tags", 0),
"last_indexed": stats.get("last_indexed"),
"dirty_pages": len(dirty_pages),
"dirty_page_list": dirty_pages[:10] # 只返回前 10 个
}