redhat-oracle 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redhat_oracle/__init__.py +17 -0
- redhat_oracle/__main__.py +5 -0
- redhat_oracle/cli/__init__.py +0 -0
- redhat_oracle/cli/app.py +541 -0
- redhat_oracle/core/__init__.py +0 -0
- redhat_oracle/core/cache.py +283 -0
- redhat_oracle/core/config.py +349 -0
- redhat_oracle/core/llm/__init__.py +0 -0
- redhat_oracle/core/llm/base.py +34 -0
- redhat_oracle/core/llm/external.py +129 -0
- redhat_oracle/core/llm/ollama.py +85 -0
- redhat_oracle/core/llm/openvino.py +107 -0
- redhat_oracle/core/rag.py +177 -0
- redhat_oracle/core/session.py +138 -0
- redhat_oracle/core/sources/__init__.py +0 -0
- redhat_oracle/core/sources/github.py +396 -0
- redhat_oracle/core/sources/gitlab.py +479 -0
- redhat_oracle/core/sources/jira.py +315 -0
- redhat_oracle/core/sources/pages.py +230 -0
- redhat_oracle/core/sources/slack.py +501 -0
- redhat_oracle/core/sources/slack_creds.py +121 -0
- redhat_oracle/core/sources/web.py +332 -0
- redhat_oracle/core/vectordb/__init__.py +0 -0
- redhat_oracle/core/vectordb/chunker.py +278 -0
- redhat_oracle/core/vectordb/embeddings.py +45 -0
- redhat_oracle/core/vectordb/store.py +378 -0
- redhat_oracle/core/vectordb/tracker.py +91 -0
- redhat_oracle/gui/__init__.py +0 -0
- redhat_oracle/gui/app.py +1608 -0
- redhat_oracle-0.1.0.dist-info/METADATA +152 -0
- redhat_oracle-0.1.0.dist-info/RECORD +34 -0
- redhat_oracle-0.1.0.dist-info/WHEEL +5 -0
- redhat_oracle-0.1.0.dist-info/entry_points.txt +2 -0
- redhat_oracle-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Red Hat Oracle — AI-powered knowledge assistant for Red Hat engineering teams."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _read_version() -> str:
|
|
7
|
+
version_file = Path(__file__).resolve().parent.parent.parent / "version"
|
|
8
|
+
try:
|
|
9
|
+
for line in version_file.read_text().splitlines():
|
|
10
|
+
if line.startswith("version="):
|
|
11
|
+
return line.split("=", 1)[1].strip()
|
|
12
|
+
except FileNotFoundError:
|
|
13
|
+
pass
|
|
14
|
+
return "0.1.0"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__version__ = _read_version()
|
|
File without changes
|
redhat_oracle/cli/app.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""CLI entry point for Red Hat Oracle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.progress import Progress
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from redhat_oracle import __version__
|
|
17
|
+
from redhat_oracle.core.config import load_config
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run_async(coro: Any) -> Any:
|
|
23
|
+
"""Run an async coroutine from synchronous CLI code using a background thread."""
|
|
24
|
+
result: Any = None
|
|
25
|
+
exception: BaseException | None = None
|
|
26
|
+
|
|
27
|
+
def _target() -> None:
|
|
28
|
+
nonlocal result, exception
|
|
29
|
+
try:
|
|
30
|
+
loop = asyncio.new_event_loop()
|
|
31
|
+
asyncio.set_event_loop(loop)
|
|
32
|
+
try:
|
|
33
|
+
result = loop.run_until_complete(coro)
|
|
34
|
+
finally:
|
|
35
|
+
loop.close()
|
|
36
|
+
except BaseException as e:
|
|
37
|
+
exception = e
|
|
38
|
+
|
|
39
|
+
thread = threading.Thread(target=_target, daemon=True)
|
|
40
|
+
thread.start()
|
|
41
|
+
thread.join()
|
|
42
|
+
|
|
43
|
+
if exception is not None:
|
|
44
|
+
raise exception
|
|
45
|
+
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@click.group(invoke_without_command=True)
|
|
50
|
+
@click.version_option(version=__version__, prog_name="oracle")
|
|
51
|
+
@click.pass_context
|
|
52
|
+
def main(ctx: click.Context) -> None:
|
|
53
|
+
"""Red Hat Oracle — AI-powered knowledge assistant."""
|
|
54
|
+
ctx.ensure_object(dict)
|
|
55
|
+
ctx.obj["config"] = load_config()
|
|
56
|
+
if ctx.invoked_subcommand is None:
|
|
57
|
+
click.echo(ctx.get_help())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@main.command()
|
|
61
|
+
@click.option(
|
|
62
|
+
"--source",
|
|
63
|
+
type=click.Choice(["github", "gitlab", "slack", "jira", "web", "pages"]),
|
|
64
|
+
help="Sync only this source.",
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--days",
|
|
68
|
+
type=int,
|
|
69
|
+
default=None,
|
|
70
|
+
help="Override history_days for Slack/Jira (default: 730 for Slack, 365 for Jira).",
|
|
71
|
+
)
|
|
72
|
+
@click.pass_context
|
|
73
|
+
def ingest(ctx: click.Context, source: str | None, days: int | None) -> None:
|
|
74
|
+
"""Sync data sources and index into the vector store."""
|
|
75
|
+
config = ctx.obj["config"]
|
|
76
|
+
if days is not None:
|
|
77
|
+
config.sources.slack.history_days = days
|
|
78
|
+
config.sources.jira.history_days = days
|
|
79
|
+
|
|
80
|
+
async def _ingest() -> dict[str, Any]:
|
|
81
|
+
from redhat_oracle.core.sources.github import fetch_github_comments, sync_github
|
|
82
|
+
from redhat_oracle.core.sources.gitlab import fetch_gitlab_comments, sync_gitlab
|
|
83
|
+
from redhat_oracle.core.sources.jira import sync_jira
|
|
84
|
+
from redhat_oracle.core.sources.pages import sync_pages
|
|
85
|
+
from redhat_oracle.core.sources.slack import sync_slack
|
|
86
|
+
from redhat_oracle.core.sources.web import sync_web
|
|
87
|
+
from redhat_oracle.core.vectordb.chunker import chunk_repo
|
|
88
|
+
from redhat_oracle.core.vectordb.store import VectorStore
|
|
89
|
+
|
|
90
|
+
stats: dict[str, Any] = {}
|
|
91
|
+
store = VectorStore(config)
|
|
92
|
+
|
|
93
|
+
with Progress(console=console) as progress:
|
|
94
|
+
# GitHub repos (clone/pull only — indexing below)
|
|
95
|
+
if source is None or source == "github":
|
|
96
|
+
task = progress.add_task("[cyan]Syncing GitHub...", total=None)
|
|
97
|
+
try:
|
|
98
|
+
gh_stats = await sync_github(config, progress=progress)
|
|
99
|
+
stats["github"] = gh_stats
|
|
100
|
+
except Exception as e:
|
|
101
|
+
stats["github"] = {"error": str(e)}
|
|
102
|
+
progress.update(task, completed=True)
|
|
103
|
+
|
|
104
|
+
# GitLab repos (clone/pull only — indexing below)
|
|
105
|
+
if source is None or source == "gitlab":
|
|
106
|
+
task = progress.add_task("[cyan]Syncing GitLab...", total=None)
|
|
107
|
+
try:
|
|
108
|
+
gl_stats = await sync_gitlab(config, progress=progress)
|
|
109
|
+
stats["gitlab"] = gl_stats
|
|
110
|
+
except Exception as e:
|
|
111
|
+
stats["gitlab"] = {"error": str(e)}
|
|
112
|
+
progress.update(task, completed=True)
|
|
113
|
+
|
|
114
|
+
# Code repo indexing (GitHub/GitLab)
|
|
115
|
+
if source is None or source in ("github", "gitlab"):
|
|
116
|
+
try:
|
|
117
|
+
from redhat_oracle.core.config import get_data_dir
|
|
118
|
+
from redhat_oracle.core.vectordb.tracker import IndexTracker
|
|
119
|
+
|
|
120
|
+
data_dir = get_data_dir(config)
|
|
121
|
+
repos_dir = data_dir / "repos"
|
|
122
|
+
tracker = IndexTracker(data_dir)
|
|
123
|
+
skipped_repos = 0
|
|
124
|
+
|
|
125
|
+
if repos_dir.exists():
|
|
126
|
+
for source_dir in repos_dir.iterdir():
|
|
127
|
+
if not source_dir.is_dir():
|
|
128
|
+
continue
|
|
129
|
+
source_name = source_dir.name
|
|
130
|
+
if source is not None and source_name != source:
|
|
131
|
+
continue
|
|
132
|
+
for org_dir in source_dir.iterdir():
|
|
133
|
+
if not org_dir.is_dir():
|
|
134
|
+
continue
|
|
135
|
+
for repo_dir in org_dir.iterdir():
|
|
136
|
+
if not repo_dir.is_dir():
|
|
137
|
+
continue
|
|
138
|
+
if not tracker.needs_reindex(
|
|
139
|
+
source_name, org_dir.name, repo_dir.name, repo_dir
|
|
140
|
+
):
|
|
141
|
+
skipped_repos += 1
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
chunks = chunk_repo(
|
|
145
|
+
repo_dir,
|
|
146
|
+
source_name,
|
|
147
|
+
org_dir.name,
|
|
148
|
+
repo_dir.name,
|
|
149
|
+
)
|
|
150
|
+
if chunks:
|
|
151
|
+
idx_task = progress.add_task(
|
|
152
|
+
f"[green]Indexing {org_dir.name}/{repo_dir.name}",
|
|
153
|
+
total=None,
|
|
154
|
+
)
|
|
155
|
+
store.reindex_repo(
|
|
156
|
+
chunks,
|
|
157
|
+
source_name,
|
|
158
|
+
org_dir.name,
|
|
159
|
+
repo_dir.name,
|
|
160
|
+
)
|
|
161
|
+
progress.update(idx_task, completed=True)
|
|
162
|
+
|
|
163
|
+
tracker.mark_indexed(
|
|
164
|
+
source_name, org_dir.name, repo_dir.name, repo_dir
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if skipped_repos:
|
|
168
|
+
console.print(
|
|
169
|
+
f"[dim]Skipped {skipped_repos} repos (unchanged)[/dim]"
|
|
170
|
+
)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
stats["code_index"] = {"error": str(e)}
|
|
173
|
+
console.print(f"[red]Code indexing error: {e}[/red]")
|
|
174
|
+
|
|
175
|
+
# GitHub comments (upserts per-repo internally)
|
|
176
|
+
if source is None or source == "github":
|
|
177
|
+
try:
|
|
178
|
+
gh_comment_stats = await fetch_github_comments(
|
|
179
|
+
config, store=store, progress=progress
|
|
180
|
+
)
|
|
181
|
+
stats["github_comments"] = gh_comment_stats
|
|
182
|
+
except Exception as e:
|
|
183
|
+
stats["github_comments"] = {"error": str(e)}
|
|
184
|
+
|
|
185
|
+
# GitLab comments (upserts per-project internally)
|
|
186
|
+
if source is None or source == "gitlab":
|
|
187
|
+
try:
|
|
188
|
+
gl_comment_stats = await fetch_gitlab_comments(
|
|
189
|
+
config, store=store, progress=progress
|
|
190
|
+
)
|
|
191
|
+
stats["gitlab_comments"] = gl_comment_stats
|
|
192
|
+
except Exception as e:
|
|
193
|
+
stats["gitlab_comments"] = {"error": str(e)}
|
|
194
|
+
|
|
195
|
+
# Slack (upserts per-day internally)
|
|
196
|
+
if source is None or source == "slack":
|
|
197
|
+
try:
|
|
198
|
+
slack_stats = await sync_slack(
|
|
199
|
+
config, store=store, progress=progress
|
|
200
|
+
)
|
|
201
|
+
stats["slack"] = slack_stats
|
|
202
|
+
except Exception as e:
|
|
203
|
+
stats["slack"] = {"error": str(e)}
|
|
204
|
+
|
|
205
|
+
# Jira (upserts per-project internally)
|
|
206
|
+
if source is None or source == "jira":
|
|
207
|
+
try:
|
|
208
|
+
jira_stats = await sync_jira(
|
|
209
|
+
config, store=store, progress=progress
|
|
210
|
+
)
|
|
211
|
+
stats["jira"] = jira_stats
|
|
212
|
+
except Exception as e:
|
|
213
|
+
stats["jira"] = {"error": str(e)}
|
|
214
|
+
|
|
215
|
+
# Web (upserts per-site internally)
|
|
216
|
+
if source is None or source == "web":
|
|
217
|
+
try:
|
|
218
|
+
web_stats = await sync_web(
|
|
219
|
+
config, store=store, progress=progress
|
|
220
|
+
)
|
|
221
|
+
stats["web"] = web_stats
|
|
222
|
+
except Exception as e:
|
|
223
|
+
stats["web"] = {"error": str(e)}
|
|
224
|
+
|
|
225
|
+
# Pages (upserts per-site internally)
|
|
226
|
+
if source is None or source == "pages":
|
|
227
|
+
try:
|
|
228
|
+
pages_stats = await sync_pages(
|
|
229
|
+
config, store=store, progress=progress
|
|
230
|
+
)
|
|
231
|
+
stats["pages"] = pages_stats
|
|
232
|
+
except Exception as e:
|
|
233
|
+
stats["pages"] = {"error": str(e)}
|
|
234
|
+
|
|
235
|
+
stats["index"] = store.collection_stats()
|
|
236
|
+
return stats
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
stats = _run_async(_ingest())
|
|
240
|
+
console.print("\n[bold green]Ingestion complete![/bold green]")
|
|
241
|
+
for key, value in stats.items():
|
|
242
|
+
console.print(f" {key}: {value}")
|
|
243
|
+
except KeyboardInterrupt:
|
|
244
|
+
console.print("\n[yellow]Ingestion cancelled.[/yellow]")
|
|
245
|
+
sys.exit(0)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@main.command()
|
|
249
|
+
@click.option("--provider", default=None, help="LLM provider override.")
|
|
250
|
+
@click.option("--resume", is_flag=True, help="Resume the latest session.")
|
|
251
|
+
@click.option("--session", "session_name", default=None, help="Resume a specific session.")
|
|
252
|
+
@click.pass_context
|
|
253
|
+
def chat(ctx: click.Context, provider: str | None, resume: bool, session_name: str | None) -> None:
|
|
254
|
+
"""Start an interactive chat session."""
|
|
255
|
+
from prompt_toolkit import PromptSession
|
|
256
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
257
|
+
|
|
258
|
+
from redhat_oracle.core.llm.base import LLMProvider
|
|
259
|
+
from redhat_oracle.core.llm.external import (
|
|
260
|
+
AnthropicLLM,
|
|
261
|
+
OpenAILLM,
|
|
262
|
+
ProviderUnavailableError,
|
|
263
|
+
)
|
|
264
|
+
from redhat_oracle.core.llm.ollama import OllamaLLM, OllamaUnavailableError
|
|
265
|
+
from redhat_oracle.core.llm.openvino import OpenVINOLLM, OpenVINOUnavailableError
|
|
266
|
+
from redhat_oracle.core.rag import RAGPipeline
|
|
267
|
+
from redhat_oracle.core.session import (
|
|
268
|
+
load_latest_session,
|
|
269
|
+
load_session,
|
|
270
|
+
new_session,
|
|
271
|
+
save_session,
|
|
272
|
+
)
|
|
273
|
+
from redhat_oracle.core.vectordb.store import VectorStore
|
|
274
|
+
|
|
275
|
+
config = ctx.obj["config"]
|
|
276
|
+
|
|
277
|
+
# Load or create session
|
|
278
|
+
session = None
|
|
279
|
+
if session_name:
|
|
280
|
+
session = load_session(session_name, config)
|
|
281
|
+
if session is None:
|
|
282
|
+
console.print(f"[red]Session '{session_name}' not found.[/red]")
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
console.print(f"[green]Resumed session: {session.name}[/green]")
|
|
285
|
+
elif resume:
|
|
286
|
+
session = load_latest_session(config)
|
|
287
|
+
if session is None:
|
|
288
|
+
console.print("[yellow]No previous session found. Starting new session.[/yellow]")
|
|
289
|
+
else:
|
|
290
|
+
console.print(f"[green]Resumed session: {session.name}[/green]")
|
|
291
|
+
|
|
292
|
+
if session is None:
|
|
293
|
+
session = new_session(config)
|
|
294
|
+
console.print(f"[green]New session: {session.name}[/green]")
|
|
295
|
+
|
|
296
|
+
# Set up LLM
|
|
297
|
+
llm_config = config.llm
|
|
298
|
+
provider_name = provider or llm_config.default_provider
|
|
299
|
+
llm: LLMProvider
|
|
300
|
+
|
|
301
|
+
if provider_name == "ollama":
|
|
302
|
+
llm = OllamaLLM(
|
|
303
|
+
base_url=llm_config.ollama_base_url,
|
|
304
|
+
model=llm_config.ollama_model,
|
|
305
|
+
)
|
|
306
|
+
elif provider_name == "openai":
|
|
307
|
+
api_key = llm_config.openai_api_key
|
|
308
|
+
if not api_key:
|
|
309
|
+
console.print("[red]OpenAI API key not configured.[/red]")
|
|
310
|
+
sys.exit(1)
|
|
311
|
+
llm = OpenAILLM(api_key=api_key)
|
|
312
|
+
elif provider_name == "anthropic":
|
|
313
|
+
api_key = llm_config.anthropic_api_key
|
|
314
|
+
if not api_key:
|
|
315
|
+
console.print("[red]Anthropic API key not configured.[/red]")
|
|
316
|
+
sys.exit(1)
|
|
317
|
+
llm = AnthropicLLM(api_key=api_key)
|
|
318
|
+
elif provider_name == "openvino":
|
|
319
|
+
llm = OpenVINOLLM()
|
|
320
|
+
else:
|
|
321
|
+
console.print(f"[red]Unknown provider '{provider_name}'.[/red]")
|
|
322
|
+
sys.exit(1)
|
|
323
|
+
|
|
324
|
+
# Check LLM availability
|
|
325
|
+
unavailable_errors = (
|
|
326
|
+
OllamaUnavailableError,
|
|
327
|
+
ProviderUnavailableError,
|
|
328
|
+
OpenVINOUnavailableError,
|
|
329
|
+
)
|
|
330
|
+
try:
|
|
331
|
+
_run_async(llm._check_available()) # type: ignore[union-attr]
|
|
332
|
+
except unavailable_errors:
|
|
333
|
+
while True:
|
|
334
|
+
choice = click.prompt(
|
|
335
|
+
f"{provider_name} is not reachable. Choose an action",
|
|
336
|
+
type=click.Choice(["retry", "quit"]),
|
|
337
|
+
default="retry",
|
|
338
|
+
)
|
|
339
|
+
if choice == "quit":
|
|
340
|
+
sys.exit(0)
|
|
341
|
+
try:
|
|
342
|
+
_run_async(llm._check_available()) # type: ignore[union-attr]
|
|
343
|
+
break
|
|
344
|
+
except unavailable_errors:
|
|
345
|
+
console.print("[red]Still unreachable.[/red]")
|
|
346
|
+
|
|
347
|
+
store = VectorStore(config)
|
|
348
|
+
pipeline = RAGPipeline(config, store, llm)
|
|
349
|
+
|
|
350
|
+
prompt_session: PromptSession[str] = PromptSession(history=InMemoryHistory())
|
|
351
|
+
console.print("[bold]Oracle chat ready. Type 'exit' or Ctrl+C to quit.[/bold]\n")
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
while True:
|
|
355
|
+
try:
|
|
356
|
+
user_input = prompt_session.prompt("You> ")
|
|
357
|
+
except EOFError:
|
|
358
|
+
break
|
|
359
|
+
|
|
360
|
+
if not user_input.strip():
|
|
361
|
+
continue
|
|
362
|
+
if user_input.strip().lower() in ("exit", "quit"):
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
session.add_message("user", user_input)
|
|
366
|
+
|
|
367
|
+
async def _stream(question: str = user_input) -> str:
|
|
368
|
+
token_stream, sources = await pipeline.stream_query(
|
|
369
|
+
question, session=session,
|
|
370
|
+
)
|
|
371
|
+
full_response: list[str] = []
|
|
372
|
+
console.print("[bold cyan]Oracle>[/bold cyan] ", end="")
|
|
373
|
+
async for token in token_stream:
|
|
374
|
+
console.print(token, end="", highlight=False)
|
|
375
|
+
full_response.append(token)
|
|
376
|
+
console.print() # newline after response
|
|
377
|
+
|
|
378
|
+
if sources:
|
|
379
|
+
console.print("\n[dim]Sources:[/dim]")
|
|
380
|
+
for s in sources:
|
|
381
|
+
url = s.get('url', '')
|
|
382
|
+
if url:
|
|
383
|
+
ref = url
|
|
384
|
+
else:
|
|
385
|
+
ref = "/".join(
|
|
386
|
+
v for v in (s.get('source', '?'), s.get('org', ''),
|
|
387
|
+
s.get('repo', ''), s.get('path', '?'))
|
|
388
|
+
if v
|
|
389
|
+
)
|
|
390
|
+
console.print(
|
|
391
|
+
f" [dim]{ref} (score: {s['score']:.2f})[/dim]"
|
|
392
|
+
)
|
|
393
|
+
console.print()
|
|
394
|
+
|
|
395
|
+
return "".join(full_response)
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
response_text = _run_async(_stream())
|
|
399
|
+
source_refs = [{"source": "oracle"}]
|
|
400
|
+
session.add_message("assistant", response_text, sources=source_refs)
|
|
401
|
+
save_session(session, config)
|
|
402
|
+
except unavailable_errors:
|
|
403
|
+
console.print(f"[red]{provider_name} became unreachable.[/red]")
|
|
404
|
+
break
|
|
405
|
+
|
|
406
|
+
except KeyboardInterrupt:
|
|
407
|
+
console.print("\n[yellow]Chat ended.[/yellow]")
|
|
408
|
+
|
|
409
|
+
save_session(session, config)
|
|
410
|
+
console.print(f"[green]Session saved: {session.name}[/green]")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@main.command()
|
|
414
|
+
@click.argument("query")
|
|
415
|
+
@click.option(
|
|
416
|
+
"--source",
|
|
417
|
+
type=click.Choice(["github", "gitlab", "slack", "jira", "web", "pages", "code"]),
|
|
418
|
+
help="Filter by source.",
|
|
419
|
+
)
|
|
420
|
+
@click.option("--top-k", default=10, help="Number of results.")
|
|
421
|
+
@click.pass_context
|
|
422
|
+
def search(ctx: click.Context, query: str, source: str | None, top_k: int) -> None:
|
|
423
|
+
"""Search the vector store without using the LLM."""
|
|
424
|
+
from redhat_oracle.core.vectordb.store import VectorStore
|
|
425
|
+
|
|
426
|
+
config = ctx.obj["config"]
|
|
427
|
+
store = VectorStore(config)
|
|
428
|
+
|
|
429
|
+
results = store.search(query, source=source, top_k=top_k)
|
|
430
|
+
|
|
431
|
+
if not results:
|
|
432
|
+
console.print("[yellow]No results found.[/yellow]")
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
for i, result in enumerate(results, 1):
|
|
436
|
+
meta = result.get("metadata", {})
|
|
437
|
+
score = result.get("score", 0.0)
|
|
438
|
+
url = meta.get("url", "")
|
|
439
|
+
if url:
|
|
440
|
+
path = url
|
|
441
|
+
else:
|
|
442
|
+
path = "/".join(
|
|
443
|
+
v for v in (meta.get('source', '?'), meta.get('org', ''),
|
|
444
|
+
meta.get('repo', ''), meta.get('path', '?'))
|
|
445
|
+
if v
|
|
446
|
+
)
|
|
447
|
+
text_preview = result.get("text", "")[:200]
|
|
448
|
+
|
|
449
|
+
table = Table(title=f"Result {i}", show_header=False, border_style="dim")
|
|
450
|
+
table.add_row("Path", path)
|
|
451
|
+
table.add_row("Score", f"{score:.4f}")
|
|
452
|
+
table.add_row("Preview", text_preview)
|
|
453
|
+
console.print(table)
|
|
454
|
+
console.print()
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@main.command()
|
|
458
|
+
@click.pass_context
|
|
459
|
+
def status(ctx: click.Context) -> None:
|
|
460
|
+
"""Show collection stats and system status."""
|
|
461
|
+
from redhat_oracle.core.vectordb.store import VectorStore
|
|
462
|
+
|
|
463
|
+
config = ctx.obj["config"]
|
|
464
|
+
store = VectorStore(config)
|
|
465
|
+
|
|
466
|
+
stats = store.collection_stats()
|
|
467
|
+
|
|
468
|
+
table = Table(title="Vector Store Status")
|
|
469
|
+
table.add_column("Collection", style="cyan")
|
|
470
|
+
table.add_column("Chunks", style="green", justify="right")
|
|
471
|
+
|
|
472
|
+
total = 0
|
|
473
|
+
for name, count in stats.items():
|
|
474
|
+
table.add_row(name, str(count))
|
|
475
|
+
total += count
|
|
476
|
+
|
|
477
|
+
table.add_row("[bold]Total[/bold]", f"[bold]{total}[/bold]")
|
|
478
|
+
console.print(table)
|
|
479
|
+
|
|
480
|
+
console.print(f"\n[dim]LLM provider: {config.llm.default_provider}[/dim]")
|
|
481
|
+
console.print(f"[dim]Embedding model: {config.vectordb.embeddings.model}[/dim]")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@main.command()
|
|
485
|
+
@click.pass_context
|
|
486
|
+
def gui(ctx: click.Context) -> None:
|
|
487
|
+
"""Launch the desktop GUI."""
|
|
488
|
+
from redhat_oracle.gui.app import launch_gui
|
|
489
|
+
|
|
490
|
+
sys.exit(launch_gui(ctx.obj["config"]))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@main.command("config")
|
|
494
|
+
@click.option("--edit", is_flag=True, help="Open config in $EDITOR.")
|
|
495
|
+
@click.pass_context
|
|
496
|
+
def config_cmd(ctx: click.Context, edit: bool) -> None:
|
|
497
|
+
"""Show or edit the current configuration."""
|
|
498
|
+
import yaml
|
|
499
|
+
|
|
500
|
+
config = ctx.obj["config"]
|
|
501
|
+
|
|
502
|
+
if edit:
|
|
503
|
+
from redhat_oracle.core.config import _config_paths
|
|
504
|
+
|
|
505
|
+
paths = _config_paths()
|
|
506
|
+
# Find the first existing config or use the project-level one
|
|
507
|
+
config_path = None
|
|
508
|
+
for p in reversed(paths):
|
|
509
|
+
if p.is_file():
|
|
510
|
+
config_path = p
|
|
511
|
+
break
|
|
512
|
+
if config_path is None:
|
|
513
|
+
config_path = paths[-1] # project config
|
|
514
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
515
|
+
if not config_path.exists():
|
|
516
|
+
config_path.write_text("# Red Hat Oracle config\n")
|
|
517
|
+
|
|
518
|
+
import os
|
|
519
|
+
|
|
520
|
+
editor = os.environ.get("EDITOR", "vi")
|
|
521
|
+
subprocess.run([editor, str(config_path)], check=False)
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
config_dict = config.model_dump()
|
|
525
|
+
# Mask secrets
|
|
526
|
+
for key_path in [
|
|
527
|
+
("sources", "github", "token"),
|
|
528
|
+
("sources", "gitlab", "token"),
|
|
529
|
+
("sources", "slack", "token"),
|
|
530
|
+
("sources", "jira", "token"),
|
|
531
|
+
("llm", "openai_api_key"),
|
|
532
|
+
("llm", "anthropic_api_key"),
|
|
533
|
+
]:
|
|
534
|
+
obj = config_dict
|
|
535
|
+
for k in key_path[:-1]:
|
|
536
|
+
obj = obj.get(k, {})
|
|
537
|
+
last = key_path[-1]
|
|
538
|
+
if obj.get(last):
|
|
539
|
+
obj[last] = "***"
|
|
540
|
+
|
|
541
|
+
console.print(yaml.dump(config_dict, default_flow_style=False))
|
|
File without changes
|