433 lines
18 KiB
Python
433 lines
18 KiB
Python
import asyncio
|
|
import json
|
|
from importlib.metadata import version as pkg_version
|
|
from pathlib import Path
|
|
|
|
from rich.console import Console
|
|
from rich.markdown import Markdown
|
|
from rich.progress import Progress
|
|
|
|
from haiku.rag.client import HaikuRAG
|
|
from haiku.rag.config import Config
|
|
from haiku.rag.mcp import create_mcp_server
|
|
from haiku.rag.monitor import FileWatcher
|
|
from haiku.rag.research.dependencies import ResearchContext
|
|
from haiku.rag.research.graph import (
|
|
PlanNode,
|
|
ResearchDeps,
|
|
ResearchState,
|
|
build_research_graph,
|
|
)
|
|
from haiku.rag.store.models.chunk import Chunk
|
|
from haiku.rag.store.models.document import Document
|
|
|
|
|
|
class HaikuRAGApp:
|
|
def __init__(self, db_path: Path):
|
|
self.db_path = db_path
|
|
self.console = Console()
|
|
|
|
async def info(self):
|
|
"""Display read-only information about the database without modifying it."""
|
|
|
|
import lancedb
|
|
|
|
# Basic: show path
|
|
self.console.print("[bold]haiku.rag database info[/bold]")
|
|
self.console.print(
|
|
f" [repr.attrib_name]path[/repr.attrib_name]: {self.db_path}"
|
|
)
|
|
|
|
if not self.db_path.exists():
|
|
self.console.print("[red]Database path does not exist.[/red]")
|
|
return
|
|
|
|
# Connect without going through Store to avoid upgrades/validation writes
|
|
try:
|
|
db = lancedb.connect(self.db_path)
|
|
table_names = set(db.table_names())
|
|
except Exception as e:
|
|
self.console.print(f"[red]Failed to open database: {e}[/red]")
|
|
return
|
|
|
|
try:
|
|
ldb_version = pkg_version("lancedb")
|
|
except Exception:
|
|
ldb_version = "unknown"
|
|
try:
|
|
hr_version = pkg_version("haiku.rag")
|
|
except Exception:
|
|
hr_version = "unknown"
|
|
try:
|
|
docling_version = pkg_version("docling")
|
|
except Exception:
|
|
docling_version = "unknown"
|
|
|
|
# Read settings (if present) to find stored haiku.rag version and embedding config
|
|
stored_version = "unknown"
|
|
embed_provider: str | None = None
|
|
embed_model: str | None = None
|
|
vector_dim: int | None = None
|
|
|
|
if "settings" in table_names:
|
|
settings_tbl = db.open_table("settings")
|
|
arrow = settings_tbl.search().where("id = 'settings'").limit(1).to_arrow()
|
|
rows = arrow.to_pylist() if arrow is not None else []
|
|
if rows:
|
|
raw = rows[0].get("settings") or "{}"
|
|
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
|
stored_version = str(data.get("version", stored_version))
|
|
embed_provider = data.get("EMBEDDINGS_PROVIDER")
|
|
embed_model = data.get("EMBEDDINGS_MODEL")
|
|
vector_dim = (
|
|
int(data.get("EMBEDDINGS_VECTOR_DIM")) # pyright: ignore[reportArgumentType]
|
|
if data.get("EMBEDDINGS_VECTOR_DIM") is not None
|
|
else None
|
|
)
|
|
|
|
num_docs = 0
|
|
if "documents" in table_names:
|
|
docs_tbl = db.open_table("documents")
|
|
num_docs = int(docs_tbl.count_rows()) # type: ignore[attr-defined]
|
|
|
|
# Table versions per table (direct API)
|
|
doc_versions = (
|
|
len(list(db.open_table("documents").list_versions()))
|
|
if "documents" in table_names
|
|
else 0
|
|
)
|
|
chunk_versions = (
|
|
len(list(db.open_table("chunks").list_versions()))
|
|
if "chunks" in table_names
|
|
else 0
|
|
)
|
|
|
|
self.console.print(
|
|
f" [repr.attrib_name]haiku.rag version (db)[/repr.attrib_name]: {stored_version}"
|
|
)
|
|
if embed_provider or embed_model or vector_dim:
|
|
provider_part = embed_provider or "unknown"
|
|
model_part = embed_model or "unknown"
|
|
dim_part = f"{vector_dim}" if vector_dim is not None else "unknown"
|
|
self.console.print(
|
|
" [repr.attrib_name]embeddings[/repr.attrib_name]: "
|
|
f"{provider_part}/{model_part} (dim: {dim_part})"
|
|
)
|
|
else:
|
|
self.console.print(
|
|
" [repr.attrib_name]embeddings[/repr.attrib_name]: unknown"
|
|
)
|
|
self.console.print(
|
|
f" [repr.attrib_name]documents[/repr.attrib_name]: {num_docs}"
|
|
)
|
|
self.console.print(
|
|
f" [repr.attrib_name]versions (documents)[/repr.attrib_name]: {doc_versions}"
|
|
)
|
|
self.console.print(
|
|
f" [repr.attrib_name]versions (chunks)[/repr.attrib_name]: {chunk_versions}"
|
|
)
|
|
self.console.rule()
|
|
self.console.print("[bold]Versions[/bold]")
|
|
self.console.print(
|
|
f" [repr.attrib_name]haiku.rag[/repr.attrib_name]: {hr_version}"
|
|
)
|
|
self.console.print(
|
|
f" [repr.attrib_name]lancedb[/repr.attrib_name]: {ldb_version}"
|
|
)
|
|
self.console.print(
|
|
f" [repr.attrib_name]docling[/repr.attrib_name]: {docling_version}"
|
|
)
|
|
|
|
async def list_documents(self):
|
|
async with HaikuRAG(db_path=self.db_path) as self.client:
|
|
documents = await self.client.list_documents()
|
|
for doc in documents:
|
|
self._rich_print_document(doc, truncate=True)
|
|
|
|
async def add_document_from_text(self, text: str):
|
|
async with HaikuRAG(db_path=self.db_path) as self.client:
|
|
doc = await self.client.create_document(text)
|
|
self._rich_print_document(doc, truncate=True)
|
|
self.console.print(
|
|
f"[bold green]Document {doc.id} added successfully.[/bold green]"
|
|
)
|
|
|
|
async def add_document_from_source(self, source: str, title: str | None = None):
|
|
async with HaikuRAG(db_path=self.db_path) as self.client:
|
|
doc = await self.client.create_document_from_source(source, title=title)
|
|
self._rich_print_document(doc, truncate=True)
|
|
self.console.print(
|
|
f"[bold green]Document {doc.id} added successfully.[/bold green]"
|
|
)
|
|
|
|
async def get_document(self, doc_id: str):
|
|
async with HaikuRAG(db_path=self.db_path) as self.client:
|
|
doc = await self.client.get_document_by_id(doc_id)
|
|
if doc is None:
|
|
self.console.print(f"[red]Document with id {doc_id} not found.[/red]")
|
|
return
|
|
self._rich_print_document(doc, truncate=False)
|
|
|
|
async def delete_document(self, doc_id: str):
|
|
async with HaikuRAG(db_path=self.db_path) as self.client:
|
|
deleted = await self.client.delete_document(doc_id)
|
|
if deleted:
|
|
self.console.print(
|
|
f"[bold green]Document {doc_id} deleted successfully.[/bold green]"
|
|
)
|
|
else:
|
|
self.console.print(
|
|
f"[yellow]Document with id {doc_id} not found.[/yellow]"
|
|
)
|
|
|
|
async def search(self, query: str, limit: int = 5):
|
|
async with HaikuRAG(db_path=self.db_path) as self.client:
|
|
results = await self.client.search(query, limit=limit)
|
|
if not results:
|
|
self.console.print("[yellow]No results found.[/yellow]")
|
|
return
|
|
for chunk, score in results:
|
|
self._rich_print_search_result(chunk, score)
|
|
|
|
async def ask(self, question: str, cite: bool = False):
|
|
async with HaikuRAG(db_path=self.db_path) as self.client:
|
|
try:
|
|
answer = await self.client.ask(question, cite=cite)
|
|
self.console.print(f"[bold blue]Question:[/bold blue] {question}")
|
|
self.console.print()
|
|
self.console.print("[bold green]Answer:[/bold green]")
|
|
self.console.print(Markdown(answer))
|
|
except Exception as e:
|
|
self.console.print(f"[red]Error: {e}[/red]")
|
|
|
|
async def research(
|
|
self,
|
|
question: str,
|
|
max_iterations: int = 3,
|
|
confidence_threshold: float = 0.8,
|
|
max_concurrency: int = 1,
|
|
verbose: bool = False,
|
|
):
|
|
"""Run research via the pydantic-graph pipeline (default)."""
|
|
async with HaikuRAG(db_path=self.db_path) as client:
|
|
try:
|
|
if verbose:
|
|
self.console.print("[bold cyan]Starting research[/bold cyan]")
|
|
self.console.print(f"[bold blue]Question:[/bold blue] {question}")
|
|
self.console.print()
|
|
|
|
graph = build_research_graph()
|
|
state = ResearchState(
|
|
question=question,
|
|
context=ResearchContext(original_question=question),
|
|
max_iterations=max_iterations,
|
|
confidence_threshold=confidence_threshold,
|
|
max_concurrency=max_concurrency,
|
|
)
|
|
deps = ResearchDeps(
|
|
client=client, console=self.console if verbose else None
|
|
)
|
|
|
|
start = PlanNode(
|
|
provider=Config.RESEARCH_PROVIDER or Config.QA_PROVIDER,
|
|
model=Config.RESEARCH_MODEL or Config.QA_MODEL,
|
|
)
|
|
# Prefer graph.run; fall back to iter if unavailable
|
|
report = None
|
|
try:
|
|
result = await graph.run(start, state=state, deps=deps)
|
|
report = result.output
|
|
except Exception:
|
|
from pydantic_graph import End
|
|
|
|
async with graph.iter(start, state=state, deps=deps) as run:
|
|
node = run.next_node
|
|
while not isinstance(node, End):
|
|
node = await run.next(node)
|
|
if run.result:
|
|
report = run.result.output
|
|
if report is None:
|
|
raise RuntimeError("Graph did not produce a report")
|
|
|
|
# Display the report
|
|
self.console.print("[bold green]Research Report[/bold green]")
|
|
self.console.rule()
|
|
|
|
# Title and Executive Summary
|
|
self.console.print(f"[bold]{report.title}[/bold]")
|
|
self.console.print()
|
|
self.console.print("[bold cyan]Executive Summary:[/bold cyan]")
|
|
self.console.print(report.executive_summary)
|
|
self.console.print()
|
|
|
|
# Confidence (from last evaluation)
|
|
if state.last_eval:
|
|
conf = state.last_eval.confidence_score # type: ignore[attr-defined]
|
|
self.console.print(f"[bold cyan]Confidence:[/bold cyan] {conf:.1%}")
|
|
self.console.print()
|
|
|
|
# Main Findings
|
|
if report.main_findings:
|
|
self.console.print("[bold cyan]Main Findings:[/bold cyan]")
|
|
for finding in report.main_findings:
|
|
self.console.print(f"• {finding}")
|
|
self.console.print()
|
|
|
|
# (Themes section removed)
|
|
|
|
# Conclusions
|
|
if report.conclusions:
|
|
self.console.print("[bold cyan]Conclusions:[/bold cyan]")
|
|
for conclusion in report.conclusions:
|
|
self.console.print(f"• {conclusion}")
|
|
self.console.print()
|
|
|
|
# Recommendations
|
|
if report.recommendations:
|
|
self.console.print("[bold cyan]Recommendations:[/bold cyan]")
|
|
for rec in report.recommendations:
|
|
self.console.print(f"• {rec}")
|
|
self.console.print()
|
|
|
|
# Limitations
|
|
if report.limitations:
|
|
self.console.print("[bold yellow]Limitations:[/bold yellow]")
|
|
for limitation in report.limitations:
|
|
self.console.print(f"• {limitation}")
|
|
self.console.print()
|
|
|
|
# Sources Summary
|
|
if report.sources_summary:
|
|
self.console.print("[bold cyan]Sources:[/bold cyan]")
|
|
self.console.print(report.sources_summary)
|
|
|
|
except Exception as e:
|
|
self.console.print(f"[red]Error during research: {e}[/red]")
|
|
|
|
async def rebuild(self):
|
|
async with HaikuRAG(db_path=self.db_path, skip_validation=True) as client:
|
|
try:
|
|
documents = await client.list_documents()
|
|
total_docs = len(documents)
|
|
|
|
if total_docs == 0:
|
|
self.console.print(
|
|
"[yellow]No documents found in database.[/yellow]"
|
|
)
|
|
return
|
|
|
|
self.console.print(
|
|
f"[bold cyan]Rebuilding database with {total_docs} documents...[/bold cyan]"
|
|
)
|
|
with Progress() as progress:
|
|
task = progress.add_task("Rebuilding...", total=total_docs)
|
|
async for _ in client.rebuild_database():
|
|
progress.update(task, advance=1)
|
|
|
|
self.console.print(
|
|
"[bold green]Database rebuild completed successfully.[/bold green]"
|
|
)
|
|
except Exception as e:
|
|
self.console.print(f"[red]Error rebuilding database: {e}[/red]")
|
|
|
|
async def vacuum(self):
|
|
"""Run database maintenance: optimize and cleanup table history."""
|
|
try:
|
|
async with HaikuRAG(db_path=self.db_path, skip_validation=True) as client:
|
|
await client.vacuum()
|
|
self.console.print(
|
|
"[bold green]Vacuum completed successfully.[/bold green]"
|
|
)
|
|
except Exception as e:
|
|
self.console.print(f"[red]Error during vacuum: {e}[/red]")
|
|
|
|
def show_settings(self):
|
|
"""Display current configuration settings."""
|
|
self.console.print("[bold]haiku.rag configuration[/bold]")
|
|
self.console.print()
|
|
|
|
# Get all config fields dynamically
|
|
for field_name, field_value in Config.model_dump().items():
|
|
# Format the display value
|
|
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(
|
|
f" [repr.attrib_name]{field_name}[/repr.attrib_name]: {display_value}"
|
|
)
|
|
|
|
def _rich_print_document(self, doc: Document, truncate: bool = False):
|
|
"""Format a document for display."""
|
|
if truncate:
|
|
content = doc.content.splitlines()
|
|
if len(content) > 3:
|
|
content = content[:3] + ["\n…"]
|
|
content = "\n".join(content)
|
|
content = Markdown(content)
|
|
else:
|
|
content = Markdown(doc.content)
|
|
title_part = (
|
|
f" [repr.attrib_name]title[/repr.attrib_name]: {doc.title}"
|
|
if doc.title
|
|
else ""
|
|
)
|
|
self.console.print(
|
|
f"[repr.attrib_name]id[/repr.attrib_name]: {doc.id} "
|
|
f"[repr.attrib_name]uri[/repr.attrib_name]: {doc.uri}"
|
|
+ title_part
|
|
+ f" [repr.attrib_name]meta[/repr.attrib_name]: {doc.metadata}"
|
|
)
|
|
self.console.print(
|
|
f"[repr.attrib_name]created at[/repr.attrib_name]: {doc.created_at} [repr.attrib_name]updated at[/repr.attrib_name]: {doc.updated_at}"
|
|
)
|
|
self.console.print("[repr.attrib_name]content[/repr.attrib_name]:")
|
|
self.console.print(content)
|
|
self.console.rule()
|
|
|
|
def _rich_print_search_result(self, chunk: Chunk, score: float):
|
|
"""Format a search result chunk for display."""
|
|
content = Markdown(chunk.content)
|
|
self.console.print(
|
|
f"[repr.attrib_name]document_id[/repr.attrib_name]: {chunk.document_id} "
|
|
f"[repr.attrib_name]score[/repr.attrib_name]: {score:.4f}"
|
|
)
|
|
if chunk.document_uri:
|
|
self.console.print("[repr.attrib_name]document uri[/repr.attrib_name]:")
|
|
self.console.print(chunk.document_uri)
|
|
if chunk.document_title:
|
|
self.console.print("[repr.attrib_name]document title[/repr.attrib_name]:")
|
|
self.console.print(chunk.document_title)
|
|
if chunk.document_meta:
|
|
self.console.print("[repr.attrib_name]document meta[/repr.attrib_name]:")
|
|
self.console.print(chunk.document_meta)
|
|
self.console.print("[repr.attrib_name]content[/repr.attrib_name]:")
|
|
self.console.print(content)
|
|
self.console.rule()
|
|
|
|
async def serve(self, transport: str | None = None):
|
|
"""Start the MCP server."""
|
|
async with HaikuRAG(self.db_path) as client:
|
|
monitor = FileWatcher(paths=Config.MONITOR_DIRECTORIES, client=client)
|
|
monitor_task = asyncio.create_task(monitor.observe())
|
|
server = create_mcp_server(self.db_path)
|
|
|
|
try:
|
|
if transport == "stdio":
|
|
await server.run_stdio_async()
|
|
else:
|
|
await server.run_http_async(transport="streamable-http")
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
monitor_task.cancel()
|
|
try:
|
|
await monitor_task
|
|
except asyncio.CancelledError:
|
|
pass
|