← back to Norma

agents/price-agent/skills/enrich.js

194 lines

/**
 * enrich.js — Custom route skill
 *
 * Gemini AI fallback for extracting cost data from HTML that
 * Cheerio-based parsers cannot handle (e.g. React-rendered pages,
 * complex tables, or non-standard layouts).
 *
 * Body: { html, school_name, campus_system }
 * Returns: normalized cost object matching tuition_history columns
 */

const fetch = require('node-fetch');
const { logAction } = require('../../shared/audit-logger');

const AGENT_NAME = 'price-agent';
const GEMINI_API_KEY = '${GOOGLE_API_KEY}';
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}`;
const MAX_HTML_LENGTH = 30000;

/**
 * Parse a dollar string like "$12,345" or "12345.00" into an integer.
 */
function parseDollar(str) {
  if (str == null) return null;
  if (typeof str === 'number') return Math.round(str);
  const cleaned = String(str).replace(/[$,\s]/g, '');
  const num = parseFloat(cleaned);
  return isNaN(num) ? null : Math.round(num);
}

module.exports = async function enrich(body = {}) {
  const { html, school_name, campus_system } = body || {};

  if (!html) {
    return { error: 'html parameter is required' };
  }

  const schoolLabel = school_name || 'Unknown School';
  const systemLabel = campus_system || 'California';

  // 1. Truncate HTML to stay within Gemini context limits
  const truncatedHtml = html.substring(0, MAX_HTML_LENGTH);

  // 2. Build Gemini prompt
  const prompt = `You are extracting cost-of-attendance data from a university financial aid webpage.

School: ${schoolLabel}
System: ${systemLabel}

Extract ALL cost fields you can find and return ONLY a JSON object (no markdown code fences, no explanation, just raw JSON) with these exact keys. Use integer dollar amounts (no decimals, no dollar signs). Use null for any field you cannot find.

{
  "tuition_in_state": null,
  "tuition_out_state": null,
  "mandatory_fees": null,
  "room_board_on_campus": null,
  "room_board_off_campus": null,
  "books_supplies": null,
  "other_expenses": null,
  "transportation": null,
  "coa_on_campus": null,
  "coa_off_campus": null,
  "academic_year": null
}

Notes:
- "tuition_in_state" = tuition for CA residents (or "in-state" students)
- "tuition_out_state" = tuition for nonresidents / out-of-state students
- "mandatory_fees" = campus/student fees separate from tuition
- "coa_on_campus" = total cost of attendance living on campus
- "coa_off_campus" = total cost of attendance living off campus
- "academic_year" = the fall-term year as an integer (e.g. 2025 for 2025-2026)
- If tuition and fees are combined into one number, put it in tuition_in_state/tuition_out_state and set mandatory_fees to null
- For community colleges, per-unit fees times ~30 units = annual tuition

HTML content:
${truncatedHtml}`;

  // 3. Call Gemini 2.0 Flash
  let geminiResult = null;
  try {
    const resp = await fetch(GEMINI_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{ parts: [{ text: prompt }] }],
        generationConfig: {
          temperature: 0.1,
          maxOutputTokens: 1500,
        },
      }),
      timeout: 30000,
    });

    if (!resp.ok) {
      const errBody = await resp.text().catch(() => 'unknown');
      console.error(`[enrich] Gemini API error ${resp.status}: ${errBody.substring(0, 200)}`);
      await logAction({
        agent: AGENT_NAME,
        actionType: 'enrich',
        platform: 'gemini',
        content: `Enrichment failed for ${schoolLabel}`,
        status: 'error',
        errorMessage: `Gemini HTTP ${resp.status}`,
      }).catch(() => {});
      return { error: `Gemini API returned ${resp.status}` };
    }

    const data = await resp.json();

    // Extract text from Gemini response
    const text =
      data.candidates &&
      data.candidates[0] &&
      data.candidates[0].content &&
      data.candidates[0].content.parts &&
      data.candidates[0].content.parts[0] &&
      data.candidates[0].content.parts[0].text;

    if (!text) {
      console.warn('[enrich] Gemini returned empty response');
      return { error: 'Empty Gemini response' };
    }

    // 4. Parse Gemini response JSON
    // Handle possible markdown wrapping
    let jsonStr = text.trim();

    // Strip markdown code fences if present
    if (jsonStr.startsWith('```')) {
      jsonStr = jsonStr.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
    }

    // Find JSON object in response
    const jsonMatch = jsonStr.match(/\{[\s\S]*\}/);
    if (!jsonMatch) {
      console.warn('[enrich] Could not find JSON in Gemini response:', jsonStr.substring(0, 200));
      return { error: 'No JSON in Gemini response' };
    }

    geminiResult = JSON.parse(jsonMatch[0]);
  } catch (err) {
    console.error(`[enrich] Gemini call failed for ${schoolLabel}:`, err.message);
    await logAction({
      agent: AGENT_NAME,
      actionType: 'enrich',
      platform: 'gemini',
      content: `Enrichment failed for ${schoolLabel}`,
      status: 'error',
      errorMessage: err.message,
    }).catch(() => {});
    return { error: err.message };
  }

  // 5. Normalize the response to match tuition_history columns
  const normalized = {
    tuition_in_state: parseDollar(geminiResult.tuition_in_state),
    tuition_out_state: parseDollar(geminiResult.tuition_out_state),
    mandatory_fees: parseDollar(geminiResult.mandatory_fees),
    room_board_on_campus: parseDollar(geminiResult.room_board_on_campus),
    room_board_off_campus: parseDollar(geminiResult.room_board_off_campus),
    books_supplies: parseDollar(geminiResult.books_supplies),
    other_expenses: parseDollar(geminiResult.other_expenses),
    transportation: parseDollar(geminiResult.transportation),
    coa_on_campus: parseDollar(geminiResult.coa_on_campus),
    coa_off_campus: parseDollar(geminiResult.coa_off_campus),
    academic_year: geminiResult.academic_year != null
      ? parseInt(geminiResult.academic_year, 10) || null
      : null,
  };

  // Count non-null fields for quality check
  const fieldCount = Object.values(normalized).filter((v) => v != null).length;

  console.log(`[enrich] ${schoolLabel}: Gemini extracted ${fieldCount} fields`);

  await logAction({
    agent: AGENT_NAME,
    actionType: 'enrich',
    platform: 'gemini',
    content: `Enriched ${schoolLabel} (${campus_system || 'unknown'}): ${fieldCount} cost fields extracted`,
    responseData: {
      school_name: schoolLabel,
      campus_system: systemLabel,
      fields_extracted: fieldCount,
      tuition_in_state: normalized.tuition_in_state,
      tuition_out_state: normalized.tuition_out_state,
    },
    status: fieldCount > 0 ? 'success' : 'skipped',
  }).catch(() => {});

  return normalized;
};