diff --git a/docs/en/guides/cli.md b/docs/en/guides/cli.md index 553f974..80c85cc 100644 --- a/docs/en/guides/cli.md +++ b/docs/en/guides/cli.md @@ -178,7 +178,7 @@ Features: | Command | Key args | Description | |---------|----------|-------------| | `add` | `--content`(req) `--source` `--tags` | Write a context item, returns `{id, stage}` | -| `retrieve` | `--query`(req) `--k`(10) `--full` `--json` `--tags` | Ranked SearchHits; L1 summaries by default, `--full` for L0. `--tags a,b` requires returned items to carry all listed tags | +| `retrieve` | `--query`(req) `--k`(10) `--full` `--json` `--tags` `--tag-match` | Ranked SearchHits; L1 summaries by default, `--full` for L0. `--tags a,b` filters by tag; `--tag-match all` (default) requires every tag, `any` requires at least one | | `expand` | `--ids`(req, comma-separated) | Expand retrieved ids to L0 full content | | `items` | `--stage`(raw/extracted/knowledge/skill) | List all items in a scope | diff --git a/docs/en/reference/api.md b/docs/en/reference/api.md index 391506b..92e3a48 100644 --- a/docs/en/reference/api.md +++ b/docs/en/reference/api.md @@ -115,7 +115,7 @@ See [DataPlugs](../guides/integrations/dataplugs.md) for available plug types. ## Read -### `retrieve(query, *, scope, k=10, full=False, stage=None, tags=None, filters=None, include_deleted=False) → RetrieveResponse` +### `retrieve(query, *, scope, k=10, full=False, stage=None, tags=None, tag_match='all', filters=None, include_deleted=False) → RetrieveResponse` Ranked semantic search. Returns a `RetrieveResponse` iterable of `SearchHit` objects. @@ -128,8 +128,9 @@ By default returns **L1 summaries** (token-efficient). Pass `full=True` to recei | `k` | `10` | Maximum hits to return | | `full` | `False` | `True` → L0 bodies; `False` → L1 summaries (call `expand()` for L0) | | `stage` | `None` | Filter by `Stage` enum value | -| `tags` | `None` | All listed tags must match (AND filter) | -| `filters` | `None` | Dict bag: may include `stage`, `tags`, `min_confidence` | +| `tags` | `None` | Tag filter list; matching controlled by `tag_match` | +| `tag_match` | `'all'` | `'all'` — item must have every tag (AND); `'any'` — at least one tag (OR) | +| `filters` | `None` | Dict bag: may include `stage`, `tags`, `tag_match`, `min_confidence` | | `include_deleted` | `False` | Whether soft-deleted items appear in results | **Returns:** `RetrieveResponse` — iterable as `for hit in response`. Each `hit` has: diff --git a/docs/zh/guides/cli.md b/docs/zh/guides/cli.md index e50f526..d193f8a 100644 --- a/docs/zh/guides/cli.md +++ b/docs/zh/guides/cli.md @@ -178,7 +178,7 @@ sync 会跳过写入时的冲突检测以保证批量导入速度;导入后用 | 命令 | 关键参数 | 说明 | |------|----------|------| | `add` | `--content`(必填) `--source` `--tags` | 写入一条上下文,返回 `{id, stage}` | -| `retrieve` | `--query`(必填) `--k`(10) `--full` `--json` `--tags` | 检索排序后的 SearchHit;默认 L1 摘要,`--full` 返回 L0 全文。`--tags a,b` 要求返回项同时包含列出的所有标签 | +| `retrieve` | `--query`(必填) `--k`(10) `--full` `--json` `--tags` `--tag-match` | 检索排序后的 SearchHit;默认 L1 摘要,`--full` 返回 L0 全文。`--tags a,b` 按标签过滤;`--tag-match all`(默认)要求包含所有标签,`any` 只需匹配一个 | | `expand` | `--ids`(必填,逗号分隔) | 把已检索 id 升档到 L0 全文 | | `items` | `--stage`(raw/extracted/knowledge/skill) | 列举 scope 内全部 item | diff --git a/docs/zh/reference/api.md b/docs/zh/reference/api.md index 93bcad7..9992352 100644 --- a/docs/zh/reference/api.md +++ b/docs/zh/reference/api.md @@ -114,7 +114,7 @@ ctx.plug(RAGPlug(results=my_rag_results), scope="acme/kb/general") ## 读取 -### `retrieve(query, *, scope, k=10, full=False, stage=None, tags=None, filters=None, include_deleted=False) → RetrieveResponse` +### `retrieve(query, *, scope, k=10, full=False, stage=None, tags=None, tag_match='all', filters=None, include_deleted=False) → RetrieveResponse` 排名语义检索,返回 `SearchHit` 的可迭代 `RetrieveResponse`。 @@ -127,8 +127,9 @@ ctx.plug(RAGPlug(results=my_rag_results), scope="acme/kb/general") | `k` | `10` | 最多返回命中数 | | `full` | `False` | `True` 返回 L0 正文;`False` 返回 L1 摘要 | | `stage` | `None` | 按 Stage 枚举值过滤 | -| `tags` | `None` | AND 过滤:所有标签必须全部匹配 | -| `filters` | `None` | 字典包:可含 `stage`、`tags`、`min_confidence` | +| `tags` | `None` | 标签过滤列表,匹配方式由 `tag_match` 控制 | +| `tag_match` | `'all'` | `'all'`:条目必须包含所有标签(AND);`'any'`:至少匹配一个标签(OR) | +| `filters` | `None` | 字典包:可含 `stage`、`tags`、`tag_match`、`min_confidence` | | `include_deleted` | `False` | 是否包含软删除条目 | **返回:** `RetrieveResponse` — 可用 `for hit in response` 遍历。每个 `hit`: diff --git a/src/contextseek/cli/doctor_cmd.py b/src/contextseek/cli/doctor_cmd.py new file mode 100644 index 0000000..2e7aeb0 --- /dev/null +++ b/src/contextseek/cli/doctor_cmd.py @@ -0,0 +1,581 @@ +"""``contextseek doctor`` — config & connectivity self-check. + +Loads ``ContextSeekSettings``, reports which backend/embedder/LLM are resolved, +runs a lightweight liveness check on each (e.g. a tiny embedding call, a storage +ping), and prints clear PASS/FAIL/SKIP/WARN lines with actionable hints pointing +at ``.env.example``. + +Never prints secrets. Exits non-zero if any required component fails. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from contextseek.config.factory import ( + build_embedder, + build_llm, + resolve_embedding_dims, +) +from contextseek.config.settings import ContextSeekSettings + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + +PASS = "PASS" +FAIL = "FAIL" +SKIP = "SKIP" +WARN = "WARN" + + +@dataclass +class CheckResult: + """Outcome of a single doctor check.""" + + status: str + component: str + message: str + hint: str = "" + env_section: str = "" + + +# --------------------------------------------------------------------------- +# Error message sanitisation — never leak secrets +# --------------------------------------------------------------------------- + +_SECRET_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"sk-[A-Za-z0-9\-_]{10,}"), "sk-***"), + (re.compile(r"(?i)(password\s*=\s*)\S+"), r"\1***"), + (re.compile(r"(?i)(api[_-]?key\s*=\s*)\S+"), r"\1***"), + (re.compile(r"(?i)(token\s*=\s*)\S+"), r"\1***"), +] + + +def _sanitize_error_message(msg: str, *, max_len: int = 200) -> str: + """Remove secrets from an error message and cap its length.""" + sanitized = msg + for pattern, replacement in _SECRET_PATTERNS: + sanitized = pattern.sub(replacement, sanitized) + if len(sanitized) > max_len: + sanitized = sanitized[:max_len] + "…" + return sanitized + + +# --------------------------------------------------------------------------- +# Error classification +# --------------------------------------------------------------------------- + +def _classify_exception(exc: Exception, component: str) -> tuple[str, str, str]: + """Return (message, hint, env_section) for a caught exception. + + The message is sanitised before being returned. + """ + raw = str(exc) + exc_type = type(exc).__name__ + msg = _sanitize_error_message(raw) + + # Build a short type prefix for the message + short = f"{exc_type}: {msg}" if msg else exc_type + + # --- Import / missing dependency --- + if isinstance(exc, ImportError): + if "pyseekdb" in raw.lower(): + return ( + short, + "Install pyseekdb: pip install pyseekdb", + "section 1.0", + ) + if "pyobvector" in raw.lower() or "sqlalchemy" in raw.lower(): + return ( + short, + "Install OceanBase deps: pip install 'contextseek[oceanbase]'", + "section 1.1", + ) + if "langchain" in raw.lower(): + pkg_hint = "pip install 'contextseek[langchain]'" + if "openai" in raw.lower(): + pkg_hint += " && pip install 'contextseek[openai]'" + elif "dashscope" in raw.lower(): + pkg_hint += " && pip install 'contextseek[dashscope]'" + elif "ollama" in raw.lower(): + pkg_hint += " && pip install 'contextseek[ollama]'" + elif "huggingface" in raw.lower(): + pkg_hint += " && pip install 'contextseek[huggingface]'" + return ( + short, + f"Missing LangChain package: {pkg_hint}", + f"section {'2' if component == 'embedding' else '3'}", + ) + return (short, "Install the missing dependency", f"section {'2' if component == 'embedding' else '3'}") + + # --- Authentication --- + raw_lower = raw.lower() + if ( + "auth" in raw_lower + or "api key" in raw_lower + or "api_key" in raw_lower + or "unauthorized" in raw_lower + or "401" in raw_lower + or "forbidden" in raw_lower + or "403" in raw_lower + ): + if component == "embedding": + return (short, "Set the correct API key (e.g. OPENAI_API_KEY) in .env", "section 2") + return (short, "Set the correct API key (e.g. OPENAI_API_KEY) in .env", "section 3") + + # --- Connection / network --- + if ( + isinstance(exc, (ConnectionError, TimeoutError, OSError)) + or "connection refused" in raw_lower + or "timed out" in raw_lower + or "timeout" in raw_lower + or "unreachable" in raw_lower + or "network" in raw_lower + ): + if component == "storage": + return (short, "Check that the storage service is running and host/port are correct", "section 1 / 1.0 / 1.1") + if component == "embedding": + return (short, "Check EMBEDDING_BASE_URL and network connectivity", "section 2") + return (short, "Check LLM_BASE_URL and network connectivity", "section 3") + + # --- Configuration / value errors --- + if isinstance(exc, ValueError): + if "dims" in raw_lower or "embedding_dims" in raw_lower: + return (short, "Set EMBEDDING_DIMS when using oceanbase backend", "section 1.1 / 2") + if "unknown" in raw_lower and "provider" in raw_lower: + if component == "embedding": + return (short, "Check EMBEDDING_PROVIDER spelling; see supported providers", "section 2") + return (short, "Check LLM_PROVIDER spelling; see supported providers", "section 3") + return (short, "Check configuration values in .env", "section 1 / 2 / 3") + + # --- Generic fallback --- + return (short, "See .env.example for configuration reference", "section 1 / 2 / 3") + + +# --------------------------------------------------------------------------- +# Individual checks +# --------------------------------------------------------------------------- + +def _check_storage(settings: ContextSeekSettings) -> CheckResult: + """Build and initialise the storage backend to verify connectivity.""" + storage = settings.storage + backend_name = storage.backend + + # --- oceanbase requires EMBEDDING_DIMS --- + if backend_name == "oceanbase": + vector_dims = settings.embedding.dims + if not vector_dims: + return CheckResult( + FAIL, + "storage", + "EMBEDDING_DIMS must be set when STORAGE_BACKEND=oceanbase", + "Set EMBEDDING_DIMS (e.g. 1536 for OpenAI text-embedding-3-small)", + "section 1.1 / 2", + ) + + # --- Construct the backend (mirrors client/contextseek.py:261-318) --- + backend: Any + try: + if backend_name == "oceanbase": + from contextseek.storage.ob_backend import OceanBaseBackend + + ob = settings.ob + geo = getattr(settings, "geo", None) + if geo is not None and getattr(geo, "enabled", False): + from contextseek.storage.ob_geo_backend import OceanBaseGeoBackend + + backend = OceanBaseGeoBackend( + table_name=ob.table_name, + vector_dims=vector_dims, + host=ob.host, + port=ob.port, + user=ob.user, + password=ob.password, + db_name=ob.db_name, + geo_table_name=geo.geo_table_name, + distance_decay_km=geo.distance_decay_km, + route_sample_interval_km=geo.route_sample_interval_km, + ) + else: + backend = OceanBaseBackend( + table_name=ob.table_name, + vector_dims=vector_dims, + host=ob.host, + port=ob.port, + user=ob.user, + password=ob.password, + db_name=ob.db_name, + ) + elif backend_name == "sqlite": + from contextseek.storage.sqlite_backend import SQLiteBackend + + backend = SQLiteBackend(path=settings.sqlite.path) + elif backend_name == "seekdb": + from contextseek.storage.seekdb_backend import SeekDBBackend + + seekdb = settings.seekdb + backend = SeekDBBackend( + path=seekdb.path, + database=seekdb.database, + host=seekdb.host, + port=seekdb.port, + ) + elif backend_name == "file": + from contextseek.storage.file_backend import FileBackend + + backend = FileBackend(root_dir=storage.path) + elif backend_name == "memory": + from contextseek.storage.in_memory_backend import InMemoryBackend + + backend = InMemoryBackend() + else: + return CheckResult( + FAIL, + "storage", + f"Unknown storage backend: {backend_name!r}", + "Set STORAGE_BACKEND to one of: memory, file, sqlite, seekdb, oceanbase", + "section 1", + ) + except Exception as exc: + msg, hint, section = _classify_exception(exc, "storage") + return CheckResult(FAIL, "storage", msg, hint, section) + + # --- Initialise (this is the actual connectivity check) --- + try: + backend.initialize() + except Exception as exc: + msg, hint, section = _classify_exception(exc, "storage") + return CheckResult(FAIL, "storage", msg, hint, section) + + # --- Clean up --- + close = getattr(backend, "close", None) + if callable(close): + try: + close() + except Exception: + pass + + return CheckResult(PASS, "storage", f"{backend_name} backend initialized") + + +def _check_embedding(settings: ContextSeekSettings) -> CheckResult: + """Build embedder and do a tiny probe call.""" + emb_settings = settings.embedding + provider = emb_settings.provider.strip().lower() if emb_settings.provider else "none" + + if provider in {"", "none"}: + return CheckResult( + SKIP, + "embedding", + "provider=none — vector search disabled", + "Set EMBEDDING_PROVIDER to enable vector retrieval", + "section 2", + ) + + # langchain provider requires class_path + if provider == "langchain" and not emb_settings.class_path: + return CheckResult( + SKIP, + "embedding", + "provider=langchain but EMBEDDING_CLASS_PATH not set", + "Set EMBEDDING_CLASS_PATH to your custom LangChain embeddings class", + "section 2", + ) + + # Build the embedder + try: + embedder = build_embedder(emb_settings) + except Exception as exc: + msg, hint, section = _classify_exception(exc, "embedding") + return CheckResult(FAIL, "embedding", msg, hint, section) + + if embedder is None: + return CheckResult( + SKIP, + "embedding", + f"provider={provider} resolved to None — vector search disabled", + "Check EMBEDDING_PROVIDER / EMBEDDING_CLASS_PATH configuration", + "section 2", + ) + + # Probe call + try: + vec = embedder("test") + except Exception as exc: + msg, hint, section = _classify_exception(exc, "embedding") + return CheckResult(FAIL, "embedding", msg, hint, section) + + if not vec or not isinstance(vec, list): + return CheckResult( + FAIL, + "embedding", + f"embedder returned empty/invalid result for probe: {type(vec).__name__}", + "Check EMBEDDING_MODEL and provider configuration", + "section 2", + ) + + actual_dims = len(vec) + configured_dims = resolve_embedding_dims(emb_settings) + if configured_dims and configured_dims != actual_dims: + return CheckResult( + WARN, + "embedding", + f"embedder returned {actual_dims}-dim vector (configured dims={configured_dims})", + f"Update EMBEDDING_DIMS to {actual_dims} or verify EMBEDDING_MODEL", + "section 2", + ) + + return CheckResult( + PASS, + "embedding", + f"embedder returned {actual_dims}-dim vector for probe \"test\"", + ) + + +def _check_llm(settings: ContextSeekSettings) -> CheckResult: + """Build LLM and do a tiny invoke call.""" + llm_settings = settings.llm + provider = llm_settings.provider.strip().lower() if llm_settings.provider else "none" + + if provider in {"", "none"}: + return CheckResult( + SKIP, + "llm", + "provider=none — rerank/summarize/evolution LLM disabled", + "Set LLM_PROVIDER to enable LLM-powered features", + "section 3", + ) + + # langchain provider requires class_path + if provider == "langchain" and not llm_settings.class_path: + return CheckResult( + SKIP, + "llm", + "provider=langchain but LLM_CLASS_PATH not set", + "Set LLM_CLASS_PATH to your custom LangChain chat model class", + "section 3", + ) + + # Build the LLM + try: + llm = build_llm(llm_settings) + except Exception as exc: + msg, hint, section = _classify_exception(exc, "llm") + return CheckResult(FAIL, "llm", msg, hint, section) + + if llm is None: + return CheckResult( + SKIP, + "llm", + f"provider={provider} resolved to None — LLM disabled", + "Check LLM_PROVIDER / LLM_CLASS_PATH configuration", + "section 3", + ) + + # Probe call — directly invoke to get precise error reporting + try: + from langchain_core.messages import HumanMessage + + resp = llm.invoke([HumanMessage(content="hello")]) + except Exception as exc: + msg, hint, section = _classify_exception(exc, "llm") + return CheckResult(FAIL, "llm", msg, hint, section) + + if resp is None: + return CheckResult( + FAIL, + "llm", + "LLM invoke returned None", + "Check LLM_MODEL and provider configuration", + "section 3", + ) + + return CheckResult(PASS, "llm", f"LLM responded to probe (provider={provider})") + + +def _check_cross( + settings: ContextSeekSettings, + results: list[CheckResult], +) -> list[CheckResult]: + """Cross-dependency checks that produce WARN (never FAIL).""" + warnings: list[CheckResult] = [] + + backend_name = settings.storage.backend + emb_provider = settings.embedding.provider.strip().lower() if settings.embedding.provider else "none" + llm_provider = settings.llm.provider.strip().lower() if settings.llm.provider else "none" + + # seekdb + no external embedder + if backend_name == "seekdb" and emb_provider in {"", "none"}: + warnings.append( + CheckResult( + WARN, + "cross", + "seekdb backend with no external embedder — using built-in 384-dim embedding", + "Set EMBEDDING_PROVIDER for higher-quality vectors", + "section 2", + ) + ) + + # LLM configured but embedding not — retrieval will lack vector route + if llm_provider not in {"", "none"} and emb_provider in {"", "none"}: + warnings.append( + CheckResult( + WARN, + "cross", + "LLM configured but embedding is not — retrieval recall routes will not include vector search", + "Set EMBEDDING_PROVIDER to enable vector retrieval", + "section 2", + ) + ) + + return warnings + + +# --------------------------------------------------------------------------- +# Configuration report (no side effects) +# --------------------------------------------------------------------------- + +def _describe_storage(settings: ContextSeekSettings) -> str: + """Human-readable description of the resolved storage config (no secrets).""" + s = settings.storage + backend = s.backend + if backend == "sqlite": + return f"sqlite (path={settings.sqlite.path})" + if backend == "seekdb": + seekdb = settings.seekdb + if seekdb.host: + return f"seekdb (host={seekdb.host}:{seekdb.port}, database={seekdb.database})" + return f"seekdb embedded (path={seekdb.path}, database={seekdb.database})" + if backend == "oceanbase": + ob = settings.ob + return f"oceanbase (host={ob.host}:{ob.port}, user={ob.user}, db={ob.db_name}, table={ob.table_name})" + if backend == "file": + return f"file (path={s.path})" + if backend == "memory": + return "memory" + return f"{backend} (unknown)" + + +def _describe_embedding(settings: ContextSeekSettings) -> str: + """Human-readable description of the resolved embedding config (no secrets).""" + emb = settings.embedding + provider = emb.provider or "none" + if provider in {"", "none"}: + return "none" + parts = [f"provider={provider}"] + if emb.model: + parts.append(f"model={emb.model}") + dims = resolve_embedding_dims(emb) + if dims: + parts.append(f"dims={dims}") + if emb.base_url: + parts.append(f"base_url={emb.base_url}") + return ", ".join(parts) + + +def _describe_llm(settings: ContextSeekSettings) -> str: + """Human-readable description of the resolved LLM config (no secrets).""" + llm = settings.llm + provider = llm.provider or "none" + if provider in {"", "none"}: + return "none" + parts = [f"provider={provider}"] + if llm.model: + parts.append(f"model={llm.model}") + if llm.base_url: + parts.append(f"base_url={llm.base_url}") + return ", ".join(parts) + + +# --------------------------------------------------------------------------- +# Output rendering +# --------------------------------------------------------------------------- + +_STATUS_LABELS = { + PASS: "[PASS]", + FAIL: "[FAIL]", + SKIP: "[SKIP]", + WARN: "[WARN]", +} + + +def _print(text: str = "") -> None: + print(text) + + +def _render_results(results: list[CheckResult]) -> None: + """Print check results in human-readable format.""" + _print() + _print("Checks:") + for r in results: + label = _STATUS_LABELS.get(r.status, f"[{r.status}]") + _print(f" {label} {r.component:<10} {r.message}") + if r.hint: + section = f"; see .env.example ({r.env_section})" if r.env_section else "" + indent = " ↳ fix: " if r.status == FAIL else " ↳ hint: " + _print(f"{indent}{r.hint}{section}") + _print() + + +def _render_summary(results: list[CheckResult]) -> int: + """Print summary line and return exit code.""" + counts = {PASS: 0, FAIL: 0, SKIP: 0, WARN: 0} + for r in results: + counts[r.status] = counts.get(r.status, 0) + 1 + + parts = [] + if counts[PASS]: + parts.append(f"{counts[PASS]} PASS") + if counts[FAIL]: + parts.append(f"{counts[FAIL]} FAIL") + if counts[SKIP]: + parts.append(f"{counts[SKIP]} SKIP") + if counts[WARN]: + parts.append(f"{counts[WARN]} WARN") + + exit_code = 1 if counts[FAIL] > 0 else 0 + suffix = f" — exit {exit_code}" if exit_code else "" + _print(f"Result: {', '.join(parts) if parts else 'no checks'}{suffix}") + return exit_code + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +def run_doctor(settings: ContextSeekSettings) -> int: + """Run the doctor diagnostics and return an exit code. + + Exit code is 1 if any check FAILs, 0 otherwise. + """ + # --- Configuration report (no side effects) --- + _print() + _print("ContextSeek doctor — configuration diagnostics") + _print() + _print("Configuration resolved:") + _print(f" storage : {_describe_storage(settings)}") + _print(f" embedding : {_describe_embedding(settings)}") + _print(f" llm : {_describe_llm(settings)}") + + # --- Run checks --- + results: list[CheckResult] = [] + + # Storage check — wrap in suppress_backend_noise to avoid pyseekdb etc. + from contextseek.cli.ui import suppress_backend_noise + + with suppress_backend_noise(): + results.append(_check_storage(settings)) + + results.append(_check_embedding(settings)) + results.append(_check_llm(settings)) + + # Cross-dependency warnings + results.extend(_check_cross(settings, results)) + + # --- Render --- + _render_results(results) + return _render_summary(results) \ No newline at end of file diff --git a/src/contextseek/cli/main.py b/src/contextseek/cli/main.py index 340f74f..7424a7e 100644 --- a/src/contextseek/cli/main.py +++ b/src/contextseek/cli/main.py @@ -97,7 +97,13 @@ def build_parser() -> argparse.ArgumentParser: retrieve_parser.add_argument( "--tags", default="", - help="comma-separated tag filter; returned items must contain all tags", + help="comma-separated tag filter (use --tag-match to control all/any semantics)", + ) + retrieve_parser.add_argument( + "--tag-match", + choices=["all", "any"], + default="all", + help="tag match mode: 'all' requires every tag, 'any' requires at least one (default: all)", ) retrieve_parser.add_argument( "--trace", @@ -311,6 +317,12 @@ def build_parser() -> argparse.ArgumentParser: help="initialize ~/.contextseek/: generate config.env, mcp.json, register system service", ) + # doctor — config & connectivity self-check + subparsers.add_parser( + "doctor", + help="diagnose storage / embedding / LLM configuration and connectivity", + ) + # daemon daemon_parser = subparsers.add_parser("daemon", help="manage the background daemon") daemon_sub = daemon_parser.add_subparsers(dest="daemon_command", required=True) @@ -500,6 +512,11 @@ def run_cli( run_init(pathlib.Path.home() / ".contextseek") return 0 + if args.command == "doctor": + from contextseek.cli.doctor_cmd import run_doctor + + return run_doctor(settings) + if args.command == "daemon" and args.daemon_command in {"stop", "status"}: from contextseek.daemon.process import DaemonProcess import pathlib @@ -594,6 +611,7 @@ def run_cli( k=args.k, full=args.full, tags=tags or None, + tag_match=args.tag_match, with_trace=args.trace, ) output = { diff --git a/src/contextseek/client/contextseek.py b/src/contextseek/client/contextseek.py index 774a988..bcfad77 100644 --- a/src/contextseek/client/contextseek.py +++ b/src/contextseek/client/contextseek.py @@ -18,7 +18,7 @@ from contextvars import ContextVar from dataclasses import dataclass, field, replace from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Callable, Iterator +from typing import TYPE_CHECKING, Any, Callable, Iterator, Literal from uuid import NAMESPACE_URL, uuid4, uuid5 if TYPE_CHECKING: @@ -565,6 +565,7 @@ def retrieve( full: bool = False, stage: Stage | None = None, tags: list[str] | None = None, + tag_match: Literal["all", "any"] = "all", filters: dict[str, Any] | None = None, include_deleted: bool = False, include_expired: bool = False, @@ -586,8 +587,11 @@ def retrieve( (default) L1 summaries replace content to save tokens—call :meth:`expand` to upgrade to full text. stage: Optional stage filter. - tags: Optional tag filter (all tags must match). - filters: Compatibility bag; may include ``stage`` / ``tags`` / ``min_confidence``. + tags: Optional tag filter; ``tag_match`` controls matching semantics. + tag_match: Tag match mode — ``"all"`` (default) requires the item to + have *every* tag in ``tags``; ``"any"`` requires at least one. + filters: Compatibility bag; may include ``stage`` / ``tags`` / + ``tag_match`` / ``min_confidence``. include_deleted: Whether soft-deleted items are visible. include_expired: Whether items whose bi-temporal validity window has closed (``valid_to`` in the past) are visible. Default False, so @@ -599,12 +603,15 @@ def retrieve( """ stage_filter = stage tag_filter = tags + tag_match_filter = tag_match min_conf = None if filters: if filters.get("stage"): stage_filter = Stage(filters["stage"]) if not tag_filter: tag_filter = filters.get("tags") + if tag_match_filter == "all" and filters.get("tag_match"): + tag_match_filter = filters["tag_match"] min_conf = filters.get("min_confidence") prefix = self.resolver.prefix_for(scope) @@ -633,6 +640,7 @@ def retrieve( k=k, stage=stage_filter, tags=tag_filter, + tag_match=tag_match_filter, include_deleted=include_deleted, include_expired=include_expired, geo_query=geo_query, @@ -648,6 +656,7 @@ def retrieve( k=k, stage=stage_filter, tags=tag_filter, + tag_match=tag_match_filter, include_deleted=include_deleted, include_expired=include_expired, geo_query=geo_query, diff --git a/src/contextseek/domain/tools.py b/src/contextseek/domain/tools.py index 5e0b9ba..cfaa06a 100644 --- a/src/contextseek/domain/tools.py +++ b/src/contextseek/domain/tools.py @@ -77,6 +77,18 @@ def to_anthropic(self) -> dict[str, Any]: "description": "If true, return L0 full content instead of L1 summaries.", "default": False, }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional tag filter list.", + "default": [], + }, + "tag_match": { + "type": "string", + "enum": ["all", "any"], + "description": "Tag match mode: 'all' requires every tag, 'any' requires at least one.", + "default": "all", + }, }, "required": ["query", "scope"], }, diff --git a/src/contextseek/mcp/server.py b/src/contextseek/mcp/server.py index 388c869..f0c0562 100644 --- a/src/contextseek/mcp/server.py +++ b/src/contextseek/mcp/server.py @@ -174,6 +174,16 @@ def _meta_tools(self) -> list[dict[str, Any]]: "query": {"type": "string", "required": True}, "k": {"type": "integer", "default": 10}, "full": {"type": "boolean", "default": False}, + "tags": { + "type": "array", + "items": {"type": "string"}, + "default": [], + }, + "tag_match": { + "type": "string", + "enum": ["all", "any"], + "default": "all", + }, "include_expired": {"type": "boolean", "default": False}, "include_trace": {"type": "boolean", "default": False}, }, @@ -329,6 +339,8 @@ def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: scope=arguments["scope"], k=arguments.get("k", 10), full=bool(arguments.get("full", False)), + tags=arguments.get("tags") or None, + tag_match=arguments.get("tag_match", "all"), include_expired=bool(arguments.get("include_expired", False)), with_trace=bool(arguments.get("include_trace", False)), ) diff --git a/src/contextseek/retrieval/orchestrator.py b/src/contextseek/retrieval/orchestrator.py index bb437b5..c695fbc 100644 --- a/src/contextseek/retrieval/orchestrator.py +++ b/src/contextseek/retrieval/orchestrator.py @@ -6,7 +6,7 @@ from datetime import datetime, timezone import math from time import perf_counter -from typing import Any, Callable +from typing import Any, Callable, Literal from contextseek.storage.protocol import SeekVFSAdapter from contextseek.config import RetrievalStrategy @@ -125,6 +125,7 @@ def search( k: int, stage: Stage | None = None, tags: list[str] | None = None, + tag_match: Literal["all", "any"] = "all", include_deleted: bool = False, include_expired: bool = False, with_stats: bool = False, @@ -139,7 +140,11 @@ def search( query: User query string. k: Maximum number of results to return. stage: Optional stage filter — only include items matching this stage. - tags: Optional tags filter — only include items having ALL these tags. + tags: Optional tags filter — only include items matching the tag + predicate controlled by ``tag_match``. + tag_match: Tag match mode — ``"all"`` (default) requires the item to + have *every* tag in ``tags``; ``"any"`` requires at least one + matching tag. include_deleted: Whether to include soft-deleted items. with_stats: If True, return (hits, stats) tuple. min_score: Optional threshold applied to the reranker's raw ``_score`` @@ -230,8 +235,13 @@ def _keep(h: dict[str, object]) -> bool: if stage is not None and h.get("stage") != stage.value: return False if tags: - if not set(tags).issubset(set(h.get("tags") or [])): - return False + item_tags = set(h.get("tags") or []) + if tag_match == "any": + if set(tags).isdisjoint(item_tags): + return False + else: # "all" (default) + if not set(tags).issubset(item_tags): + return False return True ranked_streams = [ diff --git a/tests/integration_tests/test_full_pipeline_file_example.py b/tests/integration_tests/test_full_pipeline_file_example.py index 47baf39..8212347 100644 --- a/tests/integration_tests/test_full_pipeline_file_example.py +++ b/tests/integration_tests/test_full_pipeline_file_example.py @@ -72,6 +72,36 @@ def test_hits_tag_filter_requires_all_tags(tmp_path: Path) -> None: assert hits[0].item.id == vector_item_id +def test_hits_tag_filter_any_semantics(tmp_path: Path) -> None: + with file_backend_demo_stack(tmp_path) as s: + ocean_item_id = s.item_ids[0] + vector_item_id = s.item_ids[1] + response = s.ctx.retrieve( + "分布式 向量", + scope=s.scope, + k=5, + filters={"tags": ["database", "vector"], "tag_match": "any"}, + ) + hits = list(response) + hit_ids = {h.item.id for h in hits} + assert ocean_item_id in hit_ids + assert vector_item_id in hit_ids + + +def test_hits_tag_filter_any_single_tag(tmp_path: Path) -> None: + with file_backend_demo_stack(tmp_path) as s: + langchain_item_id = s.item_ids[2] + response = s.ctx.retrieve( + "embedding", + scope=s.scope, + k=5, + filters={"tags": ["embedding"], "tag_match": "any"}, + ) + hits = list(response) + assert len(hits) == 1 + assert hits[0].item.id == langchain_item_id + + def test_response_meta_carries_layer(tmp_path: Path) -> None: with file_backend_demo_stack(tmp_path) as s: response = s.ctx.retrieve("LangChain", scope=s.scope, k=3) diff --git a/tests/unit_tests/test_cli.py b/tests/unit_tests/test_cli.py index 11fa121..be9c9d4 100644 --- a/tests/unit_tests/test_cli.py +++ b/tests/unit_tests/test_cli.py @@ -84,6 +84,8 @@ def test_openai_format_outputs_valid_tool_definitions(self, capsys): "scope", "k", "full", + "tags", + "tag_match", } assert expand_tool["parameters"]["required"] == ["ids", "scope"] assert expand_tool["parameters"]["properties"]["ids"]["type"] == "array" @@ -108,6 +110,8 @@ def test_anthropic_format_outputs_valid_tool_definitions(self, capsys): "scope", "k", "full", + "tags", + "tag_match", } assert expand_tool["input_schema"]["required"] == ["ids", "scope"] assert expand_tool["input_schema"]["properties"]["ids"]["items"] == { @@ -159,8 +163,60 @@ def test_retrieve_filters_results_by_all_tags(self) -> None: assert code == 0 assert [item["id"] for item in payload["items"]] == [kept.id] + def test_retrieve_accepts_tag_match_flag(self) -> None: + parser = build_parser() + args = parser.parse_args( + ["retrieve", "--scope", "t", "--query", "q", "--tags", "a,b", "--tag-match", "any"] + ) + + assert args.tag_match == "any" + + def test_retrieve_tag_match_any_returns_items_with_any_tag(self) -> None: + ctx = ContextSeek() + kept_a = ctx.add( + "database backup runbook", + scope="t/p", + source="test", + tags=["ops", "database"], + ) + kept_b = ctx.add( + "database onboarding guide", + scope="t/p", + source="test", + tags=["docs", "database"], + ) + out = StringIO() + + with redirect_stdout(out): + code = run_cli( + [ + "retrieve", + "--scope", + "t/p", + "--query", + "database", + "--tags", + "ops,docs", + "--tag-match", + "any", + "--json", + ], + client=ctx, + ) + + payload = json.loads(out.getvalue()) + assert code == 0 + ids = {item["id"] for item in payload["items"]} + assert ids == {kept_a.id, kept_b.id} + + def test_retrieve_tag_match_all_still_default(self) -> None: + parser = build_parser() + args = parser.parse_args( + ["retrieve", "--scope", "t", "--query", "q", "--tags", "a,b"] + ) + + assert args.tag_match == "all" -class TestExpandOutput: def test_expand_reports_missing_ids(self) -> None: ctx = ContextSeek() item = ctx.add("expand target", scope="t/p", source="test") @@ -182,3 +238,564 @@ def test_expand_reports_missing_ids(self) -> None: assert code == 0 assert [it["id"] for it in payload["items"]] == [item.id] assert payload["missing_ids"] == ["missing-id"] + + +class TestDoctor: + """Tests for the ``contextseek doctor`` subcommand.""" + + # ------------------------------------------------------------------ + # Parser registration + # ------------------------------------------------------------------ + + def test_doctor_parser_registered(self) -> None: + """``doctor`` subcommand is registered and parseable.""" + parser = build_parser() + args = parser.parse_args(["doctor"]) + assert args.command == "doctor" + + def test_doctor_in_help(self) -> None: + """``doctor`` appears in the main parser help text.""" + parser = build_parser() + help_text = parser.format_help() + assert "doctor" in help_text + assert "diagnose" in help_text + + # ------------------------------------------------------------------ + # Storage checks + # ------------------------------------------------------------------ + + def test_doctor_all_none_passes(self, capsys) -> None: + """Default isolated env (memory/none/none) → exit 0 with PASS + SKIPs.""" + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[PASS] storage" in out + assert "[SKIP] embedding" in out + assert "[SKIP] llm" in out + assert "[FAIL]" not in out + assert "exit 0" not in out # no failure, no exit suffix + # Should mention .env.example in the SKIP hints + assert ".env.example" in out + + def test_doctor_storage_memory_pass(self, monkeypatch, capsys) -> None: + """Memory backend → PASS.""" + monkeypatch.setenv("STORAGE_BACKEND", "memory") + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[PASS] storage" in out + assert "memory backend initialized" in out + + def test_doctor_storage_sqlite_pass(self, monkeypatch, tmp_path, capsys) -> None: + """SQLite backend with a temp path → PASS.""" + db_path = str(tmp_path / "test.sqlite3") + monkeypatch.setenv("STORAGE_BACKEND", "sqlite") + monkeypatch.setenv("SQLITE_PATH", db_path) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[PASS] storage" in out + assert "sqlite backend initialized" in out + + def test_doctor_storage_file_pass(self, monkeypatch, tmp_path, capsys) -> None: + """File backend with a temp path → PASS.""" + file_path = str(tmp_path / "store") + monkeypatch.setenv("STORAGE_BACKEND", "file") + monkeypatch.setenv("STORAGE_PATH", file_path) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[PASS] storage" in out + assert "file backend initialized" in out + + def test_doctor_storage_oceanbase_missing_dims_fails( + self, monkeypatch, capsys + ) -> None: + """OceanBase backend without EMBEDDING_DIMS → storage FAIL, exit 1.""" + monkeypatch.setenv("STORAGE_BACKEND", "oceanbase") + monkeypatch.setenv("EMBEDDING_PROVIDER", "none") + monkeypatch.setenv("EMBEDDING_DIMS", "0") + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert "[FAIL] storage" in out + assert "EMBEDDING_DIMS" in out + assert ".env.example" in out + + def test_doctor_storage_unknown_backend_fails(self, monkeypatch, capsys) -> None: + """Unknown storage backend → FAIL.""" + monkeypatch.setenv("STORAGE_BACKEND", "unknown_backend") + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert "[FAIL] storage" in out + assert "Unknown storage backend" in out + assert "memory, file, sqlite, seekdb, oceanbase" in out + + def test_doctor_storage_empty_backend_fails(self, monkeypatch, capsys) -> None: + """Empty storage backend string → FAIL (unknown).""" + monkeypatch.setenv("STORAGE_BACKEND", "") + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert "[FAIL] storage" in out + + # ------------------------------------------------------------------ + # Embedding checks + # ------------------------------------------------------------------ + + def test_doctor_embedding_none_skips(self, capsys) -> None: + """Embedding provider=none → SKIP.""" + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[SKIP] embedding" in out + assert "vector search disabled" in out + + def test_doctor_embedding_langchain_no_class_path_skips( + self, monkeypatch, capsys + ) -> None: + """Embedding provider=langchain without class_path → SKIP.""" + monkeypatch.setenv("EMBEDDING_PROVIDER", "langchain") + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[SKIP] embedding" in out + assert "EMBEDDING_CLASS_PATH" in out + + def test_doctor_embedding_build_failure(self, monkeypatch, capsys) -> None: + """Embedding provider configured but build_embedder raises → FAIL.""" + monkeypatch.setenv("EMBEDDING_PROVIDER", "openai") + monkeypatch.setenv("EMBEDDING_MODEL", "text-embedding-3-small") + + def _fake_build_embedder(settings): + raise PermissionError("AuthenticationError: Incorrect API key provided") + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_embedder", _fake_build_embedder + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert "[FAIL] embedding" in out + assert "API key" in out + assert ".env.example" in out + + def test_doctor_embedding_probe_success(self, monkeypatch, capsys) -> None: + """Embedder successfully returns a vector → PASS.""" + monkeypatch.setenv("EMBEDDING_PROVIDER", "openai") + monkeypatch.setenv("EMBEDDING_MODEL", "text-embedding-3-small") + monkeypatch.setenv("EMBEDDING_DIMS", "4") + + def _fake_embedder(text): + return [0.1, 0.2, 0.3, 0.4] + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_embedder", lambda s: _fake_embedder + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[PASS] embedding" in out + assert "4-dim" in out + + def test_doctor_embedding_probe_returns_empty_fails( + self, monkeypatch, capsys + ) -> None: + """Embedder returns empty list → FAIL.""" + monkeypatch.setenv("EMBEDDING_PROVIDER", "openai") + + def _fake_embedder(text): + return [] + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_embedder", lambda s: _fake_embedder + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert "[FAIL] embedding" in out + + def test_doctor_embedding_probe_raises_fails(self, monkeypatch, capsys) -> None: + """Embedder probe call raises → FAIL.""" + monkeypatch.setenv("EMBEDDING_PROVIDER", "openai") + + def _fake_embedder(text): + raise ConnectionError("Connection refused to embedding server") + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_embedder", lambda s: _fake_embedder + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert "[FAIL] embedding" in out + assert "Connection" in out or "connection" in out + + def test_doctor_embedding_dims_mismatch_warns(self, monkeypatch, capsys) -> None: + """Embedder returns dims different from configured → WARN.""" + monkeypatch.setenv("EMBEDDING_PROVIDER", "openai") + monkeypatch.setenv("EMBEDDING_DIMS", "1536") + + def _fake_embedder(text): + return [0.1] * 768 # returns 768 but configured 1536 + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_embedder", lambda s: _fake_embedder + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + # WARN does not cause failure + assert code == 0 + assert "[WARN] embedding" in out + assert "768-dim" in out + assert "1536" in out + + def test_doctor_embedding_probe_returns_non_list_fails( + self, monkeypatch, capsys + ) -> None: + """Embedder returns non-list (e.g. None) → FAIL.""" + monkeypatch.setenv("EMBEDDING_PROVIDER", "openai") + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_embedder", lambda s: (lambda t: None) + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert "[FAIL] embedding" in out + + # ------------------------------------------------------------------ + # LLM checks + # ------------------------------------------------------------------ + + def test_doctor_llm_none_skips(self, capsys) -> None: + """LLM provider=none → SKIP.""" + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[SKIP] llm" in out + assert "rerank/summarize/evolution" in out + + def test_doctor_llm_langchain_no_class_path_skips( + self, monkeypatch, capsys + ) -> None: + """LLM provider=langchain without class_path → SKIP.""" + monkeypatch.setenv("LLM_PROVIDER", "langchain") + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[SKIP] llm" in out + assert "LLM_CLASS_PATH" in out + + def test_doctor_llm_build_failure(self, monkeypatch, capsys) -> None: + """LLM build_llm raises → FAIL.""" + monkeypatch.setenv("LLM_PROVIDER", "openai") + + def _fake_build_llm(settings): + raise ImportError("No module named 'langchain_openai'") + + monkeypatch.setattr("contextseek.cli.doctor_cmd.build_llm", _fake_build_llm) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert "[FAIL] llm" in out + assert "langchain" in out.lower() + + def test_doctor_llm_invoke_failure(self, monkeypatch, capsys) -> None: + """LLM provider configured but invoke raises → FAIL.""" + monkeypatch.setenv("LLM_PROVIDER", "openai") + monkeypatch.setenv("LLM_MODEL", "gpt-4o-mini") + + class _FakeLLM: + def invoke(self, messages): + raise ConnectionError("Connection refused to api.openai.com") + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_llm", lambda s: _FakeLLM() + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert "[FAIL] llm" in out + assert ".env.example" in out + + def test_doctor_llm_invoke_success(self, monkeypatch, capsys) -> None: + """LLM responds to probe → PASS.""" + monkeypatch.setenv("LLM_PROVIDER", "openai") + monkeypatch.setenv("LLM_MODEL", "gpt-4o-mini") + + class _FakeLLM: + def invoke(self, messages): + return "hello back" + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_llm", lambda s: _FakeLLM() + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[PASS] llm" in out + + def test_doctor_llm_invoke_returns_none_fails(self, monkeypatch, capsys) -> None: + """LLM invoke returns None → FAIL.""" + monkeypatch.setenv("LLM_PROVIDER", "openai") + + class _FakeLLM: + def invoke(self, messages): + return None + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_llm", lambda s: _FakeLLM() + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert "[FAIL] llm" in out + assert "None" in out + + # ------------------------------------------------------------------ + # Cross-dependency checks + # ------------------------------------------------------------------ + + def test_doctor_cross_dependency_seekdb_warn(self, monkeypatch, capsys) -> None: + """seekdb + embedding=none → WARN about built-in embedding.""" + monkeypatch.setenv("STORAGE_BACKEND", "seekdb") + monkeypatch.setenv("EMBEDDING_PROVIDER", "none") + + from contextseek.cli.doctor_cmd import CheckResult, PASS + + def _fake_check_storage(settings): + return CheckResult(PASS, "storage", "seekdb backend initialized") + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd._check_storage", _fake_check_storage + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[WARN] cross" in out + assert "seekdb" in out.lower() + + def test_doctor_cross_dependency_llm_without_embedding_warn( + self, monkeypatch, capsys + ) -> None: + """LLM configured but embedding=none → WARN about missing vector search.""" + monkeypatch.setenv("EMBEDDING_PROVIDER", "none") + monkeypatch.setenv("LLM_PROVIDER", "openai") + + from contextseek.cli.doctor_cmd import CheckResult, PASS + + def _fake_check_llm(settings): + return CheckResult(PASS, "llm", "LLM responded") + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd._check_llm", _fake_check_llm + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[WARN] cross" in out + assert "vector search" in out.lower() + + def test_doctor_no_cross_warning_when_both_configured( + self, monkeypatch, capsys + ) -> None: + """Both embedding and LLM configured → no cross WARN.""" + monkeypatch.setenv("EMBEDDING_PROVIDER", "openai") + monkeypatch.setenv("LLM_PROVIDER", "openai") + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_embedder", + lambda s: (lambda t: [0.1, 0.2]), + ) + + class _FakeLLM: + def invoke(self, messages): + return "ok" + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_llm", lambda s: _FakeLLM() + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 0 + assert "[WARN] cross" not in out + + # ------------------------------------------------------------------ + # Secret sanitisation + # ------------------------------------------------------------------ + + def test_doctor_no_secrets_leaked_sk_key(self, monkeypatch, capsys) -> None: + """Doctor output must never contain raw sk- keys from error messages.""" + fake_secret = "sk-FAKESECRET1234567890abcdef" + + def _fake_build_embedder(settings): + raise PermissionError( + f"AuthenticationError: Incorrect API key provided: {fake_secret}" + ) + + monkeypatch.setenv("EMBEDDING_PROVIDER", "openai") + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_embedder", _fake_build_embedder + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert fake_secret not in out + assert "sk-***" in out + + def test_doctor_no_secrets_leaked_password(self, monkeypatch, capsys) -> None: + """Doctor output must not leak password= values from error messages.""" + fake_password = "SuperSecret123!" + + def _fake_build_embedder(settings): + raise ValueError(f"Connection failed: password={fake_password} rejected") + + monkeypatch.setenv("EMBEDDING_PROVIDER", "openai") + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_embedder", _fake_build_embedder + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert fake_password not in out + assert "password=***" in out + + def test_doctor_no_secrets_leaked_token(self, monkeypatch, capsys) -> None: + """Doctor output must not leak token= values from error messages.""" + fake_token = "tok_abcDEF123456" + + def _fake_build_embedder(settings): + raise RuntimeError(f"Auth failed: token={fake_token} is invalid") + + monkeypatch.setenv("EMBEDDING_PROVIDER", "openai") + monkeypatch.setattr( + "contextseek.cli.doctor_cmd.build_embedder", _fake_build_embedder + ) + code = run_cli(["doctor"]) + out = capsys.readouterr().out + + assert code == 1 + assert fake_token not in out + assert "token=***" in out + + def test_doctor_config_report_no_password(self, monkeypatch, capsys) -> None: + """OceanBase config report must not include the password field.""" + monkeypatch.setenv("STORAGE_BACKEND", "oceanbase") + monkeypatch.setenv("OB_PASSWORD", "should_not_appear_in_output") + monkeypatch.setenv("EMBEDDING_DIMS", "1536") + + # Patch storage check to avoid real OB connection + from contextseek.cli.doctor_cmd import CheckResult, PASS + + monkeypatch.setattr( + "contextseek.cli.doctor_cmd._check_storage", + lambda s: CheckResult(PASS, "storage", "ok"), + ) + run_cli(["doctor"]) + out = capsys.readouterr().out + + assert "should_not_appear_in_output" not in out + + # ------------------------------------------------------------------ + # Sanitisation unit tests + # ------------------------------------------------------------------ + + def test_sanitize_truncates_long_messages(self) -> None: + """_sanitize_error_message truncates messages over 200 chars.""" + from contextseek.cli.doctor_cmd import _sanitize_error_message + + long_msg = "x" * 300 + result = _sanitize_error_message(long_msg) + assert len(result) <= 201 # 200 + ellipsis + assert result.endswith("…") + + def test_sanitize_preserves_short_messages(self) -> None: + """_sanitize_error_message preserves short messages unchanged.""" + from contextseek.cli.doctor_cmd import _sanitize_error_message + + assert _sanitize_error_message("short error") == "short error" + + def test_sanitize_sk_key_pattern(self) -> None: + """_sanitize_error_message masks sk- prefixed keys.""" + from contextseek.cli.doctor_cmd import _sanitize_error_message + + result = _sanitize_error_message("error: sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ1234") + assert "sk-***" in result + assert "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234" not in result + + # ------------------------------------------------------------------ + # Output format structure + # ------------------------------------------------------------------ + + def test_doctor_output_has_header(self, capsys) -> None: + """Doctor output starts with the diagnostic header.""" + run_cli(["doctor"]) + out = capsys.readouterr().out + + assert "ContextSeek doctor" in out + assert "configuration diagnostics" in out.lower() + + def test_doctor_output_has_config_resolved_section(self, capsys) -> None: + """Doctor output includes a 'Configuration resolved:' section.""" + run_cli(["doctor"]) + out = capsys.readouterr().out + + assert "Configuration resolved:" in out + assert "storage" in out + assert "embedding" in out + assert "llm" in out + + def test_doctor_output_has_checks_section(self, capsys) -> None: + """Doctor output includes a 'Checks:' section.""" + run_cli(["doctor"]) + out = capsys.readouterr().out + + assert "Checks:" in out + + def test_doctor_output_has_result_summary(self, capsys) -> None: + """Doctor output ends with a 'Result:' summary line.""" + run_cli(["doctor"]) + out = capsys.readouterr().out + + assert "Result:" in out + assert "PASS" in out + assert "SKIP" in out + + def test_doctor_exit_code_nonzero_on_fail(self, monkeypatch, capsys) -> None: + """Exit code is 1 when any check FAILs.""" + monkeypatch.setenv("STORAGE_BACKEND", "bad_backend") + code = run_cli(["doctor"]) + assert code == 1 + + def test_doctor_exit_code_zero_on_all_pass_skip(self, capsys) -> None: + """Exit code is 0 when no checks FAIL.""" + code = run_cli(["doctor"]) + assert code == 0 diff --git a/tests/unit_tests/test_context_injection.py b/tests/unit_tests/test_context_injection.py index c177bfe..b20060c 100644 --- a/tests/unit_tests/test_context_injection.py +++ b/tests/unit_tests/test_context_injection.py @@ -61,6 +61,73 @@ def test_tags_filter_requires_all_tags(self): assert [hit.item.id for hit in tagged_response] == [kept.id] assert {hit.item.id for hit in unfiltered_response} == {kept.id, other.id} + def test_tags_filter_any_matches_partial(self): + ctx = ContextSeek() + kept = ctx.add( + "database backup runbook", + scope="t/p", + source="cli", + tags=["ops", "database"], + ) + other = ctx.add( + "database onboarding guide", + scope="t/p", + source="cli", + tags=["docs", "database"], + ) + + response = ctx.retrieve( + "database", scope="t/p", tags=["ops", "docs"], tag_match="any" + ) + + assert {hit.item.id for hit in response} == {kept.id, other.id} + + def test_tags_filter_any_with_non_matching_tag(self): + ctx = ContextSeek() + kept = ctx.add( + "database backup runbook", + scope="t/p", + source="cli", + tags=["ops", "database"], + ) + ctx.add( + "database onboarding guide", + scope="t/p", + source="cli", + tags=["docs", "database"], + ) + + response = ctx.retrieve( + "database", scope="t/p", tags=["ops", "nonexistent"], tag_match="any" + ) + + assert [hit.item.id for hit in response] == [kept.id] + + def test_tag_match_defaults_to_all(self): + ctx = ContextSeek() + kept = ctx.add( + "database backup runbook", + scope="t/p", + source="cli", + tags=["ops", "database"], + ) + ctx.add( + "database onboarding guide", + scope="t/p", + source="cli", + tags=["docs", "database"], + ) + + response_default = ctx.retrieve( + "database", scope="t/p", tags=["ops", "docs"] + ) + response_explicit_all = ctx.retrieve( + "database", scope="t/p", tags=["ops", "docs"], tag_match="all" + ) + + assert [hit.item.id for hit in response_default] == [] + assert [hit.item.id for hit in response_explicit_all] == [] + class TestExpand: def test_expand_returns_full_items_without_scope(self):