From de2edc1532d7ed60dcd55d04f338b1da2f3bb76a Mon Sep 17 00:00:00 2001 From: Alex Rockwell Date: Sat, 7 Mar 2026 01:12:08 -0500 Subject: [PATCH] refarch: create labs namespace for experimental commands (Issue #ARCH-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move experimental/unstable commands behind `emdx labs` to establish a clean, committed API surface for 1.0. Stable commands remain at top level. Moved to `emdx labs`: - wiki (entire group), explore, distill, compact, briefing, serve, context - find --ask/--think/--debug/--wander/--watch → labs ask/wander/watch - maintain drift/gaps/contradictions/code-drift/wikify/entities/cloud-backup → labs maintain Stable top-level commands (18): save, find, view, edit, delete, gist, tag, task, db, status, prime, stale, touch, trash, history, diff, maintain, gui All labs commands print a stderr warning on use. find retains --context. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 71 +-- emdx/commands/core.py | 529 +---------------------- emdx/commands/labs.py | 137 ++++++ emdx/commands/labs_ask.py | 576 +++++++++++++++++++++++++ emdx/commands/maintain.py | 31 +- emdx/main.py | 2 + tests/test_ask_improvements.py | 22 +- tests/test_ask_modes.py | 54 +-- tests/test_cloud_backup.py | 4 +- tests/test_compact.py | 26 +- tests/test_contradictions.py | 10 +- tests/test_entities_json.py | 12 +- tests/test_find_wander.py | 22 +- tests/test_gaps.py | 16 +- tests/test_intelligence_integration.py | 50 +-- tests/test_lazy_loading.py | 10 +- tests/test_maintain_drift.py | 14 +- tests/test_wiki_coverage.py | 24 +- tests/test_wiki_editorial_prompt.py | 20 +- tests/test_wiki_export.py | 10 +- tests/test_wiki_model_override.py | 10 +- tests/test_wiki_progress.py | 22 +- tests/test_wiki_rating.py | 22 +- tests/test_wiki_rename.py | 18 +- tests/test_wiki_retitle.py | 6 +- tests/test_wiki_source_weight.py | 26 +- tests/test_wiki_topic_merge_split.py | 38 +- tests/test_wiki_topic_skip_pin.py | 22 +- tests/test_wiki_triage_setup.py | 22 +- 29 files changed, 983 insertions(+), 843 deletions(-) create mode 100644 emdx/commands/labs.py create mode 100644 emdx/commands/labs_ask.py diff --git a/CLAUDE.md b/CLAUDE.md index 9dfe9d44..0ed142fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -228,19 +228,7 @@ emdx find --tags "gameplan,active" # Tag filtering (comma = AND, use --any-t emdx find --all # List all documents emdx find --recent 10 # Show 10 most recently accessed docs emdx find --similar 42 # Find docs similar to doc #42 -emdx find --ask "question" # RAG: retrieve context + LLM answer -emdx find --ask "question" --think # Deliberative: position paper with arguments -emdx find --ask "question" --think --challenge # Devil's advocate (requires --think) -emdx find --ask "question" --debug # Socratic debugger: diagnostic questions -emdx find --ask "question" --cite # Inline [#ID] citations emdx find --context "question" | claude # Output retrieved context for piping -emdx find "query" --wander # Serendipity: surface surprising connections -emdx find "query" --watch # Save as standing query (alerts on new matches) -emdx find --watch-check # Check standing queries for new matches -emdx find --watch-list # List all standing queries -emdx find --watch-remove 1 # Remove a standing query by ID -emdx find --ask --machine "question" # Pipe-friendly output (ANSWER/SOURCES/CONFIDENCE) -emdx find --ask --recent-days 7 "q" # Scope ask to recent docs only # View emdx view 42 # View document content @@ -270,11 +258,6 @@ emdx prime # Full context injection emdx prime --smart # Context-aware: recent activity, key docs, knowledge map emdx prime --brief # Compact: tasks + epics only -# Context assembly -emdx context 87 # Graph-walk context bundle from doc 87 -emdx context --seed "auth error" # Find seeds from text query -emdx context 87 --depth 3 --max-tokens 6000 # Control traversal and budget - # Staleness emdx stale # Show documents needing review by urgency emdx stale --tier critical # Show only critical tier @@ -287,8 +270,7 @@ emdx status --stats --detailed # Detailed stats with project breakdown emdx status --vitals # KB vitals dashboard emdx status --mirror # Reflective KB summary (narrative) -# Maintenance -emdx maintain compact --dry-run # Find similar docs to merge +# Maintenance (stable) emdx maintain index # Build/update embedding index emdx maintain link --all # Auto-link related documents # Note: `emdx save` auto-links new docs by default (--auto-link/--no-auto-link). @@ -298,35 +280,28 @@ emdx maintain backup --list # List existing backups emdx maintain backup --restore # Restore from a backup emdx maintain freshness # Score document freshness (staleness) emdx maintain freshness --stale # Show only stale docs -emdx maintain gaps # Detect knowledge gaps and sparse coverage -emdx maintain drift # Detect stale work items (default 30 days) -emdx maintain drift --days 7 # More aggressive threshold -emdx maintain contradictions # Find conflicting claims via NLI -emdx maintain code-drift # Detect code references that have drifted -emdx maintain cloud-backup upload # Upload backup to GitHub Gists -emdx maintain cloud-backup list # List cloud backups -emdx maintain cloud-backup download # Download a cloud backup -emdx compact --dry-run # Top-level alias for maintain compact - -# Wiki (top-level; `emdx maintain wiki ...` still works) -emdx wiki # Compact wiki overview -emdx wiki setup # Full bootstrap (index → entities → topics → auto-label) -emdx wiki topics --save --auto-label # Discover and label topics -emdx wiki triage --skip-below 0.05 # Bulk skip low-coherence topics -emdx wiki triage --auto-label # LLM-label all topics -emdx wiki progress # Show generation progress + costs -emdx wiki generate # Generate articles (sequential) -emdx wiki generate -c 3 # Generate with 3 concurrent -emdx wiki view # View a wiki article by topic -emdx wiki search "query" # Search wiki articles -emdx wiki export ./wiki-site # Export to MkDocs -emdx wiki export ./wiki-site --topic 42 # Single article - -# Distill — audience-aware content synthesis -emdx distill "authentication" # Synthesize docs on a topic -emdx distill --tags "security,active" # Synthesize docs matching tags -emdx distill "topic" --for coworkers # Audience: me (default), docs, coworkers -emdx distill "topic" --save # Save distilled output to KB + +# Labs — experimental commands (may change or be removed) +emdx labs ask "question" # RAG: retrieve context + LLM answer +emdx labs ask --think "question" # Deliberative: position paper +emdx labs ask --debug "bug" # Socratic debugger +emdx labs ask --cite "question" # Inline [#ID] citations +emdx labs wander # Serendipity: surprising connections +emdx labs wander "topic" # Serendipity from a topic seed +emdx labs watch add "query" # Save as standing query +emdx labs watch check # Check standing queries +emdx labs watch list # List standing queries +emdx labs context 87 # Graph-walk context bundle +emdx labs briefing # Recent activity summary +emdx labs compact --dry-run # AI-powered doc merging +emdx labs distill "topic" # Audience-aware synthesis +emdx labs wiki setup # Auto-wiki bootstrap +emdx labs explore # Topic clustering map +emdx labs maintain drift # Detect stale work items +emdx labs maintain gaps # Detect knowledge gaps +emdx labs maintain contradictions # Find conflicting claims +emdx labs maintain code-drift # Detect stale code refs +emdx labs maintain cloud-backup upload # Cloud backup to Gists # History / Diff — document versioning emdx history 42 # Show version history for doc #42 diff --git a/emdx/commands/core.py b/emdx/commands/core.py index f937c9c4..aaa6ff4f 100644 --- a/emdx/commands/core.py +++ b/emdx/commands/core.py @@ -4,7 +4,6 @@ from __future__ import annotations -import contextlib import json import os import subprocess @@ -13,10 +12,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from emdx.services.ask_service import AskMode +from typing import Any import typer from rich.panel import Panel @@ -395,71 +391,18 @@ def find( similar: int | None = typer.Option( None, "--similar", help="Find documents similar to this doc ID" ), - ask: bool = typer.Option( - False, "--ask", help="Answer the query using RAG (retrieves context + LLM)" - ), - think: bool = typer.Option( - False, - "--think", - help="Deliberative search: build a position paper with arguments for/against", - ), - challenge: bool = typer.Option( - False, - "--challenge", - help="Devil's advocate: find evidence AGAINST the queried position (use with --think)", - ), - debug: bool = typer.Option( - False, - "--debug", - help="Socratic debugger: diagnostic questions from your bug history", - ), - cite: bool = typer.Option( - False, - "--cite", - help="Add inline [#ID] citations using chunk-level retrieval", - ), context: bool = typer.Option( False, "--context", help="Output retrieved context as plain text (for piping to claude)" ), - machine: bool = typer.Option( - False, - "--machine", - help="Machine-readable output for --ask (answer on stdout, metadata on stderr)", - ), recent_days: int | None = typer.Option( None, "--recent-days", - help="Filter --ask/--context to docs created in the last N days", + help="Filter --context to docs created in the last N days", ), wiki: bool = typer.Option(False, "--wiki", help="Show only wiki articles (doc_type='wiki')"), all_types: bool = typer.Option( False, "--all-types", help="Show all document types (user, wiki, etc.)" ), - wander: bool = typer.Option( - False, - "--wander", - help="Serendipity mode: surface surprising but related documents", - ), - watch: bool = typer.Option( - False, - "--watch", - help="Save query as a standing query (alerts on new matches)", - ), - watch_list: bool = typer.Option( - False, - "--watch-list", - help="List all standing queries", - ), - watch_check: bool = typer.Option( - False, - "--watch-check", - help="Check all standing queries for new matches", - ), - watch_remove: int | None = typer.Option( - None, - "--watch-remove", - help="Remove a standing query by ID", - ), ) -> None: """Search the knowledge base with full-text search. @@ -471,14 +414,10 @@ def find( Use --extract to see the matching paragraph/section instead of the full document. Use --all to list all documents, --recent N to show recently accessed docs. Use --similar N to find documents similar to doc #N. - Use --ask to get an AI-powered answer to your question. - Use --think to get a deliberative position paper with arguments for/against. - Use --debug to get Socratic diagnostic questions from your bug history. - Use --cite to add inline citations to any AI-powered answer. Use --context to retrieve docs as plain text for piping to claude. - Use --machine to get pipe-friendly output (answer on stdout, metadata on stderr). - Use --recent-days N to scope --ask/--context to docs from the last N days. - Use --wander for serendipity: surface surprising but related documents. + Use --recent-days N to scope --context to docs from the last N days. + + For AI-powered search, use: emdx labs ask, emdx labs wander, emdx labs watch. Examples: emdx find "authentication patterns" # hybrid search @@ -487,82 +426,10 @@ def find( emdx find --all # list all documents emdx find --recent 10 # recently accessed emdx find --similar 42 # docs similar to #42 - emdx find --ask "What's our caching strategy?" # RAG Q&A - emdx find --ask --tags "gameplan" "strategy?" # scoped to tagged docs - emdx find --ask --recent-days 7 "what changed?" # last 7 days only - emdx find --ask --machine "summarize auth" # pipe-friendly output - emdx find --think "rewrite in Rust" # position paper - emdx find --think --challenge "rewrite in Rust" # devil's advocate - emdx find --debug "TUI freezes on click" # Socratic debugger - emdx find --ask --cite "how does auth work?" # with citations emdx find --context "auth" | claude # pipe context to claude - emdx find --wander # random serendipity - emdx find --wander "machine learning" # serendipity from topic - emdx find --watch "deployment" # save as standing query - emdx find --watch-list # list standing queries - emdx find --watch-check # check for new matches - emdx find --watch-remove 3 # remove standing query #3 """ search_query = " ".join(query) if query else "" - # ── Handle --watch sub-commands ────────────────────────────────── - if watch_list or watch_check or watch_remove is not None or watch: - from emdx.commands._watch import ( - check_standing_queries, - create_standing_query, - display_check_results, - display_standing_queries_list, - remove_standing_query, - ) - - if watch_list: - display_standing_queries_list(json_output=json_output) - return - - if watch_check: - matches = check_standing_queries() - display_check_results(matches, json_output=json_output) - return - - if watch_remove is not None: - removed = remove_standing_query(watch_remove) - if removed: - if json_output: - print(json.dumps({"removed": watch_remove})) - else: - print(f"Removed standing query #{watch_remove}") - else: - if json_output: - print(json.dumps({"error": f"No standing query #{watch_remove}"})) - else: - console.print(f"[red]Error: No standing query #{watch_remove}[/red]") - raise typer.Exit(1) - return - - if watch: - if not search_query and not tags: - console.print("[red]Error: --watch requires a query or --tags[/red]") - raise typer.Exit(1) - sq_id = create_standing_query( - query=search_query, - tags=tags, - project=project, - ) - if json_output: - print( - json.dumps( - { - "id": sq_id, - "query": search_query, - "tags": tags, - "project": project, - } - ) - ) - else: - print(f"Saved standing query #{sq_id}: {search_query or tags}") - return - # Record search event (non-critical, best-effort) if search_query: from emdx.models.events import record_event @@ -594,33 +461,6 @@ def find( _find_similar(similar, limit, json_output) return - # Handle --wander: serendipity search - if wander: - _find_wander(search_query, limit, project, json_output) - return - - # Handle AI-powered modes: --ask, --think, --debug - ask_mode = _resolve_ask_mode(ask, think, challenge, debug, cite) - if ask_mode is not None: - if not search_query: - from ..services.ask_service import AskMode - - flag = "ask" if ask_mode == AskMode.ANSWER else ask_mode.value - console.print(f"[red]Error: --{flag} requires a question[/red]") - raise typer.Exit(1) - _find_ask( - search_query, - limit, - project, - tags, - recent_days=recent_days, - mode=ask_mode, - cite=cite, - json_output=json_output, - machine=machine, - ) - return - # Handle --context: retrieve context for piping if context: if not search_query: @@ -952,365 +792,6 @@ def _find_similar( console.print(table) -def _find_wander( - search_query: str, - limit: int, - project: str | None, - json_output: bool, -) -> None: - """Serendipity search using the Goldilocks similarity band. - - Surfaces documents in the 0.2-0.4 cosine similarity range -- - related enough to be interesting, different enough to surprise. - """ - import random - - try: - from ..services.embedding_service import EmbeddingService - except ImportError as e: - console.print(f"[red]{e}[/red]") - raise typer.Exit(1) from None - - from ..database import db - - service = EmbeddingService() - - # Check how many docs have embeddings - stats = service.stats() - if stats.indexed_documents < 10: - msg = ( - f"Serendipity works better with 50+ documents. " - f"You have {stats.indexed_documents}. " - f"Try `emdx maintain index` first." - ) - if json_output: - print(json.dumps({"error": msg})) - else: - console.print(f"[yellow]{msg}[/yellow]") - return - - # Get the seed embedding - seed_doc_id: int | None = None - if search_query: - # Use query text as seed - seed_embedding = service.embed_text(search_query) - else: - # Pick a random recently-accessed document as seed - with db.get_connection() as conn: - cursor = conn.cursor() - query_sql = ( - "SELECT d.id FROM documents d " - "JOIN document_embeddings e " - "ON d.id = e.document_id " - "WHERE d.is_deleted = 0 AND e.model_name = ? " - "ORDER BY d.accessed_at DESC NULLS LAST " - "LIMIT 20" - ) - params: list[str | int] = [service.MODEL_NAME] - cursor.execute(query_sql, params) - recent_ids = [row[0] for row in cursor.fetchall()] - - if not recent_ids: - msg = "No documents with embeddings found." - if json_output: - print(json.dumps({"error": msg})) - else: - console.print(f"[yellow]{msg}[/yellow]") - return - - chosen_id: int = random.choice(recent_ids) - seed_doc_id = chosen_id - seed_embedding = service.embed_document(chosen_id) - - # Load all embeddings and find docs in the Goldilocks band - try: - import numpy as np - except ImportError: - console.print( - "[red]numpy is required for --wander. Install with: pip install 'emdx[ai]'[/red]" - ) - raise typer.Exit(1) from None - - with db.get_connection() as conn: - cursor = conn.cursor() - base_sql = ( - "SELECT e.document_id, e.embedding, d.title, " - "d.project, SUBSTR(d.content, 1, 200) as snippet " - "FROM document_embeddings e " - "JOIN documents d ON e.document_id = d.id " - "WHERE e.model_name = ? AND d.is_deleted = 0" - ) - sql_params: list[str | int] = [service.MODEL_NAME] - - if project: - base_sql += " AND d.project = ?" - sql_params.append(project) - - cursor.execute(base_sql, sql_params) - rows = cursor.fetchall() - - # Compute similarities and filter to Goldilocks band - goldilocks_min = 0.2 - goldilocks_max = 0.4 - candidates = [] - for doc_id, emb_bytes, title, doc_project, snippet in rows: - # Skip the seed document - if doc_id == seed_doc_id: - continue - - doc_embedding = np.frombuffer(emb_bytes, dtype=np.float32) - similarity = float(np.dot(seed_embedding, doc_embedding)) - - if goldilocks_min <= similarity <= goldilocks_max: - clean_snippet = "" - if snippet: - clean_snippet = snippet.replace("\n", " ")[:100] - candidates.append( - { - "id": doc_id, - "title": title, - "project": doc_project, - "similarity": round(similarity, 3), - "snippet": clean_snippet, - } - ) - - if not candidates: - msg = ( - "No surprising connections found. " - "Your KB might be too focused -- " - "try saving docs on different topics!" - ) - if json_output: - print(json.dumps({"error": msg})) - else: - console.print(f"[yellow]{msg}[/yellow]") - return - - # Sort by similarity descending (prefer more-related end) - candidates.sort(key=lambda x: x["similarity"], reverse=True) - - # Cap results - effective_limit = min(limit, 5) - results = candidates[:effective_limit] - - if json_output: - output = { - "seed": search_query if search_query else f"doc #{seed_doc_id}", - "results": results, - } - print(json.dumps(output, indent=2)) - return - - # Human-readable output - seed_desc = f"'{search_query}'" if search_query else f"doc #{seed_doc_id}" - console.print( - f"\n[bold]Wandering from {seed_desc} ({len(results)} surprising connections):[/bold]\n" - ) - for i, r in enumerate(results, 1): - console.print(f"[bold cyan]#{r['id']}[/bold cyan] [bold]{r['title']}[/bold]") - meta = [] - if r["project"]: - meta.append(f"[green]{r['project']}[/green]") - meta.append(f"[dim]similarity: {r['similarity']:.3f}[/dim]") - console.print(" | ".join(meta)) - if r["snippet"]: - console.print(f"[dim]{r['snippet']}[/dim]") - if i < len(results): - console.print() - - console.print("\n[dim]Use 'emdx view ' to explore a document[/dim]") - - -def _resolve_ask_mode( - ask: bool, - think: bool, - challenge: bool, - debug: bool, - cite: bool, -) -> AskMode | None: - """Resolve CLI flags to an AskMode, or None if no AI mode requested. - - Validates mutual exclusivity of --ask, --think, --debug. - --challenge is only valid with --think. - --cite without --ask/--think/--debug auto-enables --ask. - """ - from ..services.ask_service import AskMode - - active_modes = sum([ask, think, debug]) - if active_modes > 1: - console.print("[red]Error: --ask, --think, and --debug are mutually exclusive[/red]") - raise typer.Exit(1) - - if challenge and not think: - console.print("[red]Error: --challenge requires --think[/red]") - raise typer.Exit(1) - - if think and challenge: - return AskMode.CHALLENGE - if think: - return AskMode.THINK - if debug: - return AskMode.DEBUG - if ask: - return AskMode.ANSWER - if cite: - # --cite without explicit mode auto-enables --ask - return AskMode.ANSWER - return None - - -def _find_ask( - question: str, - limit: int, - project: str | None, - tags: str | None, - recent_days: int | None = None, - mode: AskMode | None = None, - cite: bool = False, - json_output: bool = False, - machine: bool = False, -) -> None: - """Answer a question using RAG (retrieves context + LLM).""" - import sys - - from ..services.ask_service import AskMode, AskService - - if mode is None: - mode = AskMode.ANSWER - - mode_labels = { - AskMode.ANSWER: "Thinking", - AskMode.THINK: "Building position paper", - AskMode.CHALLENGE: "Finding counterarguments", - AskMode.DEBUG: "Analyzing error patterns", - } - spinner_label = mode_labels.get(mode, "Thinking") - - service = AskService() - try: - # Suppress spinner in JSON/machine mode to keep stdout clean - status_ctx = ( - contextlib.nullcontext() - if (json_output or machine) - else console.status(f"[bold blue]{spinner_label}...", spinner="dots") - ) - with status_ctx: - result = service.ask( - question, - limit=limit, - project=project, - tags=tags, - recent_days=recent_days, - mode=mode, - cite=cite, - ) - except ImportError as e: - console.print(f"[red]{e}[/red]") - raise typer.Exit(1) from None - - # Machine-readable output mode (--machine) - if machine: - # Answer on stdout (clean, no markup) - print(f"ANSWER: {result.text}") - print() - print("SOURCES:") - if result.source_titles: - for doc_id, title in result.source_titles: - print(f'#{doc_id} "{title}"') - else: - print("(none)") - print() - print(f"CONFIDENCE: {result.confidence}") - - # Metadata on stderr - print( - f"method={result.method} " - f"context_size={result.context_size} " - f"sources={len(result.sources)}", - file=sys.stderr, - ) - return - - # JSON output mode - if json_output: - output: dict[str, object] = { - "mode": mode.value, - "query": question, - "answer": result.text, - "confidence": result.confidence, - "method": result.method, - "context_size": result.context_size, - "sources": [{"id": doc_id, "title": title} for doc_id, title in result.source_titles], - } - if result.confidence_signals: - signals = result.confidence_signals - output["confidence_score"] = round(signals.composite_score, 3) - output["confidence_signals"] = { - "retrieval_score_mean": round(signals.retrieval_score_mean, 3), - "retrieval_score_spread": round(signals.retrieval_score_spread, 3), - "source_count": signals.source_count, - "query_term_coverage": round(signals.query_term_coverage, 3), - "topic_coherence": round(signals.topic_coherence, 3), - "recency_score": round(signals.recency_score, 3), - } - if cite and result.cited_ids: - output["cited_ids"] = result.cited_ids - print(json.dumps(output, indent=2)) - return - - # Rich output mode - from rich.panel import Panel - - confidence_colors = { - "high": "green", - "medium": "yellow", - "low": "red", - "insufficient": "red", - } - confidence_color = confidence_colors.get(result.confidence, "dim") - - # Build panel title based on mode - mode_title = { - AskMode.ANSWER: "Answer", - AskMode.THINK: "Position Paper", - AskMode.CHALLENGE: "Devil's Advocate", - AskMode.DEBUG: "Debugging Analysis", - }.get(mode, "Answer") - panel_title = f"{mode_title} [{result.confidence.upper()} confidence]" - - console.print() - console.print( - Panel( - result.text, - title=panel_title, - border_style=confidence_color, - ) - ) - - # Show confidence details if signals are available - if result.confidence_signals: - signals = result.confidence_signals - console.print() - console.print( - f"[dim]Confidence: {signals.composite_score:.0%} " - f"({signals.source_count} sources, " - f"coverage: {signals.query_term_coverage:.0%}, " - f"coherence: {signals.topic_coherence:.0%})[/dim]" - ) - - # Show cited IDs if cite mode - if cite and result.cited_ids: - console.print() - cited_strs = [f"#{cid}" for cid in result.cited_ids] - console.print(f"[dim]Cited: {', '.join(cited_strs)}[/dim]") - - if result.source_titles: - console.print() - source_strs = [f'#{doc_id} "{title}"' for doc_id, title in result.source_titles] - console.print(f"[dim]Sources: {', '.join(source_strs)}[/dim]") - - def _find_context( question: str, limit: int, diff --git a/emdx/commands/labs.py b/emdx/commands/labs.py new file mode 100644 index 00000000..755cd037 --- /dev/null +++ b/emdx/commands/labs.py @@ -0,0 +1,137 @@ +""" +Labs namespace for experimental emdx commands. + +Commands under 'emdx labs' are experimental and may be changed or removed +in future releases. They are fully functional but their APIs are not yet +committed to for 1.0 stability. +""" + +from __future__ import annotations + +import sys + +import typer + +from emdx.commands.backup import app as cloud_backup_app +from emdx.commands.briefing import briefing as briefing_command +from emdx.commands.code_drift import code_drift_command +from emdx.commands.compact import app as compact_app +from emdx.commands.context import context as context_command +from emdx.commands.distill import app as distill_app +from emdx.commands.explore import app as explore_app +from emdx.commands.labs_ask import ( + ask_app, + watch_app, +) +from emdx.commands.labs_ask import ( + wander as wander_command, +) +from emdx.commands.maintain import contradictions, drift, gaps +from emdx.commands.maintain_index import ( + entities_command, + wikify_command, +) +from emdx.commands.serve import serve as serve_command +from emdx.commands.wiki import wiki_app + +app = typer.Typer( + name="labs", + help="Experimental commands (may change or be removed in future releases)", + rich_markup_mode="rich", +) + + +_WARNING_PRINTED = False + + +@app.callback() +def labs_callback(ctx: typer.Context) -> None: + """Print experimental warning before any labs subcommand.""" + global _WARNING_PRINTED # noqa: PLW0603 + if not _WARNING_PRINTED: + print( + "Warning: experimental command — may change or be removed in a future release.", + file=sys.stderr, + ) + _WARNING_PRINTED = True + + +# ============================================================================= +# Register experimental commands +# ============================================================================= + +# Wiki (entire group) +app.add_typer(wiki_app, name="wiki", help="Auto-wiki from your knowledge base (experimental)") + +# Explore (topic clustering) +app.add_typer( + explore_app, + name="explore", + help="Explore what your knowledge base knows (experimental)", +) + +# Distill (audience-aware synthesis) +app.add_typer( + distill_app, + name="distill", + help="Distill KB content into audience-aware summaries (experimental)", +) + +# Compact (AI-powered merge) +app.add_typer( + compact_app, + name="compact", + help="Reduce KB redundancy through AI synthesis (experimental)", +) + +# Context (graph-walk) +app.command(name="context")(context_command) + +# Briefing (activity summary) +app.command(name="briefing")(briefing_command) + +# Serve (JSON-RPC for IDE integrations) +app.command(name="serve")(serve_command) + +# ============================================================================= +# Labs maintain — experimental maintenance subcommands +# ============================================================================= + +labs_maintain_app = typer.Typer( + help="Experimental maintenance commands (moved from emdx maintain)", +) + +labs_maintain_app.command(name="drift")(drift) +labs_maintain_app.command(name="gaps")(gaps) +labs_maintain_app.command(name="contradictions")(contradictions) +labs_maintain_app.command(name="code-drift")(code_drift_command) +labs_maintain_app.command(name="wikify")(wikify_command) +labs_maintain_app.command(name="entities")(entities_command) +labs_maintain_app.add_typer( + cloud_backup_app, + name="cloud-backup", + help="Cloud backup operations (experimental)", +) + +app.add_typer( + labs_maintain_app, + name="maintain", + help="Experimental maintenance subcommands", +) + +# Ask (AI-powered Q&A — moved from find --ask/--think/--debug) +app.add_typer( + ask_app, + name="ask", + help="AI-powered question answering (experimental)", +) + +# Wander (serendipity — moved from find --wander) +app.command(name="wander")(wander_command) + +# Watch (standing queries — moved from find --watch*) +app.add_typer( + watch_app, + name="watch", + help="Standing queries that alert on new matches (experimental)", +) diff --git a/emdx/commands/labs_ask.py b/emdx/commands/labs_ask.py new file mode 100644 index 00000000..2841cc3d --- /dev/null +++ b/emdx/commands/labs_ask.py @@ -0,0 +1,576 @@ +"""Labs ask, wander, and watch commands. + +Moved from find flags to standalone commands under `emdx labs`. +""" + +from __future__ import annotations + +import contextlib +import json +import sys +from typing import TYPE_CHECKING + +import typer + +from emdx.utils.output import console + +if TYPE_CHECKING: + from emdx.services.ask_service import AskMode + + +# ============================================================================= +# Ask command +# ============================================================================= + +ask_app = typer.Typer( + name="ask", + help="AI-powered question answering over your knowledge base", + invoke_without_command=True, +) + + +@ask_app.callback(invoke_without_command=True) +def ask( + ctx: typer.Context, + question: list[str] | None = typer.Argument(default=None, help="Question to ask"), + think: bool = typer.Option( + False, + "--think", + help="Deliberative: build a position paper with arguments for/against", + ), + challenge: bool = typer.Option( + False, + "--challenge", + help="Devil's advocate: find evidence AGAINST the position (requires --think)", + ), + debug: bool = typer.Option( + False, + "--debug", + help="Socratic debugger: diagnostic questions from your bug history", + ), + cite: bool = typer.Option( + False, + "--cite", + help="Add inline [#ID] citations using chunk-level retrieval", + ), + machine: bool = typer.Option( + False, + "--machine", + help="Machine-readable output (answer on stdout, metadata on stderr)", + ), + recent_days: int | None = typer.Option( + None, + "--recent-days", + help="Filter to docs created in the last N days", + ), + tags: str | None = typer.Option(None, "--tags", "-t", help="Filter by tags (comma-separated)"), + limit: int = typer.Option(10, "--limit", "-n", help="Maximum results to retrieve"), + project: str | None = typer.Option(None, "--project", "-p", help="Filter by project"), + json_output: bool = typer.Option(False, "--json", "-j", help="Output results as JSON"), +) -> None: + """Answer a question using RAG (retrieves context + LLM). + + Examples: + emdx labs ask "What's our caching strategy?" + emdx labs ask --think "Should we rewrite in Rust?" + emdx labs ask --think --challenge "Should we rewrite in Rust?" + emdx labs ask --debug "TUI freezes on click" + emdx labs ask --cite "How does auth work?" + emdx labs ask --machine "Summarize auth" + emdx labs ask --recent-days 7 "What changed?" + emdx labs ask --tags "gameplan" "What's the strategy?" + """ + if ctx.invoked_subcommand is not None: + return + + search_query = " ".join(question) if question else "" + + ask_mode = _resolve_ask_mode(ask=True, think=think, challenge=challenge, debug=debug, cite=cite) + + if not search_query: + from emdx.services.ask_service import AskMode as AM + + flag = "ask" if ask_mode == AM.ANSWER else ask_mode.value + console.print(f"[red]Error: --{flag} requires a question[/red]") + raise typer.Exit(1) + + _run_ask( + search_query, + limit, + project, + tags, + recent_days=recent_days, + mode=ask_mode, + cite=cite, + json_output=json_output, + machine=machine, + ) + + +# ============================================================================= +# Wander command +# ============================================================================= + + +def wander( + query: list[str] | None = typer.Argument(default=None, help="Optional topic to wander from"), + limit: int = typer.Option(10, "--limit", "-n", help="Maximum results"), + project: str | None = typer.Option(None, "--project", "-p", help="Filter by project"), + json_output: bool = typer.Option(False, "--json", "-j", help="Output results as JSON"), +) -> None: + """Serendipity mode: surface surprising but related documents. + + Finds documents in the 'Goldilocks' similarity band (0.2-0.4) -- + related enough to be interesting, different enough to surprise. + + Examples: + emdx labs wander # random serendipity + emdx labs wander "machine learning" # wander from a topic + """ + search_query = " ".join(query) if query else "" + _run_wander(search_query, limit, project, json_output) + + +# ============================================================================= +# Watch command group +# ============================================================================= + +watch_app = typer.Typer( + name="watch", + help="Standing queries that alert on new matches", +) + + +@watch_app.command("add") +def watch_add( + query: list[str] | None = typer.Argument(default=None, help="Search terms to watch for"), + tags: str | None = typer.Option(None, "--tags", "-t", help="Filter by tags (comma-separated)"), + project: str | None = typer.Option(None, "--project", "-p", help="Filter by project"), + json_output: bool = typer.Option(False, "--json", "-j", help="Output results as JSON"), +) -> None: + """Save a standing query that alerts when new matches appear. + + Examples: + emdx labs watch add "deployment" + emdx labs watch add --tags "security" + """ + from emdx.commands._watch import create_standing_query + + search_query = " ".join(query) if query else "" + + if not search_query and not tags: + console.print("[red]Error: watch add requires a query or --tags[/red]") + raise typer.Exit(1) + + sq_id = create_standing_query(query=search_query, tags=tags, project=project) + + if json_output: + print( + json.dumps( + { + "id": sq_id, + "query": search_query, + "tags": tags, + "project": project, + } + ) + ) + else: + print(f"Saved standing query #{sq_id}: {search_query or tags}") + + +@watch_app.command("list") +def watch_list( + json_output: bool = typer.Option(False, "--json", "-j", help="Output results as JSON"), +) -> None: + """List all standing queries. + + Examples: + emdx labs watch list + emdx labs watch list --json + """ + from emdx.commands._watch import display_standing_queries_list + + display_standing_queries_list(json_output=json_output) + + +@watch_app.command("check") +def watch_check( + json_output: bool = typer.Option(False, "--json", "-j", help="Output results as JSON"), +) -> None: + """Check all standing queries for new matches. + + Examples: + emdx labs watch check + emdx labs watch check --json + """ + from emdx.commands._watch import ( + check_standing_queries, + display_check_results, + ) + + matches = check_standing_queries() + display_check_results(matches, json_output=json_output) + + +@watch_app.command("remove") +def watch_remove( + query_id: int = typer.Argument(help="Standing query ID to remove"), + json_output: bool = typer.Option(False, "--json", "-j", help="Output results as JSON"), +) -> None: + """Remove a standing query by ID. + + Examples: + emdx labs watch remove 3 + """ + from emdx.commands._watch import remove_standing_query + + removed = remove_standing_query(query_id) + if removed: + if json_output: + print(json.dumps({"removed": query_id})) + else: + print(f"Removed standing query #{query_id}") + else: + if json_output: + print(json.dumps({"error": f"No standing query #{query_id}"})) + else: + console.print(f"[red]Error: No standing query #{query_id}[/red]") + raise typer.Exit(1) + + +# ============================================================================= +# Internal helpers (moved from core.py) +# ============================================================================= + + +def _resolve_ask_mode( + ask: bool, + think: bool, + challenge: bool, + debug: bool, + cite: bool, +) -> AskMode: + """Resolve CLI flags to an AskMode. + + Validates mutual exclusivity of --think, --debug. + --challenge is only valid with --think. + --cite without --think/--debug uses default ANSWER mode. + """ + from emdx.services.ask_service import AskMode + + active_modes = sum([think, debug]) + if active_modes > 1: + console.print("[red]Error: --think and --debug are mutually exclusive[/red]") + raise typer.Exit(1) + + if challenge and not think: + console.print("[red]Error: --challenge requires --think[/red]") + raise typer.Exit(1) + + if think and challenge: + return AskMode.CHALLENGE + if think: + return AskMode.THINK + if debug: + return AskMode.DEBUG + return AskMode.ANSWER + + +def _run_ask( + question: str, + limit: int, + project: str | None, + tags: str | None, + recent_days: int | None = None, + mode: AskMode | None = None, + cite: bool = False, + json_output: bool = False, + machine: bool = False, +) -> None: + """Answer a question using RAG (retrieves context + LLM).""" + from emdx.services.ask_service import AskMode, AskService + + if mode is None: + mode = AskMode.ANSWER + + mode_labels = { + AskMode.ANSWER: "Thinking", + AskMode.THINK: "Building position paper", + AskMode.CHALLENGE: "Finding counterarguments", + AskMode.DEBUG: "Analyzing error patterns", + } + spinner_label = mode_labels.get(mode, "Thinking") + + service = AskService() + try: + status_ctx = ( + contextlib.nullcontext() + if (json_output or machine) + else console.status(f"[bold blue]{spinner_label}...", spinner="dots") + ) + with status_ctx: + result = service.ask( + question, + limit=limit, + project=project, + tags=tags, + recent_days=recent_days, + mode=mode, + cite=cite, + ) + except ImportError as e: + console.print(f"[red]{e}[/red]") + raise typer.Exit(1) from None + + # Machine-readable output mode (--machine) + if machine: + print(f"ANSWER: {result.text}") + print() + print("SOURCES:") + if result.source_titles: + for doc_id, title in result.source_titles: + print(f'#{doc_id} "{title}"') + else: + print("(none)") + print() + print(f"CONFIDENCE: {result.confidence}") + + print( + f"method={result.method} " + f"context_size={result.context_size} " + f"sources={len(result.sources)}", + file=sys.stderr, + ) + return + + # JSON output mode + if json_output: + output: dict[str, object] = { + "mode": mode.value, + "query": question, + "answer": result.text, + "confidence": result.confidence, + "method": result.method, + "context_size": result.context_size, + "sources": [{"id": doc_id, "title": title} for doc_id, title in result.source_titles], + } + if result.confidence_signals: + signals = result.confidence_signals + output["confidence_score"] = round(signals.composite_score, 3) + output["confidence_signals"] = { + "retrieval_score_mean": round(signals.retrieval_score_mean, 3), + "retrieval_score_spread": round(signals.retrieval_score_spread, 3), + "source_count": signals.source_count, + "query_term_coverage": round(signals.query_term_coverage, 3), + "topic_coherence": round(signals.topic_coherence, 3), + "recency_score": round(signals.recency_score, 3), + } + if cite and result.cited_ids: + output["cited_ids"] = result.cited_ids + print(json.dumps(output, indent=2)) + return + + # Rich output mode + from rich.panel import Panel + + confidence_colors = { + "high": "green", + "medium": "yellow", + "low": "red", + "insufficient": "red", + } + confidence_color = confidence_colors.get(result.confidence, "dim") + + mode_title = { + AskMode.ANSWER: "Answer", + AskMode.THINK: "Position Paper", + AskMode.CHALLENGE: "Devil's Advocate", + AskMode.DEBUG: "Debugging Analysis", + }.get(mode, "Answer") + panel_title = f"{mode_title} [{result.confidence.upper()} confidence]" + + console.print() + console.print( + Panel( + result.text, + title=panel_title, + border_style=confidence_color, + ) + ) + + if result.confidence_signals: + signals = result.confidence_signals + console.print() + console.print( + f"[dim]Confidence: {signals.composite_score:.0%} " + f"({signals.source_count} sources, " + f"coverage: {signals.query_term_coverage:.0%}, " + f"coherence: {signals.topic_coherence:.0%})[/dim]" + ) + + if cite and result.cited_ids: + console.print() + cited_strs = [f"#{cid}" for cid in result.cited_ids] + console.print(f"[dim]Cited: {', '.join(cited_strs)}[/dim]") + + if result.source_titles: + console.print() + source_strs = [f'#{doc_id} "{title}"' for doc_id, title in result.source_titles] + console.print(f"[dim]Sources: {', '.join(source_strs)}[/dim]") + + +def _run_wander( + search_query: str, + limit: int, + project: str | None, + json_output: bool, +) -> None: + """Serendipity search using the Goldilocks similarity band.""" + import random + + try: + from emdx.services.embedding_service import EmbeddingService + except ImportError as e: + console.print(f"[red]{e}[/red]") + raise typer.Exit(1) from None + + from emdx.database import db + + service = EmbeddingService() + + stats = service.stats() + if stats.indexed_documents < 10: + msg = ( + f"Serendipity works better with 50+ documents. " + f"You have {stats.indexed_documents}. " + f"Try `emdx maintain index` first." + ) + if json_output: + print(json.dumps({"error": msg})) + else: + console.print(f"[yellow]{msg}[/yellow]") + return + + seed_doc_id: int | None = None + if search_query: + seed_embedding = service.embed_text(search_query) + else: + with db.get_connection() as conn: + cursor = conn.cursor() + query_sql = ( + "SELECT d.id FROM documents d " + "JOIN document_embeddings e " + "ON d.id = e.document_id " + "WHERE d.is_deleted = 0 AND e.model_name = ? " + "ORDER BY d.accessed_at DESC NULLS LAST " + "LIMIT 20" + ) + params: list[str | int] = [service.MODEL_NAME] + cursor.execute(query_sql, params) + recent_ids = [row[0] for row in cursor.fetchall()] + + if not recent_ids: + msg = "No documents with embeddings found." + if json_output: + print(json.dumps({"error": msg})) + else: + console.print(f"[yellow]{msg}[/yellow]") + return + + chosen_id: int = random.choice(recent_ids) + seed_doc_id = chosen_id + seed_embedding = service.embed_document(chosen_id) + + try: + import numpy as np + except ImportError: + console.print( + "[red]numpy is required for wander. Install with: pip install 'emdx[ai]'[/red]" + ) + raise typer.Exit(1) from None + + with db.get_connection() as conn: + cursor = conn.cursor() + base_sql = ( + "SELECT e.document_id, e.embedding, d.title, " + "d.project, SUBSTR(d.content, 1, 200) as snippet " + "FROM document_embeddings e " + "JOIN documents d ON e.document_id = d.id " + "WHERE e.model_name = ? AND d.is_deleted = 0" + ) + sql_params: list[str | int] = [service.MODEL_NAME] + + if project: + base_sql += " AND d.project = ?" + sql_params.append(project) + + cursor.execute(base_sql, sql_params) + rows = cursor.fetchall() + + goldilocks_min = 0.2 + goldilocks_max = 0.4 + candidates = [] + for doc_id, emb_bytes, title, doc_project, snippet in rows: + if doc_id == seed_doc_id: + continue + + doc_embedding = np.frombuffer(emb_bytes, dtype=np.float32) + similarity = float(np.dot(seed_embedding, doc_embedding)) + + if goldilocks_min <= similarity <= goldilocks_max: + clean_snippet = "" + if snippet: + clean_snippet = snippet.replace("\n", " ")[:100] + candidates.append( + { + "id": doc_id, + "title": title, + "project": doc_project, + "similarity": round(similarity, 3), + "snippet": clean_snippet, + } + ) + + if not candidates: + msg = ( + "No surprising connections found. " + "Your KB might be too focused -- " + "try saving docs on different topics!" + ) + if json_output: + print(json.dumps({"error": msg})) + else: + console.print(f"[yellow]{msg}[/yellow]") + return + + candidates.sort(key=lambda x: x["similarity"], reverse=True) + + effective_limit = min(limit, 5) + results = candidates[:effective_limit] + + if json_output: + output = { + "seed": search_query if search_query else f"doc #{seed_doc_id}", + "results": results, + } + print(json.dumps(output, indent=2)) + return + + seed_desc = f"'{search_query}'" if search_query else f"doc #{seed_doc_id}" + console.print( + f"\n[bold]Wandering from {seed_desc} ({len(results)} surprising connections):[/bold]\n" + ) + for i, r in enumerate(results, 1): + console.print(f"[bold cyan]#{r['id']}[/bold cyan] [bold]{r['title']}[/bold]") + meta = [] + if r["project"]: + meta.append(f"[green]{r['project']}[/green]") + meta.append(f"[dim]similarity: {r['similarity']:.3f}[/dim]") + console.print(" | ".join(meta)) + if r["snippet"]: + console.print(f"[dim]{r['snippet']}[/dim]") + if i < len(results): + console.print() + + console.print("\n[dim]Use 'emdx view ' to explore a document[/dim]") diff --git a/emdx/commands/maintain.py b/emdx/commands/maintain.py index 966c3605..45a7d5cb 100644 --- a/emdx/commands/maintain.py +++ b/emdx/commands/maintain.py @@ -672,14 +672,7 @@ def maintain_callback( ) -app.command(name="drift")(drift) app.command(name="freshness")(freshness) -app.command(name="gaps")(gaps) - -# Register code-drift as a direct subcommand of maintain -from emdx.commands.code_drift import code_drift_command # noqa: E402 - -app.command(name="code-drift")(code_drift_command) def backup_command( @@ -818,43 +811,21 @@ def backup_command( app.command(name="backup")(backup_command) -# Register compact as a subcommand of maintain -from emdx.commands.compact import app as compact_app # noqa: E402 - -app.add_typer(compact_app, name="compact", help="Compact related documents via AI synthesis") - -# Register index/link/entity commands from maintain_index as direct subcommands +# Register index/link commands from maintain_index as direct subcommands from emdx.commands.maintain_index import ( # noqa: E402 create_links, - entities_command, index_embeddings, remove_link, - wikify_command, ) app.command(name="index")(index_embeddings) app.command(name="link")(create_links) app.command(name="unlink")(remove_link) -app.command(name="wikify")(wikify_command) -app.command(name="entities")(entities_command) - -# Register wiki as a subcommand group -from emdx.commands.wiki import wiki_app # noqa: E402 - -app.add_typer(wiki_app, name="wiki", help="Auto-wiki generation") # Register stale as a subcommand group of maintain from emdx.commands.stale import app as stale_app # noqa: E402 app.add_typer(stale_app, name="stale", help="Knowledge decay and staleness tracking") -# Register contradictions command -app.command(name="contradictions")(contradictions) - -# Register cloud-backup as a subcommand group -from emdx.commands.backup import app as cloud_backup_app # noqa: E402 - -app.add_typer(cloud_backup_app, name="cloud-backup", help="Cloud backup operations") - if __name__ == "__main__": app() diff --git a/emdx/main.py b/emdx/main.py index 0dcc0250..52fd7dfe 100644 --- a/emdx/main.py +++ b/emdx/main.py @@ -23,6 +23,7 @@ "distill": "emdx.commands.distill:app", "compact": "emdx.commands.compact:app", "maintain": "emdx.commands.maintain:app", + "labs": "emdx.commands.labs:app", } # Pre-computed help strings so --help doesn't trigger imports @@ -31,6 +32,7 @@ "distill": "Distill KB content into audience-aware summaries", "compact": "Reduce KB redundancy through AI synthesis", "maintain": "Maintenance and analysis tools", + "labs": "Experimental commands (may change or be removed)", } diff --git a/tests/test_ask_improvements.py b/tests/test_ask_improvements.py index 5db9dd46..773466d7 100644 --- a/tests/test_ask_improvements.py +++ b/tests/test_ask_improvements.py @@ -49,7 +49,7 @@ class TestAskTagFiltering: def test_tags_passed_to_ask_service(self) -> None: """--tags should be forwarded to AskService.ask().""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = _make_answer() @@ -116,7 +116,7 @@ class TestAskRecentDaysFiltering: def test_recent_days_passed_to_ask_service(self) -> None: """--recent-days should be forwarded to AskService.ask().""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = _make_answer() @@ -188,7 +188,7 @@ class TestMachineOutput: def test_machine_output_format(self) -> None: """--machine should produce structured plain text output.""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = _make_answer(confidence="high", source_count=3) @@ -222,7 +222,7 @@ def test_machine_output_format(self) -> None: def test_machine_output_metadata_on_stderr(self) -> None: """--machine should write metadata to stderr.""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = _make_answer() @@ -253,7 +253,7 @@ def test_machine_output_metadata_on_stderr(self) -> None: def test_machine_no_sources(self) -> None: """--machine with no sources should show '(none)'.""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = _make_answer(confidence="insufficient", source_count=0) @@ -280,7 +280,7 @@ def test_machine_no_sources(self) -> None: def test_machine_does_not_produce_json(self) -> None: """--machine should not produce JSON output.""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = _make_answer() @@ -315,7 +315,7 @@ def test_machine_does_not_produce_json(self) -> None: def test_machine_suppresses_spinner(self) -> None: """--machine should not show Rich spinner.""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = _make_answer() @@ -388,7 +388,7 @@ def test_insufficient_confidence_zero_sources(self) -> None: def test_machine_output_shows_confidence(self) -> None: """--machine output should include confidence level.""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask for level in ("high", "medium", "low", "insufficient"): answer = _make_answer(confidence=level) @@ -423,7 +423,7 @@ class TestMachineWithFilters: def test_machine_with_tags(self) -> None: """--machine --tags should pass tags to service and format output.""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = _make_answer() @@ -446,7 +446,7 @@ def test_machine_with_tags(self) -> None: def test_machine_with_recent_days(self) -> None: """--machine --recent-days should pass recent_days to service.""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = _make_answer() @@ -470,7 +470,7 @@ def test_machine_with_recent_days(self) -> None: def test_machine_with_all_filters(self) -> None: """All filters combined should work with --machine.""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = _make_answer() diff --git a/tests/test_ask_modes.py b/tests/test_ask_modes.py index f4d96397..d100d88e 100644 --- a/tests/test_ask_modes.py +++ b/tests/test_ask_modes.py @@ -596,72 +596,62 @@ class TestResolveModeFlags: def test_ask_returns_answer_mode(self) -> None: """--ask should return ANSWER mode.""" - from emdx.commands.core import _resolve_ask_mode + from emdx.commands.labs_ask import _resolve_ask_mode result = _resolve_ask_mode(ask=True, think=False, challenge=False, debug=False, cite=False) assert result == AskMode.ANSWER def test_think_returns_think_mode(self) -> None: """--think should return THINK mode.""" - from emdx.commands.core import _resolve_ask_mode + from emdx.commands.labs_ask import _resolve_ask_mode result = _resolve_ask_mode(ask=False, think=True, challenge=False, debug=False, cite=False) assert result == AskMode.THINK def test_think_challenge_returns_challenge_mode(self) -> None: """--think --challenge should return CHALLENGE mode.""" - from emdx.commands.core import _resolve_ask_mode + from emdx.commands.labs_ask import _resolve_ask_mode result = _resolve_ask_mode(ask=False, think=True, challenge=True, debug=False, cite=False) assert result == AskMode.CHALLENGE def test_debug_returns_debug_mode(self) -> None: """--debug should return DEBUG mode.""" - from emdx.commands.core import _resolve_ask_mode + from emdx.commands.labs_ask import _resolve_ask_mode result = _resolve_ask_mode(ask=False, think=False, challenge=False, debug=True, cite=False) assert result == AskMode.DEBUG def test_cite_alone_returns_answer_mode(self) -> None: """--cite alone should auto-enable --ask (ANSWER).""" - from emdx.commands.core import _resolve_ask_mode + from emdx.commands.labs_ask import _resolve_ask_mode result = _resolve_ask_mode(ask=False, think=False, challenge=False, debug=False, cite=True) assert result == AskMode.ANSWER - def test_no_flags_returns_none(self) -> None: - """No AI flags should return None.""" - from emdx.commands.core import _resolve_ask_mode + def test_no_flags_returns_answer(self) -> None: + """No AI flags should return ANSWER (default for labs ask).""" + from emdx.commands.labs_ask import _resolve_ask_mode result = _resolve_ask_mode(ask=False, think=False, challenge=False, debug=False, cite=False) - assert result is None - - def test_mutual_exclusion_ask_think(self) -> None: - """--ask and --think together should raise Exit.""" - import pytest - from click.exceptions import Exit - - from emdx.commands.core import _resolve_ask_mode - - with pytest.raises(Exit): - _resolve_ask_mode(ask=True, think=True, challenge=False, debug=False, cite=False) + assert result == AskMode.ANSWER - def test_mutual_exclusion_ask_debug(self) -> None: - """--ask and --debug together should raise Exit.""" + def test_mutual_exclusion_think_debug(self) -> None: + """--think and --debug together should raise Exit.""" import pytest from click.exceptions import Exit - from emdx.commands.core import _resolve_ask_mode + from emdx.commands.labs_ask import _resolve_ask_mode with pytest.raises(Exit): - _resolve_ask_mode(ask=True, think=False, challenge=False, debug=True, cite=False) + _resolve_ask_mode(ask=True, think=True, challenge=False, debug=True, cite=False) def test_challenge_without_think_raises(self) -> None: """--challenge without --think should raise Exit.""" import pytest from click.exceptions import Exit - from emdx.commands.core import _resolve_ask_mode + from emdx.commands.labs_ask import _resolve_ask_mode with pytest.raises(Exit): _resolve_ask_mode(ask=False, think=False, challenge=True, debug=False, cite=False) @@ -703,7 +693,7 @@ def test_json_output_produces_valid_json(self) -> None: """--json should produce valid JSON with expected keys.""" import json - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = self._make_answer() @@ -739,7 +729,7 @@ def test_json_output_includes_confidence_signals(self) -> None: """--json should include confidence score and signal breakdown.""" import json - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = self._make_answer() @@ -772,7 +762,7 @@ def test_json_output_think_mode(self) -> None: """--think --json should output mode='think'.""" import json - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = self._make_answer(mode=AskMode.THINK) @@ -799,7 +789,7 @@ def test_json_output_debug_mode(self) -> None: """--debug --json should output mode='debug'.""" import json - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = self._make_answer(mode=AskMode.DEBUG) @@ -826,7 +816,7 @@ def test_json_output_challenge_mode(self) -> None: """--think --challenge --json should output mode='challenge'.""" import json - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = self._make_answer(mode=AskMode.CHALLENGE) @@ -853,7 +843,7 @@ def test_json_output_with_cite(self) -> None: """--cite --json should include cited_ids.""" import json - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = self._make_answer(cite=True) @@ -881,7 +871,7 @@ def test_json_output_no_cited_ids_without_cite(self) -> None: """Without --cite, JSON should not include cited_ids.""" import json - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = self._make_answer(cite=False) @@ -905,7 +895,7 @@ def test_json_output_no_cited_ids_without_cite(self) -> None: def test_json_output_no_console_print(self) -> None: """--json should not use console.print (no Rich markup).""" - from emdx.commands.core import _find_ask + from emdx.commands.labs_ask import _run_ask as _find_ask answer = self._make_answer() diff --git a/tests/test_cloud_backup.py b/tests/test_cloud_backup.py index 65d216b6..297df619 100644 --- a/tests/test_cloud_backup.py +++ b/tests/test_cloud_backup.py @@ -38,9 +38,9 @@ def runner() -> CliRunner: @pytest.fixture def cli_app() -> typer.Typer: - from emdx.commands.maintain import app + from emdx.commands.labs import labs_maintain_app - return app + return labs_maintain_app @pytest.fixture diff --git a/tests/test_compact.py b/tests/test_compact.py index faa35129..d35ba55d 100644 --- a/tests/test_compact.py +++ b/tests/test_compact.py @@ -365,7 +365,7 @@ def test_compact_dry_run_shows_clusters(self, sample_docs_for_clustering, capsys from emdx.main import app runner = CliRunner() - result = runner.invoke(app, ["maintain", "compact", "--dry-run", "--threshold", "0.3"]) + result = runner.invoke(app, ["labs", "compact", "--dry-run", "--threshold", "0.3"]) # Should succeed assert result.exit_code == 0 @@ -390,7 +390,7 @@ def test_compact_specific_docs_validates_ids(self, clean_db, capsys): conn.commit() runner = CliRunner() - result = runner.invoke(app, ["maintain", "compact", "99999", "99998"]) + result = runner.invoke(app, ["labs", "compact", "99999", "99998"]) # Should show error about documents not found assert "not found" in result.stdout.lower() @@ -419,7 +419,7 @@ def test_compact_requires_at_least_two_docs(self, clean_db, capsys): conn.commit() runner = CliRunner() - result = runner.invoke(app, ["maintain", "compact", str(doc_id)]) + result = runner.invoke(app, ["labs", "compact", str(doc_id)]) # Should show error about needing at least 2 documents assert "at least 2" in result.stdout.lower() @@ -431,7 +431,7 @@ def test_compact_help_shows_usage(self): from emdx.main import app runner = CliRunner() - result = runner.invoke(app, ["maintain", "compact", "--help"]) + result = runner.invoke(app, ["labs", "compact", "--help"]) assert result.exit_code == 0 output = re.sub(r"\x1b\[[0-9;]*m", "", result.stdout).lower() @@ -446,7 +446,7 @@ def test_compact_empty_db_handles_gracefully(self, clean_db): from emdx.main import app runner = CliRunner() - result = runner.invoke(app, ["maintain", "compact", "--dry-run"]) + result = runner.invoke(app, ["labs", "compact", "--dry-run"]) assert result.exit_code == 0 assert "no documents" in result.stdout.lower() @@ -474,7 +474,7 @@ def test_compact_no_clusters_shows_message(self, clean_db): runner = CliRunner() # Use high threshold to ensure no clusters - result = runner.invoke(app, ["maintain", "compact", "--dry-run", "--threshold", "0.95"]) + result = runner.invoke(app, ["labs", "compact", "--dry-run", "--threshold", "0.95"]) assert result.exit_code == 0 assert "no clusters" in result.stdout.lower() @@ -486,7 +486,7 @@ def test_compact_topic_filter(self, sample_docs_for_clustering): from emdx.main import app runner = CliRunner() - result = runner.invoke(app, ["maintain", "compact", "--dry-run", "--topic", "Python"]) + result = runner.invoke(app, ["labs", "compact", "--dry-run", "--topic", "Python"]) assert result.exit_code == 0 # Should filter to only Python documents @@ -504,7 +504,7 @@ def test_json_empty_db(self, clean_db): from emdx.main import app runner = CliRunner() - result = runner.invoke(app, ["maintain", "compact", "--dry-run", "--json"]) + result = runner.invoke(app, ["labs", "compact", "--dry-run", "--json"]) assert result.exit_code == 0 data = json.loads(result.stdout) @@ -520,7 +520,7 @@ def test_json_dry_run_shows_clusters(self, sample_docs_for_clustering): runner = CliRunner() result = runner.invoke( app, - ["maintain", "compact", "--dry-run", "--json", "--threshold", "0.3"], + ["labs", "compact", "--dry-run", "--json", "--threshold", "0.3"], ) assert result.exit_code == 0 @@ -574,7 +574,7 @@ def test_json_no_clusters(self, clean_db): runner = CliRunner() result = runner.invoke( app, - ["maintain", "compact", "--dry-run", "--json", "--threshold", "0.95"], + ["labs", "compact", "--dry-run", "--json", "--threshold", "0.95"], ) assert result.exit_code == 0 @@ -601,7 +601,7 @@ def test_json_topic_filter_no_match(self, clean_db): runner = CliRunner() result = runner.invoke( app, - ["maintain", "compact", "--dry-run", "--json", "--topic", "nonexistent"], + ["labs", "compact", "--dry-run", "--json", "--topic", "nonexistent"], ) assert result.exit_code == 0 @@ -625,7 +625,7 @@ def test_json_specific_docs_not_found(self, clean_db): conn.commit() runner = CliRunner() - result = runner.invoke(app, ["maintain", "compact", "--json", "99999", "99998"]) + result = runner.invoke(app, ["labs", "compact", "--json", "99999", "99998"]) data = json.loads(result.stdout) assert "error" in data @@ -656,7 +656,7 @@ def test_json_requires_at_least_two_docs(self, clean_db): conn.commit() runner = CliRunner() - result = runner.invoke(app, ["maintain", "compact", "--json", str(doc_id)]) + result = runner.invoke(app, ["labs", "compact", "--json", str(doc_id)]) data = json.loads(result.stdout) assert "error" in data diff --git a/tests/test_contradictions.py b/tests/test_contradictions.py index 08fff6f9..bf4050e3 100644 --- a/tests/test_contradictions.py +++ b/tests/test_contradictions.py @@ -334,7 +334,7 @@ def test_no_embeddings_shows_message(self) -> None: instance = MockSvc.return_value instance._check_embeddings_exist.return_value = False - result = runner.invoke(app, ["maintain", "contradictions"]) + result = runner.invoke(app, ["labs", "maintain", "contradictions"]) assert result.exit_code == 1 assert "maintain index" in result.output @@ -345,7 +345,7 @@ def test_no_embeddings_json_output(self) -> None: instance = MockSvc.return_value instance._check_embeddings_exist.return_value = False - result = runner.invoke(app, ["maintain", "contradictions", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "contradictions", "--json"]) assert result.exit_code == 1 assert "No embedding index found" in result.output @@ -358,7 +358,7 @@ def test_no_contradictions_found(self) -> None: instance._check_nli_available.return_value = False instance.find_contradictions.return_value = [] - result = runner.invoke(app, ["maintain", "contradictions"]) + result = runner.invoke(app, ["labs", "maintain", "contradictions"]) assert result.exit_code == 0 assert "No contradictions" in result.output @@ -386,7 +386,7 @@ def test_contradictions_found_displayed(self) -> None: instance._check_nli_available.return_value = False instance.find_contradictions.return_value = [mock_result] - result = runner.invoke(app, ["maintain", "contradictions"]) + result = runner.invoke(app, ["labs", "maintain", "contradictions"]) assert result.exit_code == 0 # Strip ANSI escape codes for assertion plain = re.sub(r"\x1b\[[0-9;]*m", "", result.output) @@ -417,7 +417,7 @@ def test_json_output_format(self) -> None: instance._check_nli_available.return_value = False instance.find_contradictions.return_value = [mock_result] - result = runner.invoke(app, ["maintain", "contradictions", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "contradictions", "--json"]) assert result.exit_code == 0 # Verify the mock was called correctly instance.find_contradictions.assert_called_once() diff --git a/tests/test_entities_json.py b/tests/test_entities_json.py index 8e9414cf..c2aed3e9 100644 --- a/tests/test_entities_json.py +++ b/tests/test_entities_json.py @@ -53,7 +53,7 @@ def test_json_extract_and_link(self, clean_entity_db: Any) -> None: "## Authentication\n\nThe `auth_handler` manages tokens.\n", ) - result = runner.invoke(app, ["maintain", "entities", "--json", "1"]) + result = runner.invoke(app, ["labs", "maintain", "entities", "--json", "1"]) assert result.exit_code == 0 data = json.loads(result.output) @@ -72,7 +72,7 @@ def test_json_extract_only(self, clean_entity_db: Any) -> None: "## Important Concept\n\nUse `some_function` here.\n", ) - result = runner.invoke(app, ["maintain", "entities", "--no-wikify", "--json", "1"]) + result = runner.invoke(app, ["labs", "maintain", "entities", "--no-wikify", "--json", "1"]) assert result.exit_code == 0 data = json.loads(result.output) @@ -98,7 +98,7 @@ def test_json_all_extract_and_link(self, clean_entity_db: Any) -> None: "## Session Management\n\nUses `auth_handler` for auth.\n", ) - result = runner.invoke(app, ["maintain", "entities", "--all", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "entities", "--all", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) @@ -116,7 +116,9 @@ def test_json_all_extract_only(self, clean_entity_db: Any) -> None: "## Some Heading\n\nContent with `code_term`.\n", ) - result = runner.invoke(app, ["maintain", "entities", "--all", "--no-wikify", "--json"]) + result = runner.invoke( + app, ["labs", "maintain", "entities", "--all", "--no-wikify", "--json"] + ) assert result.exit_code == 0 data = json.loads(result.output) @@ -130,7 +132,7 @@ class TestEntitiesJsonErrors: def test_json_no_doc_id_no_all(self, clean_entity_db: Any) -> None: """Missing doc ID and --all with --json outputs error JSON.""" - result = runner.invoke(app, ["maintain", "entities", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "entities", "--json"]) data = json.loads(result.output) assert "error" in data diff --git a/tests/test_find_wander.py b/tests/test_find_wander.py index 0c342a8f..ae9b063a 100644 --- a/tests/test_find_wander.py +++ b/tests/test_find_wander.py @@ -82,7 +82,7 @@ class TestFindWander: ) def test_wander_too_few_documents(self, mock_es_class: Any, capsys: Any) -> None: """Should show helpful message when <10 docs have embeddings.""" - from emdx.commands.core import _find_wander + from emdx.commands.labs_ask import _run_wander as _find_wander mock_service = _make_service_mock(stats_indexed=5) mock_es_class.return_value = mock_service @@ -100,7 +100,7 @@ def test_wander_too_few_documents(self, mock_es_class: Any, capsys: Any) -> None ) def test_wander_too_few_documents_json(self, mock_es_class: Any, capsys: Any) -> None: """JSON mode should also report the error.""" - from emdx.commands.core import _find_wander + from emdx.commands.labs_ask import _run_wander as _find_wander mock_service = _make_service_mock(stats_indexed=3) mock_es_class.return_value = mock_service @@ -121,7 +121,7 @@ def test_wander_with_query_seed( self, mock_es_class: Any, mock_db: Any, capsys: Any, seed_embedding: Any ) -> None: """--wander with a query uses the query as embedding seed.""" - from emdx.commands.core import _find_wander + from emdx.commands.labs_ask import _run_wander as _find_wander mock_service = _make_service_mock() mock_es_class.return_value = mock_service @@ -198,7 +198,7 @@ def test_wander_without_query_picks_random_seed( seed_embedding: Any, ) -> None: """--wander without query picks a random recent doc as seed.""" - from emdx.commands.core import _find_wander + from emdx.commands.labs_ask import _run_wander as _find_wander mock_service = _make_service_mock() mock_es_class.return_value = mock_service @@ -259,7 +259,7 @@ def test_wander_no_goldilocks_results( self, mock_es_class: Any, mock_db: Any, capsys: Any, seed_embedding: Any ) -> None: """Should show message when no docs in Goldilocks band.""" - from emdx.commands.core import _find_wander + from emdx.commands.labs_ask import _run_wander as _find_wander mock_service = _make_service_mock() mock_es_class.return_value = mock_service @@ -303,7 +303,7 @@ def test_wander_respects_limit( self, mock_es_class: Any, mock_db: Any, capsys: Any, seed_embedding: Any ) -> None: """--wander should respect --limit but cap at 5.""" - from emdx.commands.core import _find_wander + from emdx.commands.labs_ask import _run_wander as _find_wander mock_service = _make_service_mock() mock_es_class.return_value = mock_service @@ -349,7 +349,7 @@ def test_wander_caps_at_five( self, mock_es_class: Any, mock_db: Any, capsys: Any, seed_embedding: Any ) -> None: """--wander should cap results at 5 even with higher limit.""" - from emdx.commands.core import _find_wander + from emdx.commands.labs_ask import _run_wander as _find_wander mock_service = _make_service_mock() mock_es_class.return_value = mock_service @@ -393,7 +393,7 @@ def test_wander_project_filter( self, mock_es_class: Any, mock_db: Any, capsys: Any, seed_embedding: Any ) -> None: """--wander with --project should filter by project.""" - from emdx.commands.core import _find_wander + from emdx.commands.labs_ask import _run_wander as _find_wander mock_service = _make_service_mock() mock_es_class.return_value = mock_service @@ -429,7 +429,7 @@ def test_wander_human_readable_output( self, mock_es_class: Any, mock_db: Any, capsys: Any, seed_embedding: Any ) -> None: """Human-readable output includes title, similarity, snippet.""" - from emdx.commands.core import _find_wander + from emdx.commands.labs_ask import _run_wander as _find_wander mock_service = _make_service_mock() mock_es_class.return_value = mock_service @@ -474,7 +474,7 @@ def test_wander_no_recent_docs_without_query( self, mock_es_class: Any, mock_db: Any, capsys: Any ) -> None: """Without query, if no recent docs have embeddings, show error.""" - from emdx.commands.core import _find_wander + from emdx.commands.labs_ask import _run_wander as _find_wander mock_service = _make_service_mock() mock_es_class.return_value = mock_service @@ -499,7 +499,7 @@ def test_wander_excludes_seed_doc( self, mock_es_class: Any, mock_db: Any, capsys: Any, seed_embedding: Any ) -> None: """The seed document itself should be excluded from results.""" - from emdx.commands.core import _find_wander + from emdx.commands.labs_ask import _run_wander as _find_wander mock_service = _make_service_mock() mock_es_class.return_value = mock_service diff --git a/tests/test_gaps.py b/tests/test_gaps.py index aef95ba1..ab3155fb 100644 --- a/tests/test_gaps.py +++ b/tests/test_gaps.py @@ -451,7 +451,7 @@ class TestGapsCLI: def test_gaps_help(self) -> None: """Gaps command shows help.""" - result = runner.invoke(app, ["maintain", "gaps", "--help"]) + result = runner.invoke(app, ["labs", "maintain", "gaps", "--help"]) assert result.exit_code == 0 plain = _strip_ansi(result.output) assert "gaps" in plain.lower() @@ -459,7 +459,7 @@ def test_gaps_help(self) -> None: def test_gaps_no_results(self) -> None: """No gaps shows friendly message.""" - result = runner.invoke(app, ["maintain", "gaps"]) + result = runner.invoke(app, ["labs", "maintain", "gaps"]) assert result.exit_code == 0 assert "No knowledge gaps detected" in result.output @@ -468,7 +468,7 @@ def test_gaps_with_orphan_doc(self) -> None: with db.get_connection() as conn: _create_doc(conn, "Isolated Document") - result = runner.invoke(app, ["maintain", "gaps"]) + result = runner.invoke(app, ["labs", "maintain", "gaps"]) assert result.exit_code == 0 assert "Isolated Document" in result.output assert "Orphan Documents" in result.output @@ -479,13 +479,13 @@ def test_gaps_custom_top(self) -> None: for i in range(5): _create_doc(conn, f"Orphan {i}") - result = runner.invoke(app, ["maintain", "gaps", "--top", "2"]) + result = runner.invoke(app, ["labs", "maintain", "gaps", "--top", "2"]) assert result.exit_code == 0 assert "Orphan Documents" in result.output def test_gaps_json_output(self) -> None: """JSON output produces valid JSON.""" - result = runner.invoke(app, ["maintain", "gaps", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "gaps", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert "tag_gaps" in data @@ -499,7 +499,7 @@ def test_gaps_json_with_data(self) -> None: with db.get_connection() as conn: _create_doc(conn, "JSON Test Doc") - result = runner.invoke(app, ["maintain", "gaps", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "gaps", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert len(data["orphan_docs"]) >= 1 @@ -513,13 +513,13 @@ def test_gaps_stale_days_option(self) -> None: _add_tag(conn, doc_id, "semi-stale") # Default 60 days -- not stale - result = runner.invoke(app, ["maintain", "gaps"]) + result = runner.invoke(app, ["labs", "maintain", "gaps"]) assert "Stale Topics" not in result.output # 30-day threshold -- stale result = runner.invoke( app, - ["maintain", "gaps", "--stale-days", "30"], + ["labs", "maintain", "gaps", "--stale-days", "30"], ) assert "Stale Topics" in result.output assert "semi-stale" in result.output diff --git a/tests/test_intelligence_integration.py b/tests/test_intelligence_integration.py index 4fd6e946..3b07659e 100644 --- a/tests/test_intelligence_integration.py +++ b/tests/test_intelligence_integration.py @@ -159,7 +159,7 @@ def test_lifecycle_drift_after_seeding(self) -> None: ) # Step 3: Run drift analysis - result = runner.invoke(app, ["maintain", "drift"]) + result = runner.invoke(app, ["labs", "maintain", "drift"]) assert result.exit_code == 0 out = _strip_ansi(result.stdout) assert "Lifecycle Epic" in out @@ -170,7 +170,7 @@ def test_lifecycle_drift_then_vitals(self) -> None: _seed_docs(8) # Drift should find no issues (no stale epics) - drift_result = runner.invoke(app, ["maintain", "drift"]) + drift_result = runner.invoke(app, ["labs", "maintain", "drift"]) assert drift_result.exit_code == 0 assert "No drift detected" in drift_result.stdout @@ -211,7 +211,7 @@ def test_prime_brief_empty(self) -> None: def test_drift_empty(self) -> None: """maintain drift with no tasks shows 'No drift detected'.""" - result = runner.invoke(app, ["maintain", "drift"]) + result = runner.invoke(app, ["labs", "maintain", "drift"]) assert result.exit_code == 0 assert "No drift detected" in result.stdout @@ -241,13 +241,13 @@ def test_code_drift_empty( mock_has_tool.return_value = True mock_git_repo.return_value = True - result = runner.invoke(app, ["maintain", "code-drift"]) + result = runner.invoke(app, ["labs", "maintain", "code-drift"]) assert result.exit_code == 0 assert "No documents to check" in _strip_ansi(result.stdout) def test_drift_json_empty(self) -> None: """maintain drift --json with empty KB returns valid empty JSON.""" - result = runner.invoke(app, ["maintain", "drift", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "drift", "--json"]) assert result.exit_code == 0 data = json.loads(result.stdout) assert data["stale_epics"] == [] @@ -266,7 +266,7 @@ class TestJsonOutput: def test_drift_json_structure(self) -> None: """maintain drift --json has all expected keys.""" - result = runner.invoke(app, ["maintain", "drift", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "drift", "--json"]) assert result.exit_code == 0 data = json.loads(result.stdout) assert "stale_epics" in data @@ -286,7 +286,7 @@ def test_drift_json_with_data(self) -> None: days_ago=45, ) - result = runner.invoke(app, ["maintain", "drift", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "drift", "--json"]) assert result.exit_code == 0 data = json.loads(result.stdout) assert len(data["stale_epics"]) == 1 @@ -358,7 +358,7 @@ def test_code_drift_json_structure( mock_git_repo.return_value = False mock_search.return_value = False - result = runner.invoke(app, ["maintain", "code-drift", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "code-drift", "--json"]) assert result.exit_code == 0 data = json.loads(result.stdout) assert "total_docs_scanned" in data @@ -418,11 +418,11 @@ def test_drift_custom_days(self) -> None: ) # Default 30 days -- not stale - result_30 = runner.invoke(app, ["maintain", "drift"]) + result_30 = runner.invoke(app, ["labs", "maintain", "drift"]) assert "No drift detected" in result_30.stdout # 7 days threshold -- stale - result_7 = runner.invoke(app, ["maintain", "drift", "--days", "7"]) + result_7 = runner.invoke(app, ["labs", "maintain", "drift", "--days", "7"]) assert "Semi-Stale" in result_7.stdout @@ -572,8 +572,8 @@ def test_wander_no_embeddings_library( self, mock_embed_cls: MagicMock, ) -> None: - """--wander without sentence-transformers exits with error.""" - result = runner.invoke(app, ["find", "--wander", "test"]) + """labs wander without sentence-transformers exits with error.""" + result = runner.invoke(app, ["labs", "wander", "test"]) assert result.exit_code != 0 @patch("emdx.services.embedding_service.EmbeddingService", autospec=True) @@ -581,7 +581,7 @@ def test_wander_too_few_indexed_docs( self, mock_embed_cls: Any, ) -> None: - """--wander with <10 indexed docs shows helpful message.""" + """labs wander with <10 indexed docs shows helpful message.""" from emdx.services.embedding_service import EmbeddingStats mock_service = MagicMock() @@ -594,7 +594,7 @@ def test_wander_too_few_indexed_docs( ) mock_embed_cls.return_value = mock_service - result = runner.invoke(app, ["find", "--wander"]) + result = runner.invoke(app, ["labs", "wander"]) assert result.exit_code == 0 out = _strip_ansi(result.stdout) assert "Serendipity works better" in out @@ -605,7 +605,7 @@ def test_wander_too_few_indexed_docs_json( self, mock_embed_cls: Any, ) -> None: - """--wander --json with too few docs returns JSON error object.""" + """labs wander --json with too few docs returns JSON error object.""" from emdx.services.embedding_service import EmbeddingStats mock_service = MagicMock() @@ -618,7 +618,7 @@ def test_wander_too_few_indexed_docs_json( ) mock_embed_cls.return_value = mock_service - result = runner.invoke(app, ["find", "--wander", "--json"]) + result = runner.invoke(app, ["labs", "wander", "--json"]) assert result.exit_code == 0 data = json.loads(result.stdout) assert "error" in data @@ -694,7 +694,7 @@ def test_detects_stale_references( mock_git_repo.return_value = False mock_search.return_value = False - result = runner.invoke(app, ["maintain", "code-drift"]) + result = runner.invoke(app, ["labs", "maintain", "code-drift"]) assert result.exit_code == 0 out = _strip_ansi(result.stdout) assert "OldModule" in out @@ -719,7 +719,7 @@ def test_clean_codebase( mock_git_repo.return_value = True mock_search.return_value = True - result = runner.invoke(app, ["maintain", "code-drift"]) + result = runner.invoke(app, ["labs", "maintain", "code-drift"]) assert result.exit_code == 0 out = _strip_ansi(result.stdout) assert "All code references look current" in out @@ -743,7 +743,7 @@ def test_code_drift_json_with_stale_refs( mock_git_repo.return_value = False mock_search.return_value = False - result = runner.invoke(app, ["maintain", "code-drift", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "code-drift", "--json"]) assert result.exit_code == 0 data = json.loads(result.stdout) assert data["total_docs_scanned"] == 1 @@ -892,7 +892,7 @@ def test_orphaned_active_task_detected(self) -> None: days_ago=20, ) - result = runner.invoke(app, ["maintain", "drift"]) + result = runner.invoke(app, ["labs", "maintain", "drift"]) assert result.exit_code == 0 out = result.stdout assert "Abandoned active task" in out @@ -909,7 +909,7 @@ def test_burst_epic_detected(self) -> None: days_ago=45, ) - result = runner.invoke(app, ["maintain", "drift"]) + result = runner.invoke(app, ["labs", "maintain", "drift"]) assert result.exit_code == 0 out = result.stdout assert "Burst Epic" in out @@ -935,7 +935,7 @@ def test_stale_linked_doc_detected(self) -> None: source_doc_id=doc_id, ) - result = runner.invoke(app, ["maintain", "drift"]) + result = runner.invoke(app, ["labs", "maintain", "drift"]) assert result.exit_code == 0 out = result.stdout assert "Stale Source Doc" in out @@ -960,7 +960,7 @@ def test_multiple_drift_types_together(self) -> None: days_ago=20, ) - result = runner.invoke(app, ["maintain", "drift", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "drift", "--json"]) assert result.exit_code == 0 data = json.loads(result.stdout) assert len(data["stale_epics"]) >= 1 @@ -1019,13 +1019,13 @@ class TestHelpText: def test_drift_help(self) -> None: """maintain drift --help shows usage.""" - result = runner.invoke(app, ["maintain", "drift", "--help"]) + result = runner.invoke(app, ["labs", "maintain", "drift", "--help"]) assert result.exit_code == 0 assert "--days" in _strip_ansi(result.stdout) def test_code_drift_help(self) -> None: """maintain code-drift --help shows usage.""" - result = runner.invoke(app, ["maintain", "code-drift", "--help"]) + result = runner.invoke(app, ["labs", "maintain", "code-drift", "--help"]) assert result.exit_code == 0 out = _strip_ansi(result.stdout) assert "--json" in out diff --git a/tests/test_lazy_loading.py b/tests/test_lazy_loading.py index 94679aa2..78e02e1a 100644 --- a/tests/test_lazy_loading.py +++ b/tests/test_lazy_loading.py @@ -255,13 +255,19 @@ def test_lazy_commands_appear_in_help(self) -> None: def test_lazy_help_text_in_output(self) -> None: """Test that lazy commands show their pre-defined help text.""" + import importlib + + import emdx.main + + importlib.reload(emdx.main) from emdx.main import app result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 - # Check that our lazy help text appears (not the actual module help) - assert "Explore what your knowledge base knows" in result.output + # Check that lazy commands appear in help + assert "maintain" in result.output + assert "labs" in result.output def test_core_commands_still_work(self) -> None: """Test that core (eager) commands still work.""" diff --git a/tests/test_maintain_drift.py b/tests/test_maintain_drift.py index a9a1f1ad..85c0a68a 100644 --- a/tests/test_maintain_drift.py +++ b/tests/test_maintain_drift.py @@ -404,7 +404,7 @@ def test_drift_help(self) -> None: """Drift command shows help.""" import re - result = runner.invoke(app, ["maintain", "drift", "--help"]) + result = runner.invoke(app, ["labs", "maintain", "drift", "--help"]) assert result.exit_code == 0 plain = re.sub(r"\x1b\[[0-9;]*m", "", result.output) assert "drift" in plain.lower() @@ -412,7 +412,7 @@ def test_drift_help(self) -> None: def test_drift_no_results(self) -> None: """No drift shows friendly message.""" - result = runner.invoke(app, ["maintain", "drift"]) + result = runner.invoke(app, ["labs", "maintain", "drift"]) assert result.exit_code == 0 assert "No drift detected" in result.output @@ -427,7 +427,7 @@ def test_drift_with_stale_epic(self) -> None: days_ago=45, ) - result = runner.invoke(app, ["maintain", "drift"]) + result = runner.invoke(app, ["labs", "maintain", "drift"]) assert result.exit_code == 0 assert "Forgotten Epic" in result.output assert "Stale Epics" in result.output @@ -444,16 +444,16 @@ def test_drift_custom_days(self) -> None: ) # Default 30 days -- not stale - result = runner.invoke(app, ["maintain", "drift"]) + result = runner.invoke(app, ["labs", "maintain", "drift"]) assert "No drift detected" in result.output # 7 days threshold -- stale - result = runner.invoke(app, ["maintain", "drift", "--days", "7"]) + result = runner.invoke(app, ["labs", "maintain", "drift", "--days", "7"]) assert "Semi-Stale Epic" in result.output def test_drift_json_output(self) -> None: """JSON output produces valid JSON.""" - result = runner.invoke(app, ["maintain", "drift", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "drift", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert "stale_epics" in data @@ -472,7 +472,7 @@ def test_drift_json_with_data(self) -> None: days_ago=45, ) - result = runner.invoke(app, ["maintain", "drift", "--json"]) + result = runner.invoke(app, ["labs", "maintain", "drift", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert len(data["stale_epics"]) == 1 diff --git a/tests/test_wiki_coverage.py b/tests/test_wiki_coverage.py index 662560cd..9332bc9b 100644 --- a/tests/test_wiki_coverage.py +++ b/tests/test_wiki_coverage.py @@ -104,13 +104,13 @@ class TestWikiCoverageEmpty: def test_coverage_empty_db(self, clean_wiki_db: Any) -> None: """Empty database shows 100% coverage (0/0).""" - result = runner.invoke(app, ["maintain", "wiki", "coverage"]) + result = runner.invoke(app, ["labs", "wiki", "coverage"]) assert result.exit_code == 0 assert "All user documents are covered" in result.output def test_coverage_empty_json(self, clean_wiki_db: Any) -> None: """Empty database JSON output has correct structure.""" - result = runner.invoke(app, ["maintain", "wiki", "coverage", "--json"]) + result = runner.invoke(app, ["labs", "wiki", "coverage", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["total_docs"] == 0 @@ -131,7 +131,7 @@ def test_all_covered(self, clean_wiki_db: Any) -> None: _add_member(1, 1) _add_member(1, 2) - result = runner.invoke(app, ["maintain", "wiki", "coverage"]) + result = runner.invoke(app, ["labs", "wiki", "coverage"]) assert result.exit_code == 0 assert "All user documents are covered" in result.output @@ -142,7 +142,7 @@ def test_some_uncovered(self, clean_wiki_db: Any) -> None: _create_topic(1, "Topic 1") _add_member(1, 1) - result = runner.invoke(app, ["maintain", "wiki", "coverage"]) + result = runner.invoke(app, ["labs", "wiki", "coverage"]) assert result.exit_code == 0 output = _strip_ansi(result.output) assert "Uncovered: 1" in output @@ -154,7 +154,7 @@ def test_none_covered(self, clean_wiki_db: Any) -> None: _create_user_doc(2, "Doc B") _create_user_doc(3, "Doc C") - result = runner.invoke(app, ["maintain", "wiki", "coverage"]) + result = runner.invoke(app, ["labs", "wiki", "coverage"]) assert result.exit_code == 0 output = _strip_ansi(result.output) assert "Uncovered: 3" in output @@ -165,7 +165,7 @@ def test_excludes_wiki_docs(self, clean_wiki_db: Any) -> None: _create_user_doc(1, "User Doc") _create_wiki_doc(2, "Wiki Article") - result = runner.invoke(app, ["maintain", "wiki", "coverage", "--json"]) + result = runner.invoke(app, ["labs", "wiki", "coverage", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["total_docs"] == 1 # Only user doc counted @@ -175,7 +175,7 @@ def test_excludes_deleted_docs(self, clean_wiki_db: Any) -> None: _create_user_doc(1, "Active Doc") _create_deleted_doc(2, "Deleted Doc") - result = runner.invoke(app, ["maintain", "wiki", "coverage", "--json"]) + result = runner.invoke(app, ["labs", "wiki", "coverage", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["total_docs"] == 1 # Only non-deleted counted @@ -187,7 +187,7 @@ def test_json_output_structure(self, clean_wiki_db: Any) -> None: _create_topic(1, "Topic 1") _add_member(1, 1) - result = runner.invoke(app, ["maintain", "wiki", "coverage", "--json"]) + result = runner.invoke(app, ["labs", "wiki", "coverage", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) @@ -204,7 +204,7 @@ def test_limit_option(self, clean_wiki_db: Any) -> None: for i in range(1, 6): _create_user_doc(i, f"Doc {i}") - result = runner.invoke(app, ["maintain", "wiki", "coverage", "--limit", "2"]) + result = runner.invoke(app, ["labs", "wiki", "coverage", "--limit", "2"]) assert result.exit_code == 0 output = _strip_ansi(result.output) assert "and 3 more" in output @@ -214,7 +214,7 @@ def test_limit_json(self, clean_wiki_db: Any) -> None: for i in range(1, 6): _create_user_doc(i, f"Doc {i}") - result = runner.invoke(app, ["maintain", "wiki", "coverage", "--json", "--limit", "2"]) + result = runner.invoke(app, ["labs", "wiki", "coverage", "--json", "--limit", "2"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["uncovered_docs"] == 5 # Total count still correct @@ -229,7 +229,7 @@ def test_doc_in_multiple_topics_counted_once(self, clean_wiki_db: Any) -> None: _add_member(1, 1) _add_member(2, 1) - result = runner.invoke(app, ["maintain", "wiki", "coverage", "--json"]) + result = runner.invoke(app, ["labs", "wiki", "coverage", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["covered_docs"] == 1 @@ -241,6 +241,6 @@ class TestWikiCoverageHelp: def test_help(self) -> None: """Help text shows expected content.""" - result = runner.invoke(app, ["maintain", "wiki", "coverage", "--help"]) + result = runner.invoke(app, ["labs", "wiki", "coverage", "--help"]) assert result.exit_code == 0 assert "coverage" in result.output.lower() diff --git a/tests/test_wiki_editorial_prompt.py b/tests/test_wiki_editorial_prompt.py index 0836c331..e5c93ed7 100644 --- a/tests/test_wiki_editorial_prompt.py +++ b/tests/test_wiki_editorial_prompt.py @@ -89,7 +89,7 @@ def _setup(self) -> Generator[None, None, None]: conn.commit() def test_set_editorial_prompt(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "prompt", "70", "Focus on security"]) + result = runner.invoke(app, ["labs", "wiki", "prompt", "70", "Focus on security"]) assert result.exit_code == 0 assert "Set editorial prompt" in result.output @@ -103,7 +103,7 @@ def test_clear_editorial_prompt(self) -> None: conn.execute("UPDATE wiki_topics SET editorial_prompt = 'old prompt' WHERE id = 70") conn.commit() - result = runner.invoke(app, ["maintain", "wiki", "prompt", "70", "--clear"]) + result = runner.invoke(app, ["labs", "wiki", "prompt", "70", "--clear"]) assert result.exit_code == 0 assert "Cleared editorial prompt" in result.output @@ -116,27 +116,27 @@ def test_show_editorial_prompt(self) -> None: conn.execute("UPDATE wiki_topics SET editorial_prompt = 'my prompt' WHERE id = 70") conn.commit() - result = runner.invoke(app, ["maintain", "wiki", "prompt", "70"]) + result = runner.invoke(app, ["labs", "wiki", "prompt", "70"]) assert result.exit_code == 0 assert "my prompt" in result.output def test_show_no_prompt(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "prompt", "70"]) + result = runner.invoke(app, ["labs", "wiki", "prompt", "70"]) assert result.exit_code == 0 assert "no editorial prompt set" in result.output def test_nonexistent_topic(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "prompt", "999", "some text"]) + result = runner.invoke(app, ["labs", "wiki", "prompt", "999", "some text"]) assert result.exit_code == 1 assert "not found" in result.output def test_clear_nonexistent_topic(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "prompt", "999", "--clear"]) + result = runner.invoke(app, ["labs", "wiki", "prompt", "999", "--clear"]) assert result.exit_code == 1 assert "not found" in result.output def test_clear_with_text_errors(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "prompt", "70", "text", "--clear"]) + result = runner.invoke(app, ["labs", "wiki", "prompt", "70", "text", "--clear"]) assert result.exit_code == 1 assert "Cannot use --clear" in result.output @@ -266,12 +266,12 @@ def _setup(self) -> Generator[None, None, None]: conn.commit() def test_verbose_shows_editorial_prompt_column(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "topics", "--verbose"]) + result = runner.invoke(app, ["labs", "wiki", "topics", "--verbose"]) assert result.exit_code == 0 assert "Editorial Prompt" in result.output def test_verbose_shows_prompt_text(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "topics", "--verbose"]) + result = runner.invoke(app, ["labs", "wiki", "topics", "--verbose"]) assert result.exit_code == 0 assert "Be concise" in result.output @@ -279,6 +279,6 @@ def test_verbose_shows_dash_for_no_prompt(self) -> None: with db.get_connection() as conn: conn.execute("UPDATE wiki_topics SET editorial_prompt = NULL WHERE id = 85") conn.commit() - result = runner.invoke(app, ["maintain", "wiki", "topics", "--verbose"]) + result = runner.invoke(app, ["labs", "wiki", "topics", "--verbose"]) assert result.exit_code == 0 assert "-" in result.output diff --git a/tests/test_wiki_export.py b/tests/test_wiki_export.py index 3d05bb28..9e94a0d3 100644 --- a/tests/test_wiki_export.py +++ b/tests/test_wiki_export.py @@ -481,7 +481,7 @@ def test_export_command_basic(self, clean_wiki_db: Any, tmp_path: Path) -> None: _setup_full_article(conn, 1, "auth", "Auth", "# Auth") out = str(tmp_path / "wiki-site") - result = runner.invoke(app, ["maintain", "wiki", "export", out]) + result = runner.invoke(app, ["labs", "wiki", "export", out]) assert result.exit_code == 0 assert "Articles: 1" in result.output assert "mkdocs.yml: yes" in result.output @@ -489,7 +489,7 @@ def test_export_command_basic(self, clean_wiki_db: Any, tmp_path: Path) -> None: def test_export_command_custom_name(self, clean_wiki_db: Any, tmp_path: Path) -> None: """CLI export with custom site name.""" out = str(tmp_path / "wiki-site") - result = runner.invoke(app, ["maintain", "wiki", "export", out, "-n", "Test Wiki"]) + result = runner.invoke(app, ["labs", "wiki", "export", out, "-n", "Test Wiki"]) assert result.exit_code == 0 config = yaml.safe_load((tmp_path / "wiki-site" / "mkdocs.yml").read_text()) assert config["site_name"] == "Test Wiki" @@ -498,7 +498,7 @@ def test_export_help(self) -> None: """Help text shows expected content.""" import re - result = runner.invoke(app, ["maintain", "wiki", "export", "--help"]) + result = runner.invoke(app, ["labs", "wiki", "export", "--help"]) assert result.exit_code == 0 # Strip ANSI escape codes before checking content clean = re.sub(r"\x1b\[[0-9;]*m", "", result.output) @@ -513,7 +513,7 @@ def test_export_command_topic_flag(self, clean_wiki_db: Any, tmp_path: Path) -> _setup_full_article(conn, 2, "database", "Database", "# Database") out = str(tmp_path / "wiki-site") - result = runner.invoke(app, ["maintain", "wiki", "export", out, "--topic", "1"]) + result = runner.invoke(app, ["labs", "wiki", "export", out, "--topic", "1"]) assert result.exit_code == 0 assert "Articles: 1" in result.output assert "mkdocs.yml: no" in result.output @@ -524,6 +524,6 @@ def test_export_build_no_mkdocs(self, clean_wiki_db: Any, tmp_path: Path) -> Non """--build fails gracefully when mkdocs is not installed.""" out = str(tmp_path / "wiki-site") with patch("shutil.which", return_value=None): - result = runner.invoke(app, ["maintain", "wiki", "export", out, "--build"]) + result = runner.invoke(app, ["labs", "wiki", "export", out, "--build"]) assert result.exit_code == 1 assert "mkdocs not found" in result.output diff --git a/tests/test_wiki_model_override.py b/tests/test_wiki_model_override.py index 676e0d21..971dfc03 100644 --- a/tests/test_wiki_model_override.py +++ b/tests/test_wiki_model_override.py @@ -85,7 +85,7 @@ def _setup(self) -> Generator[None, None, None]: conn.commit() def test_set_model_override(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "model", "70", "claude-opus-4-5-20250514"]) + result = runner.invoke(app, ["labs", "wiki", "model", "70", "claude-opus-4-5-20250514"]) assert result.exit_code == 0 assert "claude-opus-4-5-20250514" in result.output assert "Set model override" in result.output @@ -102,7 +102,7 @@ def test_clear_model_override(self) -> None: ) conn.commit() - result = runner.invoke(app, ["maintain", "wiki", "model", "70", "--clear"]) + result = runner.invoke(app, ["labs", "wiki", "model", "70", "--clear"]) assert result.exit_code == 0 assert "Cleared model override" in result.output @@ -111,17 +111,17 @@ def test_clear_model_override(self) -> None: assert row[0] is None def test_model_requires_name_or_clear(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "model", "70"]) + result = runner.invoke(app, ["labs", "wiki", "model", "70"]) assert result.exit_code == 1 assert "Provide a model name or use --clear" in result.output def test_model_rejects_both_name_and_clear(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "model", "70", "some-model", "--clear"]) + result = runner.invoke(app, ["labs", "wiki", "model", "70", "some-model", "--clear"]) assert result.exit_code == 1 assert "Cannot use both" in result.output def test_model_nonexistent_topic(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "model", "999", "some-model"]) + result = runner.invoke(app, ["labs", "wiki", "model", "999", "some-model"]) assert result.exit_code == 1 assert "not found" in result.output diff --git a/tests/test_wiki_progress.py b/tests/test_wiki_progress.py index 57db20ec..d8120dea 100644 --- a/tests/test_wiki_progress.py +++ b/tests/test_wiki_progress.py @@ -83,14 +83,14 @@ class TestWikiProgressEmpty: def test_progress_empty_db(self, clean_wiki_db: Any) -> None: """Empty database shows 0/0 with 0% progress.""" - result = runner.invoke(app, ["maintain", "wiki", "progress"]) + result = runner.invoke(app, ["labs", "wiki", "progress"]) assert result.exit_code == 0 output = _strip_ansi(result.output) assert "0/0 generated" in output def test_progress_empty_json(self, clean_wiki_db: Any) -> None: """Empty database JSON output has correct structure.""" - result = runner.invoke(app, ["maintain", "wiki", "progress", "--json"]) + result = runner.invoke(app, ["labs", "wiki", "progress", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["total_topics"] == 0 @@ -112,7 +112,7 @@ def test_all_generated(self, clean_wiki_db: Any) -> None: _create_article(1, 1, 101) _create_article(2, 2, 102) - result = runner.invoke(app, ["maintain", "wiki", "progress"]) + result = runner.invoke(app, ["labs", "wiki", "progress"]) assert result.exit_code == 0 output = _strip_ansi(result.output) assert "2/2 generated" in output @@ -126,7 +126,7 @@ def test_partial_progress(self, clean_wiki_db: Any) -> None: _create_topic(3, "Topic C") _create_article(1, 1, 101, cost_usd=0.25) - result = runner.invoke(app, ["maintain", "wiki", "progress"]) + result = runner.invoke(app, ["labs", "wiki", "progress"]) assert result.exit_code == 0 output = _strip_ansi(result.output) assert "1/3 generated" in output @@ -140,7 +140,7 @@ def test_skipped_topics(self, clean_wiki_db: Any) -> None: _create_topic(3, "Topic C", status="skipped") _create_article(1, 1, 101) - result = runner.invoke(app, ["maintain", "wiki", "progress"]) + result = runner.invoke(app, ["labs", "wiki", "progress"]) assert result.exit_code == 0 output = _strip_ansi(result.output) assert "1/3 generated" in output @@ -154,7 +154,7 @@ def test_cost_display(self, clean_wiki_db: Any) -> None: _create_topic(3, "Topic C") _create_article(1, 1, 101, cost_usd=0.50, input_tokens=5000, output_tokens=2000) - result = runner.invoke(app, ["maintain", "wiki", "progress"]) + result = runner.invoke(app, ["labs", "wiki", "progress"]) assert result.exit_code == 0 output = _strip_ansi(result.output) assert "$0.50" in output @@ -166,7 +166,7 @@ def test_no_est_remaining_when_all_done(self, clean_wiki_db: Any) -> None: _create_topic(1, "Topic A") _create_article(1, 1, 101, cost_usd=0.25) - result = runner.invoke(app, ["maintain", "wiki", "progress"]) + result = runner.invoke(app, ["labs", "wiki", "progress"]) assert result.exit_code == 0 output = _strip_ansi(result.output) assert "Est. remaining" not in output @@ -178,7 +178,7 @@ def test_json_output_structure(self, clean_wiki_db: Any) -> None: _create_topic(3, "Topic C", status="skipped") _create_article(1, 1, 101, cost_usd=0.30, input_tokens=3000, output_tokens=1500) - result = runner.invoke(app, ["maintain", "wiki", "progress", "--json"]) + result = runner.invoke(app, ["labs", "wiki", "progress", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) @@ -198,7 +198,7 @@ def test_tokens_display(self, clean_wiki_db: Any) -> None: _create_topic(1, "Topic A") _create_article(1, 1, 101, input_tokens=10000, output_tokens=5000) - result = runner.invoke(app, ["maintain", "wiki", "progress"]) + result = runner.invoke(app, ["labs", "wiki", "progress"]) assert result.exit_code == 0 output = _strip_ansi(result.output) assert "10,000 in" in output @@ -210,7 +210,7 @@ def test_progress_bar_present(self, clean_wiki_db: Any) -> None: _create_topic(2, "Topic B") _create_article(1, 1, 101) - result = runner.invoke(app, ["maintain", "wiki", "progress"]) + result = runner.invoke(app, ["labs", "wiki", "progress"]) assert result.exit_code == 0 output = _strip_ansi(result.output) # Progress bar uses block chars @@ -222,6 +222,6 @@ class TestWikiProgressHelp: def test_help(self) -> None: """Help text shows expected content.""" - result = runner.invoke(app, ["maintain", "wiki", "progress", "--help"]) + result = runner.invoke(app, ["labs", "wiki", "progress", "--help"]) assert result.exit_code == 0 assert "progress" in result.output.lower() diff --git a/tests/test_wiki_rating.py b/tests/test_wiki_rating.py index d0b64d80..bb5dafe7 100644 --- a/tests/test_wiki_rating.py +++ b/tests/test_wiki_rating.py @@ -92,7 +92,7 @@ def _setup(self) -> Generator[None, None, None]: conn.commit() def test_rate_with_numeric_value(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rate", "50", "4"]) + result = runner.invoke(app, ["labs", "wiki", "rate", "50", "4"]) assert result.exit_code == 0 assert "4/5" in result.output @@ -104,7 +104,7 @@ def test_rate_with_numeric_value(self) -> None: assert row[1] is not None def test_rate_thumbs_up(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rate", "50", "--up"]) + result = runner.invoke(app, ["labs", "wiki", "rate", "50", "--up"]) assert result.exit_code == 0 assert "4/5" in result.output @@ -113,7 +113,7 @@ def test_rate_thumbs_up(self) -> None: assert row[0] == 4 def test_rate_thumbs_down(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rate", "50", "--down"]) + result = runner.invoke(app, ["labs", "wiki", "rate", "50", "--down"]) assert result.exit_code == 0 assert "2/5" in result.output @@ -122,25 +122,25 @@ def test_rate_thumbs_down(self) -> None: assert row[0] == 2 def test_rate_rejects_both_up_and_down(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rate", "50", "--up", "--down"]) + result = runner.invoke(app, ["labs", "wiki", "rate", "50", "--up", "--down"]) assert result.exit_code == 1 assert "Cannot use both" in result.output def test_rate_rejects_out_of_range(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rate", "50", "6"]) + result = runner.invoke(app, ["labs", "wiki", "rate", "50", "6"]) assert result.exit_code == 1 assert "between 1 and 5" in result.output - result = runner.invoke(app, ["maintain", "wiki", "rate", "50", "0"]) + result = runner.invoke(app, ["labs", "wiki", "rate", "50", "0"]) assert result.exit_code == 1 def test_rate_requires_value_or_flag(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rate", "50"]) + result = runner.invoke(app, ["labs", "wiki", "rate", "50"]) assert result.exit_code == 1 assert "Provide a rating" in result.output def test_rate_nonexistent_topic(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rate", "999", "3"]) + result = runner.invoke(app, ["labs", "wiki", "rate", "999", "3"]) assert result.exit_code == 1 assert "No wiki article" in result.output @@ -162,12 +162,12 @@ def _setup(self) -> Generator[None, None, None]: conn.commit() def test_list_shows_rating_column(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "list"]) + result = runner.invoke(app, ["labs", "wiki", "list"]) assert result.exit_code == 0 assert "Rating" in result.output def test_list_shows_stars_for_rated(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "list"]) + result = runner.invoke(app, ["labs", "wiki", "list"]) assert result.exit_code == 0 # 5 filled stars for rating=5 assert "\u2605\u2605\u2605\u2605\u2605" in result.output @@ -176,6 +176,6 @@ def test_list_shows_dash_for_unrated(self) -> None: with db.get_connection() as conn: conn.execute("UPDATE wiki_articles SET rating = NULL WHERE topic_id = 60") conn.commit() - result = runner.invoke(app, ["maintain", "wiki", "list"]) + result = runner.invoke(app, ["labs", "wiki", "list"]) assert result.exit_code == 0 assert "-" in result.output diff --git a/tests/test_wiki_rename.py b/tests/test_wiki_rename.py index bdc0dcb7..60792763 100644 --- a/tests/test_wiki_rename.py +++ b/tests/test_wiki_rename.py @@ -65,7 +65,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup_topic(conn, 70) def test_rename_updates_label_and_slug(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rename", "70", "New Name"]) + result = runner.invoke(app, ["labs", "wiki", "rename", "70", "New Name"]) assert result.exit_code == 0 assert "Old Name" in result.output assert "New Name" in result.output @@ -80,7 +80,7 @@ def test_rename_updates_label_and_slug(self) -> None: assert row[1] == "new-name" def test_rename_auto_generates_slug(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rename", "70", "Database Architecture"]) + result = runner.invoke(app, ["labs", "wiki", "rename", "70", "Database Architecture"]) assert result.exit_code == 0 with db.get_connection() as conn: @@ -88,7 +88,7 @@ def test_rename_auto_generates_slug(self) -> None: assert row[0] == "database-architecture" def test_rename_slug_strips_special_chars(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rename", "70", "Auth / OAuth / JWT"]) + result = runner.invoke(app, ["labs", "wiki", "rename", "70", "Auth / OAuth / JWT"]) assert result.exit_code == 0 with db.get_connection() as conn: @@ -96,12 +96,12 @@ def test_rename_slug_strips_special_chars(self) -> None: assert row[0] == "auth-oauth-jwt" def test_rename_nonexistent_topic(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rename", "999", "Anything"]) + result = runner.invoke(app, ["labs", "wiki", "rename", "999", "Anything"]) assert result.exit_code == 1 assert "not found" in result.output def test_rename_shows_old_and_new(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rename", "70", "Brand New"]) + result = runner.invoke(app, ["labs", "wiki", "rename", "70", "Brand New"]) assert result.exit_code == 0 assert "Old Name" in result.output assert "Brand New" in result.output @@ -126,7 +126,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup_topic(conn, 71) def test_rename_updates_document_title(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "rename", "71", "New Article Title"]) + result = runner.invoke(app, ["labs", "wiki", "rename", "71", "New Article Title"]) assert result.exit_code == 0 assert "Document #171 title updated" in result.output @@ -145,7 +145,7 @@ def test_rename_without_article_skips_doc_update(self) -> None: with_article=False, ) try: - result = runner.invoke(app, ["maintain", "wiki", "rename", "72", "Still No Article"]) + result = runner.invoke(app, ["labs", "wiki", "rename", "72", "Still No Article"]) assert result.exit_code == 0 assert "Document #" not in result.output @@ -172,13 +172,13 @@ def _setup(self) -> Generator[None, None, None]: def test_rename_rejects_conflicting_slug(self) -> None: # Try to rename topic 73 to a label that would produce slug "topic-b" - result = runner.invoke(app, ["maintain", "wiki", "rename", "73", "Topic B"]) + result = runner.invoke(app, ["labs", "wiki", "rename", "73", "Topic B"]) assert result.exit_code == 1 assert "already in use" in result.output def test_rename_allows_same_slug_on_same_topic(self) -> None: # Renaming topic 73 to a different label that produces the same slug should work - result = runner.invoke(app, ["maintain", "wiki", "rename", "73", "TOPIC A!"]) + result = runner.invoke(app, ["labs", "wiki", "rename", "73", "TOPIC A!"]) assert result.exit_code == 0 with db.get_connection() as conn: diff --git a/tests/test_wiki_retitle.py b/tests/test_wiki_retitle.py index 04ab73c2..6c738f47 100644 --- a/tests/test_wiki_retitle.py +++ b/tests/test_wiki_retitle.py @@ -219,7 +219,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup_topics(conn, [90, 91, 92]) def test_dry_run_shows_changes(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "retitle", "--dry-run"]) + result = runner.invoke(app, ["labs", "wiki", "retitle", "--dry-run"]) assert result.exit_code == 0 assert "old-label-a" in result.output assert "Better Title A" in result.output @@ -231,7 +231,7 @@ def test_dry_run_shows_changes(self) -> None: assert row[0] == "old-label-a" def test_updates_labels(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "retitle"]) + result = runner.invoke(app, ["labs", "wiki", "retitle"]) assert result.exit_code == 0 assert "Retitled 2/" in result.output @@ -253,7 +253,7 @@ def test_updates_labels(self) -> None: assert doc_a[0] == "Better Title A" def test_skips_matching(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "retitle"]) + result = runner.invoke(app, ["labs", "wiki", "retitle"]) assert result.exit_code == 0 assert "1 already matching" in result.output diff --git a/tests/test_wiki_source_weight.py b/tests/test_wiki_source_weight.py index 0d90ab51..aea58d52 100644 --- a/tests/test_wiki_source_weight.py +++ b/tests/test_wiki_source_weight.py @@ -64,7 +64,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup(conn) def test_sources_lists_all_members(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "sources", "80"]) + result = runner.invoke(app, ["labs", "wiki", "sources", "80"]) assert result.exit_code == 0 assert "Weight Test Topic" in result.output assert "#180" in result.output @@ -72,7 +72,7 @@ def test_sources_lists_all_members(self) -> None: assert "#182" in result.output def test_sources_shows_weight_and_status(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "sources", "80"]) + result = runner.invoke(app, ["labs", "wiki", "sources", "80"]) assert result.exit_code == 0 assert "w=1.00" in result.output assert "included" in result.output @@ -85,12 +85,12 @@ def test_sources_shows_excluded_status(self) -> None: ) conn.commit() - result = runner.invoke(app, ["maintain", "wiki", "sources", "80"]) + result = runner.invoke(app, ["labs", "wiki", "sources", "80"]) assert result.exit_code == 0 assert "EXCLUDED" in result.output def test_sources_nonexistent_topic(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "sources", "999"]) + result = runner.invoke(app, ["labs", "wiki", "sources", "999"]) assert result.exit_code == 1 assert "not found" in result.output @@ -107,7 +107,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup(conn) def test_weight_sets_relevance_score(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "weight", "80", "180", "0.5"]) + result = runner.invoke(app, ["labs", "wiki", "weight", "80", "180", "0.5"]) assert result.exit_code == 0 assert "0.50" in result.output @@ -120,23 +120,23 @@ def test_weight_sets_relevance_score(self) -> None: assert row[0] == pytest.approx(0.5) def test_weight_shows_old_and_new(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "weight", "80", "180", "0.3"]) + result = runner.invoke(app, ["labs", "wiki", "weight", "80", "180", "0.3"]) assert result.exit_code == 0 assert "1.00" in result.output # old weight assert "0.30" in result.output # new weight def test_weight_rejects_invalid_range(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "weight", "80", "180", "1.5"]) + result = runner.invoke(app, ["labs", "wiki", "weight", "80", "180", "1.5"]) assert result.exit_code == 1 assert "between 0.0 and 1.0" in result.output def test_weight_rejects_negative(self) -> None: # Negative floats are parsed as flags by click, so exit_code is 2 - result = runner.invoke(app, ["maintain", "wiki", "weight", "80", "180", "-0.1"]) + result = runner.invoke(app, ["labs", "wiki", "weight", "80", "180", "-0.1"]) assert result.exit_code != 0 def test_weight_nonexistent_member(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "weight", "80", "999", "0.5"]) + result = runner.invoke(app, ["labs", "wiki", "weight", "80", "999", "0.5"]) assert result.exit_code == 1 assert "not a member" in result.output @@ -153,7 +153,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup(conn) def test_exclude_sets_is_primary_to_zero(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "exclude", "80", "181"]) + result = runner.invoke(app, ["labs", "wiki", "exclude", "80", "181"]) assert result.exit_code == 0 assert "Excluded" in result.output @@ -166,7 +166,7 @@ def test_exclude_sets_is_primary_to_zero(self) -> None: assert row[0] == 0 def test_exclude_nonexistent_member(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "exclude", "80", "999"]) + result = runner.invoke(app, ["labs", "wiki", "exclude", "80", "999"]) assert result.exit_code == 1 assert "not a member" in result.output @@ -188,7 +188,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup(conn) def test_include_sets_is_primary_to_one(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "include", "80", "182"]) + result = runner.invoke(app, ["labs", "wiki", "include", "80", "182"]) assert result.exit_code == 0 assert "Included" in result.output @@ -201,7 +201,7 @@ def test_include_sets_is_primary_to_one(self) -> None: assert row[0] == 1 def test_include_nonexistent_member(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "include", "80", "999"]) + result = runner.invoke(app, ["labs", "wiki", "include", "80", "999"]) assert result.exit_code == 1 assert "not a member" in result.output diff --git a/tests/test_wiki_topic_merge_split.py b/tests/test_wiki_topic_merge_split.py index 66112066..b226f35f 100644 --- a/tests/test_wiki_topic_merge_split.py +++ b/tests/test_wiki_topic_merge_split.py @@ -102,7 +102,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup_docs(conn, [801, 802, 803, 804]) def test_merge_combines_members(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "merge", "80", "81"]) + result = runner.invoke(app, ["labs", "wiki", "merge", "80", "81"]) assert result.exit_code == 0 assert "Merged topic 81" in result.output assert "Auth" in result.output @@ -117,7 +117,7 @@ def test_merge_combines_members(self) -> None: assert doc_ids == [801, 802, 803, 804] def test_merge_updates_label_and_slug(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "merge", "80", "81"]) + result = runner.invoke(app, ["labs", "wiki", "merge", "80", "81"]) assert result.exit_code == 0 with db.get_connection() as conn: @@ -128,7 +128,7 @@ def test_merge_updates_label_and_slug(self) -> None: assert row[1] == "auth-security" def test_merge_deletes_source_topic(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "merge", "80", "81"]) + result = runner.invoke(app, ["labs", "wiki", "merge", "80", "81"]) assert result.exit_code == 0 with db.get_connection() as conn: @@ -136,23 +136,23 @@ def test_merge_deletes_source_topic(self) -> None: assert row is None def test_merge_shows_member_counts(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "merge", "80", "81"]) + result = runner.invoke(app, ["labs", "wiki", "merge", "80", "81"]) assert result.exit_code == 0 assert "Members moved: 2" in result.output assert "Total members: 4" in result.output def test_merge_nonexistent_target(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "merge", "999", "81"]) + result = runner.invoke(app, ["labs", "wiki", "merge", "999", "81"]) assert result.exit_code == 1 assert "999 not found" in result.output def test_merge_nonexistent_source(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "merge", "80", "999"]) + result = runner.invoke(app, ["labs", "wiki", "merge", "80", "999"]) assert result.exit_code == 1 assert "999 not found" in result.output def test_merge_self(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "merge", "80", "80"]) + result = runner.invoke(app, ["labs", "wiki", "merge", "80", "80"]) assert result.exit_code == 1 assert "cannot merge a topic with itself" in result.output @@ -171,7 +171,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup_docs(conn, [810, 811, 812]) def test_merge_skips_duplicate_members(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "merge", "82", "83"]) + result = runner.invoke(app, ["labs", "wiki", "merge", "82", "83"]) assert result.exit_code == 0 # Doc 811 was in both — only 812 should be moved assert "Members moved: 1" in result.output @@ -206,7 +206,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup_docs(conn, [820, 821]) def test_merge_deletes_source_article(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "merge", "84", "85"]) + result = runner.invoke(app, ["labs", "wiki", "merge", "84", "85"]) assert result.exit_code == 0 assert "Deleted wiki article" in result.output @@ -220,7 +220,7 @@ def test_merge_deletes_source_article(self) -> None: assert doc[0] == 1 def test_merge_marks_target_article_stale(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "merge", "84", "85"]) + result = runner.invoke(app, ["labs", "wiki", "merge", "84", "85"]) assert result.exit_code == 0 with db.get_connection() as conn: @@ -273,7 +273,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup_docs(conn, [830, 831, 832, 833]) def test_split_moves_matching_docs(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "split", "86", "--entity", "OAuth"]) + result = runner.invoke(app, ["labs", "wiki", "split", "86", "--entity", "OAuth"]) assert result.exit_code == 0 assert "Split topic 86" in result.output assert "Moved 2 doc(s)" in result.output @@ -282,7 +282,7 @@ def test_split_moves_matching_docs(self) -> None: assert "Remaining in original: 2" in result.output def test_split_creates_new_topic(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "split", "86", "--entity", "OAuth"]) + result = runner.invoke(app, ["labs", "wiki", "split", "86", "--entity", "OAuth"]) assert result.exit_code == 0 with db.get_connection() as conn: @@ -293,7 +293,7 @@ def test_split_creates_new_topic(self) -> None: assert new_topic[2] == "oauth" def test_split_moves_members_correctly(self) -> None: - runner.invoke(app, ["maintain", "wiki", "split", "86", "--entity", "OAuth"]) + runner.invoke(app, ["labs", "wiki", "split", "86", "--entity", "OAuth"]) with db.get_connection() as conn: # Original topic should retain non-OAuth docs @@ -317,17 +317,17 @@ def test_split_moves_members_correctly(self) -> None: assert new_doc_ids == [830, 832] def test_split_case_insensitive(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "split", "86", "--entity", "oauth"]) + result = runner.invoke(app, ["labs", "wiki", "split", "86", "--entity", "oauth"]) assert result.exit_code == 0 assert "Moved 2 doc(s)" in result.output def test_split_nonexistent_topic(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "split", "999", "--entity", "OAuth"]) + result = runner.invoke(app, ["labs", "wiki", "split", "999", "--entity", "OAuth"]) assert result.exit_code == 1 assert "999 not found" in result.output def test_split_no_matching_docs(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "split", "86", "--entity", "GraphQL"]) + result = runner.invoke(app, ["labs", "wiki", "split", "86", "--entity", "GraphQL"]) assert result.exit_code == 1 assert "No documents" in result.output @@ -352,7 +352,7 @@ def test_split_all_docs_match(self) -> None: ) try: - result = runner.invoke(app, ["maintain", "wiki", "split", "87", "--entity", "OAuth"]) + result = runner.invoke(app, ["labs", "wiki", "split", "87", "--entity", "OAuth"]) assert result.exit_code == 1 assert "nothing would remain" in result.output finally: @@ -391,7 +391,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup_docs(conn, [850, 851]) def test_split_marks_original_article_stale(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "split", "88", "--entity", "OAuth"]) + result = runner.invoke(app, ["labs", "wiki", "split", "88", "--entity", "OAuth"]) assert result.exit_code == 0 with db.get_connection() as conn: @@ -435,7 +435,7 @@ def _setup(self) -> Generator[None, None, None]: _cleanup_docs(conn, [860, 861]) def test_split_appends_suffix_on_slug_conflict(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "split", "89", "--entity", "OAuth"]) + result = runner.invoke(app, ["labs", "wiki", "split", "89", "--entity", "OAuth"]) assert result.exit_code == 0 with db.get_connection() as conn: diff --git a/tests/test_wiki_topic_skip_pin.py b/tests/test_wiki_topic_skip_pin.py index 6664351d..d5c3db19 100644 --- a/tests/test_wiki_topic_skip_pin.py +++ b/tests/test_wiki_topic_skip_pin.py @@ -45,7 +45,7 @@ def _setup(self) -> Generator[None, None, None]: conn.commit() def test_skip_sets_status_to_skipped(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "skip", "70"]) + result = runner.invoke(app, ["labs", "wiki", "skip", "70"]) assert result.exit_code == 0 assert "Skipped" in result.output assert "Test Topic" in result.output @@ -59,12 +59,12 @@ def test_skip_sets_status_to_skipped(self) -> None: assert _get_topic_status(conn, 70) == "skipped" def test_skip_shows_previous_status(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "skip", "70"]) + result = runner.invoke(app, ["labs", "wiki", "skip", "70"]) assert result.exit_code == 0 assert "was: active" in result.output def test_skip_nonexistent_topic(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "skip", "999"]) + result = runner.invoke(app, ["labs", "wiki", "skip", "999"]) assert result.exit_code == 1 assert "not found" in result.output @@ -82,7 +82,7 @@ def _setup(self) -> Generator[None, None, None]: conn.commit() def test_unskip_sets_status_to_active(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "unskip", "71"]) + result = runner.invoke(app, ["labs", "wiki", "unskip", "71"]) assert result.exit_code == 0 assert "Unskipped" in result.output @@ -90,7 +90,7 @@ def test_unskip_sets_status_to_active(self) -> None: assert _get_topic_status(conn, 71) == "active" def test_unskip_shows_previous_status(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "unskip", "71"]) + result = runner.invoke(app, ["labs", "wiki", "unskip", "71"]) assert result.exit_code == 0 assert "was: skipped" in result.output @@ -108,7 +108,7 @@ def _setup(self) -> Generator[None, None, None]: conn.commit() def test_pin_sets_status_to_pinned(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "pin", "72"]) + result = runner.invoke(app, ["labs", "wiki", "pin", "72"]) assert result.exit_code == 0 assert "Pinned" in result.output @@ -116,12 +116,12 @@ def test_pin_sets_status_to_pinned(self) -> None: assert _get_topic_status(conn, 72) == "pinned" def test_pin_shows_previous_status(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "pin", "72"]) + result = runner.invoke(app, ["labs", "wiki", "pin", "72"]) assert result.exit_code == 0 assert "was: active" in result.output def test_pin_nonexistent_topic(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "pin", "999"]) + result = runner.invoke(app, ["labs", "wiki", "pin", "999"]) assert result.exit_code == 1 assert "not found" in result.output @@ -139,7 +139,7 @@ def _setup(self) -> Generator[None, None, None]: conn.commit() def test_unpin_sets_status_to_active(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "unpin", "73"]) + result = runner.invoke(app, ["labs", "wiki", "unpin", "73"]) assert result.exit_code == 0 assert "Unpinned" in result.output @@ -147,7 +147,7 @@ def test_unpin_sets_status_to_active(self) -> None: assert _get_topic_status(conn, 73) == "active" def test_unpin_shows_previous_status(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "unpin", "73"]) + result = runner.invoke(app, ["labs", "wiki", "unpin", "73"]) assert result.exit_code == 0 assert "was: pinned" in result.output @@ -265,7 +265,7 @@ def _setup(self) -> Generator[None, None, None]: @patch("emdx.services.wiki_entity_service.get_entity_index_stats") def test_status_shows_skipped_and_pinned(self, mock_stats: MagicMock) -> None: mock_stats.return_value = MagicMock(tier_a_count=0, tier_b_count=0, tier_c_count=0) - result = runner.invoke(app, ["maintain", "wiki", "status"]) + result = runner.invoke(app, ["labs", "wiki", "status"]) assert result.exit_code == 0 assert "skipped" in result.output assert "pinned" in result.output diff --git a/tests/test_wiki_triage_setup.py b/tests/test_wiki_triage_setup.py index 69e41353..fac1c87f 100644 --- a/tests/test_wiki_triage_setup.py +++ b/tests/test_wiki_triage_setup.py @@ -63,7 +63,7 @@ def _setup(self) -> Generator[None, None, None]: conn.commit() def test_skip_below_skips_low_coherence(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "triage", "--skip-below", "0.05"]) + result = runner.invoke(app, ["labs", "wiki", "triage", "--skip-below", "0.05"]) assert result.exit_code == 0 assert "Skipped" in result.output or "skipped" in result.output @@ -72,7 +72,7 @@ def test_skip_below_skips_low_coherence(self) -> None: assert _get_topic_status(conn, 81) == "active" def test_skip_below_ignores_already_skipped(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "triage", "--skip-below", "0.05"]) + result = runner.invoke(app, ["labs", "wiki", "triage", "--skip-below", "0.05"]) assert result.exit_code == 0 # Topic 82 was already skipped — should not appear in output assert "Already Skipped" not in result.output @@ -80,7 +80,7 @@ def test_skip_below_ignores_already_skipped(self) -> None: def test_skip_below_dry_run(self) -> None: result = runner.invoke( app, - ["maintain", "wiki", "triage", "--skip-below", "0.05", "--dry-run"], + ["labs", "wiki", "triage", "--skip-below", "0.05", "--dry-run"], ) assert result.exit_code == 0 assert "dry run" in result.output.lower() @@ -89,7 +89,7 @@ def test_skip_below_dry_run(self) -> None: assert _get_topic_status(conn, 80) == "active" def test_triage_requires_flags(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "triage"]) + result = runner.invoke(app, ["labs", "wiki", "triage"]) assert result.exit_code == 1 assert "Specify" in result.output @@ -98,7 +98,7 @@ class TestWikiTriageNoTopics: """Test triage with no saved topics.""" def test_triage_no_topics(self) -> None: - result = runner.invoke(app, ["maintain", "wiki", "triage", "--skip-below", "0.05"]) + result = runner.invoke(app, ["labs", "wiki", "triage", "--skip-below", "0.05"]) assert result.exit_code == 1 assert "No saved topics" in result.output @@ -131,7 +131,7 @@ def test_auto_label_renames_topics( mock_has_claude.return_value = True mock_run.return_value = MagicMock(returncode=0, stdout="Better Topic Name", stderr="") - result = runner.invoke(app, ["maintain", "wiki", "triage", "--auto-label"]) + result = runner.invoke(app, ["labs", "wiki", "triage", "--auto-label"]) assert result.exit_code == 0 assert "Better Topic Name" in result.output @@ -141,7 +141,7 @@ def test_auto_label_renames_topics( @patch("emdx.services.wiki_clustering_service._has_claude_cli") def test_auto_label_fails_without_claude(self, mock_has_claude: MagicMock) -> None: mock_has_claude.return_value = False - result = runner.invoke(app, ["maintain", "wiki", "triage", "--auto-label"]) + result = runner.invoke(app, ["labs", "wiki", "triage", "--auto-label"]) assert result.exit_code == 1 assert "Claude CLI not found" in result.output @@ -181,7 +181,7 @@ def test_auto_label_called_on_save( mock_auto_label.return_value = [cluster] mock_save.return_value = 1 - result = runner.invoke(app, ["maintain", "wiki", "topics", "--save", "--auto-label"]) + result = runner.invoke(app, ["labs", "wiki", "topics", "--save", "--auto-label"]) assert result.exit_code == 0 mock_auto_label.assert_called_once() @@ -197,7 +197,7 @@ def test_auto_label_not_called_without_flag(self, mock_discover: MagicMock) -> N resolution=0.005, ) - result = runner.invoke(app, ["maintain", "wiki", "topics"]) + result = runner.invoke(app, ["labs", "wiki", "topics"]) assert result.exit_code == 0 @@ -340,7 +340,7 @@ def test_setup_runs_all_steps( resolution=0.005, ) - result = runner.invoke(app, ["maintain", "wiki", "setup"]) + result = runner.invoke(app, ["labs", "wiki", "setup"]) assert result.exit_code == 0 assert "Wiki Setup Complete" in result.output assert "Saved" in result.output @@ -370,6 +370,6 @@ def test_setup_no_clusters( resolution=0.005, ) - result = runner.invoke(app, ["maintain", "wiki", "setup"]) + result = runner.invoke(app, ["labs", "wiki", "setup"]) assert result.exit_code == 0 assert "No topic clusters found" in result.output