- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3) - Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor - Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS - Configurare adresă platformă externalizată (build args / .env / config.js) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import styled from '@emotion/styled';
|
|
import {
|
|
Cpu, ShieldCheck, ShieldAlert, AlertOctagon, AlertTriangle, AlertCircle,
|
|
FileText, Image as ImageIcon, Sparkles, ChevronDown, Plus,
|
|
} from 'lucide-react';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import { typography } from '../../../theme';
|
|
import { localized } from '../../../utils/i18n-fields';
|
|
import { enumLabel } from '../../../utils/i18n-enums';
|
|
import type { AiTamperedResult, AiIndicatorDetected } from '../../../types/analysis-session';
|
|
import { HeadlineCard } from './HeadlineCard';
|
|
import { FindingCard, type FindingAccent } from './FindingCard';
|
|
import { SectionDivider } from './SectionDivider';
|
|
import {
|
|
StatBlock, ChipsRow, Chip,
|
|
CollapseBtn, CollapseBtnLeft, CollapseBtnChevron,
|
|
} from '../styles';
|
|
|
|
const probAccent = (p: number): string =>
|
|
p >= 80 ? '#ef4444' : p >= 60 ? '#f97316' : p >= 40 ? '#eab308' : '#22c55e';
|
|
|
|
const probIcon = (p: number): LucideIcon => {
|
|
if (p >= 80) return AlertOctagon;
|
|
if (p >= 60) return AlertTriangle;
|
|
if (p >= 40) return Cpu;
|
|
return ShieldCheck;
|
|
};
|
|
|
|
const indicatorAccent = (confidence: number): FindingAccent => {
|
|
if (confidence >= 80) return 'critical';
|
|
if (confidence >= 60) return 'warning';
|
|
if (confidence >= 40) return 'info';
|
|
return 'neutral';
|
|
};
|
|
|
|
const verdictLabelKey = (verdict: string): string => {
|
|
switch (verdict) {
|
|
case 'LIKELY_AI': return 'aiTamper.verdicts.likelyAi';
|
|
case 'POSSIBLY_AI': return 'aiTamper.verdicts.possiblyAi';
|
|
case 'UNLIKELY_AI': return 'aiTamper.verdicts.unlikelyAi';
|
|
case 'LIKELY_HUMAN': return 'aiTamper.verdicts.likelyHuman';
|
|
case 'HUMAN': return 'aiTamper.verdicts.humanWritten';
|
|
default: return '';
|
|
}
|
|
};
|
|
|
|
const CATEGORY_LABEL_KEYS: Record<string, string> = {
|
|
T1: 'aiTamper.categories.writingStyle',
|
|
T2: 'aiTamper.categories.contentPatterns',
|
|
T3: 'aiTamper.categories.structuralAnalysis',
|
|
T4: 'aiTamper.categories.statisticalMarkers',
|
|
T5: 'aiTamper.categories.explicitSignals',
|
|
};
|
|
|
|
const DEFAULT_VISIBLE_INDICATORS = 5;
|
|
|
|
/** Display-friendly shape that flattens AiTamperedResult variations across modes. */
|
|
export interface AiDisplayResult {
|
|
verdict: string;
|
|
ai_probability: number;
|
|
disclosure_detected?: boolean;
|
|
disclosure_explicit?: boolean;
|
|
disclosure_text?: string | null;
|
|
/** Quick mode text indicators */
|
|
indicators_found?: string[];
|
|
/** Deep mode text + audio/video indicators */
|
|
indicators_detected?: AiIndicatorDetected[];
|
|
categories_affected?: string[];
|
|
coupling_context?: AiTamperedResult['coupling_context'];
|
|
image_indicators?: string[];
|
|
image_evidence?: string;
|
|
model_used?: string;
|
|
transcript?: string;
|
|
}
|
|
|
|
/** Build AiDisplayResult from a raw AiTamperedResult + optional input meta. */
|
|
export function toAiDisplayResult(
|
|
r: AiTamperedResult,
|
|
ctx: { inputType?: string; inputText?: string | null } = {},
|
|
): AiDisplayResult {
|
|
const out: AiDisplayResult = {
|
|
verdict: r.verdict,
|
|
ai_probability: r.ai_probability,
|
|
disclosure_detected: r.disclosure_detected,
|
|
disclosure_explicit: r.disclosure_explicit,
|
|
disclosure_text: r.disclosure_text,
|
|
};
|
|
if (r.indicators_detected && r.indicators_detected.length > 0) {
|
|
out.indicators_detected = r.indicators_detected;
|
|
out.categories_affected = r.categories_affected;
|
|
out.coupling_context = r.coupling_context;
|
|
}
|
|
if (r.image_analysis?.indicators && r.image_analysis.indicators.length > 0) {
|
|
out.image_indicators = r.image_analysis.indicators;
|
|
out.image_evidence = r.image_analysis.evidence;
|
|
out.model_used = r.image_analysis.model_used;
|
|
}
|
|
if (ctx.inputText && (ctx.inputType === 'audio' || ctx.inputType === 'video')) {
|
|
out.transcript = ctx.inputText;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface Props {
|
|
result: AiDisplayResult;
|
|
isRo: boolean;
|
|
/** When set, a "try deep analysis" CTA is shown inside the clean-content card. Used by the live standalone where the user can re-run in deep mode. History view should omit this. */
|
|
onTryDeepScan?: () => void;
|
|
}
|
|
|
|
export const AiResults: React.FC<Props> = ({ result, isRo, onTryDeepScan }) => {
|
|
const { t } = useTranslation();
|
|
const [indicatorsExpand, setIndicatorsExpand] = useState(false);
|
|
|
|
const probability = result.ai_probability || 0;
|
|
const verdict = result.verdict || '';
|
|
const accent = probAccent(probability);
|
|
const Icon = probIcon(probability);
|
|
|
|
const verdictText = verdictLabelKey(verdict)
|
|
? t(verdictLabelKey(verdict))
|
|
: (verdict || '').replace(/_/g, ' ');
|
|
|
|
const tldr = (() => {
|
|
const indCount = result.indicators_detected?.length
|
|
|| result.indicators_found?.length
|
|
|| result.image_indicators?.length
|
|
|| 0;
|
|
const disclosureNote = result.disclosure_detected === true
|
|
? (isRo ? ' cu declarație AI' : ' with AI disclosure')
|
|
: result.disclosure_detected === false
|
|
? (isRo ? ' fără declarație AI' : ' without AI disclosure')
|
|
: '';
|
|
if (isRo) {
|
|
return `Probabilitate ${probability}% de generare AI${disclosureNote}${indCount > 0 ? ` · ${indCount} ${indCount === 1 ? 'indicator detectat' : 'indicatori detectați'}` : ''}.`;
|
|
}
|
|
return `${probability}% likelihood of AI generation${disclosureNote}${indCount > 0 ? ` · ${indCount} ${indCount === 1 ? 'indicator detected' : 'indicators detected'}` : ''}.`;
|
|
})();
|
|
|
|
const totalIndicators = result.indicators_detected?.length ?? 0;
|
|
const visibleCount = indicatorsExpand ? totalIndicators : Math.min(DEFAULT_VISIBLE_INDICATORS, totalIndicators);
|
|
const visibleIndicators = result.indicators_detected?.slice(0, visibleCount) ?? [];
|
|
const remainingIndicators = totalIndicators - visibleCount;
|
|
|
|
return (
|
|
<>
|
|
<HeadlineCard
|
|
accent={accent}
|
|
score={probability}
|
|
scoreLabel="% AI"
|
|
eyebrow={isRo ? 'Detectare AI' : 'AI detection'}
|
|
icon={Icon}
|
|
category={verdictText}
|
|
descriptor={
|
|
<>
|
|
{result.coupling_context?.for_verdict?.confidence_level && (
|
|
<>
|
|
<span>{isRo ? 'încredere' : 'confidence'} {enumLabel(result.coupling_context.for_verdict.confidence_level).toLowerCase()}</span>
|
|
{result.disclosure_detected !== undefined && <span className="sep">·</span>}
|
|
</>
|
|
)}
|
|
{result.disclosure_detected === true && (
|
|
<span>{isRo ? 'cu declarație AI' : 'AI disclosure'}</span>
|
|
)}
|
|
{result.disclosure_detected === false && (
|
|
<span>{isRo ? 'fără declarație AI' : 'no AI disclosure'}</span>
|
|
)}
|
|
</>
|
|
}
|
|
tldr={tldr}
|
|
/>
|
|
|
|
{(result.coupling_context?.for_verdict || (result.categories_affected && result.categories_affected.length > 0) || result.model_used) && (
|
|
<StatBlock>
|
|
<ChipsRow>
|
|
{result.coupling_context?.for_verdict?.undisclosed_ai && (
|
|
<Chip variant="warning">{t('aiTamper.undisclosedAi')}</Chip>
|
|
)}
|
|
{result.coupling_context?.for_verdict?.needs_manual_review && (
|
|
<Chip variant="warning">{t('aiTamper.needsManualReview')}</Chip>
|
|
)}
|
|
{result.categories_affected?.map(cat => (
|
|
<Chip key={cat} variant="violet">
|
|
{cat} · {CATEGORY_LABEL_KEYS[cat] ? t(CATEGORY_LABEL_KEYS[cat]) : cat}
|
|
</Chip>
|
|
))}
|
|
{result.model_used && (
|
|
<Chip variant="neutral">{result.model_used}</Chip>
|
|
)}
|
|
</ChipsRow>
|
|
</StatBlock>
|
|
)}
|
|
|
|
{result.disclosure_detected !== undefined && (
|
|
<FindingCard
|
|
icon={result.disclosure_detected ? ShieldCheck : ShieldAlert}
|
|
accent={result.disclosure_detected ? 'success' : 'neutral'}
|
|
eyebrow={isRo ? 'Declarație AI' : 'AI disclosure'}
|
|
headline={
|
|
result.disclosure_detected
|
|
? (result.disclosure_explicit ? t('aiTamper.explicitDisclosure') : t('aiTamper.implicitDisclosure'))
|
|
: t('aiTamper.noDisclosureDetected')
|
|
}
|
|
quote={result.disclosure_text || undefined}
|
|
/>
|
|
)}
|
|
|
|
{result.image_evidence && (
|
|
<FindingCard
|
|
icon={ImageIcon}
|
|
accent="info"
|
|
eyebrow={isRo ? 'Analiză imagine' : 'Image analysis'}
|
|
headline={t('aiTamper.analysisSummary')}
|
|
quote={result.image_evidence}
|
|
/>
|
|
)}
|
|
|
|
{result.transcript && (
|
|
<FindingCard
|
|
icon={FileText}
|
|
accent="info"
|
|
eyebrow={t('aiTamper.transcript')}
|
|
headline={isRo ? 'Conținut transcris' : 'Transcribed content'}
|
|
quote={result.transcript.length > 320
|
|
? result.transcript.slice(0, 320) + '…'
|
|
: result.transcript}
|
|
/>
|
|
)}
|
|
|
|
{result.indicators_found && result.indicators_found.length > 0 && (
|
|
<>
|
|
<SectionDivider
|
|
label={isRo ? 'Semnale detectate' : 'Signals detected'}
|
|
count={result.indicators_found.length}
|
|
/>
|
|
{result.indicators_found.map((ind, i) => (
|
|
<FindingCard
|
|
key={i}
|
|
icon={AlertCircle}
|
|
accent="info"
|
|
headline={ind}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{result.image_indicators && result.image_indicators.length > 0 && (
|
|
<>
|
|
<SectionDivider
|
|
label={t('aiTamper.imageIndicators')}
|
|
count={result.image_indicators.length}
|
|
/>
|
|
{result.image_indicators.map((ind, i) => (
|
|
<FindingCard
|
|
key={i}
|
|
icon={Sparkles}
|
|
accent="warning"
|
|
headline={ind}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{totalIndicators > 0 && (
|
|
<>
|
|
<SectionDivider
|
|
label={isRo ? 'Indicatori detectați' : 'Indicators detected'}
|
|
count={`${totalIndicators} ${isRo ? 'detectați' : 'detected'}${result.categories_affected?.length ? ` · ${result.categories_affected.join(' · ')}` : ''}`}
|
|
/>
|
|
{visibleIndicators.map(ind => {
|
|
const catLabel = CATEGORY_LABEL_KEYS[ind.category]
|
|
? t(CATEGORY_LABEL_KEYS[ind.category])
|
|
: ind.category;
|
|
const eyebrowParts = [
|
|
ind.category,
|
|
catLabel,
|
|
`${isRo ? 'încredere' : 'confidence'} ${ind.confidence}%`,
|
|
];
|
|
return (
|
|
<FindingCard
|
|
key={ind.id}
|
|
icon={AlertCircle}
|
|
accent={indicatorAccent(ind.confidence)}
|
|
eyebrow={eyebrowParts.join(' · ')}
|
|
headline={localized(ind, 'indicator_name', 'name')}
|
|
quote={ind.evidence}
|
|
/>
|
|
);
|
|
})}
|
|
{remainingIndicators > 0 && (
|
|
<CollapseBtn onClick={() => setIndicatorsExpand(true)}>
|
|
<CollapseBtnLeft>
|
|
<Plus size={16} strokeWidth={2} />
|
|
<span>
|
|
{isRo
|
|
? `Vezi celelalți ${remainingIndicators} ${remainingIndicators === 1 ? 'indicator' : 'indicatori'}`
|
|
: `Show ${remainingIndicators} more ${remainingIndicators === 1 ? 'indicator' : 'indicators'}`}
|
|
</span>
|
|
</CollapseBtnLeft>
|
|
<CollapseBtnChevron expanded={false}>
|
|
<ChevronDown size={14} strokeWidth={2.5} />
|
|
</CollapseBtnChevron>
|
|
</CollapseBtn>
|
|
)}
|
|
{indicatorsExpand && totalIndicators > DEFAULT_VISIBLE_INDICATORS && (
|
|
<CollapseBtn onClick={() => setIndicatorsExpand(false)}>
|
|
<CollapseBtnLeft>
|
|
<ChevronDown size={16} strokeWidth={2} />
|
|
<span>{isRo ? 'Ascunde' : 'Hide'}</span>
|
|
</CollapseBtnLeft>
|
|
<CollapseBtnChevron expanded={true}>
|
|
<ChevronDown size={14} strokeWidth={2.5} />
|
|
</CollapseBtnChevron>
|
|
</CollapseBtn>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{(result.indicators_found?.length === 0
|
|
&& !result.indicators_detected
|
|
&& !result.image_indicators?.length) && (
|
|
<FindingCard
|
|
icon={ShieldCheck}
|
|
accent="success"
|
|
eyebrow={isRo ? 'Conținut curat' : 'Clean content'}
|
|
headline={t('aiTamper.noSignalsQuick')}
|
|
source={
|
|
onTryDeepScan ? (
|
|
<DeepScanHint onClick={onTryDeepScan}>
|
|
{t('aiTamper.tryDeepAnalysis')}
|
|
</DeepScanHint>
|
|
) : undefined
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const DeepScanHint = styled.button`
|
|
padding: 0;
|
|
border: none;
|
|
background: none;
|
|
font-family: ${typography.fontFamily.primary};
|
|
font-size: 12px;
|
|
font-weight: ${typography.fontWeight.semibold};
|
|
color: var(--accent-text);
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
|
|
&:hover {
|
|
color: var(--accent-hover);
|
|
}
|
|
`;
|