""" doc_facts.py — Fact Lock Layer for Stepan v3.2. Зберігає структуровані числові факти з документів у session (TTL=900s), щоб уникнути інконсистентності RAG між запитами. Ключові функції: extract_doc_facts(text) → dict (rule-based, без LLM) merge_doc_facts(old, new) → dict (merge з conflict detection) can_answer_from_facts(q, f) → (bool, list[str]) compute_scenario(q, facts) → (bool, str) """ from __future__ import annotations import re import logging from typing import Any logger = logging.getLogger(__name__) # ── Ключі фактів ──────────────────────────────────────────────────────────── # Усі числові значення в UAH або га. FACT_KEYS = ( "profit_uah", "revenue_uah", "cost_total_uah", "fertilizer_uah", "seed_uah", "area_ha", "profit_uah_per_ha", "cost_uah_per_ha", ) # ── Словник тригерів у питаннях ────────────────────────────────────────────── _QUESTION_TRIGGERS: dict[str, list[str]] = { "profit_uah": ["прибут", "profit", "дохід", "заробіт", "чист"], "revenue_uah": ["виручк", "revenue", "надходж"], "cost_total_uah": ["витрат", "cost", "видатк", "загальн"], "fertilizer_uah": ["добрив", "fertiliz", "мінерал"], "seed_uah": ["насінн", "seed", "посів"], "area_ha": ["площ", "гектар", " га", "area"], "profit_uah_per_ha": ["грн/га", "на гектар", "per ha", "прибут.*га"], "cost_uah_per_ha": ["витрат.*га", "cost.*ha", "на гектар.*витрат"], } # ── Регулярні вирази для числових значень ──────────────────────────────────── # Числа типу: 5 972 016, 9684737, 12 016.13, 497, 5\u00a0972\u00a0016 (NBSP) _NUM = r"[\d][\d\s\u00a0\u202f]*(?:[.,][\d]+)?" # Шаблони для витягування фактів (порядок важливий — специфічніші спочатку) _PATTERNS: list[tuple[str, str]] = [ # грн/га — спочатку, специфічніші паттерни (r"(?:прибут\w*)\s*[—:–]\s*(" + _NUM + r")\s*грн/га", "profit_uah_per_ha"), (r"(?:прибут\w*\s+(?:на\s+)?(?:гектар|га)[^.]{0,20}?)\s*[:\s]\s*(" + _NUM + r")\s*грн/га", "profit_uah_per_ha"), (r"(" + _NUM + r")\s*грн/га\s*(?:прибут)", "profit_uah_per_ha"), (r"прибут\w+\s+на\s+гектар[^.]{0,30}(" + _NUM + r")\s*грн/га", "profit_uah_per_ha"), (r"(?:витрат\w*)\s*[—:–\(]*\s*(" + _NUM + r")\s*грн/га", "cost_uah_per_ha"), (r"(" + _NUM + r")\s*грн/га", "cost_uah_per_ha"), # га / гектар (r"площ\w*\s*[—:–]\s*(" + _NUM + r")\s*(?:га|гектар)", "area_ha"), (r"(" + _NUM + r")\s*(?:га\b|гектар)", "area_ha"), # Конкретні категорії (грн / гривень) (r"добрив\w*[^.]*?(" + _NUM + r")\s*(?:грн|гривень)", "fertilizer_uah"), (r"насінн\w*[^.]*?(" + _NUM + r")\s*(?:грн|гривень)", "seed_uah"), (r"(?:загальн\w*\s*)?витрат\w*[^.]*?(" + _NUM + r")\s*(?:грн|гривень)", "cost_total_uah"), (r"виручк\w*[^.]*?(" + _NUM + r")\s*(?:грн|гривень)", "revenue_uah"), (r"прибут\w*[^.]*?(" + _NUM + r")\s*(?:грн|гривень)", "profit_uah"), # Зворотній порядок (r"(" + _NUM + r")\s*(?:грн|гривень)[^.]*?прибут", "profit_uah"), (r"(" + _NUM + r")\s*(?:грн|гривень)[^.]*?виручк", "revenue_uah"), (r"(" + _NUM + r")\s*(?:грн|гривень)[^.]*?добрив", "fertilizer_uah"), (r"(" + _NUM + r")\s*(?:грн|гривень)[^.]*?витрат", "cost_total_uah"), ] def _parse_number(s: str) -> float: """ Fix 3: Нормалізація числового рядка з XLSX/тексту. Обробляє: пробіли, NBSP, тонкі пробіли, кому як роздільник тисяч, одиниці виміру (грн, грн/га, га, ha, %), скобки. """ s = str(s).strip() # Прибираємо одиниці виміру та стрічки після числа s = re.sub(r"\s*(грн/га|грн|гривень|ga|га\b|ha\b|%|тис\.?|млн\.?)\s*$", "", s, flags=re.IGNORECASE) # Прибираємо дужки (від'ємні числа в бухобліку: "(1 234)" → "-1234") negative = s.startswith("(") and s.endswith(")") if negative: s = s[1:-1].strip() # Прибираємо всі пробільні символи всередині числа: # звичайний пробіл, NBSP (U+00A0), тонкий пробіл (U+202F), нерозривний вузький пробіл s = re.sub(r"(?<=\d)[\s\u00a0\u202f\u2009\u2007]+(?=\d)", "", s) # Якщо кома — роздільник тисяч (5,972,016) → прибрати # Якщо кома — десяткова (1,5) → замінити на крапку comma_count = s.count(",") dot_count = s.count(".") if comma_count >= 2 or (comma_count == 1 and dot_count == 0 and len(s) - s.index(",") > 3): # "5,972,016" або "1,234,567" — роздільник тисяч s = s.replace(",", "") else: # "1,5" або "12,016.13" — десяткова кома s = s.replace(",", ".") # Прибираємо зайві крапки (залишаємо лише останню як десяткову) parts = s.split(".") if len(parts) > 2: s = "".join(parts[:-1]) + "." + parts[-1] s = s.strip() try: val = float(s) return -val if negative else val except ValueError: return 0.0 def extract_doc_facts(text: str) -> dict[str, float]: """ Rule-based витягує числові факти з тексту. Повертає тільки впевнено розпізнані пари {key: float}. Fail-safe: будь-яка помилка → повертає {}. """ if not text: return {} try: found: dict[str, float] = {} t = text.lower() for pattern, key in _PATTERNS: # Якщо ключ вже знайдений з конкретнішого паттерну — не перезаписувати if key in found: continue m = re.search(pattern, t, re.IGNORECASE) if m: val = _parse_number(m.group(1)) if val > 0: found[key] = val return found except Exception as exc: logger.debug("extract_doc_facts error (non-blocking): %s", exc) return {} def merge_doc_facts(old: dict, new: dict) -> dict: """ Зливає старий і новий словники фактів. - Якщо ключ новий — додає. - Якщо ключ є і значення відрізняється > 1% — фіксує конфлікт, не перезаписує. Fail-safe: помилка → повертає old. """ if not new: return old try: merged = dict(old) conflicts: dict[str, dict] = merged.get("conflicts", {}) for key, new_val in new.items(): if key in ("conflicts", "needs_recheck"): continue old_val = merged.get(key) if old_val is None: merged[key] = new_val elif old_val > 0 and abs(new_val - old_val) / old_val > 0.01: conflicts[key] = {"old": old_val, "new": new_val} merged["needs_recheck"] = True # Якщо однаковий — залишаємо старий (стабільність) if conflicts: merged["conflicts"] = conflicts return merged except Exception as exc: logger.debug("merge_doc_facts error (non-blocking): %s", exc) return old def can_answer_from_facts(question: str, facts: dict) -> tuple[bool, list[str]]: """ Визначає чи питання стосується ключів що є у facts. Повертає (True, [keys]) якщо можна відповісти з кешу. """ if not facts or not question: return False, [] try: q = question.lower() matched: list[str] = [] for key, triggers in _QUESTION_TRIGGERS.items(): if key not in facts: continue for trigger in triggers: if re.search(trigger, q): if key not in matched: matched.append(key) break return bool(matched), matched except Exception as exc: logger.debug("can_answer_from_facts error: %s", exc) return False, [] def compute_scenario(question: str, facts: dict) -> tuple[bool, str]: """ Розраховує прості сценарії: - "якщо добрива ×2, яким буде прибуток?" Повертає (True, text) якщо розрахунок можливий, (False, "") інакше. """ if not facts or not question: return False, "" try: q = question.lower() # Сценарій: добрива × N → новий прибуток double_fertilizer = re.search( r"добрив\w*.{0,30}(?:збільш|×\s*2|удвіч|вдві|2\s*раз|подвоїт)", q ) or re.search( r"(?:збільш|удвіч|вдві|2\s*раз).{0,30}добрив", q ) if double_fertilizer: profit = facts.get("profit_uah") fertilizer = facts.get("fertilizer_uah") if profit and fertilizer: new_profit = profit - fertilizer # добрива×2 → +fertilizer зайвих витрат delta = -fertilizer sign = "зменшиться" if delta < 0 else "збільшиться" abs_delta = abs(delta) area = facts.get("area_ha") per_ha = "" if area and area > 0: per_ha = f" ({new_profit/area:,.0f} грн/га)".replace(",", " ") text = ( f"Якщо витрати на добрива збільшити вдвічі (+{fertilizer:,.0f} грн), " f"прибуток {sign} на {abs_delta:,.0f} грн і складе " f"{new_profit:,.0f} грн{per_ha}." ).replace(",", " ") return True, text missing = [] if not profit: missing.append("прибуток") if not fertilizer: missing.append("витрати на добрива") return False, f"Для розрахунку потрібно: {', '.join(missing)}." except Exception as exc: logger.debug("compute_scenario error: %s", exc) return False, "" # ── PROMPT 26: Self-Correction helpers ─────────────────────────────────────── _CLAIM_PATTERNS: list[tuple[re.Pattern, str, bool]] = [ (re.compile(r"(нем[аі]\w*\s+прибут|прибут\w+\s+нем[аі]|без\s+прибут)", re.I), "profit_present", False), (re.compile(r"(є\s+прибут|прибут\w+\s+[—–:]\s*\d|прибут.*\d.*грн)", re.I), "profit_present", True), (re.compile(r"(нем[аі]\w*\s+витрат|витрат\w+\s+нем[аі])", re.I), "cost_present", False), (re.compile(r"(є\s+витрат|витрат\w+\s+[—–:]\s*\d)", re.I), "cost_present", True), ] _CORRECTION_PHRASES: dict[tuple, str] = { ("profit_present", False, True): ( "Раніше я написав, що прибутку в документі немає. Це було неточно — він є. " ), ("profit_present", True, False): ( "Раніше я вказував прибуток. Схоже, у цьому фрагменті його не знайшов — перевір, будь ласка. " ), ("cost_present", False, True): ( "Раніше я написав, що витрат немає. Це було неточно — вони є. " ), } def extract_fact_claims(text: str) -> list[dict]: """Витягує fact claims з тексту відповіді агента (для Self-Correction).""" import time as _time if not text: return [] claims = [] for pattern, key, value in _CLAIM_PATTERNS: if pattern.search(text): claims.append({"key": key, "value": value, "ts": _time.time()}) return claims def build_self_correction( response_text: str, facts: dict, session: dict, current_doc_id: str | None = None, ) -> str: """ Якщо нова відповідь суперечить попереднім claims → повертає prefix-речення. Тільки для deep-mode. Self-correction спрацьовує лише в межах одного doc_id. Fail-safe: помилка → "". """ try: # v3.3: Doc Anchor Guard — не виправляємо між різними документами if current_doc_id is not None: session_doc_id = session.get("active_doc_id") if session_doc_id and session_doc_id != current_doc_id: return "" prev_claims: list[dict] = session.get("fact_claims") or [] if not prev_claims: return "" new_claims = extract_fact_claims(response_text) if not new_claims: return "" prev_by_key: dict[str, bool] = {c["key"]: c["value"] for c in prev_claims} for claim in new_claims: key = claim["key"] new_val = claim["value"] old_val = prev_by_key.get(key) if old_val is not None and old_val != new_val: phrase = _CORRECTION_PHRASES.get((key, old_val, new_val), "") if phrase: return phrase return "" except Exception: return "" def format_facts_as_text(facts: dict) -> str: """Форматує doc_facts у коротку людяну відповідь.""" lines = [] labels = { "profit_uah": "Прибуток", "revenue_uah": "Виручка", "cost_total_uah": "Загальні витрати", "fertilizer_uah": "Витрати на добрива", "seed_uah": "Витрати на насіння", "area_ha": "Площа", "profit_uah_per_ha": "Прибуток на га", "cost_uah_per_ha": "Витрати на га", } units = { "area_ha": "га", "profit_uah_per_ha": "грн/га", "cost_uah_per_ha": "грн/га", } for key, label in labels.items(): val = facts.get(key) if val is None: continue unit = units.get(key, "грн") if unit == "грн": lines.append(f"• {label}: {val:,.0f} {unit}".replace(",", " ")) else: lines.append(f"• {label}: {val:,.2f} {unit}".replace(",", ".")) return "\n".join(lines)