Merge pull request #567 from ggozad/chore/coverage-honesty
Measure the CLI and its application layer
This commit is contained in:
commit
d429ac4996
8 changed files with 1183 additions and 67 deletions
8
.github/workflows/build-docs.yml
vendored
8
.github/workflows/build-docs.yml
vendored
|
|
@ -3,12 +3,15 @@ on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pages
|
group: pages-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -19,11 +22,14 @@ jobs:
|
||||||
- run: uv sync --group dev
|
- run: uv sync --group dev
|
||||||
- run: uv run zensical build
|
- run: uv run zensical build
|
||||||
- uses: actions/configure-pages@v5
|
- uses: actions/configure-pages@v5
|
||||||
|
if: github.event_name == 'push'
|
||||||
- uses: actions/upload-pages-artifact@v3
|
- uses: actions/upload-pages-artifact@v3
|
||||||
|
if: github.event_name == 'push'
|
||||||
with:
|
with:
|
||||||
path: ./site
|
path: ./site
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
|
if: github.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
|
|
|
||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
|
@ -77,7 +77,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
HF_HUB_OFFLINE: ${{ steps.hf-cache.outputs.cache-hit == 'true' && '1' || '0' }}
|
HF_HUB_OFFLINE: ${{ steps.hf-cache.outputs.cache-hit == 'true' && '1' || '0' }}
|
||||||
TRANSFORMERS_OFFLINE: ${{ steps.hf-cache.outputs.cache-hit == 'true' && '1' || '0' }}
|
TRANSFORMERS_OFFLINE: ${{ steps.hf-cache.outputs.cache-hit == 'true' && '1' || '0' }}
|
||||||
run: uv run pytest -m "not integration" --cov=haiku --cov-report=xml --cov-report=term-missing:skip-covered
|
run: uv run pytest -m "not integration" --cov --cov-report=xml --cov-report=term-missing:skip-covered
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- `haiku-rag settings` masked only top-level secret-named fields, printing nested ones in full (`lancedb.api_key`, `providers.docling_serve.api_key`, WebDAV source passwords). It redacts the whole dump.
|
||||||
|
- `haiku-rag chat` reports a missing `tui` extra instead of raising `ImportError`, matching `haiku-rag inspect`.
|
||||||
- `storage.data_dir: ""` resolves to the platform data directory, as documented; it coerced to `Path("")`, so the database was created in the process's working directory. Set `data_dir: .` to keep the old placement.
|
- `storage.data_dir: ""` resolves to the platform data directory, as documented; it coerced to `Path("")`, so the database was created in the process's working directory. Set `data_dir: .` to keep the old placement.
|
||||||
- Documented `search.limit` default is 5, was stated as 10.
|
- Documented `search.limit` default is 5, was stated as 10.
|
||||||
- The documented way to disable reranking is omitting `reranking.model` or setting it to `null`; the previous `provider: ""` example raised `Unknown reranking provider`.
|
- The documented way to disable reranking is omitting `reranking.model` or setting it to `null`; the previous `provider: ""` example raised `Unknown reranking provider`.
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,13 @@ from haiku.rag.store.models.document import Document
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from haiku.rag.store.engine import Store
|
from haiku.rag.store.engine import Store
|
||||||
from haiku.rag.store.models import SearchResult
|
from haiku.rag.store.models import SearchResult
|
||||||
|
from haiku.rag.config import redact_secrets
|
||||||
from haiku.rag.utils import format_bytes, format_citations_rich
|
from haiku.rag.utils import format_bytes, format_citations_rich
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class HaikuRAGApp: # pragma: no cover
|
class HaikuRAGApp:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
db_path: Path,
|
db_path: Path,
|
||||||
|
|
@ -822,21 +823,11 @@ class HaikuRAGApp: # pragma: no cover
|
||||||
self.console.print("[bold]haiku.rag configuration[/bold]")
|
self.console.print("[bold]haiku.rag configuration[/bold]")
|
||||||
self.console.print()
|
self.console.print()
|
||||||
|
|
||||||
# Get all config fields dynamically
|
# redact_secrets walks the whole dump: masking only top-level names left
|
||||||
for field_name, field_value in self.config.model_dump().items():
|
# nested api keys, tokens and source passwords printed in full.
|
||||||
# Format the display value
|
for field_name, field_value in redact_secrets(self.config.model_dump()).items():
|
||||||
if isinstance(field_value, str) and (
|
|
||||||
"key" in field_name.lower()
|
|
||||||
or "password" in field_name.lower()
|
|
||||||
or "token" in field_name.lower()
|
|
||||||
):
|
|
||||||
# Hide sensitive values but show if they're set
|
|
||||||
display_value = "✓ Set" if field_value else "✗ Not set"
|
|
||||||
else:
|
|
||||||
display_value = field_value
|
|
||||||
|
|
||||||
self.console.print(
|
self.console.print(
|
||||||
f" [repr.attrib_name]{field_name}[/repr.attrib_name]: {display_value}"
|
f" [repr.attrib_name]{field_name}[/repr.attrib_name]: {field_value}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _rich_print_document(self, doc: Document, truncate: bool = False):
|
def _rich_print_document(self, doc: Document, truncate: bool = False):
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ def cli():
|
||||||
_read_only: bool = False
|
_read_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
def create_app(db: Path | None = None) -> "HaikuRAGApp": # pragma: no cover
|
def create_app(db: Path | None = None) -> "HaikuRAGApp":
|
||||||
"""Create HaikuRAGApp with loaded config and resolved database path.
|
"""Create HaikuRAGApp with loaded config and resolved database path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -66,7 +66,7 @@ def create_app(db: Path | None = None) -> "HaikuRAGApp": # pragma: no cover
|
||||||
return HaikuRAGApp(db_path=db_path, config=config, read_only=_read_only)
|
return HaikuRAGApp(db_path=db_path, config=config, read_only=_read_only)
|
||||||
|
|
||||||
|
|
||||||
async def check_version(): # pragma: no cover
|
async def check_version():
|
||||||
"""Check if haiku.rag is up to date and show warning if not."""
|
"""Check if haiku.rag is up to date and show warning if not."""
|
||||||
up_to_date, current_version, latest_version = await is_up_to_date()
|
up_to_date, current_version, latest_version = await is_up_to_date()
|
||||||
if not up_to_date:
|
if not up_to_date:
|
||||||
|
|
@ -76,7 +76,7 @@ async def check_version(): # pragma: no cover
|
||||||
typer.echo("Please update.")
|
typer.echo("Please update.")
|
||||||
|
|
||||||
|
|
||||||
def version_callback(value: bool): # pragma: no cover
|
def version_callback(value: bool):
|
||||||
if value:
|
if value:
|
||||||
v = version("haiku.rag-slim")
|
v = version("haiku.rag-slim")
|
||||||
typer.echo(f"haiku.rag version {v}")
|
typer.echo(f"haiku.rag version {v}")
|
||||||
|
|
@ -130,13 +130,13 @@ def main(
|
||||||
# Run version check before any command
|
# Run version check before any command
|
||||||
try:
|
try:
|
||||||
asyncio.run(check_version())
|
asyncio.run(check_version())
|
||||||
except Exception: # pragma: no cover
|
except Exception:
|
||||||
# Do not block CLI on version check issues
|
# Do not block CLI on version check issues
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("list", help="List all stored documents")
|
@_cli.command("list", help="List all stored documents")
|
||||||
def list_documents( # pragma: no cover
|
def list_documents(
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--db",
|
"--db",
|
||||||
|
|
@ -178,7 +178,7 @@ def _parse_meta_options(meta: list[str] | None) -> dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("add", help="Add a document from text input")
|
@_cli.command("add", help="Add a document from text input")
|
||||||
def add_document_text( # pragma: no cover
|
def add_document_text(
|
||||||
text: str = typer.Argument(
|
text: str = typer.Argument(
|
||||||
help="The text content of the document to add",
|
help="The text content of the document to add",
|
||||||
),
|
),
|
||||||
|
|
@ -207,7 +207,7 @@ def add_document_text( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("add-src", help="Add a document from a file path, directory, or URL")
|
@_cli.command("add-src", help="Add a document from a file path, directory, or URL")
|
||||||
def add_document_src( # pragma: no cover
|
def add_document_src(
|
||||||
source: str = typer.Argument(
|
source: str = typer.Argument(
|
||||||
help="The file path, directory, or URL of the document(s) to add",
|
help="The file path, directory, or URL of the document(s) to add",
|
||||||
),
|
),
|
||||||
|
|
@ -238,7 +238,7 @@ def add_document_src( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("get", help="Get and display a document by its ID")
|
@_cli.command("get", help="Get and display a document by its ID")
|
||||||
def get_document( # pragma: no cover
|
def get_document(
|
||||||
doc_id: str = typer.Argument(
|
doc_id: str = typer.Argument(
|
||||||
help="The ID of the document to get",
|
help="The ID of the document to get",
|
||||||
),
|
),
|
||||||
|
|
@ -253,7 +253,7 @@ def get_document( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("delete", help="Delete a document by its ID")
|
@_cli.command("delete", help="Delete a document by its ID")
|
||||||
def delete_document( # pragma: no cover
|
def delete_document(
|
||||||
doc_id: str = typer.Argument(
|
doc_id: str = typer.Argument(
|
||||||
help="The ID of the document to delete",
|
help="The ID of the document to delete",
|
||||||
),
|
),
|
||||||
|
|
@ -274,7 +274,7 @@ _cli.command("rm", help="Alias for delete: remove a document by its ID")(
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("search", help="Search for documents by a query")
|
@_cli.command("search", help="Search for documents by a query")
|
||||||
def search( # pragma: no cover
|
def search(
|
||||||
query: str | None = typer.Argument(
|
query: str | None = typer.Argument(
|
||||||
None,
|
None,
|
||||||
help="The search query (omit when using --image)",
|
help="The search query (omit when using --image)",
|
||||||
|
|
@ -321,7 +321,7 @@ def search( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("visualize", help="Show visual grounding for a chunk")
|
@_cli.command("visualize", help="Show visual grounding for a chunk")
|
||||||
def visualize( # pragma: no cover
|
def visualize(
|
||||||
chunk_id: str = typer.Argument(
|
chunk_id: str = typer.Argument(
|
||||||
help="The ID of the chunk to visualize",
|
help="The ID of the chunk to visualize",
|
||||||
),
|
),
|
||||||
|
|
@ -341,7 +341,7 @@ def visualize( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("ask", help="Ask a question using the QA agent")
|
@_cli.command("ask", help="Ask a question using the QA agent")
|
||||||
def ask( # pragma: no cover
|
def ask(
|
||||||
question: str = typer.Argument(
|
question: str = typer.Argument(
|
||||||
help="The question to ask",
|
help="The question to ask",
|
||||||
),
|
),
|
||||||
|
|
@ -373,7 +373,7 @@ def ask( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("analyze", help="Answer questions using the analysis capability")
|
@_cli.command("analyze", help="Answer questions using the analysis capability")
|
||||||
def analyze( # pragma: no cover
|
def analyze(
|
||||||
question: str = typer.Argument(
|
question: str = typer.Argument(
|
||||||
help="The question to answer",
|
help="The question to answer",
|
||||||
),
|
),
|
||||||
|
|
@ -405,7 +405,7 @@ def analyze( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("settings", help="Display current configuration settings")
|
@_cli.command("settings", help="Display current configuration settings")
|
||||||
def settings(): # pragma: no cover
|
def settings():
|
||||||
from haiku.rag.app import HaikuRAGApp
|
from haiku.rag.app import HaikuRAGApp
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
|
@ -499,25 +499,25 @@ def rebuild(
|
||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
if embed_only: # pragma: no cover
|
if embed_only:
|
||||||
mode = RebuildMode.EMBED_ONLY
|
mode = RebuildMode.EMBED_ONLY
|
||||||
elif rechunk: # pragma: no cover
|
elif rechunk:
|
||||||
mode = RebuildMode.RECHUNK
|
mode = RebuildMode.RECHUNK
|
||||||
elif title_only: # pragma: no cover
|
elif title_only:
|
||||||
mode = RebuildMode.TITLE_ONLY
|
mode = RebuildMode.TITLE_ONLY
|
||||||
elif descriptions: # pragma: no cover
|
elif descriptions:
|
||||||
mode = RebuildMode.DESCRIPTIONS
|
mode = RebuildMode.DESCRIPTIONS
|
||||||
elif set_embedder: # pragma: no cover
|
elif set_embedder:
|
||||||
mode = RebuildMode.SET_EMBEDDER
|
mode = RebuildMode.SET_EMBEDDER
|
||||||
else: # pragma: no cover
|
else:
|
||||||
mode = RebuildMode.FULL
|
mode = RebuildMode.FULL
|
||||||
|
|
||||||
app = create_app(db) # pragma: no cover
|
app = create_app(db)
|
||||||
asyncio.run(app.rebuild(mode=mode)) # pragma: no cover
|
asyncio.run(app.rebuild(mode=mode))
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("vacuum", help="Optimize and clean up all tables to reduce disk usage")
|
@_cli.command("vacuum", help="Optimize and clean up all tables to reduce disk usage")
|
||||||
def vacuum( # pragma: no cover
|
def vacuum(
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--db",
|
"--db",
|
||||||
|
|
@ -529,7 +529,7 @@ def vacuum( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("migrate", help="Run pending database migrations")
|
@_cli.command("migrate", help="Run pending database migrations")
|
||||||
def migrate( # pragma: no cover
|
def migrate(
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--db",
|
"--db",
|
||||||
|
|
@ -554,7 +554,7 @@ def migrate( # pragma: no cover
|
||||||
@_cli.command(
|
@_cli.command(
|
||||||
"create-index", help="Create vector index for efficient similarity search"
|
"create-index", help="Create vector index for efficient similarity search"
|
||||||
)
|
)
|
||||||
def create_index( # pragma: no cover
|
def create_index(
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--db",
|
"--db",
|
||||||
|
|
@ -566,7 +566,7 @@ def create_index( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("init", help="Initialize a new database")
|
@_cli.command("init", help="Initialize a new database")
|
||||||
def init_db( # pragma: no cover
|
def init_db(
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--db",
|
"--db",
|
||||||
|
|
@ -578,7 +578,7 @@ def init_db( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("info", help="Show database info")
|
@_cli.command("info", help="Show database info")
|
||||||
def info( # pragma: no cover
|
def info(
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--db",
|
"--db",
|
||||||
|
|
@ -590,7 +590,7 @@ def info( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("doctor", help="Check database and provider health")
|
@_cli.command("doctor", help="Check database and provider health")
|
||||||
def doctor( # pragma: no cover
|
def doctor(
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--db",
|
"--db",
|
||||||
|
|
@ -608,7 +608,7 @@ def doctor( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("history", help="Show version history for database tables")
|
@_cli.command("history", help="Show version history for database tables")
|
||||||
def history( # pragma: no cover
|
def history(
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--db",
|
"--db",
|
||||||
|
|
@ -639,7 +639,7 @@ _cli.add_typer(tag_cli, name="tag")
|
||||||
|
|
||||||
|
|
||||||
@tag_cli.command("create", help="Tag the current database state")
|
@tag_cli.command("create", help="Tag the current database state")
|
||||||
def tag_create( # pragma: no cover
|
def tag_create(
|
||||||
name: str = typer.Argument(help="Name of the tag to create"),
|
name: str = typer.Argument(help="Name of the tag to create"),
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
|
|
@ -656,7 +656,7 @@ def tag_create( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@tag_cli.command("list", help="List database tags")
|
@tag_cli.command("list", help="List database tags")
|
||||||
def tag_list( # pragma: no cover
|
def tag_list(
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--db",
|
"--db",
|
||||||
|
|
@ -672,7 +672,7 @@ def tag_list( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@tag_cli.command("delete", help="Delete a tag")
|
@tag_cli.command("delete", help="Delete a tag")
|
||||||
def tag_delete( # pragma: no cover
|
def tag_delete(
|
||||||
name: str = typer.Argument(help="Name of the tag to delete"),
|
name: str = typer.Argument(help="Name of the tag to delete"),
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
|
|
@ -689,7 +689,7 @@ def tag_delete( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@tag_cli.command("restore", help="Restore the database to a tagged state")
|
@tag_cli.command("restore", help="Restore the database to a tagged state")
|
||||||
def tag_restore( # pragma: no cover
|
def tag_restore(
|
||||||
name: str = typer.Argument(help="Name of the tag to restore"),
|
name: str = typer.Argument(help="Name of the tag to restore"),
|
||||||
yes: bool = typer.Option(
|
yes: bool = typer.Option(
|
||||||
False,
|
False,
|
||||||
|
|
@ -724,7 +724,7 @@ def tag_restore( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("download-models", help="Download Docling and Ollama models per config")
|
@_cli.command("download-models", help="Download Docling and Ollama models per config")
|
||||||
def download_models_cmd(): # pragma: no cover
|
def download_models_cmd():
|
||||||
from haiku.rag.app import HaikuRAGApp
|
from haiku.rag.app import HaikuRAGApp
|
||||||
|
|
||||||
app = HaikuRAGApp(db_path=Path(), config=get_config(), read_only=True)
|
app = HaikuRAGApp(db_path=Path(), config=get_config(), read_only=True)
|
||||||
|
|
@ -736,7 +736,7 @@ def download_models_cmd(): # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("inspect", help="Launch interactive TUI to inspect database contents")
|
@_cli.command("inspect", help="Launch interactive TUI to inspect database contents")
|
||||||
def inspect( # pragma: no cover
|
def inspect(
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--db",
|
"--db",
|
||||||
|
|
@ -755,7 +755,7 @@ def inspect( # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
@_cli.command("chat", help="Launch interactive chat TUI for conversational RAG")
|
@_cli.command("chat", help="Launch interactive chat TUI for conversational RAG")
|
||||||
def chat( # pragma: no cover
|
def chat(
|
||||||
db: Path | None = typer.Option(
|
db: Path | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--db",
|
"--db",
|
||||||
|
|
@ -779,12 +779,16 @@ def chat( # pragma: no cover
|
||||||
db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb"
|
db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb"
|
||||||
capabilities = capability if capability else ["rag"]
|
capabilities = capability if capability else ["rag"]
|
||||||
|
|
||||||
run_chat(
|
try:
|
||||||
db_path,
|
run_chat(
|
||||||
read_only=True,
|
db_path,
|
||||||
model=model,
|
read_only=True,
|
||||||
capabilities=capabilities,
|
model=model,
|
||||||
)
|
capabilities=capabilities,
|
||||||
|
)
|
||||||
|
except ImportError as e:
|
||||||
|
typer.echo(f"Error: {e}", err=True)
|
||||||
|
raise typer.Exit(1) from e
|
||||||
|
|
||||||
|
|
||||||
@_cli.command(
|
@_cli.command(
|
||||||
|
|
@ -814,14 +818,12 @@ def mcp(
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run the MCP server."""
|
"""Run the MCP server."""
|
||||||
app = create_app(db) # pragma: no cover
|
app = create_app(db)
|
||||||
|
|
||||||
transport = "stdio" if stdio else None # pragma: no cover
|
transport = "stdio" if stdio else None
|
||||||
|
|
||||||
asyncio.run( # pragma: no cover
|
asyncio.run(app.run_mcp(transport=transport, host=host, port=port))
|
||||||
app.run_mcp(transport=transport, host=host, port=port)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__":
|
||||||
cli()
|
cli() # pragma: no cover - module-as-script entry, never imported by tests
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,8 @@ source = ["haiku_rag_slim"]
|
||||||
# flow via a greenlet + worker thread; track both so that code isn't reported
|
# flow via a greenlet + worker thread; track both so that code isn't reported
|
||||||
# as uncovered.
|
# as uncovered.
|
||||||
concurrency = ["greenlet", "thread"]
|
concurrency = ["greenlet", "thread"]
|
||||||
|
# Textual TUIs only: driving a terminal UI is not what this gate is for.
|
||||||
|
# Everything else, the CLI application layer included, is measured.
|
||||||
omit = [
|
omit = [
|
||||||
"haiku_rag_slim/haiku/rag/chat/*",
|
"haiku_rag_slim/haiku/rag/chat/*",
|
||||||
"haiku_rag_slim/haiku/rag/inspector/*",
|
"haiku_rag_slim/haiku/rag/inspector/*",
|
||||||
|
|
|
||||||
810
tests/test_app.py
Normal file
810
tests/test_app.py
Normal file
|
|
@ -0,0 +1,810 @@
|
||||||
|
"""Tests for the CLI application layer.
|
||||||
|
|
||||||
|
They stub the client and record the console: each test pins what the app asks
|
||||||
|
the client for and what it renders, without a database or a model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from haiku.rag.app import HaikuRAGApp
|
||||||
|
from haiku.rag.client import RebuildMode
|
||||||
|
from haiku.rag.config.models import AppConfig, LanceDBConfig
|
||||||
|
from haiku.rag.store.models.chunk import Chunk, SearchResult
|
||||||
|
from haiku.rag.store.models.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return AsyncMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(tmp_path, client, monkeypatch):
|
||||||
|
class StubHaikuRAG:
|
||||||
|
# run_mcp passes db_path positionally; every other caller uses kwargs.
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.app.HaikuRAG", StubHaikuRAG)
|
||||||
|
db = tmp_path / "db.lancedb"
|
||||||
|
db.mkdir()
|
||||||
|
application = HaikuRAGApp(db_path=db, config=AppConfig())
|
||||||
|
application.console = Console(record=True, width=200)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
def out(app) -> str:
|
||||||
|
# clear=False: export_text() empties the buffer by default, so a second
|
||||||
|
# assertion in the same test would see nothing.
|
||||||
|
return app.console.export_text(clear=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _doc(content: str = "body text", **kwargs) -> Document:
|
||||||
|
doc = Document(content=content, uri=kwargs.pop("uri", "test://doc"), **kwargs)
|
||||||
|
doc.id = kwargs.get("id", "doc-1")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_documents_prints_each_document(app, client):
|
||||||
|
client.list_documents.return_value = [_doc("first"), _doc("second")]
|
||||||
|
|
||||||
|
await app.list_documents()
|
||||||
|
|
||||||
|
client.list_documents.assert_awaited_once_with(filter=None)
|
||||||
|
assert "first" in out(app)
|
||||||
|
assert "second" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_document_from_text_reports_the_new_id(app, client):
|
||||||
|
client.create_document.return_value = _doc("added body")
|
||||||
|
|
||||||
|
await app.add_document_from_text("added body", title="T", metadata={"k": "v"})
|
||||||
|
|
||||||
|
client.create_document.assert_awaited_once_with(
|
||||||
|
"added body", title="T", metadata={"k": "v"}
|
||||||
|
)
|
||||||
|
assert "doc-1 added successfully" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_document_from_source_reports_one_document(app, client):
|
||||||
|
client.create_document_from_source.return_value = _doc("from file")
|
||||||
|
|
||||||
|
await app.add_document_from_source("/tmp/x.md")
|
||||||
|
|
||||||
|
assert "doc-1 added successfully" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_document_from_source_reports_a_directory_count(app, client):
|
||||||
|
client.create_document_from_source.return_value = [_doc("a"), _doc("b")]
|
||||||
|
|
||||||
|
await app.add_document_from_source("/tmp/dir")
|
||||||
|
|
||||||
|
assert "2 documents added successfully" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_document_reports_a_missing_id(app, client):
|
||||||
|
client.get_document_by_id.return_value = None
|
||||||
|
|
||||||
|
await app.get_document("nope")
|
||||||
|
|
||||||
|
assert "not found" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_document_prints_it_untruncated(app, client):
|
||||||
|
client.get_document_by_id.return_value = _doc("line1\nline2\nline3\nline4")
|
||||||
|
|
||||||
|
await app.get_document("doc-1")
|
||||||
|
|
||||||
|
assert "line4" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_document_confirms(app, client):
|
||||||
|
client.delete_document.return_value = True
|
||||||
|
|
||||||
|
await app.delete_document("doc-1")
|
||||||
|
|
||||||
|
assert "deleted successfully" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_document_reports_a_missing_id(app, client):
|
||||||
|
client.delete_document.return_value = False
|
||||||
|
|
||||||
|
await app.delete_document("nope")
|
||||||
|
|
||||||
|
assert "not found" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_requires_a_query_or_an_image(app):
|
||||||
|
await app.search()
|
||||||
|
|
||||||
|
assert "Provide either a query argument or --image" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_refuses_both_a_query_and_an_image(app, tmp_path):
|
||||||
|
await app.search(query="q", image=tmp_path / "i.png")
|
||||||
|
|
||||||
|
assert "not both" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_type_needs_a_text_query(app, tmp_path):
|
||||||
|
await app.search(image=tmp_path / "i.png", search_type="vector")
|
||||||
|
|
||||||
|
assert "only for text queries" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_reports_no_results(app, client):
|
||||||
|
client.search.return_value = []
|
||||||
|
|
||||||
|
await app.search(query="q")
|
||||||
|
|
||||||
|
assert "No results found" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_prints_results(app, client):
|
||||||
|
client.search.return_value = [
|
||||||
|
SearchResult(content="hit one", score=0.9, chunk_id="c1")
|
||||||
|
]
|
||||||
|
|
||||||
|
await app.search(query="q", limit=3)
|
||||||
|
|
||||||
|
client.search.assert_awaited_once_with("q", limit=3, filter=None, search_type=None)
|
||||||
|
assert "hit one" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_by_image_reads_the_bytes(app, client, tmp_path):
|
||||||
|
image = tmp_path / "query.png"
|
||||||
|
image.write_bytes(b"pixels")
|
||||||
|
client.search.return_value = []
|
||||||
|
|
||||||
|
await app.search(image=image)
|
||||||
|
|
||||||
|
assert client.search.await_args.args[0] == b"pixels"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_visualize_reports_a_missing_chunk(app, client):
|
||||||
|
client.get_chunk_by_id.return_value = None
|
||||||
|
|
||||||
|
await app.visualize_chunk("nope")
|
||||||
|
|
||||||
|
assert "not found" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_visualize_reports_no_grounding(app, client):
|
||||||
|
client.get_chunk_by_id.return_value = Chunk(content="c", order=0)
|
||||||
|
client.visualize_chunk.return_value = []
|
||||||
|
|
||||||
|
await app.visualize_chunk("c1")
|
||||||
|
|
||||||
|
assert "No visual grounding available" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ask_prints_question_answer_and_citations(app, client, monkeypatch):
|
||||||
|
client.ask.return_value = ("The answer.", [])
|
||||||
|
|
||||||
|
async def no_citations(citations, client=None):
|
||||||
|
return ["citation block"]
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations)
|
||||||
|
|
||||||
|
await app.ask("why?", filter="uri LIKE 'x%'")
|
||||||
|
|
||||||
|
client.ask.assert_awaited_once_with("why?", filter="uri LIKE 'x%'", images=None)
|
||||||
|
printed = out(app)
|
||||||
|
assert "why?" in printed
|
||||||
|
assert "The answer." in printed
|
||||||
|
assert "citation block" in printed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ask_attaches_image_bytes(app, client, monkeypatch, tmp_path):
|
||||||
|
image = tmp_path / "a.png"
|
||||||
|
image.write_bytes(b"img")
|
||||||
|
client.ask.return_value = ("answer", [])
|
||||||
|
|
||||||
|
async def no_citations(citations, client=None):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations)
|
||||||
|
|
||||||
|
await app.ask("why?", images=[image])
|
||||||
|
|
||||||
|
assert client.ask.await_args.kwargs["images"] == [b"img"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_analyze_prints_the_answer(app, client, monkeypatch):
|
||||||
|
result = AsyncMock()
|
||||||
|
result.answer = "computed answer"
|
||||||
|
result.citations = []
|
||||||
|
client.analyze.return_value = result
|
||||||
|
|
||||||
|
async def no_citations(citations, client=None):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations)
|
||||||
|
|
||||||
|
await app.analyze("how many?")
|
||||||
|
|
||||||
|
printed = out(app)
|
||||||
|
assert "how many?" in printed
|
||||||
|
assert "computed answer" in printed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rebuild_set_embedder_reports_settings_updated(app, client):
|
||||||
|
async def one_document(mode):
|
||||||
|
yield "doc-1"
|
||||||
|
|
||||||
|
client.rebuild_database = one_document
|
||||||
|
|
||||||
|
await app.rebuild(mode=RebuildMode.SET_EMBEDDER)
|
||||||
|
|
||||||
|
assert "Stored embedder settings updated" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rebuild_reports_an_empty_database(app, client):
|
||||||
|
client.list_documents.return_value = []
|
||||||
|
|
||||||
|
await app.rebuild(mode=RebuildMode.FULL)
|
||||||
|
|
||||||
|
assert "No documents found" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"mode, description",
|
||||||
|
[
|
||||||
|
(RebuildMode.FULL, "full rebuild"),
|
||||||
|
(RebuildMode.RECHUNK, "rechunk"),
|
||||||
|
(RebuildMode.EMBED_ONLY, "embed only"),
|
||||||
|
(RebuildMode.TITLE_ONLY, "title only"),
|
||||||
|
(RebuildMode.DESCRIPTIONS, "picture descriptions"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_rebuild_names_the_mode_and_completes(app, client, mode, description):
|
||||||
|
client.list_documents.return_value = [_doc()]
|
||||||
|
|
||||||
|
async def one_document(mode):
|
||||||
|
yield "doc-1"
|
||||||
|
|
||||||
|
client.rebuild_database = one_document
|
||||||
|
|
||||||
|
await app.rebuild(mode=mode)
|
||||||
|
|
||||||
|
printed = out(app)
|
||||||
|
assert description in printed
|
||||||
|
assert "rebuild completed successfully" in printed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_vacuum_confirms(app, client):
|
||||||
|
await app.vacuum()
|
||||||
|
|
||||||
|
client.vacuum.assert_awaited_once()
|
||||||
|
assert "Vacuum completed successfully" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_index_refuses_a_small_table(app, client):
|
||||||
|
client.store.chunks_table.count_rows = AsyncMock(return_value=10)
|
||||||
|
|
||||||
|
await app.create_index()
|
||||||
|
|
||||||
|
assert "Need at least 256 chunks" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_index_creates_one(app, client):
|
||||||
|
client.store.chunks_table.count_rows = AsyncMock(return_value=512)
|
||||||
|
client.store.chunks_table.list_indices = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
await app.create_index()
|
||||||
|
|
||||||
|
client.store._ensure_vector_index.assert_awaited_once()
|
||||||
|
assert "Vector index created successfully" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_index_rebuilds_an_existing_one(app, client):
|
||||||
|
client.store.chunks_table.count_rows = AsyncMock(return_value=512)
|
||||||
|
client.store.chunks_table.list_indices = AsyncMock(return_value=["vector_idx"])
|
||||||
|
|
||||||
|
await app.create_index()
|
||||||
|
|
||||||
|
assert "Rebuilding existing vector index" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_settings_hides_secrets(tmp_path):
|
||||||
|
config = AppConfig(lancedb=LanceDBConfig(uri="db://x", api_key="secret-value"))
|
||||||
|
app = HaikuRAGApp(db_path=tmp_path / "db", config=config)
|
||||||
|
app.console = Console(record=True, width=200)
|
||||||
|
|
||||||
|
app.show_settings()
|
||||||
|
|
||||||
|
printed = app.console.export_text()
|
||||||
|
assert "haiku.rag configuration" in printed
|
||||||
|
assert "secret-value" not in printed
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_uri_is_the_display_path(tmp_path):
|
||||||
|
config = AppConfig(lancedb=LanceDBConfig(uri="s3://bucket/path"))
|
||||||
|
app = HaikuRAGApp(db_path=tmp_path / "db", config=config)
|
||||||
|
|
||||||
|
assert app._display_path == "s3://bucket/path"
|
||||||
|
assert app._is_local is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- paths that do not open a client -----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store_stub(monkeypatch):
|
||||||
|
"""Stub the Store for the paths that use it directly (history, migrate, tags)."""
|
||||||
|
store = AsyncMock()
|
||||||
|
|
||||||
|
class StubStore:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return store
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.store.engine.Store", StubStore)
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
async def test_init_reports_an_existing_database(app):
|
||||||
|
await app.init()
|
||||||
|
|
||||||
|
assert "Database already exists" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_info_reports_a_missing_path(tmp_path):
|
||||||
|
application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig())
|
||||||
|
application.console = Console(record=True, width=200)
|
||||||
|
|
||||||
|
await application.info()
|
||||||
|
|
||||||
|
assert "Database path does not exist" in application.console.export_text(
|
||||||
|
clear=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_reports_a_missing_path(tmp_path):
|
||||||
|
application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig())
|
||||||
|
application.console = Console(record=True, width=200)
|
||||||
|
|
||||||
|
await application.history()
|
||||||
|
|
||||||
|
assert "Database path does not exist" in application.console.export_text(
|
||||||
|
clear=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_rejects_an_unknown_table(app, store_stub):
|
||||||
|
await app.history(table="nope")
|
||||||
|
|
||||||
|
assert "Unknown table: nope" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migrate_returns_what_the_store_applied(app, store_stub):
|
||||||
|
store_stub.migrate.return_value = ["v0_40_0"]
|
||||||
|
|
||||||
|
assert await app.migrate() == ["v0_40_0"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_tags_reports_none(app, monkeypatch):
|
||||||
|
store = AsyncMock()
|
||||||
|
store.list_tags.return_value = {}
|
||||||
|
monkeypatch.setattr(app, "_tag_read_store", lambda: _as_cm(store))
|
||||||
|
|
||||||
|
await app.list_tags()
|
||||||
|
|
||||||
|
assert "No tags" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_tags_flags_a_partial_tag(app, monkeypatch):
|
||||||
|
from haiku.rag.store.engine import TagInfo
|
||||||
|
|
||||||
|
store = AsyncMock()
|
||||||
|
store.list_tags.return_value = {
|
||||||
|
"release-1": TagInfo(
|
||||||
|
tables={"documents": 3},
|
||||||
|
missing_tables=["chunks"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(app, "_tag_read_store", lambda: _as_cm(store))
|
||||||
|
|
||||||
|
await app.list_tags()
|
||||||
|
|
||||||
|
printed = out(app)
|
||||||
|
assert "release-1" in printed
|
||||||
|
assert "partial" in printed
|
||||||
|
assert "chunks" in printed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_and_delete_tag_confirm(app, monkeypatch):
|
||||||
|
store = AsyncMock()
|
||||||
|
monkeypatch.setattr(app, "_tag_write_store", lambda: _as_cm(store))
|
||||||
|
|
||||||
|
await app.create_tag("release-1")
|
||||||
|
await app.delete_tag("release-1")
|
||||||
|
|
||||||
|
printed = out(app)
|
||||||
|
assert "Created tag 'release-1'" in printed
|
||||||
|
assert "Deleted tag 'release-1'" in printed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_restore_tag_reports_the_safety_tag(app, monkeypatch):
|
||||||
|
store = AsyncMock()
|
||||||
|
store.restore_tag.return_value = "before-restore-20260820T000000Z"
|
||||||
|
monkeypatch.setattr(app, "_tag_write_store", lambda: _as_cm(store))
|
||||||
|
|
||||||
|
await app.restore_tag("release-1")
|
||||||
|
|
||||||
|
assert "Restored database to tag 'release-1'" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method, args",
|
||||||
|
[
|
||||||
|
("create_tag", ("release-1",)),
|
||||||
|
("list_tags", ()),
|
||||||
|
("delete_tag", ("release-1",)),
|
||||||
|
("restore_tag", ("release-1",)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_tag_operations_require_the_database(tmp_path, method, args):
|
||||||
|
application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig())
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="does not exist"):
|
||||||
|
await getattr(application, method)(*args)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_visualize_prints_a_page_per_image(app, client, monkeypatch):
|
||||||
|
chunk = Chunk(content="c", order=0)
|
||||||
|
chunk.document_uri = "test://doc"
|
||||||
|
client.get_chunk_by_id.return_value = chunk
|
||||||
|
client.visualize_chunk.return_value = [object(), object()]
|
||||||
|
monkeypatch.setattr("textual_image.renderable.Image", lambda img: "<page image>")
|
||||||
|
|
||||||
|
await app.visualize_chunk("c1")
|
||||||
|
|
||||||
|
printed = out(app)
|
||||||
|
assert "Visual grounding for chunk c1" in printed
|
||||||
|
assert "Page 1/2" in printed
|
||||||
|
assert "Page 2/2" in printed
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_result_rendering_includes_provenance(app):
|
||||||
|
result = SearchResult(
|
||||||
|
content="hit",
|
||||||
|
score=0.5,
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="doc-1",
|
||||||
|
document_uri="test://doc",
|
||||||
|
document_title="The Title",
|
||||||
|
page_numbers=[2, 3],
|
||||||
|
headings=["Chapter", "Section"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app._rich_print_search_result(result)
|
||||||
|
|
||||||
|
printed = out(app)
|
||||||
|
assert "The Title" in printed
|
||||||
|
assert "2, 3" in printed
|
||||||
|
assert "Chapter > Section" in printed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_mcp_stdio(app, client, monkeypatch):
|
||||||
|
server = AsyncMock()
|
||||||
|
monkeypatch.setattr("haiku.rag.app.create_mcp_server", lambda *a, **kw: server)
|
||||||
|
|
||||||
|
await app.run_mcp(transport="stdio")
|
||||||
|
|
||||||
|
server.run_stdio_async.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_mcp_http(app, client, monkeypatch):
|
||||||
|
server = AsyncMock()
|
||||||
|
monkeypatch.setattr("haiku.rag.app.create_mcp_server", lambda *a, **kw: server)
|
||||||
|
|
||||||
|
await app.run_mcp(host="0.0.0.0", port=9001)
|
||||||
|
|
||||||
|
server.run_http_async.assert_awaited_once_with(
|
||||||
|
transport="streamable-http", host="0.0.0.0", port=9001
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_mcp_survives_interruption(app, client, monkeypatch):
|
||||||
|
server = AsyncMock()
|
||||||
|
server.run_stdio_async.side_effect = KeyboardInterrupt
|
||||||
|
monkeypatch.setattr("haiku.rag.app.create_mcp_server", lambda *a, **kw: server)
|
||||||
|
|
||||||
|
await app.run_mcp(transport="stdio")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_cm(store):
|
||||||
|
class _CM:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return store
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return _CM()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_doctor_renders_the_report(app, monkeypatch):
|
||||||
|
from haiku.rag.doctor import CheckResult, Severity
|
||||||
|
|
||||||
|
class Report:
|
||||||
|
results = [
|
||||||
|
CheckResult(name="tables", severity=Severity.OK, message="tables present"),
|
||||||
|
CheckResult(
|
||||||
|
name="orphans",
|
||||||
|
severity=Severity.WARN,
|
||||||
|
message="2 orphaned chunks",
|
||||||
|
details=["chunk #1", "chunk #2"],
|
||||||
|
remediation="run haiku-rag rebuild",
|
||||||
|
),
|
||||||
|
CheckResult(
|
||||||
|
name="provider:ollama",
|
||||||
|
severity=Severity.FAIL,
|
||||||
|
message="ollama unreachable",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
failed = True
|
||||||
|
|
||||||
|
def count(self, severity):
|
||||||
|
return sum(1 for r in self.results if r.severity is severity)
|
||||||
|
|
||||||
|
async def report(*args, **kwargs):
|
||||||
|
kwargs["on_progress"]("checking tables")
|
||||||
|
return Report()
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.doctor.run_doctor", report)
|
||||||
|
|
||||||
|
failed = await app.doctor()
|
||||||
|
|
||||||
|
assert failed is True
|
||||||
|
printed = out(app)
|
||||||
|
assert "tables present" in printed
|
||||||
|
assert "2 orphaned chunks" in printed
|
||||||
|
assert "chunk #1" in printed
|
||||||
|
assert "run haiku-rag rebuild" in printed
|
||||||
|
# providers are reported under their own rule
|
||||||
|
assert "ollama unreachable" in printed
|
||||||
|
assert "1 ok" in printed and "1 warning(s)" in printed and "1 failure(s)" in printed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_doctor_reports_the_duplicates_export(app, monkeypatch, tmp_path):
|
||||||
|
class Report:
|
||||||
|
results = []
|
||||||
|
failed = False
|
||||||
|
|
||||||
|
def count(self, severity):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def report(*args, **kwargs):
|
||||||
|
return Report()
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.doctor.run_doctor", report)
|
||||||
|
target = tmp_path / "dupes.yaml"
|
||||||
|
|
||||||
|
assert await app.doctor(duplicates_out=target) is False
|
||||||
|
assert f"written to {target}" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_doctor_reports_a_missing_database(tmp_path):
|
||||||
|
application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig())
|
||||||
|
application.console = Console(record=True, width=200)
|
||||||
|
|
||||||
|
assert await application.doctor() is True
|
||||||
|
assert "does not exist" in application.console.export_text(clear=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_models_reports_each_stage(app, monkeypatch):
|
||||||
|
from haiku.rag.client.downloads import DownloadProgress
|
||||||
|
|
||||||
|
events = [
|
||||||
|
DownloadProgress(status="start", model="docling"),
|
||||||
|
DownloadProgress(status="done", model="docling"),
|
||||||
|
DownloadProgress(status="pulling", model="qwen3-embedding"),
|
||||||
|
DownloadProgress(
|
||||||
|
status="downloading",
|
||||||
|
model="qwen3-embedding",
|
||||||
|
digest="sha256:abcdef0123456789",
|
||||||
|
total=100,
|
||||||
|
completed=50,
|
||||||
|
),
|
||||||
|
DownloadProgress(
|
||||||
|
status="verifying sha256 digest",
|
||||||
|
model="qwen3-embedding",
|
||||||
|
),
|
||||||
|
DownloadProgress(status="done", model="qwen3-embedding"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def stream(config):
|
||||||
|
for event in events:
|
||||||
|
yield event
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.client.downloads.download_models", stream)
|
||||||
|
|
||||||
|
await app.download_models()
|
||||||
|
|
||||||
|
printed = out(app)
|
||||||
|
assert "Downloading docling" in printed
|
||||||
|
assert "Pulling qwen3-embedding" in printed
|
||||||
|
assert "qwen3-embedding" in printed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_document_content_is_truncated_in_lists(app, client):
|
||||||
|
long_doc = _doc("l1\nl2\nl3\nl4\nl5")
|
||||||
|
client.list_documents.return_value = [long_doc]
|
||||||
|
|
||||||
|
await app.list_documents()
|
||||||
|
|
||||||
|
printed = out(app)
|
||||||
|
assert "l1" in printed
|
||||||
|
assert "l5" not in printed
|
||||||
|
|
||||||
|
|
||||||
|
def _database_info(vector_index, chunk_rows=300):
|
||||||
|
"""A complete DatabaseInfo: info() indexes every required table by name."""
|
||||||
|
from haiku.rag.store.info import (
|
||||||
|
DatabaseInfo,
|
||||||
|
EmbeddingsInfo,
|
||||||
|
TableInfo,
|
||||||
|
VectorIndexInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = {"chunks": chunk_rows}
|
||||||
|
return DatabaseInfo(
|
||||||
|
path="/tmp/db.lancedb",
|
||||||
|
exists=True,
|
||||||
|
stored_version="0.75.0",
|
||||||
|
embeddings=EmbeddingsInfo(provider="ollama", name="m", vector_dim=3),
|
||||||
|
tables=[
|
||||||
|
TableInfo(
|
||||||
|
name=name,
|
||||||
|
exists=True,
|
||||||
|
num_rows=rows.get(name, 1),
|
||||||
|
num_versions=2 if name == "documents" else 3,
|
||||||
|
)
|
||||||
|
for name in (
|
||||||
|
"documents",
|
||||||
|
"document_meta",
|
||||||
|
"chunks",
|
||||||
|
"document_items",
|
||||||
|
"settings",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
vector_index=VectorIndexInfo(**vector_index),
|
||||||
|
# info() indexes these by name when printing the version block.
|
||||||
|
packages={
|
||||||
|
"haiku_rag": "0.75.0",
|
||||||
|
"lancedb": "0.26.0",
|
||||||
|
"docling": "2.102.2",
|
||||||
|
"pydantic_ai": "2.18.0",
|
||||||
|
"docling_document_schema": "1.7.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_info_reports_unindexed_chunks_and_document_meta_versions(
|
||||||
|
app, monkeypatch
|
||||||
|
):
|
||||||
|
"""The index-status branches read from gather_database_info, so drive them
|
||||||
|
through a stubbed report rather than building databases."""
|
||||||
|
info = _database_info({"exists": True, "indexed_rows": 200, "unindexed_rows": 100})
|
||||||
|
|
||||||
|
async def stub_info(config, db_path):
|
||||||
|
return info
|
||||||
|
|
||||||
|
# info() imports it inside the method, so patch where it is looked up.
|
||||||
|
monkeypatch.setattr("haiku.rag.store.info.gather_database_info", stub_info)
|
||||||
|
|
||||||
|
await app.info()
|
||||||
|
|
||||||
|
printed = out(app)
|
||||||
|
assert "unindexed chunks" in printed
|
||||||
|
assert "100" in printed
|
||||||
|
assert "versions (document_meta)" in printed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_info_suggests_creating_an_index_when_there_are_enough_chunks(
|
||||||
|
app, monkeypatch
|
||||||
|
):
|
||||||
|
info = _database_info({"exists": False})
|
||||||
|
|
||||||
|
async def stub_info(config, db_path):
|
||||||
|
return info
|
||||||
|
|
||||||
|
# info() imports it inside the method, so patch where it is looked up.
|
||||||
|
monkeypatch.setattr("haiku.rag.store.info.gather_database_info", stub_info)
|
||||||
|
|
||||||
|
await app.info()
|
||||||
|
|
||||||
|
assert "haiku-rag create-index" in out(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_doctor_updates_a_terminal_status(app, monkeypatch):
|
||||||
|
"""On a terminal the checks report progress through console.status."""
|
||||||
|
|
||||||
|
class Report:
|
||||||
|
results = []
|
||||||
|
failed = False
|
||||||
|
|
||||||
|
def count(self, severity):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
labels: list[str] = []
|
||||||
|
|
||||||
|
async def report(*args, **kwargs):
|
||||||
|
kwargs["on_progress"]("scanning vectors")
|
||||||
|
return Report()
|
||||||
|
|
||||||
|
class Status:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update(self, label):
|
||||||
|
labels.append(label)
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.doctor.run_doctor", report)
|
||||||
|
monkeypatch.setattr(type(app.console), "is_terminal", property(lambda self: True))
|
||||||
|
monkeypatch.setattr(app.console, "status", lambda label: Status())
|
||||||
|
|
||||||
|
await app.doctor()
|
||||||
|
|
||||||
|
assert labels == ["scanning vectors..."]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_limits_the_versions_shown(app, monkeypatch):
|
||||||
|
store = AsyncMock()
|
||||||
|
store.list_tags.return_value = {}
|
||||||
|
store.list_table_versions.return_value = [
|
||||||
|
{"version": v, "timestamp": None} for v in (1, 2, 3)
|
||||||
|
]
|
||||||
|
|
||||||
|
class StubStore:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return store
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.store.engine.Store", StubStore)
|
||||||
|
|
||||||
|
await app.history(table="documents", limit=1)
|
||||||
|
|
||||||
|
printed = out(app)
|
||||||
|
assert "v3" in printed
|
||||||
|
assert "v1" not in printed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_analyze_prints_citation_renderables(app, client, monkeypatch):
|
||||||
|
result = AsyncMock()
|
||||||
|
result.answer = "computed"
|
||||||
|
result.citations = ["c1"]
|
||||||
|
client.analyze.return_value = result
|
||||||
|
|
||||||
|
async def one_citation(citations, client=None):
|
||||||
|
return ["citation renderable"]
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.app.format_citations_rich", one_citation)
|
||||||
|
|
||||||
|
await app.analyze("how many?")
|
||||||
|
|
||||||
|
assert "citation renderable" in out(app)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from click.exceptions import BadParameter
|
from click.exceptions import BadParameter
|
||||||
|
|
@ -349,3 +349,306 @@ class TestAskAnalyzeImageOption:
|
||||||
await app.ask("q", images=[img_path])
|
await app.ask("q", images=[img_path])
|
||||||
|
|
||||||
assert mock_ask.call_args.kwargs["images"] == [buffer.getvalue()]
|
assert mock_ask.call_args.kwargs["images"] == [buffer.getvalue()]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_stub(monkeypatch, tmp_path):
|
||||||
|
"""Stand in for HaikuRAGApp so a command's wiring can be checked without a
|
||||||
|
database or a model. These tests pin argument parsing and dispatch, not what
|
||||||
|
the application layer renders."""
|
||||||
|
# AsyncMock so every command's `asyncio.run(app.x(...))` gets a coroutine.
|
||||||
|
stub = AsyncMock()
|
||||||
|
monkeypatch.setattr("haiku.rag.cli.create_app", lambda db=None: stub)
|
||||||
|
return stub
|
||||||
|
|
||||||
|
|
||||||
|
DB_ARGS = ["--db", "/tmp/test.lancedb"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"argv, method, expected",
|
||||||
|
[
|
||||||
|
(["list"], "list_documents", {"filter": None}),
|
||||||
|
(
|
||||||
|
["list", "--filter", "uri LIKE 'x%'"],
|
||||||
|
"list_documents",
|
||||||
|
{"filter": "uri LIKE 'x%'"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
["add", "some text", "--title", "T"],
|
||||||
|
"add_document_from_text",
|
||||||
|
{"text": "some text", "title": "T", "metadata": None},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
["add-src", "/tmp/doc.md"],
|
||||||
|
"add_document_from_source",
|
||||||
|
{"source": "/tmp/doc.md", "title": None, "metadata": None},
|
||||||
|
),
|
||||||
|
(["get", "doc-1"], "get_document", {"doc_id": "doc-1"}),
|
||||||
|
(["delete", "doc-1"], "delete_document", {"doc_id": "doc-1"}),
|
||||||
|
(
|
||||||
|
["visualize", "chunk-1"],
|
||||||
|
"visualize_chunk",
|
||||||
|
{"chunk_id": "chunk-1", "expand": True},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
["visualize", "chunk-1", "--no-expand"],
|
||||||
|
"visualize_chunk",
|
||||||
|
{"chunk_id": "chunk-1", "expand": False},
|
||||||
|
),
|
||||||
|
(["vacuum"], "vacuum", {}),
|
||||||
|
(["create-index"], "create_index", {}),
|
||||||
|
(["init"], "init", {}),
|
||||||
|
(["info"], "info", {}),
|
||||||
|
# limit/search_type default to None: the app layer resolves the config
|
||||||
|
# default, so the CLI must not invent one.
|
||||||
|
(["history"], "history", {"table": None, "limit": None}),
|
||||||
|
(
|
||||||
|
["history", "--table", "chunks", "--limit", "5"],
|
||||||
|
"history",
|
||||||
|
{"table": "chunks", "limit": 5},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_command_dispatches_to_the_app(app_stub, argv, method, expected):
|
||||||
|
result = runner.invoke(cli, argv + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
getattr(app_stub, method).assert_called_once_with(**expected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"argv, expected",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
["search", "q"],
|
||||||
|
{
|
||||||
|
"query": "q",
|
||||||
|
"limit": None,
|
||||||
|
"filter": None,
|
||||||
|
"search_type": None,
|
||||||
|
"image": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
["search", "q", "--limit", "3", "--search-type", "vector"],
|
||||||
|
{
|
||||||
|
"query": "q",
|
||||||
|
"limit": 3,
|
||||||
|
"filter": None,
|
||||||
|
"search_type": "vector",
|
||||||
|
"image": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_search_dispatch(app_stub, argv, expected):
|
||||||
|
result = runner.invoke(cli, argv + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
app_stub.search.assert_called_once_with(**expected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("command, method", [("ask", "ask"), ("analyze", "analyze")])
|
||||||
|
def test_question_commands_dispatch(app_stub, command, method):
|
||||||
|
result = runner.invoke(cli, [command, "why?"] + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
getattr(app_stub, method).assert_called_once_with(
|
||||||
|
question="why?", filter=None, images=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"flag, mode_name",
|
||||||
|
[
|
||||||
|
(None, "FULL"),
|
||||||
|
("--embed-only", "EMBED_ONLY"),
|
||||||
|
("--rechunk", "RECHUNK"),
|
||||||
|
("--title-only", "TITLE_ONLY"),
|
||||||
|
("--descriptions", "DESCRIPTIONS"),
|
||||||
|
("--set-embedder", "SET_EMBEDDER"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rebuild_flag_selects_the_mode(app_stub, flag, mode_name):
|
||||||
|
"""Each flag picks one rebuild mode, and no flag means a full rebuild."""
|
||||||
|
result = runner.invoke(cli, ["rebuild"] + ([flag] if flag else []) + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
(_, kwargs) = app_stub.rebuild.call_args
|
||||||
|
assert kwargs["mode"].name == mode_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_reports_applied_migrations(app_stub):
|
||||||
|
app_stub.migrate.return_value = ["v0_40_0: add document_items"]
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["migrate"] + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "Applied 1 migration(s)" in result.output
|
||||||
|
assert "add document_items" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_reports_an_up_to_date_database(app_stub):
|
||||||
|
app_stub.migrate.return_value = []
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["migrate"] + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "No migrations pending" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_exits_nonzero_on_failure(app_stub):
|
||||||
|
app_stub.migrate.side_effect = RuntimeError("schema is from the future")
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["migrate"] + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Migration failed: schema is from the future" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_stdio_selects_the_transport(app_stub):
|
||||||
|
result = runner.invoke(cli, ["mcp", "--stdio"] + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
app_stub.run_mcp.assert_called_once()
|
||||||
|
kwargs = app_stub.run_mcp.call_args.kwargs
|
||||||
|
assert kwargs["transport"] == "stdio"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_without_stdio_leaves_the_transport_unset(app_stub):
|
||||||
|
result = runner.invoke(cli, ["mcp"] + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert app_stub.run_mcp.call_args.kwargs["transport"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_flag_prints_the_version():
|
||||||
|
result = runner.invoke(cli, ["--version"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "haiku.rag version" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_outdated_install_warns(app_stub, monkeypatch):
|
||||||
|
"""The startup check warns but does not block the command."""
|
||||||
|
|
||||||
|
async def outdated():
|
||||||
|
return False, "0.1.0", "9.9.9"
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.cli.is_up_to_date", outdated)
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["list"] + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "haiku.rag is outdated" in result.output
|
||||||
|
assert "Current: 0.1.0, Latest: 9.9.9" in result.output
|
||||||
|
app_stub.list_documents.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_up_to_date_install_says_nothing(app_stub, monkeypatch):
|
||||||
|
async def current():
|
||||||
|
return True, "9.9.9", "9.9.9"
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.cli.is_up_to_date", current)
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["list"] + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "outdated" not in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failing_version_check_does_not_block_the_cli(app_stub, monkeypatch):
|
||||||
|
"""PyPI being unreachable must not stop a command from running."""
|
||||||
|
|
||||||
|
async def boom():
|
||||||
|
raise RuntimeError("no network")
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.cli.is_up_to_date", boom)
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["list"] + DB_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
app_stub.list_documents.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_command_shows_the_configuration(monkeypatch):
|
||||||
|
shown = []
|
||||||
|
|
||||||
|
class StubApp:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
shown.append(kwargs)
|
||||||
|
|
||||||
|
def show_settings(self):
|
||||||
|
shown.append("shown")
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.app.HaikuRAGApp", StubApp)
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["settings"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "shown" in shown
|
||||||
|
assert shown[0]["read_only"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_models_reports_failure(monkeypatch):
|
||||||
|
class StubApp:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def download_models(self):
|
||||||
|
raise RuntimeError("hub unreachable")
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.app.HaikuRAGApp", StubApp)
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["download-models"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Error downloading models: hub unreachable" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_models_succeeds(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class StubApp:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def download_models(self):
|
||||||
|
calls.append("downloaded")
|
||||||
|
|
||||||
|
monkeypatch.setattr("haiku.rag.app.HaikuRAGApp", StubApp)
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["download-models"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert calls == ["downloaded"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_reports_a_missing_tui_extra(monkeypatch):
|
||||||
|
"""run_chat imports the Textual app itself, so a missing tui extra surfaces
|
||||||
|
from the call, not from importing haiku.rag.chat. The CLI must report it and
|
||||||
|
exit nonzero rather than traceback."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "haiku.rag.chat.app", None)
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["chat"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "textual is not installed" in result.output
|
||||||
|
assert "haiku.rag-slim[tui]" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_inspect_reports_a_missing_tui_extra(monkeypatch):
|
||||||
|
"""run_inspector raises at import instead, so the guard sits on the import."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
monkeypatch.delitem(sys.modules, "haiku.rag.inspector", raising=False)
|
||||||
|
monkeypatch.setitem(sys.modules, "haiku.rag.inspector.app", None)
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["inspect"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "textual is not installed" in result.output
|
||||||
|
assert "haiku.rag-slim[tui]" in result.output
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue