Files
microdao-daarion/crews/agromatrix_crew/doc_facts.py
Apple 129e4ea1fc feat(platform): add new services, tools, tests and crews modules
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
2026-03-03 07:14:14 -08:00

346 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)