diff --git a/README.md b/README.md index df3d67f..85250a1 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,33 @@ cd apps/dashboard && bun run dev - `bun run build` - Build for production - `bun run preview` - Preview production build +## Environment Variables + +Create a `.env` file in the project root with the following variables: + +```bash +# ADP Configuration (for WeatherKit) +ADP_SERVICE_ID=your_service_id +ADP_TEAM_ID=your_team_id +ADP_KEY_ID=your_key_id +ADP_KEY_PATH=./adp_auth_key.p8 + +# Groq API Configuration (for TFL status and weather summarization) +GROQ_API_KEY=your_groq_api_key_here +# Get your API key at: https://console.groq.com + +# Beszel Configuration (Optional) +BESZEL_HOST= +BESZEL_EMAIL= +BESZEL_PASSWORD= + +# MQTT Configuration +MQTT_HOST=your_mqtt_host +MQTT_PORT=1883 +MQTT_USERNAME=your_mqtt_username +MQTT_PASSWORD=your_mqtt_password +``` + ## API Endpoints ### Backend diff --git a/apps/backend/src/env.d.ts b/apps/backend/src/env.d.ts index f53998a..b4568c8 100644 --- a/apps/backend/src/env.d.ts +++ b/apps/backend/src/env.d.ts @@ -4,7 +4,7 @@ declare namespace NodeJS { ADP_SERVICE_ID: string ADP_KEY_ID: string ADP_KEY_PATH: string - GEMINI_API_KEY: string + GROQ_API_KEY: string BESZEL_HOST?: string BESZEL_EMAIL?: string BESZEL_PASSWORD?: string diff --git a/apps/backend/src/tfl/gemini.ts b/apps/backend/src/tfl/gemini.ts index 1371503..20f1e7c 100644 --- a/apps/backend/src/tfl/gemini.ts +++ b/apps/backend/src/tfl/gemini.ts @@ -1,5 +1,6 @@ /** - * Gemini AI integration for shortening TfL disruption descriptions + * Groq AI integration for shortening TfL disruption descriptions + * Uses Groq's OpenAI-compatible API with GPT OSS 120B model */ import { getCachedShortened, setCachedShortened } from "./cache" @@ -38,22 +39,24 @@ function stripLineName(text: string, lineName: string): string { } /** - * Shorten multiple disruption reasons in a single Gemini API call + * Shorten multiple disruption reasons in a single Groq API call */ export async function shortenMultipleDisruptions( disruptions: DisruptionToShorten[] ): Promise> { - const apiKey = process.env.GEMINI_API_KEY + const apiKey = process.env.GROQ_API_KEY const results = new Map() - if (!apiKey) { - console.warn("GEMINI_API_KEY not set, returning stripped versions") + if (!apiKey || apiKey.trim() === "") { + console.warn("GROQ_API_KEY not set or empty, returning stripped versions") for (const disruption of disruptions) { results.set(disruption.lineName, stripLineName(disruption.reason, disruption.lineName)) } return results } + console.log(`[TFL Groq] Processing ${disruptions.length} disruptions with API key (length: ${apiKey.length})`) + // Filter disruptions that need shortening const toShorten: DisruptionToShorten[] = [] @@ -80,41 +83,58 @@ export async function shortenMultipleDisruptions( // If nothing needs shortening, return early if (toShorten.length === 0) { + console.log(`[TFL Groq] All ${disruptions.length} disruptions were cached or already short`) return results } + console.log(`[TFL Groq] Shortening ${toShorten.length} disruptions via API`) + // Build batch prompt const prompt = buildBatchShorteningPrompt(toShorten) try { + // Groq uses OpenAI-compatible API format const response = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key=${apiKey}`, + "https://api.groq.com/openai/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, }, body: JSON.stringify({ - contents: [ + model: "openai/gpt-oss-120b", + messages: [ { - parts: [ - { - text: prompt, - }, - ], + role: "user", + content: prompt, }, ], - generationConfig: { - temperature: 0.3, - maxOutputTokens: 2000, // Higher limit to account for thinking tokens in Gemini 2.5 Flash - topP: 0.9, - }, + temperature: 0.3, + max_tokens: 2000, + top_p: 0.9, }), } ) if (!response.ok) { - console.error(`Gemini API error: ${response.status}`) + const errorText = await response.text().catch(() => "Unable to read error response") + console.error(`Groq API error: ${response.status} ${response.statusText}`) + + // Check for quota/rate limit errors + if (response.status === 429) { + try { + const errorJson = JSON.parse(errorText) + const message = errorJson.error?.message || errorText + console.error("[TFL Groq] QUOTA EXCEEDED - Rate limit hit!") + console.error("[TFL Groq] Error details:", message) + } catch { + console.error("Error response:", errorText) + } + } else { + console.error("Error response:", errorText) + } + // Fallback to stripped versions for (const disruption of toShorten) { results.set(disruption.lineName, disruption.reason) @@ -123,7 +143,18 @@ export async function shortenMultipleDisruptions( } const data = (await response.json()) as any - const responseText = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || "" + + // Groq uses OpenAI-compatible response format + const responseText = data.choices?.[0]?.message?.content?.trim() || "" + + if (!responseText) { + console.error("Groq API returned empty response text. Full response:", JSON.stringify(data, null, 2)) + // Fallback to stripped versions + for (const disruption of toShorten) { + results.set(disruption.lineName, disruption.reason) + } + return results + } // Parse JSON response try { @@ -136,8 +167,21 @@ export async function shortenMultipleDisruptions( const shortened = JSON.parse(jsonText) as ShortenedResult[] + if (!Array.isArray(shortened)) { + console.error("Gemini API response is not an array:", shortened) + // Fallback to stripped versions + for (const disruption of toShorten) { + results.set(disruption.lineName, disruption.reason) + } + return results + } + // Map results for (const item of shortened) { + if (!item.lineName || !item.shortened) { + console.warn("Invalid shortened result item:", item) + continue + } results.set(item.lineName, item.shortened) // Cache the result const original = toShorten.find(d => d.lineName === item.lineName) @@ -145,9 +189,12 @@ export async function shortenMultipleDisruptions( setCachedShortened(original.reason, item.shortened) } } + + console.log(`[TFL Groq] Successfully shortened ${results.size} disruptions`) } catch (parseError) { - console.error("Failed to parse Gemini JSON response:", parseError) - console.error("Response was:", responseText) + console.error("Failed to parse Groq JSON response:", parseError) + console.error("Response text was:", responseText) + console.error("Full API response:", JSON.stringify(data, null, 2)) // Fallback to stripped versions for (const disruption of toShorten) { results.set(disruption.lineName, disruption.reason) @@ -165,7 +212,7 @@ export async function shortenMultipleDisruptions( } /** - * Builds a batch prompt for Gemini to shorten multiple disruptions at once + * Builds a batch prompt for Groq to shorten multiple disruptions at once */ function buildBatchShorteningPrompt(disruptions: DisruptionToShorten[]): string { const disruptionsList = disruptions.map((d, i) => diff --git a/apps/backend/src/weather-kit/gemini.ts b/apps/backend/src/weather-kit/gemini.ts index 7bc2c78..29e821d 100644 --- a/apps/backend/src/weather-kit/gemini.ts +++ b/apps/backend/src/weather-kit/gemini.ts @@ -1,5 +1,6 @@ /** - * Gemini AI integration for generating weather descriptions + * Groq AI integration for generating weather descriptions + * Uses Groq's OpenAI-compatible API with GPT OSS 120B model */ interface WeatherData { @@ -20,53 +21,60 @@ interface WeatherData { } /** - * Generates a concise weather description using Gemini 2.5 Flash + * Generates a concise weather description using Groq's GPT OSS 120B */ export async function generateWeatherDescription( weatherData: WeatherData, ): Promise { - const apiKey = process.env.GEMINI_API_KEY; + const apiKey = process.env.GROQ_API_KEY; if (!apiKey) { - throw new Error("GEMINI_API_KEY environment variable is not set"); + throw new Error("GROQ_API_KEY environment variable is not set"); } const prompt = buildWeatherPrompt(weatherData); try { + // Groq uses OpenAI-compatible API format const response = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key=${apiKey}`, + "https://api.groq.com/openai/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, }, body: JSON.stringify({ - contents: [ + model: "openai/gpt-oss-120b", + messages: [ { - parts: [ - { - text: prompt, - }, - ], + role: "user", + content: prompt, }, ], - generationConfig: { - temperature: 0.7, - maxOutputTokens: 1000, - topP: 0.95, - }, + temperature: 0.7, + max_tokens: 1000, + top_p: 0.95, }), }, ); if (!response.ok) { - throw new Error(`Gemini API error: ${response.status}`); + const errorText = await response.text().catch(() => "Unable to read error response"); + console.error(`Groq API error: ${response.status} ${response.statusText}`); + console.error("Error response:", errorText); + throw new Error(`Groq API error: ${response.status}`); } const data = (await response.json()) as any; - const description = - data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ""; + // Groq uses OpenAI-compatible response format + const description = data.choices?.[0]?.message?.content?.trim() || ""; + + if (!description) { + console.error("Groq API returned empty response. Full response:", JSON.stringify(data, null, 2)); + // Fallback to basic description + return `${weatherData.condition}, ${Math.round(weatherData.temperature)}°C`; + } return description; } catch (error) { @@ -77,7 +85,7 @@ export async function generateWeatherDescription( } /** - * Builds an optimized prompt for Gemini to generate weather descriptions + * Builds an optimized prompt for Groq to generate weather descriptions */ function buildWeatherPrompt(weatherData: WeatherData): string { let laterConditions = ""; diff --git a/apps/backend/src/weather.ts b/apps/backend/src/weather.ts index 6c7672d..5aa25f3 100644 --- a/apps/backend/src/weather.ts +++ b/apps/backend/src/weather.ts @@ -170,7 +170,7 @@ weather.get("/description/:lat/:lon", async (c) => { // Get tomorrow's forecast if it's nighttime const tomorrow = isNighttime ? data.forecastDaily?.days?.[1] : null - // Generate description using Gemini + // Generate description using Groq const description = await generateWeatherDescription({ condition: current.conditionCode, temperature: current.temperature, diff --git a/apps/backend/test-groq-weather.ts b/apps/backend/test-groq-weather.ts new file mode 100644 index 0000000..4a06bee --- /dev/null +++ b/apps/backend/test-groq-weather.ts @@ -0,0 +1,184 @@ +/** + * Test script to verify Groq API integration for weather descriptions + */ + +const apiKey = process.env.GROQ_API_KEY + +if (!apiKey || apiKey.trim() === "") { + console.error("ERROR: GROQ_API_KEY is not set or empty") + console.error("Please check your .env file") + console.error("Add: GROQ_API_KEY=your_api_key_here") + process.exit(1) +} + +console.log(`API Key found (length: ${apiKey.length}, starts with: ${apiKey.substring(0, 10)}...)`) + +// Sample weather data +const sampleWeatherData = { + condition: "PartlyCloudy", + temperature: 18, + feelsLike: 16, + humidity: 0.65, + windSpeed: 15, + precipitationChance: 0.3, + uvIndex: 6, + daytimeCondition: "MostlyCloudy", + isNighttime: false, +} + +// Build the same prompt that the real code uses +function buildWeatherPrompt(weatherData: typeof sampleWeatherData): string { + let laterConditions = "" + + // If it's nighttime, mention tomorrow's weather + if (weatherData.isNighttime && (weatherData as any).tomorrowCondition) { + laterConditions = `\n\nTomorrow's forecast: +- Condition: ${(weatherData as any).tomorrowCondition} +- High: ${(weatherData as any).tomorrowHighTemp ? Math.round((weatherData as any).tomorrowHighTemp) : "N/A"}°C +- Low: ${(weatherData as any).tomorrowLowTemp ? Math.round((weatherData as any).tomorrowLowTemp) : "N/A"}°C +${(weatherData as any).tomorrowPrecipitationChance ? `- Precipitation chance: ${Math.round((weatherData as any).tomorrowPrecipitationChance * 100)}%` : ""}` + } + // Otherwise, mention changes later today + else if (weatherData.daytimeCondition || (weatherData as any).overnightCondition) { + laterConditions = `\n- Later today: ${weatherData.daytimeCondition || (weatherData as any).overnightCondition}` + } + + return `Generate a concise, natural weather description for a dashboard. Keep it under 25 words. + +Current conditions: +- Condition: ${weatherData.condition} +- Feels like: ${Math.round(weatherData.feelsLike)}°C +- Humidity: ${Math.round(weatherData.humidity * 100)}% +- Wind speed: ${Math.round(weatherData.windSpeed)} km/h +${weatherData.precipitationChance ? `- Precipitation chance: ${Math.round(weatherData.precipitationChance * 100)}%` : ""} +- UV index: ${weatherData.uvIndex}${laterConditions} + +Requirements: +- Be conversational and friendly +- Focus on what matters most (condition, any warnings) +- DO NOT mention the current temperature - it will be displayed separately +- CRITICAL: If it's nighttime and tomorrow's forecast is provided, PRIORITIZE tomorrow's weather (e.g., "Cool night. Tomorrow will be partly cloudy with a high of 10°C.") +- If it's daytime and conditions change later, mention it (e.g., "turning cloudy later", "clearing up tonight") +- Tomorrow's temperature is OK to mention +- Mention feels-like only if significantly different (>3°C) and explain WHY (e.g., "due to wind", "due to humidity") +- Include precipitation chance if >30% +- For wind: Use descriptive terms (calm, light, moderate, strong, extreme) - NEVER use specific km/h numbers +- For UV: Use descriptive terms (low, moderate, high, very high, extreme) - NEVER use specific numbers +- Warn about extreme conditions (very hot/cold, high UV, strong winds) +- Use natural language, not technical jargon +- NO emojis +- One or two short sentences maximum + +Example good outputs (DAYTIME): +- "Partly cloudy and pleasant. Light winds make it comfortable." +- "Clear skies, but feels hotter. High UV - wear sunscreen." +- "Mostly sunny, turning cloudy later. Comfortable conditions." +- "Rainy with 70% chance of more rain. Bring an umbrella." +- "Feels much colder due to strong winds. Bundle up." +- "Cloudy and mild, clearing up tonight." +- "Feels warmer due to humidity. Stay hydrated." + +Example good outputs (NIGHTTIME - focus on tomorrow): +- "Cool night. Tomorrow will be sunny and warm with a high of 24°C." +- "Clear skies. Expect partly cloudy skies tomorrow, high of 10°C." +- "Chilly night. Tomorrow brings rain with a high of 15°C." +- "Mild evening. Tomorrow will be hot and sunny, reaching 32°C." + +Example BAD outputs (avoid these): +- "Mostly clear at 7°C, feels like 0°C due to the 21 km/h wind." ❌ (don't mention current temp, don't use specific wind speed) +- "Sunny at 28°C with UV index of 9." ❌ (don't mention current temp, don't use specific UV number) +- "Temperature is 22°C with 65% humidity." ❌ (don't mention current temp, too technical) + +Generate description:` +} + +const prompt = buildWeatherPrompt(sampleWeatherData) + +console.log("\n=== Sending request to Groq API ===\n") +console.log("URL:", "https://api.groq.com/openai/v1/chat/completions") +console.log("Model:", "openai/gpt-oss-120b") +console.log("\nPrompt length:", prompt.length, "characters") +console.log("\nFirst 500 chars of prompt:") +console.log(prompt.substring(0, 500)) +console.log("...\n") + +const requestBody = { + model: "openai/gpt-oss-120b", + messages: [ + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 1000, + top_p: 0.95, +} + +try { + console.log("Making API request...\n") + const response = await fetch( + "https://api.groq.com/openai/v1/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify(requestBody), + } + ) + + console.log("=== Response Status ===") + console.log(`Status: ${response.status} ${response.statusText}`) + console.log(`OK: ${response.ok}`) + + if (!response.ok) { + const errorText = await response.text() + console.log("\n=== Error Response Body ===") + console.log(errorText) + try { + const errorJson = JSON.parse(errorText) + console.log("\n=== Parsed Error JSON ===") + console.log(JSON.stringify(errorJson, null, 2)) + } catch { + // Not JSON, that's fine + } + process.exit(1) + } + + const data = await response.json() + + console.log("\n=== Full API Response ===") + console.log(JSON.stringify(data, null, 2)) + + console.log("\n=== Extracting Response Text ===") + const description = data.choices?.[0]?.message?.content?.trim() || "" + + if (!description) { + console.error("ERROR: Response text is empty!") + console.log("Response structure:") + console.log("- choices exists:", !!data.choices) + console.log("- choices[0] exists:", !!data.choices?.[0]) + console.log("- message exists:", !!data.choices?.[0]?.message) + console.log("- content exists:", !!data.choices?.[0]?.message?.content) + process.exit(1) + } + + console.log("\n=== Weather Description ===") + console.log(description) + console.log("\n=== Description Length ===") + console.log(description.length, "characters") + console.log(description.split(" ").length, "words") + + console.log("\n✅ Test completed successfully!") +} catch (error) { + console.error("\n=== Request Failed ===") + console.error(error) + if (error instanceof Error) { + console.error("Error message:", error.message) + console.error("Error stack:", error.stack) + } + process.exit(1) +} + diff --git a/apps/backend/test-groq.ts b/apps/backend/test-groq.ts new file mode 100644 index 0000000..1a7cfe3 --- /dev/null +++ b/apps/backend/test-groq.ts @@ -0,0 +1,190 @@ +/** + * Test script to diagnose Groq API issues with TFL status summarization + */ + +const apiKey = process.env.GROQ_API_KEY + +if (!apiKey || apiKey.trim() === "") { + console.error("ERROR: GROQ_API_KEY is not set or empty") + console.error("Please check your .env file") + console.error("Add: GROQ_API_KEY=your_api_key_here") + process.exit(1) +} + +console.log(`API Key found (length: ${apiKey.length}, starts with: ${apiKey.substring(0, 10)}...)`) + +// Sample TFL disruption data (similar to what the real code sends) +const sampleDisruptions = [ + { + lineName: "Piccadilly", + status: "Part Suspended", + reason: "Piccadilly Line: No service between Rayners Lane and Uxbridge due to Storm Benjamin. Use Metropolitan line services between Rayners Lane and Uxbridge. Good service on the rest of the line." + }, + { + lineName: "Central", + status: "Minor Delays", + reason: "Central Line: Minor delays due to train cancellations." + } +] + +// Build the same prompt that the real code uses +function buildBatchShorteningPrompt(disruptions: typeof sampleDisruptions): string { + const disruptionsList = disruptions.map((d, i) => + `${i + 1}. Line: ${d.lineName}\n Status: ${d.status}\n Message: "${d.reason}"` + ).join('\n\n') + + return `Shorten these London transport disruption messages for a dashboard display. Return your response as a JSON array. + +Disruptions to shorten: +${disruptionsList} + +Requirements: +- Keep each shortened message under 80 characters +- Be concise but keep essential information (reason, locations, alternatives, time info) +- DO NOT include line names in the shortened text (they're displayed separately) +- Use natural, clear language +- NO emojis + +Return ONLY a JSON array in this exact format: +[ + {"lineName": "Piccadilly", "shortened": "Suspended Rayners Lane-Uxbridge until Fri due to Storm Benjamin. Use Metropolitan line."}, + {"lineName": "Central", "shortened": "Minor delays due to train cancellations"}, + ... +] + +Good examples of shortened messages: +- "Suspended Rayners Lane-Uxbridge until Fri due to Storm Benjamin. Use Metropolitan line." +- "Minor delays due to train cancellations" +- "Minor delays due to earlier incidents at Gospel Oak & Highbury" +- "Severe delays - signal failure at King's Cross. Use buses/Elizabeth line." +- "No service Earls Court-Wimbledon until Sun 27 Oct (engineering)" + +Generate JSON array:` +} + +const prompt = buildBatchShorteningPrompt(sampleDisruptions) + +console.log("\n=== Sending request to Groq API ===\n") +console.log("URL:", "https://api.groq.com/openai/v1/chat/completions") +console.log("Model:", "openai/gpt-oss-120b") +console.log("\nPrompt length:", prompt.length, "characters") +console.log("\nFirst 500 chars of prompt:") +console.log(prompt.substring(0, 500)) +console.log("...\n") + +const requestBody = { + model: "openai/gpt-oss-120b", + messages: [ + { + role: "user", + content: prompt, + }, + ], + temperature: 0.3, + max_tokens: 2000, + top_p: 0.9, +} + +try { + console.log("Making API request...\n") + const response = await fetch( + "https://api.groq.com/openai/v1/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify(requestBody), + } + ) + + console.log("=== Response Status ===") + console.log(`Status: ${response.status} ${response.statusText}`) + console.log(`OK: ${response.ok}`) + console.log("\n=== Response Headers ===") + for (const [key, value] of response.headers.entries()) { + console.log(`${key}: ${value}`) + } + + if (!response.ok) { + const errorText = await response.text() + console.log("\n=== Error Response Body ===") + console.log(errorText) + try { + const errorJson = JSON.parse(errorText) + console.log("\n=== Parsed Error JSON ===") + console.log(JSON.stringify(errorJson, null, 2)) + } catch { + // Not JSON, that's fine + } + process.exit(1) + } + + const data = await response.json() + + console.log("\n=== Full API Response ===") + console.log(JSON.stringify(data, null, 2)) + + console.log("\n=== Extracting Response Text ===") + const responseText = data.choices?.[0]?.message?.content?.trim() || "" + + if (!responseText) { + console.error("ERROR: Response text is empty!") + console.log("Response structure:") + console.log("- choices exists:", !!data.choices) + console.log("- choices[0] exists:", !!data.choices?.[0]) + console.log("- message exists:", !!data.choices?.[0]?.message) + console.log("- content exists:", !!data.choices?.[0]?.message?.content) + process.exit(1) + } + + console.log("\n=== Response Text ===") + console.log(responseText) + console.log("\n=== Response Text Length ===") + console.log(responseText.length, "characters") + + // Try to parse JSON + console.log("\n=== Attempting to Parse JSON ===") + let jsonText = responseText + const jsonMatch = responseText.match(/```json\s*([\s\S]*?)\s*```/) + if (jsonMatch) { + console.log("Found JSON in markdown code block") + jsonText = jsonMatch[1] + } else { + console.log("No markdown code block found, using response text directly") + } + + try { + const shortened = JSON.parse(jsonText) + console.log("\n=== Successfully Parsed JSON ===") + console.log(JSON.stringify(shortened, null, 2)) + + if (Array.isArray(shortened)) { + console.log("\n=== Summary ===") + console.log(`Parsed ${shortened.length} items:`) + for (const item of shortened) { + console.log(` - ${item.lineName}: "${item.shortened}"`) + } + } else { + console.log("WARNING: Response is not an array!") + } + } catch (parseError) { + console.error("\n=== JSON Parse Error ===") + console.error(parseError) + console.log("\n=== Text that failed to parse ===") + console.log(jsonText) + process.exit(1) + } + + console.log("\n✅ Test completed successfully!") +} catch (error) { + console.error("\n=== Request Failed ===") + console.error(error) + if (error instanceof Error) { + console.error("Error message:", error.message) + console.error("Error stack:", error.stack) + } + process.exit(1) +} +