150 lines
5.4 KiB
JavaScript
150 lines
5.4 KiB
JavaScript
// Agent: gathers data from public sources, saves, builds summaries with AI.
|
|
|
|
// ---- Public sources ----
|
|
const CITIES = [
|
|
{ name: "Astana", lat: 51.16, lon: 71.47 },
|
|
{ name: "Almaty", lat: 43.24, lon: 76.89 },
|
|
{ name: "Shymkent", lat: 42.31, lon: 69.59 },
|
|
{ name: "Pavlodar", lat: 52.3, lon: 76.93 }
|
|
];
|
|
|
|
async function getFx() {
|
|
const r = await fetch("https://open.er-api.com/v6/latest/KZT", { signal: AbortSignal.timeout(15000) });
|
|
if (!r.ok) throw new Error("open.er-api.com " + r.status);
|
|
const j = await r.json();
|
|
if (!j.rates) throw new Error("open.er-api.com: empty response");
|
|
// rate = KZT -> foreign; value = tenge per unit = 1/rate
|
|
const out = {};
|
|
for (const c of ["USD", "EUR", "RUB", "CNY"]) {
|
|
if (j.rates[c]) out[c] = +(1 / j.rates[c]).toFixed(c === "RUB" ? 3 : 1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function getWeather(c, days) {
|
|
days = days || 1;
|
|
const u = "https://api.open-meteo.com/v1/forecast?latitude=" + c.lat + "&longitude=" + c.lon +
|
|
"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=Asia%2FAlmaty&past_days=" + (days - 1);
|
|
const r = await fetch(u, { signal: AbortSignal.timeout(20000) });
|
|
if (!r.ok) throw new Error("open-meteo " + c.name + " " + r.status);
|
|
const j = await r.json();
|
|
if (!j.daily) throw new Error("open-meteo: no data");
|
|
const d = j.daily;
|
|
const out = [];
|
|
for (let i = 0; i < d.time.length; i++) {
|
|
if (d.temperature_2m_max[i] == null) continue;
|
|
out.push({
|
|
date: d.time[i],
|
|
city: c.name,
|
|
max: +d.temperature_2m_max[i],
|
|
min: +d.temperature_2m_min[i],
|
|
rain: +d.precipitation_sum[i]
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function today() {
|
|
return new Date().toLocaleDateString("en-CA");
|
|
}
|
|
async function collect() {
|
|
const quotes = [];
|
|
const weather = [];
|
|
const sources = [];
|
|
const errors = [];
|
|
// Rates
|
|
try {
|
|
const map = await getFx();
|
|
const date = today();
|
|
for (const code of Object.keys(map).sort()) {
|
|
quotes.push({
|
|
date, code,
|
|
unit: "₸/юнит",
|
|
value: map[code],
|
|
prev: 0, changePct: 0,
|
|
source: "open.er-api.com"
|
|
});
|
|
}
|
|
sources.push({ id: "fx", name: "Forex open.er-api.com", ok: true, count: quotes.length });
|
|
} catch (e) {
|
|
errors.push("Rates: " + e.message);
|
|
sources.push({ id: "fx", name: "Forex open.er-api.com", ok: false, error: e.message });
|
|
}
|
|
// Weather
|
|
for (const c of CITIES) {
|
|
try {
|
|
const w = await getWeather(c, 2);
|
|
const last = w[w.length - 1];
|
|
if (last) weather.push(last);
|
|
} catch (e) {
|
|
errors.push("Weather " + c.name + ": " + e.message);
|
|
}
|
|
}
|
|
sources.push({ id: "weather", name: "open-meteo.com (4 regions)", ok: weather.length === CITIES.length, count: weather.length });
|
|
// Agricultural commodity prices — planned after public access is opened
|
|
sources.push({ id: "agrop", name: "Vetlex/ALAPI (agricultural prices)", ok: false, note: "Planned — container has no access to KZ sources" });
|
|
return { quotes, weather, sources, errors };
|
|
}
|
|
|
|
// Наполнение истории погоды за N дней (курсы на сегодня берёт collect,
|
|
// исторические курсов в ₸ нет — нет открытого источника, KZ API недоступны).
|
|
async function backfill(nDays) {
|
|
const n = nDays || 30;
|
|
const weather = [];
|
|
const errors = [];
|
|
for (const c of CITIES) {
|
|
try {
|
|
const w = await getWeather(c, n);
|
|
weather.push(...w.filter((x) => x.date <= today()));
|
|
} catch (e) {
|
|
errors.push("Weather history " + c.name + ": " + e.message);
|
|
}
|
|
}
|
|
return { weather, forex: {}, errors };
|
|
}
|
|
|
|
// ---- AI ----
|
|
async function ai(text) {
|
|
const r = await fetch(process.env.AI_BASE_URL + "/chat/completions", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer " + process.env.AI_API_KEY
|
|
},
|
|
body: JSON.stringify({
|
|
model: process.env.AI_MODEL,
|
|
messages: [
|
|
{ role: "system", content: "You are an agricultural market price analysis assistant. Answer briefly in Russian, using only the data from the context. If the data is insufficient, state so." },
|
|
{ role: "user", content: text }
|
|
]
|
|
})
|
|
});
|
|
if (!r.ok) throw new Error("AI is temporarily unavailable, code " + r.status);
|
|
const j = await r.json();
|
|
const out = j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content;
|
|
if (!out) throw new Error("AI returned an empty response");
|
|
return out;
|
|
}
|
|
|
|
function fmt(d) { return d; }
|
|
|
|
async function summarize(d) {
|
|
const lastDate = d.quotes.length ? d.quotes[d.quotes.length - 1].date : "—";
|
|
const ctx = "Date " + lastDate + ":\n" +
|
|
d.quotes.filter((q) => q.date === lastDate).map((q) => q.code + " = " + q.value + " tenge (" + (q.changePct >= 0 ? "+" : "") + q.changePct + "%)").join(", ") +
|
|
"\nWeather: " +
|
|
d.weather.filter((w) => w.date === lastDate).map((w) => w.city + " " + w.min + ".." + w.max + "°С, " + w.rain + " mm").join("; ");
|
|
try {
|
|
return await ai("Create a 3-5 sentence summary for the 'Price Agent' dashboard: what the data is about, what changed, possible impact on agricultural product prices and logistics.\nData:\n" + ctx);
|
|
} catch (e) {
|
|
return e.message;
|
|
}
|
|
}
|
|
|
|
async function ask(ctx, q) {
|
|
const text = "Question: " + q + "\nData:\n" + JSON.stringify(ctx, null, 1).slice(0, 6000);
|
|
return await ai(text);
|
|
}
|
|
|
|
module.exports = { collect, backfill, summarize, ask };
|