fix(nordagpt): nuclear anti-hallucination — whitelist + bold text validation
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Three layers of defense:
1. PROMPT: explicit whitelist of allowed company names + slugs
2. VALIDATOR: link slug verification (existing)
3. VALIDATOR: bold text scan — removes **FakeName** if not in DB

AI can no longer mention companies as plain/bold text to bypass link validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-28 06:37:38 +01:00
parent 464e456939
commit 855856dc99

View File

@ -215,13 +215,41 @@ class NordaBizChatEngine:
text = re.sub(r'<a[^>]*href=["\']/firma/([a-z0-9-]+)["\'][^>]*>(.*?)</a>', replace_pill_link, text)
# 3. Clean up artifacts left by removals
text = re.sub(r'\*\s*\s*\n', '\n', text) # "* " (bullet with removed company)
text = re.sub(r'\*\s*oraz\s*', '*', text) # "* oraz " fragments
text = re.sub(r'\n\s*\*\s*\n', '\n', text) # empty bullet points
text = re.sub(r'\n\s*-\s*\n', '\n', text) # empty list items
text = re.sub(r' +', ' ', text) # double spaces
text = re.sub(r'\n{3,}', '\n\n', text) # triple+ newlines
# 3. Remove plain-text mentions of fake companies (bold or plain)
# Catch patterns like "**Baumar**" or "Baumar" that appear as company names
# but aren't in the valid list
valid_names_set = set(n.lower() for n in valid_companies.values())
def replace_bold_company(match):
bold_name = match.group(1).strip()
if bold_name.lower() in valid_names_set:
return match.group(0) # Keep valid company
# Check if it's likely a company name (capitalized, not a common word)
common_words = {'budownictwo', 'infrastruktura', 'technologia', 'energia', 'bezpieczeństwo',
'doradztwo', 'networking', 'konsorcja', 'aktualności', 'przygotowanie',
'zatrudnienie', 'wniosek', 'kontakt', 'local content', 'projekt',
'chłodzenie', 'elektryka', 'telekomunikacja', 'ochrona', 'zarządzanie',
'hvac', 'instalacje', 'oze', 'it', 'inwestycje'}
if bold_name.lower() in common_words or len(bold_name) < 3:
return match.group(0) # Not a company name
# Check if valid company name contains this as substring
for vn in valid_names_set:
if bold_name.lower() in vn or vn in bold_name.lower():
return match.group(0) # Partial match — keep it
logger.warning(f"NordaGPT hallucination blocked: bold text '{bold_name}' not a known company")
return ''
text = re.sub(r'\*\*([^*]{2,40})\*\*', replace_bold_company, text)
# 4. Clean up artifacts left by removals
text = re.sub(r':\s*oraz\s*to\b', ': to', text) # ": oraz to" → ": to"
text = re.sub(r':\s*,', ':', text) # ": ," → ":"
text = re.sub(r'\*\s*\s*\n', '\n', text) # "* "
text = re.sub(r'\*\s*oraz\s*', '*', text) # "* oraz "
text = re.sub(r'\n\s*\*\s*\n', '\n', text) # empty bullet points
text = re.sub(r'\n\s*-\s*\n', '\n', text) # empty list items
text = re.sub(r' +', ' ', text) # double spaces
text = re.sub(r'\n{3,}', '\n\n', text) # triple+ newlines
return text.strip()
@ -1103,12 +1131,14 @@ ZASADY PERSONALIZACJI:
- Uwzględniaj kontekst firmy użytkownika w odpowiedziach
- NIE ujawniaj danych technicznych (user_id, company_id, rola systemowa)
KRYTYCZNA ZASADA DOTYCZĄCA FIRM:
- WYMIENIAJ WYŁĄCZNIE firmy, które znajdują się w dostarczonej bazie danych poniżej
- NIGDY nie wymyślaj, nie zgaduj ani nie halucynuj nazw firm
- Jeśli w bazie nie ma firmy pasującej do zapytania, powiedz wprost: "W bazie Izby nie znalazłem firmy o takim profilu"
- Każda wymieniona firma MUSI mieć link do profilu w formacie [Nazwa](/firma/slug)
- Jeśli nie masz pewności czy firma istnieje w bazie NIE WYMIENIAJ JEJ
ABSOLUTNY ZAKAZ HALUCYNACJI FIRM:
- NIGDY nie wymyślaj nazw firm. To jest NAJWAŻNIEJSZA zasada.
- Wymieniaj WYŁĄCZNIE firmy z sekcji "FIRMY W BAZIE" poniżej żadnych innych.
- Każdą firmę podawaj WYŁĄCZNIE jako link: [Nazwa Firmy](/firma/slug) używając dokładnego slug z bazy.
- Jeśli żadna firma z bazy nie pasuje do zapytania, napisz wprost: "W bazie Izby nie znalazłem firmy o takim profilu."
- NIE WYMIENIAJ firm jako zwykły tekst (bold, kursywa) TYLKO jako link [Nazwa](/firma/slug).
- NIE UŻYWAJ nazw firm ze swojej wiedzy ogólnej TYLKO z dostarczonej bazy.
- Złamanie tej zasady oznacza linkowanie do nieistniejących stron (404) co jest niedopuszczalne.
"""
# Inject user memory (facts + conversation summaries) into prompt
@ -1377,7 +1407,17 @@ W dyskusji [Artur Wiertel](link) pytał o moderację. Pełna treść: [moje uwag
# Add ALL companies in compact JSON format
if context.get('all_companies'):
system_prompt += "\n\n🏢 PEŁNA BAZA FIRM (wybierz najlepsze):\n"
# Build explicit whitelist of allowed company names + slugs
whitelist_lines = []
for c in context['all_companies']:
name = c.get('name', '')
profile = c.get('profile', '')
slug = profile.replace('/firma/', '') if profile else ''
if name and slug:
whitelist_lines.append(f" {name} → [link](/firma/{slug})")
system_prompt += "\n\n⚠️ DOZWOLONE FIRMY — możesz wymieniać TYLKO te (użyj dokładnie podanego linku):\n"
system_prompt += "\n".join(whitelist_lines)
system_prompt += "\n\n🏢 SZCZEGÓŁY FIRM:\n"
system_prompt += json.dumps(context['all_companies'], ensure_ascii=False, indent=None)
system_prompt += "\n"