"""Knowledge base simulation with deterministic search and persistence.""" from __future__ import annotations import re from typing import Literal from pydantic import BaseModel, Field from models.incident import IncidentScenario, KBArticleState _PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE) class KBArticle(BaseModel): """Knowledge base article with hidden accuracy fields.""" article_id: str title: str content: str solution_steps: list[str] = Field(default_factory=list) tags: list[str] = Field(default_factory=list) last_updated: str is_accurate: bool outdated_reason: str | None = None correct_solution: str | None = None class KBSearchHit(BaseModel): """Single article search hit.""" model_config = {"frozen": True} article_id: str title: str summary: str confidence: float class KBQueryResult(BaseModel): """Search response from the knowledge base.""" model_config = {"frozen": True} query: str hits: list[KBSearchHit] = Field(default_factory=list) class KBUpdateResult(BaseModel): """Result of adding or updating a KB article.""" model_config = {"frozen": True} article_id: str updated: bool accepted_for_persistence: bool message: str class KnowledgeBase: """Simulated knowledge base with staleness tracking.""" def __init__(self, articles: list[KBArticle]) -> None: self._articles: dict[str, KBArticle] = {article.article_id: article for article in articles} def query(self, search_query: str) -> KBQueryResult: """Search KB using deterministic keyword overlap.""" query_tokens = _tokenize(search_query) hits: list[KBSearchHit] = [] for article in self._articles.values(): confidence = _keyword_overlap(query_tokens, _article_tokens(article)) if confidence <= 0: continue hits.append( KBSearchHit( article_id=article.article_id, title=article.title, summary=article.content[:140], confidence=confidence, ) ) hits.sort(key=lambda h: (-h.confidence, h.article_id)) return KBQueryResult(query=search_query, hits=hits) def update_article(self, title: str, content: str) -> KBUpdateResult: """Add or update an article and score persistence eligibility.""" existing = self._find_by_title(title) accepted = _is_high_quality_update(content) if existing is not None: article_id = existing.article_id existing.content = content existing.last_updated = "2026-04-20" existing.tags = sorted(set(existing.tags + _tokenize(title))) return KBUpdateResult( article_id=article_id, updated=True, accepted_for_persistence=accepted, message="Article updated.", ) article_id = f"KB-AGENT-{len(self._articles) + 1:03d}" article = KBArticle( article_id=article_id, title=title, content=content, solution_steps=[content], tags=_tokenize(title), last_updated="2026-04-20", is_accurate=True, correct_solution=content if accepted else None, ) self._articles[article_id] = article return KBUpdateResult( article_id=article_id, updated=True, accepted_for_persistence=accepted, message="Article created.", ) def list_articles(self) -> list[KBArticle]: """Return all KB articles.""" return list(self._articles.values()) def _find_by_title(self, title: str) -> KBArticle | None: lower = title.strip().lower() for article in self._articles.values(): if article.title.strip().lower() == lower: return article return None class PersistentKnowledgeBase: """KB that accumulates accepted agent contributions across episodes.""" def __init__(self, base_articles: list[KBArticle]) -> None: self._base = list(base_articles) self._agent_contributions: list[KBArticle] = [] def reset_for_episode(self, incident: IncidentScenario) -> KnowledgeBase: """Load base + scenario + accepted contributions for the next episode.""" scenario_articles = [_from_state(article) for article in incident.kb_articles] combined = self._base + scenario_articles + list(self._agent_contributions) return KnowledgeBase(articles=combined) def record_update( self, title: str, content: str, accepted_for_persistence: bool ) -> None: """Record a correct contribution for future episodes.""" if not accepted_for_persistence: return article = KBArticle( article_id=f"KB-PERSIST-{len(self._agent_contributions) + 1:03d}", title=title, content=content, solution_steps=[content], tags=_tokenize(title), last_updated="2026-04-20", is_accurate=True, correct_solution=content, ) self._agent_contributions.append(article) def contribution_count(self) -> int: """Return number of persistent contributions.""" return len(self._agent_contributions) def _from_state(state: KBArticleState) -> KBArticle: content = state.summary return KBArticle( article_id=state.article_id, title=state.title, content=content, solution_steps=[content], tags=_tokenize(f"{state.title} {state.summary}"), last_updated="2026-04-20", is_accurate=state.is_accurate, outdated_reason=None, correct_solution=None, ) def _tokenize(text: str) -> list[str]: return _PUNCT_RE.sub(" ", text.lower()).split() def _article_tokens(article: KBArticle) -> list[str]: corpus = " ".join([article.title, article.content, " ".join(article.tags)]) return _tokenize(corpus) def _keyword_overlap(query_tokens: list[str], article_tokens: list[str]) -> float: if not query_tokens: return 0.0 matches = sum(1 for token in query_tokens if token in article_tokens) return round(matches / len(query_tokens), 3) def _is_high_quality_update(content: str) -> bool: normalized = " ".join(_tokenize(content)) required = ("root cause", "verify", "fix") return all(term in normalized for term in required)