File size: 18,203 Bytes
0017280
 
 
 
 
f95cdb7
0017280
 
 
 
 
 
fbd18c8
ebd834c
f95cdb7
0017280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01b9f34
0017280
01b9f34
0017280
 
 
 
 
9815e49
0017280
 
 
06249fb
0017280
01b9f34
0017280
 
6b689a5
 
 
01b9f34
 
 
 
0017280
 
9815e49
0017280
 
 
9815e49
0017280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9815e49
0017280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb04f6f
e47c16c
 
 
 
 
fb04f6f
44c8a3e
 
 
 
 
 
 
 
 
 
7e9f52f
fbd18c8
7e9f52f
 
 
89e2c52
0017280
 
 
 
 
 
 
 
ebd834c
0017280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f95cdb7
0017280
 
f95cdb7
0017280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ebd834c
 
 
 
 
 
 
9815e49
39c2d11
 
ebd834c
 
 
 
 
 
 
 
 
f95cdb7
9093d8f
ebd834c
 
 
9815e49
c014da5
 
ebd834c
 
 
 
 
 
1330e5c
 
 
 
 
 
 
c014da5
ebd834c
 
 
 
 
 
 
9093d8f
ebd834c
 
 
9815e49
ebd834c
 
 
9815e49
20d594f
 
ebd834c
 
 
f95cdb7
20d594f
 
ebd834c
 
0017280
 
20d594f
0017280
f95cdb7
0017280
 
 
 
ebd834c
0017280
 
c23d859
 
 
 
0017280
f95cdb7
0017280
 
ebd834c
 
 
f95cdb7
 
 
 
 
9815e49
ebd834c
 
0017280
9815e49
0017280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20d594f
6b689a5
0017280
20d594f
6b689a5
 
0017280
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
"""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()