mirror of
https://github.com/vinta/awesome-python.git
synced 2026-08-04 15:05:39 +00:00
feat(farm-aggregator): standalone farm data aggregator service
Node.js service for the farm server that replaces the Chrome extension concept. Pulls from AG-Refine REST API and Dropbox (DairyComp/FeedLync logs), synthesises with Claude Haiku on a cron schedule, pushes summaries back to Dropbox and WhatsApp. - Hourly pulse, daily digest (Haiku), weekly summary (Sonnet) - TomTom geofence webhook receiver — entry/exit events → WhatsApp + Dropbox log - WhatsApp Business API send (gracefully disabled if no credentials) - Express HTTP server with /health, /status, manual /run/* trigger endpoints - Windows start.bat for farm server deployment - .env.example documents all credentials (none committed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBD2dN2KEjzz3UQFa9hEpu
This commit is contained in:
parent
8e375ba687
commit
db1ba75274
14 changed files with 732 additions and 0 deletions
83
farm-aggregator/README.md
Normal file
83
farm-aggregator/README.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Farm Aggregator
|
||||
|
||||
A lightweight Node.js service that runs on the farm server. It pulls data from AG-Refine and Dropbox, synthesises it with Claude Haiku, and pushes summaries back to Dropbox and WhatsApp on a schedule.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
AG-Refine (Render) ─┐
|
||||
DairyComp logs ──┤ Dropbox ──→ farm-aggregator ──→ Haiku ──→ Dropbox /Summaries
|
||||
FeedLync files ──┘ │ WhatsApp
|
||||
│
|
||||
TomTom geofence webhooks ────────────────→─┘
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install Node.js 20+
|
||||
Download from https://nodejs.org/
|
||||
|
||||
### 2. Clone the repo and install dependencies
|
||||
```bat
|
||||
cd farm-aggregator
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Create your .env file
|
||||
```bat
|
||||
copy .env.example .env
|
||||
notepad .env
|
||||
```
|
||||
|
||||
Fill in:
|
||||
- `ANTHROPIC_API_KEY` — your Anthropic key
|
||||
- `DROPBOX_*` — see Dropbox section below
|
||||
- `WHATSAPP_*` — optional, leave blank to skip
|
||||
|
||||
### 4. Dropbox OAuth setup
|
||||
|
||||
1. Go to https://www.dropbox.com/developers/apps and create an app
|
||||
2. Set permissions: `files.content.read`, `files.content.write`
|
||||
3. Generate an access token OR set up the refresh token flow
|
||||
4. Copy App Key, App Secret, and Refresh Token into `.env`
|
||||
|
||||
### 5. Start the service
|
||||
```bat
|
||||
start.bat
|
||||
```
|
||||
|
||||
Or to run as a Windows service (stays running after logout):
|
||||
```bat
|
||||
npm install -g node-windows
|
||||
node install-service.js
|
||||
```
|
||||
|
||||
## Schedules
|
||||
|
||||
| Job | Default | What it does |
|
||||
|-----|---------|--------------|
|
||||
| Pulse | Hourly | Quick snapshot — tickets, lab flags, ops |
|
||||
| Daily | 06:00 | Full daily digest pushed to Dropbox + WhatsApp |
|
||||
| Weekly | Mon 06:00 | Weekly summary (Sonnet, deeper analysis) |
|
||||
|
||||
Change schedules in `.env` using cron syntax.
|
||||
|
||||
## TomTom geofencing
|
||||
|
||||
1. In TomTom Geofencing API, create fences for each field boundary
|
||||
2. Set webhook URL to `http://YOUR_SERVER_IP:3001/webhook/tomtom`
|
||||
3. Entry/exit events are logged to Dropbox and sent to WhatsApp
|
||||
|
||||
## Manual triggers (testing)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/run/pulse
|
||||
curl -X POST http://localhost:3001/run/daily
|
||||
curl -X POST http://localhost:3001/run/weekly
|
||||
```
|
||||
|
||||
## Planned integrations
|
||||
|
||||
- **iyohtah / nTELL AI** — herd health data once API access is confirmed
|
||||
- **Daitrix** — AI camera feed events (scale automation, pen detection)
|
||||
- **Oracle Cloud** — heavy data processing offload
|
||||
41
farm-aggregator/config.js
Normal file
41
farm-aggregator/config.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'dotenv/config';
|
||||
|
||||
export const config = {
|
||||
anthropic: {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
modelDeep: 'claude-sonnet-4-6',
|
||||
},
|
||||
agrefine: {
|
||||
url: process.env.AGREFINE_API_URL ?? 'https://ag-refine.onrender.com/api',
|
||||
apiKey: process.env.AGREFINE_API_KEY ?? '',
|
||||
},
|
||||
dropbox: {
|
||||
appKey: process.env.DROPBOX_APP_KEY,
|
||||
appSecret: process.env.DROPBOX_APP_SECRET,
|
||||
refreshToken: process.env.DROPBOX_REFRESH_TOKEN,
|
||||
paths: {
|
||||
inbox: process.env.DROPBOX_INBOX_PATH ?? '/Farm/Inbox',
|
||||
outbox: process.env.DROPBOX_OUTBOX_PATH ?? '/Farm/Summaries',
|
||||
dairycomp: process.env.DROPBOX_DAIRYCOMP_PATH ?? '/Farm/DairyComp',
|
||||
feedlync: process.env.DROPBOX_FEEDLYNC_PATH ?? '/Farm/FeedLync',
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
accessToken: process.env.WHATSAPP_ACCESS_TOKEN ?? '',
|
||||
phoneId: process.env.WHATSAPP_PHONE_ID ?? '',
|
||||
recipient: process.env.WHATSAPP_RECIPIENT ?? '',
|
||||
enabled: !!(process.env.WHATSAPP_ACCESS_TOKEN && process.env.WHATSAPP_PHONE_ID),
|
||||
},
|
||||
tomtom: {
|
||||
apiKey: process.env.TOMTOM_API_KEY ?? '',
|
||||
},
|
||||
server: {
|
||||
port: parseInt(process.env.WEBHOOK_PORT ?? '3001', 10),
|
||||
},
|
||||
schedule: {
|
||||
pulse: process.env.PULSE_SCHEDULE ?? '0 * * * *',
|
||||
daily: process.env.DAILY_SCHEDULE ?? '0 6 * * *',
|
||||
weekly: process.env.WEEKLY_SCHEDULE ?? '0 6 * * 1',
|
||||
},
|
||||
};
|
||||
82
farm-aggregator/index.js
Normal file
82
farm-aggregator/index.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import 'dotenv/config';
|
||||
import cron from 'node-cron';
|
||||
import express from 'express';
|
||||
import { config } from './config.js';
|
||||
import { runPulse } from './jobs/pulse.js';
|
||||
import { runDaily, runWeekly } from './jobs/digest.js';
|
||||
import { handleGeofenceEvent, getRecentEvents } from './jobs/geofence-handler.js';
|
||||
|
||||
// ── HTTP server ──────────────────────────────────────────────────────────────
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', ts: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// TomTom geofence webhook — configure this URL in TomTom Geofencing API
|
||||
app.post('/webhook/tomtom', (req, res) => {
|
||||
try {
|
||||
const event = handleGeofenceEvent(req.body);
|
||||
res.json({ ok: true, event });
|
||||
} catch (e) {
|
||||
console.error('[webhook/tomtom]', e.message);
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// WhatsApp inbound messages (Meta sends a GET for verification)
|
||||
app.get('/webhook/whatsapp', (req, res) => {
|
||||
const mode = req.query['hub.mode'];
|
||||
const token = req.query['hub.verify_token'];
|
||||
const challenge = req.query['hub.challenge'];
|
||||
if (mode === 'subscribe' && token === process.env.WHATSAPP_VERIFY_TOKEN) {
|
||||
res.status(200).send(challenge);
|
||||
} else {
|
||||
res.status(403).send('Forbidden');
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/webhook/whatsapp', (req, res) => {
|
||||
console.log('[whatsapp webhook]', JSON.stringify(req.body).slice(0, 200));
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
// Status dashboard — last N geofence events
|
||||
app.get('/status', (_req, res) => {
|
||||
res.json({ events: getRecentEvents(50) });
|
||||
});
|
||||
|
||||
// Manual trigger endpoints (for testing)
|
||||
app.post('/run/pulse', async (_req, res) => { const s = await runPulse(); res.json({ ok: true, summary: s }); });
|
||||
app.post('/run/daily', async (_req, res) => { const s = await runDaily(); res.json({ ok: true, summary: s }); });
|
||||
app.post('/run/weekly', async (_req, res) => { const s = await runWeekly(); res.json({ ok: true, summary: s }); });
|
||||
|
||||
app.listen(config.server.port, () => {
|
||||
console.log(`[server] listening on :${config.server.port}`);
|
||||
});
|
||||
|
||||
// ── Scheduler ────────────────────────────────────────────────────────────────
|
||||
|
||||
cron.schedule(config.schedule.pulse, async () => {
|
||||
try { await runPulse(); }
|
||||
catch (e) { console.error('[cron:pulse]', e.message); }
|
||||
});
|
||||
|
||||
cron.schedule(config.schedule.daily, async () => {
|
||||
try { await runDaily(); }
|
||||
catch (e) { console.error('[cron:daily]', e.message); }
|
||||
});
|
||||
|
||||
cron.schedule(config.schedule.weekly, async () => {
|
||||
try { await runWeekly(); }
|
||||
catch (e) { console.error('[cron:weekly]', e.message); }
|
||||
});
|
||||
|
||||
console.log('[farm-aggregator] started');
|
||||
console.log(` pulse: ${config.schedule.pulse}`);
|
||||
console.log(` daily: ${config.schedule.daily}`);
|
||||
console.log(` weekly: ${config.schedule.weekly}`);
|
||||
console.log(` port: ${config.server.port}`);
|
||||
console.log(` whatsapp: ${config.whatsapp.enabled ? 'enabled' : 'disabled (no credentials)'}`);
|
||||
76
farm-aggregator/jobs/digest.js
Normal file
76
farm-aggregator/jobs/digest.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { fetchSnapshot, summariseSnapshot } from '../services/agrefine.js';
|
||||
import { readFolderFiles, readLatestSummaries, uploadSummary } from '../services/dropbox.js';
|
||||
import { sendMessage } from '../services/whatsapp.js';
|
||||
import { haiku, sonnet } from '../services/claude.js';
|
||||
import { FARM_SYSTEM, DAILY_PROMPT, WEEKLY_PROMPT } from '../prompts/system.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
async function buildContext() {
|
||||
const [agSnap, dairycomp, feedlync] = await Promise.allSettled([
|
||||
fetchSnapshot(),
|
||||
readFolderFiles(config.dropbox.paths.dairycomp, 5),
|
||||
readFolderFiles(config.dropbox.paths.feedlync, 5),
|
||||
]);
|
||||
|
||||
const parts = [];
|
||||
if (agSnap.status === 'fulfilled') parts.push(summariseSnapshot(agSnap.value));
|
||||
if (dairycomp.status === 'fulfilled') dairycomp.value.forEach((f) => {
|
||||
parts.push(`--- DairyComp: ${f.name} ---\n${f.text.slice(0, 2000)}`);
|
||||
});
|
||||
if (feedlync.status === 'fulfilled') feedlync.value.forEach((f) => {
|
||||
parts.push(`--- FeedLync: ${f.name} ---\n${f.text.slice(0, 2000)}`);
|
||||
});
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
export async function runDaily() {
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
console.log(`[daily] starting ${ts}`);
|
||||
|
||||
const [context, priorDocs] = await Promise.all([
|
||||
buildContext(),
|
||||
readLatestSummaries(3),
|
||||
]);
|
||||
|
||||
const priorText = priorDocs.map((d) => `[${d.name}]\n${d.text}`).join('\n\n---\n\n');
|
||||
const prompt = DAILY_PROMPT(context, priorText);
|
||||
|
||||
const summary = await haiku(FARM_SYSTEM, prompt, 1024);
|
||||
console.log('[daily] summary:\n', summary);
|
||||
|
||||
const filename = `daily_${ts}.txt`;
|
||||
await uploadSummary(filename, summary).catch((e) =>
|
||||
console.warn('[daily] Dropbox upload failed:', e.message)
|
||||
);
|
||||
await sendMessage(`🌾 Daily Farm Digest — ${ts}\n\n${summary}`).catch((e) =>
|
||||
console.warn('[daily] WhatsApp failed:', e.message)
|
||||
);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export async function runWeekly() {
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
console.log(`[weekly] starting ${ts}`);
|
||||
|
||||
const [context, priorDocs] = await Promise.all([
|
||||
buildContext(),
|
||||
readLatestSummaries(7),
|
||||
]);
|
||||
|
||||
const priorText = priorDocs.map((d) => `[${d.name}]\n${d.text}`).join('\n\n---\n\n');
|
||||
const prompt = WEEKLY_PROMPT(context, priorText);
|
||||
|
||||
const summary = await sonnet(FARM_SYSTEM, prompt, 2048);
|
||||
console.log('[weekly] summary:\n', summary);
|
||||
|
||||
const filename = `weekly_${ts}.txt`;
|
||||
await uploadSummary(filename, summary).catch((e) =>
|
||||
console.warn('[weekly] Dropbox upload failed:', e.message)
|
||||
);
|
||||
await sendMessage(`📋 Weekly Farm Summary — week ending ${ts}\n\n${summary}`).catch((e) =>
|
||||
console.warn('[weekly] WhatsApp failed:', e.message)
|
||||
);
|
||||
|
||||
return summary;
|
||||
}
|
||||
36
farm-aggregator/jobs/geofence-handler.js
Normal file
36
farm-aggregator/jobs/geofence-handler.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { parseWebhookEvent } from '../services/tomtom.js';
|
||||
import { sendMessage } from '../services/whatsapp.js';
|
||||
import { uploadSummary } from '../services/dropbox.js';
|
||||
|
||||
const eventLog = [];
|
||||
|
||||
export function handleGeofenceEvent(rawBody) {
|
||||
const event = parseWebhookEvent(rawBody);
|
||||
const ts = event.timestamp?.slice(0, 16).replace('T', ' ') ?? new Date().toISOString().slice(0, 16);
|
||||
|
||||
eventLog.push(event);
|
||||
if (eventLog.length > 200) eventLog.shift();
|
||||
|
||||
const emoji = event.eventType === 'entry' ? '🟢' : '🔴';
|
||||
const msg = `${emoji} [${ts}] ${event.objectId} ${event.eventType === 'entry' ? 'entered' : 'left'} ${event.fenceName}`;
|
||||
|
||||
console.log('[geofence]', msg);
|
||||
|
||||
sendMessage(msg).catch(() => {});
|
||||
|
||||
const logLine = `${ts} | ${event.eventType} | object: ${event.objectId} | fence: ${event.fenceName} | ${event.lat},${event.lon}\n`;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
uploadSummary(`geofence_log_${today}.txt`, getGeofenceLogText()).catch(() => {});
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
function getGeofenceLogText() {
|
||||
return eventLog
|
||||
.map((e) => `${e.timestamp?.slice(0, 16)} | ${e.eventType} | ${e.objectId} | ${e.fenceName}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function getRecentEvents(limit = 20) {
|
||||
return eventLog.slice(-limit);
|
||||
}
|
||||
56
farm-aggregator/jobs/pulse.js
Normal file
56
farm-aggregator/jobs/pulse.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { fetchSnapshot, summariseSnapshot } from '../services/agrefine.js';
|
||||
import { readFolderFiles, uploadSummary, readLatestSummaries } from '../services/dropbox.js';
|
||||
import { sendMessage } from '../services/whatsapp.js';
|
||||
import { haiku } from '../services/claude.js';
|
||||
import { FARM_SYSTEM, PULSE_PROMPT } from '../prompts/system.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export async function runPulse() {
|
||||
const ts = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
||||
console.log(`[pulse] starting ${ts}`);
|
||||
|
||||
const [agSnap, dairycompFiles, feedlyncFiles] = await Promise.allSettled([
|
||||
fetchSnapshot(),
|
||||
readFolderFiles(config.dropbox.paths.dairycomp, 3),
|
||||
readFolderFiles(config.dropbox.paths.feedlync, 3),
|
||||
]);
|
||||
|
||||
const contextParts = [];
|
||||
|
||||
if (agSnap.status === 'fulfilled') {
|
||||
contextParts.push(summariseSnapshot(agSnap.value));
|
||||
} else {
|
||||
contextParts.push(`AG-Refine: unavailable (${agSnap.reason?.message})`);
|
||||
}
|
||||
|
||||
if (dairycompFiles.status === 'fulfilled' && dairycompFiles.value.length) {
|
||||
contextParts.push('\n--- DairyComp logs ---');
|
||||
dairycompFiles.value.forEach((f) => {
|
||||
contextParts.push(`[${f.name}]\n${f.text.slice(0, 1000)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (feedlyncFiles.status === 'fulfilled' && feedlyncFiles.value.length) {
|
||||
contextParts.push('\n--- FeedLync files ---');
|
||||
feedlyncFiles.value.forEach((f) => {
|
||||
contextParts.push(`[${f.name}]\n${f.text.slice(0, 1000)}`);
|
||||
});
|
||||
}
|
||||
|
||||
const context = contextParts.join('\n\n');
|
||||
const prompt = PULSE_PROMPT(context).replace('{timestamp}', ts);
|
||||
const summary = await haiku(FARM_SYSTEM, prompt, 512);
|
||||
|
||||
console.log('[pulse] summary:\n', summary);
|
||||
|
||||
const filename = `pulse_${ts.replace(/ /g, '_').replace(/:/g, '')}.txt`;
|
||||
await uploadSummary(filename, summary).catch((e) =>
|
||||
console.warn('[pulse] Dropbox upload failed:', e.message)
|
||||
);
|
||||
|
||||
await sendMessage(summary).catch((e) =>
|
||||
console.warn('[pulse] WhatsApp failed:', e.message)
|
||||
);
|
||||
|
||||
return summary;
|
||||
}
|
||||
19
farm-aggregator/package.json
Normal file
19
farm-aggregator/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "farm-aggregator",
|
||||
"version": "0.1.0",
|
||||
"description": "Farm data aggregator — pulls AG-Refine + Dropbox, synthesises with Haiku, pushes summaries",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
"dropbox": "^10.34.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-fetch": "^3.3.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2"
|
||||
}
|
||||
}
|
||||
52
farm-aggregator/prompts/system.js
Normal file
52
farm-aggregator/prompts/system.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export const FARM_SYSTEM = `You are an AI farm data analyst for a dairy operation. Your job is to synthesise incoming data from multiple sources — weigh tickets, lab samples, field operations, DairyComp herd logs, and FeedLync ration records — into clear, actionable summaries.
|
||||
|
||||
Rules:
|
||||
- Be specific: cite actual numbers (DM%, net weights, field names, ticket IDs) rather than vague statements.
|
||||
- Flag anomalies: DM below target, missing lab samples after harvest, gaps in ticket sequence, SCC trends.
|
||||
- Separate facts from inference — if you are estimating, say so.
|
||||
- Keep it tight: farmers are busy. Lead with what matters most right now.
|
||||
- Use plain language, not academic phrasing.`;
|
||||
|
||||
export const PULSE_PROMPT = (context) => `
|
||||
Produce a brief hourly pulse report based on the farm data below.
|
||||
|
||||
Format:
|
||||
📊 HOURLY PULSE — {timestamp}
|
||||
|
||||
ACTIVITY (last hour):
|
||||
- [list any new weigh tickets, operations, or field events]
|
||||
|
||||
FLAGS:
|
||||
- [any anomalies or items requiring attention]
|
||||
|
||||
STANDING STATUS:
|
||||
- [1-2 sentences on current harvest / ration / herd status]
|
||||
|
||||
Data:
|
||||
${context}
|
||||
`.trim();
|
||||
|
||||
export const DAILY_PROMPT = (context, priorSummaries) => `
|
||||
Produce a daily farm digest. Cover:
|
||||
1. Yesterday's harvest activity (loads, fields, total tonnage, average DM%)
|
||||
2. Feed quality highlights (any lab results, NDF/NEL concerns)
|
||||
3. Operational notes (any gaps, equipment issues, crew items visible in data)
|
||||
4. Action items for today
|
||||
|
||||
${priorSummaries ? `Recent context from prior summaries:\n${priorSummaries}\n\n` : ''}
|
||||
Today's data:
|
||||
${context}
|
||||
`.trim();
|
||||
|
||||
export const WEEKLY_PROMPT = (context, priorSummaries) => `
|
||||
Produce a weekly farm summary for the week ending today. Cover:
|
||||
1. Harvest totals by field and cut number
|
||||
2. Feed quality trend (DM%, NDF, RFV averages vs. targets)
|
||||
3. Income over feed cost signal (if ticket + lab data supports it)
|
||||
4. Top 3 risk flags this week
|
||||
5. Top 3 priorities for next week
|
||||
|
||||
${priorSummaries ? `Recent summaries for context:\n${priorSummaries}\n\n` : ''}
|
||||
This week's data:
|
||||
${context}
|
||||
`.trim();
|
||||
73
farm-aggregator/services/agrefine.js
Normal file
73
farm-aggregator/services/agrefine.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { config } from '../config.js';
|
||||
|
||||
const BASE = config.agrefine.url;
|
||||
|
||||
function headers() {
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
if (config.agrefine.apiKey) h['Authorization'] = `Bearer ${config.agrefine.apiKey}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function get(path) {
|
||||
const res = await fetch(`${BASE}${path}`, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`AG-Refine ${res.status}: ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchSnapshot() {
|
||||
const [fields, tickets, labSamples, harvestPlans, operations] = await Promise.allSettled([
|
||||
get('/fields/'),
|
||||
get('/scales/tickets/all'),
|
||||
get('/intelligence/lab-samples'),
|
||||
get('/harvest/plans'),
|
||||
get('/operations/'),
|
||||
]);
|
||||
|
||||
return {
|
||||
fields: fields.status === 'fulfilled' ? fields.value : [],
|
||||
tickets: tickets.status === 'fulfilled' ? tickets.value : [],
|
||||
labSamples: labSamples.status === 'fulfilled' ? labSamples.value : [],
|
||||
harvestPlans: harvestPlans.status === 'fulfilled' ? harvestPlans.value : [],
|
||||
operations: operations.status === 'fulfilled' ? operations.value : [],
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function summariseSnapshot(snap) {
|
||||
const lines = [`AG-Refine snapshot (${snap.fetchedAt?.slice(0, 16)} UTC)`];
|
||||
|
||||
if (snap.fields?.length) {
|
||||
lines.push(`\nFields (${snap.fields.length}):`);
|
||||
snap.fields.slice(0, 10).forEach((f) => {
|
||||
lines.push(` • ${f.name} — ${f.acres ?? '?'} ac, ${f.soil_type ?? 'unknown soil'}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (snap.tickets?.length) {
|
||||
const recent = snap.tickets.slice(0, 5);
|
||||
lines.push(`\nRecent weigh tickets (${snap.tickets.length} total):`);
|
||||
recent.forEach((t) => {
|
||||
const dm = t.dm_pct != null ? ` DM ${t.dm_pct}%` : '';
|
||||
lines.push(` • ${t.date?.slice(0, 10) ?? '?'} ${t.commodity ?? t.label ?? 'load'}: net ${t.net_weight ?? '?'} ${t.unit ?? 'lb'}${dm}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (snap.labSamples?.length) {
|
||||
const recent = snap.labSamples.slice(0, 3);
|
||||
lines.push(`\nRecent lab samples:`);
|
||||
recent.forEach((s) => {
|
||||
const parts = [];
|
||||
if (s.dm_pct != null) parts.push(`DM ${s.dm_pct}%`);
|
||||
if (s.ndf_pct != null) parts.push(`NDF ${s.ndf_pct}%`);
|
||||
if (s.rfv != null) parts.push(`RFV ${s.rfv}`);
|
||||
if (s.nel != null) parts.push(`NEL ${s.nel}`);
|
||||
lines.push(` • ${s.field_name ?? s.field_id ?? '?'} (${s.sample_date?.slice(0, 10) ?? '?'}): ${parts.join(', ')}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (snap.harvestPlans?.length) {
|
||||
lines.push(`\nHarvest plans: ${snap.harvestPlans.length} active`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
24
farm-aggregator/services/claude.js
Normal file
24
farm-aggregator/services/claude.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const client = new Anthropic({ apiKey: config.anthropic.apiKey });
|
||||
|
||||
export async function haiku(systemPrompt, userMessage, maxTokens = 1024) {
|
||||
const msg = await client.messages.create({
|
||||
model: config.anthropic.model,
|
||||
max_tokens: maxTokens,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
});
|
||||
return msg.content[0]?.text ?? '';
|
||||
}
|
||||
|
||||
export async function sonnet(systemPrompt, userMessage, maxTokens = 2048) {
|
||||
const msg = await client.messages.create({
|
||||
model: config.anthropic.modelDeep,
|
||||
max_tokens: maxTokens,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
});
|
||||
return msg.content[0]?.text ?? '';
|
||||
}
|
||||
76
farm-aggregator/services/dropbox.js
Normal file
76
farm-aggregator/services/dropbox.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Dropbox } from 'dropbox';
|
||||
import { config } from '../config.js';
|
||||
|
||||
let _dbx = null;
|
||||
|
||||
function dbx() {
|
||||
if (!_dbx) {
|
||||
_dbx = new Dropbox({
|
||||
clientId: config.dropbox.appKey,
|
||||
clientSecret: config.dropbox.appSecret,
|
||||
refreshToken: config.dropbox.refreshToken,
|
||||
});
|
||||
}
|
||||
return _dbx;
|
||||
}
|
||||
|
||||
export async function listNewFiles(folderPath, since) {
|
||||
const res = await dbx().filesListFolder({ path: folderPath, recursive: false });
|
||||
const entries = res.result.entries.filter(
|
||||
(e) => e['.tag'] === 'file' && (!since || new Date(e.client_modified) > new Date(since))
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function downloadText(path) {
|
||||
const res = await dbx().filesDownload({ path });
|
||||
const buf = await res.result.fileBinary;
|
||||
return Buffer.from(buf).toString('utf-8');
|
||||
}
|
||||
|
||||
export async function uploadSummary(filename, content) {
|
||||
const path = `${config.dropbox.paths.outbox}/${filename}`;
|
||||
await dbx().filesUpload({
|
||||
path,
|
||||
contents: content,
|
||||
mode: { '.tag': 'overwrite' },
|
||||
autorename: false,
|
||||
});
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function readLatestSummaries(maxFiles = 5) {
|
||||
try {
|
||||
const res = await dbx().filesListFolder({ path: config.dropbox.paths.outbox, recursive: false });
|
||||
const files = res.result.entries
|
||||
.filter((e) => e['.tag'] === 'file' && e.name.endsWith('.txt'))
|
||||
.sort((a, b) => new Date(b.client_modified) - new Date(a.client_modified))
|
||||
.slice(0, maxFiles);
|
||||
|
||||
const texts = await Promise.all(files.map(async (f) => {
|
||||
try { return { name: f.name, text: await downloadText(f.path_lower) }; }
|
||||
catch { return null; }
|
||||
}));
|
||||
return texts.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFolderFiles(folderPath, maxFiles = 10) {
|
||||
try {
|
||||
const res = await dbx().filesListFolder({ path: folderPath, recursive: false });
|
||||
const files = res.result.entries
|
||||
.filter((e) => e['.tag'] === 'file')
|
||||
.sort((a, b) => new Date(b.client_modified) - new Date(a.client_modified))
|
||||
.slice(0, maxFiles);
|
||||
|
||||
const texts = await Promise.all(files.map(async (f) => {
|
||||
try { return { name: f.name, text: await downloadText(f.path_lower) }; }
|
||||
catch { return null; }
|
||||
}));
|
||||
return texts.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
70
farm-aggregator/services/tomtom.js
Normal file
70
farm-aggregator/services/tomtom.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* TomTom Geofencing
|
||||
*
|
||||
* This module:
|
||||
* - Provides a webhook receiver for TomTom geofence entry/exit events
|
||||
* - Creates geofences around field boundaries via the TomTom Geofencing API
|
||||
* - Emits structured events consumed by jobs (e.g. log truck departure from field)
|
||||
*
|
||||
* TomTom Geofencing API docs:
|
||||
* https://developer.tomtom.com/geofencing-api/documentation/product-information/introduction
|
||||
*/
|
||||
import { config } from '../config.js';
|
||||
|
||||
const BASE = 'https://api.tomtom.com';
|
||||
|
||||
export async function createGeofence({ name, lat, lon, radiusMeters = 500 }) {
|
||||
const url = `${BASE}/geofencing/1/projects/project/fences?key=${config.tomtom.apiKey}`;
|
||||
const body = {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
radius: radiusMeters,
|
||||
coordinates: [lon, lat],
|
||||
},
|
||||
properties: { name },
|
||||
};
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`TomTom geofence create ${res.status}: ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function listGeofences() {
|
||||
const url = `${BASE}/geofencing/1/projects/project/fences?key=${config.tomtom.apiKey}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`TomTom list fences ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function registerObject({ objectId, externalId = objectId }) {
|
||||
const url = `${BASE}/geofencing/1/projects/project/objects/${objectId}?key=${config.tomtom.apiKey}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ externalId }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`TomTom register object ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function reportPosition({ objectId, lat, lon }) {
|
||||
const url = `${BASE}/geofencing/1/report/${objectId}?key=${config.tomtom.apiKey}&position=${lon},${lat}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`TomTom report position ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function parseWebhookEvent(body) {
|
||||
return {
|
||||
objectId: body.objectId ?? body.object_id,
|
||||
fenceName: body.fenceName ?? body.fence_name,
|
||||
eventType: body.eventType ?? body.event_type, // 'entry' | 'exit'
|
||||
lat: body.lat ?? body.latitude,
|
||||
lon: body.lon ?? body.longitude,
|
||||
timestamp: body.timestamp ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
30
farm-aggregator/services/whatsapp.js
Normal file
30
farm-aggregator/services/whatsapp.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { config } from '../config.js';
|
||||
|
||||
const BASE = 'https://graph.facebook.com/v19.0';
|
||||
|
||||
export async function sendMessage(text, to = config.whatsapp.recipient) {
|
||||
if (!config.whatsapp.enabled) {
|
||||
console.log('[WhatsApp] disabled — message would have been:', text.slice(0, 120));
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}/${config.whatsapp.phoneId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.whatsapp.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messaging_product: 'whatsapp',
|
||||
to,
|
||||
type: 'text',
|
||||
text: { body: text.slice(0, 4096) },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`WhatsApp ${res.status}: ${err}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
14
farm-aggregator/start.bat
Normal file
14
farm-aggregator/start.bat
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
@echo off
|
||||
cd /d "%~dp0"
|
||||
if not exist node_modules (
|
||||
echo Installing dependencies...
|
||||
npm install
|
||||
)
|
||||
if not exist .env (
|
||||
echo ERROR: .env file not found. Copy .env.example to .env and fill in your keys.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Starting Farm Aggregator...
|
||||
node index.js
|
||||
pause
|
||||
Loading…
Add table
Add a link
Reference in a new issue