Files
sanguo_llmwiki/mcp_server/services/indexer.py
T
claude_dev 62b8fef18a fix: 代码审查问题修复
- C1 (Critical): 添加 PyYAML 解析 frontmatter,支持多行值和列表
- M1 (Major): 添加 FTS5 查询验证,防止注入攻击
- M2/M3 (Major): 工具类使用显式 wiki_vault_path 参数
- M4 (Major): 集成 aiofiles 实现真正的异步文件 I/O
- M5 (Major): 改用 logger.exception() 记录完整堆栈

Co-Authored-By: Claude Dev <noreply@anthropic.com>
2026-06-26 12:10:41 +08:00

228 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Service Layer - IndexerService(索引服务)
管理 wiki 页面索引,支持增量更新和全量重建。
参考设计文档:第 2.3 节
"""
import logging
import asyncio
from pathlib import Path
from typing import List, Set, Optional
from datetime import datetime
import aiofiles
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
# 读取文件内容(使用 aiofiles 实现真正的异步 I/O
async with aiofiles.open(full_path, 'r', encoding='utf-8') as f:
content = await 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:
async with aiofiles.open(full_path, 'r', encoding='utf-8') as f:
content = await 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
async with aiofiles.open(full_path, 'r', encoding='utf-8') as f:
content = await 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 个
}