diff --git a/RELEASES.json b/RELEASES.json index c4e0742..d723dc8 100644 --- a/RELEASES.json +++ b/RELEASES.json @@ -1,4 +1,14 @@ [ + { + "version": "2026-05-13T22:38Z", + "date": "2026-05-13", + "title": "Oberfläche vollständig in Ihrer Sprache verfügbar", + "items": [ + "Alle Bereiche der Oberfläche – Menüs, Dialoge, Karte und Meldungen – sind jetzt lokalisiert.", + "Beim Bearbeiten einer Lage bleibt die Benachrichtigungs-Einstellung jetzt korrekt erhalten.", + "Tab-Beschriftungen wurden teilweise falsch angezeigt – dieser Fehler ist behoben." + ] + }, { "version": "2026-05-03T15:21Z", "date": "2026-05-03", diff --git a/scripts/migrate_sources_classification.py b/scripts/migrate_sources_classification.py deleted file mode 100644 index 3fab3fe..0000000 --- a/scripts/migrate_sources_classification.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Einmalige LLM-Klassifikation aller noch unklassifizierten Quellen. - -Verwendung: - python3 scripts/migrate_sources_classification.py --limit 50 - python3 scripts/migrate_sources_classification.py --limit 500 # Alle - python3 scripts/migrate_sources_classification.py --recheck-pending # bereits Pending neu - -Schreibt Vorschlaege in proposed_*-Spalten. Approval erfolgt anschliessend -ueber das Verwaltungs-UI / API (POST /api/sources/{id}/classification/approve). -""" -import argparse -import asyncio -import logging -import sys -from pathlib import Path - -# src/ in PYTHONPATH aufnehmen, wenn Skript direkt aufgerufen wird -HERE = Path(__file__).resolve().parent -SRC = HERE.parent / "src" -if str(SRC) not in sys.path: - sys.path.insert(0, str(SRC)) - -from database import get_db # noqa: E402 -from services.source_classifier import bulk_classify # noqa: E402 - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", -) -logger = logging.getLogger("migrate_sources") - - -async def main(): - parser = argparse.ArgumentParser(description="LLM-Klassifikation aller Quellen.") - parser.add_argument("--limit", type=int, default=50, help="Max. Quellen pro Lauf") - parser.add_argument( - "--recheck-pending", - action="store_true", - help="Auch Quellen mit classification_source='llm_pending' neu klassifizieren", - ) - args = parser.parse_args() - - db = await get_db() - try: - result = await bulk_classify( - db, - limit=args.limit, - only_unclassified=not args.recheck_pending, - ) - finally: - await db.close() - - print(f"Verarbeitet: {result['processed']}") - print(f"Erfolgreich: {result['success']}") - print(f"Fehler: {len(result['errors'])}") - print(f"Kosten: ${result['total_cost_usd']:.4f}") - if result["errors"]: - print("\nFehler-Details:") - for e in result["errors"][:10]: - print(f" source_id={e['source_id']}: {e['error']}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/agents/analyzer.py b/src/agents/analyzer.py index 9bb45e6..88db2be 100644 --- a/src/agents/analyzer.py +++ b/src/agents/analyzer.py @@ -396,14 +396,13 @@ class AnalyzerAgent: articles_text += f"Inhalt: {content[:800]}\n" return articles_text - async def analyze(self, title: str, description: str, articles: list[dict], incident_type: str = "adhoc", fact_context_block: str = "") -> tuple[dict | None, ClaudeUsage | None]: + async def analyze(self, title: str, description: str, articles: list[dict], incident_type: str = "adhoc", fact_context_block: str = "", output_language: str = "Deutsch") -> tuple[dict | None, ClaudeUsage | None]: """Erstanalyse: Analysiert alle Meldungen zu einem Vorfall (erster Refresh).""" if not articles: return None, None articles_text = self._format_articles_text(articles) - from config import OUTPUT_LANGUAGE today = datetime.now(TIMEZONE).strftime("%d.%m.%Y") template = BRIEFING_PROMPT_TEMPLATE if incident_type == "research" else ANALYSIS_PROMPT_TEMPLATE prompt = template.format( @@ -411,7 +410,7 @@ class AnalyzerAgent: description=description or "Keine weiteren Details", articles_text=articles_text, today=today, - output_language=OUTPUT_LANGUAGE, + output_language=output_language, fact_context_block=fact_context_block, ) @@ -435,6 +434,7 @@ class AnalyzerAgent: previous_sources_json: str | None, incident_type: str = "adhoc", fact_context_block: str = "", + output_language: str = "Deutsch", ) -> tuple[dict | None, ClaudeUsage | None]: """Inkrementelle Analyse: Aktualisiert das Lagebild mit nur den neuen Artikeln. @@ -465,7 +465,6 @@ class AnalyzerAgent: except (json.JSONDecodeError, TypeError): previous_sources_text = "Fehler beim Laden der bisherigen Quellen" - from config import OUTPUT_LANGUAGE today = datetime.now(TIMEZONE).strftime("%d.%m.%Y") template = INCREMENTAL_BRIEFING_PROMPT_TEMPLATE if incident_type == "research" else INCREMENTAL_ANALYSIS_PROMPT_TEMPLATE @@ -476,7 +475,7 @@ class AnalyzerAgent: previous_sources_text=previous_sources_text, new_articles_text=new_articles_text, today=today, - output_language=OUTPUT_LANGUAGE, + output_language=output_language, fact_context_block=fact_context_block, ) @@ -580,6 +579,7 @@ class AnalyzerAgent: summary: str, recent_articles: list[dict], previous_developments: str | None = None, + output_language: str = "Deutsch", ) -> tuple[str | None, ClaudeUsage | None]: """Generiert die Kachel 'Neueste Entwicklungen' aus dem Lagebild. @@ -598,7 +598,7 @@ class AnalyzerAgent: if not recent_articles: return prev, None - from config import OUTPUT_LANGUAGE, CLAUDE_MODEL_FAST + from config import CLAUDE_MODEL_FAST today = datetime.now(TIMEZONE).strftime("%d.%m.%Y") # Kompakter Artikel-Block: nur die für Zeitstempel/Quellen nötigen Felder. @@ -629,7 +629,7 @@ class AnalyzerAgent: summary=summary.strip(), articles_text=articles_text, today=today, - output_language=OUTPUT_LANGUAGE, + output_language=output_language, ) try: diff --git a/src/agents/factchecker.py b/src/agents/factchecker.py index 2f5bff2..ef15113 100644 --- a/src/agents/factchecker.py +++ b/src/agents/factchecker.py @@ -462,19 +462,18 @@ class FactCheckerAgent: lines.append(line) return "\n".join(lines) - async def check(self, title: str, articles: list[dict], incident_type: str = "adhoc") -> tuple[list[dict], ClaudeUsage | None]: + async def check(self, title: str, articles: list[dict], incident_type: str = "adhoc", output_language: str = "Deutsch") -> tuple[list[dict], ClaudeUsage | None]: """Führt vollständigen Faktencheck durch (erster Refresh).""" if not articles: return [], None articles_text = self._format_articles_text(articles) - from config import OUTPUT_LANGUAGE template = RESEARCH_FACTCHECK_PROMPT_TEMPLATE if incident_type == "research" else FACTCHECK_PROMPT_TEMPLATE prompt = template.format( title=title, articles_text=articles_text, - output_language=OUTPUT_LANGUAGE, + output_language=output_language, ) try: @@ -494,6 +493,7 @@ class FactCheckerAgent: new_articles: list[dict], existing_facts: list[dict], incident_type: str = "adhoc", + output_language: str = "Deutsch", ) -> tuple[list[dict], ClaudeUsage | None]: """Inkrementeller Faktencheck: Prüft nur neue Artikel gegen bestehende Fakten. @@ -506,7 +506,6 @@ class FactCheckerAgent: articles_text = self._format_articles_text(new_articles, max_articles=15) existing_facts_text = self._format_existing_facts(existing_facts) - from config import OUTPUT_LANGUAGE if incident_type == "research": template = INCREMENTAL_RESEARCH_FACTCHECK_PROMPT_TEMPLATE else: @@ -516,7 +515,7 @@ class FactCheckerAgent: title=title, articles_text=articles_text, existing_facts_text=existing_facts_text, - output_language=OUTPUT_LANGUAGE, + output_language=output_language, ) try: @@ -536,6 +535,7 @@ class FactCheckerAgent: new_articles: list[dict], existing_facts: list[dict], incident_type: str = "adhoc", + output_language: str = "Deutsch", ) -> tuple[list[dict], ClaudeUsage | None]: """Zwei-Phasen inkrementeller Faktencheck: Haiku-Triage + parallele Opus-Verifikation. @@ -556,9 +556,9 @@ class FactCheckerAgent: triage_facts_text = self._format_facts_for_triage(existing_facts) articles_text = self._format_articles_text(new_articles, max_articles=15) - from config import OUTPUT_LANGUAGE, CLAUDE_MODEL_FAST + from config import CLAUDE_MODEL_FAST triage_prompt = TRIAGE_PROMPT_TEMPLATE.format( - output_language=OUTPUT_LANGUAGE, + output_language=output_language, fact_count=len(existing_facts), existing_facts_text=triage_facts_text, article_count=len(new_articles), @@ -619,7 +619,7 @@ class FactCheckerAgent: template = VERIFY_GROUP_PROMPT_TEMPLATE prompt = template.format( - output_language=OUTPUT_LANGUAGE, + output_language=output_language, theme=theme, facts_text=facts_text, new_claims_text=new_claims_text, diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index e8bb457..a81efc0 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -341,6 +341,10 @@ async def _send_email_notifications_for_incident( from email_utils.sender import send_email from email_utils.templates import incident_notification_email from config import MAGIC_LINK_BASE_URL + from services.org_settings import get_org_language + + # Sprache der Org bestimmen (die Lage gehoert genau einer Org) + org_lang_iso = await get_org_language(db, tenant_id) if tenant_id else "de" # Alle Nutzer mit aktiven Abos fuer diese Lage laden cursor = await db.execute( @@ -386,6 +390,7 @@ async def _send_email_notifications_for_incident( notifications=filtered_notifications, dashboard_url=dashboard_url, incident_type=incident_type, + lang=org_lang_iso, ) try: await send_email(prefs["email"], subject, html) @@ -743,6 +748,10 @@ class AgentOrchestrator: visibility = incident["visibility"] if "visibility" in incident.keys() else "public" created_by = incident["created_by"] if "created_by" in incident.keys() else None tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None + # Org-Sprache fuer alle KI-Agenten (Lagebild, Faktencheck, Recherche) + from services.org_settings import get_org_language, language_display + output_language_iso = await get_org_language(db, tenant_id) if tenant_id else "de" + output_language = language_display(output_language_iso) previous_summary = incident["summary"] or "" previous_sources_json = incident["sources_json"] if "sources_json" in incident.keys() else None previous_developments = incident["latest_developments"] if "latest_developments" in incident.keys() else None @@ -923,6 +932,8 @@ class AgentOrchestrator: international=international, user_id=user_id, existing_articles=existing_for_context, preferred_sources=preferred_sources, + output_language=output_language, + output_language_iso=output_language_iso, ) logger.info( f"Claude-Recherche: {len(results)} Ergebnisse" @@ -1308,12 +1319,14 @@ class AgentOrchestrator: title, description, new_articles_for_analysis, previous_summary, previous_sources_json, incident_type, fact_context_block=fact_context_block, + output_language=output_language, ) else: logger.info("Erstanalyse: Alle Artikel werden analysiert") return await analyzer.analyze( title, description, all_articles_preloaded, incident_type, fact_context_block=fact_context_block, + output_language=output_language, ) # --- Faktencheck-Task --- @@ -1327,6 +1340,7 @@ class AgentOrchestrator: ) return await factchecker.check_incremental_twophase( title, new_articles_for_analysis, existing_facts, incident_type, + output_language=output_language, ) else: logger.info( @@ -1335,6 +1349,7 @@ class AgentOrchestrator: ) return await factchecker.check_incremental( title, new_articles_for_analysis, existing_facts, incident_type, + output_language=output_language, ) else: # Alle Artikel laden falls nicht vorab geladen (Henne-Ei-Problem: @@ -1346,7 +1361,7 @@ class AgentOrchestrator: (incident_id,), ) articles_for_check = [dict(row) for row in await cursor.fetchall()] - return await factchecker.check(title, articles_for_check, incident_type) + return await factchecker.check(title, articles_for_check, incident_type, output_language=output_language) # Pipeline-Schritt 6: Faktencheck zuerst (sequenziell). Liefert den # Faktenkontext fuer das Lagebild, damit dieses auf geprueftem Stand @@ -1573,6 +1588,7 @@ class AgentOrchestrator: dev_analyzer = AnalyzerAgent() dev_text, dev_usage = await dev_analyzer.generate_latest_developments( title, description, dev_summary_source, dev_articles, previous_developments, + output_language=output_language, ) if dev_usage: usage_acc.add(dev_usage) @@ -1742,27 +1758,41 @@ class AgentOrchestrator: }, }, visibility, created_by, tenant_id) - # DB-Notifications erzeugen + # DB-Notifications erzeugen (Texte org-sprach-relativ) + is_en = output_language_iso == "en" parts = [] - if new_count > 0: - parts.append(f"{new_count} neue Meldung{'en' if new_count != 1 else ''}") - if confirmed_count > 0: - parts.append(f"{confirmed_count} bestätigt") - if contradicted_count > 0: - parts.append(f"{contradicted_count} widersprochen") - summary_text = ", ".join(parts) if parts else "Keine neuen Entwicklungen" + if is_en: + if new_count > 0: + parts.append(f"{new_count} new article{'s' if new_count != 1 else ''}") + if confirmed_count > 0: + parts.append(f"{confirmed_count} confirmed") + if contradicted_count > 0: + parts.append(f"{contradicted_count} contradicted") + summary_text = ", ".join(parts) if parts else "No new developments" + research_prefix = "Research" + new_articles_msg = f"{new_count} new article{'s' if new_count != 1 else ''} found" + else: + if new_count > 0: + parts.append(f"{new_count} neue Meldung{'en' if new_count != 1 else ''}") + if confirmed_count > 0: + parts.append(f"{confirmed_count} bestätigt") + if contradicted_count > 0: + parts.append(f"{contradicted_count} widersprochen") + summary_text = ", ".join(parts) if parts else "Keine neuen Entwicklungen" + research_prefix = "Recherche" + new_articles_msg = f"{new_count} neue Meldung{'en' if new_count != 1 else ''} gefunden" db_notifications = [{ "type": "refresh_summary", "title": title, - "text": f"Recherche: {summary_text}", + "text": f"{research_prefix}: {summary_text}", "icon": "warning" if contradicted_count > 0 else "success", }] if new_count > 0: db_notifications.append({ "type": "new_articles", "title": title, - "text": f"{new_count} neue Meldung{'en' if new_count != 1 else ''} gefunden", + "text": new_articles_msg, "icon": "info", }) for sc in status_changes: diff --git a/src/agents/researcher.py b/src/agents/researcher.py index 734b62d..64db182 100644 --- a/src/agents/researcher.py +++ b/src/agents/researcher.py @@ -153,12 +153,37 @@ Jedes Element hat diese Felder: Antworte NUR mit dem JSON-Array. Keine Einleitung, keine Erklärung.""" -# Sprach-Anweisungen -LANG_INTERNATIONAL = "- Suche in Deutsch UND Englisch für internationale Abdeckung" -LANG_GERMAN_ONLY = "- Suche NUR auf Deutsch bei deutschsprachigen Quellen (Deutschland, Österreich, Schweiz)\n- KEINE englischsprachigen oder anderssprachigen Quellen" +# Sprach-Anweisungen (org-sprach-relativ; primary_display = "Deutsch" | "English") +def lang_international(primary_display: str) -> str: + if primary_display == "Deutsch": + return "- Suche in Deutsch UND Englisch für internationale Abdeckung" + if primary_display == "English": + return "- Search in English AND other relevant languages for international coverage" + return f"- Suche in {primary_display} und weiteren relevanten Sprachen" -LANG_DEEP_INTERNATIONAL = "- Suche in Deutsch, Englisch und weiteren relevanten Sprachen" -LANG_DEEP_GERMAN_ONLY = "- Suche NUR auf Deutsch bei deutschsprachigen Quellen (Deutschland, Österreich, Schweiz)\n- KEINE englischsprachigen oder anderssprachigen Quellen" + +def lang_primary_only(primary_display: str) -> str: + if primary_display == "Deutsch": + return "- Suche NUR auf Deutsch bei deutschsprachigen Quellen (Deutschland, Österreich, Schweiz)\n- KEINE englischsprachigen oder anderssprachigen Quellen" + if primary_display == "English": + return "- Search ONLY in English-language sources\n- NO sources in other languages" + return f"- Suche NUR auf {primary_display}\n- KEINE Quellen in anderen Sprachen" + + +def lang_deep_international(primary_display: str) -> str: + if primary_display == "Deutsch": + return "- Suche in Deutsch, Englisch und weiteren relevanten Sprachen" + if primary_display == "English": + return "- Search in English and other relevant languages" + return f"- Suche in {primary_display} und weiteren relevanten Sprachen" + + +def lang_deep_primary_only(primary_display: str) -> str: + if primary_display == "Deutsch": + return "- Suche NUR auf Deutsch bei deutschsprachigen Quellen (Deutschland, Österreich, Schweiz)\n- KEINE englischsprachigen oder anderssprachigen Quellen" + if primary_display == "English": + return "- Search ONLY in English-language sources\n- NO sources in other languages" + return f"- Suche NUR auf {primary_display}\n- KEINE Quellen in anderen Sprachen" FEED_SELECTION_PROMPT_TEMPLATE = """Du bist ein OSINT-Analyst. Wähle aus dieser Feed-Liste die Feeds aus, die für die Lage relevant sein könnten. Generiere außerdem optimierte Suchbegriffe für das RSS-Matching. @@ -392,7 +417,7 @@ class ResearcherAgent: logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}") return None, None - async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None, preferred_sources: list[dict] = None) -> tuple[list[dict], ClaudeUsage | None, bool]: + async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None, preferred_sources: list[dict] = None, output_language: str = "Deutsch", output_language_iso: str = "de") -> tuple[list[dict], ClaudeUsage | None, bool]: """Sucht nach Informationen zu einem Vorfall. Returns: @@ -400,8 +425,6 @@ class ResearcherAgent: das JSON aber nicht extrahierbar war. So kann der Orchestrator zwischen "echt keine Treffer" und "kaputte Antwort" unterscheiden. """ - from config import OUTPUT_LANGUAGE - # Bevorzugte Web-Quellen als Prompt-Block (optional) preferred_sources_block = "" if preferred_sources: @@ -422,7 +445,7 @@ class ResearcherAgent: ) if incident_type == "research": - lang_instruction = LANG_DEEP_INTERNATIONAL if international else LANG_DEEP_GERMAN_ONLY + lang_instruction = lang_deep_international(output_language) if international else lang_deep_primary_only(output_language) # Bestehende Artikel als Kontext für den Prompt aufbereiten existing_context = "" if existing_articles: @@ -439,11 +462,11 @@ class ResearcherAgent: ) prompt = DEEP_RESEARCH_PROMPT_TEMPLATE.format( title=title, description=description, language_instruction=lang_instruction, - output_language=OUTPUT_LANGUAGE, existing_context=existing_context, + output_language=output_language, existing_context=existing_context, preferred_sources_block=preferred_sources_block, ) else: - lang_instruction = LANG_INTERNATIONAL if international else LANG_GERMAN_ONLY + lang_instruction = lang_international(output_language) if international else lang_primary_only(output_language) # Bestehende Artikel als Kontext: bei Folge-Refreshes findet Claude andere Quellen existing_context = "" if existing_articles: @@ -458,7 +481,7 @@ class ResearcherAgent: ) prompt = RESEARCH_PROMPT_TEMPLATE.format( title=title, description=description, language_instruction=lang_instruction, - output_language=OUTPUT_LANGUAGE, existing_context=existing_context, + output_language=output_language, existing_context=existing_context, preferred_sources_block=preferred_sources_block, ) @@ -486,8 +509,8 @@ class ResearcherAgent: excluded = True break if not excluded: - # Bei nur-deutsch: nicht-deutsche Ergebnisse nachfiltern - if not international and article.get("language", "de") != "de": + # Bei nur-primary: andersprachige Ergebnisse nachfiltern + if not international and article.get("language", output_language_iso) != output_language_iso: continue filtered.append(article) diff --git a/src/config.py b/src/config.py index 1b39ea5..9acf64d 100644 --- a/src/config.py +++ b/src/config.py @@ -34,8 +34,10 @@ CLAUDE_MODEL_FAST = "claude-haiku-4-5-20251001" # Für einfache Aufgaben (Feed- CLAUDE_MODEL_MEDIUM = "claude-sonnet-4-6" # Für qualitätskritische Aufgaben (Netzwerkanalyse) CLAUDE_MODEL_STANDARD = "claude-opus-4-7" # Standard-Opus für Recherche, Analyse, Faktencheck -# Ausgabesprache (Lagebilder, Faktenchecks, Zusammenfassungen) -OUTPUT_LANGUAGE = "Deutsch" +# Ausgabesprache wird pro Organisation gesteuert -- siehe services/org_settings.py +# (organization_settings-Tabelle, Key 'output_language', Werte 'de' | 'en'). +# Default-Fallback in den Agent-Methoden ist 'Deutsch', sodass Calls ohne +# explizite Org-Bindung weiterhin deutsch produzieren. # Dev-Modus: ausfuehrliches Logging (DEBUG-Level, HTTP-Request-Log) # In Kundenversion auf False setzen oder Env-Variable entfernen diff --git a/src/database.py b/src/database.py index b8d9366..8f6c4c1 100644 --- a/src/database.py +++ b/src/database.py @@ -181,7 +181,8 @@ CREATE TABLE IF NOT EXISTS sources ( eu_disinfo_case_count INTEGER DEFAULT 0, eu_disinfo_last_seen TIMESTAMP, ifcn_signatory INTEGER DEFAULT 0, - external_data_synced_at TIMESTAMP + external_data_synced_at TIMESTAMP, + primary_language TEXT ); CREATE TABLE IF NOT EXISTS source_alignments ( @@ -345,6 +346,15 @@ CREATE TABLE IF NOT EXISTS network_generation_log ( error_message TEXT, tenant_id INTEGER REFERENCES organizations(id) ); + +CREATE TABLE IF NOT EXISTS organization_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id, key) +); """ @@ -782,6 +792,68 @@ async def init_db(): await db.commit() logger.info("Migration: token_usage_monthly Tabelle erstellt") + # Migration: organization_settings KV-Tabelle (pro Org Sprache, ggf. spaeter weitere Settings) + cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='organization_settings'") + if not await cursor.fetchone(): + await db.execute(""" + CREATE TABLE organization_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id, key) + ) + """) + await db.commit() + logger.info("Migration: organization_settings Tabelle erstellt") + + # Default-Setting output_language='de' fuer Orgs ohne Eintrag + await db.execute(""" + INSERT OR IGNORE INTO organization_settings (organization_id, key, value) + SELECT id, 'output_language', 'de' FROM organizations + WHERE id NOT IN ( + SELECT organization_id FROM organization_settings WHERE key='output_language' + ) + """) + await db.commit() + + # Migration: sources.primary_language (ISO-2-Sprachcode aus Freitext-Feld 'language') + cursor = await db.execute("PRAGMA table_info(sources)") + sources_columns = [row[1] for row in await cursor.fetchall()] + if "primary_language" not in sources_columns: + await db.execute("ALTER TABLE sources ADD COLUMN primary_language TEXT") + await db.commit() + logger.info("Migration: primary_language zu sources hinzugefuegt") + + # Backfill: aus Freitext-Feld 'language' (z.B. 'Deutsch', 'Hebraeisch/Englisch') + # die erste Sprache als ISO-Code uebernehmen. Nur fuer Quellen mit NULL primary_language. + _LANGUAGE_LOOKUP = { + "Deutsch": "de", "Englisch": "en", "Russisch": "ru", "Ukrainisch": "uk", + "Arabisch": "ar", "Hebraeisch": "he", "Hebräisch": "he", + "Farsi": "fa", "Japanisch": "ja", "Kurdisch": "ku", "Malaiisch": "ms", + } + cursor = await db.execute( + "SELECT id, language FROM sources WHERE primary_language IS NULL" + ) + rows = await cursor.fetchall() + backfilled = 0 + for row in rows: + sid = row[0] + lang = row[1] + iso = "de" # Default fuer NULL oder unbekannt + if lang: + first = lang.split("/")[0].strip() + iso = _LANGUAGE_LOOKUP.get(first, "de") + await db.execute( + "UPDATE sources SET primary_language = ? WHERE id = ?", + (iso, sid), + ) + backfilled += 1 + if backfilled: + await db.commit() + logger.info("Migration: primary_language Backfill fuer %d Quellen", backfilled) + # Verwaiste running-Eintraege beim Start als error markieren (aelter als 15 Min) await db.execute( """UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart', diff --git a/src/email_utils/templates.py b/src/email_utils/templates.py index b855a4c..455e844 100644 --- a/src/email_utils/templates.py +++ b/src/email_utils/templates.py @@ -1,13 +1,40 @@ -"""HTML-E-Mail-Vorlagen für Magic Links, Einladungen und Benachrichtigungen.""" +"""HTML-E-Mail-Vorlagen für Magic Links, Einladungen und Benachrichtigungen. + +Sprache pro Empfaenger-Org gesteuert (Default 'de'). +""" -def magic_link_login_email(username: str, link: str) -> tuple[str, str]: +def magic_link_login_email(username: str, link: str, lang: str = "de") -> tuple[str, str]: """Erzeugt Login-E-Mail mit Magic Link. + Args: + username: Empfaenger-Anzeigename + link: Magic-Link-URL + lang: ISO-Sprachcode ('de' | 'en') + Returns: (subject, html_body) """ - subject = f"AegisSight Monitor - Anmeldung" + if lang == "en": + subject = "AegisSight Monitor - Sign in" + body = ( + "Hi {username},", + "Click the button below to sign in:", + "Sign in", + "Or copy this link into your browser:", + "This link is valid for 10 minutes. If you did not request this sign-in, simply ignore this email.", + ) + else: + subject = "AegisSight Monitor - Anmeldung" + body = ( + "Hallo {username},", + "Klicken Sie auf den Button, um sich anzumelden:", + "Jetzt anmelden", + "Oder kopieren Sie diesen Link in Ihren Browser:", + "Dieser Link ist 10 Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.", + ) + + greeting, intro, button_label, copy_hint, validity = body html = f"""
@@ -15,18 +42,18 @@ def magic_link_login_email(username: str, link: str) -> tuple[str, str]:Hallo {username},
+{greeting.format(username=username)}
-Klicken Sie auf den Button, um sich anzumelden:
+{intro}
-Oder kopieren Sie diesen Link in Ihren Browser:
+{copy_hint}
{link}
-Dieser Link ist 10 Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.
+{validity}