2025-12-10 02:49:54 +01:00
|
|
|
"""
|
2025-12-27 21:53:38 +01:00
|
|
|
Optimal API Service (Refactored with Plugin Architecture)
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
Knowledge base, RAG queries, and prompt optimization using piragi.
|
|
|
|
|
This service provides semantic search across code, documentation,
|
2025-12-27 21:53:38 +01:00
|
|
|
conversations, journal entries, errors, standards, decisions, reviews, and learnings.
|
2025-12-13 00:00:52 +01:00
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
The service uses a plugin-based architecture where each index type is handled
|
|
|
|
|
by a specialized plugin that implements the BaseIndexPlugin interface.
|
2025-12-10 02:49:54 +01:00
|
|
|
"""
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
import structlog
|
|
|
|
|
|
2025-12-14 01:09:57 +01:00
|
|
|
from roboco.models.optimal import (
|
|
|
|
|
IndexConversationParams,
|
2025-12-27 21:53:38 +01:00
|
|
|
IndexDecisionParams,
|
|
|
|
|
IndexErrorParams,
|
2025-12-14 01:09:57 +01:00
|
|
|
IndexJournalEntryParams,
|
2025-12-27 21:53:38 +01:00
|
|
|
IndexReviewParams,
|
|
|
|
|
IndexStandardParams,
|
2025-12-14 01:09:57 +01:00
|
|
|
IndexType,
|
|
|
|
|
QueryContext,
|
|
|
|
|
RAGResponse,
|
|
|
|
|
SearchResult,
|
|
|
|
|
)
|
2025-12-27 21:53:38 +01:00
|
|
|
from roboco.services.optimal_brain.indexes import (
|
|
|
|
|
BaseIndexPlugin,
|
|
|
|
|
CodeIndexPlugin,
|
|
|
|
|
ConversationsIndexPlugin,
|
|
|
|
|
DecisionsIndexPlugin,
|
|
|
|
|
DocsIndexPlugin,
|
|
|
|
|
ErrorsIndexPlugin,
|
|
|
|
|
JournalsIndexPlugin,
|
|
|
|
|
LearningsIndexPlugin,
|
|
|
|
|
ReviewsIndexPlugin,
|
|
|
|
|
StandardsIndexPlugin,
|
|
|
|
|
)
|
|
|
|
|
from roboco.services.optimal_brain.indexes.learnings import (
|
|
|
|
|
RecordLearningParams as LearningParams,
|
|
|
|
|
)
|
|
|
|
|
from roboco.services.optimal_brain.indexes.reviews import (
|
|
|
|
|
RecordReviewParams as ReviewParams,
|
|
|
|
|
)
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
# Plugin registry mapping IndexType to plugin class
|
|
|
|
|
PLUGIN_REGISTRY: dict[IndexType, type[BaseIndexPlugin]] = {
|
|
|
|
|
IndexType.CODE: CodeIndexPlugin,
|
|
|
|
|
IndexType.DOCUMENTATION: DocsIndexPlugin,
|
|
|
|
|
IndexType.CONVERSATIONS: ConversationsIndexPlugin,
|
|
|
|
|
IndexType.JOURNALS: JournalsIndexPlugin,
|
|
|
|
|
IndexType.ERRORS: ErrorsIndexPlugin,
|
|
|
|
|
IndexType.STANDARDS: StandardsIndexPlugin,
|
|
|
|
|
IndexType.DECISIONS: DecisionsIndexPlugin,
|
|
|
|
|
IndexType.REVIEWS: ReviewsIndexPlugin,
|
|
|
|
|
IndexType.LEARNINGS: LearningsIndexPlugin,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 02:49:54 +01:00
|
|
|
class OptimalService:
|
|
|
|
|
"""
|
|
|
|
|
Service for knowledge base operations and RAG queries.
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
Uses a plugin-based architecture with piragi and PostgreSQL/pgvector
|
|
|
|
|
for vector storage. Manages multiple indexes for different content types:
|
|
|
|
|
|
|
|
|
|
Existing:
|
2025-12-10 02:49:54 +01:00
|
|
|
- Code: Repositories, functions, classes
|
|
|
|
|
- Documentation: READMEs, API docs, guides
|
|
|
|
|
- Conversations: Extracted messages from agent streams
|
|
|
|
|
- Journals: Agent journal entries
|
2025-12-27 21:53:38 +01:00
|
|
|
|
|
|
|
|
New (Optimal Brain):
|
|
|
|
|
- Errors: Error patterns and solutions
|
|
|
|
|
- Standards: Coding standards, security policies, workflow rules
|
|
|
|
|
- Decisions: Architectural and design decisions
|
|
|
|
|
- Reviews: Code review feedback
|
|
|
|
|
- Learnings: Cross-agent learnings
|
2025-12-10 02:49:54 +01:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self._initialized = False
|
2025-12-27 21:53:38 +01:00
|
|
|
self._plugins: dict[IndexType, BaseIndexPlugin] = {}
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
async def initialize(self) -> None:
|
|
|
|
|
"""Initialize all knowledge base indexes."""
|
|
|
|
|
if self._initialized:
|
|
|
|
|
return
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
logger.info("Initializing OptimalService with plugin architecture")
|
2025-12-10 02:49:54 +01:00
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
# Create and initialize plugins for each index type
|
|
|
|
|
for index_type, plugin_class in PLUGIN_REGISTRY.items():
|
|
|
|
|
plugin = plugin_class()
|
|
|
|
|
await plugin.initialize()
|
|
|
|
|
self._plugins[index_type] = plugin
|
|
|
|
|
logger.info(f"Initialized {index_type.value} plugin")
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
self._initialized = True
|
|
|
|
|
logger.info("OptimalService initialization complete")
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
# Auto-index documentation on startup
|
|
|
|
|
await self._auto_index_docs()
|
|
|
|
|
|
|
|
|
|
async def _auto_index_docs(self) -> None:
|
|
|
|
|
"""
|
|
|
|
|
Auto-index documentation directories on startup.
|
|
|
|
|
|
|
|
|
|
Indexes:
|
|
|
|
|
- /docs/standards/ - Coding, security, workflow standards
|
|
|
|
|
- /docs/workflows/ - Agent workflow documentation
|
|
|
|
|
|
|
|
|
|
This ensures agents can search for standards and workflows
|
|
|
|
|
immediately after startup.
|
|
|
|
|
"""
|
|
|
|
|
# Find the docs directory relative to the project root
|
|
|
|
|
possible_docs_roots = [
|
2025-12-27 22:05:07 +01:00
|
|
|
Path("/app/docs"), # Docker absolute path
|
|
|
|
|
Path(__file__).parent.parent.parent / "docs", # roboco/docs (local)
|
2025-12-27 21:53:38 +01:00
|
|
|
Path.cwd() / "docs", # Current working directory
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
docs_root = None
|
|
|
|
|
for path in possible_docs_roots:
|
|
|
|
|
if path.exists() and path.is_dir():
|
|
|
|
|
docs_root = path
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if docs_root is None:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Docs directory not found",
|
|
|
|
|
searched_paths=[str(p) for p in possible_docs_roots],
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Directories to auto-index (relative to docs root)
|
|
|
|
|
auto_index_dirs = ["standards", "workflows"]
|
|
|
|
|
|
|
|
|
|
for subdir in auto_index_dirs:
|
|
|
|
|
target_dir = docs_root / subdir
|
|
|
|
|
if not target_dir.exists():
|
|
|
|
|
logger.debug(f"Auto-index directory not found: {target_dir}")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
await self._index_docs_directory(target_dir, subdir)
|
|
|
|
|
|
|
|
|
|
async def _index_docs_directory(self, directory: Path, name: str) -> None:
|
|
|
|
|
"""Index all markdown files in a documentation directory."""
|
|
|
|
|
md_files = list(directory.rglob("*.md"))
|
|
|
|
|
if not md_files:
|
|
|
|
|
logger.info(f"No files found to index in {name}/", path=str(directory))
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Auto-indexing {name} files",
|
|
|
|
|
directory=str(directory),
|
|
|
|
|
file_count=len(md_files),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Index each file
|
|
|
|
|
total_indexed = 0
|
|
|
|
|
for md_file in md_files:
|
|
|
|
|
try:
|
|
|
|
|
if name == "standards":
|
|
|
|
|
count = await self.index_standards_file(str(md_file))
|
|
|
|
|
total_indexed += count
|
|
|
|
|
else:
|
|
|
|
|
# For workflows, index as documentation
|
|
|
|
|
await self.index_documentation([str(md_file)])
|
|
|
|
|
total_indexed += 1
|
|
|
|
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
f"Indexed {name} file",
|
|
|
|
|
file=str(md_file),
|
|
|
|
|
items_indexed=count if name == "standards" else 1,
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(
|
|
|
|
|
f"Failed to index {name} file",
|
|
|
|
|
file=str(md_file),
|
|
|
|
|
error=str(e),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"{name.capitalize()} auto-indexing complete",
|
|
|
|
|
files_processed=len(md_files),
|
|
|
|
|
total_indexed=total_indexed,
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-10 02:49:54 +01:00
|
|
|
async def close(self) -> None:
|
|
|
|
|
"""Cleanup resources."""
|
2025-12-27 21:53:38 +01:00
|
|
|
for plugin in self._plugins.values():
|
|
|
|
|
await plugin.close()
|
|
|
|
|
self._plugins.clear()
|
2025-12-10 02:49:54 +01:00
|
|
|
self._initialized = False
|
2025-12-27 22:05:07 +01:00
|
|
|
|
|
|
|
|
# Close shared embedder
|
|
|
|
|
from roboco.services.optimal_brain.shared_embedder import close_shared_embedder
|
|
|
|
|
|
|
|
|
|
await close_shared_embedder()
|
2025-12-10 02:49:54 +01:00
|
|
|
logger.info("OptimalService closed")
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
def _get_plugin(self, index_type: IndexType) -> BaseIndexPlugin:
|
|
|
|
|
"""Get the plugin for an index type."""
|
2025-12-10 02:49:54 +01:00
|
|
|
if not self._initialized:
|
2025-12-10 17:35:43 +01:00
|
|
|
raise RuntimeError(
|
|
|
|
|
"OptimalService not initialized. Call initialize() first."
|
|
|
|
|
)
|
2025-12-27 21:53:38 +01:00
|
|
|
return self._plugins[index_type]
|
2025-12-10 02:49:54 +01:00
|
|
|
|
2025-12-13 00:00:52 +01:00
|
|
|
# =========================================================================
|
2025-12-27 21:53:38 +01:00
|
|
|
# INDEXING OPERATIONS (Existing - Backwards Compatible)
|
2025-12-10 02:49:54 +01:00
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
async def index_code(
|
|
|
|
|
self,
|
|
|
|
|
sources: list[str],
|
|
|
|
|
project: str | None = None,
|
|
|
|
|
) -> int:
|
2025-12-27 21:53:38 +01:00
|
|
|
"""Index code files/directories."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.CODE)
|
|
|
|
|
if isinstance(plugin, CodeIndexPlugin):
|
|
|
|
|
return await plugin.index_sources(sources, project)
|
|
|
|
|
return await plugin.add_sources(sources)
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
async def index_documentation(
|
|
|
|
|
self,
|
|
|
|
|
sources: list[str],
|
|
|
|
|
project: str | None = None,
|
|
|
|
|
) -> int:
|
2025-12-27 21:53:38 +01:00
|
|
|
"""Index documentation files."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.DOCUMENTATION)
|
|
|
|
|
if isinstance(plugin, DocsIndexPlugin):
|
|
|
|
|
return await plugin.index_sources(sources, project)
|
|
|
|
|
return await plugin.add_sources(sources)
|
2025-12-10 02:49:54 +01:00
|
|
|
|
2025-12-28 19:52:01 +01:00
|
|
|
async def _track_indexed_document(
|
|
|
|
|
self,
|
|
|
|
|
index_type: IndexType,
|
|
|
|
|
source: str,
|
|
|
|
|
title: str | None = None,
|
|
|
|
|
preview: str | None = None,
|
|
|
|
|
metadata: dict | None = None,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Track an indexed document in the database for browsing/stats."""
|
|
|
|
|
import hashlib
|
|
|
|
|
|
|
|
|
|
from roboco.db import get_db_context
|
|
|
|
|
from roboco.db.tables import IndexedDocumentTable
|
|
|
|
|
|
|
|
|
|
source_hash = hashlib.sha256(source.encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
async with get_db_context() as db:
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
existing = await db.execute(
|
|
|
|
|
select(IndexedDocumentTable).where(
|
|
|
|
|
IndexedDocumentTable.index_type == index_type.value,
|
|
|
|
|
IndexedDocumentTable.source_hash == source_hash,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
doc = existing.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if doc:
|
|
|
|
|
if title:
|
|
|
|
|
doc.title = title
|
|
|
|
|
if preview:
|
|
|
|
|
doc.preview = preview[:500] if preview else None
|
|
|
|
|
if metadata:
|
|
|
|
|
doc.metadata = {**(doc.metadata or {}), **metadata}
|
|
|
|
|
else:
|
|
|
|
|
doc = IndexedDocumentTable(
|
|
|
|
|
index_type=index_type.value,
|
|
|
|
|
source=source,
|
|
|
|
|
source_hash=source_hash,
|
|
|
|
|
title=title,
|
|
|
|
|
preview=preview[:500] if preview else None,
|
|
|
|
|
metadata=metadata or {},
|
|
|
|
|
)
|
|
|
|
|
db.add(doc)
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
2025-12-13 13:25:37 +01:00
|
|
|
async def index_conversation(self, params: IndexConversationParams) -> None:
|
2025-12-27 21:53:38 +01:00
|
|
|
"""Index a conversation message."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.CONVERSATIONS)
|
|
|
|
|
if isinstance(plugin, ConversationsIndexPlugin):
|
|
|
|
|
await plugin.index_message(params)
|
|
|
|
|
else:
|
|
|
|
|
await plugin.ingest(
|
|
|
|
|
content=params.content,
|
|
|
|
|
channel_id=params.channel_id,
|
|
|
|
|
session_id=params.session_id,
|
|
|
|
|
agent_id=params.agent_id,
|
|
|
|
|
task_id=params.task_id,
|
|
|
|
|
message_type=params.message_type,
|
|
|
|
|
)
|
2025-12-10 02:49:54 +01:00
|
|
|
|
2025-12-28 19:52:01 +01:00
|
|
|
# Track in database
|
|
|
|
|
source = f"roboco://conversations/{params.session_id or 'unknown'}"
|
|
|
|
|
await self._track_indexed_document(
|
|
|
|
|
IndexType.CONVERSATIONS,
|
|
|
|
|
source=source,
|
|
|
|
|
title=f"Message in {params.channel_id or 'channel'}",
|
|
|
|
|
preview=params.content[:500] if params.content else None,
|
|
|
|
|
metadata={
|
|
|
|
|
"channel_id": params.channel_id,
|
|
|
|
|
"session_id": params.session_id,
|
|
|
|
|
"agent_id": str(params.agent_id) if params.agent_id else None,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-13 13:25:37 +01:00
|
|
|
async def index_journal_entry(self, params: IndexJournalEntryParams) -> None:
|
2025-12-27 21:53:38 +01:00
|
|
|
"""Index a journal entry."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.JOURNALS)
|
|
|
|
|
if isinstance(plugin, JournalsIndexPlugin):
|
|
|
|
|
await plugin.index_entry(params)
|
|
|
|
|
else:
|
|
|
|
|
await plugin.ingest(
|
|
|
|
|
content=params.content,
|
|
|
|
|
entry_id=params.entry_id,
|
|
|
|
|
agent_id=params.agent_id,
|
|
|
|
|
entry_type=params.entry_type,
|
|
|
|
|
task_id=params.task_id,
|
|
|
|
|
tags=params.tags,
|
|
|
|
|
)
|
2025-12-10 02:49:54 +01:00
|
|
|
|
2025-12-28 19:52:01 +01:00
|
|
|
# Track in database
|
|
|
|
|
source = f"roboco://journals/{params.entry_id or 'unknown'}"
|
|
|
|
|
await self._track_indexed_document(
|
|
|
|
|
IndexType.JOURNALS,
|
|
|
|
|
source=source,
|
|
|
|
|
title=f"Journal: {params.entry_type or 'entry'}",
|
|
|
|
|
preview=params.content[:500] if params.content else None,
|
|
|
|
|
metadata={
|
|
|
|
|
"entry_id": params.entry_id,
|
|
|
|
|
"agent_id": str(params.agent_id) if params.agent_id else None,
|
|
|
|
|
"entry_type": params.entry_type,
|
|
|
|
|
"tags": params.tags,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
# =========================================================================
|
|
|
|
|
# INDEXING OPERATIONS (New - Optimal Brain)
|
|
|
|
|
# =========================================================================
|
2025-12-10 02:49:54 +01:00
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
async def index_error(self, params: IndexErrorParams) -> None:
|
|
|
|
|
"""Index an error pattern with solution."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.ERRORS)
|
|
|
|
|
if isinstance(plugin, ErrorsIndexPlugin):
|
|
|
|
|
await plugin.record_error(params)
|
2025-12-10 02:49:54 +01:00
|
|
|
|
2025-12-28 19:52:01 +01:00
|
|
|
# Track in database
|
|
|
|
|
import hashlib
|
|
|
|
|
|
|
|
|
|
error_hash = hashlib.md5(params.error_message.encode()).hexdigest()[:12]
|
|
|
|
|
source = f"roboco://errors/err-{error_hash}"
|
|
|
|
|
await self._track_indexed_document(
|
|
|
|
|
IndexType.ERRORS,
|
|
|
|
|
source=source,
|
|
|
|
|
title=f"Error: {params.error_message[:100]}",
|
|
|
|
|
preview=f"{params.error_message}\n\nSolution: {params.solution}",
|
|
|
|
|
metadata={
|
|
|
|
|
"context": params.context,
|
|
|
|
|
"worked": params.worked,
|
|
|
|
|
"tags": params.tags,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
async def index_standard(self, params: IndexStandardParams) -> None:
|
|
|
|
|
"""Index a coding/security/workflow standard."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.STANDARDS)
|
|
|
|
|
if isinstance(plugin, StandardsIndexPlugin):
|
|
|
|
|
await plugin.index_standard(params)
|
2025-12-10 02:49:54 +01:00
|
|
|
|
2025-12-28 19:52:01 +01:00
|
|
|
# Track in database
|
|
|
|
|
source = f"roboco://standards/{params.domain or 'general'}"
|
|
|
|
|
await self._track_indexed_document(
|
|
|
|
|
IndexType.STANDARDS,
|
|
|
|
|
source=source,
|
|
|
|
|
title=f"Standard: {params.domain or 'General'}",
|
|
|
|
|
preview=params.content[:500] if params.content else None,
|
|
|
|
|
metadata={
|
|
|
|
|
"domain": params.domain,
|
|
|
|
|
"language": params.language,
|
|
|
|
|
"scope": params.scope,
|
|
|
|
|
"severity": params.severity,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
async def index_decision(self, params: IndexDecisionParams) -> None:
|
|
|
|
|
"""Index an architectural/design decision."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.DECISIONS)
|
|
|
|
|
if isinstance(plugin, DecisionsIndexPlugin):
|
|
|
|
|
await plugin.record_decision(params)
|
|
|
|
|
|
2025-12-28 19:52:01 +01:00
|
|
|
# Track in database
|
|
|
|
|
import hashlib
|
|
|
|
|
|
|
|
|
|
topic_hash = hashlib.md5(params.topic.encode()).hexdigest()[:12]
|
|
|
|
|
source = f"roboco://decisions/dec-{topic_hash}"
|
|
|
|
|
await self._track_indexed_document(
|
|
|
|
|
IndexType.DECISIONS,
|
|
|
|
|
source=source,
|
|
|
|
|
title=f"Decision: {params.topic[:100]}",
|
|
|
|
|
preview=f"{params.topic}\n\nDecision: {params.decision}\n\nRationale: {params.rationale}",
|
|
|
|
|
metadata={
|
|
|
|
|
"scope": params.scope,
|
|
|
|
|
"tags": params.tags,
|
|
|
|
|
"alternatives": params.alternatives,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
async def record_review(self, params: IndexReviewParams) -> str:
|
|
|
|
|
"""Record a code review for future reference."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.REVIEWS)
|
2025-12-28 19:52:01 +01:00
|
|
|
doc_id = ""
|
2025-12-27 21:53:38 +01:00
|
|
|
if isinstance(plugin, ReviewsIndexPlugin):
|
|
|
|
|
review_params = ReviewParams(
|
|
|
|
|
comment=params.summary,
|
|
|
|
|
file_path=params.file_path,
|
|
|
|
|
reviewer_id=params.reviewer_id,
|
|
|
|
|
task_id=params.task_id,
|
|
|
|
|
review_type="code",
|
|
|
|
|
severity="info",
|
|
|
|
|
)
|
|
|
|
|
result = await plugin.record_review(review_params)
|
2025-12-28 19:52:01 +01:00
|
|
|
doc_id = result.doc_id
|
|
|
|
|
|
|
|
|
|
# Track in database
|
|
|
|
|
source = f"roboco://reviews/{params.file_path or 'unknown'}"
|
|
|
|
|
await self._track_indexed_document(
|
|
|
|
|
IndexType.REVIEWS,
|
|
|
|
|
source=source,
|
|
|
|
|
title=f"Review: {params.file_path or 'Code'}",
|
|
|
|
|
preview=params.summary[:500] if params.summary else None,
|
|
|
|
|
metadata={
|
|
|
|
|
"file_path": params.file_path,
|
|
|
|
|
"reviewer_id": str(params.reviewer_id) if params.reviewer_id else None,
|
|
|
|
|
"task_id": str(params.task_id) if params.task_id else None,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return doc_id
|
2025-12-27 21:53:38 +01:00
|
|
|
|
|
|
|
|
async def record_learning(self, params: LearningParams) -> str:
|
|
|
|
|
"""Record a learning for cross-agent knowledge sharing."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.LEARNINGS)
|
2025-12-28 19:52:01 +01:00
|
|
|
doc_id = ""
|
2025-12-27 21:53:38 +01:00
|
|
|
if isinstance(plugin, LearningsIndexPlugin):
|
|
|
|
|
result = await plugin.record_learning(params)
|
2025-12-28 19:52:01 +01:00
|
|
|
doc_id = result.doc_id
|
|
|
|
|
|
|
|
|
|
# Track in database
|
|
|
|
|
import hashlib
|
|
|
|
|
|
|
|
|
|
content_hash = hashlib.md5(params.content.encode()).hexdigest()[:12]
|
|
|
|
|
source = f"roboco://learnings/learn-{content_hash}"
|
|
|
|
|
await self._track_indexed_document(
|
|
|
|
|
IndexType.LEARNINGS,
|
|
|
|
|
source=source,
|
|
|
|
|
title=f"Learning: {params.category or 'General'}",
|
|
|
|
|
preview=params.content[:500] if params.content else None,
|
|
|
|
|
metadata={
|
|
|
|
|
"category": params.category,
|
|
|
|
|
"team": params.team,
|
|
|
|
|
"shareable": params.shareable,
|
|
|
|
|
"tags": params.tags,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return doc_id
|
2025-12-27 21:53:38 +01:00
|
|
|
|
|
|
|
|
async def index_standards_file(self, file_path: str) -> int:
|
|
|
|
|
"""Index a markdown standards file."""
|
2025-12-28 19:52:01 +01:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
plugin = self._get_plugin(IndexType.STANDARDS)
|
2025-12-28 19:52:01 +01:00
|
|
|
count = 0
|
2025-12-27 21:53:38 +01:00
|
|
|
if isinstance(plugin, StandardsIndexPlugin):
|
|
|
|
|
results = await plugin.index_markdown_file(file_path)
|
2025-12-28 19:52:01 +01:00
|
|
|
count = len([r for r in results if r.success])
|
|
|
|
|
|
|
|
|
|
# Track in database
|
|
|
|
|
path = Path(file_path)
|
|
|
|
|
if path.exists():
|
|
|
|
|
await self._track_indexed_document(
|
|
|
|
|
IndexType.STANDARDS,
|
|
|
|
|
source=str(path.absolute()),
|
|
|
|
|
title=path.stem.replace("-", " ").replace("_", " ").title(),
|
|
|
|
|
preview=path.read_text(errors="ignore")[:500],
|
|
|
|
|
metadata={"file_path": file_path},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return count
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# SEARCH OPERATIONS
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
async def search(
|
|
|
|
|
self,
|
|
|
|
|
query: str,
|
|
|
|
|
context: QueryContext | None = None,
|
|
|
|
|
top_k: int = 5,
|
|
|
|
|
) -> list[SearchResult]:
|
|
|
|
|
"""
|
|
|
|
|
Semantic search across knowledge base.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
query: Natural language query
|
|
|
|
|
context: Optional context for filtering results
|
|
|
|
|
top_k: Number of results per index type
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List of search results sorted by relevance
|
|
|
|
|
"""
|
|
|
|
|
if not self._initialized:
|
|
|
|
|
raise RuntimeError("OptimalService not initialized")
|
|
|
|
|
|
|
|
|
|
results: list[SearchResult] = []
|
2025-12-10 17:35:43 +01:00
|
|
|
index_types = (
|
|
|
|
|
context.index_types if context and context.index_types else list(IndexType)
|
|
|
|
|
)
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
for index_type in index_types:
|
2025-12-27 21:53:38 +01:00
|
|
|
plugin = self._plugins.get(index_type)
|
|
|
|
|
if plugin:
|
|
|
|
|
try:
|
|
|
|
|
plugin_results = await plugin.search(query=query, top_k=top_k)
|
|
|
|
|
results.extend(plugin_results)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Search failed for index",
|
|
|
|
|
index_type=index_type.value,
|
|
|
|
|
error=str(e),
|
2025-12-10 02:49:54 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Sort by score descending
|
|
|
|
|
results.sort(key=lambda r: r.score, reverse=True)
|
2025-12-10 17:35:43 +01:00
|
|
|
return results[: top_k * len(index_types)]
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
async def query(
|
|
|
|
|
self,
|
|
|
|
|
query: str,
|
|
|
|
|
context: QueryContext | None = None,
|
|
|
|
|
top_k: int = 5,
|
|
|
|
|
) -> RAGResponse:
|
|
|
|
|
"""
|
|
|
|
|
Query the knowledge base with RAG.
|
|
|
|
|
|
|
|
|
|
Retrieves relevant context and generates an answer.
|
|
|
|
|
"""
|
|
|
|
|
if not self._initialized:
|
|
|
|
|
raise RuntimeError("OptimalService not initialized")
|
|
|
|
|
|
2025-12-10 17:35:43 +01:00
|
|
|
index_types = (
|
|
|
|
|
context.index_types if context and context.index_types else list(IndexType)
|
|
|
|
|
)
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
for index_type in index_types:
|
2025-12-27 21:53:38 +01:00
|
|
|
plugin = self._plugins.get(index_type)
|
|
|
|
|
if plugin:
|
|
|
|
|
try:
|
|
|
|
|
answer, citations = await plugin.ask(query=query, top_k=top_k)
|
|
|
|
|
if answer:
|
|
|
|
|
return RAGResponse(
|
|
|
|
|
answer=answer,
|
|
|
|
|
citations=citations,
|
|
|
|
|
query=query,
|
|
|
|
|
context_used=len(citations),
|
2025-12-10 02:49:54 +01:00
|
|
|
)
|
2025-12-27 21:53:38 +01:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"RAG query failed for index",
|
|
|
|
|
index_type=index_type.value,
|
|
|
|
|
error=str(e),
|
2025-12-10 02:49:54 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return RAGResponse(
|
|
|
|
|
answer="I couldn't find relevant information to answer your question.",
|
|
|
|
|
citations=[],
|
|
|
|
|
query=query,
|
|
|
|
|
context_used=0,
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
# =========================================================================
|
|
|
|
|
# SPECIALIZED SEARCH (Optimal Brain)
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
async def search_errors(
|
|
|
|
|
self,
|
|
|
|
|
error_message: str,
|
|
|
|
|
context: str = "",
|
|
|
|
|
top_k: int = 5,
|
|
|
|
|
) -> list[SearchResult]:
|
|
|
|
|
"""Search for known solutions to an error."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.ERRORS)
|
|
|
|
|
if isinstance(plugin, ErrorsIndexPlugin):
|
|
|
|
|
return await plugin.search_error(error_message, context, top_k)
|
|
|
|
|
return await plugin.search(query=error_message, top_k=top_k)
|
|
|
|
|
|
|
|
|
|
async def get_standards(
|
|
|
|
|
self,
|
|
|
|
|
domain: str,
|
|
|
|
|
language: str | None = None,
|
|
|
|
|
severity: str | None = None,
|
|
|
|
|
top_k: int = 20,
|
|
|
|
|
) -> list[SearchResult]:
|
|
|
|
|
"""Get standards for a domain/language."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.STANDARDS)
|
|
|
|
|
if isinstance(plugin, StandardsIndexPlugin):
|
|
|
|
|
return await plugin.get_standards(domain, language, severity, top_k)
|
|
|
|
|
return await plugin.search(query=f"{domain} standards", top_k=top_k)
|
|
|
|
|
|
|
|
|
|
async def check_decision(
|
|
|
|
|
self,
|
|
|
|
|
topic: str,
|
|
|
|
|
threshold: float = 0.7,
|
|
|
|
|
top_k: int = 5,
|
|
|
|
|
) -> list[Any]:
|
|
|
|
|
"""Check for similar past decisions."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.DECISIONS)
|
|
|
|
|
if isinstance(plugin, DecisionsIndexPlugin):
|
|
|
|
|
return await plugin.check_decision(topic, threshold, top_k)
|
|
|
|
|
results = await plugin.search(query=topic, top_k=top_k)
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
async def search_learnings(
|
|
|
|
|
self,
|
|
|
|
|
query: str,
|
|
|
|
|
category: str | None = None,
|
|
|
|
|
team: str | None = None,
|
|
|
|
|
top_k: int = 10,
|
|
|
|
|
) -> list[SearchResult]:
|
|
|
|
|
"""Search for relevant learnings."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.LEARNINGS)
|
|
|
|
|
if isinstance(plugin, LearningsIndexPlugin):
|
|
|
|
|
return await plugin.search_learnings(query, category, team, True, top_k)
|
|
|
|
|
return await plugin.search(query=query, top_k=top_k)
|
|
|
|
|
|
|
|
|
|
async def get_reviews_for_file(
|
|
|
|
|
self,
|
|
|
|
|
file_path: str,
|
|
|
|
|
top_k: int = 10,
|
|
|
|
|
) -> list[SearchResult]:
|
|
|
|
|
"""Get past reviews for a file or similar files."""
|
|
|
|
|
plugin = self._get_plugin(IndexType.REVIEWS)
|
|
|
|
|
if isinstance(plugin, ReviewsIndexPlugin):
|
|
|
|
|
return await plugin.get_reviews_for_file(file_path, top_k)
|
|
|
|
|
return await plugin.search(query=f"review {file_path}", top_k=top_k)
|
|
|
|
|
|
2025-12-10 02:49:54 +01:00
|
|
|
# =========================================================================
|
|
|
|
|
# UTILITY OPERATIONS
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
async def get_stats(self) -> dict[str, Any]:
|
|
|
|
|
"""Get statistics about all indexes."""
|
|
|
|
|
if not self._initialized:
|
|
|
|
|
return {"initialized": False}
|
|
|
|
|
|
|
|
|
|
stats: dict[str, Any] = {"initialized": True, "indexes": {}}
|
2025-12-27 21:53:38 +01:00
|
|
|
for index_type, plugin in self._plugins.items():
|
2025-12-10 02:49:54 +01:00
|
|
|
try:
|
2025-12-27 21:53:38 +01:00
|
|
|
count = await plugin.count()
|
2025-12-10 02:49:54 +01:00
|
|
|
stats["indexes"][index_type.value] = {"document_count": count}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
stats["indexes"][index_type.value] = {"error": str(e)}
|
|
|
|
|
|
|
|
|
|
return stats
|
|
|
|
|
|
|
|
|
|
async def clear_index(self, index_type: IndexType) -> None:
|
|
|
|
|
"""Clear a specific index."""
|
2025-12-27 21:53:38 +01:00
|
|
|
plugin = self._get_plugin(index_type)
|
|
|
|
|
await plugin.clear()
|
2025-12-10 02:49:54 +01:00
|
|
|
logger.info("Cleared index", index_type=index_type.value)
|
|
|
|
|
|
2025-12-28 19:52:01 +01:00
|
|
|
async def list_documents(
|
|
|
|
|
self, index_type: IndexType, limit: int = 50, offset: int = 0
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
List documents in a specific index.
|
|
|
|
|
|
|
|
|
|
Returns list of documents with id, source, indexed_at, and metadata.
|
|
|
|
|
"""
|
|
|
|
|
plugin = self._get_plugin(index_type)
|
|
|
|
|
|
|
|
|
|
# Check if plugin has list_documents method
|
|
|
|
|
if hasattr(plugin, "list_documents"):
|
|
|
|
|
return await plugin.list_documents(limit=limit, offset=offset)
|
|
|
|
|
|
|
|
|
|
# Fallback: return empty list if plugin doesn't support listing
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Plugin does not support list_documents",
|
|
|
|
|
index_type=index_type.value,
|
|
|
|
|
)
|
|
|
|
|
return []
|
|
|
|
|
|
2025-12-10 02:49:54 +01:00
|
|
|
async def refresh_index(self, index_type: IndexType, sources: list[str]) -> None:
|
|
|
|
|
"""Refresh an index with new sources."""
|
2025-12-27 21:53:38 +01:00
|
|
|
plugin = self._get_plugin(index_type)
|
|
|
|
|
# For file-based indexes, re-add sources
|
|
|
|
|
await plugin.add_sources(sources)
|
2025-12-10 02:49:54 +01:00
|
|
|
logger.info("Refreshed index", index_type=index_type.value, sources=sources)
|
|
|
|
|
|
2025-12-27 21:53:38 +01:00
|
|
|
# =========================================================================
|
|
|
|
|
# BACKWARDS COMPATIBILITY - Document Ingestion
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
async def ingest_document(
|
|
|
|
|
self,
|
|
|
|
|
index_type: IndexType,
|
|
|
|
|
content: str,
|
|
|
|
|
metadata: dict[str, Any],
|
|
|
|
|
doc_id: str | None = None,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""
|
|
|
|
|
Ingest a document with metadata directly into an index.
|
|
|
|
|
|
|
|
|
|
This method maintains backwards compatibility with the old API.
|
|
|
|
|
"""
|
|
|
|
|
plugin = self._get_plugin(index_type)
|
|
|
|
|
await plugin.ingest(content=content, doc_id=doc_id, **metadata)
|
|
|
|
|
|
2025-12-10 02:49:54 +01:00
|
|
|
|
2025-12-12 02:45:47 +01:00
|
|
|
class _OptimalServiceHolder:
|
|
|
|
|
"""Holder for singleton OptimalService instance."""
|
|
|
|
|
|
|
|
|
|
instance: OptimalService | None = None
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_optimal_service() -> OptimalService:
|
|
|
|
|
"""Get or create the OptimalService instance."""
|
2025-12-12 02:45:47 +01:00
|
|
|
if _OptimalServiceHolder.instance is None:
|
|
|
|
|
_OptimalServiceHolder.instance = OptimalService()
|
|
|
|
|
await _OptimalServiceHolder.instance.initialize()
|
|
|
|
|
return _OptimalServiceHolder.instance
|
2025-12-10 02:49:54 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def close_optimal_service() -> None:
|
|
|
|
|
"""Close the OptimalService instance."""
|
2025-12-12 02:45:47 +01:00
|
|
|
if _OptimalServiceHolder.instance is not None:
|
|
|
|
|
await _OptimalServiceHolder.instance.close()
|
|
|
|
|
_OptimalServiceHolder.instance = None
|