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>
This commit is contained in:
2026-06-26 11:32:43 +08:00
parent 67a53d82de
commit dfd8421dc6
22 changed files with 2561 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
"""
Service Layer - 服务层
提供各种服务:缓存、查询、索引、解析、图服务。
"""
from .cache import CacheService
from .query import QueryService
from .parser import ParserService
from .indexer import IndexerService
from .graph import GraphService
__all__ = [
"CacheService",
"QueryService",
"ParserService",
"IndexerService",
"GraphService"
]
+91
View File
@@ -0,0 +1,91 @@
"""
Service Layer - CacheService(缓存服务)
使用 LRU 缓存 + TTL 过期策略,防止内存泄漏。
参考设计文档:第 2.3 节
"""
import asyncio
import time
from collections import OrderedDict
from typing import Optional, Any
import fnmatch
import logging
logger = logging.getLogger(__name__)
class CacheService:
"""缓存服务 - LRU 缓存 + TTL 过期"""
def __init__(self, max_size: int = 1000):
self.cache: OrderedDict[str, tuple] = OrderedDict() # key -> (value, expire_time)
self.max_size = max_size
self.lock = asyncio.Lock()
async def get(self, key: str) -> Optional[Any]:
"""获取缓存值(异步,带锁)"""
async with self.lock:
if key not in self.cache:
return None
value, expire_time = self.cache[key]
# 检查是否过期
if expire_time and time.time() > expire_time:
del self.cache[key]
logger.debug(f"Cache expired: {key}")
return None
# LRU: 移到末尾
self.cache.move_to_end(key)
return value
async def set(self, key: str, value: Any, ttl: int = 3600) -> None:
"""设置缓存值(异步,带锁)"""
async with self.lock:
expire_time = time.time() + ttl if ttl else None
# 如果缓存已满,删除最旧的条目
if len(self.cache) >= self.max_size and key not in self.cache:
self.cache.popitem(last=False) # FIFO 删除
logger.debug(f"Cache full, evicted oldest entry")
self.cache[key] = (value, expire_time)
self.cache.move_to_end(key)
logger.debug(f"Cache set: {key} (TTL={ttl}s)")
async def invalidate(self, pattern: str) -> int:
"""按模式清除缓存(支持 * 通配符)"""
async with self.lock:
if pattern == "*":
count = len(self.cache)
self.cache.clear()
logger.info(f"Cache cleared: {count} entries")
return count
keys_to_delete = [k for k in self.cache.keys() if fnmatch.fnmatch(k, pattern)]
for key in keys_to_delete:
del self.cache[key]
logger.info(f"Cache invalidated: {len(keys_to_delete)} entries matching '{pattern}'")
return len(keys_to_delete)
async def get_stats(self) -> dict:
"""获取缓存统计信息"""
async with self.lock:
now = time.time()
expired_count = sum(1 for _, expire_time in self.cache.values() if expire_time and now > expire_time)
return {
"size": len(self.cache),
"max_size": self.max_size,
"expired_count": expired_count,
"utilization": len(self.cache) / self.max_size if self.max_size > 0 else 0
}
async def clear(self) -> None:
"""清空缓存"""
async with self.lock:
self.cache.clear()
logger.info("Cache cleared")
+137
View File
@@ -0,0 +1,137 @@
"""
Service Layer - GraphService(图服务)
管理页面间的链接关系,查找孤立页面和缺失链接。
参考设计文档:第 2.3 节
"""
import logging
from typing import Set, List, Tuple
from ..storage import Database
logger = logging.getLogger(__name__)
class GraphService:
"""图服务 - 管理链接关系"""
def __init__(self, db: Database):
self.db = db
async def get_links(self, path: str) -> Set[str]:
"""获取页面的出链"""
return await self.db.get_links(path)
async def get_backlinks(self, path: str) -> Set[str]:
"""获取页面的反向链接"""
return await self.db.get_backlinks(path)
async def find_orphans(self) -> Set[str]:
"""查找孤立页面(无反向链接)"""
all_pages = await self.db.get_all_pages()
orphans = set()
for page in all_pages:
backlinks = await self.db.get_backlinks(page.path)
if not backlinks and page.path != "index.md":
orphans.add(page.path)
return orphans
async def find_missing_links(self) -> List[Tuple[str, str]]:
"""查找缺失的交叉引用"""
# 获取所有页面内容
all_pages = await self.db.get_all_pages()
page_paths = {page.path for page in all_pages}
# 获取所有链接关系
links_rows = await self.db.fetch_all("SELECT source, target FROM wiki_links")
missing = []
for source, target in links_rows:
if target not in page_paths:
missing.append((source, target))
return missing
async def get_link_graph(self) -> dict:
"""获取完整的链接图"""
pages = await self.db.get_all_pages()
graph = {}
for page in pages:
links = await self.db.get_links(page.path)
backlinks = await self.db.get_backlinks(page.path)
graph[page.path] = {
"title": page.title,
"category": page.category,
"links": sorted(links),
"backlinks": sorted(backlinks),
"links_count": len(links),
"backlinks_count": len(backlinks)
}
return graph
async def get_highly_connected_pages(self, threshold: int = 5) -> List[dict]:
"""获取高度连接的页面(入链 + 出链 >= threshold"""
pages = await self.db.get_all_pages()
highly_connected = []
for page in pages:
links = await self.db.get_links(page.path)
backlinks = await self.db.get_backlinks(page.path)
total = len(links) + len(backlinks)
if total >= threshold:
highly_connected.append({
"path": page.path,
"title": page.title,
"links_count": len(links),
"backlinks_count": len(backlinks),
"total_connections": total
})
return sorted(highly_connected, key=lambda x: x["total_connections"], reverse=True)
async def get_disconnected_components(self) -> List[Set[str]]:
"""获取不连通的图分量(使用 BFS"""
pages = await self.db.get_all_pages()
page_paths = {page.path for page in pages}
if not page_paths:
return []
visited = set()
components = []
for start_path in page_paths:
if start_path in visited:
continue
# BFS 构建连通分量
component = set()
queue = [start_path]
while queue:
current = queue.pop(0)
if current in visited:
continue
visited.add(current)
component.add(current)
# 添加出链和入链
for link in await self.db.get_links(current):
if link in page_paths and link not in visited:
queue.append(link)
for backlink in await self.db.get_backlinks(current):
if backlink in page_paths and backlink not in visited:
queue.append(backlink)
components.append(component)
# 返回最大的分量以外的所有分量(即孤立的子图)
main_component = max(components, key=len)
return [c for c in components if c != main_component]
+226
View File
@@ -0,0 +1,226 @@
"""
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 个
}
+239
View File
@@ -0,0 +1,239 @@
"""
Service Layer - ParserService(解析服务)
解析 Markdown 文件,提取 frontmatter、链接、标签等。
参考设计文档:第 2.3 节
"""
import re
import logging
import json
from typing import List, Dict, Optional, Tuple
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
class ParserService:
"""解析服务 - 解析 Markdown 文件"""
# Frontmatter 正则
FRONTMATTER_PATTERN = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL)
# Wikilink 正则
WIKILINK_PATTERN = re.compile(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]')
# Tag 正则(frontmatter 中的 tags 字段)
TAG_PATTERN = re.compile(r'tags:\s*\[(.*?)\]')
def parse_frontmatter(self, content: str) -> Dict:
"""
解析 frontmatter
返回格式:
{
"name": "...",
"description": "...",
"metadata": {...},
"custom": {...} # 自定义字段
}
"""
frontmatter = {}
match = self.FRONTMATTER_PATTERN.match(content)
if match:
yaml_content = match.group(1)
# 简单解析(对于复杂情况,应该使用 PyYAML)
for line in yaml_content.split('\n'):
if ':' in line:
key, value = line.split(':', 1)
key = key.strip()
value = value.strip()
frontmatter[key] = value
return frontmatter
def extract_links(self, content: str) -> List[str]:
"""
提取所有 [[wikilinks]]
返回链接目标列表(去重)
"""
links = self.WIKILINK_PATTERN.findall(content)
return list(set(links))
def extract_tags(self, content: str) -> List[str]:
"""
从 frontmatter 中提取标签
支持:
- tags: ["tag1", "tag2"]
- tags: [tag1, tag2]
"""
tags = []
match = self.TAG_PATTERN.search(content)
if match:
tag_content = match.group(1)
# 尝试 JSON 解析
try:
tags = json.loads(f"[{tag_content}]")
except:
# 简单逗号分隔
tags = [t.strip().strip('"\'') for t in tag_content.split(',')]
return tags
def extract_title(self, content: str) -> str:
"""
提取标题(优先级:frontmatter > 第一个 # 标题)
返回标题或空字符串
"""
# 先检查 frontmatter
frontmatter = self.parse_frontmatter(content)
if "title" in frontmatter:
return frontmatter["title"]
# 检查第一个 # 标题
lines = content.split('\n')
for line in lines:
line = line.strip()
if line.startswith('#'):
# 移除 # 符号和空格
title = re.sub(r'^#+\s*', '', line)
return title
return ""
def extract_summary(self, content: str, max_length: int = 200) -> str:
"""
提取摘要(优先级:frontmatter description > 第一段文字)
限制在 max_length 字符内
"""
# 先检查 frontmatter
frontmatter = self.parse_frontmatter(content)
if "description" in frontmatter:
return frontmatter["description"][:max_length]
# 移除 frontmatter 和代码块
content_without_frontmatter = self.FRONTMATTER_PATTERN.sub('', content)
content_without_code = re.sub(r'```.*?```', '', content_without_frontmatter, flags=re.DOTALL)
# 获取第一段
lines = content_without_code.strip().split('\n\n')
for paragraph in lines:
paragraph = paragraph.strip()
if paragraph and not paragraph.startswith('#'):
# 移除 Markdown 格式
clean_paragraph = re.sub(r'[*_`#\[\]]', '', paragraph)
if clean_paragraph:
return clean_paragraph[:max_length]
return ""
def infer_category(self, path: str) -> str:
"""
从路径推断分类
例如:practices/xxx.md -> practices
"""
path_obj = Path(path)
if path_obj.parent.name:
return path_obj.parent.name
return "uncategorized"
def infer_source_tool(self, content: str) -> str:
"""
从内容推断来源工具
检查 frontmatter 中的特定字段:
- source_tool: claude/web_reader/gitea/other
"""
frontmatter = self.parse_frontmatter(content)
return frontmatter.get("source_tool", "other")
def validate_page(self, path: str, content: str) -> List[str]:
"""
验证页面,返回问题列表
检查:
- 是否有标题
- 是否有摘要
- 是否有分类
- frontmatter 是否有效
"""
issues = []
# 检查标题
title = self.extract_title(content)
if not title:
issues.append("Missing title")
# 检查摘要
summary = self.extract_summary(content)
if not summary:
issues.append("Missing summary")
# 检查分类
category = self.infer_category(path)
if category == "uncategorized":
issues.append("Uncategorized page")
# 检查 frontmatter
frontmatter = self.parse_frontmatter(content)
if not frontmatter:
issues.append("Missing or invalid frontmatter")
return issues
def parse_wiki_page(self, path: str, content: str, created_at: Optional[str] = None, updated_at: Optional[str] = None) -> Dict:
"""
完整解析 wiki 页面,返回结构化数据
返回格式与 WikiPage 兼容
"""
title = self.extract_title(content)
summary = self.extract_summary(content)
category = self.infer_category(path)
tags = self.extract_tags(content)
source_tool = self.infer_source_tool(content)
links = self.extract_links(content)
now = datetime.now().isoformat()
return {
"path": path,
"title": title,
"category": category,
"tags": tags,
"summary": summary,
"links": links,
"source_tool": source_tool,
"created_at": created_at or now,
"updated_at": updated_at or now,
"indexed_at": now
}
def format_wikilink(self, path: str, title: Optional[str] = None) -> str:
"""
格式化 wikilink
如果提供 title,使用 [[path|title]] 格式
否则使用 [[path]] 格式
"""
if title:
return f"[[{path}|{title}]]"
return f"[[{path}]]"
def resolve_wikilink_path(self, link: str, current_path: str) -> str:
"""
解析相对 wikilink 路径
例如:在 practices/a.md 中的 [[../concepts/x]] 解析为 concepts/x
"""
# TODO: 实现相对路径解析
return link
+120
View File
@@ -0,0 +1,120 @@
"""
Service Layer - QueryService(查询服务)
负责所有查询逻辑,包括 FTS5 全文搜索、标签搜索、页面查询等。
参考设计文档:第 2.3 节
"""
import logging
from typing import List, Set, Optional
from ..storage import Database, WikiPage
from .cache import CacheService
logger = logging.getLogger(__name__)
class QueryService:
"""查询服务 - 负责所有查询逻辑"""
def __init__(self, db: Database, cache: CacheService):
self.db = db
self.cache = cache
async def search(self, query: str, limit: int = 10) -> List[WikiPage]:
"""FTS5 全文搜索"""
# 1. 检查缓存
cache_key = f"search:{query}:{limit}"
cached = await self.cache.get(cache_key)
if cached:
logger.debug(f"Cache hit for search: {query}")
return cached
# 2. FTS5 搜索
results = await self.db.fts_search(query, limit)
# 3. 缓存结果
await self.cache.set(cache_key, results, ttl=3600)
return results
async def search_by_tags(self, tags: List[str]) -> List[WikiPage]:
"""按标签搜索"""
cache_key = f"tags:{','.join(tags)}"
cached = await self.cache.get(cache_key)
if cached:
return cached
results = await self.db.search_by_tags(tags)
await self.cache.set(cache_key, results, ttl=3600)
return results
async def search_by_source_tool(self, tool_name: str, limit: int = 50) -> List[WikiPage]:
"""按来源工具搜索(memory_bridge 使用)"""
cache_key = f"tool:{tool_name}:{limit}"
cached = await self.cache.get(cache_key)
if cached:
return cached
results = await self.db.search_by_source_tool(tool_name, limit)
await self.cache.set(cache_key, results, ttl=1800) # 30 分钟 TTL
return results
async def get_page(self, path: str) -> Optional[WikiPage]:
"""获取单个页面"""
cache_key = f"page:{path}"
cached = await self.cache.get(cache_key)
if cached:
return cached
page = await self.db.get_page(path)
if page:
await self.cache.set(cache_key, page, ttl=3600)
return page
async def get_links(self, path: str) -> Set[str]:
"""获取页面的出链"""
cache_key = f"links:{path}"
cached = await self.cache.get(cache_key)
if cached:
return cached
links = await self.db.get_links(path)
await self.cache.set(cache_key, links, ttl=3600)
return links
async def get_backlinks(self, path: str) -> Set[str]:
"""获取页面的反向链接"""
cache_key = f"backlinks:{path}"
cached = await self.cache.get(cache_key)
if cached:
return cached
backlinks = await self.db.get_backlinks(path)
await self.cache.set(cache_key, backlinks, ttl=3600)
return backlinks
async def find_orphans(self) -> Set[str]:
"""查找孤立页面(无反向链接)"""
cache_key = "orphans"
cached = await self.cache.get(cache_key)
if cached:
return cached
all_pages = await self.db.get_all_pages()
orphans = set()
for page in all_pages:
backlinks = await self.db.get_backlinks(page.path)
if not backlinks and page.path != "index.md":
orphans.add(page.path)
await self.cache.set(cache_key, orphans, ttl=1800)
return orphans
async def get_stats(self) -> dict:
"""获取索引统计信息"""
return await self.db.get_stats()
async def invalidate_cache(self, pattern: str = "*") -> int:
"""使缓存失效"""
return await self.cache.invalidate(pattern)