Ctrl K
ComunidadGuías…

src/lib/resume-diff.ts

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

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

/**
 * What changed between two resume documents — typically a tailored variant
 * against the base it was duplicated from.
 *
 * Pure and client-safe on purpose, like resume-text.ts: the editor computes it
 * live as you type, the grid computes it server-side for the lineage chip, and
 * the compare_resumes tool returns it over MCP. One implementation, three
 * surfaces.
 *
 * The comparison is exact-string on bullets. A reworded bullet therefore shows
 * as one removed and one added, which is honest — the reader sees the old
 * wording and the new side by side rather than a similarity score's opinion of
 * whether they are "the same" bullet.
 */

export type ResumeItemDiff = {
  /** "Stripe — Staff Engineer" style label for the entry. */
  label: string;
  status: "added" | "removed" | "changed";
  bulletsAdded: string[];
  bulletsRemoved: string[];
  summaryChanged: boolean;
};

export type ResumeSectionDiff = {
  kind: string;
  heading: string;
  status: "added" | "removed" | "changed";
  /** Per-entry detail for experience sections. */
  items: ResumeItemDiff[];
  /** For summary sections: the text differs. */
  textChanged: boolean;
  /** One line for the list-ish kinds (skills, education, certifications…). */
  detail: string;
};

export type ResumeDiff = {
  identical: boolean;
  /** Header fields whose values differ, e.g. ["title", "location"]. */
  headerChanged: string[];
  sections: ResumeSectionDiff[];
  bulletsAdded: number;
  bulletsRemoved: number;
  /** "+4 bullets · −2 bullets · summary edited" — or "No changes from base". */
  summary: string;
};

/** The fallback identity for an experience entry: where, as what. */
function companyTitleKey(item: { company: string; title: string }) {
  return `${item.company.trim().toLowerCase()}|${item.title.trim().toLowerCase()}`;
}

function experienceLabel(item: { company: string; title: string }) {
  return [item.title, item.company].filter(Boolean).join(" — ") || "Untitled entry";
}

/** A section's identity across the two documents: same kind, same heading. */
function sectionKey(section: ResumeSection) {
  return `${section.kind}|${section.heading.trim().toLowerCase()}`;
}

const cleaned = (bullets: string[]) => bullets.map((b) => b.trim()).filter(Boolean);

function diffBullets(base: string[], variant: string[]) {
  const baseSet = new Set(cleaned(base));
  const variantSet = new Set(cleaned(variant));
  return {
    added: [...variantSet].filter((b) => !baseSet.has(b)),
    removed: [...baseSet].filter((b) => !variantSet.has(b)),
  };
}

/** Every printable line of a list-ish section, for a coarse entry-level diff. */
function sectionEntries(section: ResumeSection): string[] {
  switch (section.kind) {
    case "education":
      return section.education.map((e) =>
        [e.degree, e.field, e.school].filter(Boolean).join(" · "),
      );
    case "projects":
      return section.projects.map((p) => p.name);
    case "skills":
      return section.skills.flatMap((g) => g.skills.map((skill) => `${g.name}: ${skill}`));
    case "certifications":
      return section.certifications.map((c) => c.name);
    case "custom":
      return section.items.map((item) => item.title);
    default:
      return [];
  }
}

