This commit is contained in:
Marcelo Dares
2026-04-29 01:15:50 +02:00
parent 65aaf9275e
commit ea23136288
172 changed files with 30358 additions and 353 deletions

View File

@@ -25,6 +25,34 @@ type StrategicDiagnosticWizardProps = {
type TabKey = StrategicSectionKey | "results";
type StrategicAiInsights = {
sectionGaps: {
sectionKey: StrategicSectionKey;
gap: string;
impact: string;
urgency: "alta" | "media" | "baja";
}[];
priorityActions: {
title: string;
description: string;
priority: "alta" | "media" | "baja";
ownerSuggestion: string;
targetDateSuggestion: string;
}[];
suggestedEvidence: {
sectionKey: StrategicSectionKey;
category: string;
reason: string;
}[];
suggestedFieldValues: {
sectionKey: StrategicSectionKey;
fieldPath: string;
suggestedValue: string;
rationale: string;
}[];
confidence: "low" | "medium" | "high";
};
const tabItems: { key: TabKey; label: string }[] = [
{ key: "technical", label: "Capacidades Tecnicas" },
{ key: "experience", label: "Experiencia" },
@@ -135,6 +163,40 @@ function SectionScoreBar({ title, subtitle, score }: { title: string; subtitle:
);
}
function setNestedValue(target: unknown, pathSegments: string[], value: string): unknown {
if (!pathSegments.length || !target || typeof target !== "object" || Array.isArray(target)) {
return target;
}
const [head, ...tail] = pathSegments;
const record = target as Record<string, unknown>;
if (!(head in record)) {
return target;
}
if (tail.length === 0) {
if (typeof record[head] !== "string") {
return target;
}
return {
...record,
[head]: value,
};
}
const nextValue = setNestedValue(record[head], tail, value);
if (nextValue === record[head]) {
return target;
}
return {
...record,
[head]: nextValue,
};
}
export function StrategicDiagnosticWizard({
initialData,
initialScores,
@@ -160,6 +222,10 @@ export function StrategicDiagnosticWizard({
organization: STRATEGIC_EVIDENCE_CATEGORY_OPTIONS.organization[0] ?? "Otro",
publicProcurement: STRATEGIC_EVIDENCE_CATEGORY_OPTIONS.publicProcurement[0] ?? "Otro",
});
const [aiInsights, setAiInsights] = useState<StrategicAiInsights | null>(null);
const [aiSuggestionId, setAiSuggestionId] = useState<string | null>(null);
const [isLoadingAiInsights, setIsLoadingAiInsights] = useState(false);
const [isApplyingAiField, setIsApplyingAiField] = useState<string | null>(null);
const progressPercent = useMemo(() => {
return Math.round((scores.completedSections / scores.totalSections) * 100);
@@ -185,6 +251,20 @@ export function StrategicDiagnosticWizard({
return tabItems[index + 1]?.key ?? "results";
}
async function setAiDecision(decision: "accept" | "dismiss") {
if (!aiSuggestionId) {
return;
}
await fetch(`/api/ai/suggestions/${encodeURIComponent(aiSuggestionId)}/decision`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ decision }),
});
}
async function persistData(section: TabKey, forceCompleted = false) {
setIsSaving(true);
setSavingSection(section);
@@ -284,6 +364,103 @@ export function StrategicDiagnosticWizard({
}
}
async function generateAiInsights() {
setIsLoadingAiInsights(true);
clearFeedback();
try {
const response = await fetch("/api/strategic-diagnostic/ai/insights", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
data,
evidenceMetadata: {
technical: {
count: evidenceBySection.technical.length,
categories: Array.from(new Set(evidenceBySection.technical.map((item) => item.category))),
},
experience: {
count: evidenceBySection.experience.length,
categories: Array.from(new Set(evidenceBySection.experience.map((item) => item.category))),
},
organization: {
count: evidenceBySection.organization.length,
categories: Array.from(new Set(evidenceBySection.organization.map((item) => item.category))),
},
publicProcurement: {
count: evidenceBySection.publicProcurement.length,
categories: Array.from(new Set(evidenceBySection.publicProcurement.map((item) => item.category))),
},
},
}),
});
const payload = (await response.json().catch(() => ({}))) as {
ok?: boolean;
error?: string;
sectionGaps?: StrategicAiInsights["sectionGaps"];
priorityActions?: StrategicAiInsights["priorityActions"];
suggestedEvidence?: StrategicAiInsights["suggestedEvidence"];
suggestedFieldValues?: StrategicAiInsights["suggestedFieldValues"];
confidence?: StrategicAiInsights["confidence"];
suggestionId?: string;
};
if (!response.ok || !payload.ok) {
setErrorMessage(payload.error ?? "No fue posible generar plan IA.");
return;
}
setAiInsights({
sectionGaps: payload.sectionGaps ?? [],
priorityActions: payload.priorityActions ?? [],
suggestedEvidence: payload.suggestedEvidence ?? [],
suggestedFieldValues: payload.suggestedFieldValues ?? [],
confidence: payload.confidence ?? "low",
});
setAiSuggestionId(payload.suggestionId ?? null);
setSuccessMessage("Plan sugerido por IA generado.");
} catch {
setErrorMessage("No fue posible generar plan IA.");
} finally {
setIsLoadingAiInsights(false);
}
}
async function applySuggestedFieldValue(fieldPath: string, value: string) {
const segments = fieldPath.split(".").map((segment) => segment.trim()).filter(Boolean);
if (!segments.length) {
return;
}
setIsApplyingAiField(fieldPath);
clearFeedback();
try {
let applied = false;
setData((previous) => {
const nextValue = setNestedValue(previous, segments, value);
applied = nextValue !== previous;
return (nextValue as StrategicDiagnosticData) ?? previous;
});
if (!applied) {
setErrorMessage("Este campo no acepta aplicacion directa desde IA. Aplica manualmente.");
return;
}
await setAiDecision("accept");
setSuccessMessage(`Valor sugerido aplicado: ${fieldPath}.`);
} catch {
setErrorMessage("No fue posible aplicar el valor sugerido.");
} finally {
setIsApplyingAiField(null);
}
}
function addListItem(section: "technical" | "experience", field: "coverageRegions" | "certifications" | "sectorsServed", rawValue: string) {
const value = rawValue.trim();
@@ -1239,6 +1416,95 @@ export function StrategicDiagnosticWizard({
</CardContent>
</Card>
<Card className="border-[#cfdcf4] bg-[#f7f9ff]">
<CardHeader>
<h4 className="text-3xl font-semibold text-[#1b2a46]">Plan sugerido por IA</h4>
<p className="text-sm text-[#60718e]">Asistencia opcional. Nunca se aplican cambios automaticamente.</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" disabled={isLoadingAiInsights} onClick={() => void generateAiInsights()}>
{isLoadingAiInsights ? "Generando plan IA..." : "Generar plan IA"}
</Button>
{aiInsights ? (
<Button
type="button"
variant="secondary"
onClick={() => {
void setAiDecision("dismiss");
setAiInsights(null);
setSuccessMessage("Plan IA descartado.");
}}
>
Descartar plan IA
</Button>
) : null}
{aiInsights ? (
<p className="text-xs font-semibold text-[#405578]">Confianza IA: {aiInsights.confidence}</p>
) : null}
</div>
{!aiInsights ? <p className="text-sm text-[#60718e]">Genera un plan IA para ver brechas y acciones priorizadas.</p> : null}
{aiInsights ? (
<div className="space-y-4">
<div>
<p className="text-lg font-semibold text-[#1d335a]">Brechas por seccion</p>
<ul className="mt-2 space-y-2 text-sm text-[#4f6385]">
{aiInsights.sectionGaps.map((item) => (
<li key={`${item.sectionKey}-${item.gap}`} className="rounded-lg border border-[#d8e1ef] bg-white px-3 py-2">
<p className="font-semibold">
{item.sectionKey} · {item.urgency}
</p>
<p>{item.gap}</p>
<p className="text-xs">{item.impact}</p>
</li>
))}
</ul>
</div>
<div>
<p className="text-lg font-semibold text-[#1d335a]">Acciones prioritarias</p>
<ul className="mt-2 space-y-2 text-sm text-[#4f6385]">
{aiInsights.priorityActions.map((item) => (
<li key={`${item.title}-${item.priority}`} className="rounded-lg border border-[#d8e1ef] bg-white px-3 py-2">
<p className="font-semibold">{item.title}</p>
<p>{item.description}</p>
<p className="text-xs">
Prioridad: {item.priority} · Responsable sugerido: {item.ownerSuggestion} · Fecha objetivo: {item.targetDateSuggestion}
</p>
</li>
))}
</ul>
</div>
<div>
<p className="text-lg font-semibold text-[#1d335a]">Campos sugeridos para aplicar</p>
<ul className="mt-2 space-y-2 text-sm text-[#4f6385]">
{aiInsights.suggestedFieldValues.map((item) => (
<li key={`${item.fieldPath}-${item.suggestedValue}`} className="rounded-lg border border-[#d8e1ef] bg-white px-3 py-2">
<p className="font-semibold">{item.fieldPath}</p>
<p className="mt-1 text-xs">{item.rationale}</p>
<p className="mt-1 rounded bg-[#f5f8ff] px-2 py-1 text-xs font-semibold text-[#1d335a]">{item.suggestedValue}</p>
<div className="mt-2">
<Button
type="button"
size="sm"
disabled={isApplyingAiField === item.fieldPath}
onClick={() => void applySuggestedFieldValue(item.fieldPath, item.suggestedValue)}
>
{isApplyingAiField === item.fieldPath ? "Aplicando..." : "Aplicar valor"}
</Button>
</div>
</li>
))}
</ul>
</div>
</div>
) : null}
</CardContent>
</Card>
<Card className="border-[#9bd7bf] bg-[#f6fffb]">
<CardHeader>
<h4 className="text-3xl font-semibold text-[#1b2a46]">Modulo 2 Completado! Siguiente paso recomendado</h4>
@@ -1255,8 +1521,8 @@ export function StrategicDiagnosticWizard({
<Link href="/licitations">
<Button variant="secondary">Ir a Modulo 3</Button>
</Link>
<Link href="/manual">
<Button variant="secondary">Ir a Analisis Normativo (Modulo 4)</Button>
<Link href="/normative-analysis">
<Button variant="secondary">Ir a Modulo 4: Analisis Normativo</Button>
</Link>
</div>
</CardContent>