chto-ty-takoe-na-baze-kakih/agent.js

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 };