Ctrl K
ComunidadGuías…

src/lib/resume-fit.ts

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

Archivo original ↗Preguntar a KiBot
import type { ResumeDoc } from "@/lib/resume-schema";

/**
 * What to cut when a resume runs long.
 *
 * Pure and client-safe, like resume-text.ts and resume-diff.ts, so the editor
 * and the MCP tool rank candidates the same way.
 *
 * This ranks; it does not measure. How many pages a document actually makes is
 * a question only a browser can answer — the editor asks one (see
 * resume-pagination.ts) and export_resume_pdf renders one. What is knowable
 * without a layout engine is which pieces are big, and that is what a person
 * asking "what do I cut?" wants: the longest bullets, and the sections carrying
 * the most weight.
 */

/** Roughly how many rendered lines a string of body text occupies. */
const linesFor = (text: string, perLine = 105) => Math.max(1, Math.ceil(text.trim().length / perLine));

export type TrimCandidate = {
  /** A `data-rp` path, so the editor can jump to it and the tool can name it. */
  path: string;
  /** Where it lives: "Senior Engineer — Meridian". */
  entry: string;
  text: string;
  /** Characters, and the lines they cost. */
  length: number;
  lines: number;
};

export type SectionWeight = {
  path: string;
  heading: string;
  kind: string;
  visible: boolean;
  lines: number;
  /** How many entries or bullets it holds. */
  items: number;
};

export type FitReport = {
  /** Longest first. The obvious things to shorten or drop. */
  bullets: TrimCandidate[];
  /** Heaviest first. A section you can hide outright is the biggest lever. */
  sections: SectionWeight[];
  /** Total estimated lines, and the estimate's own page count. */
  estimatedLines: number;
  approxPages: number;
};

const entryLabel = (title: string, company: string) =>
  [title, company].filter(Boolean).join(" — ") || "an entry";

/**
 * Rank what is worth cutting. `perPage` is the estimator's lines-per-page, kept
 * as an argument so the caller states the assumption rather than this file
 * hiding a second copy of it.
 */
export function fitReport(doc: ResumeDoc, perPage: number): FitReport {
  const bullets: TrimCandidate[] = [];
  const sections: SectionWeight[] = [];
  let total = 5; // the header

  doc.sections.forEach((section, index) => {
    const path = `s${index}`;
    let lines = 2; // the heading and its rule
    let items = 0;

    if (section.kind === "summary" && section.text.trim()) {
      lines += linesFor(section.text, 110);
      items += 1;
    }

    // Every entry kind that can carry bullets, with the letter its `data-rp`
    // path uses so the editor can jump straight to what it names.
    const entries: { letter: string; label: string; bullets: string[] }[] = [
      ...section.experience.map((item) => ({
        letter: "e",
        label: entryLabel(item.title, item.company),
        bullets: item.bullets,
      })),
      ...section.projects.map((item) => ({
        letter: "p",
        label: item.name || "a project",
        bullets: item.bullets,
      })),
      ...section.items.map((item) => ({
        letter: "x",
        label: item.title || "an entry",
        bullets: item.bullets,
      })),
      ...section.education.map((item) => ({
        letter: "d",
        label: entryLabel(item.degree, item.school),
        bullets: item.details,
      })),
    ];

    // Positions are per kind, matching how the renderer indexes each list.
    const seen: Record<string, number> = {};
    for (const entry of entries) {
      const position = seen[entry.letter] ?? 0;
      seen[entry.letter] = position + 1;
      items += 1;
      lines += 2;
      entry.bullets.forEach((text, bulletIndex) => {
        if (!text.trim()) return;
        const cost = linesFor(text);
        lines += cost;
        bullets.push({
          path: `${path}/${entry.letter}${position}/b${bulletIndex}`,
          entry: entry.label,
          text,
          length: text.trim().length,
          lines: cost,
        });
      });
    }

    if (section.kind === "skills") {
      for (const group of section.skills) {
        items += 1;
        lines += linesFor(`${group.name}: ${group.skills.join(", ")}`, 95);
      }
    }
    if (section.kind === "certifications") {
      items += section.certifications.length;
      lines += section.certifications.length;
    }

    sections.push({
      path,
      heading: section.heading || section.kind,
      kind: section.kind,
      visible: section.visible,
      lines,
      items,
    });
    if (section.visible) total += lines;
  });

  bullets.sort((a, b) => b.length - a.length);
  const visibleSections = [...sections].sort((a, b) => b.lines - a.lines);

  return {
    bullets,
    sections: visibleSections,
    estimatedLines: total,
    approxPages: Math.max(1, Math.ceil(total / perPage)),
  };
}

// ---------------------------------------------------------------------------
// The cuts themselves
// ---------------------------------------------------------------------------

/**
 * Remove the bullet a path names, returning a new document.
 *
 * Returns the document unchanged when the path does not resolve, which is what
 * happens if the layout it came from is a beat behind an edit. Silently doing
 * nothing is right here: the alternative is deleting whatever now sits at that
 * index, which would be somebody else's sentence.
 */
export function removeBulletAt(doc: ResumeDoc, path: string): ResumeDoc {
  const match = /^s(\d+)\/([epxd])(\d+)\/b(\d+)$/.exec(path);
  if (!match) return doc;
  const [, rawSection, letter, rawEntry, rawBullet] = match;
  const sectionIndex = Number(rawSection);
  const entryIndex = Number(rawEntry);
  const bulletIndex = Number(rawBullet);
  const section = doc.sections[sectionIndex];
  if (!section) return doc;

  const drop = (list: string[]) =>
    bulletIndex < list.length ? list.filter((_, index) => index !== bulletIndex) : list;

  const sections = [...doc.sections];
  if (letter === "e" && section.experience[entryIndex]) {
    const experience = [...section.experience];
    experience[entryIndex] = {
      ...experience[entryIndex],
      bullets: drop(experience[entryIndex].bullets),
    };
    sections[sectionIndex] = { ...section, experience };
  } else if (letter === "p" && section.projects[entryIndex]) {
    const projects = [...section.projects];
    projects[entryIndex] = { ...projects[entryIndex], bullets: drop(projects[entryIndex].bullets) };
    sections[sectionIndex] = { ...section, projects };
  } else if (letter === "x" && section.items[entryIndex]) {
    const items = [...section.items];
    items[entryIndex] = { ...items[entryIndex], bullets: drop(items[entryIndex].bullets) };
    sections[sectionIndex] = { ...section, items };
  } else if (letter === "d" && section.education[entryIndex]) {
    const education = [...section.education];
    education[entryIndex] = {
      ...education[entryIndex],
      details: drop(education[entryIndex].details),
    };
    sections[sectionIndex] = { ...section, education };
  } else {
    return doc;
  }
  return { ...doc, sections };
}

/** Hide or show a section without deleting it. The biggest single lever. */
export function setSectionVisible(doc: ResumeDoc, index: number, visible: boolean): ResumeDoc {
  const section = doc.sections[index];
  if (!section) return doc;
  const sections = [...doc.sections];
  sections[index] = { ...section, visible };
  return { ...doc, sections };
}