feat(community): Domain-Approval-Lyra-Posts multi-locale (de/en/fr/ar)
Bug: User mit FR-locale sahen Lyra-Confirmation-Posts trotzdem auf Deutsch
(Banner/Tabs richtig FR). Root: approve.post.ts generierte den Text via
Groq mit hartcodiertem 'auf Deutsch'-Prompt, speicherte als plain content.
Server (approve.post.ts):
- 4 parallele Groq-Calls (Promise.allSettled) — de + en + fr + ar
- Per-Locale-PROMPT_CFG mit subject/action/statsLine/thanksSegment-Texten
- Locale-aware Number-Format (toLocaleString('de-DE'|'en-US'|'fr-FR'|'ar-EG'))
- Content als JSON {de:'...',en:'...',fr:'...',ar:'...'} gespeichert
- Mindestens DE muss gelingen, sonst kein Post (Sicherheit gegen halbe Posts)
- ~4x Groq-cost pro Post (sehr günstig bei Llama-3.3-70b, parallel-latency
bleibt ähnlich)
Frontend (PostCard.tsx):
- resolveLocalizedJsonContent() — try-parsed JSON content
- Wenn JSON-Object mit Locale-Keys → pickt i18n.language, fällt auf DE → EN
- Sonst plain content (Legacy-Posts, Comments, User-Posts unverändert)
- Quick-Reject auf '{' first-char vermeidet JSON.parse-Overhead für 99.9%
der Text-Posts
Legacy-Posts in DB bleiben DE-only (kein retroaktiver Multi-Locale-Rewrite).
Neue Posts ab Deploy haben alle 4 Sprachen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
fe2096309f
commit
44a3348845
@ -3,6 +3,7 @@ import { View, Text, Pressable, Image, Animated } from 'react-native';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import i18n from '../lib/i18n';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { resolveAvatar } from '../lib/resolveAvatar';
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
||||||
import { formatRelativeTime } from '../lib/formatTime';
|
import { formatRelativeTime } from '../lib/formatTime';
|
||||||
@ -11,6 +12,33 @@ import { RiveAvatar } from './RiveAvatar';
|
|||||||
import { HeroShieldCheck } from './HeroShieldCheck';
|
import { HeroShieldCheck } from './HeroShieldCheck';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-Approval-Posts werden vom Backend in 4 Sprachen parallel via Groq
|
||||||
|
* generiert und als JSON-encoded `{de:'...',en:'...',fr:'...',ar:'...'}` im
|
||||||
|
* `content`-Feld gespeichert. Diese Helper parsed das + pickt die aktuelle
|
||||||
|
* App-Locale; fällt auf DE zurück wenn locale fehlt; gibt plain content
|
||||||
|
* zurück wenn der content kein JSON ist (Legacy-Posts, User-Posts, Reposts).
|
||||||
|
*/
|
||||||
|
function resolveLocalizedJsonContent(raw: string | null | undefined, currentLang: string): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
// Quick-Reject: wenn nicht mit '{' anfängt, ist's mit Sicherheit kein JSON.
|
||||||
|
// Vermeidet JSON.parse-Overhead für 99.9% der Posts (normale Text-Posts).
|
||||||
|
if (raw[0] !== '{') return raw;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
const langs = ['de', 'en', 'fr', 'ar'];
|
||||||
|
const hasLocaleKey = Object.keys(parsed).some((k) => langs.includes(k));
|
||||||
|
if (hasLocaleKey) {
|
||||||
|
return parsed[currentLang] ?? parsed.de ?? parsed.en ?? raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not JSON, fall through
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
post: CommunityPost;
|
post: CommunityPost;
|
||||||
onCommentPress: (postId: string) => void;
|
onCommentPress: (postId: string) => void;
|
||||||
@ -76,10 +104,16 @@ function PostCardImpl({ post, onCommentPress }: Props) {
|
|||||||
|
|
||||||
const displayAuthor = post.repostOf ? post.repostOf.author : post.author;
|
const displayAuthor = post.repostOf ? post.repostOf.author : post.author;
|
||||||
const rawContent = post.repostOf ? post.repostOf.content : post.content;
|
const rawContent = post.repostOf ? post.repostOf.content : post.content;
|
||||||
// i18n-aware content: wenn i18nKey gesetzt → übersetzten Text nehmen,
|
// i18n-aware content. Drei Pfade in Prioritäts-Reihenfolge:
|
||||||
// sonst rawContent (Legacy-Verhalten unverändert).
|
// 1. post.i18nKey gesetzt → t(`lyra_posts.${id}`) (Catalog-Lyra-Posts)
|
||||||
|
// 2. content ist JSON-encoded mit Locale-Keys → pick current locale
|
||||||
|
// (Domain-Approval-Posts, Server-generiert 4x parallel)
|
||||||
|
// 3. plain content (Legacy-Posts, Comments, User-Posts) → unverändert
|
||||||
const i18nKey = post.repostOf ? undefined : post.i18nKey;
|
const i18nKey = post.repostOf ? undefined : post.i18nKey;
|
||||||
const displayContent = i18nKey ? t(`lyra_posts.${i18nKey}`) : rawContent;
|
const currentLang = (i18n.language ?? 'de').slice(0, 2);
|
||||||
|
const displayContent = i18nKey
|
||||||
|
? t(`lyra_posts.${i18nKey}`)
|
||||||
|
: resolveLocalizedJsonContent(rawContent, currentLang);
|
||||||
const displayImage = post.repostOf ? post.repostOf.imageUrl : post.imageUrl;
|
const displayImage = post.repostOf ? post.repostOf.imageUrl : post.imageUrl;
|
||||||
|
|
||||||
// Image aspect-ratio: ermittelt aus onLoad event.source.{width,height}.
|
// Image aspect-ratio: ermittelt aus onLoad event.source.{width,height}.
|
||||||
|
|||||||
@ -37,80 +37,153 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Für @mention: Leerzeichen entfernen (Regex matcht nur einzelne Wörter)
|
// Für @mention: Leerzeichen entfernen (Regex matcht nur einzelne Wörter)
|
||||||
const mentionName = rawName?.replace(/\s+/g, "") ?? null;
|
const mentionName = rawName?.replace(/\s+/g, "") ?? null;
|
||||||
const hasMention = !!mentionName;
|
const hasMention = !!mentionName;
|
||||||
const mentionRef = hasMention
|
|
||||||
? `@${mentionName}`
|
|
||||||
: "einem Community-Mitglied";
|
|
||||||
|
|
||||||
// Stats für Lyra-Text holen
|
// Stats für Lyra-Text holen (Zahlen sind locale-agnostic, locale-Format
|
||||||
let statsLine = "";
|
// bauen wir per-Sprache zusammen).
|
||||||
|
let totalDomains = 0;
|
||||||
|
let monthlyAdded = 0;
|
||||||
try {
|
try {
|
||||||
const stats = await db.blocklistDomain.count({
|
totalDomains = await db.blocklistDomain.count({ where: { isActive: true } });
|
||||||
where: { isActive: true },
|
|
||||||
});
|
|
||||||
const startOfMonth = new Date();
|
const startOfMonth = new Date();
|
||||||
startOfMonth.setDate(1);
|
startOfMonth.setDate(1);
|
||||||
startOfMonth.setHours(0, 0, 0, 0);
|
startOfMonth.setHours(0, 0, 0, 0);
|
||||||
const monthlyAdded = await db.domainSubmission.count({
|
monthlyAdded = await db.domainSubmission.count({
|
||||||
where: { status: "approved", reviewedAt: { gte: startOfMonth } },
|
where: { status: "approved", reviewedAt: { gte: startOfMonth } },
|
||||||
});
|
});
|
||||||
statsLine = `Damit schützen wir gemeinsam vor ${stats.toLocaleString("de-DE")} Domains${monthlyAdded > 0 ? ` (+${monthlyAdded} diesen Monat)` : ""}.`;
|
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Prompt-Inhalt variiert je nach Type (web vs mail_domain)
|
|
||||||
const isMailDomain = domainType === "mail_domain";
|
const isMailDomain = domainType === "mail_domain";
|
||||||
const subjectLabel = isMailDomain
|
|
||||||
? `der Mail-Absender "${domain}"`
|
// Per-Locale-Prompt-Bausteine. 4 parallele Groq-Calls statt 1 — Latency
|
||||||
: `die Domain "${domain}"`;
|
// bleibt ~gleich (parallel) + Output ist garantiert konsistent pro Sprache
|
||||||
const actionLabel = isMailDomain
|
// (vs. 1 Multi-Locale-Call der gerne JSON-Format-Fehler produziert).
|
||||||
? `zur ReBreak-Blockliste hinzugefügt – casino-affiliates nutzen oft unauffällige Absender-Domains`
|
type Lang = "de" | "en" | "fr" | "ar";
|
||||||
: `zur ReBreak-Blockliste hinzugefügt`;
|
const LANGS: Lang[] = ["de", "en", "fr", "ar"];
|
||||||
const groqUserPrompt = hasMention
|
const PROMPT_CFG: Record<Lang, {
|
||||||
? `Schreibe einen kurzen Community-Post (max. 2 Sätze, auf Deutsch): ${subjectLabel} wurde ${actionLabel} – möglich gemacht durch ${mentionRef}. Erwähne ${mentionRef} genau einmal. Füge am Ende diesen Satz exakt ein: "${statsLine}" Warm, direkt, kein doppelter Dank.`
|
promptLang: string;
|
||||||
: `Schreibe einen kurzen Community-Post (max. 2 Sätze, auf Deutsch): ${subjectLabel} wurde ${actionLabel}. Füge am Ende diesen Satz exakt ein: "${statsLine}" Warm, direkt.`;
|
anonRef: string;
|
||||||
|
subjectMail: string;
|
||||||
|
subjectWeb: string;
|
||||||
|
actionMail: string;
|
||||||
|
actionWeb: string;
|
||||||
|
statsLine: (total: number, monthly: number) => string;
|
||||||
|
thanksSegment: (mentionRef: string) => string;
|
||||||
|
}> = {
|
||||||
|
de: {
|
||||||
|
promptLang: "Deutsch",
|
||||||
|
anonRef: "einem Community-Mitglied",
|
||||||
|
subjectMail: `der Mail-Absender "${domain}"`,
|
||||||
|
subjectWeb: `die Domain "${domain}"`,
|
||||||
|
actionMail: `zur ReBreak-Blockliste hinzugefügt – casino-affiliates nutzen oft unauffällige Absender-Domains`,
|
||||||
|
actionWeb: `zur ReBreak-Blockliste hinzugefügt`,
|
||||||
|
statsLine: (t, m) => `Damit schützen wir gemeinsam vor ${t.toLocaleString("de-DE")} Domains${m > 0 ? ` (+${m} diesen Monat)` : ""}.`,
|
||||||
|
thanksSegment: (m) => `– möglich gemacht durch ${m}. Erwähne ${m} genau einmal`,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
promptLang: "English",
|
||||||
|
anonRef: "a community member",
|
||||||
|
subjectMail: `the mail sender "${domain}"`,
|
||||||
|
subjectWeb: `the domain "${domain}"`,
|
||||||
|
actionMail: `was added to the ReBreak blocklist — casino affiliates often use inconspicuous sender domains`,
|
||||||
|
actionWeb: `was added to the ReBreak blocklist`,
|
||||||
|
statsLine: (t, m) => `Together we now protect against ${t.toLocaleString("en-US")} domains${m > 0 ? ` (+${m} this month)` : ""}.`,
|
||||||
|
thanksSegment: (m) => `— made possible by ${m}. Mention ${m} exactly once`,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
promptLang: "français",
|
||||||
|
anonRef: "un membre de la communauté",
|
||||||
|
subjectMail: `l'expéditeur de mail « ${domain} »`,
|
||||||
|
subjectWeb: `le domaine « ${domain} »`,
|
||||||
|
actionMail: `a été ajouté à la liste de blocage ReBreak — les affiliés casino utilisent souvent des domaines d'expéditeur discrets`,
|
||||||
|
actionWeb: `a été ajouté à la liste de blocage ReBreak`,
|
||||||
|
statsLine: (t, m) => `Ensemble, nous protégeons maintenant contre ${t.toLocaleString("fr-FR")} domaines${m > 0 ? ` (+${m} ce mois-ci)` : ""}.`,
|
||||||
|
thanksSegment: (m) => `— rendu possible par ${m}. Mentionne ${m} exactement une fois`,
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
promptLang: "العربية",
|
||||||
|
anonRef: "أحد أفراد المجتمع",
|
||||||
|
subjectMail: `المُرسِل البريدي «${domain}»`,
|
||||||
|
subjectWeb: `النطاق «${domain}»`,
|
||||||
|
actionMail: `تمت إضافته إلى قائمة الحظر في ReBreak — يستخدم المنتسبون للكازينوهات غالباً نطاقات مُرسِلين غير لافتة`,
|
||||||
|
actionWeb: `تمت إضافته إلى قائمة الحظر في ReBreak`,
|
||||||
|
statsLine: (t, m) => `معاً نحمي الآن من ${t.toLocaleString("ar-EG")} نطاق${m > 0 ? ` (+${m} هذا الشهر)` : ""}.`,
|
||||||
|
thanksSegment: (m) => `— بفضل ${m}. اذكر ${m} مرة واحدة فقط`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const groqApiKey = config.groqApiKey;
|
const groqApiKey = config.groqApiKey;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await $fetch<{
|
// 4 parallele Groq-Calls, eine pro Sprache. Promise.allSettled damit
|
||||||
choices: { message: { content: string } }[];
|
// ein fehlgeschlagener Locale-Call die anderen 3 nicht killt — wir
|
||||||
}>("https://api.groq.com/openai/v1/chat/completions", {
|
// speichern dann nur die erfolgreichen.
|
||||||
method: "POST",
|
const results = await Promise.allSettled(
|
||||||
headers: {
|
LANGS.map(async (lang) => {
|
||||||
Authorization: `Bearer ${groqApiKey}`,
|
const cfg = PROMPT_CFG[lang];
|
||||||
"Content-Type": "application/json",
|
const subject = isMailDomain ? cfg.subjectMail : cfg.subjectWeb;
|
||||||
},
|
const action = isMailDomain ? cfg.actionMail : cfg.actionWeb;
|
||||||
body: {
|
const stats = cfg.statsLine(totalDomains, monthlyAdded);
|
||||||
model: "llama-3.3-70b-versatile",
|
const mentionRef = hasMention ? `@${mentionName}` : cfg.anonRef;
|
||||||
max_tokens: 150,
|
const thanksPart = hasMention ? ` ${cfg.thanksSegment(mentionRef)}` : "";
|
||||||
messages: [
|
const userPrompt = `Schreibe einen kurzen Community-Post (max. 2 Sätze, auf ${cfg.promptLang}): ${subject} ${action}${thanksPart}. Füge am Ende diesen Satz exakt ein: "${stats}" Warm, direkt, kein doppelter Dank.`;
|
||||||
{
|
const response = await $fetch<{
|
||||||
role: "system",
|
choices: { message: { content: string } }[];
|
||||||
content: `Du bist Lyra – Recovery-Coach der ReBreak-Community. Tonalität: warm, persönlich, direkt. Schreibe NUR den Post-Text, kein Prefix, keine Anführungszeichen.`,
|
}>("https://api.groq.com/openai/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${groqApiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
{
|
body: {
|
||||||
role: "user",
|
model: "llama-3.3-70b-versatile",
|
||||||
content: groqUserPrompt,
|
max_tokens: 200,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `Du bist Lyra – Recovery-Coach der ReBreak-Community. Tonalität: warm, persönlich, direkt. Schreibe NUR den Post-Text in der angefragten Sprache, kein Prefix, keine Anführungszeichen.`,
|
||||||
|
},
|
||||||
|
{ role: "user", content: userPrompt },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
});
|
||||||
|
const content = response.choices?.[0]?.message?.content?.trim();
|
||||||
|
if (!content) throw new Error("empty content");
|
||||||
|
return [lang, content] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const localized: Partial<Record<Lang, string>> = {};
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === "fulfilled") {
|
||||||
|
const [lang, content] = r.value;
|
||||||
|
localized[lang] = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mindestens DE muss klappen — sonst kein Post (sicheres Fallback,
|
||||||
|
// statt einen halben deutsch-leeren Post zu schreiben).
|
||||||
|
if (!localized.de) {
|
||||||
|
console.error(`[approve] Lyra-Post abgebrochen: DE-Generation fehlgeschlagen für ${domain}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentJson = JSON.stringify(localized);
|
||||||
|
const faviconUrl = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
|
||||||
|
await db.communityPost.create({
|
||||||
|
data: {
|
||||||
|
userId: lyraBotUserId,
|
||||||
|
category: "domain_approved",
|
||||||
|
// JSON-encoded mit Locale-Keys — PostCard.tsx try-parsed + picked
|
||||||
|
// current-locale; fällt auf DE zurück wenn locale fehlt.
|
||||||
|
content: contentJson,
|
||||||
|
imageUrl: faviconUrl,
|
||||||
|
isAnonymous: false,
|
||||||
|
isModerated: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const content = response.choices?.[0]?.message?.content?.trim();
|
console.log(
|
||||||
if (content) {
|
`[approve] Lyra-Post erstellt für domain=${domain}, submitter=${mentionName ?? "anonym"}, locales=${Object.keys(localized).join(",")}`,
|
||||||
const faviconUrl = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
|
);
|
||||||
await db.communityPost.create({
|
|
||||||
data: {
|
|
||||||
userId: lyraBotUserId,
|
|
||||||
category: "domain_approved",
|
|
||||||
content,
|
|
||||||
imageUrl: faviconUrl,
|
|
||||||
isAnonymous: false,
|
|
||||||
isModerated: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(
|
|
||||||
`[approve] Lyra-Post erstellt für domain=${domain}, submitter=${mentionName ?? "anonym"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[approve] Lyra-Post fehlgeschlagen:`, err);
|
console.error(`[approve] Lyra-Post fehlgeschlagen:`, err);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user