diff --git a/VERSION b/VERSION index bda8fbe..5bc1cc4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.6 +2.2.7 diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py index 8c2f607..be2d6ae 100644 --- a/app/emails/backend/router.py +++ b/app/emails/backend/router.py @@ -396,6 +396,35 @@ async def extract_vendor_suggestion(email_id: int): return bare return raw.strip()[:20] + # Kendte faktureringsplatforme der sender på vegne af leverandører + PLATFORM_DOMAINS = { + 'e-conomic.com', 'e-conomic.dk', 'dinero.dk', 'billy.dk', + 'uniconta.com', 'visma.com', 'simplybilling.dk', 'fakturasend.dk', + 'invoicecloud.com', 'invoiced.com', 'stripe.com', 'paypal.com', + } + + # Placeholder-adresser (e-conomic og lignende skabeloner) + PLACEHOLDER_ADDRESSES = { + 'vejnavn 1, 1234 by', 'vejnavn 1,1234 by', 'vejnavn 1', + '1234 by', 'adresse 1', 'eksempel 1', 'gadenavn 1', + } + + def is_placeholder_cvr(cvr: str) -> bool: + """Filtrer åbenlyse placeholder/dummy CVRs""" + known_fakes = { + '12345678', '87654321', '00000000', '11111111', '22222222', + '33333333', '44444444', '55555555', '66666666', '77777777', + '88888888', '99999999', '12341234', '11223344', '99887766', + } + if cvr in known_fakes: + return True + if len(set(cvr)) == 1: + return True + digits = [int(c) for c in cvr] + if all(digits[i+1] - digits[i] == 1 for i in range(7)): + return True + return False + def extract_cvr(text: str, own_cvr: str = '') -> Optional[str]: patterns = [ # Med label @@ -408,7 +437,7 @@ async def extract_vendor_suggestion(email_id: int): for pat in patterns: for m in re.finditer(pat, text, re.IGNORECASE): val = m.group(1) - if val != own_cvr and val.isdigit(): + if val != own_cvr and val.isdigit() and not is_placeholder_cvr(val): return val return None @@ -429,47 +458,89 @@ async def extract_vendor_suggestion(email_id: int): return None def extract_address(text: str) -> Optional[str]: - # Dansk postnummer 4 cifre + by + # Format 1: "Vejnavn 12K[, etage/side][ -/,] 4000 By" + # Håndterer: "Jernbanegade 12K, st.tv - 4000 Roskilde" + # "Nørregade 5, 1. sal, 8000 Aarhus" + # "Industrivej 3 - 2200 København N" 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\-]+)', + r'([A-ZÆØÅ][a-zæøåA-ZÆØÅ\-\.]{2,}\s+\d+[A-Za-z]?' + r'(?:[,\s]+[a-zæøåA-ZÆØÅ0-9][a-zæøåA-ZÆØÅ0-9\.\s]{0,15}?)?' + r'(?:[,\s]+|\s*[-–]\s*)\d{4}\s+[A-ZÆØÅ][a-zæøåA-ZÆØÅ]{2,})', text ) if m: return m.group(0).strip() - # Fallback: vejnavn + husnummer + postnummer + # Format 2: vejnavn-suffix (vej/gade/alle osv.) + 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})', + r'([A-ZÆØÅ][a-zæøåA-ZÆØÅ]+(?:vej|gade|alle|vænge|torv|plads|stræde|boulevard|have|bakke|skov|park|strand|mark|eng)' + r'\s*\d+[A-Za-z]?(?:[,\s]+|\s*[-–]\s*)\d{4}(?:\s+[A-ZÆØÅ][a-zæøåA-ZÆØÅ]+)?)', text, re.IGNORECASE ) if m: return m.group(0).strip() + # Format 3: find postnummer + by, tag kontekst foran + m = re.search(r'(\d{4}\s+[A-ZÆØÅ][a-zæøåA-ZÆØÅ]{2,})', text) + if m: + start = max(0, m.start() - 50) + snippet = text[start:m.end()].strip().lstrip('-–, ') + return snippet return None + def is_platform_or_spam(domain: str) -> bool: + """Returnerer True hvis domænet tilhører en platform/mailsystem""" + spam = {'gmail.com', 'hotmail.com', 'outlook.com', 'yahoo.com', 'live.com', 'icloud.com'} + return domain in spam or domain in PLATFORM_DOMAINS or 'bmc' in domain + 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) + dom = m.group(1).lower() + if not is_platform_or_spam(dom): + return dom + # Emailadresser i teksten 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: + if not is_platform_or_spam(dom): return dom - # Sender email + # Sender email (kun hvis ikke platform) 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: + if not is_platform_or_spam(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""" + """Find firmanavn via DK-suffikser, footer-mønster eller CVR-nær tekst""" + # Prioritet 1: navne med DK juridiske suffikser 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() + + # Prioritet 2: typisk faktura-footer format: + # "FirmaNavn - Adresse - Postnr By - CVR-nr.: XXXXXXXX" + m = re.search( + r'^([A-ZÆØÅ][A-Za-zæøåÆØÅ\s\-&\'\.]{2,50}?)\s*[-–]\s*[A-ZÆØÅ][a-zæøåA-ZÆØÅ]', + text, re.MULTILINE + ) + if m: + name = m.group(1).strip() + if len(name) > 2 and not any(w in name.lower() for w in ('tlf', 'tel', 'mail', 'bank', 'cvr', 'mobil')): + return name + + # Prioritet 3: tekst lige FORAN "CVR" på samme linje + m = re.search( + r'([A-ZÆØÅ][A-Za-zæøåÆØÅ\s\-&\'\.]{2,50}?)\s*[-–,]?\s*(?:CVR|Cvr)', + text + ) + if m: + name = m.group(1).strip().rstrip('-–, ') + if len(name) > 2: + return name + return sender_name or None # ── Hoved-logik ───────────────────────────────────────────────────────── @@ -521,14 +592,30 @@ async def extract_vendor_suggestion(email_id: int): sender_name = email.get('sender_name') or '' sender_email = email.get('sender_email') or '' + # Er afsenderen en faktureringsplatform? (e-conomic etc.) + sender_domain = sender_email.split('@')[1].lower() if '@' in sender_email else '' + is_platform_sender = sender_domain in PLATFORM_DOMAINS + + # Brug ikke sender_email som leverandør-email når det er en platform + # Prøv i stedet at finde en rigtig email i teksten + vendor_email = None + if not is_platform_sender and sender_email: + vendor_email = sender_email + else: + for em in re.finditer(r'[\w.\-+]+@([\w\-]+\.[\w\-]+(?:\.[\w]{2,6})?)', focused_text): + dom = em.group(1).lower() + if dom not in PLATFORM_DOMAINS and 'bmc' not in dom: + vendor_email = em.group(0) + break + # ── Regex udtræk ──────────────────────────────────────────────────── suggestion = { "name": extract_company_name(focused_text, sender_name) or sender_name, - "email": sender_email, + "email": vendor_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), + "domain": extract_domain(focused_text, sender_email if not is_platform_sender else ''), "source": "regex" } @@ -540,35 +627,50 @@ async def extract_vendor_suggestion(email_id: int): # Send kun den fokuserede tekst (max 4000 tegn) til AI ai_text = focused_text[:4000] + # Pre-filtrer hints inden AI ser dem (undgå at sende placeholders som hints) + hint_cvr = suggestion.get('cvr_number') + if hint_cvr and is_placeholder_cvr(hint_cvr): + hint_cvr = None + hint_addr = suggestion.get('address') + if hint_addr and any(ph in hint_addr.lower() for ph in PLACEHOLDER_ADDRESSES): + hint_addr = None + hint_domain = suggestion.get('domain') + if hint_domain and is_platform_or_spam(hint_domain): + hint_domain = None + 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. +Leverandøren er AFSENDEREN (sælger/udsteder) - IKKE BMC Networks og IKKE køber/modtager. + +VIGTIGT: E-mails kan være sendt via faktureringsplatforme som e-conomic, Dinero, Billy osv. +I så fald er leverandøren den virksomhed DER EJER fakturaen - IKKE platformen selv. +Ignorer alle data tilhørende: e-conomic.com, dinero.dk, billy.dk, uniconta.com RETURNER KUN DETTE JSON - ingen forklaring, ingen markdown: {{ "name": "Firmanavn ApS", - "cvr_number": "12345678", - "address": "Vejnavn 1, 2000 By", + "cvr_number": "87654321", + "address": "Rigtig Vej 5, 2200 København", "phone": "12345678", "email": "kontakt@firma.dk", "domain": "firma.dk" }} REGLER: -- 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") +- name: Firmanavn med A/S, ApS, IVS osv. - IKKE BMC Networks, IKKE e-conomic +- cvr_number: Præcis 8 cifre efter "CVR", "CVR-nr", "Moms" eller "DK" - IGNORER {own_cvr}, IGNORER 12345678 (placeholder) +- address: Fuld RIGTIG adresse med postnummer og by - IGNORER "Vejnavn 1, 1234 By" (placeholder) - 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" +- email: Kontakt-email til firmaet - IKKE e-conomic.com, IKKE post@e-conomic.com +- domain: Hjemmeside-domæne - IKKE e-conomic.com, IKKE faktureringsplatform - Sæt null for felter der IKKE kan findes med sikkerhed -KENDTE REGEX-RESULTATER (brug som hjælp, ret dem hvis de er forkerte): -- cvr: {suggestion.get('cvr_number') or 'ikke fundet'} +KENDTE REGEX-RESULTATER (brug som hjælp - disse er allerede filtrerede): +- cvr: {hint_cvr or 'ikke fundet'} - phone: {suggestion.get('phone') or 'ikke fundet'} -- address: {suggestion.get('address') or 'ikke fundet'} -- domain: {suggestion.get('domain') or 'ikke fundet'} +- address: {hint_addr or 'ikke fundet'} +- domain: {hint_domain or 'ikke fundet'} TEKST: {ai_text} @@ -591,15 +693,27 @@ JSON:""" 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: + # Rens: fjern platform/spam domæner + if suggestion.get('domain') and is_platform_or_spam(suggestion['domain']): suggestion['domain'] = None - # Fjern own_cvr hvis den snegte sig ind - if suggestion.get('cvr_number') == own_cvr: + # Fjern own_cvr og placeholder CVRs + if suggestion.get('cvr_number') == own_cvr or ( + suggestion.get('cvr_number') and is_placeholder_cvr(suggestion['cvr_number']) + ): suggestion['cvr_number'] = None + # Fjern placeholder-adresser (e-conomic og lignende skabeloner) + addr_lower = (suggestion.get('address') or '').lower().strip() + if any(ph in addr_lower for ph in PLACEHOLDER_ADDRESSES): + suggestion['address'] = None + + # Fjern platform email hvis AI satte den alligevel + if suggestion.get('email') and '@' in suggestion.get('email', ''): + em_domain = suggestion['email'].split('@')[-1].lower() + if em_domain in PLATFORM_DOMAINS: + suggestion['email'] = None + return suggestion except HTTPException: