Emeritus-21's picture
Initial commit: Frag Naija AI Support API
9298f7f
Raw History Blame Contribute Delete
7.8 kB
"""
Stage 26.5 - Knowledge Synchronization.
Provides a non-blocking, incremental synchronization mechanism for both documents
and approved websites. It compares content hashes to avoid unnecessary reprocessing,
identifies new/changed/removed content, and delegates vector updates to the
KnowledgeIndexer (which safely upserts/deletes without wiping the entire database).
"""
import asyncio
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
from app.knowledge.hashing import compute_content_hash
from app.knowledge.indexer import KnowledgeIndexer
from app.knowledge.ingestion import prepare_chunks
from app.knowledge.loaders import LoaderRegistry, default_registry
from app.knowledge.models import (
IngestionStatus,
KnowledgeSource,
LifecycleStatus,
SourceType,
)
from app.knowledge.repository import KnowledgeSourceRepository
from app.knowledge.website import CrawlPolicy, HttpClient, HttpxClient, WebsiteCrawler, page_to_chunks
import structlog
logger = structlog.get_logger(__name__)
@dataclass
class SyncReport:
source_id: str
source_name: str
source_type: str
pages_discovered: int = 0
pages_changed: int = 0
pages_unchanged: int = 0
pages_removed: int = 0
chunks_added: int = 0
chunks_updated: int = 0
chunks_removed: int = 0
failures: List[str] = field(default_factory=list)
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
class KnowledgeSyncService:
"""Orchestrates incremental synchronization for all active knowledge sources."""
def __init__(
self,
repo: KnowledgeSourceRepository,
indexer: KnowledgeIndexer,
loader_registry: LoaderRegistry = default_registry,
http_client: Optional[HttpClient] = None,
):
self.repo = repo
self.indexer = indexer
self.loader_registry = loader_registry
self.http_client = http_client or HttpxClient()
async def sync_all(self) -> List[SyncReport]:
"""Synchronizes all ACTIVE sources. Safe to run as a background task."""
sources = await self.repo.list_sources(lifecycle_status=LifecycleStatus.ACTIVE)
reports = []
for source in sources:
try:
if source.source_type == SourceType.DOCUMENT:
report = await self._sync_document(source)
elif source.source_type == SourceType.WEBSITE:
report = await self._sync_website(source)
else:
continue # Skip FAQ/ARTICLE for now as per Stage 26.4 scope
reports.append(report)
except Exception as e:
logger.error("sync_failed", source_id=source.id, error=str(e))
reports.append(SyncReport(
source_id=source.id, source_name=source.name,
source_type=source.source_type.value, failures=[str(e)]
))
return reports
async def _sync_document(self, source: KnowledgeSource) -> SyncReport:
report = SyncReport(
source_id=source.id, source_name=source.name, source_type=source.source_type.value
)
path = Path(source.location)
if not path.exists():
report.failures.append(f"File not found: {source.location}")
return report
try:
text = self.loader_registry.load(path)
except Exception as e:
report.failures.append(f"Load failed: {e}")
return report
new_hash = compute_content_hash(text)
report.pages_discovered = 1
if new_hash == source.content_hash:
report.pages_unchanged = 1
return report
# Content changed -> reprocess
report.pages_changed = 1
run = await self.repo.begin_ingestion(source.id, triggered_by="sync_service")
try:
chunks = prepare_chunks(source, path, self.loader_registry)
index_result = await self.indexer.index_source(source.id, chunks)
report.chunks_added = index_result.inserted
report.chunks_updated = index_result.updated
report.chunks_removed = index_result.deleted
await self.repo.complete_ingestion(
run.id, new_hash, len(chunks), index_result.total_written, IngestionStatus.SUCCEEDED
)
except Exception as e:
report.failures.append(f"Index failed: {e}")
await self.repo.fail_ingestion(run.id, str(e))
return report
async def _sync_website(self, source: KnowledgeSource) -> SyncReport:
report = SyncReport(
source_id=source.id, source_name=source.name, source_type=source.source_type.value
)
# Extract policy from source metadata or use defaults
meta = source.extra_metadata
policy = CrawlPolicy(
allowed_domains=meta.get("allowed_domains", [source.location.split("/")[2]]),
allowed_paths=meta.get("allowed_paths", []),
max_pages=meta.get("max_pages", 10),
respect_robots=meta.get("respect_robots", True),
)
crawler = WebsiteCrawler(self.http_client, policy)
prev_page_hashes: Dict[str, str] = meta.get("page_hashes", {})
try:
# Crawl the seed URL
result = await crawler.crawl([source.location], known_urls=set(prev_page_hashes.keys()))
except Exception as e:
report.failures.append(f"Crawl failed: {e}")
return report
report.pages_discovered = len(result.pages)
report.pages_removed = len(result.removed)
report.failures.extend([f.error for f in result.failures])
new_page_hashes = {}
all_chunks = []
for page in result.pages:
new_page_hashes[page.url] = page.content_hash
if prev_page_hashes.get(page.url) == page.content_hash:
report.pages_unchanged += 1
else:
report.pages_changed += 1
# Generate chunks for new/changed pages
chunks = page_to_chunks(source.id, page, source_version=source.current_version + 1)
all_chunks.extend(chunks)
# Index all new/changed chunks (Indexer handles deleting obsolete chunks for this source)
if all_chunks or result.removed:
run = await self.repo.begin_ingestion(source.id, triggered_by="sync_service")
try:
index_result = await self.indexer.index_source(source.id, all_chunks)
report.chunks_added = index_result.inserted
report.chunks_updated = index_result.updated
report.chunks_removed = index_result.deleted
# Update source metadata with new page hashes
meta["page_hashes"] = new_page_hashes
source.extra_metadata = meta
await self.repo.complete_ingestion(
run.id, compute_content_hash(str(new_page_hashes)),
len(all_chunks), index_result.total_written, IngestionStatus.SUCCEEDED
)
except Exception as e:
report.failures.append(f"Index failed: {e}")
await self.repo.fail_ingestion(run.id, str(e))
# Deactivate removed pages if they were tracked as separate sources (optional based on architecture)
# Here we just record them in the report as they are removed from the vector store by the indexer.
return report