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:
@@ -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"
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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 个
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user