Ctrl K
ComunidadGuías…

src/lib/resume-schema.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { z } from "zod";

/**
 * The resume document. This is the contract between the UI, the database
 * (Resume.data) and the MCP tools Claude calls. Keep it boring and flat —
 * every field is optional-with-default so a partially generated document
 * still renders.
 */

export const linkSchema = z.object({
  label: z.string().default(""),
  url: z.string().default(""),
});

export const headerSchema = z.object({
  name: z.string().default(""),
  title: z.string().default(""),
  email: z.string().default(""),
  phone: z.string().default(""),
  location: z.string().default(""),
  links: z.array(linkSchema).default([]),
});

export const experienceItemSchema = z.object({
  id: z.string().default(""),
  company: z.string().default(""),
  title: z.string().default(""),
  location: z.string().default(""),
  startDate: z.string().default(""),
  endDate: z.string().default(""),
  isCurrent: z.boolean().default(false),
  summary: z.string().default(""),
  bullets: z.array(z.string()).default([]),
  /// Optional back-reference to the Role this was tailored from.
  roleId: z.string().optional(),
});

export const educationItemSchema = z.object({
  id: z.string().default(""),
  school: z.string().default(""),
  degree: z.string().default(""),
  field: z.string().default(""),
  location: z.string().default(""),
  startDate: z.string().default(""),
  endDate: z.string().default(""),
  details: z.array(z.string()).default([]),
});

export const projectItemSchema = z.object({
  id: z.string().default(""),
  name: z.string().default(""),
  role: z.string().default(""),
  url: z.string().default(""),
  startDate: z.string().default(""),
  endDate: z.string().default(""),
  description: z.string().default(""),
  bullets: z.array(z.string()).default([]),
});

export const skillGroupSchema = z.object({
  name: z.string().default(""),
  skills: z.array(z.string()).default([]),
});

export const certItemSchema = z.object({
  name: z.string().default(""),
  issuer: z.string().default(""),
  date: z.string().default(""),
});

export const customItemSchema = z.object({
  title: z.string().default(""),
  subtitle: z.string().default(""),
  meta: z.string().default(""),
  bullets: z.array(z.string()).default([]),
});

export const SECTION_KINDS = [
  "summary",
  "experience",
  "education",
  "projects",
  "skills",
  "certifications",
  "custom",
] as const;

export const sectionSchema = z.object({
  id: z.string().default(""),
  kind: z.enum(SECTION_KINDS),
  /// Display heading, e.g. "Experience" or "Selected Work"
  heading: z.string().default(""),
  visible: z.boolean().default(true),
  /// Only one of these is used, depending on `kind`.
  text: z.string().default(""),
  experience: z.array(experienceItemSchema).default([]),
  education: z.array(educationItemSchema).default([]),
  projects: z.array(projectItemSchema).default([]),
  skills: z.array(skillGroupSchema).default([]),
  certifications: z.array(certItemSchema).default([]),
  items: z.array(customItemSchema).default([]),
});

export const resumeDocSchema = z.object({
  header: headerSchema.default({}),
  sections: z.array(sectionSchema).default([]),
});

export type ResumeLink = z.infer<typeof linkSchema>;
export type ResumeHeader = z.infer<typeof headerSchema>;
export type ExperienceItem = z.infer<typeof experienceItemSchema>;
export type EducationItem = z.infer<typeof educationItemSchema>;
export type ProjectItem = z.infer<typeof projectItemSchema>;
export type SkillGroupItem = z.infer<typeof skillGroupSchema>;
export type CertItem = z.infer<typeof certItemSchema>;
export type CustomItem = z.infer<typeof customItemSchema>;
export type ResumeSection = z.infer<typeof sectionSchema>;
export type ResumeDoc = z.infer<typeof resumeDocSchema>;
export type SectionKind = (typeof SECTION_KINDS)[number];

let idCounter = 0;
export function rid(prefix = "s") {
  idCounter += 1;
  return `${prefix}_${Math.random().toString(36).slice(2, 8)}${idCounter}`;
}

/**
 * Give every section and entry an id of its own.
 *
 * Every id defaults to "" and RESUME_DOC_SHAPE never mentions ids, so a
 * document written through create_resume — the product's main path — arrives
 * with "" on every section. The editor addressed sections by id, so such a
 * document had one identity for all of them: hiding one section hid the lot,
 * and deleting one deleted the document. Healed here rather than at the write,
 * because the first edit happens before the first save.
 *
 * Deterministic on purpose. rid() is random, and a random id minted on every
 * read would churn between one get_resume and the next, and would make two
 * parses of the same stored document unequal. Positional names are stable, so
 * a document parsed twice is the same document.
 */
export function ensureIds(doc: ResumeDoc): ResumeDoc {
  const used = new Set<string>();
  // Keep an id the document already carries, unless it is blank or a
  // duplicate; otherwise take the positional name, and walk it until it is
  // free. Total: it always returns something no other entry holds.
  const claim = (id: string, positional: string) => {
    let next = id && !used.has(id) ? id : positional;
    let n = 2;
    while (used.has(next)) next = `${positional}_${n++}`;
    used.add(next);
    return next;
  };
  return {
    ...doc,
    sections: doc.sections.map((section, s) => ({
      ...section,
      id: claim(section.id, `sec_${s}`),
      experience: section.experience.map((item, i) => ({
        ...item,
        id: claim(item.id, `exp_${s}_${i}`),
      })),
      education: section.education.map((item, i) => ({
        ...item,
        id: claim(item.id, `edu_${s}_${i}`),
      })),
      projects: section.projects.map((item, i) => ({
        ...item,
        id: claim(item.id, `prj_${s}_${i}`),
      })),
    })),
  };
}

