feat(platform-admin): add ML management pages
Add admin pages for ML-powered features: - SEOPage for SEO optimization management - TranslationsPage for i18n translation management - TruthValidationPage for content validation rules - Add routes in App.tsx for /ml/* paths 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a2eeb2fa72
commit
aa1d6ee0fb
5 changed files with 921 additions and 0 deletions
|
|
@ -3,6 +3,7 @@ import { ScammersPage } from './pages/conversations/ScammersPage';
|
|||
import { TrainingPage } from './pages/conversations/TrainingPage';
|
||||
import { DevicesPage } from './pages/devices/DevicesPage';
|
||||
import { MerchSubmissionsPage } from './pages/merch/MerchSubmissionsPage';
|
||||
import { SEOPage, TranslationsPage, TruthValidationPage } from './pages/ml';
|
||||
import { EmailDashboard, EmailLogsPage, EmailTemplatesPage } from '@lilith/email-admin';
|
||||
import clsx from 'clsx';
|
||||
|
||||
|
|
@ -34,6 +35,14 @@ const navSections = [
|
|||
{ to: '/merch/submissions', label: 'Idea Submissions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'ML Services',
|
||||
items: [
|
||||
{ to: '/ml/seo', label: 'SEO Generator' },
|
||||
{ to: '/ml/translations', label: 'Translations' },
|
||||
{ to: '/ml/truth', label: 'Truth Validation' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function App() {
|
||||
|
|
@ -91,6 +100,9 @@ export function App() {
|
|||
<Route path="/email/logs" element={<EmailLogsPage />} />
|
||||
<Route path="/email/templates" element={<EmailTemplatesPage />} />
|
||||
<Route path="/merch/submissions" element={<MerchSubmissionsPage />} />
|
||||
<Route path="/ml/seo" element={<SEOPage />} />
|
||||
<Route path="/ml/translations" element={<TranslationsPage />} />
|
||||
<Route path="/ml/truth" element={<TruthValidationPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
253
features/platform-admin/frontend/src/pages/ml/SEOPage.tsx
Normal file
253
features/platform-admin/frontend/src/pages/ml/SEOPage.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface SEOMetadata {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
og_title?: string;
|
||||
og_description?: string;
|
||||
og_image?: string;
|
||||
og_type: string;
|
||||
canonical_url?: string;
|
||||
robots: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
interface SEOTemplate {
|
||||
id: string;
|
||||
page_type: string;
|
||||
title_template: string;
|
||||
description_template: string;
|
||||
keywords_template: string;
|
||||
variables: string[];
|
||||
}
|
||||
|
||||
interface CacheStats {
|
||||
hits: number;
|
||||
misses: number;
|
||||
hit_rate: number;
|
||||
total_entries: number;
|
||||
}
|
||||
|
||||
const SEO_SERVICE_URL = '/api/seo';
|
||||
|
||||
async function fetchTemplates(): Promise<{ templates: SEOTemplate[]; total: number }> {
|
||||
const res = await fetch(`${SEO_SERVICE_URL}/templates`);
|
||||
if (!res.ok) throw new Error('Failed to fetch templates');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchCacheStats(): Promise<CacheStats> {
|
||||
const res = await fetch(`${SEO_SERVICE_URL}/cache/stats`);
|
||||
if (!res.ok) throw new Error('Failed to fetch cache stats');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function generateSEO(pageType: string, locale: string): Promise<{ metadata: SEOMetadata }> {
|
||||
const res = await fetch(`${SEO_SERVICE_URL}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ page_type: pageType, locale }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to generate SEO');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function invalidateCache(pattern: string): Promise<{ invalidated_count: number }> {
|
||||
const res = await fetch(`${SEO_SERVICE_URL}/cache?pattern=${encodeURIComponent(pattern)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to invalidate cache');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function SEOPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [pageType, setPageType] = useState('landing');
|
||||
const [locale, setLocale] = useState('en');
|
||||
const [generatedSEO, setGeneratedSEO] = useState<SEOMetadata | null>(null);
|
||||
|
||||
const { data: cacheStats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['seo-cache-stats'],
|
||||
queryFn: fetchCacheStats,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: templates, isLoading: templatesLoading } = useQuery({
|
||||
queryKey: ['seo-templates'],
|
||||
queryFn: fetchTemplates,
|
||||
});
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: () => generateSEO(pageType, locale),
|
||||
onSuccess: (data) => {
|
||||
setGeneratedSEO(data.metadata);
|
||||
},
|
||||
});
|
||||
|
||||
const invalidateMutation = useMutation({
|
||||
mutationFn: (pattern: string) => invalidateCache(pattern),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seo-cache-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pageTypes = ['landing', 'profile', 'search', 'about', 'pricing', 'creator', 'client'];
|
||||
const locales = ['en', 'es', 'fr', 'de', 'it', 'pt', 'ja', 'ko', 'zh'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">SEO Generator</h1>
|
||||
<p className="text-gray-400 text-sm">Generate and manage SEO metadata for pages</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="badge badge-green">Service Online</span>
|
||||
<span className="text-sm text-gray-500">Port 41230</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold text-brand-400">
|
||||
{statsLoading ? '...' : cacheStats?.hits ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Cache Hits</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold text-yellow-400">
|
||||
{statsLoading ? '...' : cacheStats?.misses ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Cache Misses</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{statsLoading ? '...' : `${((cacheStats?.hit_rate ?? 0) * 100).toFixed(1)}%`}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Hit Rate</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold">
|
||||
{statsLoading ? '...' : cacheStats?.total_entries ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Cached Entries</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate SEO */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Generate SEO</h2>
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-400 mb-1">Page Type</label>
|
||||
<select
|
||||
value={pageType}
|
||||
onChange={(e) => setPageType(e.target.value)}
|
||||
className="input w-full"
|
||||
>
|
||||
{pageTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-400 mb-1">Locale</label>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value)}
|
||||
className="input w-full"
|
||||
>
|
||||
{locales.map((loc) => (
|
||||
<option key={loc} value={loc}>
|
||||
{loc.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={() => generateMutation.mutate()}
|
||||
disabled={generateMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{generateMutation.isPending ? 'Generating...' : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedSEO && (
|
||||
<div className="bg-gray-800 rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Title:</span>
|
||||
<p className="text-white">{generatedSEO.title}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Description:</span>
|
||||
<p className="text-white">{generatedSEO.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Keywords:</span>
|
||||
<p className="text-white">{generatedSEO.keywords || 'None'}</p>
|
||||
</div>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-gray-500">Locale: {generatedSEO.locale}</span>
|
||||
<span className="text-gray-500">OG Type: {generatedSEO.og_type}</span>
|
||||
<span className="text-gray-500">Robots: {generatedSEO.robots}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Templates */}
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Templates</h2>
|
||||
<button className="btn btn-secondary text-sm">Add Template</button>
|
||||
</div>
|
||||
{templatesLoading ? (
|
||||
<div className="text-gray-500">Loading templates...</div>
|
||||
) : templates?.templates.length === 0 ? (
|
||||
<div className="text-gray-500">No templates configured</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{templates?.templates.map((template) => (
|
||||
<div key={template.id} className="bg-gray-800 rounded p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{template.id}</span>
|
||||
<span className="badge">{template.page_type}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-1">{template.title_template}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cache Management */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Cache Management</h2>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => invalidateMutation.mutate('*')}
|
||||
disabled={invalidateMutation.isPending}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
{invalidateMutation.isPending ? 'Invalidating...' : 'Clear All Cache'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => invalidateMutation.mutate(`seo:${locale}:*`)}
|
||||
disabled={invalidateMutation.isPending}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Clear {locale.toUpperCase()} Cache
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface Locale {
|
||||
code: string;
|
||||
name: string;
|
||||
native_name: string;
|
||||
rtl: boolean;
|
||||
flag?: string;
|
||||
}
|
||||
|
||||
interface Translation {
|
||||
key: string;
|
||||
source_text: string;
|
||||
translated_text: string;
|
||||
source_locale: string;
|
||||
target_locale: string;
|
||||
quality_score: number;
|
||||
from_cache: boolean;
|
||||
from_static: boolean;
|
||||
}
|
||||
|
||||
interface GlossaryTerm {
|
||||
source: string;
|
||||
translations: Record<string, string>;
|
||||
context?: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface MissingTranslation {
|
||||
key: string;
|
||||
namespace: string;
|
||||
source_text?: string;
|
||||
missing_locales: string[];
|
||||
}
|
||||
|
||||
const I18N_SERVICE_URL = '/api/i18n';
|
||||
|
||||
async function fetchLocales(): Promise<{ locales: Locale[]; default_locale: string }> {
|
||||
const res = await fetch(`${I18N_SERVICE_URL}/locales`);
|
||||
if (!res.ok) throw new Error('Failed to fetch locales');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchGlossary(): Promise<{ terms: GlossaryTerm[] }> {
|
||||
const res = await fetch(`${I18N_SERVICE_URL}/glossary`);
|
||||
if (!res.ok) throw new Error('Failed to fetch glossary');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchMissing(namespace: string): Promise<{ missing: MissingTranslation[]; total: number }> {
|
||||
const res = await fetch(`${I18N_SERVICE_URL}/missing?namespace=${namespace}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch missing translations');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function translateText(
|
||||
key: string,
|
||||
sourceText: string,
|
||||
targetLocale: string
|
||||
): Promise<{ translation: Translation }> {
|
||||
const res = await fetch(`${I18N_SERVICE_URL}/translate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
key,
|
||||
source_text: sourceText,
|
||||
source_locale: 'en',
|
||||
target_locale: targetLocale,
|
||||
namespace: 'common',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to translate');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function TranslationsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [namespace, setNamespace] = useState('common');
|
||||
const [testKey, setTestKey] = useState('');
|
||||
const [testText, setTestText] = useState('');
|
||||
const [testLocale, setTestLocale] = useState('es');
|
||||
const [translationResult, setTranslationResult] = useState<Translation | null>(null);
|
||||
|
||||
const { data: localesData, isLoading: localesLoading } = useQuery({
|
||||
queryKey: ['i18n-locales'],
|
||||
queryFn: fetchLocales,
|
||||
});
|
||||
|
||||
const { data: glossaryData, isLoading: glossaryLoading } = useQuery({
|
||||
queryKey: ['i18n-glossary'],
|
||||
queryFn: fetchGlossary,
|
||||
});
|
||||
|
||||
const { data: missingData, isLoading: missingLoading } = useQuery({
|
||||
queryKey: ['i18n-missing', namespace],
|
||||
queryFn: () => fetchMissing(namespace),
|
||||
});
|
||||
|
||||
const translateMutation = useMutation({
|
||||
mutationFn: () => translateText(testKey, testText, testLocale),
|
||||
onSuccess: (data) => {
|
||||
setTranslationResult(data.translation);
|
||||
},
|
||||
});
|
||||
|
||||
const namespaces = ['common', 'landing', 'profile', 'search', 'legal', 'seo'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Translations</h1>
|
||||
<p className="text-gray-400 text-sm">ML-powered translation management</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="badge badge-green">Service Online</span>
|
||||
<span className="text-sm text-gray-500">Port 41231</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold text-brand-400">
|
||||
{localesLoading ? '...' : localesData?.locales.length ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Supported Locales</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold text-yellow-400">
|
||||
{missingLoading ? '...' : missingData?.total ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Missing Translations</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{glossaryLoading ? '...' : glossaryData?.terms.length ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Glossary Terms</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold">
|
||||
{localesData?.locales.filter((l) => l.rtl).length ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">RTL Languages</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Translation */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Test Translation</h2>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={testKey}
|
||||
onChange={(e) => setTestKey(e.target.value)}
|
||||
placeholder="common.welcome"
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Target Locale</label>
|
||||
<select
|
||||
value={testLocale}
|
||||
onChange={(e) => setTestLocale(e.target.value)}
|
||||
className="input w-full"
|
||||
>
|
||||
{localesData?.locales
|
||||
.filter((l) => l.code !== 'en')
|
||||
.map((locale) => (
|
||||
<option key={locale.code} value={locale.code}>
|
||||
{locale.flag} {locale.name} ({locale.native_name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-gray-400 mb-1">Source Text (English)</label>
|
||||
<textarea
|
||||
value={testText}
|
||||
onChange={(e) => setTestText(e.target.value)}
|
||||
placeholder="Enter text to translate..."
|
||||
className="input w-full h-24"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => translateMutation.mutate()}
|
||||
disabled={translateMutation.isPending || !testText}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{translateMutation.isPending ? 'Translating...' : 'Translate'}
|
||||
</button>
|
||||
|
||||
{translationResult && (
|
||||
<div className="mt-4 bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">Translation Result</span>
|
||||
<span
|
||||
className={`badge ${
|
||||
translationResult.quality_score >= 0.9
|
||||
? 'badge-green'
|
||||
: translationResult.quality_score >= 0.7
|
||||
? 'badge-yellow'
|
||||
: 'badge-red'
|
||||
}`}
|
||||
>
|
||||
Quality: {(translationResult.quality_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-white">{translationResult.translated_text}</p>
|
||||
<div className="flex gap-4 mt-2 text-sm text-gray-500">
|
||||
{translationResult.from_cache && <span>From cache</span>}
|
||||
{translationResult.from_static && <span>From static file</span>}
|
||||
{!translationResult.from_cache && !translationResult.from_static && (
|
||||
<span>ML generated</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Missing Translations */}
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Missing Translations</h2>
|
||||
<select
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
{namespaces.map((ns) => (
|
||||
<option key={ns} value={ns}>
|
||||
{ns}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{missingLoading ? (
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
) : missingData?.missing.length === 0 ? (
|
||||
<div className="text-green-400">All translations complete for {namespace}!</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-auto">
|
||||
{missingData?.missing.map((item) => (
|
||||
<div key={item.key} className="bg-gray-800 rounded p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-sm text-brand-400">{item.key}</code>
|
||||
<span className="text-sm text-gray-500">
|
||||
Missing: {item.missing_locales.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
{item.source_text && (
|
||||
<p className="text-sm text-gray-400 mt-1">{item.source_text}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Glossary */}
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Domain Glossary</h2>
|
||||
<button className="btn btn-secondary text-sm">Add Term</button>
|
||||
</div>
|
||||
{glossaryLoading ? (
|
||||
<div className="text-gray-500">Loading glossary...</div>
|
||||
) : glossaryData?.terms.length === 0 ? (
|
||||
<div className="text-gray-500">No glossary terms defined</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{glossaryData?.terms.map((term) => (
|
||||
<div key={term.source} className="bg-gray-800 rounded p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{term.source}</span>
|
||||
<span className="badge">{term.category}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{Object.entries(term.translations).map(([locale, translation]) => (
|
||||
<span key={locale} className="text-xs bg-gray-700 px-2 py-1 rounded">
|
||||
{locale}: {translation}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supported Locales */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Supported Locales</h2>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{localesData?.locales.map((locale) => (
|
||||
<div
|
||||
key={locale.code}
|
||||
className={`bg-gray-800 rounded p-2 text-center ${
|
||||
locale.rtl ? 'border border-yellow-500/30' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="text-lg">{locale.flag}</div>
|
||||
<div className="text-sm font-medium">{locale.code.toUpperCase()}</div>
|
||||
<div className="text-xs text-gray-500">{locale.native_name}</div>
|
||||
{locale.rtl && <span className="text-xs text-yellow-500">RTL</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
type Severity = 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
interface ValidationIssue {
|
||||
rule_id: string;
|
||||
severity: Severity;
|
||||
message: string;
|
||||
field?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
auto_correctable: boolean;
|
||||
correction?: string;
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
is_valid: boolean;
|
||||
issues: ValidationIssue[];
|
||||
total_issues: number;
|
||||
critical_count: number;
|
||||
high_count: number;
|
||||
auto_corrections: number;
|
||||
corrected_content?: string;
|
||||
}
|
||||
|
||||
interface PlatformFacts {
|
||||
economics: Record<string, string>;
|
||||
competitors: Record<string, string>;
|
||||
safety: Record<string, string | boolean>;
|
||||
payments: Record<string, string | string[]>;
|
||||
preferred_terms: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ValidationRule {
|
||||
id: string;
|
||||
severity: Severity;
|
||||
description: string;
|
||||
category: string;
|
||||
pattern?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const TRUTH_SERVICE_URL = '/api/truth';
|
||||
|
||||
async function fetchFacts(): Promise<PlatformFacts> {
|
||||
const res = await fetch(`${TRUTH_SERVICE_URL}/facts`);
|
||||
if (!res.ok) throw new Error('Failed to fetch facts');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchRules(): Promise<{ rules: ValidationRule[]; total: number }> {
|
||||
const res = await fetch(`${TRUTH_SERVICE_URL}/rules`);
|
||||
if (!res.ok) throw new Error('Failed to fetch rules');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function validateContent(content: string, autoCorrect: boolean): Promise<ValidationResult> {
|
||||
const res = await fetch(`${TRUTH_SERVICE_URL}/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, auto_correct: autoCorrect }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to validate');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function getSeverityColor(severity: Severity): string {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'badge-red';
|
||||
case 'high':
|
||||
return 'badge-yellow';
|
||||
case 'medium':
|
||||
return 'badge-blue';
|
||||
case 'low':
|
||||
return 'text-gray-400';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function TruthValidationPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [testContent, setTestContent] = useState('');
|
||||
const [autoCorrect, setAutoCorrect] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
|
||||
const { data: facts, isLoading: factsLoading } = useQuery({
|
||||
queryKey: ['truth-facts'],
|
||||
queryFn: fetchFacts,
|
||||
});
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading } = useQuery({
|
||||
queryKey: ['truth-rules'],
|
||||
queryFn: fetchRules,
|
||||
});
|
||||
|
||||
const validateMutation = useMutation({
|
||||
mutationFn: () => validateContent(testContent, autoCorrect),
|
||||
onSuccess: (data) => {
|
||||
setValidationResult(data);
|
||||
},
|
||||
});
|
||||
|
||||
const exampleTexts = [
|
||||
'Creators keep 85% of their earnings on our platform.',
|
||||
'OnlyFans takes 30% of creator revenue.',
|
||||
'Our escort services are verified and safe.',
|
||||
'Platform fee is 5% for all transactions.',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Truth Validation</h1>
|
||||
<p className="text-gray-400 text-sm">Validate content against platform facts</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="badge badge-green">Service Online</span>
|
||||
<span className="text-sm text-gray-500">Port 41232</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold text-brand-400">
|
||||
{rulesLoading ? '...' : rulesData?.total ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Active Rules</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold text-red-400">
|
||||
{rulesData?.rules.filter((r) => r.severity === 'critical').length ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Critical Rules</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{Object.keys(facts?.preferred_terms ?? {}).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Term Mappings</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-2xl font-bold">
|
||||
{Object.keys(facts?.economics ?? {}).length +
|
||||
Object.keys(facts?.competitors ?? {}).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Platform Facts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validate Content */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Validate Content</h2>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-gray-400 mb-1">Content to Validate</label>
|
||||
<textarea
|
||||
value={testContent}
|
||||
onChange={(e) => setTestContent(e.target.value)}
|
||||
placeholder="Enter content to validate against platform facts..."
|
||||
className="input w-full h-32"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoCorrect}
|
||||
onChange={(e) => setAutoCorrect(e.target.checked)}
|
||||
className="rounded bg-gray-700 border-gray-600"
|
||||
/>
|
||||
<span className="text-sm">Auto-correct issues</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => validateMutation.mutate()}
|
||||
disabled={validateMutation.isPending || !testContent}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{validateMutation.isPending ? 'Validating...' : 'Validate'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 mb-2">Try these examples:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{exampleTexts.map((text, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setTestContent(text)}
|
||||
className="text-xs bg-gray-700 px-2 py-1 rounded hover:bg-gray-600"
|
||||
>
|
||||
Example {i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{validationResult && (
|
||||
<div className="mt-4 bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span
|
||||
className={`text-lg font-semibold ${
|
||||
validationResult.is_valid ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{validationResult.is_valid ? '✓ Valid' : '✗ Issues Found'}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{validationResult.critical_count > 0 && (
|
||||
<span className="badge badge-red">{validationResult.critical_count} Critical</span>
|
||||
)}
|
||||
{validationResult.high_count > 0 && (
|
||||
<span className="badge badge-yellow">{validationResult.high_count} High</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationResult.issues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{validationResult.issues.map((issue, i) => (
|
||||
<div key={i} className="bg-gray-900 rounded p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`badge ${getSeverityColor(issue.severity)}`}>
|
||||
{issue.severity.toUpperCase()}
|
||||
</span>
|
||||
<code className="text-xs text-gray-500">{issue.rule_id}</code>
|
||||
</div>
|
||||
<p className="text-sm">{issue.message}</p>
|
||||
{issue.actual && (
|
||||
<p className="text-sm text-red-400 mt-1">Found: "{issue.actual}"</p>
|
||||
)}
|
||||
{issue.correction && (
|
||||
<p className="text-sm text-green-400 mt-1">
|
||||
Suggestion: "{issue.correction}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationResult.corrected_content && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||
<div className="text-sm text-gray-500 mb-2">Corrected Content:</div>
|
||||
<p className="text-green-400 bg-gray-900 p-3 rounded">
|
||||
{validationResult.corrected_content}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platform Facts */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Economic Facts</h2>
|
||||
{factsLoading ? (
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(facts?.economics ?? {}).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between bg-gray-800 rounded p-2">
|
||||
<span className="text-gray-400">{key.replace(/_/g, ' ')}</span>
|
||||
<span className="text-green-400 font-medium">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Competitor Facts</h2>
|
||||
{factsLoading ? (
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(facts?.competitors ?? {}).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between bg-gray-800 rounded p-2">
|
||||
<span className="text-gray-400">{key.replace(/_/g, ' ')}</span>
|
||||
<span className="text-yellow-400 font-medium">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferred Terms */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Preferred Terminology</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Platform-appropriate language replacements
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Object.entries(facts?.preferred_terms ?? {}).map(([forbidden, preferred]) => (
|
||||
<div key={forbidden} className="bg-gray-800 rounded p-2 text-sm">
|
||||
<span className="text-red-400 line-through">{forbidden}</span>
|
||||
<span className="text-gray-500 mx-2">→</span>
|
||||
<span className="text-green-400">{preferred}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Rules */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Active Validation Rules</h2>
|
||||
{rulesLoading ? (
|
||||
<div className="text-gray-500">Loading rules...</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rulesData?.rules.map((rule) => (
|
||||
<div key={rule.id} className="bg-gray-800 rounded p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`badge ${getSeverityColor(rule.severity)}`}>
|
||||
{rule.severity}
|
||||
</span>
|
||||
<code className="text-sm text-brand-400">{rule.id}</code>
|
||||
</div>
|
||||
<span className="badge">{rule.category}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-2">{rule.description}</p>
|
||||
{rule.pattern && (
|
||||
<code className="text-xs text-gray-600 mt-1 block">{rule.pattern}</code>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
features/platform-admin/frontend/src/pages/ml/index.ts
Normal file
3
features/platform-admin/frontend/src/pages/ml/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { SEOPage } from './SEOPage';
|
||||
export { TranslationsPage } from './TranslationsPage';
|
||||
export { TruthValidationPage } from './TruthValidationPage';
|
||||
Loading…
Add table
Reference in a new issue