fix: massively improved vendor info extraction (CVR/address/phone/domain)

This commit is contained in:
Christian 2026-03-01 12:04:53 +01:00
parent 07584b1b0c
commit a8970701ab
2 changed files with 172 additions and 60 deletions

View File

@ -1 +1 @@
2.2.4
2.2.6

View File

@ -377,14 +377,103 @@ async def link_email(email_id: int, payload: Dict):
@router.post("/emails/{email_id}/extract-vendor-suggestion")
async def extract_vendor_suggestion(email_id: int):
"""
Forsøger at udtrække leverandørinfo fra email body og vedhæftede fakturaer.
Returnerer forslag til navn, CVR, adresse, telefon, email, domæne.
Udtrækker leverandørinfo fra email body og vedhæftede PDF-fakturaer.
Bruger stærke regex-mønstre + AI for CVR, adresse, telefon, domæne.
"""
import re
import os
# ── Hjælpefunktioner ────────────────────────────────────────────────────
def clean_phone(raw: str) -> str:
"""Normaliser telefonnummer til +45 XXXX XXXX eller 8 cifre"""
digits = re.sub(r'[^\d+]', '', raw)
if digits.startswith('+45') and len(digits) == 11:
return digits
if digits.startswith('45') and len(digits) == 10:
return '+' + digits
bare = re.sub(r'\D', '', raw)
if len(bare) == 8:
return bare
return raw.strip()[:20]
def extract_cvr(text: str, own_cvr: str = '') -> Optional[str]:
patterns = [
# Med label
r'(?:CVR|Cvr\.?-?nr\.?|cvr|Moms(?:nr\.?|registrerings?nr\.?)|VAT\s*(?:no\.?|nr\.?|number))[:\s.\-]*(?:DK)?[\s\-]?(\d{8})',
# DK-præfiks
r'\bDK[\s\-]?(\d{8})\b',
# Standalone 8 cifre (sidst mindst specifik)
r'\b(\d{8})\b',
]
for pat in patterns:
for m in re.finditer(pat, text, re.IGNORECASE):
val = m.group(1)
if val != own_cvr and val.isdigit():
return val
return None
def extract_phones(text: str) -> Optional[str]:
patterns = [
# Med label
r'(?:Tlf\.?|Tel\.?|Telefon|Phone|Mobil|Fax)[:\s.\-]*(\+?[\d][\d\s\-().]{6,18})',
# +45 XXXXXXXX
r'(\+45[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2})',
# 8 cifre i grupper: 12 34 56 78 / 1234 5678
r'\b(\d{2}[\s\-]\d{2}[\s\-]\d{2}[\s\-]\d{2})\b',
r'\b(\d{4}[\s\-]\d{4})\b',
]
for pat in patterns:
m = re.search(pat, text, re.IGNORECASE)
if m:
return clean_phone(m.group(1))
return None
def extract_address(text: str) -> Optional[str]:
# Dansk postnummer 4 cifre + by
m = re.search(
r'([A-ZÆØÅ][a-zæøåA-ZÆØÅ\-\.]+(?:\s+\d+[A-Za-z]?(?:,?\s*(?:st|tv|th|\d+\.?\s*(?:sal|etage)?))?)?,?\s*\d{4}\s+[A-ZÆØÅ][a-zæøåA-ZÆØÅ\s\-]+)',
text
)
if m:
return m.group(0).strip()
# Fallback: vejnavn + husnummer + postnummer
m = re.search(
r'([A-ZÆØÅ][a-zæøåA-ZÆØÅ]+(?:vej|gade|alle|vænge|torv|plads|stræde|boulevard|have|bakke|skov|park|strand|mark|eng)\s*\d+[A-Za-z]?\s*,?\s*\d{4})',
text, re.IGNORECASE
)
if m:
return m.group(0).strip()
return None
def extract_domain(text: str, sender_email: str = '') -> Optional[str]:
# Eksplicit www
m = re.search(r'(?:www\.|https?://)([\w\-]+\.[\w\-]+(?:\.[\w]{2,6})?)', text, re.IGNORECASE)
if m:
return m.group(1).lower()
# Emailadresser i teksten (ikke @bmcnetworks)
for em in re.finditer(r'[\w.\-+]+@([\w\-]+\.[\w\-]+(?:\.[\w]{2,6})?)', text):
dom = em.group(1).lower()
if 'bmc' not in dom and 'gmail' not in dom and 'outlook' not in dom and 'hotmail' not in dom:
return dom
# Sender email
if sender_email and '@' in sender_email:
dom = sender_email.split('@')[1].lower()
if 'gmail' not in dom and 'outlook' not in dom and 'hotmail' not in dom:
return dom
return None
def extract_company_name(text: str, sender_name: str = '') -> Optional[str]:
"""Prøv at finde firmanavn via CVR-nær tekst eller typiske DK-firmasuffikser"""
m = re.search(
r'\b([\w\s\-&\'\.]+(?:A/S|ApS|IVS|I/S|K/S|P/S|GmbH|Ltd\.?|LLC|AB|AS))\b',
text
)
if m:
return m.group(1).strip()
return sender_name or None
# ── Hoved-logik ─────────────────────────────────────────────────────────
try:
# Hent email
email_result = execute_query(
"SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,)
@ -393,10 +482,13 @@ async def extract_vendor_suggestion(email_id: int):
raise HTTPException(status_code=404, detail="Email ikke fundet")
email = email_result[0]
# Saml tekst fra body + vedhæftede PDF-filer
from app.core.config import settings
own_cvr = getattr(settings, 'OWN_CVR', '')
# Saml tekst fra body + PDF-bilag
text_parts = []
if email.get('body_text'):
text_parts.append(email['body_text'])
text_parts.append(("body", email['body_text']))
attachments = execute_query(
"SELECT * FROM email_attachments WHERE email_id = %s ORDER BY id",
@ -405,89 +497,109 @@ async def extract_vendor_suggestion(email_id: int):
for att in (attachments or []):
file_path = att.get('file_path')
if file_path and os.path.exists(file_path):
content_type = att.get('content_type', '')
if 'pdf' in content_type or file_path.lower().endswith('.pdf'):
ct = att.get('content_type', '')
if 'pdf' in ct or file_path.lower().endswith('.pdf'):
try:
pdf_text = await ollama_service._extract_text_from_file(file_path)
if pdf_text:
text_parts.append(f"[PDF: {att['filename']}]\n{pdf_text}")
text_parts.append(("pdf", pdf_text))
except Exception as e:
logger.warning(f"⚠️ Kunne ikke læse PDF {file_path}: {e}")
combined_text = "\n\n".join(text_parts)
# Prioriter PDF-tekst for leverandørinfo (header + footer indeholder firmainfo)
# Tag: første 800 tegn (header) + sidste 800 tegn (footer) fra hvert dokument
focused_parts = []
for src, txt in text_parts:
if len(txt) > 1200:
focused_parts.append(f"[{src} header]\n{txt[:800]}")
focused_parts.append(f"[{src} footer]\n{txt[-800:]}")
else:
focused_parts.append(f"[{src}]\n{txt}")
focused_text = "\n\n".join(focused_parts)
combined_text = "\n\n".join(t for _, t in text_parts)
sender_name = email.get('sender_name') or ''
sender_email = email.get('sender_email') or ''
# ── Regex udtræk ────────────────────────────────────────────────────
suggestion = {
"name": email.get('sender_name') or '',
"email": email.get('sender_email') or '',
"cvr_number": None,
"phone": None,
"address": None,
"domain": None,
"source": "regex"
"name": extract_company_name(focused_text, sender_name) or sender_name,
"email": sender_email,
"cvr_number": extract_cvr(focused_text, own_cvr),
"phone": extract_phones(focused_text),
"address": extract_address(focused_text),
"domain": extract_domain(focused_text, sender_email),
"source": "regex"
}
# Regex fallback: uddrag CVR (8 cifre efter CVR/Momsnr/DK)
cvr_match = re.search(
r'(?:CVR|Cvr|cvr|Momsnr\.?|DK)[\s:.-]*([0-9]{8})',
combined_text
)
if cvr_match:
suggestion['cvr_number'] = cvr_match.group(1)
logger.info(f"🔍 Regex udtræk for email {email_id}: {suggestion}")
# Regex: telefon (dansk format)
phone_match = re.search(
r'(?:Tlf|Tel|Telefon|Phone)[\s.:]*([+\d][\d\s\-().]{6,15})',
combined_text, re.IGNORECASE
)
if phone_match:
suggestion['phone'] = phone_match.group(1).strip()
# Domæne fra sender email
if email.get('sender_email') and '@' in email['sender_email']:
suggestion['domain'] = email['sender_email'].split('@')[1]
# Brug AI hvis vi har tekst fra PDF
if len(combined_text) > 100:
# ── AI udtræk (forbedrer regex-resultat) ────────────────────────────
if focused_text.strip():
try:
from app.core.config import settings
own_cvr = getattr(settings, 'OWN_CVR', '')
# Send kun den fokuserede tekst (max 4000 tegn) til AI
ai_text = focused_text[:4000]
prompt = f"""OPGAVE: Udtræk leverandørens firmainfo fra denne tekst.
TEKSTEN er body/footer fra en faktura-email eller vedhæftet faktura.
RETURNER KUN VALID JSON - ingen forklaring!
prompt = f"""Du er en ekspert i at udtrække firmaoplysninger fra danske fakturaer og e-mails.
OPGAVE: Find LEVERANDØRENS firmaoplysninger i teksten nedenfor.
Leverandøren er AFSENDEREN - IKKE BMC Networks og IKKE køber.
RETURNER KUN DETTE JSON - ingen forklaring, ingen markdown:
{{
\"name\": \"Firmanavn ApS\",
\"cvr_number\": \"12345678\",
\"address\": \"Vejnavn 1, 2000 By\",
\"phone\": \"12345678\",
\"email\": \"kontakt@firma.dk\",
\"domain\": \"firma.dk\"
"name": "Firmanavn ApS",
"cvr_number": "12345678",
"address": "Vejnavn 1, 2000 By",
"phone": "12345678",
"email": "kontakt@firma.dk",
"domain": "firma.dk"
}}
REGLER:
- name: LEVERANDØRENS firmanavn (ikke køber, ikke BMC)
- cvr_number: 8-cifret CVR - IGNORER {own_cvr} (det er køber)
- Sæt null hvis ikke fundet
- KUN JSON output!
- name: Firmanavn med A/S, ApS, IVS osv. - IKKE BMC Networks
- cvr_number: Præcis 8 cifre efter "CVR", "CVR-nr", "Moms" eller "DK" - IGNORER {own_cvr}
- address: Fuld adresse med postnummer og by (dansk format: "Vejnavn 1, 1234 By")
- phone: Telefonnummer - foretrukket format: "+45 XXXX XXXX" eller "XXXX XXXX"
- email: Kontakt-email til firmaet (IKKE afsender-email hvis den er personlig)
- domain: Hjemmeside-domæne f.eks. "firma.dk" eller "www.firma.dk"
- Sæt null for felter der IKKE kan findes med sikkerhed
TEKST (max 3000 tegn):
{combined_text[:3000]}
KENDTE REGEX-RESULTATER (brug som hjælp, ret dem hvis de er forkerte):
- cvr: {suggestion.get('cvr_number') or 'ikke fundet'}
- phone: {suggestion.get('phone') or 'ikke fundet'}
- address: {suggestion.get('address') or 'ikke fundet'}
- domain: {suggestion.get('domain') or 'ikke fundet'}
KUN JSON:"""
TEKST:
{ai_text}
JSON:"""
ai_result = await ollama_service.extract_from_text(prompt)
if ai_result and isinstance(ai_result, dict):
# Merge AI resultat ind i suggestion (AI prioriteres over regex)
improved = False
for field in ('name', 'cvr_number', 'address', 'phone', 'email', 'domain'):
val = ai_result.get(field)
if val and val not in (None, 'null', '', 'N/A'):
suggestion[field] = str(val).strip()
suggestion['source'] = 'ai'
if val and str(val).strip() not in ('null', '', 'N/A', 'None', own_cvr):
new_val = str(val).strip()
if new_val != str(suggestion.get(field) or ''):
suggestion[field] = new_val
improved = True
if improved:
suggestion['source'] = 'ai'
logger.info(f"✅ AI vendor suggestion for email {email_id}: {suggestion}")
except Exception as e:
logger.warning(f"⚠️ AI udtræk fejlede, bruger regex-resultat: {e}")
# Rens: fjern domæner der tilhører kendte mailservere
spam_domains = {'gmail.com', 'hotmail.com', 'outlook.com', 'yahoo.com', 'live.com', 'icloud.com'}
if suggestion.get('domain') in spam_domains:
suggestion['domain'] = None
# Fjern own_cvr hvis den snegte sig ind
if suggestion.get('cvr_number') == own_cvr:
suggestion['cvr_number'] = None
return suggestion
except HTTPException: