325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import { displayValue } from '@/lib/display-value';
|
|
import { Link, useForm } from '@inertiajs/react';
|
|
import { Management, Field, control, button } from '../management/shared';
|
|
import { capabilities, states } from './labels';
|
|
type Item = {
|
|
[key: string]: unknown;
|
|
content?: string;
|
|
title?: string;
|
|
source_url?: string;
|
|
source_date?: string;
|
|
nature?: string;
|
|
record_id?: string | number;
|
|
rule?: string;
|
|
score?: number;
|
|
score_max?: number;
|
|
favourable_factors?: string[];
|
|
missing_data?: string[];
|
|
};
|
|
type Result = {
|
|
items: Item[];
|
|
mode: string;
|
|
language: string;
|
|
computed_at: string;
|
|
status: string;
|
|
};
|
|
type Props = {
|
|
run: {
|
|
id: string;
|
|
capability: string;
|
|
mode: string;
|
|
rules_version: string;
|
|
prompt_version: string;
|
|
latency_ms: number;
|
|
version_no: number;
|
|
review_status: string;
|
|
};
|
|
result: Result | null;
|
|
unavailable: boolean;
|
|
};
|
|
const safeLink = (value: string) =>
|
|
/^https:\/\//i.test(value) || /^\/(?!\/)/.test(value);
|
|
export default function ResultPage({ run, result, unavailable }: Props) {
|
|
const en = result?.language === 'en';
|
|
const t = (pt: string, english: string) => (en ? english : pt);
|
|
const feedback = useForm({ rating: 'helpful', review_requested: true });
|
|
const review = useForm({ status: 'accepted', version_no: run.version_no });
|
|
return (
|
|
<Management
|
|
title={
|
|
capabilities[run.capability]?.[en ? 1 : 0] ??
|
|
t('Resultado assistivo', 'Assistive result')
|
|
}
|
|
>
|
|
<Link className="underline" href="/management/ai">
|
|
{t(
|
|
'Voltar às consultas e fontes',
|
|
'Back to assistance and sources',
|
|
)}
|
|
</Link>
|
|
<p>
|
|
{t(
|
|
'Resultado assistivo, sujeito a revisão. Não constitui decisão oficial ou garantia de retorno ou aprovação.',
|
|
'Assistive result requiring review. Not an official decision or a guarantee of return or approval.',
|
|
)}
|
|
</p>
|
|
<p>
|
|
{t(
|
|
'Confiança não calibrada. Não foram produzidas previsões ou afirmações por um modelo generativo.',
|
|
'Confidence is not calibrated. No forecasts or claims were generated by a generative model.',
|
|
)}
|
|
</p>
|
|
<p>
|
|
{t('Estado da revisão', 'Review status')}:{' '}
|
|
{en ? run.review_status : states[run.review_status]}
|
|
</p>
|
|
{unavailable ? (
|
|
<p role="alert">
|
|
{t(
|
|
'Conteúdo indisponível: capacidade suspensa, fonte alterada, expirada, revogada ou sem consentimento vigente.',
|
|
'Content unavailable: suspended capability, changed, expired or revoked source, or withdrawn consent.',
|
|
)}
|
|
</p>
|
|
) : (
|
|
<>
|
|
{result?.items.length === 0 && (
|
|
<p className="rounded border p-4">
|
|
{t(
|
|
'Não há evidência ou correspondências suficientes nesta consulta. Não é possível formular uma conclusão. Pode solicitar revisão humana abaixo.',
|
|
'No sufficient evidence or matches for this query. A conclusion cannot be provided. You may request human review below.',
|
|
)}
|
|
</p>
|
|
)}
|
|
{run.mode === 'extractive_preview' && (
|
|
<p>
|
|
{t(
|
|
'Pré-visualização dos primeiros excertos; não é um resumo completo do documento.',
|
|
'Preview of the first excerpts; not a complete document summary.',
|
|
)}
|
|
</p>
|
|
)}
|
|
{result?.items.map((item, index) => (
|
|
<article
|
|
className="space-y-3 rounded border p-4"
|
|
key={index}
|
|
>
|
|
<h2 className="text-lg font-semibold">
|
|
{item.title ??
|
|
item.rule?.replaceAll('_', ' ') ??
|
|
`${t('Resultado', 'Result')} ${index + 1}`}
|
|
</h2>
|
|
{item.content && (
|
|
<blockquote className="border-l-4 pl-4 whitespace-pre-wrap">
|
|
{item.content}
|
|
</blockquote>
|
|
)}
|
|
{item.source_url && safeLink(item.source_url) && (
|
|
<a
|
|
className="underline"
|
|
href={item.source_url}
|
|
rel="noreferrer"
|
|
>
|
|
{t(
|
|
'Consultar fonte / registo original',
|
|
'View source / original record',
|
|
)}
|
|
</a>
|
|
)}
|
|
{item.source_date && (
|
|
<p>
|
|
{t('Data da fonte', 'Source date')}:{' '}
|
|
{item.source_date}
|
|
</p>
|
|
)}
|
|
{item.nature && (
|
|
<p>
|
|
{t('Natureza declarada', 'Declared nature')}
|
|
: {en ? item.nature : states[item.nature]}
|
|
</p>
|
|
)}
|
|
{item.score !== undefined && (
|
|
<p>
|
|
{t(
|
|
'Filtros declarados correspondentes',
|
|
'Matching declared filters',
|
|
)}
|
|
: {item.score}/{item.score_max}.{' '}
|
|
{t(
|
|
'Esta pontuação não mede rentabilidade ou probabilidade de aprovação.',
|
|
'This score does not measure profitability or likelihood of approval.',
|
|
)}
|
|
</p>
|
|
)}
|
|
{item.favourable_factors && (
|
|
<p>
|
|
{t(
|
|
'Factores correspondentes',
|
|
'Matching factors',
|
|
)}
|
|
: {item.favourable_factors.join(', ')}
|
|
</p>
|
|
)}
|
|
{item.missing_data && (
|
|
<p>
|
|
{t(
|
|
'Dados ainda necessários',
|
|
'Missing information',
|
|
)}
|
|
: {item.missing_data.join(', ')}
|
|
</p>
|
|
)}
|
|
{!item.content && (
|
|
<dl className="grid gap-2 md:grid-cols-2">
|
|
{Object.entries(item)
|
|
.filter(
|
|
([key]) =>
|
|
![
|
|
'title',
|
|
'source_url',
|
|
'source_date',
|
|
'score',
|
|
'score_max',
|
|
'score_meaning',
|
|
'favourable_factors',
|
|
'missing_data',
|
|
'semantic_similarity',
|
|
].includes(key),
|
|
)
|
|
.map(([key, value]) => (
|
|
<div key={key}>
|
|
<dt className="font-medium">
|
|
{key.replaceAll('_', ' ')}
|
|
</dt>
|
|
<dd>
|
|
{Array.isArray(value)
|
|
? value.join(', ')
|
|
: value === null
|
|
? t(
|
|
'Não determinado',
|
|
'Undetermined',
|
|
)
|
|
: displayValue(value)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
)}
|
|
</article>
|
|
))}
|
|
</>
|
|
)}
|
|
<p>
|
|
{t('Regras', 'Rules')}: {run.rules_version} ·{' '}
|
|
{t('Calculado em', 'Computed at')}: {result?.computed_at ?? '—'}{' '}
|
|
· {run.latency_ms} ms
|
|
</p>
|
|
<form
|
|
className="space-y-3 rounded border p-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
feedback.post(`/management/ai/runs/${run.id}/feedback`);
|
|
}}
|
|
>
|
|
<h2 className="text-xl font-semibold">
|
|
{t(
|
|
'Feedback e atendimento humano',
|
|
'Feedback and human review',
|
|
)}
|
|
</h2>
|
|
<Field label={t('Avaliação', 'Rating')}>
|
|
<select
|
|
className={control}
|
|
value={feedback.data.rating}
|
|
onChange={(e) =>
|
|
feedback.setData('rating', e.target.value)
|
|
}
|
|
>
|
|
<option value="helpful">{t('Útil', 'Helpful')}</option>
|
|
<option value="unhelpful">
|
|
{t('Pouco útil', 'Unhelpful')}
|
|
</option>
|
|
<option value="unsafe">
|
|
{t(
|
|
'Risco detectado — suspender capacidade',
|
|
'Risk detected — suspend capability',
|
|
)}
|
|
</option>
|
|
</select>
|
|
</Field>
|
|
<label className="flex gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={feedback.data.review_requested}
|
|
onChange={(e) =>
|
|
feedback.setData(
|
|
'review_requested',
|
|
e.target.checked,
|
|
)
|
|
}
|
|
/>
|
|
{t(
|
|
'Solicitar revisão por uma pessoa autorizada',
|
|
'Request review by an authorised person',
|
|
)}
|
|
</label>
|
|
{Object.entries(feedback.errors).map(([k, v]) => (
|
|
<p role="alert" key={k}>
|
|
{v}
|
|
</p>
|
|
))}
|
|
<button disabled={feedback.processing} className={button}>
|
|
{t('Enviar feedback', 'Submit feedback')}
|
|
</button>
|
|
</form>
|
|
<form
|
|
className="space-y-3 rounded border p-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
review.transform((data) => ({
|
|
...data,
|
|
version_no: run.version_no,
|
|
}));
|
|
review.post(`/management/ai/runs/${run.id}/review`);
|
|
}}
|
|
>
|
|
<h2 className="text-xl font-semibold">
|
|
{t('Revisão independente', 'Independent review')}
|
|
</h2>
|
|
<p>
|
|
{t(
|
|
'Uma pessoa diferente deve rever o resultado. Aceitar não altera registos oficiais nem envia conteúdo ao CRM.',
|
|
'A different person must review the result. Acceptance does not change official records or send content to CRM.',
|
|
)}
|
|
</p>
|
|
<Field label={t('Parecer interno', 'Internal review')}>
|
|
<select
|
|
className={control}
|
|
value={review.data.status}
|
|
onChange={(e) =>
|
|
review.setData('status', e.target.value)
|
|
}
|
|
>
|
|
<option value="accepted">
|
|
{t(
|
|
'Aceitar para análise interna',
|
|
'Accept for internal analysis',
|
|
)}
|
|
</option>
|
|
<option value="rejected">
|
|
{t('Rejeitar', 'Reject')}
|
|
</option>
|
|
</select>
|
|
</Field>
|
|
{Object.entries(review.errors).map(([k, v]) => (
|
|
<p role="alert" key={k}>
|
|
{v}
|
|
</p>
|
|
))}
|
|
<button disabled={review.processing} className={button}>
|
|
{t('Registar revisão', 'Record review')}
|
|
</button>
|
|
</form>
|
|
</Management>
|
|
);
|
|
}
|