PERI-AI-dev / app_cli.py
LIANJie-Jason's picture
Claude Opus 4.6
fix: Round 14 β€” 28 Tier 1 + Tier 2 bugs fixed across 17 source files
f95cdb7
Raw
History Blame Contribute Delete
18.2 kB
"""Rich-powered CLI chatbot with slash commands and RAG pipeline."""
import sys
from rich.console import Console
from rich.markup import escape as rich_escape
from rich.panel import Panel
from rich.markdown import Markdown
from rich.text import Text
from src.config_loader import load_config, get_api_key
from src.ingest import ingest_documents
from src.kb_meta import load_kb_meta_brief
from src.query_engine import understand_query
from src.retriever import clear_collection_cache, retrieve
from src.verifier import verify_and_respond
from src.llm import list_models
console = Console()
# ── Slash-command definitions ────────────────────────────────────────────────
HELP_TEXT = """\
Available commands:
/help Show this help message
/sources Show sources from the last response
/ingest Re-ingest documents from knowledge_base/
/model Switch LLM model interactively
/websearch on|off Toggle web search override for this session
/quit /exit /q Exit the chatbot
"""
def handle_command(user_input: str, cfg: dict, state: dict) -> str | None:
"""Handle slash commands.
Returns:
A string to display (help text, status message, etc.),
or a sentinel string for special actions:
- ``"__QUIT__"`` -- caller should exit
- ``"__INGEST__"`` -- caller should run ingestion
- ``"__MODEL__"`` -- caller should run model switch
``None`` if *user_input* is not a slash command (i.e. a regular query).
"""
stripped = user_input.strip()
if not stripped.startswith("/"):
return None
parts = stripped.split(None, 1)
cmd = parts[0].lower()
arg = parts[1].strip() if len(parts) > 1 else ""
if cmd in ("/quit", "/exit", "/q"):
return "__QUIT__"
if cmd == "/help":
return HELP_TEXT
if cmd == "/sources":
last = state.get("last_retrieval")
if last is None:
return "No previous query. Ask a question first."
return _format_sources(last)
if cmd == "/ingest":
return "__INGEST__"
if cmd == "/model":
return "__MODEL__"
if cmd == "/websearch":
if arg.lower() == "on":
state["web_search_override"] = True
return "Web search enabled for this session."
elif arg.lower() == "off":
state["web_search_override"] = False
return "Web search disabled for this session."
else:
current = state.get("web_search_override")
if current is None:
status = "using config default"
else:
status = "on" if current else "off"
return f"Usage: /websearch on|off (currently: {status})"
return f"Unknown command: {cmd}. Type /help for available commands."
# ── Source formatting ────────────────────────────────────────────────────────
def _format_sources(retrieval_result: dict) -> str:
"""Format sources from a retrieval result for display."""
lines: list[str] = []
db_results = retrieval_result.get("db_results", [])
web_results = retrieval_result.get("web_results", [])
sql_results = retrieval_result.get("sql_results", [])
if not db_results and not web_results and not sql_results:
return "No sources were used for the last response."
if db_results:
lines.append("Local Sources:")
for i, chunk in enumerate(db_results, 1):
meta = chunk.get("metadata", {})
source = meta.get("source", "unknown")
page = meta.get("page", "?")
dataset = meta.get("dataset", "")
lines.append(f" [{i}] {source}, page {page} [{dataset}]")
if sql_results:
if db_results:
lines.append("")
sql_match = retrieval_result.get("sql_match_type", "")
match_note = f" (matched via {sql_match} match)" if sql_match else ""
lines.append(f"SQL Results: {len(sql_results)} row(s) returned{match_note}")
if web_results:
if db_results or sql_results:
lines.append("")
lines.append("Web Sources:")
for i, r in enumerate(web_results, 1):
year_str = f" ({r.get('year', '')})" if r.get("year") else ""
lines.append(f" [{i}] {r.get('authors', 'Unknown')}{year_str}. "
f"\"{r.get('title', 'Untitled')}\"")
if r.get("url"):
lines.append(f" {r.get('url', '')}")
return "\n".join(lines)
# ── Model switching ──────────────────────────────────────────────────────────
def _handle_model_switch(cfg: dict) -> None:
"""Interactively switch the LLM model."""
provider = cfg.get("llm", {}).get("provider", "openai")
current_model = cfg.get("llm", {}).get("model", "")
console.print(f"\nCurrent provider: [bold]{provider}[/bold]")
console.print(f"Current model: [bold]{current_model}[/bold]\n")
api_key = get_api_key(cfg, provider)
if not api_key:
console.print("[red]No API key set for this provider. Cannot list models.[/red]")
return
with console.status("Fetching available models..."):
try:
models = list_models(provider, api_key)
except Exception as e:
console.print(f"[red]Error fetching models: {e}[/red]")
return
if not models:
console.print("[yellow]No models found.[/yellow]")
return
console.print("Available models:")
for i, m in enumerate(models, 1):
marker = " [bold green]<-- current[/bold green]" if m == current_model else ""
console.print(f" {i:3d}. {m}{marker}")
console.print(f"\nEnter a number (1-{len(models)}) or press Enter to cancel:")
try:
choice = input("> ").strip()
except (EOFError, KeyboardInterrupt):
console.print("\nCancelled.")
return
if not choice:
console.print("Cancelled.")
return
try:
idx = int(choice) - 1
if 0 <= idx < len(models):
cfg.setdefault("llm", {})["model"] = models[idx]
console.print(f"[green]Model switched to: {models[idx]}[/green]")
else:
console.print("[red]Invalid selection.[/red]")
except ValueError:
console.print("[red]Invalid input. Enter a number.[/red]")
# ── Main loop ────────────────────────────────────────────────────────────────
def main() -> None:
"""Run the CLI chatbot main loop."""
try:
cfg = load_config()
except Exception as e:
console.print(f"[red]Failed to load config: {e}[/red]")
console.print("Run the setup wizard first or create config.yaml manually.")
sys.exit(1)
bot_name = cfg.get("chatbot", {}).get("name", "ResearchBot")
domain = cfg.get("chatbot", {}).get("domain", "research")
# Welcome panel
welcome = Text()
welcome.append(f"{bot_name}", style="bold cyan")
welcome.append(f" -- Your {domain} research assistant\n\n", style="dim")
welcome.append(
f"Hi! I'm your research assistant on {domain}. Ask me anything about "
"the documents in your knowledge base β€” I'll search through them, "
"cite my sources with numbered references, and verify every answer "
"before showing it to you. If I can't find it in your documents, "
"I'll let you know rather than guess.\n\n",
)
web_enabled = cfg.get("web_search", {}).get("enabled", False)
if web_enabled:
welcome.append("Web search: ", style="bold")
welcome.append("ON", style="bold green")
welcome.append(" (Semantic Scholar). Use /websearch off to disable.\n\n")
else:
welcome.append("Web search: ", style="bold")
welcome.append("OFF", style="bold red")
welcome.append(". Use /websearch on to enable.\n\n")
# KB overview summary (LLM-generated welcome summary)
kb_summary = load_kb_meta_brief(cfg)
if kb_summary:
welcome.append("Knowledge Base:\n", style="bold")
welcome.append(kb_summary + "\n\n", style="dim")
welcome.append("Type your question, or /help for commands.\n", style="italic")
welcome.append("Type /quit to exit.", style="italic dim")
console.print(Panel(welcome, title="Welcome", border_style="cyan"))
# Session state
state: dict = {
"last_retrieval": None,
"web_search_override": None,
"conversation_history": [],
}
while True:
console.print()
try:
user_input = input("You> ").strip()
except (EOFError, KeyboardInterrupt):
console.print("\n[dim]Goodbye![/dim]")
break
if not user_input:
continue
# ── Handle slash commands ────────────────────────────────────────
result = handle_command(user_input, cfg, state)
if result == "__QUIT__":
console.print("[dim]Goodbye![/dim]")
break
if result == "__INGEST__":
console.print()
with console.status("Ingesting documents..."):
try:
count = ingest_documents(cfg)
clear_collection_cache()
console.print(f"[green]Ingestion complete: {count} chunks.[/green]")
except Exception as e:
console.print(f"[red]Ingestion error: {rich_escape(str(e))}[/red]")
continue
if result == "__MODEL__":
_handle_model_switch(cfg)
continue
if result is not None:
# A display string (help text, sources, etc.)
console.print(result)
continue
# ── RAG pipeline ─────────────────────────────────────────────────
# Apply web search override if set
effective_cfg = cfg
if state.get("web_search_override") is not None:
# Shallow copy to avoid mutating config permanently
effective_cfg = {**cfg}
effective_cfg["web_search"] = {
**cfg.get("web_search", {}),
"enabled": state["web_search_override"],
}
# ── Query understanding ─────────────────────────────────────────
qu_cfg = effective_cfg.get("query_understanding", {})
qu_enabled = qu_cfg.get("enabled", True)
max_clarifications = qu_cfg.get("max_clarifications", 1)
original_query = user_input
search_query = user_input
display_query = user_input
route = "vector"
sql_query = None
if qu_enabled:
with console.status("[bold blue]Understanding your question...[/bold blue]"):
try:
qu_result = understand_query(
user_input, effective_cfg,
state.get("conversation_history", []),
)
except Exception as e:
console.print(f"[yellow]Query understanding failed, using raw query: {rich_escape(str(e))}[/yellow]")
qu_result = {"action": "search", "search_query": user_input, "display_query": user_input, "original_query": user_input, "route": "vector", "sql_query": None}
# Handle clarification
clarification_rounds = 0
while qu_result.get("action") == "clarify" and clarification_rounds < max_clarifications:
clarification_question = qu_result.get('clarification_question', 'Could you be more specific?')
console.print(f"\n[bold yellow]Clarification needed:[/bold yellow] {clarification_question}")
try:
clarification = input("You> ").strip()
except (EOFError, KeyboardInterrupt):
clarification = ""
if not clarification:
break
# Add clarification exchange to history for pronoun resolution
state.setdefault("conversation_history", []).append(
{"role": "assistant", "content": clarification_question}
)
state["conversation_history"].append(
{"role": "user", "content": clarification}
)
combined = f"{user_input} (Clarification: Q: {clarification_question} A: {clarification})"
with console.status("[bold blue]Understanding your question...[/bold blue]"):
try:
qu_result = understand_query(
combined, effective_cfg,
state.get("conversation_history", []),
)
except Exception:
qu_result = {"action": "search", "search_query": combined, "display_query": combined, "original_query": user_input, "route": "vector", "sql_query": None}
clarification_rounds += 1
# After max clarification rounds, force search
if qu_result.get("action") == "clarify":
qu_result["action"] = "search"
search_query = qu_result.get("search_query", user_input)
display_query = qu_result.get("display_query", user_input)
route = qu_result.get("route", "vector")
sql_query = qu_result.get("sql_query")
# Show reformulated query if different from original
if search_query != user_input:
console.print(f"[dim]Searching for: \"{rich_escape(search_query)}\"[/dim]")
if route in ("sql", "both"):
console.print(f"[dim]Using SQL query for structured data[/dim]")
# ── Retrieve ────────────────────────────────────────────────────
with console.status("[bold blue]Searching knowledge base...[/bold blue]"):
try:
retrieval_result = retrieve(search_query, effective_cfg, route=route, sql_query=sql_query)
except Exception as e:
console.print(f"[red]Retrieval error: {rich_escape(str(e))}[/red]")
continue
state["last_retrieval"] = retrieval_result
# ── Generate + verify ───────────────────────────────────────────
with console.status("[bold blue]Generating response...[/bold blue]"):
try:
result = verify_and_respond(
display_query, retrieval_result, effective_cfg,
original_query=user_input,
)
except Exception as e:
console.print(f"[red]Generation error: {rich_escape(str(e))}[/red]")
continue
# ── Update conversation history ─────────────────────────────────
history = state.get("conversation_history", [])
max_history = qu_cfg.get("max_history", 6)
# Use display_query (post-QU) as the user message for history,
# since it captures clarification context and avoids duplicating
# the raw user_input that clarification already added.
effective_user_msg = display_query if display_query != user_input else user_input
history.append({"role": "user", "content": effective_user_msg})
history.append({"role": "assistant", "content": result.get("response", "")})
state["conversation_history"] = history[-max_history:]
# ── Display response ─────────────────────────────────────────────
response_text = result.get("response", "")
# Verification status line
status_parts = []
if result.get("refused"):
status_parts.append("[red]Refused[/red]")
elif result.get("verification_passed") is True:
status_parts.append("[green]Verified[/green]")
elif result.get("verification_passed") is False:
status_parts.append("[yellow]Verification failed[/yellow]")
else:
status_parts.append("[dim]Verification skipped[/dim]")
iterations = result.get("iterations", 0)
if iterations > 0:
status_parts.append(f"[dim]({iterations} iteration{'s' if iterations != 1 else ''})[/dim]")
status_line = " ".join(status_parts)
# Render with Rich
console.print()
console.print(Panel(
Markdown(response_text),
title=f"[bold]{bot_name}[/bold]",
subtitle=status_line,
border_style="blue",
padding=(1, 2),
))
# Source summary
db_count = len(retrieval_result.get("db_results", []))
web_count = len(retrieval_result.get("web_results", []))
sql_count = len(retrieval_result.get("sql_results", []))
sql_match = retrieval_result.get("sql_match_type", "")
source_summary = f"[dim]Sources: {db_count} local"
if sql_count:
match_label = f" ({sql_match} match)" if sql_match else ""
source_summary += f", {sql_count} SQL rows{match_label}"
if web_count:
source_summary += f", {web_count} web"
source_summary += " -- type /sources for details[/dim]"
console.print(source_summary)
if __name__ == "__main__":
main()