easyoref 1.13.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/agent/auth.d.ts +11 -0
  2. package/dist/agent/auth.d.ts.map +1 -0
  3. package/dist/agent/auth.js +54 -0
  4. package/dist/agent/auth.js.map +1 -0
  5. package/dist/agent/clarify.d.ts +43 -0
  6. package/dist/agent/clarify.d.ts.map +1 -0
  7. package/dist/agent/clarify.js +263 -0
  8. package/dist/agent/clarify.js.map +1 -0
  9. package/dist/agent/dry-run.d.ts +12 -0
  10. package/dist/agent/dry-run.d.ts.map +1 -0
  11. package/dist/agent/dry-run.js +229 -0
  12. package/dist/agent/dry-run.js.map +1 -0
  13. package/dist/agent/gramjs-monitor.d.ts +26 -0
  14. package/dist/agent/gramjs-monitor.d.ts.map +1 -0
  15. package/dist/agent/gramjs-monitor.js +320 -0
  16. package/dist/agent/gramjs-monitor.js.map +1 -0
  17. package/dist/agent/graph.d.ts +50 -0
  18. package/dist/agent/graph.d.ts.map +1 -0
  19. package/dist/agent/graph.js +803 -0
  20. package/dist/agent/graph.js.map +1 -0
  21. package/dist/agent/queue.d.ts +15 -0
  22. package/dist/agent/queue.d.ts.map +1 -0
  23. package/dist/agent/queue.js +41 -0
  24. package/dist/agent/queue.js.map +1 -0
  25. package/dist/agent/redis.d.ts +8 -0
  26. package/dist/agent/redis.d.ts.map +1 -0
  27. package/dist/agent/redis.js +33 -0
  28. package/dist/agent/redis.js.map +1 -0
  29. package/dist/agent/store.d.ts +67 -0
  30. package/dist/agent/store.d.ts.map +1 -0
  31. package/dist/agent/store.js +83 -0
  32. package/dist/agent/store.js.map +1 -0
  33. package/dist/agent/tools.d.ts +159 -0
  34. package/dist/agent/tools.d.ts.map +1 -0
  35. package/dist/agent/tools.js +439 -0
  36. package/dist/agent/tools.js.map +1 -0
  37. package/dist/agent/types.d.ts +102 -0
  38. package/dist/agent/types.d.ts.map +1 -0
  39. package/dist/agent/types.js +3 -0
  40. package/dist/agent/types.js.map +1 -0
  41. package/dist/agent/worker.d.ts +14 -0
  42. package/dist/agent/worker.d.ts.map +1 -0
  43. package/dist/agent/worker.js +90 -0
  44. package/dist/agent/worker.js.map +1 -0
  45. package/dist/bin.d.ts +17 -0
  46. package/dist/bin.d.ts.map +1 -0
  47. package/dist/bin.js +82 -0
  48. package/dist/bin.js.map +1 -0
  49. package/dist/bot.d.ts +16 -0
  50. package/dist/bot.d.ts.map +1 -0
  51. package/dist/bot.js +600 -0
  52. package/dist/bot.js.map +1 -0
  53. package/dist/config.d.ts +125 -0
  54. package/dist/config.d.ts.map +1 -0
  55. package/dist/config.js +145 -0
  56. package/dist/config.js.map +1 -0
  57. package/dist/gif-state.d.ts +17 -0
  58. package/dist/gif-state.d.ts.map +1 -0
  59. package/dist/gif-state.js +67 -0
  60. package/dist/gif-state.js.map +1 -0
  61. package/dist/i18n.d.ts +49 -0
  62. package/dist/i18n.d.ts.map +1 -0
  63. package/dist/i18n.js +229 -0
  64. package/dist/i18n.js.map +1 -0
  65. package/dist/init.d.ts +7 -0
  66. package/dist/init.d.ts.map +1 -0
  67. package/dist/init.js +163 -0
  68. package/dist/init.js.map +1 -0
  69. package/dist/logger.d.ts +14 -0
  70. package/dist/logger.d.ts.map +1 -0
  71. package/dist/logger.js +45 -0
  72. package/dist/logger.js.map +1 -0
  73. package/dist/service.d.ts +19 -0
  74. package/dist/service.d.ts.map +1 -0
  75. package/dist/service.js +165 -0
  76. package/dist/service.js.map +1 -0
  77. package/package.json +1 -1
