Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
167
app/business_knowledge_service.py
Normal file
167
app/business_knowledge_service.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user