From 421b3ada3b9ccd44e9a322149f89d7b6ad492eee Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sun, 28 Jun 2026 07:47:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=90=9C=E7=B4=A2=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E4=B8=8E=E4=BB=A3=E7=A0=81=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 连字符扩展:multi-agent → multi agent 提升搜索召回率 - 中文同义词支持:添加中英文混合查询扩展 - Snippet 高亮预留:为 FTS5 snippet 功能预留接口 - YAML 解析增强:支持 HTML 实体解码 - 标签关联维护:自动维护 wiki_page_tags 关联表 - 缓存失效优化:支持智能前缀匹配 - 设计文档更新:汇总近期改动 (v1.3) Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- docs/02-design.md | 20 +++++++- mcp_server/main.py | 8 +-- mcp_server/services/indexer.py | 5 +- mcp_server/services/parser.py | 46 +++++++++++++++++ mcp_server/services/query.py | 90 ++++++++++++++++++++++++++++++---- mcp_server/storage/database.py | 89 ++++++++++++++++++++++++--------- pytest.ini | 3 ++ scripts/deploy.sh | 65 ++++++++++++++++++++++++ tests/unit/test_services.py | 5 +- 10 files changed, 289 insertions(+), 44 deletions(-) create mode 100755 scripts/deploy.sh diff --git a/README.md b/README.md index 7b07e88..81dc213 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## 状态 -🚧 开发中 - Phase 3: 编码实现 +✅ Phase 5 完成 - 部署就绪 ## 项目概述 diff --git a/docs/02-design.md b/docs/02-design.md index eb41f85..a0b4a5d 100644 --- a/docs/02-design.md +++ b/docs/02-design.md @@ -1038,6 +1038,22 @@ pm2 save ## 14. 修订记录 +### v1.3(2026-06-28)- 搜索改进与代码修复 + +**搜索改进(P0-P1):** +- ✅ **连字符扩展**:在 `Database.update_fts_content()` 中添加 `_expand_hyphenated_text()` 方法,将 `multi-agent` 扩展为 `multi agent` 提升搜索召回率 +- ✅ **中文同义词支持**:在 `QueryService` 中添加 `CHINESE_SYNONYMS` 映射表和 `_expand_query_with_synonyms()` 方法,支持中英文混合查询(如"多智能体"自动扩展为包含 "multi-agent" 的查询) +- ✅ **Snippet 高亮预留**:在 `Database.fts_search()` 中添加 `with_snippets` 参数,为 FTS5 snippet 功能预留接口 + +**代码修复:** +- ✅ **YAML 解析增强**:添加 `html.unescape()` 支持解码 HTML 实体(如 `>` → `>`) +- ✅ **标签关联维护**:添加 `Database.update_page_tags()` 方法,自动维护 `wiki_page_tags` 关联表 +- ✅ **缓存失效优化**:在 `QueryService.invalidate_cache()` 中添加智能前缀处理,支持 `page:/links:/search:` 前缀匹配 + +**数据模型更新:** +- ✅ **FTS5 表结构优化**:移除外部内容表,直接存储所有字段在 FTS 表中 +- ✅ **索引策略调整**:title 和 summary 字段存储扩展版本(原版 + 连字符扩展版本) + ### v1.2(2026-06-26)- 第二轮评审修复 **Major(已修复):** @@ -1069,6 +1085,6 @@ pm2 save --- -*文档版本:v1.2* +*文档版本:v1.3* *创建时间:2026-06-26* -*更新时间:2026-06-26* +*更新时间:2026-06-28* diff --git a/mcp_server/main.py b/mcp_server/main.py index 92ed8bc..6309b8f 100644 --- a/mcp_server/main.py +++ b/mcp_server/main.py @@ -17,10 +17,12 @@ from pathlib import Path try: from mcp.server.models import InitializationOptions from mcp.server import Server, NotificationOptions + from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent MCP_AVAILABLE = True except ImportError: MCP_AVAILABLE = False + stdio_server = None logger = logging.getLogger(__name__) logger.warning("MCP SDK not available, using mock implementation") @@ -162,12 +164,12 @@ class MCPServer: async def run(self) -> None: """运行 MCP 服务器""" - if not self.server: + if not self.server or not stdio_server: logger.error("MCP Server not available (MCP SDK not installed)") return - # 运行服务器 - async with self.server.stdio_stdio() as (read_stream, write_stream): + # 运行服务器 - stdio 模式 + async with stdio_server() as (read_stream, write_stream): await self.server.run( read_stream, write_stream, diff --git a/mcp_server/services/indexer.py b/mcp_server/services/indexer.py index d703b7a..a577cdb 100644 --- a/mcp_server/services/indexer.py +++ b/mcp_server/services/indexer.py @@ -79,9 +79,8 @@ class IndexerService: 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) + # 更新标签关联 + await self.db.update_page_tags(page_path, page.tags) logger.info(f"Indexed page: {page_path}") return page diff --git a/mcp_server/services/parser.py b/mcp_server/services/parser.py index cf3f456..9a130d8 100644 --- a/mcp_server/services/parser.py +++ b/mcp_server/services/parser.py @@ -9,6 +9,7 @@ Service Layer - ParserService(解析服务) import re import logging import json +import html from typing import List, Dict, Optional, Tuple from datetime import datetime from pathlib import Path @@ -36,6 +37,48 @@ class ParserService: # Tag 正则(frontmatter 中的 tags 字段) TAG_PATTERN = re.compile(r'tags:\s*\[(.*?)\]') + def _fix_yaml_common_issues(self, yaml_content: str) -> str: + """修复常见的 YAML 格式问题 + + 1. HTML 实体解码 + 2. 修复一级键值对中的冒号问题(不处理嵌套) + """ + # 解码 HTML 实体 + yaml_content = html.unescape(yaml_content) + + # 修复一级键值对中的冒号问题 + # 只处理格式为 "key: value" 的行(不处理嵌套的缩进行) + lines = [] + for line in yaml_content.split('\n'): + stripped = line.strip() + + # 跳过空行、注释、列表项、多行标记 + if not stripped or stripped.startswith('#') or stripped.startswith('-') or stripped.startswith('>'): + lines.append(line) + continue + + # 只处理一级键值对(没有缩进或缩进较少) + # 检查是否是简单的 "key: value" 格式 + if ':' in line and not line.startswith(' ') and not line.startswith('\t'): + parts = line.split(':', 1) + if len(parts) == 2: + key = parts[0].strip() + value = parts[1].strip() + + # 如果值包含冒号且没有引号,添加引号 + if value and ':' in value and not (value.startswith('"') or value.startswith("'") or value.startswith('|')): + # 检查值不是特殊格式 + if not value.startswith('>') and not value.startswith('|'): + value = f'"{value}"' + + lines.append(f"{key}: {value}") + else: + lines.append(line) + else: + lines.append(line) + + return '\n'.join(lines) + def parse_frontmatter(self, content: str) -> Dict: """ 解析 frontmatter(使用 PyYAML) @@ -54,6 +97,9 @@ class ParserService: if match: yaml_content = match.group(1) + # 修复常见 YAML 问题 + yaml_content = self._fix_yaml_common_issues(yaml_content) + # 使用 PyYAML 进行完整解析 if YAML_AVAILABLE: try: diff --git a/mcp_server/services/query.py b/mcp_server/services/query.py index 3fb6499..700bfa1 100644 --- a/mcp_server/services/query.py +++ b/mcp_server/services/query.py @@ -7,13 +7,32 @@ Service Layer - QueryService(查询服务) """ import logging -from typing import List, Set, Optional +import re +from typing import List, Set, Optional, Dict from ..storage import Database, WikiPage from .cache import CacheService logger = logging.getLogger(__name__) +# 中文同义词映射(用于扩展搜索查询) +CHINESE_SYNONYMS: Dict[str, List[str]] = { + "多智能体": ["multi-agent", "多agent", "multi agent"], + "编排": ["orchestration", "调度"], + "协作": ["collaboration", "协同"], + "工具": ["tool", "工具调用"], + "模式": ["pattern", "设计模式"], + "实践": ["practice", "最佳实践"], + "架构": ["architecture", "系统架构"], + "代理": ["agent", "智能体"], + "大模型": ["llm", "language model", "语言模型"], + "向量": ["vector", "embedding", "嵌入"], + "检索": ["retrieval", "search", "搜索"], + "生成": ["generation", "生成式"], + "推理": ["inference", "reasoning"], +} + + class QueryService: """查询服务 - 负责所有查询逻辑""" @@ -21,19 +40,51 @@ class QueryService: self.db = db self.cache = cache - async def search(self, query: str, limit: int = 10) -> List[WikiPage]: - """FTS5 全文搜索""" + def _expand_query_with_synonyms(self, query: str) -> str: + """使用中文同义词扩展查询 + + 例如:"多智能体编排" → "多智能体编排 multi-agent orchestration" + """ + expanded_terms = [] + query_lower = query.lower() + + # 检查每个中文词是否有对应的英文同义词 + for chinese_term, english_synonyms in CHINESE_SYNONYMS.items(): + if chinese_term in query: + expanded_terms.extend(english_synonyms) + + if expanded_terms: + # 构建扩展查询(OR 连接) + expanded_query = f"{query} OR {' OR '.join(expanded_terms)}" + logger.debug(f"Expanded query: {query} → {expanded_query}") + return expanded_query + + return query + + async def search(self, query: str, limit: int = 10, use_synonyms: bool = True) -> List[WikiPage]: + """FTS5 全文搜索 + + Args: + query: 搜索查询 + limit: 返回结果数量限制 + use_synonyms: 是否使用中文同义词扩展 + """ # 1. 检查缓存 - cache_key = f"search:{query}:{limit}" + cache_key = f"search:{query}:{limit}:{use_synonyms}" 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) + # 2. 查询扩展(中文同义词) + search_query = query + if use_synonyms: + search_query = self._expand_query_with_synonyms(query) - # 3. 缓存结果 + # 3. FTS5 搜索 + results = await self.db.fts_search(search_query, limit) + + # 4. 缓存结果 await self.cache.set(cache_key, results, ttl=3600) return results @@ -116,5 +167,26 @@ class QueryService: return await self.db.get_stats() async def invalidate_cache(self, pattern: str = "*") -> int: - """使缓存失效""" - return await self.cache.invalidate(pattern) + """使缓存失效 + + 如果 pattern 是具体路径(不是通配符),自动尝试匹配所有缓存前缀: + - page:{pattern} + - links:{pattern} + - search:{pattern} + """ + # 清除所有缓存 + if pattern == "*": + return await self.cache.invalidate(pattern) + + # 对于具体路径,尝试所有可能的前缀 + prefixes = ["page:", "links:", "search:"] + total_invalidated = 0 + + for prefix in prefixes: + count = await self.cache.invalidate(f"{prefix}{pattern}") + total_invalidated += count + + # 也尝试原始模式(兼容直接传入完整 key 的情况) + total_invalidated += await self.cache.invalidate(pattern) + + return total_invalidated diff --git a/mcp_server/storage/database.py b/mcp_server/storage/database.py index 74b5dea..563985d 100644 --- a/mcp_server/storage/database.py +++ b/mcp_server/storage/database.py @@ -94,23 +94,13 @@ class Database: await self._conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_updated ON wiki_pages(updated_at)") await self._conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_source_tool ON wiki_pages(source_tool)") - # 内容表(FTS5 外部内容表)- 必须先创建 - await self._conn.execute(""" - CREATE TABLE IF NOT EXISTS wiki_content ( - path TEXT PRIMARY KEY, - content TEXT NOT NULL - ) - """) - - # FTS5 全文搜索表 + # FTS5 全文搜索表(不使用外部内容表,简化结构) await self._conn.execute(""" CREATE VIRTUAL TABLE IF NOT EXISTS wiki_fts USING fts5( path UNINDEXED, title, content, summary, - content=wiki_content, - content_rowid=rowid, tokenize = 'porter unicode61' ) """) @@ -241,7 +231,8 @@ class Database: return row[0] if row else None async def upsert_page(self, page: WikiPage) -> None: - """插入或更新页面""" + """插入或更新页面(包括标签关联)""" + # 先更新页面基本信息 await self.execute_with_retry(""" INSERT INTO wiki_pages (path, title, category, tags, summary, content_hash, lifecycle, source_tool, created_at, updated_at, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -261,6 +252,9 @@ class Database: page.created_at, page.updated_at, page.indexed_at )) + # 更新标签关联 + await self.update_page_tags(page.path, page.tags) + async def delete_page(self, path: str) -> None: """删除页面(级联删除相关数据)""" await self.execute_with_retry("DELETE FROM wiki_pages WHERE path = ?", (path,)) @@ -314,8 +308,14 @@ class Database: return query.strip() - async def fts_search(self, query: str, limit: int = 10) -> List[WikiPage]: - """FTS5 全文搜索""" + async def fts_search(self, query: str, limit: int = 10, with_snippets: bool = False) -> List[WikiPage]: + """FTS5 全文搜索 + + Args: + query: 搜索查询 + limit: 返回结果数量限制 + with_snippets: 是否返回匹配片段(暂未实现,预留接口) + """ # 验证和清理查询 try: safe_query = self._validate_fts_query(query) @@ -447,6 +447,24 @@ class Database: (tag, count) ) + async def update_page_tags(self, path: str, tags: List[str]) -> None: + """更新页面的标签关联 + + 先删除旧的标签关联,再插入新的。 + 同时更新 wiki_tags 表中的计数。 + """ + # 删除旧的标签关联 + await self.execute_with_retry("DELETE FROM wiki_page_tags WHERE path = ?", (path,)) + + # 插入新的标签关联 + for tag in tags: + await self.execute_with_retry( + "INSERT INTO wiki_page_tags (path, tag) VALUES (?, ?)", + (path, tag) + ) + # 更新标签计数 + await self.upsert_tag(tag) + async def get_all_tags(self) -> Dict[str, int]: """获取所有标签""" rows = await self.fetch_all("SELECT tag, count FROM wiki_tags") @@ -469,22 +487,45 @@ class Database: # === FTS5 内容更新 === async def update_fts_content(self, path: str, title: str, content: str, summary: str) -> None: - """更新 FTS5 内容""" - # 先更新内容表 + """更新 FTS5 内容 + + FTS5 虚拟表不支持 UPSERT,使用 DELETE + INSERT 替代。 + 所有内容(包括 content 字段)直接存储在 FTS 表中。 + + 优化:连字符扩展(multi-agent → multi agent)以提升搜索召回率 + """ + # 先删除旧记录(如果存在) + await self.execute_with_retry("DELETE FROM wiki_fts WHERE path = ?", (path,)) + + # 扩展连字符词汇(multi-agent → multi agent) + # 这样搜索 "multi" 或 "agent" 都能匹配到 + expanded_title = self._expand_hyphenated_text(title) + expanded_summary = self._expand_hyphenated_text(summary) + + # 插入扩展后的 title 和 summary(原版保留在 content 中) await self.execute_with_retry( - "INSERT INTO wiki_content (path, content) VALUES (?, ?) ON CONFLICT(path) DO UPDATE SET content = excluded.content", - (path, content) + "INSERT INTO wiki_fts (path, title, summary, content) VALUES (?, ?, ?, ?)", + (path, f"{title} {expanded_title}", f"{summary} {expanded_summary}", content) ) - # 再更新 FTS5 表(会自动从内容表同步) - await self.execute_with_retry( - "INSERT INTO wiki_fts (path, title, summary) VALUES (?, ?, ?) ON CONFLICT(path) DO UPDATE SET title = excluded.title, summary = excluded.summary", - (path, title, summary) - ) + def _expand_hyphenated_text(self, text: str) -> str: + """扩展连字符词汇 + + multi-agent system → multi agent system + 这样搜索 "multi" 或 "agent" 都能匹配 + + Args: + text: 原始文本 + + Returns: + 扩展后的文本(连字符替换为空格) + """ + if not text: + return "" + return text.replace('-', ' ') async def delete_fts_content(self, path: str) -> None: """删除 FTS5 内容""" - await self.execute_with_retry("DELETE FROM wiki_content WHERE path = ?", (path,)) await self.execute_with_retry("DELETE FROM wiki_fts WHERE path = ?", (path,)) # === 状态查询 === diff --git a/pytest.ini b/pytest.ini index 35bcc16..ee5c03d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -12,6 +12,9 @@ addopts = --cov-report=term-missing --cov-report=html +# asyncio 模式 - 自动处理异步 fixtures 和测试 +asyncio_mode = auto + # 标记定义 markers = unit: 单元测试 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..93658f0 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# sanguo_llmwiki Deployment Script +# Deploys from development directory to installation directory + +set -e # Exit on error + +# Paths +SRC_DIR="$HOME/.openclaw/sanguo_projects/sanguo_llmwiki" +DST_DIR="$HOME/.sanguo_projects/sanguo_llmwiki" + +# Colors +GREEN='\033[0;32m' +NC='\033[0m' + +echo "Deploying sanguo_llmwiki..." +echo "Source: $SRC_DIR" +echo "Target: $DST_DIR" + +# Create target directory if it doesn't exist +mkdir -p "$DST_DIR" + +# Deploy using rsync with exclusions +rsync -av --delete \ + --exclude='.git/' \ + --exclude='.gitignore' \ + --exclude='.claude/' \ + --exclude='__pycache__/' \ + --exclude='*.pyc' \ + --exclude='.pytest_cache/' \ + --exclude='.python-version' \ + --exclude='.coverage' \ + --exclude='.ruff_cache/' \ + --exclude='tests/' \ + --exclude='pytest.ini' \ + --exclude='.env' \ + --exclude='*.egg-info/' \ + --exclude='dist/' \ + --exclude='build/' \ + --exclude='docs/' \ + "$SRC_DIR/" "$DST_DIR/" + +echo -e "${GREEN}✓ Code deployed successfully${NC}" + +# Install dependencies using virtual environment +echo "Setting up virtual environment..." +cd "$DST_DIR" +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt + +echo -e "${GREEN}✓ Dependencies installed${NC}" + +# Update PM2 config to use virtual environment python (macOS sed syntax) +sed -i '' "s|script: 'python3'|script: '$DST_DIR/.venv/bin/python'|g" ecosystem.config.cjs + +# Create necessary directories (use paths from ecosystem.config.cjs) +mkdir -p "$HOME/.sanguo-llmwiki" +mkdir -p "/Volumes/KnowledgeBase" + +echo -e "${GREEN}✓ Deployment complete!${NC}" +echo "" +echo "Next steps:" +echo " 1. Test MCP Server: cd $DST_DIR && source .venv/bin/activate && python -m mcp_server.main" +echo " 2. Start with PM2: pm2 start ecosystem.config.cjs" diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index 17b35e0..d8cb171 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -124,8 +124,9 @@ metadata: """测试使用 PyYAML 解析复杂 frontmatter""" content = """--- name: complex-page -description: Multi-line description -with multiple lines +description: | + Multi-line description + with multiple lines tags: - tag1 - tag2