orc-renaming/src/orc_renaming/rules.py

153 lines
4.9 KiB
Python

from __future__ import annotations
import re
from datetime import date
from orc_renaming.config import AppConfig
from orc_renaming.models import DocumentScope, DocumentType, ExtractionResult
INVOICE_TERMS = (
"rechnung",
"rechnungsnummer",
"rechnungsbetrag",
"nettobetrag",
"bruttobetrag",
"umsatzsteuer",
"mehrwertsteuer",
"zahlbetrag",
)
LABELED_DATE_RE = re.compile(
r"(?i)\b(?:rechnungsdatum|belegdatum|briefdatum|datum)\s*[:\-]?\s*"
r"(?P<date>\d{1,2}[./-]\d{1,2}[./-]\d{2,4}|\d{4}-\d{2}-\d{2})"
)
GENERIC_DATE_RE = re.compile(
r"\b(?P<date>\d{1,2}[./-]\d{1,2}[./-]\d{4}|\d{4}-\d{2}-\d{2})\b"
)
def _normalize_search_text(value: str) -> str:
return re.sub(r"\s+", " ", value).casefold()
def _parse_german_date(value: str) -> date | None:
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
try:
return date.fromisoformat(value)
except ValueError:
return None
parts = re.split(r"[./-]", value)
if len(parts) != 3:
return None
day, month, year = (int(part) for part in parts)
if year < 100:
year += 2000
try:
return date(year, month, day)
except ValueError:
return None
def _extract_date(text: str) -> tuple[date | None, str | None, bool]:
for match in LABELED_DATE_RE.finditer(text):
result = _parse_german_date(match.group("date"))
if result:
return result, match.group(0), True
for match in GENERIC_DATE_RE.finditer(text):
result = _parse_german_date(match.group("date"))
if result:
return result, match.group(0), False
return None, None, False
def extract_with_rules(text: str, config: AppConfig) -> ExtractionResult:
search_text = _normalize_search_text(text)
evidence: list[str] = []
warnings: list[str] = []
term_hits = sorted(term for term in INVOICE_TERMS if term in search_text)
if len(term_hits) >= 2:
document_type = DocumentType.INVOICE
type_confidence = 0.93
evidence.append(f"Rechnungsmerkmale: {', '.join(term_hits[:5])}")
elif len(term_hits) == 1:
document_type = DocumentType.INVOICE
type_confidence = 0.68
evidence.append(f"Rechnungsmerkmal: {term_hits[0]}")
warnings.append("Dokumenttyp nur schwach erkannt")
else:
document_type = DocumentType.UNKNOWN
type_confidence = 0.0
document_date, date_evidence, date_was_labeled = _extract_date(text)
if date_evidence:
evidence.append(f"Datum: {date_evidence}")
if document_date and not date_was_labeled:
warnings.append("Datum ohne eindeutige Feldbezeichnung erkannt")
company: str | None = None
for candidate in config.companies:
aliases = [candidate.name, *candidate.aliases]
if any(_normalize_search_text(alias) in search_text for alias in aliases):
company = candidate.name
evidence.append(f"Firma aus Stammdaten: {candidate.name}")
break
property_matches: list[tuple[str, str]] = []
for candidate in config.properties:
hits = [
marker
for marker in candidate.markers
if _normalize_search_text(marker) in search_text
]
if hits:
property_matches.append((candidate.id, hits[0]))
property_id: str | None = None
scope = DocumentScope.UNKNOWN
if len(property_matches) == 1:
property_id, marker = property_matches[0]
scope = DocumentScope.PROPERTY
evidence.append(f"Immobilienmerkmal {property_id}: {marker}")
elif len(property_matches) > 1:
ids = ", ".join(item[0] for item in property_matches)
warnings.append(f"Mehrere Immobilien passen: {ids}")
if scope == DocumentScope.UNKNOWN:
private_hits = [
marker
for marker in config.private_markers
if _normalize_search_text(marker) in search_text
]
if private_hits:
scope = DocumentScope.PRIVATE
evidence.append(f"Privatmerkmal: {private_hits[0]}")
elif (
document_type == DocumentType.INVOICE
and config.processing.assume_unmatched_invoices_private
):
scope = DocumentScope.PRIVATE
warnings.append("Ohne Treffer gemäß Konfiguration als privat angenommen")
confidence_parts = [type_confidence]
if document_date:
confidence_parts.append(0.95 if date_was_labeled else 0.65)
if company:
confidence_parts.append(0.98)
if scope != DocumentScope.UNKNOWN:
confidence_parts.append(0.98)
confidence = min(confidence_parts) if confidence_parts else 0.0
return ExtractionResult(
document_type=document_type,
scope=scope,
document_date=document_date,
company=company,
property_id=property_id,
confidence=confidence,
evidence=evidence,
warnings=warnings,
source="rules",
)