easyoref 1.12.0 → 1.13.0

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