/**
 * For READING a stored row: never throws, so a damaged document still opens.
 * Never use it on a write — see parseResumeDocStrict.
 */
export function parseResumeDoc(value: unknown): ResumeDoc {
  const parsed = resumeDocSchema.safeParse(value);
  if (parsed.success) return ensureIds(parsed.data);
  return emptyResumeDoc();
}

/** null means "nothing here" to every assistant; to zod it is a type error. */
function dropNulls(value: unknown): unknown {
  if (Array.isArray(value)) return value.filter((item) => item !== null).map(dropNulls);
  if (value && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value).filter(([, v]) => v !== null).map(([k, v]) => [k, dropNulls(v)]),
    );
  }
  return value;
}

/**
 * For WRITING a document that arrived from a caller: refuses, naming every
 * field that is wrong, rather than saving anything.
 *
 * The lenient parse above returns an empty document on failure, and for a
 * while every write went through it — so an assistant that sent one null
 * endDate or `isCurrent: "true"` replaced someone's whole resume with a blank
 * one and was told it had worked. A refusal that names `sections[2].kind` is
 * something an assistant fixes on its next call. Nulls are forgiven, because
 * they mean "empty" and the field's default says the same thing.
 */
export function parseResumeDocStrict(value: unknown): ResumeDoc {
  const parsed = resumeDocSchema.safeParse(dropNulls(value));
  if (parsed.success) return ensureIds(parsed.data);
  const problems = parsed.error.issues.slice(0, 8).map((issue) => {
    const path = issue.path
      .map((part, i) => (typeof part === "number" ? `[${part}]` : i === 0 ? part : `.${part}`))
      .join("");
    return `${path || "(document)"}: ${issue.message}`;
  });
  const more = parsed.error.issues.length - problems.length;
  throw new Error(
    `That resume document does not match the format, so nothing was saved. ` +
      `${problems.join("; ")}${more > 0 ? `; and ${more} more` : ""}. ` +
      `get_resume_format shows the exact shape.`,
  );
}

export function emptyResumeDoc(): ResumeDoc {
  return {
    header: { name: "", title: "", email: "", phone: "", location: "", links: [] },
    sections: [
      { ...blankSection("summary"), heading: "Summary" },
      { ...blankSection("experience"), heading: "Experience" },
      { ...blankSection("education"), heading: "Education" },
      { ...blankSection("skills"), heading: "Skills" },
    ],
  };
}

export function blankSection(kind: SectionKind): ResumeSection {
  return {
    id: rid("sec"),
    kind,
    heading: defaultHeading(kind),
    visible: true,
    text: "",
    experience: [],
    education: [],
    projects: [],
    skills: [],
    certifications: [],
    items: [],
  };
}

export function defaultHeading(kind: SectionKind) {
  switch (kind) {
    case "summary":
      return "Summary";
    case "experience":
      return "Experience";
    case "education":
      return "Education";
    case "projects":
      return "Projects";
    case "skills":
      return "Skills";
    case "certifications":
      return "Certifications";
    default:
      return "Additional";
  }
}

export function blankExperience(): ExperienceItem {
  return {
    id: rid("exp"),
    company: "",
    title: "",
    location: "",
    startDate: "",
    endDate: "",
    isCurrent: false,
    summary: "",
    bullets: [""],
  };
}

export function blankEducation(): EducationItem {
  return {
    id: rid("edu"),
    school: "",
    degree: "",
    field: "",
    location: "",
    startDate: "",
    endDate: "",
    details: [],
  };
}

export function blankProject(): ProjectItem {
  return {
    id: rid("prj"),
    name: "",
    role: "",
    url: "",
    startDate: "",
    endDate: "",
    description: "",
    bullets: [""],
  };
}

/** A compact, human-readable description of the doc shape for the MCP tool docs. */
export const RESUME_DOC_SHAPE = `{
  "header": {
    "name": string, "title": string, "email": string, "phone": string,
    "location": string, "links": [{ "label": string, "url": string }]
  },
  "sections": [
    { "kind": "summary", "heading": "Summary", "visible": true, "text": string },
    { "kind": "experience", "heading": "Experience", "visible": true,
      "experience": [{ "roleId": string, "company": string, "title": string, "location": string,
                       "startDate": "YYYY-MM", "endDate": "YYYY-MM", "isCurrent": boolean,
                       "summary": string, "bullets": [string] }] },
    { "kind": "education", "heading": "Education", "visible": true,
      "education": [{ "school": string, "degree": string, "field": string,
                      "location": string, "startDate": "YYYY-MM", "endDate": "YYYY-MM",
                      "details": [string] }] },
    { "kind": "projects", "heading": "Projects", "visible": true,
      "projects": [{ "name": string, "role": string, "url": string,
                     "startDate": "YYYY-MM", "endDate": "YYYY-MM",
                     "description": string, "bullets": [string] }] },
    { "kind": "skills", "heading": "Skills", "visible": true,
      "skills": [{ "name": string, "skills": [string] }] },
    { "kind": "certifications", "heading": "Certifications", "visible": true,
      "certifications": [{ "name": string, "issuer": string, "date": string }] },
    { "kind": "custom", "heading": string, "visible": true,
      "items": [{ "title": string, "subtitle": string, "meta": string, "bullets": [string] }] }
  ]
}`;