mirror of
https://github.com/vinta/awesome-python.git
synced 2026-06-27 19:32:12 +00:00
feat: add AgriAgent — real agentic loop with tool use
Multi-step AI agent in src/ag-refine/: - agent.js: Claude tool-use loop (up to 10 iterations), streams events to UI as it thinks and calls tools - tools.js: 6 live tools — get_reading_list, get_field_profiles, get_ingested_files, get_weather (Open-Meteo), lookup_usda_soil (USDA SDA API), calculate_gdd - index.js: chat UI with streaming tool-call display, suggested prompts, clear conversation - Wired as new Agent tab in sidebar (6th tab) - Also fixes manifest.json paths (dist/ prefix removed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBD2dN2KEjzz3UQFa9hEpu
This commit is contained in:
parent
86775eacbd
commit
6f94bd7c79
5 changed files with 676 additions and 0 deletions
127
agrifine-extension/src/ag-refine/agent.js
Normal file
127
agrifine-extension/src/ag-refine/agent.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { sessionGet, KEYS, buildContextBundle } from '../utils/storage.js';
|
||||
import { TOOL_DEFINITIONS, executeTool } from './tools.js';
|
||||
|
||||
const MODEL = 'claude-sonnet-4-6';
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
||||
const MAX_ITERATIONS = 10;
|
||||
|
||||
/**
|
||||
* AgrifineAgent — agentic loop with tool use.
|
||||
*
|
||||
* Runs entirely in the sidebar context via the background worker proxy.
|
||||
* Each call to run() streams back events via an onEvent callback so the
|
||||
* UI can update incrementally as the agent thinks and calls tools.
|
||||
*/
|
||||
export class AgrifineAgent {
|
||||
constructor({ onEvent }) {
|
||||
this.onEvent = onEvent; // ({ type, data }) => void
|
||||
}
|
||||
|
||||
async run(userMessage) {
|
||||
const apiKey = await sessionGet(KEYS.API_KEY);
|
||||
if (!apiKey) {
|
||||
this.onEvent({ type: 'error', data: 'No API key set. Open ⚙ Settings to add your Anthropic key.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contextBundle = await buildContextBundle();
|
||||
|
||||
const systemPrompt = [
|
||||
'You are AgriAgent, an expert AI assistant for farm operations management.',
|
||||
'You have access to the user\'s farm data through tools — always use them before answering.',
|
||||
'When answering questions about fields, weather, yields, or finances: first query the relevant data, then synthesize a clear answer.',
|
||||
'Be specific: cite field names, dates, acreage, and numbers from the actual data.',
|
||||
'For weather queries on a field, always look up the field profile first to get coordinates.',
|
||||
'',
|
||||
'FARM CONTEXT (reading list summaries + field profiles):',
|
||||
contextBundle,
|
||||
].join('\n');
|
||||
|
||||
const messages = [{ role: 'user', content: userMessage }];
|
||||
|
||||
this.onEvent({ type: 'thinking', data: 'Analysing your question…' });
|
||||
|
||||
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
||||
const body = {
|
||||
model: MODEL,
|
||||
max_tokens: 2048,
|
||||
system: systemPrompt,
|
||||
tools: TOOL_DEFINITIONS,
|
||||
messages,
|
||||
};
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await this._callAPI(apiKey, body);
|
||||
} catch (err) {
|
||||
this.onEvent({ type: 'error', data: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// Append assistant turn
|
||||
messages.push({ role: 'assistant', content: response.content });
|
||||
|
||||
if (response.stop_reason === 'end_turn') {
|
||||
// Extract final text
|
||||
const text = response.content
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => b.text)
|
||||
.join('\n');
|
||||
this.onEvent({ type: 'answer', data: text });
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.stop_reason === 'tool_use') {
|
||||
const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
|
||||
const toolResults = [];
|
||||
|
||||
for (const block of toolUseBlocks) {
|
||||
this.onEvent({ type: 'tool_call', data: { name: block.name, input: block.input } });
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await executeTool(block.name, block.input);
|
||||
} catch (err) {
|
||||
result = { error: err.message };
|
||||
}
|
||||
|
||||
this.onEvent({ type: 'tool_result', data: { name: block.name, result } });
|
||||
|
||||
toolResults.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: block.id,
|
||||
content: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
|
||||
messages.push({ role: 'user', content: toolResults });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unexpected stop reason
|
||||
this.onEvent({ type: 'error', data: `Unexpected stop reason: ${response.stop_reason}` });
|
||||
return;
|
||||
}
|
||||
|
||||
this.onEvent({ type: 'error', data: 'Agent reached maximum iterations without completing.' });
|
||||
}
|
||||
|
||||
async _callAPI(apiKey, body) {
|
||||
const res = await fetch(ANTHROPIC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Anthropic API ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
234
agrifine-extension/src/ag-refine/index.js
Normal file
234
agrifine-extension/src/ag-refine/index.js
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { AgrifineAgent } from './agent.js';
|
||||
|
||||
const TOOL_ICONS = {
|
||||
get_reading_list: '📖',
|
||||
get_field_profiles: '🌱',
|
||||
get_ingested_files: '📄',
|
||||
get_weather: '🌤️',
|
||||
lookup_usda_soil: '🏛️',
|
||||
calculate_gdd: '📊',
|
||||
};
|
||||
|
||||
const SUGGESTED_PROMPTS = [
|
||||
'What are my current field conditions and harvest windows?',
|
||||
'Which fields have the best soil for carbon sequestration?',
|
||||
'Summarise all my farm data and flag any issues',
|
||||
'What does the 7-day weather look like for my fields?',
|
||||
'What USDA programs might I qualify for based on my fields?',
|
||||
];
|
||||
|
||||
export function AgRefineModule() {
|
||||
let messages = [];
|
||||
let isRunning = false;
|
||||
|
||||
return {
|
||||
id: 'ag-refine',
|
||||
label: 'AgriAgent',
|
||||
|
||||
async render(container) {
|
||||
container.innerHTML = `
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
<!-- Header bar -->
|
||||
<div class="px-4 pt-4 pb-2 flex-shrink-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-base">🤖</span>
|
||||
<h2 class="text-sm font-bold text-gray-800">AgriAgent</h2>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-agri-100 text-agri-700 font-medium">AI Agent</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">Multi-step reasoning over all your farm data</p>
|
||||
</div>
|
||||
|
||||
<!-- Chat history -->
|
||||
<div id="agent-chat" class="flex-1 overflow-y-auto px-4 py-2 space-y-3"></div>
|
||||
|
||||
<!-- Suggested prompts (shown when empty) -->
|
||||
<div id="agent-suggestions" class="px-4 pb-2 flex-shrink-0">
|
||||
<p class="text-xs text-gray-400 mb-2">Try asking…</p>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
${SUGGESTED_PROMPTS.map((p) => `
|
||||
<button class="suggest-btn text-left text-xs bg-agri-50 hover:bg-agri-100 text-agri-700 px-3 py-2 rounded-lg border border-agri-200 transition">
|
||||
${p}
|
||||
</button>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input bar -->
|
||||
<div class="flex-shrink-0 border-t border-gray-100 px-3 py-3 bg-white">
|
||||
<div class="flex gap-2 items-end">
|
||||
<textarea id="agent-input" rows="2"
|
||||
placeholder="Ask the agent anything about your farm…"
|
||||
class="flex-1 text-sm border border-gray-300 rounded-xl px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400 resize-none"></textarea>
|
||||
<button id="agent-send"
|
||||
class="flex-shrink-0 bg-agri-600 hover:bg-agri-700 disabled:bg-gray-300 text-white rounded-xl px-3 py-2 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button id="agent-clear" class="text-xs text-gray-300 hover:text-gray-500 mt-1 transition">Clear conversation</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._bindEvents(container);
|
||||
this._renderMessages(container);
|
||||
},
|
||||
|
||||
_bindEvents(container) {
|
||||
const input = container.querySelector('#agent-input');
|
||||
const sendBtn = container.querySelector('#agent-send');
|
||||
|
||||
const send = () => {
|
||||
const text = input.value.trim();
|
||||
if (!text || isRunning) return;
|
||||
input.value = '';
|
||||
this._runAgent(text, container);
|
||||
};
|
||||
|
||||
sendBtn.addEventListener('click', send);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
});
|
||||
|
||||
container.querySelectorAll('.suggest-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
input.value = btn.textContent.trim();
|
||||
send();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#agent-clear').addEventListener('click', () => {
|
||||
messages = [];
|
||||
isRunning = false;
|
||||
this._renderMessages(container);
|
||||
});
|
||||
},
|
||||
|
||||
async _runAgent(userText, container) {
|
||||
if (isRunning) return;
|
||||
isRunning = true;
|
||||
|
||||
// Hide suggestions
|
||||
container.querySelector('#agent-suggestions')?.classList.add('hidden');
|
||||
|
||||
// Add user message
|
||||
messages.push({ role: 'user', text: userText });
|
||||
this._renderMessages(container);
|
||||
|
||||
// Thinking placeholder
|
||||
const thinkingId = `thinking_${Date.now()}`;
|
||||
messages.push({ role: 'thinking', id: thinkingId, steps: [] });
|
||||
this._renderMessages(container);
|
||||
|
||||
const thinkingMsg = messages[messages.length - 1];
|
||||
|
||||
const agent = new AgrifineAgent({
|
||||
onEvent: ({ type, data }) => {
|
||||
if (type === 'thinking') {
|
||||
thinkingMsg.steps.push({ type: 'status', text: data });
|
||||
} else if (type === 'tool_call') {
|
||||
thinkingMsg.steps.push({
|
||||
type: 'tool',
|
||||
icon: TOOL_ICONS[data.name] ?? '🔧',
|
||||
name: data.name.replace(/_/g, ' '),
|
||||
input: JSON.stringify(data.input),
|
||||
});
|
||||
} else if (type === 'tool_result') {
|
||||
const last = thinkingMsg.steps[thinkingMsg.steps.length - 1];
|
||||
if (last?.type === 'tool') last.done = true;
|
||||
} else if (type === 'answer') {
|
||||
// Replace thinking bubble with final answer
|
||||
const idx = messages.findIndex((m) => m.id === thinkingId);
|
||||
if (idx >= 0) messages.splice(idx, 1);
|
||||
messages.push({ role: 'assistant', text: data });
|
||||
isRunning = false;
|
||||
} else if (type === 'error') {
|
||||
const idx = messages.findIndex((m) => m.id === thinkingId);
|
||||
if (idx >= 0) messages.splice(idx, 1);
|
||||
messages.push({ role: 'error', text: data });
|
||||
isRunning = false;
|
||||
}
|
||||
this._renderMessages(container);
|
||||
},
|
||||
});
|
||||
|
||||
await agent.run(userText);
|
||||
},
|
||||
|
||||
_renderMessages(container) {
|
||||
const chat = container.querySelector('#agent-chat');
|
||||
if (!chat) return;
|
||||
|
||||
if (messages.length === 0) {
|
||||
chat.innerHTML = '';
|
||||
container.querySelector('#agent-suggestions')?.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
chat.innerHTML = messages.map((msg) => {
|
||||
if (msg.role === 'user') {
|
||||
return `
|
||||
<div class="flex justify-end">
|
||||
<div class="max-w-[85%] bg-agri-600 text-white text-sm px-3 py-2 rounded-2xl rounded-tr-sm">
|
||||
${escapeHtml(msg.text)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (msg.role === 'thinking') {
|
||||
const steps = msg.steps ?? [];
|
||||
return `
|
||||
<div class="flex flex-col gap-1.5">
|
||||
${steps.map((step) => {
|
||||
if (step.type === 'status') {
|
||||
return `<div class="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<span class="spinner flex-shrink-0"></span> ${escapeHtml(step.text)}
|
||||
</div>`;
|
||||
}
|
||||
if (step.type === 'tool') {
|
||||
return `<div class="flex items-center gap-2 text-xs bg-earth-50 border border-earth-200 rounded-lg px-3 py-1.5">
|
||||
<span>${step.icon}</span>
|
||||
<span class="font-medium text-earth-700">${step.name}</span>
|
||||
${step.done ? '<span class="ml-auto text-agri-500">✓</span>' : '<span class="spinner ml-auto flex-shrink-0"></span>'}
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
}).join('')}
|
||||
${steps.length === 0 ? '<div class="flex items-center gap-1.5 text-xs text-gray-400"><span class="spinner"></span> Starting…</div>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
return `
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-agri-100 flex items-center justify-center text-xs">🤖</div>
|
||||
<div class="flex-1 bg-white border border-gray-200 rounded-2xl rounded-tl-sm px-3 py-2.5 text-sm text-gray-800 leading-relaxed shadow-sm whitespace-pre-wrap">
|
||||
${escapeHtml(msg.text)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (msg.role === 'error') {
|
||||
return `
|
||||
<div class="text-xs bg-red-50 border border-red-200 text-red-700 rounded-xl px-3 py-2">
|
||||
⚠️ ${escapeHtml(msg.text)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}).join('');
|
||||
|
||||
// Scroll to bottom
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
304
agrifine-extension/src/ag-refine/tools.js
Normal file
304
agrifine-extension/src/ag-refine/tools.js
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { getReadingList, getIngestedFiles, getFieldProfiles } from '../utils/storage.js';
|
||||
|
||||
// ── Tool definitions sent to Claude ──────────────────────────────────────────
|
||||
|
||||
export const TOOL_DEFINITIONS = [
|
||||
{
|
||||
name: 'get_reading_list',
|
||||
description: 'Retrieve saved web pages from the user\'s reading list. Can filter by tag.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tag: {
|
||||
type: 'string',
|
||||
description: 'Optional tag to filter by: agriculture, equipment, land, carbon, USDA, dairy, finance, weather',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_field_profiles',
|
||||
description: 'Retrieve all farm field profiles including acreage, soil type, coordinates, crop history, and notes.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
field_name: {
|
||||
type: 'string',
|
||||
description: 'Optional field name to filter by (partial match)',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_ingested_files',
|
||||
description: 'Retrieve all uploaded and parsed farm data files (CSV, Excel, PDF). Returns structured JSON extracted from each file.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['CSV', 'Excel', 'PDF'],
|
||||
description: 'Optional file type filter',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_weather',
|
||||
description: 'Fetch current conditions, 7-day forecast, and calculate Growing Degree Days (GDD) for a field location using Open-Meteo (free, no key required).',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
latitude: {
|
||||
type: 'number',
|
||||
description: 'Latitude of the field',
|
||||
},
|
||||
longitude: {
|
||||
type: 'number',
|
||||
description: 'Longitude of the field',
|
||||
},
|
||||
field_name: {
|
||||
type: 'string',
|
||||
description: 'Human-readable field name for context',
|
||||
},
|
||||
},
|
||||
required: ['latitude', 'longitude'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lookup_usda_soil',
|
||||
description: 'Look up soil data from the USDA Web Soil Survey API by coordinates. Returns soil series, texture, organic matter, and drainage class.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
latitude: {
|
||||
type: 'number',
|
||||
description: 'Latitude',
|
||||
},
|
||||
longitude: {
|
||||
type: 'number',
|
||||
description: 'Longitude',
|
||||
},
|
||||
},
|
||||
required: ['latitude', 'longitude'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'calculate_gdd',
|
||||
description: 'Calculate Growing Degree Days from temperature data. Uses base temp of 50°F for forage crops.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
daily_highs: {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
description: 'Array of daily high temperatures in Fahrenheit',
|
||||
},
|
||||
daily_lows: {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
description: 'Array of daily low temperatures in Fahrenheit',
|
||||
},
|
||||
base_temp: {
|
||||
type: 'number',
|
||||
description: 'Base temperature in °F (default 50 for forage crops)',
|
||||
},
|
||||
},
|
||||
required: ['daily_highs', 'daily_lows'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ── Tool implementations ──────────────────────────────────────────────────────
|
||||
|
||||
export async function executeTool(name, input) {
|
||||
switch (name) {
|
||||
case 'get_reading_list':
|
||||
return toolGetReadingList(input);
|
||||
case 'get_field_profiles':
|
||||
return toolGetFieldProfiles(input);
|
||||
case 'get_ingested_files':
|
||||
return toolGetIngestedFiles(input);
|
||||
case 'get_weather':
|
||||
return toolGetWeather(input);
|
||||
case 'lookup_usda_soil':
|
||||
return toolLookupUSDAsoil(input);
|
||||
case 'calculate_gdd':
|
||||
return toolCalculateGDD(input);
|
||||
default:
|
||||
return { error: `Unknown tool: ${name}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function toolGetReadingList({ tag } = {}) {
|
||||
const list = await getReadingList();
|
||||
const filtered = tag ? list.filter((i) => i.tags?.includes(tag)) : list;
|
||||
return {
|
||||
count: filtered.length,
|
||||
items: filtered.slice(0, 30).map((i) => ({
|
||||
title: i.title,
|
||||
url: i.url,
|
||||
summary: i.summary,
|
||||
tags: i.tags,
|
||||
savedAt: i.savedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function toolGetFieldProfiles({ field_name } = {}) {
|
||||
const profiles = await getFieldProfiles();
|
||||
const filtered = field_name
|
||||
? profiles.filter((p) => p.name.toLowerCase().includes(field_name.toLowerCase()))
|
||||
: profiles;
|
||||
return {
|
||||
count: filtered.length,
|
||||
profiles: filtered.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
acres: p.acres,
|
||||
soilType: p.soilType,
|
||||
cluId: p.cluId,
|
||||
coordinates: p.coordinates,
|
||||
notes: p.notes,
|
||||
cropHistory: p.cropHistory,
|
||||
harvestRecords: p.harvestRecords,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function toolGetIngestedFiles({ type } = {}) {
|
||||
const files = await getIngestedFiles();
|
||||
const filtered = type ? files.filter((f) => f.type === type) : files;
|
||||
return {
|
||||
count: filtered.length,
|
||||
files: filtered.map((f) => ({
|
||||
filename: f.filename,
|
||||
type: f.type,
|
||||
uploadedAt: f.uploadedAt,
|
||||
structuredData: f.structuredData,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function toolGetWeather({ latitude, longitude, field_name = 'field' }) {
|
||||
const url = new URL('https://api.open-meteo.com/v1/forecast');
|
||||
url.searchParams.set('latitude', latitude);
|
||||
url.searchParams.set('longitude', longitude);
|
||||
url.searchParams.set('current', 'temperature_2m,precipitation,wind_speed_10m,weather_code');
|
||||
url.searchParams.set('daily', 'temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max');
|
||||
url.searchParams.set('temperature_unit', 'fahrenheit');
|
||||
url.searchParams.set('wind_speed_unit', 'mph');
|
||||
url.searchParams.set('precipitation_unit', 'inch');
|
||||
url.searchParams.set('forecast_days', '7');
|
||||
url.searchParams.set('timezone', 'auto');
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error(`Open-Meteo error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
const current = data.current;
|
||||
const daily = data.daily;
|
||||
|
||||
// GDD accumulation from forecast
|
||||
const gddDays = daily.temperature_2m_max.map((hi, i) => {
|
||||
const lo = daily.temperature_2m_min[i];
|
||||
return Math.max(0, ((hi + lo) / 2) - 50);
|
||||
});
|
||||
const totalGDD = gddDays.reduce((a, b) => a + b, 0);
|
||||
|
||||
// Rain alert: >0.5 inch in next 48h
|
||||
const rainAlert = (daily.precipitation_sum[0] ?? 0) + (daily.precipitation_sum[1] ?? 0) > 0.5;
|
||||
|
||||
// Harvest window
|
||||
const avgRainProb = (daily.precipitation_probability_max.slice(0, 3).reduce((a, b) => a + b, 0) / 3);
|
||||
const harvestWindow = avgRainProb < 20 ? 'GREEN' : avgRainProb < 50 ? 'YELLOW' : 'RED';
|
||||
|
||||
return {
|
||||
field: field_name,
|
||||
coordinates: { latitude, longitude },
|
||||
current: {
|
||||
temperature_f: current.temperature_2m,
|
||||
precipitation_in: current.precipitation,
|
||||
wind_mph: current.wind_speed_10m,
|
||||
},
|
||||
forecast_7day: daily.time.map((date, i) => ({
|
||||
date,
|
||||
high_f: daily.temperature_2m_max[i],
|
||||
low_f: daily.temperature_2m_min[i],
|
||||
precip_in: daily.precipitation_sum[i],
|
||||
rain_probability_pct: daily.precipitation_probability_max[i],
|
||||
gdd: gddDays[i].toFixed(1),
|
||||
})),
|
||||
gdd_7day_total: totalGDD.toFixed(1),
|
||||
rain_alert_48h: rainAlert,
|
||||
harvest_window: harvestWindow,
|
||||
};
|
||||
}
|
||||
|
||||
async function toolLookupUSDAsoil({ latitude, longitude }) {
|
||||
// USDA Web Soil Survey SDA REST API
|
||||
const query = `SELECT mapunit.muname, component.compname, component.comppct_r,
|
||||
component.taxorder, component.taxsubgrp, chorizon.texture, chorizon.om_r,
|
||||
chorizon.drainagecl
|
||||
FROM mapunit
|
||||
INNER JOIN component ON mapunit.mukey = component.mukey
|
||||
INNER JOIN chorizon ON component.cokey = chorizon.cokey
|
||||
WHERE mu_lks.mukey IN (
|
||||
SELECT * FROM SDA_Get_Mukey_from_intersection_with_WktWgs84(
|
||||
'point(${longitude} ${latitude})')
|
||||
)
|
||||
AND component.majcompflag = 'Yes'
|
||||
ORDER BY component.comppct_r DESC`;
|
||||
|
||||
try {
|
||||
const res = await fetch('https://sdmdataaccess.sc.egov.usda.gov/TABULAR/post.rest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `request=query&query=${encodeURIComponent(query)}&format=JSON`,
|
||||
});
|
||||
const data = await res.json();
|
||||
const rows = data.Table ?? [];
|
||||
return {
|
||||
coordinates: { latitude, longitude },
|
||||
soil_data: rows.slice(0, 5).map((r) => ({
|
||||
map_unit: r[0],
|
||||
component: r[1],
|
||||
percent: r[2],
|
||||
tax_order: r[3],
|
||||
subgroup: r[4],
|
||||
texture: r[5],
|
||||
organic_matter_pct: r[6],
|
||||
drainage_class: r[7],
|
||||
})),
|
||||
};
|
||||
} catch (_) {
|
||||
return {
|
||||
coordinates: { latitude, longitude },
|
||||
note: 'USDA SDA API unavailable — soil data requires network access from background worker',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toolCalculateGDD({ daily_highs, daily_lows, base_temp = 50 }) {
|
||||
const gdd_per_day = daily_highs.map((hi, i) => {
|
||||
const lo = daily_lows[i] ?? hi;
|
||||
const avg = (hi + lo) / 2;
|
||||
return Math.max(0, avg - base_temp);
|
||||
});
|
||||
const total = gdd_per_day.reduce((a, b) => a + b, 0);
|
||||
return {
|
||||
base_temp_f: base_temp,
|
||||
days: gdd_per_day.length,
|
||||
gdd_per_day: gdd_per_day.map((g) => parseFloat(g.toFixed(1))),
|
||||
total_gdd: parseFloat(total.toFixed(1)),
|
||||
interpretation:
|
||||
total < 200 ? 'Early growth stage' :
|
||||
total < 500 ? 'Vegetative growth' :
|
||||
total < 900 ? 'Approaching harvest window' :
|
||||
'Harvest recommended',
|
||||
};
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { DataIngestModule } from '../modules/data-ingest/index.js';
|
|||
import { FieldProfileModule } from '../modules/field-profile/index.js';
|
||||
import { DashboardModule } from '../modules/dashboard/index.js';
|
||||
import { CarbonEstimatorModule } from '../modules/carbon-estimator/index.js';
|
||||
import { AgRefineModule } from '../ag-refine/index.js';
|
||||
import { sessionSet, sessionGet, KEYS } from '../utils/storage.js';
|
||||
|
||||
// ── Module registry ───────────────────────────────────────────────────────────
|
||||
|
|
@ -13,6 +14,7 @@ const MODULES = [
|
|||
FieldProfileModule(),
|
||||
DashboardModule(),
|
||||
CarbonEstimatorModule(),
|
||||
AgRefineModule(),
|
||||
];
|
||||
|
||||
const moduleMap = Object.fromEntries(MODULES.map((m) => [m.id, m]));
|
||||
|
|
|
|||
|
|
@ -86,6 +86,15 @@
|
|||
</svg>
|
||||
<span>Carbon</span>
|
||||
</button>
|
||||
|
||||
<button data-tab="ag-refine"
|
||||
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>Agent</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue