From 71af5c0392ea59105a83d9d83748f7103a58af0f Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 28 Oct 2026 00:13:15 +0530 Subject: [PATCH 1/5] feat(resume): add scoped refinement and requirement evidence --- __tests__/fit-score-action.test.ts | 55 ++++ __tests__/refine-resume-action.test.ts | 91 ++++++ __tests__/resume-refinement.test.ts | 166 ++++++++++ src/__tests__/fit-score-card.test.tsx | 36 +++ src/__tests__/resume-refinement.test.tsx | 137 ++++++++ src/__tests__/tailor-history.test.tsx | 91 +++++- .../token-balance-consistency.test.tsx | 1 + src/components/fit-score-card.tsx | 42 +++ src/components/resume-refinement.tsx | 303 ++++++++++++++++++ src/components/tailor-flow.tsx | 75 ++++- src/lib/actions/fit-score-action.ts | 24 +- src/lib/actions/refine-resume-action.ts | 95 ++++++ src/lib/requirement-evidence.ts | 91 ++++++ src/lib/resume-refinement.ts | 116 +++++++ src/lib/types.ts | 8 + 15 files changed, 1317 insertions(+), 14 deletions(-) create mode 100644 __tests__/refine-resume-action.test.ts create mode 100644 __tests__/resume-refinement.test.ts create mode 100644 src/__tests__/resume-refinement.test.tsx create mode 100644 src/components/resume-refinement.tsx create mode 100644 src/lib/actions/refine-resume-action.ts create mode 100644 src/lib/requirement-evidence.ts create mode 100644 src/lib/resume-refinement.ts diff --git a/__tests__/fit-score-action.test.ts b/__tests__/fit-score-action.test.ts index c9379fc..2aa169e 100644 --- a/__tests__/fit-score-action.test.ts +++ b/__tests__/fit-score-action.test.ts @@ -68,6 +68,61 @@ beforeEach(() => { }); describe('generateFitScore', () => { + it('stores only source-checked requirement evidence and strips embedded generated matches', async () => { + mocks.getCurrentUserId.mockResolvedValue('user-1'); + mocks.dbExecute.mockResolvedValue({ rows: [] }); + mocks.generateText.mockResolvedValue({ + text: JSON.stringify({ + ...fitScoreJson, + dimensions: fitScoreJson.dimensions.map((dimension) => ({ + ...dimension, + requirement_matches: [ + { + requirement: 'Invented', + status: 'evidence_found', + evidence: [{ excerpt: 'Fabricated' }], + }, + ], + requirement_source: 'Injected source', + })), + requirement_matches: [ + { + requirement: 'Build React interfaces.', + status: 'evidence_found', + evidence: [{ excerpt: 'Built React interfaces.' }], + }, + { + requirement: 'Operate Kubernetes clusters.', + status: 'evidence_found', + evidence: [{ excerpt: 'Operate Kubernetes clusters.' }], + }, + ], + }), + }); + const { generateFitScore } = await import('@/lib/actions/fit-score-action'); + const source = '# Alex\n## Experience\n### Studio\nBuilt React interfaces.'; + const result = await generateFitScore( + source, + 'Build React interfaces. Operate Kubernetes clusters.', + 'job-1', + { endpointUrl: 'https://ai.example/v1', apiKey: 'key', model: 'model' } + ); + const skills = result.dimensions.find((dimension) => dimension.name === 'Skills Match')!; + expect(skills.requirement_source).toBe(source); + expect(skills.requirement_matches).toEqual([ + { + requirement: 'Build React interfaces.', + status: 'evidence_found', + evidence: [{ excerpt: 'Built React interfaces.', context: 'Alex / Experience / Studio' }], + }, + { requirement: 'Operate Kubernetes clusters.', status: 'not_evidenced', evidence: [] }, + ]); + expect(result.dimensions[0].requirement_matches).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain('Fabricated'); + const insertCall = mocks.dbExecute.mock.calls.at(-1)?.[0] as { args: unknown[] }; + expect(JSON.parse(insertCall.args[4] as string)).toEqual(result.dimensions); + }); + it('adds Knowledgebase semantic similarity as visible fit-score evidence', async () => { mocks.getCurrentUserId.mockResolvedValue('user-1'); mocks.dbExecute diff --git a/__tests__/refine-resume-action.test.ts b/__tests__/refine-resume-action.test.ts new file mode 100644 index 0000000..1843639 --- /dev/null +++ b/__tests__/refine-resume-action.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + generate: vi.fn(), + user: vi.fn(), + debit: vi.fn(), + credit: vi.fn(), + execute: vi.fn(), +})); +vi.mock('ai', () => ({ generateObject: mocks.generate })); +vi.mock('@/lib/ai-cloudflare', () => ({ + getAIModel: () => ({}), + getAIModelRetryOptions: () => ({}), +})); +vi.mock('@/lib/auth-utils', () => ({ getCurrentUserId: mocks.user })); +vi.mock('@/lib/actions/token-actions', () => ({ + debitToken: mocks.debit, + creditTokens: mocks.credit, +})); +vi.mock('@/lib/db', () => ({ db: { execute: mocks.execute } })); +import { refineResumeSectionForClient } from '@/lib/actions/refine-resume-action'; + +const master = + '# Alex\n\n## Experience\n### Studio\n- Built APIs.\n- Built React interfaces.\n\n## Projects\n### Reader\n- Built a reading queue.\n\n## Education\nBSc, Example College.\n'; +const input = { + masterSource: master, + currentSource: master.replace( + '## Experience', + '## Summary\nSoftware engineer building APIs and React interfaces for users.\n\n## Experience' + ), + jdText: 'Build React interfaces.', + instruction: 'Lead with frontend work', + target: { kind: 'bullets' as const, groupId: 'g1' }, + aiConfig: { endpointUrl: '', apiKey: '', model: '' }, +}; +beforeEach(() => { + vi.resetAllMocks(); + mocks.user.mockResolvedValue(null); + mocks.debit.mockResolvedValue({ success: true }); + mocks.generate.mockResolvedValue({ object: { ordered_ids: ['g1b2', 'g1b1'] } }); +}); +describe('refinement action boundary', () => { + it('returns a source-preserving guest proposal without cloud writes or billing', async () => { + const result = await refineResumeSectionForClient(input); + expect(result).toMatchObject({ + success: true, + data: { + tailored: input.currentSource.replace( + '- Built APIs.\n- Built React interfaces.', + '- Built React interfaces.\n- Built APIs.' + ), + }, + }); + expect(mocks.execute).not.toHaveBeenCalled(); + expect(mocks.debit).not.toHaveBeenCalled(); + }); + it('validates bounds and current section before billing', async () => { + mocks.user.mockResolvedValue('user'); + expect( + await refineResumeSectionForClient({ ...input, currentSource: 'x'.repeat(30_001) }) + ).toMatchObject({ success: false, retryable: false }); + expect( + await refineResumeSectionForClient({ + ...input, + target: { kind: 'bullets', groupId: 'foreign' }, + }) + ).toMatchObject({ success: false, retryable: false }); + expect(mocks.debit).not.toHaveBeenCalled(); + expect(mocks.generate).not.toHaveBeenCalled(); + }); + it('refunds invalid permutations once and leaves original draft untouched', async () => { + mocks.user.mockResolvedValue('user'); + mocks.generate.mockResolvedValue({ object: { ordered_ids: ['g1b1', 'foreign'] } }); + expect(await refineResumeSectionForClient(input)).toMatchObject({ + success: false, + error: expect.stringContaining('draft is unchanged'), + }); + expect(mocks.debit).toHaveBeenCalledExactlyOnceWith('tailor', 'refine'); + expect(mocks.credit).toHaveBeenCalledExactlyOnceWith('user', 1, 'refund', 'ai_failure'); + }); + it('does not generate or refund when credits are unavailable', async () => { + mocks.user.mockResolvedValue('user'); + mocks.debit.mockResolvedValue({ success: false, error: 'insufficient_tokens' }); + expect(await refineResumeSectionForClient(input)).toMatchObject({ + success: false, + error: 'No tokens remaining. Purchase more to continue.', + }); + expect(mocks.generate).not.toHaveBeenCalled(); + expect(mocks.credit).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/resume-refinement.test.ts b/__tests__/resume-refinement.test.ts new file mode 100644 index 0000000..bce9be8 --- /dev/null +++ b/__tests__/resume-refinement.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { assembleRefinement, prepareRefinement } from '@/lib/resume-refinement'; +import { fitScoreForResume, normalizeRequirementMatches } from '@/lib/requirement-evidence'; +import type { FitScore } from '@/lib/types'; + +const master = + '# Alex\n\n## Experience\n### Studio\n- Built APIs.\n- Built React interfaces.\n\n## Projects\n### Reader\n- Built a reading queue.\n\n### Tasks\n- Built task management tools.\n\n## Education\nBSc, Example College.\n'; +const current = master.replace( + '## Experience', + '## Summary\nSoftware engineer building APIs and React interfaces for users.\n\n## Experience' +); + +describe('scoped resume refinement', () => { + it('moves only selected original bullets and rejects missing, duplicate or foreign IDs', () => { + expect( + assembleRefinement( + master, + current, + { kind: 'bullets', groupId: 'g1' }, + { ordered_ids: ['g1b2', 'g1b1'] } + ).tailored + ).toBe( + current.replace( + '- Built APIs.\n- Built React interfaces.', + '- Built React interfaces.\n- Built APIs.' + ) + ); + for (const ids of [['g1b1'], ['g1b1', 'g1b1'], ['g1b1', 'foreign']]) + expect(() => + assembleRefinement( + master, + current, + { kind: 'bullets', groupId: 'g1' }, + { ordered_ids: ids } + ) + ).toThrow(); + }); + + it('moves intact projects while preserving every other section', () => { + const result = assembleRefinement( + master, + current, + { kind: 'projects', groupId: 'p1' }, + { ordered_ids: ['p1i2', 'p1i1'] } + ); + expect(result.tailored).toBe( + current.replace( + '### Reader\n- Built a reading queue.\n\n### Tasks\n- Built task management tools.', + '### Tasks\n- Built task management tools.\n\n### Reader\n- Built a reading queue.' + ) + ); + }); + + it('refines only summary and does not let generated summary or instructions become facts', () => { + const evidence = prepareRefinement(master, current, { kind: 'summary' }).summary_evidence!; + const text = + 'Software engineer building React interfaces and APIs. Built a reading queue and task management tools.'; + const output = { summary: { text, evidence_ids: evidence.slice(0, 4).map((fact) => fact.id) } }; + expect(assembleRefinement(master, current, { kind: 'summary' }, output).tailored).toBe( + current + .replace('Software engineer building APIs and React interfaces for users.', text) + .replace('## Summary\n', '## Summary\n\n') + ); + const contaminated = master.replace( + '## Experience', + '## Summary\nCertified Kubernetes architect.\n\n## Experience' + ); + expect( + prepareRefinement(contaminated, current, { kind: 'summary' }).summary_evidence + ).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: expect.stringContaining('Kubernetes') }), + ]) + ); + expect(() => + assembleRefinement( + contaminated, + current, + { kind: 'summary' }, + { + summary: { + text: 'Certified Kubernetes architect building React interfaces and APIs for users.', + evidence_ids: ['f2'], + }, + } + ) + ).toThrow(); + }); +}); + +describe('requirement evidence', () => { + const jd = 'Build React interfaces. Operate Kubernetes clusters. Lead a large team.'; + it('hides saved evidence for a different master while preserving legacy scores', () => { + const score: FitScore = { + id: 'fit', + job_id: 'job', + overall_score: 80, + dimensions: [ + { name: 'Skills Match', score: 80, weight: 25, detail: '', requirement_source: master }, + ], + strengths: [], + gaps: [], + recommendation: '', + created_at: 1, + }; + expect(fitScoreForResume(score, master)).toBe(score); + expect(fitScoreForResume(score, master.replace('Built APIs.', 'Built databases.'))).toBeNull(); + const legacy = { ...score, dimensions: [] }; + expect(fitScoreForResume(legacy, current)).toBe(legacy); + }); + it('keeps exact resume quotes and derives attribution from the source', () => { + expect( + normalizeRequirementMatches( + [ + { + requirement: 'Build React interfaces.', + status: 'evidence_found', + evidence: [{ excerpt: 'Built React interfaces.', context: 'Invented employer' }], + }, + { + requirement: 'Operate Kubernetes clusters.', + status: 'evidence_found', + evidence: [{ excerpt: 'Operate Kubernetes clusters.' }], + }, + ], + master, + jd + ) + ).toEqual([ + { + requirement: 'Build React interfaces.', + status: 'evidence_found', + evidence: [{ context: 'Alex / Experience / Studio', excerpt: 'Built React interfaces.' }], + }, + { requirement: 'Operate Kubernetes clusters.', status: 'not_evidenced', evidence: [] }, + ]); + }); + it('rejects invented requirements, malformed output, unknown statuses and duplicate rows', () => { + const match = { + requirement: 'Build React interfaces.', + status: 'partial', + evidence: [{ excerpt: 'Built React interfaces.' }], + }; + expect( + normalizeRequirementMatches( + [ + match, + match, + { ...match, status: 'qualified' }, + { ...match, requirement: 'Fabricated requirement' }, + null, + ], + master, + jd + ) + ).toHaveLength(1); + expect(normalizeRequirementMatches({ matches: [match] }, master, jd)).toEqual([]); + expect( + normalizeRequirementMatches( + [{ ...match, evidence: 'Built React interfaces.' }], + master, + jd + )[0] + ).toMatchObject({ status: 'not_evidenced', evidence: [] }); + }); +}); diff --git a/src/__tests__/fit-score-card.test.tsx b/src/__tests__/fit-score-card.test.tsx index 0f687e9..5e7e458 100644 --- a/src/__tests__/fit-score-card.test.tsx +++ b/src/__tests__/fit-score-card.test.tsx @@ -23,6 +23,42 @@ const mockFitScore: FitScore = { }; describe('FitScoreCard', () => { + it('shows requirement status and original excerpts with an explicit inference limit', async () => { + render( + + ); + await userEvent.click(screen.getByRole('button', { name: /Job Fit Score/ })); + expect(screen.getByRole('region', { name: 'Requirement evidence' })).toHaveTextContent( + 'Built React interfaces.' + ); + expect(screen.getByText('Evidence found')).toBeInTheDocument(); + expect(screen.getByText('Not evidenced in resume')).toBeInTheDocument(); + expect(screen.getByText(/not a lack of ability/)).toBeInTheDocument(); + }); + it('renders overall score and grade', () => { render(); expect(screen.getByText('78')).toBeDefined(); diff --git a/src/__tests__/resume-refinement.test.tsx b/src/__tests__/resume-refinement.test.tsx new file mode 100644 index 0000000..fade11f --- /dev/null +++ b/src/__tests__/resume-refinement.test.tsx @@ -0,0 +1,137 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ResumeRefinement } from '@/components/resume-refinement'; +import { refineResumeSectionForClient } from '@/lib/actions/refine-resume-action'; +import type { ResumeDraft } from '@/lib/resume-refinement'; + +vi.mock('@/lib/actions/refine-resume-action', () => ({ refineResumeSectionForClient: vi.fn() })); +const original: ResumeDraft = { + source: + '# Alex\n\n## Summary\nBuilt APIs and React interfaces.\n\n## Projects\n### Reader\n- Built a reading queue.\n\n## Education\nBSc, Example College.\n', + changes: [ + { snippet: 'Built APIs and React interfaces.', reason: 'Previous summary explanation' }, + { snippet: 'Built a reading queue.', reason: 'Previous project explanation' }, + ], +}; +const refined = original.source.replace( + 'Built APIs and React interfaces.', + 'Built React interfaces and APIs.' +); +const changed = vi.fn(); +const consumed = vi.fn(); +const config = () => ({ endpointUrl: '', apiKey: '', model: '' }); +const props = { + masterSource: original.source, + jdText: 'Build React interfaces.', + isGuest: true, + disabled: false, + hasCredits: true, + aiConfig: config, + onConsumed: consumed, +}; +function Harness() { + const [draft, setDraft] = useState(original); + return ( + { + changed(next); + setDraft(next); + }} + /> + ); +} +function propose() { + fireEvent.click(screen.getByText('Refine one section')); + fireEvent.change(screen.getByLabelText('What should change?'), { + target: { value: 'Lead with frontend work' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Preview refinement' })); +} +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(refineResumeSectionForClient).mockResolvedValue({ + success: true, + data: { tailored: refined, changes: [{ snippet: 'Refined', reason: 'Summary only' }] }, + }); +}); +describe('section refinement review', () => { + it('allows an already paid proposal to be applied after the last token is spent', async () => { + const view = render( + + ); + propose(); + const apply = await screen.findByRole('button', { name: 'Apply refinement' }); + await waitFor(() => expect(apply).not.toBeDisabled()); + view.rerender( + + ); + expect(screen.getByRole('button', { name: 'Preview refinement' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Apply refinement' })).not.toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Apply refinement' })); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('previews without mutation, applies explicitly, and undoes source and explanations together', async () => { + render(); + propose(); + const apply = await screen.findByRole('button', { name: 'Apply refinement' }); + await waitFor(() => expect(apply).not.toBeDisabled()); + expect(screen.getByTitle('Refinement preview').getAttribute('srcdoc')).toContain( + 'Built React interfaces and APIs.' + ); + expect(changed).not.toHaveBeenCalled(); + fireEvent.click(apply); + expect(changed).toHaveBeenLastCalledWith({ + source: refined, + changes: [{ snippet: 'Refined', reason: 'Summary only' }, original.changes[1]], + }); + fireEvent.click(screen.getByRole('button', { name: 'Undo refinement' })); + expect(changed).toHaveBeenLastCalledWith(original); + expect(consumed).toHaveBeenCalledTimes(1); + }); + + it('cannot apply a late proposal after the draft was edited', async () => { + let resolve!: (value: Awaited>) => void; + vi.mocked(refineResumeSectionForClient).mockReturnValue( + new Promise((done) => { + resolve = done; + }) + ); + const view = render(); + propose(); + view.rerender( + + ); + await act(async () => { + resolve({ success: true, data: { tailored: refined, changes: [] } }); + }); + expect(screen.queryByRole('button', { name: 'Apply refinement' })).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('draft changed'); + expect(changed).not.toHaveBeenCalled(); + }); + + it('discards failed proposals without changing the draft', async () => { + vi.mocked(refineResumeSectionForClient).mockResolvedValue({ + success: false, + error: 'Try again safely.', + retryable: true, + }); + render(); + propose(); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Try again safely.')); + expect(changed).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/tailor-history.test.tsx b/src/__tests__/tailor-history.test.tsx index 91f85f8..b949e4d 100644 --- a/src/__tests__/tailor-history.test.tsx +++ b/src/__tests__/tailor-history.test.tsx @@ -1,9 +1,16 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TailorFlow } from '@/components/tailor-flow'; import { tailorResumeForClient } from '@/lib/actions/tailor-action'; +import { refineResumeSectionForClient } from '@/lib/actions/refine-resume-action'; +import { generateFitScoreForClient } from '@/lib/actions/fit-score-action'; import { saveTailoredResume } from '@/lib/actions/job-actions'; -import { localCreateResume, localSaveJob, localGetTailoredResumes } from '@/lib/local-storage'; +import { + localCreateResume, + localSaveJob, + localGetTailoredResumes, + localGetFitScore, +} from '@/lib/local-storage'; import type { JobApplication, Resume } from '@/lib/types'; const auth = vi.hoisted(() => ({ isGuest: true, isPending: false })); vi.mock('@/components/auth-provider', () => ({ useAuth: () => auth })); @@ -11,6 +18,7 @@ vi.mock('@/components/token-balance-provider', () => ({ useTokenBalance: () => ({ balance: 3, refreshBalance: vi.fn() }), })); vi.mock('@/lib/actions/tailor-action', () => ({ tailorResumeForClient: vi.fn() })); +vi.mock('@/lib/actions/refine-resume-action', () => ({ refineResumeSectionForClient: vi.fn() })); vi.mock('@/lib/actions/job-actions', () => ({ saveTailoredResume: vi.fn() })); vi.mock('@/lib/actions/fit-score-action', () => ({ generateFitScoreForClient: vi.fn() })); vi.mock('@/components/fit-score-card', () => ({ FitScoreCard: () => null })); @@ -67,7 +75,7 @@ beforeEach(() => { }); }); function mount() { - render( + return render( { + it('saves an explicitly applied refinement and restores it after reload', async () => { + const next = generated.replace( + 'Software engineer building reading tools', + 'Software engineer shipping reading tools' + ); + vi.mocked(refineResumeSectionForClient).mockResolvedValue({ + success: true, + data: { + tailored: next, + changes: [{ snippet: 'shipping reading tools', reason: 'Summary emphasis' }], + }, + }); + const view = mount(); + fireEvent.click(screen.getByRole('button', { name: 'Generate Tailored Resume' })); + await waitFor(() => expect(localGetTailoredResumes(job.id)).toHaveLength(1)); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Accept & Save' })).not.toBeDisabled() + ); + fireEvent.click(screen.getByText('Refine one section')); + fireEvent.change(screen.getByLabelText('What should change?'), { + target: { value: 'Lead with delivery' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Preview refinement' })); + const apply = await screen.findByRole('button', { name: 'Apply refinement' }); + await waitFor(() => expect(apply).not.toBeDisabled()); + fireEvent.click(apply); + await waitFor(() => + expect(screen.getByTitle('Resume document preview').getAttribute('srcdoc')).toContain( + 'shipping reading tools' + ) + ); + expect(localGetTailoredResumes(job.id)).toHaveLength(1); + const save = screen.getByRole('button', { name: 'Accept & Save' }); + await waitFor(() => expect(save).not.toBeDisabled()); + fireEvent.click(save); + await waitFor(() => expect(localGetTailoredResumes(job.id)).toHaveLength(2)); + expect(localGetTailoredResumes(job.id)[0].source).toBe(next); + view.unmount(); + mount(); + await waitFor(() => + expect(screen.getByTitle('Resume document preview').getAttribute('srcdoc')).toContain( + 'shipping reading tools' + ) + ); + expect(localGetTailoredResumes(job.id)[0].changes[0].reason).toBe('Summary emphasis'); + }); + + it('ignores a fit reply for a resume that is no longer selected', async () => { + const other = localCreateResume('Other master', master.replace('Alex Morgan', 'Jamie Lee')); + let resolve!: (value: Awaited>) => void; + vi.mocked(generateFitScoreForClient).mockReturnValue( + new Promise((done) => { + resolve = done; + }) + ); + mount(); + fireEvent.click(screen.getByRole('button', { name: 'Analyze Job Fit' })); + fireEvent.change(screen.getByLabelText('Base resume'), { target: { value: other } }); + await act(async () => { + resolve({ + success: true, + data: { + id: 'old-fit', + job_id: job.id, + overall_score: 90, + dimensions: [], + strengths: [], + gaps: [], + recommendation: '', + created_at: 1, + }, + }); + }); + expect(localGetFitScore(job.id)).toBeNull(); + expect(screen.getByRole('button', { name: 'Analyze Job Fit' })).not.toBeDisabled(); + }); + it('automatically stores guest generation and does not duplicate an unchanged Save', async () => { mount(); fireEvent.click(screen.getByRole('button', { name: 'Generate Tailored Resume' })); diff --git a/src/__tests__/token-balance-consistency.test.tsx b/src/__tests__/token-balance-consistency.test.tsx index 84db60b..8c54afd 100644 --- a/src/__tests__/token-balance-consistency.test.tsx +++ b/src/__tests__/token-balance-consistency.test.tsx @@ -23,6 +23,7 @@ vi.mock('@/lib/auth-client', () => ({ vi.mock('@/lib/analytics', () => ({ trackSignup: vi.fn() })); vi.mock('@/lib/actions/token-actions', () => ({ getTokenBalance: vi.fn() })); vi.mock('@/lib/actions/tailor-action', () => ({ tailorResumeForClient: vi.fn() })); +vi.mock('@/lib/actions/refine-resume-action', () => ({ refineResumeSectionForClient: vi.fn() })); vi.mock('@/lib/actions/fit-score-action', () => ({ generateFitScoreForClient: vi.fn() })); vi.mock('@/lib/actions/job-actions', () => ({ saveTailoredResume: vi.fn() })); vi.mock('@/components/resume-diff', () => ({ ResumeDiff: () =>
Generated diff
})); diff --git a/src/components/fit-score-card.tsx b/src/components/fit-score-card.tsx index 19134ae..6b4fcec 100644 --- a/src/components/fit-score-card.tsx +++ b/src/components/fit-score-card.tsx @@ -45,11 +45,15 @@ export function FitScoreCard({ fitScore }: FitScoreCardProps) { const [expanded, setExpanded] = useState(false); const color = scoreColor(fitScore.overall_score); const grade = gradeLabel(fitScore.overall_score); + const requirementMatches = fitScore.dimensions.flatMap( + (dimension) => dimension.requirement_matches ?? [] + ); return (
{/* Header */}
{/* Strengths & Gaps */} + {requirementMatches.length > 0 && ( +
+

Requirements and your evidence

+

+ AI-assisted matches. Review the original excerpts. Not evidenced means absent from + this resume, not a lack of ability. +

+
    + {requirementMatches.map((match) => ( +
  • +

    {match.requirement}

    +

    + {match.status === 'evidence_found' + ? 'Evidence found' + : match.status === 'partial' + ? 'Partial evidence' + : 'Not evidenced in resume'} +

    + {match.evidence.map((evidence) => ( +
    +

    {evidence.excerpt}

    +

    {evidence.context}

    +
    + ))} +
  • + ))} +
+
+ )}
{fitScore.strengths.length > 0 && (
diff --git a/src/components/resume-refinement.tsx b/src/components/resume-refinement.tsx new file mode 100644 index 0000000..20f4426 --- /dev/null +++ b/src/components/resume-refinement.tsx @@ -0,0 +1,303 @@ +'use client'; + +import { useRef, useState, useTransition } from 'react'; +import { refineResumeSectionForClient } from '@/lib/actions/refine-resume-action'; +import { markdownToHtml } from '@/lib/resume-html'; +import { + listRefinementTargets, + type RefinementTarget, + type ResumeDraft, +} from '@/lib/resume-refinement'; +import type { AIProviderConfig } from '@/lib/types'; + +type RefinementState = + | { status: 'idle' } + | { status: 'pending'; before: ResumeDraft } + | { status: 'error'; message: string } + | { status: 'ready'; before: ResumeDraft; candidate: ResumeDraft } + | { status: 'applied'; before: ResumeDraft; after: ResumeDraft }; +interface Props { + masterSource: string; + draft: ResumeDraft; + jdText: string; + isGuest: boolean; + disabled: boolean; + hasCredits: boolean; + aiConfig: () => AIProviderConfig; + onChange: (draft: ResumeDraft) => void; + onConsumed: () => void; +} + +async function requestRefinement( + props: Props, + instruction: string, + target: RefinementTarget +): Promise { + const before = props.draft; + try { + const result = await refineResumeSectionForClient({ + masterSource: props.masterSource, + currentSource: before.source, + jdText: props.jdText, + instruction, + target, + aiConfig: props.aiConfig(), + }); + if (!result.success) return { status: 'error', message: result.error }; + if (result.data.tailored === before.source) + return { + status: 'error', + message: + 'The current section already follows this order or wording. No change was applied.', + }; + const normalize = (text: string) => text.replace(/\s+/g, ' ').trim().toLowerCase(); + const candidateSource = normalize(result.data.tailored); + const retainedChanges = before.changes.filter((change) => { + const snippet = normalize(change.snippet); + return snippet.length > 0 && candidateSource.includes(snippet); + }); + return { + status: 'ready', + before, + candidate: { + source: result.data.tailored, + changes: [...result.data.changes, ...retainedChanges].slice(0, 12), + }, + }; + } catch { + return { + status: 'error', + message: 'Could not refine this section. Your current draft is unchanged; try again.', + }; + } finally { + props.onConsumed(); + } +} + +export function ResumeRefinement(props: Props) { + const [state, setState] = useState({ status: 'idle' }); + const [selection, setSelection] = useState('summary'); + const [instruction, setInstruction] = useState(''); + const [pending, startTransition] = useTransition(); + const inFlight = useRef(false); + const options = listRefinementTargets(props.draft.source); + const selected = options.find((option) => option.value === selection) ?? options[0]; + function propose() { + if ( + inFlight.current || + pending || + props.disabled || + !props.hasCredits || + instruction.trim().length < 3 + ) + return; + inFlight.current = true; + setState({ status: 'pending', before: props.draft }); + startTransition(async () => { + try { + setState(await requestRefinement(props, instruction, selected.target)); + } finally { + inFlight.current = false; + } + }); + } + return ( +
+ + Refine one section + +
+

+ Refine the summary or reorder approved points. Achievement wording stays fixed.{' '} + {props.isGuest ? 'Guest draft stays in this browser.' : 'Each proposal uses 1 token.'} +

+ { + setSelection(value); + setState({ status: 'idle' }); + }} + onInstruction={(value) => { + setInstruction(value); + if (!pending) setState({ status: 'idle' }); + }} + /> +
+ + +
+ {!props.hasCredits && ( +

+ No tokens remaining. You can still apply or undo an existing proposal. +

+ )} + +
+
+ ); +} + +function RefinementForm({ + options, + selection, + instruction, + disabled, + onSelection, + onInstruction, +}: { + options: ReturnType; + selection: string; + instruction: string; + disabled: boolean; + onSelection: (value: string) => void; + onInstruction: (value: string) => void; +}) { + return ( + <> +
+ +