feat(21-22): agente AI generazione preventivo + pagina pubblica deck
- Migration 0010: tabella proposals (id, slug, lead_id, client_id, offer_macro_id, content jsonb, state, selected_tier, accepted_at) applicata a prod via SSH tunnel - @anthropic-ai/sdk@0.105.0 installato; ANTHROPIC_API_KEY in .env.local - src/lib/proposal/: schema Zod ProposalContent, agente Claude Opus 4.8, assemble (AI + offerta DB + config consulente), queries, profile.ts - Admin: /admin/preventivi lista + /genera (pre-fill ?lead_id=X) + /[id] review - Sidebar: voce Preventivi + CTA globale lime "Genera preventivo" - LeadDetail: pulsante "Genera preventivo" → /admin/preventivi/genera?lead_id=X - Pagina pubblica /preventivo/[slug]: deck 20+ slide light-mode iamcavalli, navigazione frecce + dot + keyboard, accept/reject con guard immutabilità - STATE.md aggiornato (80%), 21-PLAN.md scritto nel formato GSD Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { ProposalContentSchema, type ProposalContent } from "./schema";
|
||||
import type { OfferEditorData } from "@/lib/offer-queries";
|
||||
import type { Client, Lead, ClientTranscript } from "@/db/schema";
|
||||
|
||||
const MODEL = "claude-opus-4-8";
|
||||
|
||||
type AgentInput = {
|
||||
// lead o cliente (uno dei due)
|
||||
lead?: Pick<Lead, "id" | "name" | "email" | "company" | "notes">;
|
||||
client?: Pick<Client, "id" | "name" | "brand_name" | "brief">;
|
||||
transcripts: Pick<ClientTranscript, "title" | "content" | "call_date">[];
|
||||
offer: OfferEditorData;
|
||||
};
|
||||
|
||||
function buildSystemPrompt(): string {
|
||||
return `Sei un consulente senior di brand e business strategy con 10+ anni di esperienza.
|
||||
Il tuo compito è analizzare i transcript di call discovery con un cliente e generare il
|
||||
contenuto di un preventivo professionale in italiano.
|
||||
|
||||
REGOLE FONDAMENTALI:
|
||||
- Usa SOLO informazioni reali estratte dai transcript. Non inventare problemi, soluzioni o citazioni.
|
||||
- Le citazioni (quote) nei nodi problema devono essere verbatim — parole esatte dette dal cliente.
|
||||
- I problemi devono emergere dalle parole del cliente, non da assunzioni tue.
|
||||
- Le soluzioni devono specchiare i problemi (stessa sequenza 01–05) e descrivere la trasformazione concreta.
|
||||
- Il tono è professionale ma diretto, mai generico. Usa il lessico del settore del cliente.
|
||||
- Non includere prezzi o importi nel contenuto generato — quelli vengono dal DB dell'offerta.
|
||||
- Rispondi SOLO con JSON valido, nessun testo extra prima o dopo.`;
|
||||
}
|
||||
|
||||
function buildUserPrompt(input: AgentInput): string {
|
||||
const subject = input.client
|
||||
? `Cliente: ${input.client.name} (brand: ${input.client.brand_name})\nBrief: ${input.client.brief}`
|
||||
: `Lead: ${input.lead?.name}${input.lead?.company ? ` — ${input.lead.company}` : ""}${input.lead?.notes ? `\nNote: ${input.lead.notes}` : ""}`;
|
||||
|
||||
const transcriptBlocks = input.transcripts
|
||||
.map(
|
||||
(t, i) =>
|
||||
`=== TRANSCRIPT ${i + 1} — ${t.call_date}${t.title ? ` (${t.title})` : ""} ===\n${t.content}`
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
const offerDescription = `Offerta: ${input.offer.macro.public_name}
|
||||
Descrizione: ${input.offer.macro.description ?? "—"}
|
||||
Promessa di trasformazione: ${input.offer.macro.transformation_promise ?? "—"}
|
||||
Tier disponibili: ${input.offer.tiers.map((t) => `${t.tier_letter} (${t.public_name})`).join(", ")}`;
|
||||
|
||||
return `${subject}
|
||||
|
||||
${offerDescription}
|
||||
|
||||
TRANSCRIPT DELLE CALL:
|
||||
${transcriptBlocks || "(nessun transcript disponibile — genera contenuto basato sul brief)"}
|
||||
|
||||
Genera il contenuto del preventivo come JSON. Segui ESATTAMENTE questo schema TypeScript:
|
||||
|
||||
{
|
||||
vision: { headline: string, body: string },
|
||||
problems: Array<{
|
||||
id: string, // "01" | "02" | ... | "05"
|
||||
title: string, // titolo della slide problema
|
||||
subtitle: string, // es. "LA VOCE" — tema macro
|
||||
body: string, // 2-3 frasi descrittive
|
||||
risk: string, // testo box "IL RISCHIO" — conseguenza se non si risolve
|
||||
quote: string, // citazione verbatim dal transcript
|
||||
quoteAttribution: string // "NOME · DISCOVERY YYYY-MM-DD"
|
||||
}>, // 3–5 problemi
|
||||
problemSynthesis: {
|
||||
rootCause: string, // la domanda strategica comune
|
||||
nodeLabels: string[] // 5 label brevi per il diagramma
|
||||
},
|
||||
solutions: Array<{
|
||||
id: string, // specchia il problem node
|
||||
title: string,
|
||||
subtitle: string, // es. "SOLUZIONE 01 · LA VOCE"
|
||||
transformation: string, // testo box "LA TRASFORMAZIONE"
|
||||
throughWhat: string[] // 3 bullet "ATTRAVERSO COSA"
|
||||
}>,
|
||||
solutionSynthesis: {
|
||||
direction: string,
|
||||
elevations: Array<{ id: string, label: string }> // 5 elementi
|
||||
},
|
||||
scope: {
|
||||
scopeTitle: string,
|
||||
scopeBody: string,
|
||||
objectives: string[] // 3–5 obiettivi misurabili
|
||||
},
|
||||
deliverables: {
|
||||
deliverables: string[], // output principali (tier-agnostic)
|
||||
outOfScope: string[] // 3–5 elementi esclusi
|
||||
},
|
||||
timeline: {
|
||||
totalWeeks: number,
|
||||
phases: Array<{
|
||||
label: string,
|
||||
startWeek: number,
|
||||
endWeek: number,
|
||||
color: "cyan" | "purple" | "green"
|
||||
}>,
|
||||
disclaimer: string
|
||||
},
|
||||
stagesRecap: {
|
||||
stages: Array<{
|
||||
number: string, // "01" | "02" | "03"
|
||||
tierLetter: string, // "A" | "B" | "C"
|
||||
label: string, // es. "OPZIONE A – AUDIT"
|
||||
headline: string, // es. "Si capisce."
|
||||
body: string,
|
||||
deliverable: string
|
||||
}>
|
||||
},
|
||||
comparisonMatrix: Array<{
|
||||
component: string,
|
||||
description: string,
|
||||
inA: boolean,
|
||||
inB: boolean,
|
||||
inC: boolean
|
||||
}> // 4–10 righe
|
||||
}`;
|
||||
}
|
||||
|
||||
export async function generateProposalContent(
|
||||
input: AgentInput
|
||||
): Promise<{ content: ProposalContent; rawJson: string }> {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) throw new Error("ANTHROPIC_API_KEY non configurata");
|
||||
|
||||
const client = new Anthropic({ apiKey });
|
||||
|
||||
const message = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 8192,
|
||||
system: buildSystemPrompt(),
|
||||
messages: [{ role: "user", content: buildUserPrompt(input) }],
|
||||
});
|
||||
|
||||
const rawText =
|
||||
message.content[0].type === "text" ? message.content[0].text : "";
|
||||
|
||||
// Estrae il JSON dalla risposta (gestisce eventuale wrapping in ```)
|
||||
const jsonMatch = rawText.match(/```(?:json)?\s*([\s\S]*?)\s*```/) ||
|
||||
rawText.match(/(\{[\s\S]*\})/);
|
||||
const jsonStr = jsonMatch ? jsonMatch[1] : rawText;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
throw new Error(`L'AI ha prodotto JSON non valido: ${rawText.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const result = ProposalContentSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Contenuto AI non valido: ${result.error.issues.map((i) => i.message).join("; ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return { content: result.data, rawJson: jsonStr };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { ProposalContent } from "./schema";
|
||||
import type { OfferEditorData } from "@/lib/offer-queries";
|
||||
import { CONSULTANT_PROFILE } from "./profile";
|
||||
import type { Client, Lead } from "@/db/schema";
|
||||
|
||||
// Struttura completa salvata in proposals.content (jsonb)
|
||||
export type AssembledProposal = {
|
||||
// Metadati di generazione
|
||||
generatedAt: string; // ISO string
|
||||
model: string;
|
||||
|
||||
// Intestazione (cover + vision)
|
||||
header: {
|
||||
clientName: string; // es. "Yeldo"
|
||||
consultantName: string;
|
||||
offerTitle: string;
|
||||
offerDescription: string | null;
|
||||
date: string; // "DD MMM YYYY" formattata in italiano
|
||||
validityDays: number;
|
||||
};
|
||||
|
||||
// Sezioni generate dall'AI
|
||||
ai: ProposalContent;
|
||||
|
||||
// Profilo consulente (snapshot al momento della generazione)
|
||||
consultant: typeof CONSULTANT_PROFILE;
|
||||
|
||||
// Dati offerta dal DB (snapshot — prezzi al momento della generazione)
|
||||
offer: {
|
||||
macroId: string;
|
||||
publicName: string;
|
||||
tiers: Array<{
|
||||
id: string;
|
||||
tierLetter: string;
|
||||
publicName: string;
|
||||
publicPrice: string | null;
|
||||
servicesTotal: string;
|
||||
durationMonths: number;
|
||||
services: Array<{ id: string; name: string; unitPrice: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type AssembleInput = {
|
||||
lead?: Pick<Lead, "id" | "name" | "company">;
|
||||
client?: Pick<Client, "id" | "name" | "brand_name">;
|
||||
offer: OfferEditorData;
|
||||
aiContent: ProposalContent;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export function assembleProposal(input: AssembleInput): AssembledProposal {
|
||||
const clientName =
|
||||
input.client?.brand_name ?? input.lead?.company ?? input.lead?.name ?? "Cliente";
|
||||
|
||||
const offerTitle = input.offer.macro.public_name;
|
||||
|
||||
// Formatta data in italiano
|
||||
const now = new Date();
|
||||
const date = now.toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
// Snapshot servizi per tier (solo quelli assegnati)
|
||||
const tiers = input.offer.tiers.map((tier) => {
|
||||
const assignedServices = input.offer.availableServices.filter((s) =>
|
||||
tier.assignedServiceIds.includes(s.id)
|
||||
);
|
||||
return {
|
||||
id: tier.id,
|
||||
tierLetter: tier.tier_letter ?? "",
|
||||
publicName: tier.public_name,
|
||||
publicPrice: tier.public_price,
|
||||
servicesTotal: tier.servicesTotal,
|
||||
durationMonths: tier.duration_months,
|
||||
services: assignedServices.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
unitPrice: s.unit_price,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
model: input.model,
|
||||
header: {
|
||||
clientName,
|
||||
consultantName: CONSULTANT_PROFILE.name,
|
||||
offerTitle,
|
||||
offerDescription: input.offer.macro.description ?? null,
|
||||
date,
|
||||
validityDays: CONSULTANT_PROFILE.legal.validityDays,
|
||||
},
|
||||
ai: input.aiContent,
|
||||
consultant: CONSULTANT_PROFILE,
|
||||
offer: {
|
||||
macroId: input.offer.macro.id,
|
||||
publicName: offerTitle,
|
||||
tiers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Profilo consulente iamcavalli — fonte di verità per tutte le proposte.
|
||||
// Editing = modifica diretta di questo file (v1, nessuna UI di gestione).
|
||||
// Foto e immagini via URL esterno (no file hosting v1).
|
||||
|
||||
export type Testimonial = {
|
||||
name: string;
|
||||
role: string;
|
||||
quote: string;
|
||||
photoUrl?: string; // URL esterno, opzionale
|
||||
};
|
||||
|
||||
export type ConsultantFact = {
|
||||
value: string; // es. "20+"
|
||||
label: string; // es. "ANNI DI ESPERIENZA"
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type NextStep = {
|
||||
number: string; // "01" | "02" | "03"
|
||||
text: string;
|
||||
highlightedPart?: string; // parola/data da evidenziare in verde
|
||||
};
|
||||
|
||||
export type ConsultantProfile = {
|
||||
name: string;
|
||||
title: string;
|
||||
bio: string;
|
||||
photoUrl?: string;
|
||||
credentials: string[];
|
||||
facts: [ConsultantFact, ConsultantFact, ConsultantFact]; // esattamente 3
|
||||
contact: {
|
||||
email: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
};
|
||||
// Boilerplate legale e termini — appaiono nella slide "Come si parte"
|
||||
legal: {
|
||||
revisions: string;
|
||||
ip: string;
|
||||
nda: string;
|
||||
forum: string;
|
||||
validityDays: number;
|
||||
acceptanceInstructions: string;
|
||||
};
|
||||
// Passi "Come procedere" (slide finale)
|
||||
nextSteps: [NextStep, NextStep, NextStep];
|
||||
// Testimonial grid (slide 07–09) — mostra tutte, la pagina le impagina in griglie da 9
|
||||
testimonials: Testimonial[];
|
||||
};
|
||||
|
||||
export const CONSULTANT_PROFILE: ConsultantProfile = {
|
||||
name: "Simone Cavalli",
|
||||
title: "Strategist · Brand & Business",
|
||||
bio: "Consulente di personal branding con oltre 10 anni di esperienza nell'aiutare professionisti e aziende a costruire una presenza autorevole e un posizionamento distintivo. Lavoro con founder, manager e liberi professionisti che vogliono trasformare la loro competenza in un brand riconoscibile.",
|
||||
photoUrl: undefined, // → aggiungere URL esterno (es. CDN o Google Drive public)
|
||||
credentials: [
|
||||
"Fondatore di iamcavalli — consulenza di personal branding",
|
||||
"Autore di percorsi formativi su posizionamento e comunicazione",
|
||||
"Ha lavorato con oltre 100 professionisti in Italia",
|
||||
// → aggiungere altre credenziali reali
|
||||
],
|
||||
facts: [
|
||||
{
|
||||
value: "10+",
|
||||
label: "ANNI DI ESPERIENZA",
|
||||
description:
|
||||
"Anni di consulenza strategica su brand personale e business: formazione, consulenza diretta a professionisti e team in scaling.",
|
||||
},
|
||||
{
|
||||
value: "100+",
|
||||
label: "PROFESSIONISTI SEGUITI",
|
||||
description:
|
||||
"Founder, manager e liberi professionisti trasformati in brand riconoscibili nel loro settore.",
|
||||
},
|
||||
{
|
||||
value: "3",
|
||||
label: "PILASTRI DEL METODO",
|
||||
description:
|
||||
"Posizionamento, Comunicazione, Sistema: il percorso strutturato che porta da 'bravo a fare' a 'riconosciuto come il migliore'.",
|
||||
},
|
||||
],
|
||||
contact: {
|
||||
email: "simone@iamcavalli.com", // → verificare email pubblica corretta
|
||||
phone: undefined, // → aggiungere se necessario
|
||||
website: "iamcavalli.com",
|
||||
},
|
||||
legal: {
|
||||
revisions: "Revisioni illimitate entro lo scope concordato.",
|
||||
ip: "Diritti di proprietà intellettuale trasferiti al cliente alla consegna finale.",
|
||||
nda: "NDA reciproco standard a copertura del progetto.",
|
||||
forum: "Foro competente Milano.",
|
||||
validityDays: 14,
|
||||
acceptanceInstructions:
|
||||
"Email di accettazione formale con indicazione del tier scelto. La mail vale come accordo commerciale e attiva la tranche iniziale di pagamento.",
|
||||
},
|
||||
nextSteps: [
|
||||
{
|
||||
number: "01",
|
||||
text: "Conferma del prezzo bloccato entro 7 giorni dall'invio per garantire il pricing proposto.",
|
||||
highlightedPart: undefined, // → sarà calcolato dinamicamente dalla data di pubblicazione
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
text: "Accettazione formale del tier scelto via email entro la scadenza indicata.",
|
||||
highlightedPart: undefined,
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
text: "Se utile prima di scegliere, una call di Q&A (30 min) per chiarimenti sulla proposta e definizione della data di inizio lavori.",
|
||||
highlightedPart: "call di Q&A",
|
||||
},
|
||||
],
|
||||
testimonials: [
|
||||
// → Aggiungere testimonianze reali. Formato:
|
||||
// { name: "Nome Cognome", role: "Professione", quote: "Citazione..." }
|
||||
{
|
||||
name: "Cliente 1",
|
||||
role: "Professione",
|
||||
quote: "→ Aggiungere testimonianza reale.",
|
||||
},
|
||||
{
|
||||
name: "Cliente 2",
|
||||
role: "Professione",
|
||||
quote: "→ Aggiungere testimonianza reale.",
|
||||
},
|
||||
{
|
||||
name: "Cliente 3",
|
||||
role: "Professione",
|
||||
quote: "→ Aggiungere testimonianza reale.",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { proposals, leads, clients, offer_macros } from "@/db/schema";
|
||||
import type { AssembledProposal } from "./assemble";
|
||||
|
||||
export type ProposalListItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string | null;
|
||||
state: string;
|
||||
leadId: string | null;
|
||||
leadName: string | null;
|
||||
clientId: string | null;
|
||||
clientName: string | null;
|
||||
offerMacroId: string;
|
||||
offerName: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export async function listProposals(): Promise<ProposalListItem[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: proposals.id,
|
||||
slug: proposals.slug,
|
||||
title: proposals.title,
|
||||
state: proposals.state,
|
||||
leadId: proposals.lead_id,
|
||||
leadName: leads.name,
|
||||
clientId: proposals.client_id,
|
||||
clientName: clients.name,
|
||||
offerMacroId: proposals.offer_macro_id,
|
||||
offerName: offer_macros.public_name,
|
||||
createdAt: proposals.created_at,
|
||||
updatedAt: proposals.updated_at,
|
||||
})
|
||||
.from(proposals)
|
||||
.leftJoin(leads, eq(proposals.lead_id, leads.id))
|
||||
.leftJoin(clients, eq(proposals.client_id, clients.id))
|
||||
.leftJoin(offer_macros, eq(proposals.offer_macro_id, offer_macros.id))
|
||||
.orderBy(desc(proposals.created_at));
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
slug: r.slug,
|
||||
title: r.title,
|
||||
state: r.state,
|
||||
leadId: r.leadId,
|
||||
leadName: r.leadName,
|
||||
clientId: r.clientId,
|
||||
clientName: r.clientName,
|
||||
offerMacroId: r.offerMacroId,
|
||||
offerName: r.offerName ?? "",
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export type ProposalDetail = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string | null;
|
||||
state: string;
|
||||
leadId: string | null;
|
||||
clientId: string | null;
|
||||
offerMacroId: string;
|
||||
model: string | null;
|
||||
selectedTier: string | null;
|
||||
acceptedAt: Date | null;
|
||||
clientEmail: string | null;
|
||||
clientNotes: string | null;
|
||||
content: AssembledProposal;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export async function getProposalById(id: string): Promise<ProposalDetail | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(proposals)
|
||||
.where(eq(proposals.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
return mapRow(row);
|
||||
}
|
||||
|
||||
export async function getProposalBySlug(slug: string): Promise<ProposalDetail | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(proposals)
|
||||
.where(eq(proposals.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
return mapRow(row);
|
||||
}
|
||||
|
||||
function mapRow(row: typeof proposals.$inferSelect): ProposalDetail {
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
state: row.state,
|
||||
leadId: row.lead_id,
|
||||
clientId: row.client_id,
|
||||
offerMacroId: row.offer_macro_id,
|
||||
model: row.model,
|
||||
selectedTier: row.selected_tier,
|
||||
acceptedAt: row.accepted_at,
|
||||
clientEmail: row.client_email,
|
||||
clientNotes: row.client_notes,
|
||||
content: row.content as AssembledProposal,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ── Schema delle sezioni generate dall'AI ──────────────────────────────────
|
||||
// Le sezioni da OFFER DB (pricing tier) e CONFIG (bio/testimonianze) NON
|
||||
// sono prodotte dall'AI e non fanno parte di questo schema.
|
||||
|
||||
export const ProblemNodeSchema = z.object({
|
||||
id: z.string(), // "01" | "02" | ... | "05"
|
||||
title: z.string(), // es. "La voce non è una. Sono tre."
|
||||
subtitle: z.string(), // es. "LA VOCE"
|
||||
body: z.string(), // paragrafo descrittivo
|
||||
risk: z.string(), // testo del box "IL RISCHIO"
|
||||
quote: z.string(), // citazione verbatim dal transcript
|
||||
quoteAttribution: z.string(), // "NOME COGNOME · DISCOVERY YYYY-MM-DD"
|
||||
});
|
||||
|
||||
export const SolutionNodeSchema = z.object({
|
||||
id: z.string(), // specchia il problem node
|
||||
title: z.string(), // es. "Tre voci coordinate, un solo brand."
|
||||
subtitle: z.string(), // es. "SOLUZIONE 01 · LA VOCE"
|
||||
transformation: z.string(), // testo del box "LA TRASFORMAZIONE"
|
||||
throughWhat: z.array(z.string()), // bullet "ATTRAVERSO COSA" (3 voci)
|
||||
});
|
||||
|
||||
export const DiagramNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
});
|
||||
|
||||
export const ScopeSectionSchema = z.object({
|
||||
scopeTitle: z.string(),
|
||||
scopeBody: z.string(),
|
||||
objectives: z.array(z.string()), // 3–5 bullet con "checkmark" visivo
|
||||
});
|
||||
|
||||
export const DeliverablesSectionSchema = z.object({
|
||||
deliverables: z.array(z.string()), // elenco output finali (tier-agnostic, macro)
|
||||
outOfScope: z.array(z.string()), // ciò che NON è incluso
|
||||
});
|
||||
|
||||
export const TimelineSectionSchema = z.object({
|
||||
totalWeeks: z.number(),
|
||||
phases: z.array(z.object({
|
||||
label: z.string(), // es. "Audit Strategico"
|
||||
startWeek: z.number(),
|
||||
endWeek: z.number(),
|
||||
color: z.enum(["cyan", "purple", "green"]), // palette fissa light-mode
|
||||
})),
|
||||
disclaimer: z.string(),
|
||||
});
|
||||
|
||||
export const StagesRecapSchema = z.object({
|
||||
stages: z.array(z.object({
|
||||
number: z.string(), // "01" | "02" | "03"
|
||||
tierLetter: z.string(), // "A" | "B" | "C"
|
||||
label: z.string(), // es. "OPZIONE A – AUDIT"
|
||||
headline: z.string(), // es. "Si capisce."
|
||||
body: z.string(),
|
||||
deliverable: z.string(), // es. "Audit decision document"
|
||||
})),
|
||||
});
|
||||
|
||||
export const ComparisonRowSchema = z.object({
|
||||
component: z.string(), // es. "Brand Architecture System"
|
||||
description: z.string(), // sottotitolo
|
||||
inA: z.boolean(),
|
||||
inB: z.boolean(),
|
||||
inC: z.boolean(),
|
||||
});
|
||||
|
||||
// Root schema — contenuto generato dall'AI
|
||||
export const ProposalContentSchema = z.object({
|
||||
// Slide "dove andiamo" (02)
|
||||
vision: z.object({
|
||||
headline: z.string(), // es. "Tre voci. Un solo brand."
|
||||
body: z.string(), // paragrafo breve
|
||||
}),
|
||||
|
||||
// Cap.02 — 5 nodi problema estratti dai transcript
|
||||
problems: z.array(ProblemNodeSchema).min(3).max(5),
|
||||
|
||||
// Sintesi problema (5 nodi → 1 radice)
|
||||
problemSynthesis: z.object({
|
||||
rootCause: z.string(), // la domanda strategica non ancora risolta
|
||||
nodeLabels: z.array(z.string()).length(5), // label per il diagramma
|
||||
}),
|
||||
|
||||
// Cap.03 — soluzioni mirror
|
||||
solutions: z.array(SolutionNodeSchema).min(3).max(5),
|
||||
|
||||
// Sintesi soluzione (1 direzione → 5 elevazioni)
|
||||
solutionSynthesis: z.object({
|
||||
direction: z.string(),
|
||||
elevations: z.array(DiagramNodeSchema).length(5),
|
||||
}),
|
||||
|
||||
// Cap.04 — scope, deliverable, timeline
|
||||
scope: ScopeSectionSchema,
|
||||
deliverables: DeliverablesSectionSchema,
|
||||
timeline: TimelineSectionSchema,
|
||||
|
||||
// Slide di recap (3 tappe progressive)
|
||||
stagesRecap: StagesRecapSchema,
|
||||
|
||||
// Tabella comparativa componenti × A/B/C
|
||||
comparisonMatrix: z.array(ComparisonRowSchema).min(4).max(10),
|
||||
});
|
||||
|
||||
export type ProposalContent = z.infer<typeof ProposalContentSchema>;
|
||||
export type ProblemNode = z.infer<typeof ProblemNodeSchema>;
|
||||
export type SolutionNode = z.infer<typeof SolutionNodeSchema>;
|
||||
export type ComparisonRow = z.infer<typeof ComparisonRowSchema>;
|
||||
Reference in New Issue
Block a user