export function diffResumeDocs(base: ResumeDoc, variant: ResumeDoc): ResumeDiff {
  const headerChanged: string[] = [];
  const baseHeader = base.header as unknown as Record<string, unknown>;
  const variantHeader = variant.header as unknown as Record<string, unknown>;
  for (const field of ["name", "title", "email", "phone", "location"]) {
    if (String(baseHeader[field] ?? "") !== String(variantHeader[field] ?? "")) {
      headerChanged.push(field);
    }
  }
  const linkLine = (doc: ResumeDoc) => doc.header.links.map((l) => `${l.label}|${l.url}`).join(",");
  if (linkLine(base) !== linkLine(variant)) headerChanged.push("links");

  const sections: ResumeSectionDiff[] = [];
  let bulletsAdded = 0;
  let bulletsRemoved = 0;
  let summaryEdited = false;

  const baseSections = new Map(base.sections.map((s) => [sectionKey(s), s]));
  const variantSections = new Map(variant.sections.map((s) => [sectionKey(s), s]));

  const blank: Omit<ResumeSectionDiff, "kind" | "heading" | "status"> = {
    items: [],
    textChanged: false,
    detail: "",
  };

  for (const [key, variantSection] of variantSections) {
    const baseSection = baseSections.get(key);
    if (!baseSection) {
      // A brand-new section that is hidden was never printed — not a change.
      if (!variantSection.visible) continue;
      const entryCount =
        variantSection.kind === "experience"
          ? variantSection.experience.length
          : sectionEntries(variantSection).length;
      sections.push({
        ...blank,
        kind: variantSection.kind,
        heading: variantSection.heading,
        status: "added",
        detail: entryCount ? `${entryCount} ${entryCount === 1 ? "entry" : "entries"}` : "",
      });
      continue;
    }

    // Visibility is a first-class tailoring move: the eye toggle changes what
    // prints exactly as much as deleting does. The diff describes the PRINTED
    // documents, so a flip reports as removed/added with the honest detail,
    // and a section hidden on both sides is no diff at all, whatever its text.
    if (!baseSection.visible && !variantSection.visible) continue;
    if (baseSection.visible && !variantSection.visible) {
      sections.push({
        ...blank,
        kind: variantSection.kind,
        heading: variantSection.heading,
        status: "removed",
        detail: "hidden, not deleted",
      });
      continue;
    }
    if (!baseSection.visible && variantSection.visible) {
      sections.push({
        ...blank,
        kind: variantSection.kind,
        heading: variantSection.heading,
        status: "added",
        detail: "shown again",
      });
      continue;
    }

    if (variantSection.kind === "summary") {
      if (baseSection.text.trim() !== variantSection.text.trim()) {
        summaryEdited = true;
        sections.push({
          ...blank,
          kind: variantSection.kind,
          heading: variantSection.heading,
          status: "changed",
          textChanged: true,
        });
      }
      continue;
    }

    if (variantSection.kind === "experience") {
      const items: ResumeItemDiff[] = [];
      // Two-pass matching over a POOL, not a Map: two stints at the same
      // employer with the same title (a boomerang) must match one-to-one in
      // order of appearance rather than collapse into whichever entry a Map
      // kept last — that collapse made a byte-identical copy report phantom
      // bullet changes. roleId matches first; company+title mops up, which
      // also pairs an entry that lost or gained its roleId across the copy.
      const basePool = [...baseSection.experience];
      const takeBase = (predicate: (item: ExperienceItem) => boolean) => {
        const index = basePool.findIndex(predicate);
        return index < 0 ? null : basePool.splice(index, 1)[0];
      };
      const pairs = variantSection.experience.map((item) => ({
        variant: item,
        base: item.roleId ? takeBase((candidate) => candidate.roleId === item.roleId) : null,
      }));
      for (const pair of pairs) {
        pair.base ??= takeBase(
          (candidate) => companyTitleKey(candidate) === companyTitleKey(pair.variant),
        );
      }

      for (const { base: baseItem, variant: item } of pairs) {
        if (!baseItem) {
          const added = cleaned(item.bullets);
          bulletsAdded += added.length;
          items.push({
            label: experienceLabel(item),
            status: "added",
            bulletsAdded: added,
            bulletsRemoved: [],
            summaryChanged: false,
          });
          continue;
        }
        const { added, removed } = diffBullets(baseItem.bullets, item.bullets);
        const summaryChanged = baseItem.summary.trim() !== item.summary.trim();
        if (added.length || removed.length || summaryChanged) {
          bulletsAdded += added.length;
          bulletsRemoved += removed.length;
          items.push({
            label: experienceLabel(item),
            status: "changed",
            bulletsAdded: added,
            bulletsRemoved: removed,
            summaryChanged,
          });
        }
      }
      for (const baseItem of basePool) {
        const removed = cleaned(baseItem.bullets);
        bulletsRemoved += removed.length;
        items.push({
          label: experienceLabel(baseItem),
          status: "removed",
          bulletsAdded: [],
          bulletsRemoved: removed,
          summaryChanged: false,
        });
      }
      if (items.length) {
        sections.push({
          ...blank,
          kind: variantSection.kind,
          heading: variantSection.heading,
          status: "changed",
          items,
        });
      }
      continue;
    }

    // The list-ish kinds: education, projects, skills, certifications, custom.
    const { added, removed } = diffBullets(sectionEntries(baseSection), sectionEntries(variantSection));
    if (added.length || removed.length) {
      const parts = [
        added.length ? `+${added.length}` : "",
        removed.length ? `−${removed.length}` : "",
      ].filter(Boolean);
      sections.push({
        ...blank,
        kind: variantSection.kind,
        heading: variantSection.heading,
        status: "changed",
        detail: `${parts.join(" ")} ${added.length + removed.length === 1 ? "entry" : "entries"}`,
      });
    }
  }

  for (const [key, baseSection] of baseSections) {
    // A deleted section that was already hidden changed nothing printed.
    if (!variantSections.has(key) && baseSection.visible) {
      sections.push({
        ...blank,
        kind: baseSection.kind,
        heading: baseSection.heading,
        status: "removed",
      });
    }
  }

  const identical = sections.length === 0 && headerChanged.length === 0;

  const parts: string[] = [];
  if (bulletsAdded) parts.push(`+${bulletsAdded} bullet${bulletsAdded > 1 ? "s" : ""}`);
  if (bulletsRemoved) parts.push(`−${bulletsRemoved} bullet${bulletsRemoved > 1 ? "s" : ""}`);
  if (summaryEdited) parts.push("summary edited");
  const structural = sections.filter(
    (s) => s.status !== "changed" || (s.kind !== "experience" && s.kind !== "summary"),
  ).length;
  if (structural) parts.push(`${structural} section${structural > 1 ? "s" : ""} changed`);
  if (headerChanged.length) parts.push("header edited");

  return {
    identical,
    headerChanged,
    sections,
    bulletsAdded,
    bulletsRemoved,
    summary: identical ? "No changes from base" : parts.join(" · ") || "Minor changes",
  };
}