/ src / score.js
score.js
  1  /**
  2   * LLM Scoring Module
  3   * Integrates with OpenRouter or Anthropic Claude for website conversion scoring
  4   */
  5  
  6  import { readFileSync } from 'fs';
  7  import { join, dirname } from 'path';
  8  import { fileURLToPath } from 'url';
  9  import Logger from './utils/logger.js';
 10  import { retryWithBackoff, isRetryableError, safeJsonParse } from './utils/error-handler.js';
 11  import { openRouterBreaker } from './utils/circuit-breaker.js';
 12  import { openRouterLimiter } from './utils/rate-limiter.js';
 13  import { callLLM, getProviderDisplayName } from './utils/llm-provider.js';
 14  import { sanitizeHtmlForPrompt, wrapUntrusted } from './utils/llm-sanitizer.js';
 15  import { validateScoringResponse } from './utils/llm-response-validator.js';
 16  import './utils/load-env.js';
 17  
 18  const __filename = fileURLToPath(import.meta.url);
 19  const __dirname = dirname(__filename);
 20  const projectRoot = join(__dirname, '..');
 21  
 22  const logger = new Logger('Score');
 23  
 24  // Factor weights for programmatic score calculation (matches prompt scoring framework)
 25  export const FACTOR_WEIGHTS = {
 26    headline_quality: 0.15,
 27    value_proposition: 0.14,
 28    unique_selling_proposition: 0.13,
 29    call_to_action: 0.13,
 30    urgency_messaging: 0.1,
 31    hook_engagement: 0.09,
 32    trust_signals: 0.11,
 33    imagery_design: 0.08,
 34    offer_clarity: 0.04,
 35    contextual_appropriateness: 0.03,
 36  };
 37  
 38  // Standard academic grade scale with +/- modifiers
 39  const GRADE_THRESHOLDS = [
 40    { min: 97, grade: 'A+' },
 41    { min: 93, grade: 'A' },
 42    { min: 90, grade: 'A-' },
 43    { min: 87, grade: 'B+' },
 44    { min: 83, grade: 'B' },
 45    { min: 80, grade: 'B-' },
 46    { min: 77, grade: 'C+' },
 47    { min: 73, grade: 'C' },
 48    { min: 70, grade: 'C-' },
 49    { min: 67, grade: 'D+' },
 50    { min: 63, grade: 'D' },
 51    { min: 60, grade: 'D-' },
 52    { min: 0, grade: 'F' },
 53  ];
 54  
 55  /**
 56   * Compute weighted total score (0-100) from individual factor scores (0-10)
 57   */
 58  export function computeScoreFromFactors(factorScores) {
 59    if (!factorScores || typeof factorScores !== 'object') return null;
 60  
 61    let total = 0;
 62    for (const [factor, weight] of Object.entries(FACTOR_WEIGHTS)) {
 63      const score = factorScores[factor]?.score ?? 0;
 64      total += score * weight;
 65    }
 66    // Factor scores are 0-10, weights sum to 1.0, so total is 0-10. Scale to 0-100.
 67    return Math.round(total * 10 * 10) / 10;
 68  }
 69  
 70  /**
 71   * Derive letter grade from numeric score using the business grade scale
 72   */
 73  export function computeGrade(score) {
 74    if (score === null || score === undefined || score < 0) return 'F';
 75    for (const { min, grade } of GRADE_THRESHOLDS) {
 76      if (score >= min) return grade;
 77    }
 78    return 'F';
 79  }
 80  
 81  // Check ENABLE_VISION flag (consolidates old flags)
 82  const ENABLE_VISION = process.env.ENABLE_VISION !== 'false';
 83  
 84  // Show deprecation warning if old flags are used
 85  const legacyFlags = [
 86    process.env.USE_COMPUTER_VISION_SCORING,
 87    process.env.USE_COMPUTER_VISION_RESCORING,
 88    process.env.USE_COMPUTER_VISION_ENRICHMENT,
 89  ];
 90  if (legacyFlags.some(flag => flag !== undefined)) {
 91    console.warn(
 92      '[score] WARN: Vision flags (USE_COMPUTER_VISION_*) are deprecated. Use ENABLE_VISION instead.'
 93    );
 94  }
 95  
 96  // Load prompts based on ENABLE_VISION flag
 97  const SCORING_PROMPT_VISION = readFileSync(
 98    join(projectRoot, 'prompts/CONVERSION-SCORING-VISION.md'),
 99    'utf-8'
100  );
101  const SCORING_PROMPT_NOVIS = readFileSync(
102    join(projectRoot, 'prompts/CONVERSION-SCORING-NOVIS.md'),
103    'utf-8'
104  );
105  
106  const RESUBMIT_PROMPT_BASE = readFileSync(
107    join(projectRoot, 'prompts/CONVERSION-RESCORING.md'),
108    'utf-8'
109  );
110  const RESUBMIT_PROMPT_VISION = readFileSync(
111    join(projectRoot, 'prompts/CONVERSION-RESCORING-VISION.md'),
112    'utf-8'
113  );
114  
115  // Select prompt based on ENABLE_VISION flag
116  // Vision enabled: CONVERSION-SCORING-VISION.md (full screenshot + HTML analysis)
117  // Vision disabled: CONVERSION-SCORING-NOVIS.md (HTML-only with contact extraction)
118  const SCORING_PROMPT = ENABLE_VISION ? SCORING_PROMPT_VISION : SCORING_PROMPT_NOVIS;
119  
120  const RESUBMIT_PROMPT = ENABLE_VISION
121    ? `${RESUBMIT_PROMPT_BASE}\n\n${RESUBMIT_PROMPT_VISION}`
122    : RESUBMIT_PROMPT_BASE;
123  
124  // Model configuration (from env or default)
125  const SCORING_MODEL = process.env.SCORING_MODEL || 'openai/gpt-4o-mini';
126  
127  /**
128   * Score a website using OpenRouter GPT-4o-mini
129   * @param {Object} siteData - Site screenshots and HTML
130   * @param {number} siteId - Site ID for usage tracking
131   * @returns {Promise<Object>} Scoring results
132   */
133  export async function scoreWebsite(siteData, siteId = null) {
134    const { url, domain, screenshots, screenshotsUncropped, html, visionText, httpHeaders } =
135      siteData;
136  
137    logger.info(`Scoring website: ${domain}`);
138  
139    try {
140      // Initial scoring with above-fold screenshots
141      const initialScore = await callScoringAPI({
142        url,
143        domain,
144        desktopScreenshot: screenshots.desktop_above,
145        mobileScreenshot: screenshots.mobile_above,
146        html,
147        httpHeaders,
148        prompt: SCORING_PROMPT,
149        siteId,
150      });
151  
152      // Check if we need resubmit (B- or below)
153      const grade = initialScore?.overall_calculation?.letter_grade;
154      const needsResubmit = shouldResubmit(grade);
155  
156      if (needsResubmit && screenshots.desktop_below) {
157        logger.info(`Score ${grade} requires resubmit for ${domain}`);
158  
159        // Use uncropped version for resubmit if available, otherwise fall back to cropped
160        const belowFoldScreenshot = screenshotsUncropped?.desktop_below || screenshots.desktop_below;
161  
162        const finalScore = await callResubmitAPI({
163          url,
164          domain,
165          initialScore,
166          belowFoldScreenshot,
167          html,
168          visionText,
169          prompt: RESUBMIT_PROMPT,
170          siteId,
171        });
172  
173        return finalScore;
174      }
175  
176      return initialScore;
177    } catch (error) {
178      logger.error(`Scoring failed for ${domain}`, error);
179      throw error;
180    }
181  }
182  
183  /**
184   * Call LLM API for initial scoring
185   */
186  // eslint-disable-next-line require-await -- Wraps retryWithBackoff which handles async
187  async function callScoringAPI({
188    url,
189    domain,
190    desktopScreenshot,
191    mobileScreenshot,
192    html,
193    httpHeaders,
194    prompt,
195    siteId,
196  }) {
197    return retryWithBackoff(
198      // eslint-disable-next-line require-await -- Wrapper for circuit breaker fire()
199      async () => {
200        // Wrap the API call with circuit breaker
201        return openRouterBreaker.fire(async () => {
202          // Build user message content
203          const userContent = [
204            {
205              type: 'text',
206              text: `Evaluate this website:\n\nURL: ${url}\nDomain: ${domain}\n\n${wrapUntrusted(httpHeaders ? JSON.stringify(JSON.parse(httpHeaders), null, 2) : 'Not available', 'http_headers')}\n\n${wrapUntrusted(sanitizeHtmlForPrompt(html.substring(0, 50000)), 'website_html')}`,
207            },
208          ];
209  
210          // Only include screenshots if vision enabled AND screenshots are available
211          if (ENABLE_VISION && desktopScreenshot && mobileScreenshot) {
212            userContent.push(
213              {
214                type: 'image_url',
215                image_url: {
216                  url: `data:image/jpeg;base64,${desktopScreenshot.toString('base64')}`,
217                  detail: 'low', // Use low detail to save tokens (85 tokens vs 255)
218                },
219              },
220              {
221                type: 'image_url',
222                image_url: {
223                  url: `data:image/jpeg;base64,${mobileScreenshot.toString('base64')}`,
224                  detail: 'low',
225                },
226              }
227            );
228          }
229  
230          const messages = [
231            {
232              role: 'system',
233              content: prompt,
234            },
235            {
236              role: 'user',
237              content: userContent,
238            },
239          ];
240  
241          const response = await openRouterLimiter.schedule(() =>
242            callLLM({
243              model: SCORING_MODEL,
244              messages,
245              temperature: 0.3,
246              max_tokens: 4000,
247              json_mode: true,
248              stage: 'scoring',
249              siteId,
250            })
251          );
252  
253          const { content, usage } = response;
254          const result = safeJsonParse(content);
255  
256          if (!result) {
257            throw new Error('Failed to parse JSON response');
258          }
259  
260          // Sanitize and validate LLM response (clamp scores, drop unexpected fields)
261          validateScoringResponse(result);
262  
263          // Validate the LLM returned factor scores (needed for programmatic score computation)
264          if (!result.factor_scores) {
265            const completionTokens = usage?.completionTokens ?? '?';
266            const truncated = typeof completionTokens === 'number' && completionTokens >= 3900;
267            throw new Error(
268              `Incomplete LLM response: missing factor_scores — ` +
269                `completionTokens=${completionTokens}${truncated ? ' (HIT MAX_TOKENS — increase max_tokens)' : ' (partial JSON — likely rate limit burst)'}`
270            );
271          }
272  
273          // Compute score and grade programmatically from factor scores
274          if (!result.overall_calculation) result.overall_calculation = {};
275          const computedScore = computeScoreFromFactors(result.factor_scores);
276          const computedGrade = computeGrade(computedScore);
277          result.overall_calculation.conversion_score = computedScore;
278          result.overall_calculation.letter_grade = computedGrade;
279  
280          logger.success(
281            `Scored ${domain}: ${computedGrade} (${computedScore}) (${getProviderDisplayName()}) - ${usage.promptTokens + usage.completionTokens} tokens`
282          );
283  
284          return result;
285        });
286      },
287      {
288        maxRetries: 3,
289        shouldRetry: isRetryableError,
290        onRetry: (attempt, error) => {
291          logger.warn(`Retry ${attempt + 1}/3 for ${domain}: ${error.message}`);
292        },
293      }
294    );
295  }
296  
297  /**
298   * Call LLM API for resubmit scoring
299   */
300  // eslint-disable-next-line require-await -- Wraps retryWithBackoff which handles async
301  async function callResubmitAPI({
302    url,
303    domain,
304    initialScore,
305    belowFoldScreenshot,
306    html,
307    visionText,
308    prompt,
309    siteId,
310  }) {
311    return retryWithBackoff(
312      // eslint-disable-next-line require-await -- Wrapper for circuit breaker fire()
313      async () => {
314        // Wrap the API call with circuit breaker
315        return openRouterBreaker.fire(async () => {
316          // Build user message content
317          const userContent = [
318            {
319              type: 'text',
320              text: `Re-evaluate this website with below-fold content:\n\nURL: ${url}\nDomain: ${domain}\n\nInitial Score:\n${JSON.stringify(initialScore, null, 2)}\n\nHTML DOM (first 50000 chars):\n${html.substring(0, 50000)}${visionText ? `\n\nText extracted from below-fold screenshot:\n${visionText}` : ''}`,
321            },
322          ];
323  
324          // Only include screenshot if computer vision is enabled for rescoring AND screenshot exists
325          if (ENABLE_VISION && belowFoldScreenshot) {
326            userContent.push({
327              type: 'image_url',
328              image_url: {
329                url: `data:image/jpeg;base64,${belowFoldScreenshot.toString('base64')}`,
330                detail: 'low',
331              },
332            });
333          }
334  
335          const messages = [
336            {
337              role: 'system',
338              content: prompt,
339            },
340            {
341              role: 'user',
342              content: userContent,
343            },
344          ];
345  
346          const response = await openRouterLimiter.schedule(() =>
347            callLLM({
348              model: SCORING_MODEL,
349              messages,
350              temperature: 0.3,
351              max_tokens: 3000,
352              json_mode: true,
353              stage: 'rescoring',
354              siteId,
355            })
356          );
357  
358          const { content, usage } = response;
359          const result = safeJsonParse(content);
360  
361          if (!result) {
362            throw new Error('Failed to parse resubmit JSON response');
363          }
364  
365          // Compute score and grade programmatically from factor scores
366          if (result.factor_scores) {
367            if (!result.overall_calculation) result.overall_calculation = {};
368            const computedScore = computeScoreFromFactors(result.factor_scores);
369            const computedGrade = computeGrade(computedScore);
370            result.overall_calculation.conversion_score = computedScore;
371            result.overall_calculation.letter_grade = computedGrade;
372          }
373  
374          const resubGrade = result.overall_calculation?.letter_grade || 'N/A';
375          const resubScore = result.overall_calculation?.conversion_score || 'N/A';
376          logger.success(
377            `Resubmit scored ${domain}: ${resubGrade} (${resubScore}) (${getProviderDisplayName()}) - ${usage.promptTokens + usage.completionTokens} tokens`
378          );
379  
380          return result;
381        });
382      },
383      {
384        maxRetries: 3,
385        shouldRetry: isRetryableError,
386      }
387    );
388  }
389  
390  /**
391   * Check if score requires resubmit (B- or below = score < 83)
392   */
393  function shouldResubmit(grade) {
394    if (!grade) return false;
395  
396    const lowGrades = ['B-', 'C+', 'C', 'C-', 'D+', 'D', 'D-', 'F'];
397    return lowGrades.includes(grade);
398  }
399  
400  /**
401   * Extract letter grade from scoring result
402   */
403  export function extractGrade(scoringResult) {
404    return scoringResult?.overall_calculation?.letter_grade || null;
405  }
406  
407  /**
408   * Extract numeric score from scoring result
409   */
410  export function extractScore(scoringResult) {
411    return scoringResult?.overall_calculation?.conversion_score || null;
412  }
413  
414  export default {
415    scoreWebsite,
416    extractGrade,
417    extractScore,
418    shouldResubmit,
419    computeScoreFromFactors,
420    computeGrade,
421  };