"""BLIF business knowledge retrieval for reply generation. The goal is deliberately pragmatic: keep mutable business facts outside the LLM, retrieve only relevant approved knowledge, and let the LLM (when enabled) write an editable draft. This is not fine-tuning; it is controlled business context. """ from __future__ import annotations import json import re from dataclasses import dataclass, field from functools import lru_cache from pathlib import Path from typing import Any, Dict, Iterable, List, Optional KNOWLEDGE_PATH = Path(__file__).resolve().parent / "business_knowledge" / "blif_knowledge.json" @dataclass(frozen=True) class KnowledgeTopic: id: str title: str summary: str keywords: tuple[str, ...] = field(default_factory=tuple) facts: tuple[str, ...] = field(default_factory=tuple) forbidden: tuple[str, ...] = field(default_factory=tuple) reply_type: str = "answer_without_attachment" default_template_code: str = "BUSINESS_KNOWLEDGE_REPLY" score: float = 0.0 @dataclass(frozen=True) class KnowledgeMatch: version: str source: str topics: tuple[KnowledgeTopic, ...] products: tuple[Dict[str, Any], ...] accessories: tuple[Dict[str, Any], ...] @property def has_topics(self) -> bool: return bool(self.topics) @property def primary_topic(self) -> Optional[KnowledgeTopic]: return self.topics[0] if self.topics else None @property def default_template_code(self) -> str: if self.primary_topic: return self.primary_topic.default_template_code return "BUSINESS_KNOWLEDGE_REPLY" @property def reply_type(self) -> str: if self.primary_topic: return self.primary_topic.reply_type return "answer_without_attachment" def to_prompt_context(self, *, max_topics: int = 4) -> Dict[str, Any]: return { "knowledge_version": self.version, "source": self.source, "topics": [ { "id": topic.id, "title": topic.title, "summary": topic.summary, "facts": list(topic.facts), "forbidden": list(topic.forbidden), "reply_type": topic.reply_type, "default_template_code": topic.default_template_code, } for topic in self.topics[:max_topics] ], } def _normalize(text: str) -> str: text = str(text or "").lower() text = text.replace("á", "a").replace("à", "a").replace("ã", "a").replace("â", "a") text = text.replace("é", "e").replace("ê", "e") text = text.replace("í", "i") text = text.replace("ó", "o").replace("õ", "o").replace("ô", "o") text = text.replace("ú", "u") text = text.replace("ç", "c") return re.sub(r"\s+", " ", text).strip() @lru_cache(maxsize=1) def load_business_knowledge() -> Dict[str, Any]: with KNOWLEDGE_PATH.open("r", encoding="utf-8") as fh: return json.load(fh) def _topic_from_dict(data: Dict[str, Any], *, score: float = 0.0) -> KnowledgeTopic: return KnowledgeTopic( id=str(data.get("id") or ""), title=str(data.get("title") or ""), summary=str(data.get("summary") or ""), keywords=tuple(str(item) for item in data.get("keywords") or []), facts=tuple(str(item) for item in data.get("facts") or []), forbidden=tuple(str(item) for item in data.get("forbidden") or []), reply_type=str(data.get("reply_type") or "answer_without_attachment"), default_template_code=str(data.get("default_template_code") or "BUSINESS_KNOWLEDGE_REPLY"), score=score, ) def retrieve_business_knowledge(text: str, *, limit: int = 4) -> KnowledgeMatch: """Return approved BLIF knowledge relevant to a customer message. This intentionally uses deterministic keyword scoring first. It is cheap, transparent and good enough for high-frequency commercial topics. A future vector search can replace this function without changing the reply pipeline. """ data = load_business_knowledge() normalized = _normalize(text) scored: List[KnowledgeTopic] = [] for raw_topic in data.get("topics") or []: score = 0.0 keywords = list(raw_topic.get("keywords") or []) for keyword in keywords: kw = _normalize(keyword) if not kw: continue if kw in normalized: # Longer/multi-word matches carry a bit more intent signal. score += 2.0 if " " in kw else 1.0 # If the title appears in text, boost lightly. title = _normalize(raw_topic.get("title") or "") if title and title in normalized: score += 1.5 if score > 0: scored.append(_topic_from_dict(raw_topic, score=score)) scored.sort(key=lambda item: item.score, reverse=True) return KnowledgeMatch( version=str(data.get("version") or ""), source=str(data.get("source") or ""), topics=tuple(scored[:limit]), products=tuple(data.get("products") or []), accessories=tuple(data.get("accessories") or []), ) def format_catalog_prices(match: Optional[KnowledgeMatch] = None) -> str: data = load_business_knowledge() products: Iterable[Dict[str, Any]] = data.get("products") or [] accessories: Iterable[Dict[str, Any]] = data.get("accessories") or [] lines = ["Carregadores BLIF (s/IVA):"] for product in products: lines.append(f"- {product.get('name')}: {product.get('price_without_vat')} €") lines.append("Acessórios principais (s/IVA):") for accessory in accessories: lines.append(f"- {accessory.get('name')}: {accessory.get('price_without_vat')} €") return "\n".join(lines) def knowledge_summary_lines(match: KnowledgeMatch) -> List[str]: lines: List[str] = [] for topic in match.topics: lines.append(f"{topic.title}: {topic.summary}") for fact in topic.facts[:4]: lines.append(f"- {fact}") return lines