chatbot-rag / src /fact_extractor.py
scythe327's picture
Upload src/fact_extractor.py with huggingface_hub
a6ff15d verified
Raw
History Blame Contribute Delete
7.43 kB
import json
import re
import time
from typing import Optional
import polars as pl
from src.config import Config
from src.llm import LocalLLM
class FactExtractor:
def __init__(self, config: Config, llm: LocalLLM):
self.config = config
self.llm = llm
def extract(self, chunks_df: pl.DataFrame) -> pl.DataFrame:
if chunks_df.is_empty():
return pl.DataFrame(
schema={
"entity": pl.Utf8,
"attribute": pl.Utf8,
"value": pl.Utf8,
"unit": pl.Utf8,
"currency": pl.Utf8,
"min_quantity": pl.Float64,
"max_quantity": pl.Float64,
"condition": pl.Utf8,
"valid_from": pl.Utf8,
"valid_to": pl.Utf8,
"confidence": pl.Float64,
"source_url": pl.Utf8,
"source_title": pl.Utf8,
"chunk_id": pl.Int64,
}
)
all_facts = []
for row in chunks_df.to_dicts():
text = row.get("text", "")
url = row.get("url", "")
title = row.get("title", "")
chunk_id = row.get("chunk_id", 0)
regex_facts = self._extract_regex(text, url, title, chunk_id)
all_facts.extend(regex_facts)
if not all_facts:
return pl.DataFrame(
schema={
"entity": pl.Utf8,
"attribute": pl.Utf8,
"value": pl.Utf8,
"unit": pl.Utf8,
"currency": pl.Utf8,
"min_quantity": pl.Float64,
"max_quantity": pl.Float64,
"condition": pl.Utf8,
"valid_from": pl.Utf8,
"valid_to": pl.Utf8,
"confidence": pl.Float64,
"source_url": pl.Utf8,
"source_title": pl.Utf8,
"chunk_id": pl.Int64,
}
)
return pl.DataFrame(all_facts)
def _extract_regex(
self, text: str, source_url: str, source_title: str, chunk_id: int
) -> list[dict]:
facts = []
text_lower = text.lower()
patterns = [
(r"(?:price|cost|fee|rate|charge|payment|subscription|license)\s*(?:\#|no\.?|:)?\s*\$?\s*([\d,]+(?:\.\d{1,2})?)", "price", "USD", "per_item"),
(r"(?:price|cost|fee|rate|charge|payment|subscription|license)\s*(?:\#|no\.?|:)?\s*£?\s*([\d,]+(?:\.\d{1,2})?)", "price", "GBP", "per_item"),
(r"(?:price|cost|fee|rate|charge|payment|subscription|license)\s*(?:\#|no\.?|:)?\s*€?\s*([\d,]+(?:\.\d{1,2})?)", "price", "EUR", "per_item"),
(r"\$?\s*([\d,]+(?:\.\d{1,2})?)\s*(?:per\s*(month|year|item|unit|day|week|hour|minute))", "price", "USD", "per_{}"),
(r"£?\s*([\d,]+(?:\.\d{1,2})?)\s*(?:per\s*(month|year|item|unit|day|week|hour|minute))", "price", "GBP", "per_{}"),
(r"€?\s*([\d,]+(?:\.\d{1,2})?)\s*(?:per\s*(month|year|item|unit|day|week|hour|minute))", "price", "EUR", "per_{}"),
(r"(\d+)\s*(?:-\s*(\d+))?\s*(?:years?|months?)\s*(?:experience|required|needed|warranty)", "duration", None, "time"),
(r"(?:speed|bandwidth|throughput|rate)\s*(?::|of|up to)?\s*(\d+)\s*(Mbps|Gbps|mbps|gbps)", "speed", None, "Mbps/Gbps"),
(r"(?:capacity|storage|space|memory|ram)\s*(?::|of)?\s*(\d+)\s*(GB|TB|MB|gb|tb|mb)", "capacity", None, "GB/TB"),
(r"(\d+)\s*(?:users?|seats?|licenses?|employees?)", "capacity", None, "per_seat"),
(r"(\d+)\s*(?:-\s*(\d+))?\s*(?:days?)\s*(?:money.back|guarantee|refund|cancellation|notice)", "duration", None, "days"),
(r"(?:minimum|max\.?|maximum)\s*(?:order|purchase|quantity|amount)\s*(?::|is)?\s*(\d+)", "quantity", None, "units"),
(r"(?:free|included|trial)\s*(?:for\s*)?(\d+)\s*(?:days?|months?)", "duration", None, "trial_period"),
]
for pattern, attr, currency, unit in patterns:
for match in re.finditer(pattern, text, re.IGNORECASE):
try:
value_raw = match.group(1).replace(",", "")
value = float(value_raw) if "." in value_raw else value_raw
except (ValueError, IndexError):
continue
actual_unit = unit.format(match.group(2)) if "{}" in unit else unit
min_q = None
max_q = None
try:
min_q = float(match.group(1).replace(",", ""))
max_q = float(match.group(2).replace(",", "")) if match.group(2) else None
except (IndexError, ValueError):
pass
contextual_entity = self._guess_entity(text, text_lower)
facts.append({
"entity": contextual_entity,
"attribute": attr,
"value": str(value),
"unit": actual_unit,
"currency": currency or "",
"min_quantity": min_q,
"max_quantity": max_q,
"condition": None,
"valid_from": None,
"valid_to": None,
"confidence": 0.7,
"source_url": source_url,
"source_title": source_title,
"chunk_id": chunk_id,
})
return facts
def _guess_entity(self, text: str, text_lower: str) -> str:
keywords_priority = [
"openreach", "bt", "ee", "vodafone", "virgin media", "sky",
"talktalk", "three", "o2", "plusnet", "shell energy",
]
for kw in keywords_priority:
if kw in text_lower:
return kw.title()
return "Unknown"
def _extract_llm(self, text: str, source_url: str, source_title: str, chunk_id: int) -> list[dict]:
if not self.llm:
return []
prompt = f"""Extract structured facts (prices, speeds, capacities, durations, quantities, ranges, conditions) from this text.
Text:
{text[:2000]}
Return a JSON array. Each fact has: entity, attribute, value, unit (or null), currency (or null), min_quantity (or null), max_quantity (or null), condition (or null), confidence (0-1)."""
try:
resp = self.llm.generate(
messages=[
{"role": "system", "content": "You extract structured facts from text. Return only valid JSON arrays."},
{"role": "user", "content": prompt},
],
max_tokens=1024,
temperature=0.01,
)
json_str = resp.strip()
if json_str.startswith("```"):
json_str = json_str.split("```")[1]
if json_str.startswith("json"):
json_str = json_str[4:]
json_str = json_str.strip()
facts = json.loads(json_str)
if isinstance(facts, list):
for f in facts:
f["source_url"] = source_url
f["source_title"] = source_title
f["chunk_id"] = chunk_id
return facts
except Exception:
pass
return []