@@ -0,0 +1,803 @@
1
+ /**
2
+ * LangGraph.js enrichment pipeline — tiered validation + tool calling.
3
+ *
4
+ * Design: minimize tokens, maximize confidence.
5
+ * When confidence is low, offer tools — agent decides if they help.
6
+ *
7
+ * ┌──────────────────────────────────────────────────────────────┐
8
+ * │ Tier 0: preFilter (deterministic, 0 tokens) │
9
+ * │ → keyword + region check on raw post text │
10
+ * │ │
11
+ * │ Tier 1: extractAndValidate (1 LLM call per post) │
12
+ * │ → combined extraction + 3 validators in single JSON │
13
+ * │ │
14
+ * │ Tier 2: postFilter (deterministic, 0 tokens) │
15
+ * │ → reject low relevance / trust / alarmist / empty │
16
+ * │ │
17
+ * │ Tier 3: vote (deterministic, 0 tokens) │
18
+ * │ → majority consensus across validated sources │
19
+ * │ │
20
+ * │ Tier 3.5: shouldClarify (conditional edge) │
21
+ * │ → if confidence < threshold AND tools enabled: │
22
+ * │ → clarify: LLM sees voted result + 4 tools │
23
+ * │ • read_telegram_sources (1-4 channel posts) │
24
+ * │ • alert_history (Oref history verification) │
25
+ * │ • resolve_area (defense-zone proximity check) │
26
+ * │ • betterstack_log (query recent pipeline logs) │
27
+ * │ LLM decides: call 0, 1, 2, or 3+ tools. │
28
+ * │ → revote with merged extractions │
29
+ * │ → else: proceed to editMessage │
30
+ * │ │
31
+ * │ Tier 4: editMessage (deterministic, 0 tokens) │
32
+ * │ → inline update of existing key:value pairs │
33
+ * └──────────────────────────────────────────────────────────────┘
34
+ *
35
+ * Checkpointer: MemorySaver — session-level state persistence.
36
+ * Total LLM cost: 1 call × N posts + (optional) 1 clarify call + 0-N tools.
37
+ */
38
+ import { Annotation, MemorySaver, StateGraph } from "@langchain/langgraph";
39
+ import { ChatOpenAI } from "@langchain/openai";
40
+ import { Bot } from "grammy";
41
+ import { config } from "../config.js";
42
+ import * as logger from "../logger.js";
43
+ import { runClarify } from "./clarify.js";
44
+ import { getChannelPosts } from "./store.js";
45
+ // ── State ──────────────────────────────────────────────
46
+ const AgentState = Annotation.Root({
47
+ alertId: Annotation({ reducer: (_, b) => b }),
48
+ alertTs: Annotation({ reducer: (_, b) => b }),
49
+ alertType: Annotation({ reducer: (_, b) => b }),
50
+ alertAreas: Annotation({ reducer: (_, b) => b }),
51
+ chatId: Annotation({ reducer: (_, b) => b }),
52
+ messageId: Annotation({ reducer: (_, b) => b }),
53
+ isCaption: Annotation({ reducer: (_, b) => b }),
54
+ currentText: Annotation({ reducer: (_, b) => b }),
55
+ channelPosts: Annotation({ reducer: (_, b) => b }),
56
+ filteredPosts: Annotation({ reducer: (_, b) => b }),
57
+ extractions: Annotation({ reducer: (_, b) => b }),
58
+ votedResult: Annotation({ reducer: (_, b) => b }),
59
+ /** Tracks whether clarify has already run (prevents infinite loop) */
60
+ clarifyAttempted: Annotation({ reducer: (_, b) => b }),
61
+ });
62
+ // ── LLM ───────────────────────────────────────────────
63
+ function getLLM() {
64
+ return new ChatOpenAI({
65
+ model: config.agent.model,
66
+ configuration: {
67
+ baseURL: "https://openrouter.ai/api/v1",
68
+ defaultHeaders: {
69
+ "HTTP-Referer": "https://github.com/mikhailkogan17/EasyOref",
70
+ "X-Title": "EasyOref",
71
+ },
72
+ },
73
+ apiKey: config.agent.apiKey,
74
+ temperature: 0,
75
+ maxTokens: 400,
76
+ });
77
+ }
78
+ // ── Region keywords (Hebrew + transliterations) ────────
79
+ /**
80
+ * Build keyword list from config areas + area_labels.
81
+ * Returns lowercased keywords for matching.
82
+ */
83
+ function buildRegionKeywords() {
84
+ const keywords = [];
85
+ for (const area of config.areas) {
86
+ keywords.push(area.toLowerCase());
87
+ // First word often enough (e.g. "תל אביב" → "תל")
88
+ const first = area.split(" ")[0];
89
+ if (first && first.length >= 2)
90
+ keywords.push(first.toLowerCase());
91
+ }
92
+ for (const [he, label] of Object.entries(config.agent.areaLabels)) {
93
+ keywords.push(he.toLowerCase());
94
+ // Add transliterated label words (e.g. "Дан центр" → "дан", "центр")
95
+ for (const word of label.split(/\s+/)) {
96
+ if (word.length >= 3)
97
+ keywords.push(word.toLowerCase());
98
+ }
99
+ }
100
+ // Common attack-related keywords (always relevant)
101
+ keywords.push("ישראל", "israel", "израиль", "ракет", "rocket", "missile", "iron dome", "כיפת ברזל", "жд", "перехват", "intercept", "siren", "азака", "צבע אדום", "red alert");
102
+ return [...new Set(keywords)];
103
+ }
104
+ // ─────────────────────────────────────────────────────────
105
+ // Tier 0: Pre-filter (deterministic, 0 tokens)
106
+ // ─────────────────────────────────────────────────────────
107
+ async function collectAndPreFilter(state) {
108
+ // Session-scoped: all posts belong to the current session already
109
+ const posts = await getChannelPosts(state.alertId);
110
+ if (posts.length === 0) {
111
+ logger.info("Agent: no posts in session", { alertId: state.alertId });
112
+ return { channelPosts: posts, filteredPosts: [] };
113
+ }
114
+ const keywords = buildRegionKeywords();
115
+ const filtered = posts.filter((post) => {
116
+ const text = post.text.toLowerCase();
117
+ // Must contain at least 1 region/attack keyword
118
+ return keywords.some((kw) => text.includes(kw));
119
+ });
120
+ logger.info("Agent: pre-filter", {
121
+ alertId: state.alertId,
122
+ total: posts.length,
123
+ after_keyword_filter: filtered.length,
124
+ });
125
+ return { channelPosts: posts, filteredPosts: filtered };
126
+ }
127
+ // ─────────────────────────────────────────────────────────
128
+ // Tier 1: Extract + validate (1 LLM call per post)
129
+ // ─────────────────────────────────────────────────────────
130
+ const QUAL_VALUES = '"all"|"most"|"many"|"few"|"exists"|"none"|"more_than"|"less_than"';
131
+ const SYSTEM_PROMPT = `You analyze Telegram channel messages about a missile/rocket attack on Israel.
132
+ Your job: extract factual data AND assess message quality. Be concise.
133
+
134
+ Return ONLY valid JSON (no markdown, no explanation):
135
+ {
136
+ "region_relevance": float, // 0–1: does this message discuss the specified alert region?
137
+ "source_trust": float, // 0–1: factual reporting (1.0) vs unverified rumors/panic (0.0)
138
+ "tone": "calm"|"neutral"|"alarmist", // message tone — reject alarmist content
139
+ "country_origin": string|null, // "Iran","Yemen","Lebanon","Gaza","Iraq","Syria" or null
140
+ "rocket_count": int|null, // total rockets/missiles launched if mentioned
141
+ "is_cassette": bool|null, // cluster/cassette munitions confirmed?
142
+ "intercepted": int|null, // exact number intercepted by Iron Dome/air defense
143
+ "intercepted_qual": ${QUAL_VALUES}|null, // qualitative if no exact number; null if exact number given
144
+ "intercepted_qual_num": int|null, // reference number for more_than/less_than (e.g. 5 if "more than 5")
145
+ "sea_impact": int|null, // exact number fell in sea/unpopulated area
146
+ "sea_impact_qual": ${QUAL_VALUES}|null,
147
+ "sea_impact_qual_num": int|null,
148
+ "open_area_impact": int|null, // exact number hit open/populated ground
149
+ "open_area_impact_qual": ${QUAL_VALUES}|null,
150
+ "open_area_impact_qual_num": int|null,
151
+ "hits_confirmed": int|null, // confirmed hits on structures/buildings
152
+ "eta_refined_minutes": int|null, // refined time-to-impact if mentioned
153
+ "confidence": float // 0–1: overall confidence in this extraction
154
+ }
155
+
156
+ Rules:
157
+ - If unrelated to the alert region, set region_relevance=0 and all data fields to null.
158
+ - If message is speculative/unconfirmed rumor, set source_trust < 0.4.
159
+ - If message uses excessive caps, exclamation marks, panic language → tone="alarmist".
160
+ - Only extract concrete numbers explicitly stated in the text. Never guess.
161
+ - intercpted + sea_impact + open_area_impact should sum to rocket_count when all are known.
162
+ - If partial breakdown known, set unknown sub-fields to null (not 0).
163
+ - *_qual fields: use ONLY when the message explicitly states a qualitative descriptor WITHOUT an exact count.
164
+ If an exact number is given, set *_qual to null. Do NOT infer from absence.
165
+ - NEVER extract qualitative descriptors for casualties or injuries — hits_confirmed handles structural hits only.
166
+ - "none" qual is only valid if explicitly stated in the message (e.g., "все перехвачены", "не упало в море").`;
167
+ async function extractAndValidate(state) {
168
+ if (state.filteredPosts.length === 0) {
169
+ logger.info("Agent: no filtered posts to extract", {
170
+ alertId: state.alertId,
171
+ });
172
+ return { extractions: [] };
173
+ }
174
+ const llm = getLLM();
175
+ const posts = state.filteredPosts.slice(0, 8); // max 8 posts
176
+ const regionHint = state.alertAreas.length > 0
177
+ ? state.alertAreas.join(", ")
178
+ : Object.keys(config.agent.areaLabels).join(", ") || "Israel";
179
+ // Format alert time in Israel timezone
180
+ const alertTimeIL = new Date(state.alertTs).toLocaleTimeString("he-IL", {
181
+ hour: "2-digit",
182
+ minute: "2-digit",
183
+ timeZone: "Asia/Jerusalem",
184
+ });
185
+ const nowIL = new Date().toLocaleTimeString("he-IL", {
186
+ hour: "2-digit",
187
+ minute: "2-digit",
188
+ timeZone: "Asia/Jerusalem",
189
+ });
190
+ const alertTypeLabel = state.alertType === "early_warning"
191
+ ? "early warning (radar detection)"
192
+ : state.alertType === "siren"
193
+ ? "siren (impact imminent)"
194
+ : state.alertType;
195
+ const contextHeader = `Alert type: ${alertTypeLabel}\n` +
196
+ `Alert time: ${alertTimeIL} (Israel)\n` +
197
+ `Current time: ${nowIL} (Israel)\n` +
198
+ `Alert region: ${regionHint}\n` +
199
+ `UI language: ${config.language}\n`;
200
+ const results = await Promise.all(posts.map(async (post) => {
201
+ try {
202
+ const response = await llm.invoke([
203
+ { role: "system", content: SYSTEM_PROMPT },
204
+ {
205
+ role: "user",
206
+ content: `${contextHeader}Channel: ${post.channel}\n\nMessage:\n${post.text.slice(0, 800)}`,
207
+ },
208
+ ]);
209
+ const raw = typeof response.content === "string"
210
+ ? response.content
211
+ : JSON.stringify(response.content);
212
+ // Strip markdown code fences (```json ... ```) that some models wrap around JSON
213
+ const text = raw
214
+ .replace(/^```(?:json)?\s*\n?/i, "")
215
+ .replace(/\n?```\s*$/i, "");
216
+ const parsed = JSON.parse(text.trim());
217
+ return {
218
+ ...parsed,
219
+ channel: post.channel,
220
+ messageUrl: post.messageUrl,
221
+ valid: true,
222
+ };
223
+ }
224
+ catch (err) {
225
+ logger.warn("Agent: extraction failed", {
226
+ channel: post.channel,
227
+ error: String(err),
228
+ });
229
+ return {
230
+ channel: post.channel,
231
+ region_relevance: 0,
232
+ source_trust: 0,
233
+ tone: "neutral",
234
+ country_origin: null,
235
+ rocket_count: null,
236
+ is_cassette: null,
237
+ intercepted: null,
238
+ intercepted_qual: null,
239
+ intercepted_qual_num: null,
240
+ sea_impact: null,
241
+ sea_impact_qual: null,
242
+ sea_impact_qual_num: null,
243
+ open_area_impact: null,
244
+ open_area_impact_qual: null,
245
+ open_area_impact_qual_num: null,
246
+ hits_confirmed: null,
247
+ eta_refined_minutes: null,
248
+ confidence: 0,
249
+ valid: false,
250
+ reject_reason: "extraction_error",
251
+ };
252
+ }
253
+ }));
254
+ logger.info("Agent: extracted", {
255
+ alertId: state.alertId,
256
+ count: results.length,
257
+ });
258
+ return { extractions: results };
259
+ }
260
+ // ─────────────────────────────────────────────────────────
261
+ // Tier 2: Post-filter (deterministic, 0 tokens)
262
+ // ─────────────────────────────────────────────────────────
263
+ function postFilter(state) {
264
+ const validated = state.extractions.map((ext) => {
265
+ // V1: region relevance
266
+ if (ext.region_relevance < 0.5) {
267
+ return { ...ext, valid: false, reject_reason: "region_irrelevant" };
268
+ }
269
+ // V2: source trust
270
+ if (ext.source_trust < 0.4) {
271
+ return { ...ext, valid: false, reject_reason: "untrusted_source" };
272
+ }
273
+ // V3: tone — reject alarmist (бот для успокоения, не для паники)
274
+ if (ext.tone === "alarmist") {
275
+ return { ...ext, valid: false, reject_reason: "alarmist_tone" };
276
+ }
277
+ // V4: at least one data field must be non-null
278
+ const hasData = ext.country_origin !== null ||
279
+ ext.rocket_count !== null ||
280
+ ext.is_cassette !== null ||
281
+ ext.hits_confirmed !== null ||
282
+ ext.eta_refined_minutes !== null;
283
+ if (!hasData) {
284
+ return { ...ext, valid: false, reject_reason: "no_data" };
285
+ }
286
+ // V5: overall confidence floor
287
+ if (ext.confidence < 0.3) {
288
+ return { ...ext, valid: false, reject_reason: "low_confidence" };
289
+ }
290
+ return { ...ext, valid: true };
291
+ });
292
+ const passed = validated.filter((e) => e.valid);
293
+ const rejected = validated.filter((e) => !e.valid);
294
+ logger.info("Agent: post-filter", {
295
+ alertId: state.alertId,
296
+ passed: passed.length,
297
+ rejected: rejected.length,
298
+ reasons: rejected.map((r) => r.reject_reason),
299
+ });
300
+ return { extractions: validated };
301
+ }
302
+ // ─────────────────────────────────────────────────────────
303
+ // Tier 3: Vote (deterministic, 0 tokens)
304
+ // ─────────────────────────────────────────────────────────
305
+ function vote(state) {
306
+ const valid = state.extractions.filter((e) => e.valid);
307
+ if (valid.length === 0) {
308
+ return { votedResult: null };
309
+ }
310
+ // Assign 1-based citation indices to valid extractions
311
+ const indexed = valid.map((e, i) => ({ ...e, idx: i + 1 }));
312
+ // All valid sources become cited sources
313
+ const citedSources = indexed.map((e) => ({
314
+ index: e.idx,
315
+ channel: e.channel,
316
+ messageUrl: e.messageUrl ?? null,
317
+ }));
318
+ // ETA: highest confidence source that has eta
319
+ const withEta = indexed
320
+ .filter((e) => e.eta_refined_minutes !== null)
321
+ .sort((a, b) => b.confidence - a.confidence);
322
+ const bestEta = withEta[0] ?? null;
323
+ // Country: group unique values, each with their source indices
324
+ const countryMap = new Map();
325
+ for (const e of indexed) {
326
+ if (e.country_origin) {
327
+ const list = countryMap.get(e.country_origin) ?? [];
328
+ list.push(e.idx);
329
+ countryMap.set(e.country_origin, list);
330
+ }
331
+ }
332
+ const country_origins = countryMap.size > 0
333
+ ? Array.from(countryMap.entries()).map(([name, citations]) => ({
334
+ name,
335
+ citations,
336
+ }))
337
+ : null;
338
+ // Rocket count: range across sources (min … max)
339
+ const rocketSrcs = indexed.filter((e) => e.rocket_count !== null);
340
+ const rocketVals = rocketSrcs.map((e) => e.rocket_count);
341
+ const rocket_count_min = rocketVals.length > 0 ? Math.min(...rocketVals) : null;
342
+ const rocket_count_max = rocketVals.length > 0 ? Math.max(...rocketVals) : null;
343
+ const rocket_citations = rocketSrcs.map((e) => e.idx);
344
+ // Helper: avg weighted confidence for a set of sources
345
+ function fieldConf(srcs) {
346
+ if (srcs.length === 0)
347
+ return 0;
348
+ return (srcs.reduce((s, e) => s + e.source_trust * e.confidence, 0) / srcs.length);
349
+ }
350
+ // Cassette: majority
351
+ const cassSrcs = indexed.filter((e) => e.is_cassette !== null);
352
+ const cassVals = cassSrcs.map((e) => e.is_cassette);
353
+ const is_cassette = cassVals.length > 0
354
+ ? cassVals.filter(Boolean).length > cassVals.length / 2
355
+ : null;
356
+ const is_cassette_confidence = fieldConf(cassSrcs);
357
+ // Hits: median
358
+ const hitsSrcs = indexed.filter((e) => e.hits_confirmed !== null && e.hits_confirmed > 0);
359
+ const hitsVals = indexed
360
+ .filter((e) => e.hits_confirmed !== null)
361
+ .map((e) => e.hits_confirmed)
362
+ .sort((a, b) => a - b);
363
+ const hits_confirmed = hitsVals.length > 0 ? hitsVals[Math.floor(hitsVals.length / 2)] : null;
364
+ const hits_citations = hitsSrcs.map((e) => e.idx);
365
+ const hits_confidence = fieldConf(hitsSrcs);
366
+ // Helper: mode (most frequent non-null value) for QualCount aggregation
367
+ function modeQual(srcs, key) {
368
+ const vals = srcs
369
+ .map((e) => e[key])
370
+ .filter((v) => v !== null);
371
+ if (vals.length === 0)
372
+ return null;
373
+ const freq = new Map();
374
+ for (const v of vals)
375
+ freq.set(v, (freq.get(v) ?? 0) + 1);
376
+ return [...freq.entries()].sort((a, b) => b[1] - a[1])[0][0];
377
+ }
378
+ function medianQualNum(srcs, key) {
379
+ const vals = srcs
380
+ .map((e) => e[key])
381
+ .filter((v) => v !== null)
382
+ .sort((a, b) => a - b);
383
+ return vals.length > 0 ? vals[Math.floor(vals.length / 2)] : null;
384
+ }
385
+ // Intercepted: median across sources that reported exact number; mode for qual
386
+ const interceptedSrcs = indexed.filter((e) => e.intercepted !== null);
387
+ const interceptedQualSrcs = indexed.filter((e) => e.intercepted_qual !== null);
388
+ const interceptedVals = interceptedSrcs
389
+ .map((e) => e.intercepted)
390
+ .sort((a, b) => a - b);
391
+ const intercepted = interceptedVals.length > 0
392
+ ? interceptedVals[Math.floor(interceptedVals.length / 2)]
393
+ : null;
394
+ const intercepted_qual = intercepted === null
395
+ ? modeQual(interceptedQualSrcs, "intercepted_qual")
396
+ : null;
397
+ const intercepted_qual_num = intercepted_qual !== null
398
+ ? medianQualNum(interceptedQualSrcs, "intercepted_qual_num")
399
+ : null;
400
+ const intercepted_confidence = fieldConf(interceptedSrcs.length > 0 ? interceptedSrcs : interceptedQualSrcs);
401
+ // Sea impact: median / qual
402
+ const seaSrcs = indexed.filter((e) => e.sea_impact !== null);
403
+ const seaQualSrcs = indexed.filter((e) => e.sea_impact_qual !== null);
404
+ const seaVals = seaSrcs
405
+ .map((e) => e.sea_impact)
406
+ .sort((a, b) => a - b);
407
+ const sea_impact = seaVals.length > 0 ? seaVals[Math.floor(seaVals.length / 2)] : null;
408
+ const sea_impact_qual = sea_impact === null ? modeQual(seaQualSrcs, "sea_impact_qual") : null;
409
+ const sea_impact_qual_num = sea_impact_qual !== null
410
+ ? medianQualNum(seaQualSrcs, "sea_impact_qual_num")
411
+ : null;
412
+ const sea_confidence = fieldConf(seaSrcs.length > 0 ? seaSrcs : seaQualSrcs);
413
+ // Open area impact: median / qual
414
+ const openSrcs = indexed.filter((e) => e.open_area_impact !== null);
415
+ const openQualSrcs = indexed.filter((e) => e.open_area_impact_qual !== null);
416
+ const openVals = openSrcs
417
+ .map((e) => e.open_area_impact)
418
+ .sort((a, b) => a - b);
419
+ const open_area_impact = openVals.length > 0 ? openVals[Math.floor(openVals.length / 2)] : null;
420
+ const open_area_impact_qual = open_area_impact === null
421
+ ? modeQual(openQualSrcs, "open_area_impact_qual")
422
+ : null;
423
+ const open_area_impact_qual_num = open_area_impact_qual !== null
424
+ ? medianQualNum(openQualSrcs, "open_area_impact_qual_num")
425
+ : null;
426
+ const open_area_confidence = fieldConf(openSrcs.length > 0 ? openSrcs : openQualSrcs);
427
+ // Rocket confidence
428
+ const rocket_confidence = fieldConf(rocketSrcs);
429
+ // Overall weighted confidence
430
+ const totalWeight = indexed.reduce((s, e) => s + e.source_trust * e.confidence, 0);
431
+ const weightedConf = totalWeight / indexed.length;
432
+ const voted = {
433
+ eta_refined_minutes: bestEta?.eta_refined_minutes ?? null,
434
+ eta_citations: bestEta ? [bestEta.idx] : [],
435
+ country_origins,
436
+ rocket_count_min,
437
+ rocket_count_max,
438
+ rocket_citations,
439
+ rocket_confidence,
440
+ is_cassette,
441
+ is_cassette_confidence,
442
+ intercepted,
443
+ intercepted_qual,
444
+ intercepted_qual_num,
445
+ intercepted_confidence,
446
+ sea_impact,
447
+ sea_impact_qual,
448
+ sea_impact_qual_num,
449
+ sea_confidence,
450
+ open_area_impact,
451
+ open_area_impact_qual,
452
+ open_area_impact_qual_num,
453
+ open_area_confidence,
454
+ hits_confirmed,
455
+ hits_citations,
456
+ hits_confidence,
457
+ confidence: Math.round(weightedConf * 100) / 100,
458
+ sources_count: indexed.length,
459
+ citedSources,
460
+ };
461
+ logger.info("Agent: voted", { alertId: state.alertId, voted });
462
+ return { votedResult: voted };
463
+ }
464
+ // ─────────────────────────────────────────────────────────
465
+ // Tier 4: Edit message — inline update (0 tokens)
466
+ // ─────────────────────────────────────────────────────────
467
+ /** EN country name → Russian */
468
+ const COUNTRY_RU = {
469
+ Iran: "Иран",
470
+ Yemen: "Йемен",
471
+ Lebanon: "Ливан",
472
+ Gaza: "Газа",
473
+ Iraq: "Ирак",
474
+ Syria: "Сирия",
475
+ Hezbollah: "Хезболла",
476
+ };
477
+ /** Convert index to Unicode superscript string: 1 → ¹, 13 → ¹³ */
478
+ const SUPERSCRIPTS = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"];
479
+ function sup(indices) {
480
+ return indices
481
+ .map((n) => String(n)
482
+ .split("")
483
+ .map((d) => SUPERSCRIPTS[Number(d)])
484
+ .join(""))
485
+ .join("");
486
+ }
487
+ /**
488
+ * Merge enrichment data INTO the existing key:value message.
489
+ * Format:
490
+ * Подлётное время: ~00:21¹ ← ETA as absolute clock time
491
+ *
492
+ * Откуда: Иран¹³ + Ливан² ← blank line before intel block
493
+ * Ракет: ~5-7
494
+ * Попадания (Дан центр): 2¹
495
+ * Время оповещения: 03:47
496
+ * —
497
+ * Источники: [1](url) [2](url) [3](url)
498
+ */
499
+ function buildEnrichedMessage(currentText, alertType, alertTs, r) {
500
+ let text = currentText;
501
+ // Refine ETA in-place (early/siren only)
502
+ if (r.eta_refined_minutes !== null &&
503
+ r.eta_citations.length > 0 &&
504
+ (alertType === "early_warning" || alertType === "siren")) {
505
+ text = refineEtaInPlace(text, r.eta_refined_minutes, alertTs, r.eta_citations);
506
+ }
507
+ // Insert "Откуда" before time line (with leading blank line for visual separation)
508
+ if (r.country_origins && r.country_origins.length > 0) {
509
+ const parts = r.country_origins.map((c) => {
510
+ const ru = COUNTRY_RU[c.name] ?? c.name;
511
+ return `${ru}${sup(c.citations)}`;
512
+ });
513
+ text = insertBeforeTimeLine(text, `\n<b>Откуда:</b> ${parts.join(" + ")}`);
514
+ }
515
+ // Confidence thresholds for uncertainty markers
516
+ const SKIP = 0.6; // below this → skip field entirely
517
+ const UNCERTAIN = 0.75; // below this (but ≥ SKIP) → add (?)
518
+ const CERTAIN = 0.95; // "none" qual requires this level
519
+ // Convert QualCount to Russian display string.
520
+ // Returns null if the qual should be suppressed (e.g. "none" below CERTAIN).
521
+ function qualDisplay(qual, qualNum, conf) {
522
+ if (qual === null)
523
+ return null;
524
+ if (qual === "none")
525
+ return conf >= CERTAIN ? "нет" : null;
526
+ const map = {
527
+ all: "все",
528
+ most: "большинство",
529
+ many: "много",
530
+ few: "несколько",
531
+ exists: "есть",
532
+ none: "нет",
533
+ more_than: qualNum != null ? `>​${qualNum}` : ">​1",
534
+ less_than: qualNum != null ? `<​${qualNum}` : "<​нескольких",
535
+ };
536
+ return map[qual];
537
+ }
538
+ // Format one breakdown item: prefer exact number, fall back to qual.
539
+ // Returns null if nothing to show (below threshold or not reported).
540
+ function breakdownItem(label, num, qual, qualNum, conf) {
541
+ if (conf < SKIP)
542
+ return null;
543
+ const u = conf < UNCERTAIN ? " (?)" : "";
544
+ if (num !== null)
545
+ return `${label} — ${num}${u}`;
546
+ const qs = qualDisplay(qual, qualNum, conf);
547
+ if (qs === null)
548
+ return null;
549
+ return `${label} — ${qs}${u}`;
550
+ }
551
+ // Rocket count with breakdown and uncertainty markers
552
+ if (r.rocket_count_min !== null &&
553
+ r.rocket_count_max !== null &&
554
+ r.rocket_confidence >= SKIP) {
555
+ const rocketUncertain = r.rocket_confidence < UNCERTAIN ? " (?)" : "";
556
+ const countStr = r.rocket_count_min === r.rocket_count_max
557
+ ? `${r.rocket_count_min}`
558
+ : `~${r.rocket_count_min}–${r.rocket_count_max}`;
559
+ const bParts = [];
560
+ const bi = breakdownItem("перехвачено", r.intercepted, r.intercepted_qual, r.intercepted_qual_num, r.intercepted_confidence);
561
+ if (bi)
562
+ bParts.push(bi);
563
+ const bs = breakdownItem("упали в море", r.sea_impact, r.sea_impact_qual, r.sea_impact_qual_num, r.sea_confidence);
564
+ if (bs)
565
+ bParts.push(bs);
566
+ const bo = breakdownItem("открытая местность", r.open_area_impact, r.open_area_impact_qual, r.open_area_impact_qual_num, r.open_area_confidence);
567
+ if (bo)
568
+ bParts.push(bo);
569
+ const breakdown = bParts.length > 0 ? `, из них: ${bParts.join(", ")}` : "";
570
+ const cassetteU = r.is_cassette_confidence < UNCERTAIN ? " (?)" : "";
571
+ const cassette = r.is_cassette && r.is_cassette_confidence >= SKIP
572
+ ? `, есть кассетные${cassetteU}`
573
+ : "";
574
+ text = insertBeforeTimeLine(text, `<b>Ракет:</b> ${countStr}${rocketUncertain}${breakdown}${cassette}`);
575
+ }
576
+ // Hits: есть прямое попадание/-ия в <area>: N — only if confidence ≥ SKIP
577
+ if (r.hits_confirmed !== null &&
578
+ r.hits_confirmed > 0 &&
579
+ r.hits_confidence >= SKIP) {
580
+ const areaLabel = Object.values(config.agent.areaLabels)[0] ?? "район";
581
+ const hitWord = r.hits_confirmed === 1 ? "попадание" : "попадания";
582
+ const hitsCite = r.hits_citations.length > 0 ? sup(r.hits_citations) : "";
583
+ const hitsU = r.hits_confidence < UNCERTAIN ? " (?)" : "";
584
+ text = insertBeforeTimeLine(text, `есть прямое ${hitWord} в ${areaLabel}: ${r.hits_confirmed}${hitsCite}${hitsU}`);
585
+ }
586
+ // Sources footer: [1](url) [2](url) ...
587
+ const sourcesWithUrl = r.citedSources.filter((s) => s.messageUrl);
588
+ if (sourcesWithUrl.length > 0) {
589
+ const links = sourcesWithUrl
590
+ .map((s) => `<a href="${s.messageUrl}">[${s.index}]</a>`)
591
+ .join(" ");
592
+ text += `\n—\n<i>Источники: ${links}</i>`;
593
+ }
594
+ return text;
595
+ }
596
+ /**
597
+ * Insert a line before the time line (last "Время" / "Time" / "שעת" line).
598
+ * This keeps new data visually grouped with existing fields.
599
+ */
600
+ function insertBeforeTimeLine(text, line) {
601
+ // Match "Время оповещения" / "Alert time" / "שעת ההתרעה" / "وقت الإنذار"
602
+ const timePattern = /(<b>(?:Время оповещения|Alert time|שעת ההתרעה|وقت الإنذار):<\/b>)/;
603
+ const match = text.match(timePattern);
604
+ if (match?.index !== undefined) {
605
+ return text.slice(0, match.index) + line + "\n" + text.slice(match.index);
606
+ }
607
+ // Fallback: append before last line
608
+ const lines = text.split("\n");
609
+ lines.splice(Math.max(lines.length - 1, 0), 0, line);
610
+ return lines.join("\n");
611
+ }
612
+ /**
613
+ * Replace the default ETA range with absolute impact time + superscript citation.
614
+ * "~5–12 мин" → "~00:21¹"
615
+ */
616
+ function refineEtaInPlace(text, minutes, alertTs, citations) {
617
+ // Compute absolute impact time in Israel timezone
618
+ const absTime = new Date(alertTs + minutes * 60_000).toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit", timeZone: "Asia/Jerusalem" });
619
+ const refined = `~${absTime}${sup(citations)}`;
620
+ const etaPatterns = [
621
+ /~\d+[–-]\d+\s*мин/, // ~5–12 мин
622
+ /~\d+[–-]\d+\s*min/, // ~5–12 min
623
+ /~\d+[–-]\d+\s*דקות/, // ~5–12 דקות
624
+ /~\d+[–-]\d+\s*دقائق/, // ~5–12 دقائق
625
+ /1\.5\s*мин/, // 1.5 мин (siren)
626
+ /1\.5\s*min/, // 1.5 min
627
+ /1\.5\s*דקות/, // 1.5 דקות
628
+ /1\.5\s*دقائق/, // 1.5 دقائق
629
+ ];
630
+ for (const pattern of etaPatterns) {
631
+ if (pattern.test(text)) {
632
+ return text.replace(pattern, refined);
633
+ }
634
+ }
635
+ return text;
636
+ }
637
+ async function editMessage(state) {
638
+ const { votedResult } = state;
639
+ if (!config.botToken)
640
+ return {};
641
+ const tgBot = new Bot(config.botToken);
642
+ // No valid sources found — silently skip (don't touch the message)
643
+ if (!votedResult) {
644
+ logger.info("Agent: no voted result — skipping edit", {
645
+ alertId: state.alertId,
646
+ });
647
+ return {};
648
+ }
649
+ // Low confidence: log but still show data with (?) markers
650
+ if (votedResult.confidence < config.agent.confidenceThreshold) {
651
+ logger.info("Agent: confidence below threshold — editing with (?) markers", {
652
+ alertId: state.alertId,
653
+ confidence: votedResult.confidence,
654
+ threshold: config.agent.confidenceThreshold,
655
+ });
656
+ }
657
+ const newText = buildEnrichedMessage(state.currentText, state.alertType, state.alertTs, votedResult);
658
+ try {
659
+ if (state.isCaption) {
660
+ await tgBot.api.editMessageCaption(state.chatId, state.messageId, {
661
+ caption: newText,
662
+ parse_mode: "HTML",
663
+ });
664
+ }
665
+ else {
666
+ await tgBot.api.editMessageText(state.chatId, state.messageId, newText, {
667
+ parse_mode: "HTML",
668
+ });
669
+ }
670
+ logger.info("Agent: message enriched", {
671
+ alertId: state.alertId,
672
+ messageId: state.messageId,
673
+ confidence: votedResult.confidence,
674
+ sources: votedResult.sources_count,
675
+ });
676
+ }
677
+ catch (err) {
678
+ logger.error("Agent: failed to edit message", {
679
+ alertId: state.alertId,
680
+ error: String(err),
681
+ });
682
+ }
683
+ return {};
684
+ }
685
+ // ─────────────────────────────────────────────────────────
686
+ // Clarify Node — MCP tool calling via ReAct (conditional)
687
+ // ─────────────────────────────────────────────────────────
688
+ async function clarifyNode(state) {
689
+ const { votedResult, extractions, alertId, alertAreas, alertType, messageId, currentText, } = state;
690
+ if (!votedResult) {
691
+ logger.info("Agent: clarify skipped — no voted result", { alertId });
692
+ return { clarifyAttempted: true };
693
+ }
694
+ logger.info("Agent: clarify triggered", {
695
+ alertId,
696
+ confidence: votedResult.confidence,
697
+ threshold: config.agent.confidenceThreshold,
698
+ });
699
+ try {
700
+ const result = await runClarify({
701
+ alertId,
702
+ alertAreas,
703
+ alertType,
704
+ messageId,
705
+ currentText,
706
+ extractions,
707
+ votedResult,
708
+ });
709
+ // Merge new extractions with existing valid ones
710
+ const mergedExtractions = [...extractions, ...result.newExtractions];
711
+ logger.info("Agent: clarify completed", {
712
+ alertId,
713
+ toolCalls: result.toolCallCount,
714
+ clarified: result.clarified,
715
+ newExtractions: result.newExtractions.length,
716
+ newPosts: result.newPosts.length,
717
+ });
718
+ return {
719
+ extractions: mergedExtractions,
720
+ // Reset votedResult so vote() re-runs with merged data
721
+ votedResult: null,
722
+ clarifyAttempted: true,
723
+ };
724
+ }
725
+ catch (err) {
726
+ logger.error("Agent: clarify failed", {
727
+ alertId,
728
+ error: String(err),
729
+ });
730
+ return { clarifyAttempted: true };
731
+ }
732
+ }
733
+ // ── Conditional routing after vote ─────────────────────
734
+ function shouldClarify(state) {
735
+ // Only clarify once per pipeline run (prevents infinite loop)
736
+ if (state.clarifyAttempted) {
737
+ return "editMessage";
738
+ }
739
+ // MCP tools must be enabled
740
+ if (!config.agent.mcpTools) {
741
+ return "editMessage";
742
+ }
743
+ // No voted result → nothing to clarify
744
+ if (!state.votedResult) {
745
+ return "editMessage";
746
+ }
747
+ // Confidence below threshold → clarify
748
+ if (state.votedResult.confidence < config.agent.confidenceThreshold) {
749
+ logger.info("Agent: routing to clarify (low confidence)", {
750
+ confidence: state.votedResult.confidence,
751
+ threshold: config.agent.confidenceThreshold,
752
+ });
753
+ return "clarify";
754
+ }
755
+ return "editMessage";
756
+ }
757
+ // ── Build graph ────────────────────────────────────────
758
+ /** MemorySaver checkpointer — session-level state persistence */
759
+ const checkpointer = new MemorySaver();
760
+ function buildGraph() {
761
+ const graph = new StateGraph(AgentState)
762
+ .addNode("collectAndPreFilter", collectAndPreFilter)
763
+ .addNode("extractAndValidate", extractAndValidate)
764
+ .addNode("postFilter", postFilter)
765
+ .addNode("vote", vote)
766
+ .addNode("clarify", clarifyNode)
767
+ .addNode("revote", vote) // Re-run vote after clarify with merged data
768
+ .addNode("editMessage", editMessage)
769
+ .addEdge("__start__", "collectAndPreFilter")
770
+ .addEdge("collectAndPreFilter", "extractAndValidate")
771
+ .addEdge("extractAndValidate", "postFilter")
772
+ .addEdge("postFilter", "vote")
773
+ // Conditional edge: vote → clarify (low conf) or editMessage (high conf)
774
+ .addConditionalEdges("vote", shouldClarify, {
775
+ clarify: "clarify",
776
+ editMessage: "editMessage",
777
+ })
778
+ .addEdge("clarify", "revote")
779
+ .addEdge("revote", "editMessage")
780
+ .addEdge("editMessage", "__end__");
781
+ return graph.compile({ checkpointer });
782
+ }
783
+ export async function runEnrichment(input) {
784
+ const app = buildGraph();
785
+ await app.invoke({
786
+ alertId: input.alertId,
787
+ alertTs: input.alertTs,
788
+ alertType: input.alertType,
789
+ alertAreas: input.alertAreas,
790
+ chatId: input.chatId,
791
+ messageId: input.messageId,
792
+ isCaption: input.isCaption,
793
+ currentText: input.currentText,
794
+ channelPosts: [],
795
+ filteredPosts: [],
796
+ extractions: [],
797
+ votedResult: null,
798
+ clarifyAttempted: false,
799
+ },
800
+ // Thread ID for MemorySaver — enables session-level state persistence
801
+ { configurable: { thread_id: input.alertId } });
802
+ }
803
+ //# sourceMappingURL=graph.js.map