From 15e4e381990bb3e86250372e6c5abf220618dc89 Mon Sep 17 00:00:00 2001 From: Ryan Roland Dabao Date: Sun, 19 Jul 2026 01:15:04 +0800 Subject: [PATCH 1/2] docs: reconcile docs to live code and remove dead code (Phase 0) - Rewrite AGENTS.md/README design system to the live 'Field Manual' light theme (the 'Ethereal Glass' dark design was reverted) - Correct tech stack: PostgreSQL-only (no SQLite), Bun/CI-npm, pdfkit note - Fix REMEDIATION_PLAN false claims (download route is 53 lines, not 879; SQLite) - Mark REDESIGN-PLAN.md as superseded - Delete truly-dead code (zero references): browser-llm-integration.ts (338-line unused fake rule-based AI) - NOTE: UpgradeModal/SubscriptionBanner/pricing.ts are compile-time shims still imported by components; their removal is deferred to Phase 3/4 (entitlement + component refactor), not Phase 0. - Add FIX-PLAN.md tracking the full SOLID/UI remediation roadmap --- AGENTS.md | 19 +- FIX-PLAN.md | 133 ++++++++++++ README.md | 41 ++-- REDESIGN-PLAN.md | 2 +- REMEDIATION_PLAN.md | 6 +- src/lib/browser-llm-integration.ts | 338 ----------------------------- 6 files changed, 168 insertions(+), 371 deletions(-) create mode 100644 FIX-PLAN.md delete mode 100644 src/lib/browser-llm-integration.ts diff --git a/AGENTS.md b/AGENTS.md index 463bbf6..7de0610 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,9 @@ |-------|-----------|-------| | **Framework** | Next.js 16.1.1 (App Router) | Standalone output | | **Runtime** | Bun | Package management + scripts | -| **Database** | Prisma v6 + SQLite (dev) / PostgreSQL (prod) | Local dev uses SQLite | +| **Database** | Prisma v6 + PostgreSQL | PostgreSQL only (no SQLite) | | **Auth** | JWT (jose v6) + HttpOnly cookies + localStorage client cache | Custom | -| **UI** | Tailwind CSS v4 | Custom glass design system | +| **UI** | Tailwind CSS v4 | "Field Manual" light design system | | **Icons** | Phosphor Icons (light weight) | No thick-stroked icons | | **Animation** | Framer Motion v12 | Scroll reveals, stagger, hover physics | | **Fonts** | Space Grotesk (headings) + Plus Jakarta Sans (body) | No Inter/Roboto | @@ -27,15 +27,18 @@ | **Export** | docx, PDF (pdfkit), Excel (exceljs) | Resource downloads | | **Testing** | Vitest | unit + API + component tests | -## Design System — Ethereal Glass +## Design System — "Field Manual" (light theme) + +> NOTE: The "Ethereal Glass" dark design previously described here was reverted. The live app uses the light "Field Manual" system below. | Element | Spec | |---------|------| -| **Background** | OLED black (`#050505`) with radial gradient orbs | -| **Cards** | Glass-morphism with `backdrop-blur-xl` | -| **Accents** | Indigo/violet, emerald (success), amber (warning), rose (danger) | -| **Motion** | Custom cubic-bezier: `ease-premium`, `ease-spring`, `ease-out-heavy` | -| **Components** | Double-bezel glass cards, pill CTAs with trailing icon pattern | +| **Background** | Warm off-white (`#FAFAF7`) with subtle grain/border texture | +| **Cards** | `FieldCard` — solid surface, thin border, soft shadow, no glass blur | +| **Accents** | Primary orange (`#FF6B35`), ink scale for text, emerald (success), amber (warning), rose (danger) | +| **Motion** | Framer Motion scroll reveals, stagger, hover physics (no custom cubic-beziers) | +| **Components** | `FieldButton` (variants: default/outline/ghost), `FieldCard`, `FieldBadge`, `Alert` | +| **Tokens** | Defined in `globals.css` + Tailwind config (CSS variables, not arbitrary values) | ## Code Organization diff --git a/FIX-PLAN.md b/FIX-PLAN.md new file mode 100644 index 0000000..28f7c9c --- /dev/null +++ b/FIX-PLAN.md @@ -0,0 +1,133 @@ +# Interview Lab — Thorough Fix Plan + +> Audited 2026-07-19. A sequenced, merge-ready plan. Each phase: **goal → files → changes → verification**. +> Phases are independently shippable; **Phase 0 must land first**. + +--- + +## Phase 0 — Reconcile Reality & Delete Dead Code +*Why first: docs/architecture are wrong. Fix the map before the journey.* + +### 0.1 Documentation truth-up +**Files:** `AGENTS.md`, `README.md`, `REDESIGN-PLAN.md`, `REMEDIATION_PLAN.md`, `docs/architecture.md` +- Rewrite AGENTS.md "Design System" section: replace *"Ethereal Glass / OLED black / sky `#007EFF`"* with the live **"Field Manual" light theme** (`#FAFAF7` bg, `#FF6B35` accent, `FieldCard`/`FieldButton`). +- README Tech Stack: change `UI` row to "Tailwind v4 + Field Manual design system"; fix `Export` row ("pdfkit" → "pdfkit + manual PDF builder" or remove pdfkit after Phase 3). +- `REMEDIATION_PLAN.md`: delete false claim that `downloads/[id]/route.ts` is 879 lines (it's 53). +- Mark `REDESIGN-PLAN.md` as **superseded** (glass redesign was reverted) — or delete it. + +### 0.2 Delete dead code +| File | Reason | +|------|--------| +| `src/lib/browser-llm-integration.ts` (338 lines) | Not imported anywhere; fake rule-based AI | +| `src/components/interview-lab/UpgradeModal.tsx` | Stub, no-op | +| `src/components/interview-lab/SubscriptionBanner.tsx` | Stub, no-op | +| `src/lib/pricing.ts` (tier config) — *optional* | Only powers stubs; keep if PricingPage ever real | + +**Verify:** `grep -r "browser-llm-integration\|UpgradeModal\|SubscriptionBanner" src` → no results; `bun run build` passes. + +--- + +## Phase 1 — SOLID: AI Layer Refactor (SRP + OCP + DIP) +*Highest leverage: 4 routes share ~75-line duplicated JSON-extract loop, each calls the SDK directly.* + +### 1.1 `src/lib/ai/client.ts` — AIProvider abstraction (DIP) +```ts +export interface AIProvider { + complete(system: string, user: string, opts?: { schema?: ZodSchema; signal?: AbortSignal }): Promise; +} +export class ZAIProvider implements AIProvider { /* timeout 30s, abort, retry-once */ } +export const ai: AIProvider = new ZAIProvider(); +``` +- Add `AbortSignal.timeout(30000)` + `try/catch` around `ZAI.create().chat.completions.create`. + +### 1.2 `src/lib/ai/handlers.ts` — `createAIHandler` factory (OCP) +```ts +export function createAIHandler(buildPrompt, schema: ZodSchema) { + return async (req) => { /* auth → call ai.complete with schema → validate → return */ }; +} +``` +- Centralize `extractJsonArray`/`extractJsonObject` parsing into tested util `src/lib/ai/json.ts`. + +### 1.3 Split routes +- `src/lib/ai/coach.ts`, `resume.ts`, `cover-letter.ts`, `assessment.ts` — each exports `buildPrompt()` + a zod `schema`. +- Refactor `src/app/api/ai/{coach,resume-review,cover-letter,assessment-score}/route.ts` to ~15 lines each calling `createAIHandler`. + +### 1.4 Add zod schemas +- `src/lib/ai/schemas.ts`: `CoachFeedbackSchema`, `ResumeReviewSchema`, `CoverLetterSchema`, `AssessmentScoreSchema` (replaces runtime `as` casts). + +**Verify:** `__tests__/ai/client.test.ts` (mock `z-ai-web-dev-sdk`), `handlers.test.ts` (valid + malformed JSON → 500), `bun run test`. + +--- + +## Phase 2 — SOLID: Export Layer (SRP) +### 2.1 `src/lib/export/docx.ts` +- Move `generateDocx` out of `export/route.ts` into its own module. + +### 2.2 `src/lib/export/pdf.ts` — replace hand-rolled byte builder +- Use already-installed `pdfkit` (currently unused). Fixes **silent truncation** (`if (y < 50) break`) by paginating. +- Add `content` size guard (reject > 50k chars) to prevent abuse. + +### 2.3 `src/app/api/export/route.ts` +- Becomes ~12 lines: auth → dispatch to `docx.ts` / `pdf.ts`. + +**Verify:** `export.test.ts` generates real .docx/.pdf, asserts multi-page content isn't truncated. + +--- + +## Phase 3 — SOLID: Entitlement Honesty (LSP) +### 3.1 `src/lib/subscription/entitlement.ts` +```ts +export interface EntitlementService { canAccess(feature): boolean; tier: string; } +export class FreeEntitlement implements EntitlementService { canAccess() { return true; } } +``` +- `subscription-guard.ts` returns real interface; `use-subscription.ts` returns `FreeEntitlement` honestly (remove fake `-1`/no-op that *implies* gating). + +**Verify:** `subscription.test.ts`. + +--- + +## Phase 4 — Component Decomposition (SRP) +### 4.1 `MockInterview.tsx` (618 →) +`src/components/interview-lab/mock-interview/{Setup,ActiveSession,Complete,useInterview}.tsx` + +### 4.2 Standardize error/empty states +- `ResumeLab` raw red `
` → `Alert` component (already built in `ui/`). +- Add skeletons to `MockInterview`/`ResumeLab` to match `DashboardView`. + +### 4.3 A11y fix +- `QuestionBank`: remove nested interactive (inner "Practice" button inside `role="button"` div) → make card a real `
` with a separate button. + +**Verify:** `bun run lint`, component render tests. + +--- + +## Phase 5 — Infra & Correctness +### 5.1 Rate limiter (`middleware.ts`) +- Replace in-memory `Map` with **Upstash Redis** (`@upstash/ratelimit`) so it works across Vercel serverless. +- Trust `x-forwarded-for` only behind Vercel proxy; add `x-vercel-ip` fallback. + +### 5.2 Font drift (`globals.css`) +- `JetBrains Mono`/`Inter` declared but unused → align to Space Grotesk + Plus Jakarta Sans (per spec) or update spec. + +### 5.3 `db.ts` +- Gate `log: ['query']` behind explicit `LOG_QUERIES=1` env (confirm off in prod). + +--- + +## Effort & Sequencing + +| Phase | Work | Est. | Risk | +|-------|------|------|------| +| 0 | Doc truth-up + dead-code deletion | 1 day | 🟢 none | +| 1 | AI layer SOLID refactor + zod | 4 days | 🟡 medium (test coverage needed) | +| 2 | Export layer split + pdfkit | 1 day | 🟡 medium | +| 3 | Entitlement honesty | 1 day | 🟢 low | +| 4 | Component decomposition + UI polish | 4 days | 🟡 medium | +| 5 | Rate limiter + fonts + db | 2 days | 🟡 medium | + +**Total: ~13 dev-days.** Phases are independently shippable; Phase 0 must land first. + +--- + +## Suggested first PR +**"Phase 0: Reconcile docs & remove dead code"** — smallest diff, zero behavioral risk, unblocks everything. diff --git a/README.md b/README.md index aaa8fc2..0f40f1a 100644 --- a/README.md +++ b/README.md @@ -37,14 +37,14 @@ for aspiring Amazon Virtual Assistants. | Layer | Technology | |-------|-----------| | **Framework** | Next.js 16.1.1 (App Router, standalone) | -| **Runtime** | Bun | -| **Database** | Prisma v6 + SQLite (dev) / PostgreSQL (prod) | +| **Runtime** | Bun (CI uses npm) | +| **Database** | Prisma v6 + PostgreSQL (PostgreSQL only) | | **Auth** | Custom JWT sessions (jose) + HttpOnly cookies | -| **UI** | Tailwind CSS v4 + custom glass design system | +| **UI** | Tailwind CSS v4 + "Field Manual" light design system | | **Icons** | Phosphor Icons (light weight) | | **Fonts** | Space Grotesk (headings) + Plus Jakarta Sans (body) | | **AI** | Z AI Web Dev SDK for coaching, scoring, content generation | -| **Export** | docx, PDF (pdfkit), Excel (exceljs) | +| **Export** | docx, PDF (manual builder / pdfkit), Excel (exceljs) | ## 🚀 Getting Started @@ -90,7 +90,7 @@ src/ │ │ ├── assessments/ # Practice test assessments │ │ ├── profile/ # User profile (GET/PUT) │ │ ├── dashboard/ # Dashboard aggregation -│ │ ├── subscription/ # Checkout, status, usage, webhook +│ │ ├── subscription/ # Removed (product is free) │ │ ├── resume/ # Resume upload + AI review │ │ ├── cover-letter/ # Cover letter generation │ │ ├── ai/ # AI endpoints (coach, scoring, resume, cover) @@ -100,17 +100,17 @@ src/ │ │ └── export/ # DOCX/PDF/Excel export │ └── [pages]/ # Page routes ├── components/ -│ ├── interview-lab/ # Page components (glass design system) -│ └── ui/ # Shared primitives (glass-card, glass-button, etc.) +│ ├── interview-lab/ # Page components (Field Manual design system) +│ └── ui/ # Shared primitives (field-card, field-button, etc.) ├── lib/ │ ├── auth-helpers.ts # Server-side JWT + header auth -│ ├── pricing.ts # Tier configs (Free/Starter/Pro) and limits -│ ├── subscription-guard.ts # Feature access checks per tier +│ ├── pricing.ts # Tier configs (currently unused — product is free) +│ ├── subscription-guard.ts # Feature access checks (returns allowed: true) │ ├── types.ts # TypeScript interfaces │ └── utils.ts # cn() utility └── hooks/ # Custom React hooks -prisma/ # Prisma schema (SQLite/PostgreSQL) +prisma/ # Prisma schema (PostgreSQL) __tests__/ # Test suites ├── api/ # API route unit tests (218 tests) │ ├── interview-session.test.ts @@ -133,7 +133,7 @@ docs/ # PRD, architecture, feature specs bun test # Run specific test file -bun test __tests__/api/subscription.test.ts +bun test __tests__/api/resume-coverletter.test.ts # Run API tests only bun test __tests__/api/ @@ -149,9 +149,8 @@ bun test __tests__/api/ | `auth-login.test.ts` | 26 | Login flow, rate limiting, session, email sanitization | | `assessments.test.ts` | 20 | Assessment list/get/submit, answer key stripping | | `profile-dashboard.test.ts` | 23 | Profile whitelisting, sanitization, dashboard stats | -| `subscription.test.ts` | 31 | Pricing logic, checkout, tier validation, payments | | `resume-coverletter.test.ts` | 25 | Resume CRUD, cover-letter tones, truth flags | -| **Total** | **218** | **All API routes, all green** | +| **Total** | **187** | **All API routes, all green** | Tests use in-memory stubs — no database or server required. @@ -161,19 +160,19 @@ Tests use in-memory stubs — no database or server required. |---------|-------------| | `bun run dev` | Dev server with logging | | `bun run build` | Production build (standalone) | -| `bun run db:push` | Push Prisma schema to SQLite | +| `bun run db:push` | Push Prisma schema to PostgreSQL | | `bun run db:generate` | Generate Prisma client | | `bun run test` | Run test suite | | `bun run lint` | ESLint check | -## 🎨 Design System — Ethereal Glass +## 🎨 Design System — "Field Manual" (light theme) -The app uses a premium dark-mode glass aesthetic: +> The earlier "Ethereal Glass" dark design was reverted. The live app uses a clean light "Field Manual" system. -- **Palette:** OLED black (`#050505`) with radial gradient orbs, glass-morphism cards, `backdrop-blur-xl` -- **Colors:** Indigo/violet accents, emerald success, amber warnings, rose danger -- **Motion:** Custom cubic-bezier curves, Framer Motion scroll reveals, staggered animations -- **Components:** Double-bezel glass cards, pill CTAs, glass inputs/badges +- **Palette:** Warm off-white (`#FAFAF7`) with subtle grain/border texture, primary orange (`#FF6B35`) accents +- **Colors:** Ink scale for text, emerald success, amber warnings, rose danger +- **Motion:** Framer Motion scroll reveals, stagger, hover physics (no custom cubic-beziers) +- **Components:** `FieldCard` (solid surface, thin border, soft shadow), `FieldButton` (default/outline/ghost), `FieldBadge`, `Alert` - **Icons:** Phosphor Icons in `weight="light"` — no thick-stroked icons - **Typography:** Space Grotesk for headings, Plus Jakarta Sans for body @@ -181,7 +180,7 @@ The app uses a premium dark-mode glass aesthetic: | Variable | Description | |----------|-------------| -| `DATABASE_URL` | SQLite connection string (`file:./dev.db`) or PostgreSQL URL | +| `DATABASE_URL` | PostgreSQL connection URL | | `JWT_SECRET` | Secret for signing JWT session tokens | | `NEXT_PUBLIC_APP_URL` | App base URL | diff --git a/REDESIGN-PLAN.md b/REDESIGN-PLAN.md index 4cdf9a0..3cc78e8 100644 --- a/REDESIGN-PLAN.md +++ b/REDESIGN-PLAN.md @@ -1,6 +1,6 @@ # Redesign Plan — Interview Lab -> **Status:** All phases implemented. See git history for details. +> **Status:** SUPERSEDED. The "Ethereal Glass" dark design described here was reverted in favor of the live light "Field Manual" design system (see `AGENTS.md` Design System section). Kept for historical reference only. ## Design System — Ethereal Glass diff --git a/REMEDIATION_PLAN.md b/REMEDIATION_PLAN.md index e78da38..f9056bf 100644 --- a/REMEDIATION_PLAN.md +++ b/REMEDIATION_PLAN.md @@ -87,8 +87,8 @@ Interview Lab is a **free companion** to [Project Amazon PH Academy](https://pro - Model assessment attempts properly (user, timestamps, answers, score, rubrics, status, AI version) ### P2.3 — Download route decomposition -**Files:** `src/app/api/downloads/[id]/route.ts` (879 lines) -**Problem:** Monolithic route handles auth, tier checks, 4 document formats, database access, and analytics. +**Files:** `src/app/api/downloads/[id]/route.ts` (53 lines — earlier "879-line" estimate was inaccurate) +**Problem:** Route handles auth, tier checks, multiple document formats, database access, and analytics. **Fix:** - Extract document builders: `src/lib/documents/pdf.ts`, `docx.ts`, `xlsx.ts`, `text.ts` - Extract template renderers: `src/lib/templates/amazon-training.ts` @@ -112,7 +112,7 @@ Interview Lab is a **free companion** to [Project Amazon PH Academy](https://pro - Add browser tests for critical user journeys ### P2.6 — Operational documentation -**Problem:** README documents Bun runtime but CI uses npm; describes SQLite but schema is PostgreSQL; no standalone output config. +**Problem:** README previously documented Bun runtime but CI uses npm; described SQLite but schema is PostgreSQL. (README corrected 2026-07-19; standalone output already in `next.config.ts`.) **Fix:** - Standardize on one package manager (npm, given CI/Vercel use it) - Update README to reflect PostgreSQL-only schema diff --git a/src/lib/browser-llm-integration.ts b/src/lib/browser-llm-integration.ts deleted file mode 100644 index 019863a..0000000 --- a/src/lib/browser-llm-integration.ts +++ /dev/null @@ -1,338 +0,0 @@ -"use client"; - -import { toast } from '@/hooks/use-toast'; - -// Browser-based LLM integration that works without API keys -// Uses local LLM fallback and rule-based system for Amazon VA career guidance - -// Extend Window type to support the experimental window.ai API -declare global { - interface Window { - ai?: { - models?: { - available(): Promise<{ name: string }[]>; - }; - generateText?(model: { name: string }, options: { prompt: string; max_tokens?: number; temperature?: number }): Promise<{ text: string }>; - }; - } -} - -interface LLMResponse { - score?: number; - missingKeywords?: string[]; - weakSections?: string[]; - improvedSummary?: string; - improvedBullets?: string[]; - skillsRecommendations?: string[]; - truthWarnings?: string[]; - improvedVersion?: string; - draftLetter?: string; - shorterVersion?: string; - subjectLine?: string; - customizationTips?: string[]; - claimsToVerify?: string[]; - correctDecisions?: string[]; - incorrectDecisions?: string[]; - missedOpportunities?: string[]; - recommendedNextStep?: string; - modelAnswer?: string; -} - -class BrowserLLMIntegration { - private static instance: BrowserLLMIntegration; - private conversationHistory: { role: 'system' | 'user' | 'assistant'; content: string }[] = []; - - private constructor() { - this.initializeSystemPrompt(); - } - - public static getInstance(): BrowserLLMIntegration { - if (!BrowserLLMIntegration.instance) { - BrowserLLMIntegration.instance = new BrowserLLMIntegration(); - } - return BrowserLLMIntegration.instance; - } - - private initializeSystemPrompt() { - this.conversationHistory = [ - { - role: 'system', - content: `You are an Amazon VA career preparation assistant. Your job is to help users prepare for Amazon marketplace virtual assistant roles, especially Amazon PPC, Seller Central support, listing support, reporting, and agency operations roles. You must be practical, honest, and role-specific. Help users explain their real skills clearly without exaggerating or fabricating experience. Never guarantee job placement, interview success, ranking results, ACoS improvement, or Amazon account outcomes.` - } - ]; - } - - private isLocalAvailable(): boolean { - // Check if we're in a browser environment - return typeof window !== 'undefined' && typeof window.ai !== 'undefined'; - } - - public async generateResponse( - prompt: string, - responseType: 'resume' | 'assessment' | 'cover-letter' - ): Promise { - try { - // First try local browser AI if available - if (this.isLocalAvailable()) { - try { - const response = await this.callLocalAI(prompt); - if (response) { - return this.parseResponse(response, responseType); - } - } catch (error) { - console.warn('Local AI not available, falling back to rule-based system:', error); - } - } - - // Fall back to rule-based system - return this.ruleBasedResponse(prompt, responseType); - - } catch (error) { - console.error('LLM integration error:', error); - toast({ - title: "AI Service Temporarily Unavailable", - description: "Using enhanced rule-based system for your request.", - variant: "default", - }); - return this.ruleBasedResponse(prompt, responseType); - } - } - - private async callLocalAI(prompt: string): Promise { - // Try to use browser AI API if available - if (typeof window !== 'undefined' && window.ai?.models?.available()) { - const availableModels = await window.ai.models.available(); - if (availableModels.length > 0) { - const model = availableModels[0]; - const generateText = window.ai!.generateText; - if (!generateText) return null; - const response = await generateText(model, { - prompt: `${this.getConversationContext()}\n\nUser Request: ${prompt}`, - max_tokens: 1000, - temperature: 0.3, - }); - return response.text; - } - } - return null; - } - - private getConversationContext(): string { - return this.conversationHistory - .slice(1) // Exclude system prompt - .map(msg => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`) - .join('\n\n'); - } - - private parseResponse(response: string, responseType: string): LLMResponse { - try { - // Try to parse JSON response - const jsonMatch = response.match(/\{[\s\S]*\}/); - if (jsonMatch) { - const parsed = JSON.parse(jsonMatch[0]); - return parsed; - } - - // If no valid JSON, create structured response based on responseType - return this.fallbackResponse(response, responseType); - - } catch (error) { - return this.fallbackResponse(response, responseType); - } - } - - private ruleBasedResponse(prompt: string, responseType: string): LLMResponse { - const lowerPrompt = prompt.toLowerCase(); - - switch (responseType) { - case 'resume': - return this.generateResumeResponse(lowerPrompt); - case 'assessment': - return this.generateAssessmentResponse(lowerPrompt); - case 'cover-letter': - return this.generateCoverLetterResponse(lowerPrompt); - default: - return this.generateGenericResponse(lowerPrompt); - } - } - - private generateResumeResponse(prompt: string): LLMResponse { - const score = this.calculateResumeScore(prompt); - const missingKeywords = this.extractMissingKeywords(prompt); - const weakSections = this.extractWeakSections(prompt); - - return { - score, - missingKeywords, - weakSections, - improvedSummary: this.generateImprovedSummary(prompt), - improvedBullets: this.generateImprovedBullets(prompt), - skillsRecommendations: this.generateSkillsRecommendations(prompt), - truthWarnings: this.generateTruthWarnings(prompt), - improvedVersion: this.generateImprovedVersion(prompt), - }; - } - - private generateAssessmentResponse(prompt: string): LLMResponse { - const score = this.calculateAssessmentScore(prompt); - const correctDecisions = this.extractCorrectDecisions(prompt); - const incorrectDecisions = this.extractIncorrectDecisions(prompt); - - return { - score, - correctDecisions, - incorrectDecisions, - missedOpportunities: this.generateMissedOpportunities(prompt), - recommendedNextStep: this.generateRecommendedNextStep(prompt), - modelAnswer: this.generateModelAnswer(prompt), - }; - } - - private generateCoverLetterResponse(prompt: string): LLMResponse { - return { - draftLetter: this.generateCoverLetterDraft(prompt), - shorterVersion: this.generateShorterVersion(prompt), - subjectLine: this.generateSubjectLine(prompt), - customizationTips: this.generateCustomizationTips(prompt), - claimsToVerify: this.generateClaimsToVerify(prompt), - }; - } - - private calculateResumeScore(prompt: string): number { - if (prompt.includes('amazon') && prompt.includes('ppc') && prompt.includes('seller')) { - return 85; - } else if (prompt.includes('amazon') && prompt.includes('experience')) { - return 70; - } - return 40; - } - - private extractMissingKeywords(prompt: string): string[] { - const keywords = ['Seller Central', 'PPC reporting', 'ACoS', 'ROAS', 'search term reports']; - const foundKeywords = keywords.filter(keyword => - prompt.includes(keyword.toLowerCase()) - ); - return keywords.filter(keyword => !foundKeywords.includes(keyword)); - } - - private extractWeakSections(prompt: string): string[] { - const sections: string[] = []; - if (!prompt.includes('metrics') && !prompt.includes('results')) sections.push('Metrics and outcomes'); - if (!prompt.includes('ammazon') && !prompt.includes('va')) sections.push('Professional Summary'); - if (!prompt.includes('campaigns') && !prompt.includes('structure')) sections.push('Campaign Structure'); - return sections.slice(0, 2); - } - - // Helper methods for generating responses - private generateImprovedSummary(prompt: string): string { - return 'Detail-oriented Virtual Assistant with training in Amazon Seller Central support, PPC reporting, keyword research workflows, and client communication. Familiar with Amazon PPC metrics including ACoS, ROAS, CTR, CPC, and CVR.'; - } - - private generateImprovedBullets(prompt: string): string[] { - return [ - 'Reviewed Amazon PPC search term reports and flagged high-click, zero-sale terms for negative keyword review', - 'Prepared weekly PPC performance summaries using spend, sales, ACoS, ROAS, and conversion data' - ]; - } - - private generateSkillsRecommendations(prompt: string): string[] { - return ['Amazon Seller Central', 'Amazon Ads Console', 'PPC Reporting', 'Keyword Research', 'Search Term Report Analysis']; - } - - private generateTruthWarnings(prompt: string): string[] { - return []; - } - - private generateImprovedVersion(prompt: string): string { - return prompt; - } - - private calculateAssessmentScore(prompt: string): number { - if (prompt.includes('metrics') && prompt.includes('action') && prompt.includes('data')) { - return 85; - } else if (prompt.includes('data') && prompt.includes('risk')) { - return 70; - } - return 50; - } - - private extractCorrectDecisions(prompt: string): string[] { - return ['You attempted the exercise']; - } - - private extractIncorrectDecisions(prompt: string): string[] { - return []; - } - - private generateMissedOpportunities(prompt: string): string[] { - return ['Try to include more specific metrics and reasoning']; - } - - private generateRecommendedNextStep(prompt: string): string { - return 'Review the relevant guide and try again'; - } - - private generateModelAnswer(prompt: string): string { - return 'Please review the assessment data and provide detailed analysis with specific metrics and action recommendations.'; - } - - private generateCoverLetterDraft(prompt: string): string { - const roleMatch = prompt.match(/role:\s*(.*?)(?:\n|$)/i); - const targetRole = roleMatch ? roleMatch[1] : 'Amazon VA'; - - return `Dear Hiring Manager, - -I am applying for the ${targetRole} role. I have a strong interest in Amazon marketplace operations and have been training in PPC workflows, search term report review, campaign organization, keyword research, and performance reporting. - -I understand the importance of tracking key metrics such as spend, sales, ACoS, ROAS, CTR, CPC, orders, and conversion rate. I can support a team by preparing reports, reviewing search terms, flagging high-click zero-sale keywords, documenting optimization actions, and following campaign naming and QA checklists carefully. - -I am comfortable working with SOPs, Google Sheets, ClickUp, and Amazon-related tools. I value accuracy, clear communication, and consistent documentation. - -Thank you for considering my application. - -Sincerely, -[Your Name]`; - } - - private generateShorterVersion(prompt: string): string { - const roleMatch = prompt.match(/role:\s*(.*?)(?:\n|$)/i); - const targetRole = roleMatch ? roleMatch[1] : 'Amazon VA'; - - return `Hi, I am applying for the ${targetRole} role. I have trained in Amazon PPC workflows, search term reports, and client communication. I can support your team with organized reporting, keyword analysis, and SOP-driven task execution.`; - } - - private generateSubjectLine(prompt: string): string { - const roleMatch = prompt.match(/role:\s*(.*?)(?:\n|$)/i); - const targetRole = roleMatch ? roleMatch[1] : 'Amazon VA'; - - return `Application for ${targetRole} Position`; - } - - private generateCustomizationTips(prompt: string): string[] { - return ['Add specific tool experience if you have it', 'Mention any relevant training or certifications']; - } - - private generateClaimsToVerify(prompt: string): string[] { - return ['Verify any tool familiarity mentioned']; - } - - private generateGenericResponse(prompt: string): LLMResponse { - return { - score: 50, - missingKeywords: ['Amazon', 'PPC', 'Seller Central'], - weakSections: ['Professional Summary'], - improvedSummary: 'Amazon-focused Virtual Assistant with training in PPC workflows, reporting, and client communication.', - improvedBullets: [], - skillsRecommendations: ['Amazon Seller Central', 'PPC Reporting'], - truthWarnings: [], - improvedVersion: prompt, - }; - } - - private fallbackResponse(text: string, responseType: string): LLMResponse { - console.log('Using fallback response for:', responseType); - return this.ruleBasedResponse(text, responseType); - } -} - -export { BrowserLLMIntegration }; \ No newline at end of file From 53c6f39357c89991034f59706f425d759816da02 Mon Sep 17 00:00:00 2001 From: Ryan Roland Dabao Date: Sun, 19 Jul 2026 01:29:14 +0800 Subject: [PATCH 2/2] refactor(ai): SOLID refactor of AI layer (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared AI logic out of the 4 route handlers into a single tested abstraction, fixing SRP/OCP/DIP violations: - src/lib/ai/json.ts: central JSON extraction from free-text LLM responses - src/lib/ai/client.ts: AIProvider interface + ZAIProvider with 30s timeout and abort support (swap provider without touching routes — DIP) - src/lib/ai/handlers.ts: createAIHandler factory centralizes auth, input validation, model call, parsing, and error handling (OCP: add a feature by adding a config, not copying a route) - src/lib/ai/{coach,resume,cover-letter,assessment}.ts: per-feature prompt + validation + fallback configs (SRP) Routes shrink to single-line exports. Behavior is preserved exactly: coach/cover-letter return graceful fallbacks on parse failure; resume/ assessment return 500. No new dependencies (validateShape replaces zod). Tests: add 17 unit tests for json.ts + handlers.ts (all passing). tsc --noEmit and eslint clean. --- __tests__/unit/ai-handlers.test.ts | 114 +++++++++++++++++++ __tests__/unit/ai-json.test.ts | 40 +++++++ src/app/api/ai/assessment-score/route.ts | 82 +------------- src/app/api/ai/coach/route.ts | 134 +---------------------- src/app/api/ai/cover-letter/route.ts | 85 +------------- src/app/api/ai/resume-review/route.ts | 85 +------------- src/lib/ai/assessment.ts | 57 ++++++++++ src/lib/ai/client.ts | 90 +++++++++++++++ src/lib/ai/coach.ts | 127 +++++++++++++++++++++ src/lib/ai/cover-letter.ts | 62 +++++++++++ src/lib/ai/handlers.ts | 96 ++++++++++++++++ src/lib/ai/json.ts | 35 ++++++ src/lib/ai/resume.ts | 56 ++++++++++ 13 files changed, 689 insertions(+), 374 deletions(-) create mode 100644 __tests__/unit/ai-handlers.test.ts create mode 100644 __tests__/unit/ai-json.test.ts create mode 100644 src/lib/ai/assessment.ts create mode 100644 src/lib/ai/client.ts create mode 100644 src/lib/ai/coach.ts create mode 100644 src/lib/ai/cover-letter.ts create mode 100644 src/lib/ai/handlers.ts create mode 100644 src/lib/ai/json.ts create mode 100644 src/lib/ai/resume.ts diff --git a/__tests__/unit/ai-handlers.test.ts b/__tests__/unit/ai-handlers.test.ts new file mode 100644 index 0000000..57f5c08 --- /dev/null +++ b/__tests__/unit/ai-handlers.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the auth helper so we control whether a user is present. +const getUserFromRequest = vi.fn(); +vi.mock('@/lib/auth-helpers', () => ({ + getUserFromRequest: (request: Request) => getUserFromRequest(request), +})); + +// Mock the model call so we never hit the real SDK. +const completeJson = vi.fn(); +vi.mock('@/lib/ai/client', () => ({ + completeJson: (...args: unknown[]) => completeJson(...args), +})); + +import { createAIHandler, validateShape } from '@/lib/ai/handlers'; + +interface SampleBody { + text: string; +} +interface SampleResult { + score: number; +} + +function makeConfig() { + return { + systemPrompt: 'sys', + buildUserPrompt: (body: SampleBody) => `user: ${body.text}`, + validate: (body: unknown) => { + const shape = validateShape(body, ['text']); + return shape.ok + ? ({ ok: true, value: body as SampleBody } as const) + : ({ ok: false, status: 400, error: 'text required' } as const); + }, + onParseFailure: () => ({ ok: false, status: 500, error: 'parse failed' } as const), + }; +} + +function postReq(body: unknown) { + return { + json: async () => body, + } as unknown as Request; +} + +describe('createAIHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + getUserFromRequest.mockResolvedValue({ id: 'u1' }); + }); + + it('returns 401 when no user', async () => { + getUserFromRequest.mockResolvedValue(null); + const handler = createAIHandler(makeConfig()); + const res = await handler(postReq({ text: 'hi' })); + expect(res.status).toBe(401); + }); + + it('returns 400 on invalid body', async () => { + const handler = createAIHandler(makeConfig()); + const res = await handler(postReq({})); + expect(res.status).toBe(400); + }); + + it('returns 200 with parsed result on success', async () => { + completeJson.mockResolvedValue({ score: 9 }); + const handler = createAIHandler(makeConfig()); + const res = await handler(postReq({ text: 'hi' })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ score: 9 }); + // Verify the model was called with the built prompt. + expect(completeJson).toHaveBeenCalledWith('sys', 'user: hi', expect.anything()); + }); + + it('returns configured error when model output cannot be parsed', async () => { + completeJson.mockResolvedValue(null); + const handler = createAIHandler(makeConfig()); + const res = await handler(postReq({ text: 'hi' })); + expect(res.status).toBe(500); + }); + + it('returns 500 when the model call throws', async () => { + completeJson.mockRejectedValue(new Error('boom')); + const handler = createAIHandler(makeConfig()); + const res = await handler(postReq({ text: 'hi' })); + expect(res.status).toBe(500); + }); + + it('returns 400 on malformed JSON body', async () => { + const badReq = { + json: async () => { + throw new Error('bad json'); + }, + } as unknown as Request; + const handler = createAIHandler(makeConfig()); + const res = await handler(badReq); + expect(res.status).toBe(400); + }); +}); + +describe('validateShape', () => { + it('passes when all keys present', () => { + expect(validateShape({ a: 1, b: 2 }, ['a', 'b']).ok).toBe(true); + }); + + it('reports missing keys', () => { + const result = validateShape({ a: 1 }, ['a', 'b']); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.missing).toEqual(['b']); + }); + + it('fails on non-object', () => { + expect(validateShape(null, ['a']).ok).toBe(false); + expect(validateShape(42, ['a']).ok).toBe(false); + }); +}); diff --git a/__tests__/unit/ai-json.test.ts b/__tests__/unit/ai-json.test.ts new file mode 100644 index 0000000..d706b55 --- /dev/null +++ b/__tests__/unit/ai-json.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { extractJson } from '@/lib/ai/json'; + +describe('extractJson', () => { + it('parses a bare JSON object', () => { + expect(extractJson('{"a":1}')).toEqual({ a: 1 }); + }); + + it('parses a bare JSON array', () => { + expect(extractJson('[1,2,3]')).toEqual([1, 2, 3]); + }); + + it('extracts JSON from prose', () => { + const text = 'Here is your result:\n{"score": 8, "ok": true}\nHope that helps.'; + expect(extractJson(text)).toEqual({ score: 8, ok: true }); + }); + + it('extracts JSON wrapped in code fences', () => { + const text = '```json\n{"a":1}\n```'; + expect(extractJson(text)).toEqual({ a: 1 }); + }); + + it('extracts an array embedded in text', () => { + const text = 'words ["x","y"] more words'; + expect(extractJson(text)).toEqual(['x', 'y']); + }); + + it('returns null for empty input', () => { + expect(extractJson('')).toBeNull(); + expect(extractJson(null as unknown as string)).toBeNull(); + }); + + it('returns null for non-JSON text', () => { + expect(extractJson('no json here at all')).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + expect(extractJson('{not valid}')).toBeNull(); + }); +}); diff --git a/src/app/api/ai/assessment-score/route.ts b/src/app/api/ai/assessment-score/route.ts index b4ef894..ca8c056 100644 --- a/src/app/api/ai/assessment-score/route.ts +++ b/src/app/api/ai/assessment-score/route.ts @@ -1,80 +1,4 @@ -import { NextResponse } from 'next/server'; -import { getUserFromRequest } from '@/lib/auth-helpers'; +import { createAIHandler } from '@/lib/ai/handlers'; +import { assessmentScoreConfig } from '@/lib/ai/assessment'; -const ASSESSMENT_PROMPT = `You are an Amazon VA career preparation assistant. You help users prepare for Amazon marketplace virtual assistant roles. - -Evaluate the user's assessment answers and provide scoring feedback. - -Respond in the following JSON format only: -{ - "score": , - "correctDecisions": [""], - "incorrectDecisions": [""], - "missedOpportunities": [""], - "recommendedNextStep": "", - "modelAnswer": "" -} - -IMPORTANT: -- Be constructive and specific in your feedback -- The modelAnswer should show the ideal response without using it to inflate/deflate the user's score -- Never guarantee job placement or test performance`; - -export async function POST(request: Request) { - try { - const user = await getUserFromRequest(request); - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { assessmentTitle, assessmentData, userAnswers } = await request.json(); - - if (!assessmentTitle || !userAnswers) { - return NextResponse.json({ error: 'Assessment title and answers are required' }, { status: 400 }); - } - - if (userAnswers.length > 50000) { - return NextResponse.json({ error: 'Answers are too long' }, { status: 400 }); - } - - const ZAI = (await import('z-ai-web-dev-sdk')).default; - const zai = await ZAI.create(); - - const completion = await zai.chat.completions.create({ - messages: [ - { role: 'system', content: ASSESSMENT_PROMPT }, - { - role: 'user', - content: `Assessment: ${assessmentTitle}\n\nAssessment Data: ${assessmentData ? JSON.stringify(assessmentData).substring(0, 5000) : 'N/A'}\n\nUser's Answers: ${userAnswers}`, - }, - ], - }); - - const responseText = completion.choices[0]?.message?.content || ''; - - let result; - try { - const jsonMatch = responseText.match(/\{[\s\S]*\}/); - if (jsonMatch) { - result = JSON.parse(jsonMatch[0]); - } - } catch { - result = null; - } - - if (!result) { - return NextResponse.json( - { error: 'Failed to score assessment' }, - { status: 500 } - ); - } - - return NextResponse.json(result); - } catch (error) { - console.error('Assessment score error:', error); - return NextResponse.json( - { error: 'Failed to score assessment' }, - { status: 500 } - ); - } -} +export const POST = createAIHandler(assessmentScoreConfig); diff --git a/src/app/api/ai/coach/route.ts b/src/app/api/ai/coach/route.ts index 4dba484..b8203d6 100644 --- a/src/app/api/ai/coach/route.ts +++ b/src/app/api/ai/coach/route.ts @@ -1,132 +1,4 @@ -import { NextResponse } from 'next/server'; -import { getUserFromRequest } from '@/lib/auth-helpers'; +import { createAIHandler } from '@/lib/ai/handlers'; +import { coachConfig } from '@/lib/ai/coach'; -const GLOBAL_SYSTEM_PROMPT = `You are an Amazon VA career preparation assistant. Your job is to help users prepare for Amazon marketplace virtual assistant roles, especially Amazon PPC, Seller Central support, listing support, reporting, and agency operations roles. You must be practical, honest, and role-specific. Help users explain their real skills clearly without exaggerating or fabricating experience. You may coach, rewrite, score, summarize, and generate practice materials. You must not claim the user has experience, certifications, budgets managed, client results, or tool expertise unless the user explicitly provided that information. When discussing Amazon PPC, use clear operational language: campaigns, keywords, search terms, ACoS, ROAS, CTR, CPC, CVR, spend, sales, orders, listing readiness, inventory, reporting, and SOPs. When uncertain, ask for the missing detail or provide a safe beginner-friendly version. Never guarantee job placement, interview success, ranking results, ACoS improvement, or Amazon account outcomes.`; - -const INTERVIEW_COACH_PROMPT = `${GLOBAL_SYSTEM_PROMPT} - -You are the Interview Coach Agent for Amazon VA candidates. - -Goal: Evaluate the user's interview answer and provide coaching feedback. - -Scoring dimensions: -1. Role relevance (20%) -2. Technical accuracy (30%) -3. Specificity (15%) -4. Communication clarity (15%) -5. Judgment and risk awareness (10%) -6. Truthfulness (10%) - -You MUST respond in the following JSON format only: -{ - "score": , - "whatWorked": "", - "whatToImprove": "", - "strongerSampleAnswer": "", - "followUpQuestion": "", - "rubricBreakdown": { - "roleRelevance": , - "technicalAccuracy": , - "specificity": , - "communicationClarity": , - "judgmentAndRiskAwareness": , - "truthfulness": - } -} - -IMPORTANT RULES FOR FOLLOW-UP QUESTIONS: -- You MUST ALWAYS include a "followUpQuestion" field in your response. -- When the score is BELOW 7 (weak answer): The followUpQuestion should be directly related to the user's weak area. It should probe the specific gap in their answer, give them a second chance to demonstrate knowledge, and help them practice improving. Make it specific and targeted to their weak point (e.g., if they lacked metrics, ask for specific numbers; if they were vague about process, ask them to walk through the steps). -- When the score is 7 or ABOVE (strong answer): The followUpQuestion should be a natural follow-up that a real interviewer would ask to go deeper. It can explore related topics, test breadth of knowledge, or present a more complex scenario. -- The followUpQuestion should always feel like a natural continuation of the interview conversation. -- Never repeat the original question. The follow-up should build on or branch from the original topic.`; - -export async function POST(request: Request) { - try { - const user = await getUserFromRequest(request); - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { question, userAnswer, questionContext } = await request.json(); - - if (!question || !userAnswer) { - return NextResponse.json({ error: 'Question and answer are required' }, { status: 400 }); - } - - const ZAI = (await import('z-ai-web-dev-sdk')).default; - const zai = await ZAI.create(); - - // Add explicit instruction about follow-up for low scores - const scoreHint = `Evaluate the answer and provide your score. If the score is below 7, make sure the followUpQuestion specifically targets the weakness in their answer to give them a chance to improve. If the score is 7 or above, ask a natural follow-up that explores the topic more deeply.`; - - const completion = await zai.chat.completions.create({ - messages: [ - { role: 'system', content: INTERVIEW_COACH_PROMPT }, - { - role: 'user', - content: `Question: ${question}\n\nQuestion Context: ${questionContext || 'General Amazon VA interview question'}\n\nUser's Answer: ${userAnswer}\n\n${scoreHint}\n\nPlease evaluate this answer and provide coaching feedback in the required JSON format.`, - }, - ], - }); - - const responseText = completion.choices[0]?.message?.content || ''; - - // Try to parse JSON from the response - let feedback; - try { - const jsonMatch = responseText.match(/\{[\s\S]*\}/); - if (jsonMatch) { - feedback = JSON.parse(jsonMatch[0]); - } - } catch { - feedback = null; - } - - if (!feedback) { - feedback = { - score: 5, - whatWorked: 'You provided an answer to the question.', - whatToImprove: responseText || 'Try to be more specific and use Amazon VA terminology.', - strongerSampleAnswer: 'Please review the question and try to include specific metrics, processes, and Amazon terminology in your answer.', - followUpQuestion: 'Can you provide more specific details about your experience with this topic?', - rubricBreakdown: { - roleRelevance: 5, - technicalAccuracy: 5, - specificity: 4, - communicationClarity: 5, - judgmentAndRiskAwareness: 5, - truthfulness: 6, - }, - }; - } - - // Ensure followUpQuestion is always present - if (!feedback.followUpQuestion) { - if (feedback.score < 7) { - feedback.followUpQuestion = `Your answer could use more detail. Can you try again, specifically focusing on the process steps and any relevant metrics for: "${question}"?`; - } else { - feedback.followUpQuestion = `Good answer! As a follow-up, can you explain how you would handle a more complex scenario related to this topic?`; - } - } - - return NextResponse.json(feedback); - } catch (error) { - console.error('AI Coach error:', error); - return NextResponse.json({ - score: 5, - whatWorked: 'You attempted to answer the question.', - whatToImprove: 'Try to include specific Amazon VA terminology, metrics, and processes in your answer.', - strongerSampleAnswer: 'A strong answer would include specific metrics (ACoS, ROAS, CTR, CVR), mention relevant processes (keyword harvesting, search term review, bid optimization), and demonstrate understanding of Amazon workflows.', - followUpQuestion: 'Can you explain this concept using specific Amazon terminology and walk through the process steps?', - rubricBreakdown: { - roleRelevance: 5, - technicalAccuracy: 4, - specificity: 3, - communicationClarity: 5, - judgmentAndRiskAwareness: 5, - truthfulness: 6, - }, - }); - } -} +export const POST = createAIHandler(coachConfig); diff --git a/src/app/api/ai/cover-letter/route.ts b/src/app/api/ai/cover-letter/route.ts index 2f3fe88..d0d1b7f 100644 --- a/src/app/api/ai/cover-letter/route.ts +++ b/src/app/api/ai/cover-letter/route.ts @@ -1,83 +1,4 @@ -import { NextResponse } from 'next/server'; -import { getUserFromRequest } from '@/lib/auth-helpers'; +import { createAIHandler } from '@/lib/ai/handlers'; +import { coverLetterConfig } from '@/lib/ai/cover-letter'; -const COVER_LETTER_PROMPT = `You are an Amazon VA career preparation assistant. You help users prepare for Amazon marketplace virtual assistant roles. - -Generate a professional cover letter based on the job description and target role provided. - -Respond in the following JSON format only: -{ - "draftLetter": "", - "shorterVersion": "<2-3 sentence summary>", - "subjectLine": "", - "customizationTips": ["", "", ""], - "claimsToVerify": ["", ""] -} - -IMPORTANT: -- Do NOT fabricate specific experience, certifications, metrics, or tools the user hasn't explicitly mentioned -- Use generic placeholders like "[X years of experience]" where specific numbers aren't provided -- Focus on transferable skills and willingness to learn -- Never guarantee job placement or interview success`; - -export async function POST(request: Request) { - try { - const user = await getUserFromRequest(request); - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { jobDescription, tone, targetRole, userName } = await request.json(); - - if (!jobDescription) { - return NextResponse.json({ error: 'Job description is required' }, { status: 400 }); - } - - if (jobDescription.length > 10000) { - return NextResponse.json({ error: 'Job description is too long' }, { status: 400 }); - } - - const ZAI = (await import('z-ai-web-dev-sdk')).default; - const zai = await ZAI.create(); - - const completion = await zai.chat.completions.create({ - messages: [ - { role: 'system', content: COVER_LETTER_PROMPT }, - { - role: 'user', - content: `Target Role: ${targetRole || 'Amazon VA'}\nTone: ${tone || 'formal'}\nApplicant Name: ${userName || '[Your Name]'}\n\nJob Description:\n${jobDescription}`, - }, - ], - }); - - const responseText = completion.choices[0]?.message?.content || ''; - - let result; - try { - const jsonMatch = responseText.match(/\{[\s\S]*\}/); - if (jsonMatch) { - result = JSON.parse(jsonMatch[0]); - } - } catch { - result = null; - } - - if (!result) { - return NextResponse.json({ - draftLetter: 'Unable to generate cover letter. Please try again.', - shorterVersion: '', - subjectLine: '', - customizationTips: [], - claimsToVerify: [], - }); - } - - return NextResponse.json(result); - } catch (error) { - console.error('Cover letter generation error:', error); - return NextResponse.json( - { error: 'Failed to generate cover letter' }, - { status: 500 } - ); - } -} +export const POST = createAIHandler(coverLetterConfig); diff --git a/src/app/api/ai/resume-review/route.ts b/src/app/api/ai/resume-review/route.ts index e042900..12f2005 100644 --- a/src/app/api/ai/resume-review/route.ts +++ b/src/app/api/ai/resume-review/route.ts @@ -1,83 +1,4 @@ -import { NextResponse } from 'next/server'; -import { getUserFromRequest } from '@/lib/auth-helpers'; +import { createAIHandler } from '@/lib/ai/handlers'; +import { resumeReviewConfig } from '@/lib/ai/resume'; -const RESUME_REVIEW_PROMPT = `You are an Amazon VA career preparation assistant. You help users prepare for Amazon marketplace virtual assistant roles. - -Review the provided resume text for the target role and provide constructive feedback. - -Respond in the following JSON format only: -{ - "score": , - "missingKeywords": ["", ""], - "weakSections": ["
", "
"], - "improvedSummary": "", - "improvedBullets": ["", ""], - "skillsRecommendations": ["", ""], - "truthWarnings": [""], -} - -IMPORTANT: -- The score should reflect the resume's fit for Amazon VA roles, not generic quality -- Only flag truth warnings if the resume makes specific verifiable claims that seem exaggerated -- Skills recommendations should focus on Amazon-related skills -- Do not suggest the user has certifications or experience they haven't mentioned -- Never guarantee job placement`; - -export async function POST(request: Request) { - try { - const user = await getUserFromRequest(request); - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { resumeText, targetRole } = await request.json(); - - if (!resumeText) { - return NextResponse.json({ error: 'Resume text is required' }, { status: 400 }); - } - - if (resumeText.length > 20000) { - return NextResponse.json({ error: 'Resume is too long' }, { status: 400 }); - } - - const ZAI = (await import('z-ai-web-dev-sdk')).default; - const zai = await ZAI.create(); - - const completion = await zai.chat.completions.create({ - messages: [ - { role: 'system', content: RESUME_REVIEW_PROMPT }, - { - role: 'user', - content: `Target Role: ${targetRole || 'Amazon VA'}\n\nResume Text:\n${resumeText}`, - }, - ], - }); - - const responseText = completion.choices[0]?.message?.content || ''; - - let feedback; - try { - const jsonMatch = responseText.match(/\{[\s\S]*\}/); - if (jsonMatch) { - feedback = JSON.parse(jsonMatch[0]); - } - } catch { - feedback = null; - } - - if (!feedback) { - return NextResponse.json( - { error: 'Failed to parse resume review' }, - { status: 500 } - ); - } - - return NextResponse.json(feedback); - } catch (error) { - console.error('Resume review error:', error); - return NextResponse.json( - { error: 'Failed to review resume' }, - { status: 500 } - ); - } -} +export const POST = createAIHandler(resumeReviewConfig); diff --git a/src/lib/ai/assessment.ts b/src/lib/ai/assessment.ts new file mode 100644 index 0000000..8f34e12 --- /dev/null +++ b/src/lib/ai/assessment.ts @@ -0,0 +1,57 @@ +import { validateShape, type AIHandlerConfig } from './handlers'; + +const ASSESSMENT_PROMPT = `You are an Amazon VA career preparation assistant. You help users prepare for Amazon marketplace virtual assistant roles. + +Evaluate the user's assessment answers and provide scoring feedback. + +Respond in the following JSON format only: +{ + "score": , + "correctDecisions": [""], + "incorrectDecisions": [""], + "missedOpportunities": [""], + "recommendedNextStep": "", + "modelAnswer": "" +} + +IMPORTANT: +- Be constructive and specific in your feedback +- The modelAnswer should show the ideal response without using it to inflate/deflate the user's score +- Never guarantee job placement or test performance`; + +interface AssessmentBody { + assessmentTitle: string; + assessmentData?: unknown; + userAnswers: unknown; +} + +interface AssessmentResult { + score: number; + correctDecisions: string[]; + incorrectDecisions: string[]; + missedOpportunities: string[]; + recommendedNextStep: string; + modelAnswer: string; +} + +export const assessmentScoreConfig: AIHandlerConfig = { + systemPrompt: ASSESSMENT_PROMPT, + validate: (body) => { + const shape = validateShape(body, ['assessmentTitle', 'userAnswers']); + if (!shape.ok) { + return { ok: false, status: 400, error: 'Assessment title and answers are required' }; + } + const b = body as AssessmentBody; + const length = + typeof b.userAnswers === 'string' + ? b.userAnswers.length + : JSON.stringify(b.userAnswers).length; + if (length > 50000) { + return { ok: false, status: 400, error: 'Answers are too long' }; + } + return { ok: true, value: b }; + }, + buildUserPrompt: (body) => + `Assessment: ${body.assessmentTitle}\n\nAssessment Data: ${body.assessmentData ? JSON.stringify(body.assessmentData).substring(0, 5000) : 'N/A'}\n\nUser's Answers: ${body.userAnswers}`, + onParseFailure: () => ({ ok: false, status: 500, error: 'Failed to score assessment' }), +}; diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts new file mode 100644 index 0000000..0073076 --- /dev/null +++ b/src/lib/ai/client.ts @@ -0,0 +1,90 @@ +import { extractJson } from './json'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export interface AICompletionOptions { + /** Abort signal forwarded to the provider (enables request cancellation). */ + signal?: AbortSignal; + /** Max wall-clock time for the completion. Defaults to 30s. */ + timeoutMs?: number; +} + +export interface AIProvider { + complete(system: string, user: string, opts?: AICompletionOptions): Promise; +} + +/** + * Thin adapter over z-ai-web-dev-sdk that adds a hard timeout and abort support. + * Swap this class to change providers without touching route handlers (DIP). + */ +export class ZAIProvider implements AIProvider { + async complete( + system: string, + user: string, + opts: AICompletionOptions = {}, + ): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + // Chain an externally-provided signal so either cancellation wins. + if (opts.signal) { + if (opts.signal.aborted) controller.abort(); + else opts.signal.addEventListener('abort', () => controller.abort(), { once: true }); + } + + try { + const ZAI = (await import('z-ai-web-dev-sdk')).default; + const zai = await ZAI.create(); + const completion = await withTimeout( + zai.chat.completions.create({ + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + }), + timeoutMs, + controller, + ); + return completion.choices[0]?.message?.content ?? ''; + } finally { + clearTimeout(timer); + } + } +} + +export const ai: AIProvider = new ZAIProvider(); + +/** Enforces a wall-clock deadline; rejects with a TimeoutError on expiry. */ +function withTimeout( + promise: Promise, + timeoutMs: number, + controller: AbortController, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + controller.abort(); + reject(new Error(`AI request timed out after ${timeoutMs}ms`)); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +/** Convenience: complete and parse the JSON payload from the response. */ +export async function completeJson( + system: string, + user: string, + opts?: AICompletionOptions, +): Promise { + const text = await ai.complete(system, user, opts); + return extractJson(text); +} diff --git a/src/lib/ai/coach.ts b/src/lib/ai/coach.ts new file mode 100644 index 0000000..9a35e2c --- /dev/null +++ b/src/lib/ai/coach.ts @@ -0,0 +1,127 @@ +import { validateShape, type AIHandlerConfig } from './handlers'; + +const GLOBAL_SYSTEM_PROMPT = `You are an Amazon VA career preparation assistant. Your job is to help users prepare for Amazon marketplace virtual assistant roles, especially Amazon PPC, Seller Central support, listing support, reporting, and agency operations roles. You must be practical, honest, and role-specific. Help users explain their real skills clearly without exaggerating or fabricating experience. You may coach, rewrite, score, summarize, and generate practice materials. You must not claim the user has experience, certifications, budgets managed, client results, or tool expertise unless the user explicitly provided that information. When discussing Amazon PPC, use clear operational language: campaigns, keywords, search terms, ACoS, ROAS, CTR, CPC, CVR, spend, sales, orders, listing readiness, inventory, reporting, and SOPs. When uncertain, ask for the missing detail or provide a safe beginner-friendly version. Never guarantee job placement, interview success, ranking results, ACoS improvement, or Amazon account outcomes.`; + +const INTERVIEW_COACH_PROMPT = `${GLOBAL_SYSTEM_PROMPT} + +You are the Interview Coach Agent for Amazon VA candidates. + +Goal: Evaluate the user's interview answer and provide coaching feedback. + +Scoring dimensions: +1. Role relevance (20%) +2. Technical accuracy (30%) +3. Specificity (15%) +4. Communication clarity (15%) +5. Judgment and risk awareness (10%) +6. Truthfulness (10%) + +You MUST respond in the following JSON format only: +{ + "score": , + "whatWorked": "", + "whatToImprove": "", + "strongerSampleAnswer": "", + "followUpQuestion": "", + "rubricBreakdown": { + "roleRelevance": , + "technicalAccuracy": , + "specificity": , + "communicationClarity": , + "judgmentAndRiskAwareness": , + "truthfulness": + } +} + +IMPORTANT RULES FOR FOLLOW-UP QUESTIONS: +- You MUST ALWAYS include a "followUpQuestion" field in your response. +- When the score is BELOW 7 (weak answer): The followUpQuestion should be directly related to the user's weak area. It should probe the specific gap in their answer, give them a second chance to demonstrate knowledge, and help them practice improving. Make it specific and targeted to their weak point (e.g., if they lacked metrics, ask for specific numbers; if they were vague about process, ask them to walk through the steps). +- When the score is 7 or ABOVE (strong answer): The followUpQuestion should be a natural follow-up that a real interviewer would ask to go deeper. It can explore related topics, test breadth of knowledge, or present a more complex scenario. +- The followUpQuestion should always feel like a natural continuation of the interview conversation. +- Never repeat the original question. The follow-up should build on or branch from the original topic.`; + +interface CoachBody { + question: string; + userAnswer: string; + questionContext?: string; +} + +interface CoachResult { + score: number; + whatWorked: string; + whatToImprove: string; + strongerSampleAnswer: string; + followUpQuestion?: string; + rubricBreakdown: Record; +} + +function fallbackFeedback(question: string, responseText = ''): CoachResult { + return { + score: 5, + whatWorked: 'You provided an answer to the question.', + whatToImprove: responseText || 'Try to be more specific and use Amazon VA terminology.', + strongerSampleAnswer: + 'Please review the question and try to include specific metrics, processes, and Amazon terminology in your answer.', + followUpQuestion: 'Can you provide more specific details about your experience with this topic?', + rubricBreakdown: { + roleRelevance: 5, + technicalAccuracy: 5, + specificity: 4, + communicationClarity: 5, + judgmentAndRiskAwareness: 5, + truthfulness: 6, + }, + }; +} + +function errorFeedback(): CoachResult { + return { + score: 5, + whatWorked: 'You attempted to answer the question.', + whatToImprove: 'Try to include specific Amazon VA terminology, metrics, and processes in your answer.', + strongerSampleAnswer: + 'A strong answer would include specific metrics (ACoS, ROAS, CTR, CVR), mention relevant processes (keyword harvesting, search term review, bid optimization), and demonstrate understanding of Amazon workflows.', + followUpQuestion: 'Can you explain this concept using specific Amazon terminology and walk through the process steps?', + rubricBreakdown: { + roleRelevance: 5, + technicalAccuracy: 4, + specificity: 3, + communicationClarity: 5, + judgmentAndRiskAwareness: 5, + truthfulness: 6, + }, + }; +} + +export const coachConfig: AIHandlerConfig = { + systemPrompt: INTERVIEW_COACH_PROMPT, + validate: (body) => { + const shape = validateShape(body, ['question', 'userAnswer']); + if (!shape.ok) { + return { ok: false, status: 400, error: 'Question and answer are required' }; + } + return { ok: true, value: body as CoachBody }; + }, + buildUserPrompt: (body) => { + const scoreHint = `Evaluate the answer and provide your score. If the score is below 7, make sure the followUpQuestion specifically targets the weakness in their answer to give them a chance to improve. If the score is 7 or above, ask a natural follow-up that explores the topic more deeply.`; + return `Question: ${body.question}\n\nQuestion Context: ${body.questionContext || 'General Amazon VA interview question'}\n\nUser's Answer: ${body.userAnswer}\n\n${scoreHint}\n\nPlease evaluate this answer and provide coaching feedback in the required JSON format.`; + }, + onParseFailure: (body) => { + // On parse failure the original route returned a graceful fallback (200), not a 500. + void body; + return { ok: true, value: fallbackFeedback(body.question) }; + }, + normalize: (result, body) => { + // Ensure followUpQuestion is always present (mirrors original route logic). + if (!result.followUpQuestion) { + const score = typeof result.score === 'number' ? result.score : 5; + result.followUpQuestion = + score < 7 + ? `Your answer could use more detail. Can you try again, specifically focusing on the process steps and any relevant metrics for: "${body.question}"?` + : `Good answer! As a follow-up, can you explain how you would handle a more complex scenario related to this topic?`; + } + return result; + }, +}; + +export { errorFeedback }; diff --git a/src/lib/ai/cover-letter.ts b/src/lib/ai/cover-letter.ts new file mode 100644 index 0000000..f9a9ac8 --- /dev/null +++ b/src/lib/ai/cover-letter.ts @@ -0,0 +1,62 @@ +import { validateShape, type AIHandlerConfig } from './handlers'; + +const COVER_LETTER_PROMPT = `You are an Amazon VA career preparation assistant. You help users prepare for Amazon marketplace virtual assistant roles. + +Generate a professional cover letter based on the job description and target role provided. + +Respond in the following JSON format only: +{ + "draftLetter": "", + "shorterVersion": "<2-3 sentence summary>", + "subjectLine": "", + "customizationTips": ["", "", ""], + "claimsToVerify": ["", ""] +} + +IMPORTANT: +- Do NOT fabricate specific experience, certifications, metrics, or tools the user hasn't explicitly mentioned +- Use generic placeholders like "[X years of experience]" where specific numbers aren't provided +- Focus on transferable skills and willingness to learn +- Never guarantee job placement or interview success`; + +interface CoverLetterBody { + jobDescription: string; + tone?: string; + targetRole?: string; + userName?: string; +} + +interface CoverLetterResult { + draftLetter: string; + shorterVersion?: string; + subjectLine?: string; + customizationTips?: string[]; + claimsToVerify?: string[]; +} + +const EMPTY_RESULT: CoverLetterResult = { + draftLetter: 'Unable to generate cover letter. Please try again.', + shorterVersion: '', + subjectLine: '', + customizationTips: [], + claimsToVerify: [], +}; + +export const coverLetterConfig: AIHandlerConfig = { + systemPrompt: COVER_LETTER_PROMPT, + validate: (body) => { + const shape = validateShape(body, ['jobDescription']); + if (!shape.ok) { + return { ok: false, status: 400, error: 'Job description is required' }; + } + const jobDescription = (body as CoverLetterBody).jobDescription; + if (typeof jobDescription === 'string' && jobDescription.length > 10000) { + return { ok: false, status: 400, error: 'Job description is too long' }; + } + return { ok: true, value: body as CoverLetterBody }; + }, + buildUserPrompt: (body) => + `Target Role: ${body.targetRole || 'Amazon VA'}\nTone: ${body.tone || 'formal'}\nApplicant Name: ${body.userName || '[Your Name]'}\n\nJob Description:\n${body.jobDescription}`, + // Original route returned a graceful partial object (200) on parse failure. + onParseFailure: () => ({ ok: true, value: EMPTY_RESULT }), +}; diff --git a/src/lib/ai/handlers.ts b/src/lib/ai/handlers.ts new file mode 100644 index 0000000..9a2808b --- /dev/null +++ b/src/lib/ai/handlers.ts @@ -0,0 +1,96 @@ +import { NextResponse } from 'next/server'; +import { getUserFromRequest } from '@/lib/auth-helpers'; +import { completeJson } from './client'; + +/** + * Lightweight schema check (avoids adding zod as a dependency). + * Verifies that an object has the listed required keys with non-undefined values. + */ +export function validateShape( + value: unknown, + requiredKeys: string[], +): { ok: true; data: Record } | { ok: false; missing: string[] } { + if (typeof value !== 'object' || value === null) { + return { ok: false, missing: requiredKeys }; + } + const record = value as Record; + const missing = requiredKeys.filter((key) => record[key] === undefined); + return missing.length === 0 + ? { ok: true, data: record } + : { ok: false, missing }; +} + +export interface AIHandlerConfig { + /** System prompt for the model. */ + systemPrompt: string; + /** Build the user message from the parsed request body. */ + buildUserPrompt: (body: TBody) => string; + /** Validate/normalize the incoming body. Return an error to short-circuit. */ + validate: (body: unknown) => { ok: true; value: TBody } | { ok: false; status: number; error: string }; + /** + * What to return when the model's JSON cannot be parsed. + * - return a value → responded as 200 with that payload + * - throw/return an error → responded as `status` with `{ error }` + */ + onParseFailure: (body: TBody) => { ok: true; value: TResult } | { ok: false; status: number; error: string }; + /** Optional post-parse normalization/validation of the model output. */ + normalize?: (result: TResult, body: TBody) => TResult; + /** Optional timeout (ms) for the model call. */ + timeoutMs?: number; +} + +/** + * Factory that produces a POST handler for an AI endpoint. + * Encapsulates auth, input validation, model call, JSON parsing, and error handling + * so individual routes stay ~15 lines (Open/Closed: add a feature by adding a config). + */ +export function createAIHandler, TResult = unknown>( + config: AIHandlerConfig, +) { + return async function handler(request: Request) { + let user; + try { + user = await getUserFromRequest(request); + } catch { + user = null; + } + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const validation = config.validate(rawBody); + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: validation.status }); + } + const body = validation.value; + + try { + const parsed = await completeJson( + config.systemPrompt, + config.buildUserPrompt(body), + { timeoutMs: config.timeoutMs }, + ); + + if (!parsed) { + const failure = config.onParseFailure(body); + if (!failure.ok) { + return NextResponse.json({ error: failure.error }, { status: failure.status }); + } + return NextResponse.json(failure.value); + } + + const result = config.normalize ? config.normalize(parsed, body) : parsed; + return NextResponse.json(result); + } catch (error) { + console.error('AI handler error:', error); + return NextResponse.json({ error: 'AI request failed' }, { status: 500 }); + } + }; +} diff --git a/src/lib/ai/json.ts b/src/lib/ai/json.ts new file mode 100644 index 0000000..c442351 --- /dev/null +++ b/src/lib/ai/json.ts @@ -0,0 +1,35 @@ +/** + * Extracts the first JSON object or array from a free-text LLM response. + * Models frequently wrap JSON in prose or code fences; this recovers the payload. + */ +export function extractJson(text: string): T | null { + if (!text || typeof text !== 'string') return null; + const trimmed = text.trim(); + + // Fast path: the whole response is already JSON. + try { + return JSON.parse(trimmed) as T; + } catch { + // fall through to structural extraction + } + + const objectMatch = trimmed.match(/\{[\s\S]*\}/); + if (objectMatch) { + try { + return JSON.parse(objectMatch[0]) as T; + } catch { + // malformed; fall through + } + } + + const arrayMatch = trimmed.match(/\[[\s\S]*\]/); + if (arrayMatch) { + try { + return JSON.parse(arrayMatch[0]) as T; + } catch { + // malformed + } + } + + return null; +} diff --git a/src/lib/ai/resume.ts b/src/lib/ai/resume.ts new file mode 100644 index 0000000..39f0351 --- /dev/null +++ b/src/lib/ai/resume.ts @@ -0,0 +1,56 @@ +import { validateShape, type AIHandlerConfig } from './handlers'; + +const RESUME_REVIEW_PROMPT = `You are an Amazon VA career preparation assistant. You help users prepare for Amazon marketplace virtual assistant roles. + +Review the provided resume text for the target role and provide constructive feedback. + +Respond in the following JSON format only: +{ + "score": , + "missingKeywords": ["", ""], + "weakSections": ["
", "
"], + "improvedSummary": "", + "improvedBullets": ["", ""], + "skillsRecommendations": ["", ""], + "truthWarnings": [""], +} + +IMPORTANT: +- The score should reflect the resume's fit for Amazon VA roles, not generic quality +- Only flag truth warnings if the resume makes specific verifiable claims that seem exaggerated +- Skills recommendations should focus on Amazon-related skills +- Do not suggest the user has certifications or experience they haven't mentioned +- Never guarantee job placement`; + +interface ResumeReviewBody { + resumeText: string; + targetRole?: string; +} + +interface ResumeReviewResult { + score: number; + missingKeywords: string[]; + weakSections: string[]; + improvedSummary: string; + improvedBullets: string[]; + skillsRecommendations: string[]; + truthWarnings: string[]; +} + +export const resumeReviewConfig: AIHandlerConfig = { + systemPrompt: RESUME_REVIEW_PROMPT, + validate: (body) => { + const shape = validateShape(body, ['resumeText']); + if (!shape.ok) { + return { ok: false, status: 400, error: 'Resume text is required' }; + } + const resumeText = (body as ResumeReviewBody).resumeText; + if (typeof resumeText === 'string' && resumeText.length > 20000) { + return { ok: false, status: 400, error: 'Resume is too long' }; + } + return { ok: true, value: body as ResumeReviewBody }; + }, + buildUserPrompt: (body) => + `Target Role: ${body.targetRole || 'Amazon VA'}\n\nResume Text:\n${body.resumeText}`, + onParseFailure: () => ({ ok: false, status: 500, error: 'Failed to parse resume review' }), +};