feat: dynamic trip briefing service
This commit is contained in:
parent
571ae057ad
commit
1a8695c836
|
|
@ -0,0 +1,100 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: trip-service-pvc
|
||||
namespace: ai-agents
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 256Mi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: trip-service
|
||||
namespace: ai-agents
|
||||
labels:
|
||||
app: trip-service
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: trip-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: trip-service
|
||||
spec:
|
||||
initContainers:
|
||||
- name: git-clone
|
||||
image: alpine/git
|
||||
command:
|
||||
- git
|
||||
- clone
|
||||
- http://192.168.8.248:3000/deepkoluguri/agentic-os.git
|
||||
- /app
|
||||
volumeMounts:
|
||||
- name: app-volume
|
||||
mountPath: /app
|
||||
containers:
|
||||
- name: trip-service
|
||||
image: node:18-alpine
|
||||
workingDir: /app/trip-service
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- npm install --omit=dev && node server.js
|
||||
ports:
|
||||
- containerPort: 6002
|
||||
env:
|
||||
- name: DATA_FILE
|
||||
value: /data/trips.json
|
||||
- name: PORT
|
||||
value: "6002"
|
||||
- name: GATEWAY_URL
|
||||
value: http://whatsapp-gateway.ai-agents.svc.cluster.local:5001/send-message
|
||||
- name: OLLAMA_URL
|
||||
value: http://ollama.ai-agents.svc.cluster.local:11434/api/generate
|
||||
- name: PERSONAL_NUM
|
||||
value: "+14085505485"
|
||||
- name: GROUP_URL
|
||||
value: "https://chat.whatsapp.com/0Txfg7iZBbvIuvTbTqqgAX"
|
||||
volumeMounts:
|
||||
- name: app-volume
|
||||
mountPath: /app
|
||||
- name: data-volume
|
||||
mountPath: /data
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 6002
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
volumes:
|
||||
- name: app-volume
|
||||
emptyDir: {}
|
||||
- name: data-volume
|
||||
persistentVolumeClaim:
|
||||
claimName: trip-service-pvc
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: trip-service
|
||||
namespace: ai-agents
|
||||
spec:
|
||||
selector:
|
||||
app: trip-service
|
||||
ports:
|
||||
- port: 6002
|
||||
targetPort: 6002
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "trip-service",
|
||||
"version": "1.0.0",
|
||||
"description": "Dynamic trip briefing service — weather, NPS alerts, TDOT traffic, AI insights via WhatsApp",
|
||||
"main": "server.js",
|
||||
"scripts": { "start": "node server.js" },
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"node-cron": "^3.0.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
const express = require('express');
|
||||
const cron = require('node-cron');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
const DATA_FILE = process.env.DATA_FILE || '/data/trips.json';
|
||||
const GATEWAY_URL = process.env.GATEWAY_URL || 'http://whatsapp-gateway.ai-agents.svc.cluster.local:5001/send-message';
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://ollama.ai-agents.svc.cluster.local:11434/api/generate';
|
||||
const PERSONAL_NUM = process.env.PERSONAL_NUM || '+14085505485';
|
||||
const GROUP_URL = process.env.GROUP_URL || 'https://chat.whatsapp.com/0Txfg7iZBbvIuvTbTqqgAX';
|
||||
const PORT = parseInt(process.env.PORT) || 6002;
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
function loadTrips() {
|
||||
try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function saveTrips(trips) {
|
||||
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(trips, null, 2));
|
||||
}
|
||||
|
||||
// ── HTTP Helpers ─────────────────────────────────────────────────────────────
|
||||
function fetchJson(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const lib = url.startsWith('https') ? https : http;
|
||||
lib.get(url, { headers: { 'User-Agent': 'AgenticOS-TripService/1.0' } }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchText(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const lib = url.startsWith('https') ? https : http;
|
||||
lib.get(url, { headers: { 'User-Agent': 'AgenticOS-TripService/1.0' } }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(data));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Integrations ─────────────────────────────────────────────────────────────
|
||||
async function getWeather(location) {
|
||||
try {
|
||||
const data = await fetchJson(`https://wttr.in/${encodeURIComponent(location)}?format=j1`);
|
||||
const curr = data.current_condition[0];
|
||||
return `${curr.temp_F}°F, ${curr.weatherDesc[0].value}`;
|
||||
} catch (e) {
|
||||
return 'Weather unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
async function getNpsAlerts(parkCode) {
|
||||
if (!parkCode) return null;
|
||||
try {
|
||||
const data = await fetchJson(`https://developer.nps.gov/api/v1/alerts?parkCode=${parkCode}&api_key=DEMO_KEY`);
|
||||
if (data.data && data.data.length > 0) {
|
||||
return data.data.map(a => `⚠️ ${a.title}`).slice(0, 3).join('\n');
|
||||
}
|
||||
return 'No active NPS alerts.';
|
||||
} catch (e) {
|
||||
return 'NPS alerts unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
async function getTdotTraffic() {
|
||||
try {
|
||||
// Basic TDOT Incidents fetch (Smoky Mountains general area filter could be applied, returning raw count for brevity)
|
||||
const data = await fetchJson('https://smartway.tn.gov/Traffic/Api/Incidents');
|
||||
if (data && data.length >= 0) {
|
||||
return `Current active incidents in TN: ${data.length}`;
|
||||
}
|
||||
return 'No traffic data.';
|
||||
} catch (e) {
|
||||
return 'Traffic data unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
async function getAiInsight(activityDetails, location) {
|
||||
return new Promise((resolve) => {
|
||||
const prompt = `Provide one short, clever tip or insight for someone visiting ${location} today, doing: ${activityDetails}. Keep it under 2 sentences.`;
|
||||
const body = JSON.stringify({ model: 'qwen2.5:0.5b', prompt, stream: false });
|
||||
const url = new URL(OLLAMA_URL);
|
||||
const req = http.request({
|
||||
hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
||||
}, (res) => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(d).response.trim()); } catch { resolve('Have a great trip!'); }
|
||||
});
|
||||
});
|
||||
req.on('error', () => resolve('Have a great trip!'));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function sendWhatsApp(to, message) {
|
||||
return new Promise((resolve) => {
|
||||
const body = JSON.stringify({ to, message });
|
||||
const url = new URL(GATEWAY_URL);
|
||||
const req = http.request({
|
||||
hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
||||
}, (res) => {
|
||||
res.on('data', () => {});
|
||||
res.on('end', () => resolve(true));
|
||||
});
|
||||
req.on('error', () => resolve(false));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Briefing Generation ──────────────────────────────────────────────────────
|
||||
async function generateAndSendBriefing(trip, dayPlan) {
|
||||
const weather = await getWeather(dayPlan.location || 'Pigeon Forge, TN');
|
||||
const nps = await getNpsAlerts(trip.npsParkCode);
|
||||
const traffic = await getTdotTraffic();
|
||||
const insight = await getAiInsight(dayPlan.activities, dayPlan.location || 'Smoky Mountains');
|
||||
|
||||
const message = `*🏕️ ${trip.name} - ${dayPlan.date} Briefing*\n\n` +
|
||||
`*🌤️ Weather (${dayPlan.location || 'Pigeon Forge'}):* ${weather}\n\n` +
|
||||
`*📋 Today's Plan:*\n${dayPlan.activities}\n\n` +
|
||||
`*🍴 Meals:*\n${dayPlan.meals}\n\n` +
|
||||
(nps ? `*🌲 Park Alerts:*\n${nps}\n\n` : '') +
|
||||
`*🚗 Traffic Info:*\n${traffic}\n\n` +
|
||||
`*🤖 AI Insight:*\n${insight}`;
|
||||
|
||||
console.log(`[TripService] Sending briefing for ${trip.name} - ${dayPlan.date}`);
|
||||
await sendWhatsApp(PERSONAL_NUM, message);
|
||||
if (trip.sendToGroup && GROUP_URL) {
|
||||
await sendWhatsApp(GROUP_URL, message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Routes ───────────────────────────────────────────────────────────────────
|
||||
app.post('/trips', (req, res) => {
|
||||
const trip = req.body;
|
||||
if (!trip.name || !trip.days) return res.status(400).json({ error: 'Missing name or days' });
|
||||
|
||||
trip.id = `trip_${Date.now()}`;
|
||||
const trips = loadTrips();
|
||||
trips.push(trip);
|
||||
saveTrips(trips);
|
||||
|
||||
res.json({ success: true, id: trip.id });
|
||||
});
|
||||
|
||||
app.get('/trips', (req, res) => {
|
||||
res.json(loadTrips());
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
// ── Scheduler (Runs every minute, checks if time to send) ────────────────────
|
||||
cron.schedule('* * * * *', async () => {
|
||||
const now = new Date();
|
||||
const trips = loadTrips();
|
||||
|
||||
// Format current date as YYYY-MM-DD
|
||||
const tzOffset = -4 * 60; // Assuming EDT for now, ideally use a robust timezone approach
|
||||
const localTime = new Date(now.getTime() + tzOffset * 60000);
|
||||
const todayStr = localTime.toISOString().split('T')[0];
|
||||
const currentHour = localTime.getUTCHours().toString().padStart(2, '0');
|
||||
const currentMin = localTime.getUTCMinutes().toString().padStart(2, '0');
|
||||
const currentTime = `${currentHour}:${currentMin}`;
|
||||
|
||||
for (const trip of trips) {
|
||||
const briefingTime = trip.briefingTime || '07:00';
|
||||
if (currentTime !== briefingTime) continue; // Only trigger at exact minute
|
||||
|
||||
const dayPlan = trip.days.find(d => d.date === todayStr);
|
||||
if (!dayPlan) continue;
|
||||
|
||||
// To prevent duplicate sends if cron runs multiple times in the same minute
|
||||
if (dayPlan.sent) continue;
|
||||
|
||||
try {
|
||||
await generateAndSendBriefing(trip, dayPlan);
|
||||
dayPlan.sent = true;
|
||||
saveTrips(trips);
|
||||
} catch (err) {
|
||||
console.error(`[TripService] Failed to send briefing: ${err.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[TripService] Listening on :${PORT}`);
|
||||
});
|
||||
Loading…
Reference in New Issue