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.
@@ -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()
@@ -0,0 +1,5 @@
1
+ """Entry point for `python -m redhat_oracle`."""
2
+
3
+ from redhat_oracle.cli.app import main
4
+
5
+ main()
File without changes
@@ -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