New router intelligence modules (26 files): alert_ingest/store, audit_store, architecture_pressure, backlog_generator/store, cost_analyzer, data_governance, dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment, platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files), signature_state_store, sofiia_auto_router, tool_governance New services: - sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static - memory-service: integration_endpoints, integrations, voice_endpoints, static UI - aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents) - sofiia-supervisor: new supervisor service - aistalk-bridge-lite: Telegram bridge lite - calendar-service: CalDAV calendar service with reminders - mlx-stt-service / mlx-tts-service: Apple Silicon speech services - binance-bot-monitor: market monitor service - node-worker: STT/TTS memory providers New tools (9): agent_email, browser_tool, contract_tool, observability_tool, oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus, farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine, session_context, style_adapter, telemetry) Tests: 85+ test files for all new modules Made-with: Cursor
346 lines
16 KiB
Python
346 lines
16 KiB
Python
"""
|
||
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)
|