Files
novelmaster/noma/scripts/data_modules/cross_project_rag.py
T
2026-06-23 20:29:02 +08:00

727 lines
29 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Cross-Project RAG - 跨层级 RAG 检索模块
实现三层 RAG 架构(向下继承):
1. 小说私有层 - novel/.noma/rag/
2. 工作空间共享层 - workspaces/{ws}/.noma/rag/
3. 工程共享层 - project_root/.noma/rag/
4. 插件内置层 - plugin/matrices/
读取时:小说 → 工作空间 → 工程 → 插件(向下继承)
写入时:默认写入小说层,可选择向上沉淀
"""
import json
import os
import sqlite3
import asyncio
from pathlib import Path
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
from enum import Enum
from datetime import datetime
try:
from runtime_compat import enable_windows_utf8_stdio
except ImportError:
enable_windows_utf8_stdio = lambda: None
class RAGLayer(Enum):
"""RAG 层级 - 四层架构"""
NOVEL = "novel" # 小说私有(最高权重)
WORKSPACE = "workspace" # 工作空间共享
PROJECT = "project" # 工程目录共享
PLUGIN = "plugin" # 插件内置
@dataclass
class LearnedPattern:
"""学习到的爽点模式"""
pattern_id: str
pattern_type: str
title: str
description: str
tension_curve: List[List[float]]
catharsis_model: str
structure: Dict[str, int]
hot_spots: List[List[Any]]
style_tags: List[str]
source_project: str
source_chapter: int
learned_at: str
usage_count: int = 0
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class CrossProjectSearchResult:
"""检索结果"""
chunk_id: str
content: str
score: float
source_layer: RAGLayer
source_project: Optional[str]
chapter: Optional[int]
chunk_type: Optional[str]
metadata: Dict[str, Any] = field(default_factory=dict)
class CrossProjectRAG:
"""
四层 RAG 检索器
检索时自动向下继承:小说私有 > 工作空间 > 工程 > 插件
"""
# 权重:小说私有 > 工作空间 > 工程 > 插件
DEFAULT_WEIGHTS = {
RAGLayer.NOVEL: 1.0,
RAGLayer.WORKSPACE: 0.8,
RAGLayer.PROJECT: 0.6,
RAGLayer.PLUGIN: 0.3,
}
def __init__(
self,
project_root: Path,
workspace_root: Optional[Path] = None,
project_root_dir: Optional[Path] = None,
plugin_root: Optional[Path] = None,
weights: Optional[Dict[RAGLayer, float]] = None,
config: Optional[Any] = None,
):
self.project_root = Path(project_root).resolve()
# 四层根路径
self.novel_root = self.project_root
self.workspace_root = workspace_root or self._resolve_workspace_root()
self.project_root_dir = project_root_dir or self._resolve_project_root_dir()
self.plugin_root = plugin_root or self._resolve_plugin_root()
self.weights = weights or self.DEFAULT_WEIGHTS
# 加载配置
self.config = config
self._load_config()
# 初始化四层路径
self._init_paths()
# 确保目录存在
self._ensure_dirs()
def _init_paths(self):
"""初始化四层 RAG 路径"""
# 层1:小说私有
self.novel_rag_dir = self.novel_root / ".noma" / "rag"
self.novel_vectors_db = self.novel_rag_dir / "vectors.db"
self.novel_learned_db = self.novel_rag_dir / "learned.db"
# 层2:工作空间共享
if self.workspace_root:
self.ws_rag_dir = self.workspace_root / ".noma" / "rag"
self.ws_shared_dir = self.ws_rag_dir / "shared"
self.ws_catharsis_dir = self.ws_shared_dir / "catharsis"
self.ws_genres_dir = self.ws_shared_dir / "genres"
self.ws_learned_dir = self.ws_rag_dir / "learned"
else:
self.ws_rag_dir = None
self.ws_shared_dir = None
self.ws_catharsis_dir = None
self.ws_genres_dir = None
self.ws_learned_dir = None
# 层3:工程共享
if self.project_root_dir:
self.proj_rag_dir = self.project_root_dir / ".noma" / "rag"
self.proj_shared_dir = self.proj_rag_dir / "shared"
self.proj_catharsis_dir = self.proj_shared_dir / "catharsis"
self.proj_genres_dir = self.proj_shared_dir / "genres"
self.proj_learned_dir = self.proj_rag_dir / "learned"
else:
self.proj_rag_dir = None
self.proj_shared_dir = None
self.proj_catharsis_dir = None
self.proj_genres_dir = None
self.proj_learned_dir = None
# 层4:插件内置
self.plugin_matrices_dir = self.plugin_root / "matrices"
self.plugin_catharsis_dir = self.plugin_matrices_dir / "catharsis_models"
self.plugin_genres_dir = self.plugin_matrices_dir / "genres"
def _resolve_workspace_root(self) -> Optional[Path]:
"""解析工作空间根目录"""
env_path = os.environ.get("NOMA_WORKSPACE_ROOT")
if env_path:
p = Path(env_path).resolve()
if p.exists() and (p / ".noma" / "rag").exists():
return p
current = self.project_root
while True:
workspaces_dir = current / "workspaces"
if workspaces_dir.is_dir():
for ws_dir in workspaces_dir.iterdir():
if ws_dir.is_dir() and (ws_dir / ".noma" / "rag").exists():
if self.project_root == ws_dir or str(self.project_root).startswith(str(ws_dir) + os.sep):
return ws_dir
parent = current.parent
if parent == current:
break
current = parent
return None
def _resolve_project_root_dir(self) -> Optional[Path]:
"""解析工程目录根"""
env_path = os.environ.get("NOMA_PROJECT_ROOT_DIR")
if env_path:
p = Path(env_path).resolve()
if p.exists() and (p / "workspaces").is_dir() and (p / ".noma" / "rag").is_dir():
return p
current = self.project_root
while True:
if (current / "workspaces").is_dir() and (current / ".noma" / "rag").is_dir():
return current
parent = current.parent
if parent == current:
break
current = parent
return None
def _resolve_plugin_root(self) -> Path:
"""解析插件根目录"""
env_path = os.environ.get("NOMA_PLUGIN_ROOT")
if env_path:
p = Path(env_path)
if p.exists():
return p
current_file = Path(__file__).resolve()
candidate = current_file.parent.parent.parent.parent
if (candidate / "matrices").exists():
return candidate
return current_file.parent.parent.parent
def _ensure_dirs(self):
"""确保必要的目录存在"""
self.novel_rag_dir.mkdir(parents=True, exist_ok=True)
if self.ws_rag_dir:
self.ws_rag_dir.mkdir(parents=True, exist_ok=True)
if self.ws_shared_dir:
self.ws_shared_dir.mkdir(parents=True, exist_ok=True)
if self.ws_catharsis_dir:
self.ws_catharsis_dir.mkdir(parents=True, exist_ok=True)
if self.ws_genres_dir:
self.ws_genres_dir.mkdir(parents=True, exist_ok=True)
if self.ws_learned_dir:
self.ws_learned_dir.mkdir(parents=True, exist_ok=True)
if self.proj_rag_dir:
self.proj_rag_dir.mkdir(parents=True, exist_ok=True)
if self.proj_shared_dir:
self.proj_shared_dir.mkdir(parents=True, exist_ok=True)
if self.proj_catharsis_dir:
self.proj_catharsis_dir.mkdir(parents=True, exist_ok=True)
if self.proj_genres_dir:
self.proj_genres_dir.mkdir(parents=True, exist_ok=True)
if self.proj_learned_dir:
self.proj_learned_dir.mkdir(parents=True, exist_ok=True)
def _load_config(self):
"""加载配置"""
if self.config is not None:
self._extract_embed_config(self.config)
return
try:
from data_modules.config import DataModulesConfig
self.config = DataModulesConfig.from_project_root(self.project_root)
self._extract_embed_config(self.config)
return
except Exception:
pass
self._embed_base_url = os.getenv("EMBED_BASE_URL", "")
self._embed_model = os.getenv("EMBED_MODEL", "")
self._embed_api_key = os.getenv("EMBED_API_KEY", "")
def _extract_embed_config(self, config):
"""从配置对象提取 embedding 配置"""
self._embed_base_url = getattr(config, 'embed_base_url', "") or os.getenv("EMBED_BASE_URL", "")
self._embed_model = getattr(config, 'embed_model', "") or os.getenv("EMBED_MODEL", "")
self._embed_api_key = getattr(config, 'embed_api_key', "") or os.getenv("EMBED_API_KEY", "")
async def search(
self,
query: str,
top_k: int = 5,
layers: Optional[List[RAGLayer]] = None,
chunk_type: Optional[str] = None,
) -> List[CrossProjectSearchResult]:
"""四层检索(向下继承)"""
if layers is None:
layers = [RAGLayer.NOVEL, RAGLayer.WORKSPACE, RAGLayer.PROJECT, RAGLayer.PLUGIN]
all_results = []
tasks_with_layers = []
if RAGLayer.NOVEL in layers:
tasks_with_layers.append((RAGLayer.NOVEL, self._search_novel(query, top_k, chunk_type)))
if RAGLayer.WORKSPACE in layers and self.ws_rag_dir:
tasks_with_layers.append((RAGLayer.WORKSPACE, self._search_workspace(query, top_k, chunk_type)))
if RAGLayer.PROJECT in layers and self.proj_rag_dir:
tasks_with_layers.append((RAGLayer.PROJECT, self._search_project(query, top_k, chunk_type)))
if RAGLayer.PLUGIN in layers:
tasks_with_layers.append((RAGLayer.PLUGIN, self._search_plugin(query, top_k, chunk_type)))
if tasks_with_layers:
tasks = [t[1] for t in tasks_with_layers]
layer_results = await asyncio.gather(*tasks)
for (layer, _), results in zip(tasks_with_layers, layer_results):
if results:
for r in results:
r.score *= self.weights.get(layer, 1.0)
all_results.append(r)
all_results.sort(key=lambda x: x.score, reverse=True)
return all_results[:top_k]
async def _search_novel(self, query: str, top_k: int, chunk_type: Optional[str]) -> List[CrossProjectSearchResult]:
"""检索小说私有 RAG"""
if not self.novel_vectors_db.exists():
return []
if self._embed_api_key:
return await self._novel_vector_search(query, top_k, chunk_type)
return await asyncio.to_thread(self._novel_keyword_search, query, top_k, chunk_type)
def _novel_keyword_search(self, query: str, top_k: int, chunk_type: Optional[str]) -> List[CrossProjectSearchResult]:
"""小说关键词检索"""
try:
conn = sqlite3.connect(str(self.novel_vectors_db))
cursor = conn.cursor()
if chunk_type:
cursor.execute("""
SELECT chunk_id, chapter, content, chunk_type, source_file
FROM vectors WHERE chunk_type = ? AND content LIKE ?
ORDER BY chapter DESC LIMIT ?
""", (chunk_type, f"%{query}%", top_k))
else:
cursor.execute("""
SELECT chunk_id, chapter, content, chunk_type, source_file
FROM vectors WHERE content LIKE ?
ORDER BY chapter DESC LIMIT ?
""", (f"%{query}%", top_k))
rows = cursor.fetchall()
conn.close()
keywords = self._extract_keywords(query)
results = []
for row in rows:
content = row[2] or ""
matches = sum(1 for kw in keywords if kw in content)
score = matches / max(len(keywords), 1) * 100
results.append(CrossProjectSearchResult(
chunk_id=row[0], content=content[:500], score=score,
source_layer=RAGLayer.NOVEL, source_project=self.novel_root.name,
chapter=row[1], chunk_type=row[3], metadata={"source_file": row[4]}
))
results.sort(key=lambda x: x.score, reverse=True)
return results[:top_k]
except Exception:
return []
async def _novel_vector_search(self, query: str, top_k: int, chunk_type: Optional[str]) -> List[CrossProjectSearchResult]:
"""小说向量检索"""
try:
embeddings = await self._embed_texts([query])
if not embeddings:
return await asyncio.to_thread(self._novel_keyword_search, query, top_k, chunk_type)
query_embedding = embeddings[0]
conn = sqlite3.connect(str(self.novel_vectors_db))
cursor = conn.cursor()
if chunk_type:
cursor.execute("""
SELECT chunk_id, chapter, content, embedding, chunk_type, source_file
FROM vectors WHERE chunk_type = ?
""", (chunk_type,))
else:
cursor.execute("SELECT chunk_id, chapter, content, embedding, chunk_type, source_file FROM vectors")
rows = cursor.fetchall()
conn.close()
results = []
for row in rows:
if not row[3]:
continue
embedding = self._deserialize_embedding(row[3])
score = self._cosine_similarity(query_embedding, embedding)
results.append(CrossProjectSearchResult(
chunk_id=row[0], content=row[2][:500] if row[2] else "", score=score,
source_layer=RAGLayer.NOVEL, source_project=self.novel_root.name,
chapter=row[1], chunk_type=row[4], metadata={"source_file": row[5]}
))
results.sort(key=lambda x: x.score, reverse=True)
return results[:top_k]
except Exception:
return await asyncio.to_thread(self._novel_keyword_search, query, top_k, chunk_type)
async def _search_workspace(self, query: str, top_k: int, chunk_type: Optional[str]) -> List[CrossProjectSearchResult]:
"""检索工作空间共享 RAG"""
results = []
# 检索 catharsis 模板
if self.ws_catharsis_dir and self.ws_catharsis_dir.exists():
for model_file in self.ws_catharsis_dir.glob("*.md"):
try:
content = model_file.read_text(encoding="utf-8")
keywords = self._extract_keywords(query)
matches = sum(1 for kw in keywords if kw in content[:1000])
if matches > 0:
score = matches / len(keywords) * 70
results.append(CrossProjectSearchResult(
chunk_id=f"workspace:{model_file.stem}", content=content[:500], score=score,
source_layer=RAGLayer.WORKSPACE, source_project="workspace",
chapter=None, chunk_type="catharsis_model"
))
except Exception:
continue
# 检索题材库
if self.ws_genres_dir and self.ws_genres_dir.exists():
keywords = self._extract_keywords(query)
for genre_file in self.ws_genres_dir.glob("**/*.md"):
try:
content = genre_file.read_text(encoding="utf-8")
matches = sum(1 for kw in keywords if kw in content[:1000])
if matches > 0:
score = matches / len(keywords) * 50
results.append(CrossProjectSearchResult(
chunk_id=f"workspace_genre:{genre_file.stem}", content=content[:500], score=score,
source_layer=RAGLayer.WORKSPACE, source_project="workspace",
chapter=None, chunk_type="genre_template"
))
except Exception:
continue
# 检索学习成果
if self.ws_learned_dir and self.ws_learned_dir.exists():
results.extend(await self._search_learned_dir(self.ws_learned_dir, query, top_k, RAGLayer.WORKSPACE))
return results[:top_k]
async def _search_project(self, query: str, top_k: int, chunk_type: Optional[str]) -> List[CrossProjectSearchResult]:
"""检索工程共享 RAG"""
results = []
if self.proj_catharsis_dir and self.proj_catharsis_dir.exists():
for model_file in self.proj_catharsis_dir.glob("*.md"):
try:
content = model_file.read_text(encoding="utf-8")
keywords = self._extract_keywords(query)
matches = sum(1 for kw in keywords if kw in content[:1000])
if matches > 0:
score = matches / len(keywords) * 50
results.append(CrossProjectSearchResult(
chunk_id=f"project:{model_file.stem}", content=content[:500], score=score,
source_layer=RAGLayer.PROJECT, source_project="project",
chapter=None, chunk_type="catharsis_model"
))
except Exception:
continue
if self.proj_genres_dir and self.proj_genres_dir.exists():
keywords = self._extract_keywords(query)
for genre_file in self.proj_genres_dir.glob("**/*.md"):
try:
content = genre_file.read_text(encoding="utf-8")
matches = sum(1 for kw in keywords if kw in content[:1000])
if matches > 0:
score = matches / len(keywords) * 30
results.append(CrossProjectSearchResult(
chunk_id=f"project_genre:{genre_file.stem}", content=content[:500], score=score,
source_layer=RAGLayer.PROJECT, source_project="project",
chapter=None, chunk_type="genre_template"
))
except Exception:
continue
if self.proj_learned_dir and self.proj_learned_dir.exists():
results.extend(await self._search_learned_dir(self.proj_learned_dir, query, top_k, RAGLayer.PROJECT))
return results[:top_k]
async def _search_plugin(self, query: str, top_k: int, chunk_type: Optional[str]) -> List[CrossProjectSearchResult]:
"""检索插件内置 RAG"""
results = []
if self.plugin_catharsis_dir and self.plugin_catharsis_dir.exists():
for model_file in self.plugin_catharsis_dir.glob("*.md"):
try:
content = model_file.read_text(encoding="utf-8")
keywords = self._extract_keywords(query)
matches = sum(1 for kw in keywords if kw in content[:1000])
if matches > 0:
score = matches / len(keywords) * 20
results.append(CrossProjectSearchResult(
chunk_id=f"plugin:{model_file.stem}", content=content[:500], score=score,
source_layer=RAGLayer.PLUGIN, source_project="plugin",
chapter=None, chunk_type="catharsis_model"
))
except Exception:
continue
return results[:top_k]
async def _search_learned_dir(self, learned_dir: Path, query: str, top_k: int, layer: RAGLayer) -> List[CrossProjectSearchResult]:
"""检索学习成果目录"""
results = []
keywords = self._extract_keywords(query)
for pattern_file in learned_dir.glob("**/*.json"):
try:
data = json.loads(pattern_file.read_text(encoding="utf-8"))
content = json.dumps(data, ensure_ascii=False)
matches = sum(1 for kw in keywords if kw in content)
if matches > 0:
score = matches / max(len(keywords), 1) * 50
results.append(CrossProjectSearchResult(
chunk_id=f"learned:{pattern_file.stem}", content=content[:500], score=score,
source_layer=layer, source_project=data.get("source_project", "unknown"),
chapter=data.get("source_chapter"), chunk_type="learned_pattern", metadata=data
))
except Exception:
continue
return results[:top_k]
def _extract_keywords(self, query: str) -> List[str]:
"""提取关键词"""
import re
chinese = re.findall(r'[\u4e00-\u9fff]{2,8}', query)
english = re.findall(r'[a-zA-Z]{2,}', query.lower())
return chinese + english
def _cosine_similarity(self, a: List[float], b: List[float]) -> float:
dot_product = sum(x * y for x, y in zip(a, b))
norm_a = sum(x * x for x in a) ** 0.5
norm_b = sum(x * x for x in b) ** 0.5
if norm_a == 0 or norm_b == 0:
return 0.0
return dot_product / (norm_a * norm_b)
def _deserialize_embedding(self, data: bytes) -> List[float]:
import struct
count = len(data) // 4
return list(struct.unpack(f"{count}f", data))
async def _embed_texts(self, texts: List[str]) -> Optional[List[List[float]]]:
"""调用 Embedding API"""
if not self._embed_api_key or not texts:
return None
try:
import aiohttp
headers = {"Authorization": f"Bearer {self._embed_api_key}", "Content-Type": "application/json"}
payload = {"model": self._embed_model, "input": texts}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self._embed_base_url}/embeddings", headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=60)
) as resp:
if resp.status == 200:
result = await resp.json()
return [item["embedding"] for item in result["data"]]
except Exception:
pass
return None
# ==================== 存储接口 ====================
def store_learned_pattern(self, pattern: LearnedPattern, layer: RAGLayer = RAGLayer.NOVEL) -> bool:
"""存储学习到的模式"""
if layer == RAGLayer.NOVEL:
return self._store_novel_learned(pattern)
elif layer == RAGLayer.WORKSPACE:
return self._store_workspace_learned(pattern)
elif layer == RAGLayer.PROJECT:
return self._store_project_learned(pattern)
return False
def _store_novel_learned(self, pattern: LearnedPattern) -> bool:
"""存储到小说私有库"""
self._init_learned_db(self.novel_learned_db)
try:
conn = sqlite3.connect(str(self.novel_learned_db))
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO learned_patterns
(pattern_id, pattern_type, title, description, tension_curve, catharsis_model,
structure, hot_spots, style_tags, source_project, source_chapter, learned_at, usage_count, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
pattern.pattern_id, pattern.pattern_type, pattern.title, pattern.description,
json.dumps(pattern.tension_curve), pattern.catharsis_model, json.dumps(pattern.structure),
json.dumps(pattern.hot_spots), json.dumps(pattern.style_tags), pattern.source_project,
pattern.source_chapter, pattern.learned_at, pattern.usage_count, json.dumps(pattern.metadata)
))
conn.commit()
conn.close()
return True
except Exception:
return False
def _store_workspace_learned(self, pattern: LearnedPattern) -> bool:
"""存储到工作空间共享"""
if not self.ws_learned_dir:
return False
pattern_file = self.ws_learned_dir / f"{pattern.pattern_id}.json"
return self._write_pattern_file(pattern_file, pattern)
def _store_project_learned(self, pattern: LearnedPattern) -> bool:
"""存储到工程共享"""
if not self.proj_learned_dir:
return False
pattern_file = self.proj_learned_dir / f"{pattern.pattern_id}.json"
return self._write_pattern_file(pattern_file, pattern)
def _write_pattern_file(self, path: Path, pattern: LearnedPattern) -> bool:
try:
path.parent.mkdir(parents=True, exist_ok=True)
data = {
"pattern_id": pattern.pattern_id, "pattern_type": pattern.pattern_type,
"title": pattern.title, "description": pattern.description,
"tension_curve": pattern.tension_curve, "catharsis_model": pattern.catharsis_model,
"structure": pattern.structure, "hot_spots": pattern.hot_spots,
"style_tags": pattern.style_tags, "source_project": pattern.source_project,
"source_chapter": pattern.source_chapter, "learned_at": pattern.learned_at,
"usage_count": pattern.usage_count, "metadata": pattern.metadata
}
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return True
except Exception:
return False
def _init_learned_db(self, db_path: Path):
"""初始化学习库"""
if db_path.exists():
return
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS learned_patterns (
pattern_id TEXT PRIMARY KEY, pattern_type TEXT NOT NULL, title TEXT NOT NULL,
description TEXT, tension_curve TEXT, catharsis_model TEXT, structure TEXT,
hot_spots TEXT, style_tags TEXT, source_project TEXT, source_chapter INTEGER,
learned_at TEXT, usage_count INTEGER DEFAULT 0, metadata TEXT
)
""")
conn.commit()
conn.close()
# ==================== 项目索引 ====================
@staticmethod
def get_projects_index(system_root: Path) -> Dict[str, Any]:
projects_file = system_root / "projects.json"
if projects_file.exists():
return json.loads(projects_file.read_text(encoding="utf-8"))
return {"projects": []}
@staticmethod
def register_project(system_root: Path, project_path: Path, project_info: Dict[str, Any]) -> bool:
system_root = Path(system_root)
system_root.mkdir(parents=True, exist_ok=True)
projects_file = system_root / "projects.json"
data = CrossProjectRAG.get_projects_index(system_root)
project_path_str = str(project_path.resolve())
projects = data.get("projects", [])
for i, p in enumerate(projects):
if p.get("path") == project_path_str:
projects[i] = project_info
break
else:
projects.append(project_info)
data["projects"] = projects
data["last_updated"] = datetime.now().isoformat()
try:
projects_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return True
except Exception:
return False
if __name__ == "__main__":
import argparse
import sys
if sys.platform == "win32":
enable_windows_utf8_stdio()
parser = argparse.ArgumentParser(description="Cross-Project RAG CLI")
parser.add_argument("--project-root", type=str, required=True)
parser.add_argument("--workspace-root", type=str)
parser.add_argument("--project-root-dir", type=str)
subparsers = parser.add_subparsers(dest="command")
search_parser = subparsers.add_parser("search")
search_parser.add_argument("--query", required=True)
search_parser.add_argument("--top-k", type=int, default=5)
search_parser.add_argument("--layers", type=str, default="novel,workspace,project,plugin")
args = parser.parse_args()
if not args.project_root:
print("Error: --project-root is required")
sys.exit(1)
rag = CrossProjectRAG(
project_root=Path(args.project_root).resolve(),
workspace_root=Path(args.workspace_root).resolve() if args.workspace_root else None,
project_root_dir=Path(args.project_root_dir).resolve() if args.project_root_dir else None,
)
if args.command == "search":
layer_map = {"novel": RAGLayer.NOVEL, "workspace": RAGLayer.WORKSPACE,
"project": RAGLayer.PROJECT, "plugin": RAGLayer.PLUGIN}
layers = [layer_map[l.strip()] for l in args.layers.split(",") if l.strip() in layer_map]
results = asyncio.run(rag.search(args.query, args.top_k, layers))
print(f"\n=== Search Results ({len(results)}) ===")
for r in results:
print(f"\n[{r.source_layer.value}] {r.chunk_id} (score: {r.score:.2f})")
print(f"Source: {r.source_project or 'unknown'}")
if r.chapter:
print(f"Chapter: {r.chapter}")
print(f"Content: {r.content[:200]}...")
else:
parser.print_help()