import logging import re import time from pathlib import Path from typing import Dict, List, Optional logger = logging.getLogger(__name__) DOCS_ROOT = Path(__file__).parent / "docs" TOPIC_MAP: Dict[str, List[Path]] = { "basics": [ DOCS_ROOT / "skill.md", DOCS_ROOT / "reference" / "quickstart.rst", DOCS_ROOT / "reference" / "primer.rst", ], "selectors": [ DOCS_ROOT / "concepts" / "selectors.md", DOCS_ROOT / "reference" / "selectors.rst", ], "booleans": [ DOCS_ROOT / "concepts" / "brep-mindset.md", DOCS_ROOT / "patterns" / "common-patterns.md", ], "transforms": [ DOCS_ROOT / "concepts" / "workplanes.md", DOCS_ROOT / "reference" / "workplane.rst", ], "features": [ DOCS_ROOT / "patterns" / "common-patterns.md", DOCS_ROOT / "patterns" / "anti-patterns.md", ], "sketch": [ DOCS_ROOT / "reference" / "sketch.rst", ], "advanced": [ DOCS_ROOT / "concepts" / "free-function-api.md", DOCS_ROOT / "reference" / "free-func.rst", DOCS_ROOT / "reference" / "extending.rst", ], "examples": [ DOCS_ROOT / "reference" / "examples.rst", ], "anti-patterns": [ DOCS_ROOT / "patterns" / "anti-patterns.md", ], "workplanes": [ DOCS_ROOT / "concepts" / "workplanes.md", ], } def search_docs( topic: Optional[str] = None, query: Optional[str] = None, context_lines: int = 5, max_results: int = 10, max_chars: int = 4000, ) -> List[str]: t0 = time.time() if topic and topic in TOPIC_MAP: files = TOPIC_MAP[topic] elif topic: files = _find_files_by_name(topic) else: files = [] for file_list in TOPIC_MAP.values(): files.extend(file_list) files = list(set(files)) if not files: return [f"No documentation found for topic: {topic}"] if not query: results = [] total_chars = 0 for fp in files: if not fp.exists(): continue content = fp.read_text(encoding="utf-8", errors="replace") if total_chars + len(content) > max_chars: remaining = max_chars - total_chars if remaining > 200: results.append(f"=== {fp.name} (truncated) ===\n{content[:remaining]}...") break results.append(f"=== {fp.name} ===\n{content}") total_chars += len(content) elapsed = time.time() - t0 logger.info(f"search_docs(topic={topic}) returned {len(results)} files in {elapsed:.3f}s") return results results = _grep_search(files, query, context_lines, max_results) if not results: results = _fuzzy_search(files, query, context_lines, max_results) total_chars = 0 trimmed = [] for r in results: if total_chars + len(r) > max_chars: remaining = max_chars - total_chars if remaining > 100: trimmed.append(r[:remaining] + "...") break trimmed.append(r) total_chars += len(r) elapsed = time.time() - t0 logger.info(f"search_docs(topic={topic}, query={query}) returned {len(trimmed)} results in {elapsed:.3f}s") if not trimmed: return [f"No results found for query: {query}"] return trimmed def _find_files_by_name(name: str) -> List[Path]: results = [] for fp in DOCS_ROOT.rglob("*"): if fp.is_file() and name.lower() in fp.stem.lower(): results.append(fp) return results def _grep_search( files: List[Path], query: str, context_lines: int = 5, max_results: int = 10, ) -> List[str]: results = [] keywords = query.lower().split() for fp in files: if not fp.exists(): continue try: lines = fp.read_text(encoding="utf-8", errors="replace").splitlines() except Exception: continue for i, line in enumerate(lines): line_lower = line.lower() if any(kw in line_lower for kw in keywords): start = max(0, i - context_lines) end = min(len(lines), i + context_lines + 1) snippet = "\n".join(lines[start:end]) results.append(f"--- {fp.name}:{i+1} ---\n{snippet}") if len(results) >= max_results: return results return results def _fuzzy_search( files: List[Path], query: str, context_lines: int = 5, max_results: int = 5, ) -> List[str]: results = [] keywords = query.lower().split() for fp in files: if not fp.exists(): continue try: content = fp.read_text(encoding="utf-8", errors="replace") except Exception: continue paragraphs = re.split(r"\n\s*\n", content) scored = [] for para in paragraphs: para_lower = para.lower() score = sum(1 for kw in keywords if kw in para_lower) if score > 0: scored.append((score, para, fp.name)) scored.sort(key=lambda x: x[0], reverse=True) for score, para, fname in scored[:max_results]: results.append(f"--- {fname} (relevance: {score}/{len(keywords)}) ---\n{para.strip()}") if len(results) >= max_results: return results return results def get_system_prompt() -> str: skill_path = DOCS_ROOT / "skill.md" if skill_path.exists(): return skill_path.read_text(encoding="utf-8", errors="replace") return "" def list_topics() -> List[str]: return list(TOPIC_MAP.keys())