@easyoref/agent 1.21.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 (158) hide show
  1. package/__tests__/clarify.test.ts +827 -0
  2. package/__tests__/config.test.ts +304 -0
  3. package/__tests__/enrichment.integration.test.ts +871 -0
  4. package/__tests__/graph.test.ts +661 -0
  5. package/dist/auth.d.ts +11 -0
  6. package/dist/auth.d.ts.map +1 -0
  7. package/dist/auth.js +54 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/dry-run.d.ts +12 -0
  10. package/dist/dry-run.d.ts.map +1 -0
  11. package/dist/dry-run.js +236 -0
  12. package/dist/dry-run.js.map +1 -0
  13. package/dist/extract.d.ts +180 -0
  14. package/dist/extract.d.ts.map +1 -0
  15. package/dist/extract.js +210 -0
  16. package/dist/extract.js.map +1 -0
  17. package/dist/graph.d.ts +4083 -0
  18. package/dist/graph.d.ts.map +1 -0
  19. package/dist/graph.js +162 -0
  20. package/dist/graph.js.map +1 -0
  21. package/dist/index.d.ts +23 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +23 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/models.d.ts +7 -0
  26. package/dist/models.d.ts.map +1 -0
  27. package/dist/models.js +18 -0
  28. package/dist/models.js.map +1 -0
  29. package/dist/nodes/clarify-node.d.ts +132 -0
  30. package/dist/nodes/clarify-node.d.ts.map +1 -0
  31. package/dist/nodes/clarify-node.js +118 -0
  32. package/dist/nodes/clarify-node.js.map +1 -0
  33. package/dist/nodes/clarify.d.ts +6 -0
  34. package/dist/nodes/clarify.d.ts.map +1 -0
  35. package/dist/nodes/clarify.js +124 -0
  36. package/dist/nodes/clarify.js.map +1 -0
  37. package/dist/nodes/edit-node.d.ts +71 -0
  38. package/dist/nodes/edit-node.d.ts.map +1 -0
  39. package/dist/nodes/edit-node.js +496 -0
  40. package/dist/nodes/edit-node.js.map +1 -0
  41. package/dist/nodes/edit.d.ts +6 -0
  42. package/dist/nodes/edit.d.ts.map +1 -0
  43. package/dist/nodes/edit.js +22 -0
  44. package/dist/nodes/edit.js.map +1 -0
  45. package/dist/nodes/extract-node.d.ts +174 -0
  46. package/dist/nodes/extract-node.d.ts.map +1 -0
  47. package/dist/nodes/extract-node.js +233 -0
  48. package/dist/nodes/extract-node.js.map +1 -0
  49. package/dist/nodes/extract.d.ts +6 -0
  50. package/dist/nodes/extract.d.ts.map +1 -0
  51. package/dist/nodes/extract.js +49 -0
  52. package/dist/nodes/extract.js.map +1 -0
  53. package/dist/nodes/filter-agent.d.ts +11 -0
  54. package/dist/nodes/filter-agent.d.ts.map +1 -0
  55. package/dist/nodes/filter-agent.js +60 -0
  56. package/dist/nodes/filter-agent.js.map +1 -0
  57. package/dist/nodes/filter-node.d.ts +9 -0
  58. package/dist/nodes/filter-node.d.ts.map +1 -0
  59. package/dist/nodes/filter-node.js +111 -0
  60. package/dist/nodes/filter-node.js.map +1 -0
  61. package/dist/nodes/filters.d.ts +13 -0
  62. package/dist/nodes/filters.d.ts.map +1 -0
  63. package/dist/nodes/filters.js +111 -0
  64. package/dist/nodes/filters.js.map +1 -0
  65. package/dist/nodes/message-node.d.ts +71 -0
  66. package/dist/nodes/message-node.d.ts.map +1 -0
  67. package/dist/nodes/message-node.js +491 -0
  68. package/dist/nodes/message-node.js.map +1 -0
  69. package/dist/nodes/message.d.ts +71 -0
  70. package/dist/nodes/message.d.ts.map +1 -0
  71. package/dist/nodes/message.js +496 -0
  72. package/dist/nodes/message.js.map +1 -0
  73. package/dist/nodes/vote-node.d.ts +13 -0
  74. package/dist/nodes/vote-node.d.ts.map +1 -0
  75. package/dist/nodes/vote-node.js +232 -0
  76. package/dist/nodes/vote-node.js.map +1 -0
  77. package/dist/nodes/vote.d.ts +13 -0
  78. package/dist/nodes/vote.d.ts.map +1 -0
  79. package/dist/nodes/vote.js +232 -0
  80. package/dist/nodes/vote.js.map +1 -0
  81. package/dist/queue.d.ts +15 -0
  82. package/dist/queue.d.ts.map +1 -0
  83. package/dist/queue.js +41 -0
  84. package/dist/queue.js.map +1 -0
  85. package/dist/redis.d.ts +8 -0
  86. package/dist/redis.d.ts.map +1 -0
  87. package/dist/redis.js +33 -0
  88. package/dist/redis.js.map +1 -0
  89. package/dist/runtime/auth.d.ts +11 -0
  90. package/dist/runtime/auth.d.ts.map +1 -0
  91. package/dist/runtime/auth.js +54 -0
  92. package/dist/runtime/auth.js.map +1 -0
  93. package/dist/runtime/dry-run.d.ts +12 -0
  94. package/dist/runtime/dry-run.d.ts.map +1 -0
  95. package/dist/runtime/dry-run.js +236 -0
  96. package/dist/runtime/dry-run.js.map +1 -0
  97. package/dist/runtime/queue.d.ts +15 -0
  98. package/dist/runtime/queue.d.ts.map +1 -0
  99. package/dist/runtime/queue.js +41 -0
  100. package/dist/runtime/queue.js.map +1 -0
  101. package/dist/runtime/redis.d.ts +8 -0
  102. package/dist/runtime/redis.d.ts.map +1 -0
  103. package/dist/runtime/redis.js +33 -0
  104. package/dist/runtime/redis.js.map +1 -0
  105. package/dist/runtime/worker.d.ts +14 -0
  106. package/dist/runtime/worker.d.ts.map +1 -0
  107. package/dist/runtime/worker.js +135 -0
  108. package/dist/runtime/worker.js.map +1 -0
  109. package/dist/tools/alert-history.d.ts +18 -0
  110. package/dist/tools/alert-history.d.ts.map +1 -0
  111. package/dist/tools/alert-history.js +98 -0
  112. package/dist/tools/alert-history.js.map +1 -0
  113. package/dist/tools/betterstack-log.d.ts +15 -0
  114. package/dist/tools/betterstack-log.d.ts.map +1 -0
  115. package/dist/tools/betterstack-log.js +80 -0
  116. package/dist/tools/betterstack-log.js.map +1 -0
  117. package/dist/tools/index.d.ts +44 -0
  118. package/dist/tools/index.d.ts.map +1 -0
  119. package/dist/tools/index.js +20 -0
  120. package/dist/tools/index.js.map +1 -0
  121. package/dist/tools/read-sources.d.ts +15 -0
  122. package/dist/tools/read-sources.d.ts.map +1 -0
  123. package/dist/tools/read-sources.js +67 -0
  124. package/dist/tools/read-sources.js.map +1 -0
  125. package/dist/tools/resolve-area.d.ts +19 -0
  126. package/dist/tools/resolve-area.d.ts.map +1 -0
  127. package/dist/tools/resolve-area.js +147 -0
  128. package/dist/tools/resolve-area.js.map +1 -0
  129. package/dist/tools.d.ts +115 -0
  130. package/dist/tools.d.ts.map +1 -0
  131. package/dist/tools.js +439 -0
  132. package/dist/tools.js.map +1 -0
  133. package/dist/worker.d.ts +14 -0
  134. package/dist/worker.d.ts.map +1 -0
  135. package/dist/worker.js +135 -0
  136. package/dist/worker.js.map +1 -0
  137. package/package.json +26 -0
  138. package/src/graph.ts +200 -0
  139. package/src/index.ts +27 -0
  140. package/src/models.ts +20 -0
  141. package/src/nodes/clarify-node.ts +172 -0
  142. package/src/nodes/edit-node.ts +695 -0
  143. package/src/nodes/extract-node.ts +299 -0
  144. package/src/nodes/filter-node.ts +139 -0
  145. package/src/nodes/message.ts +695 -0
  146. package/src/nodes/vote-node.ts +354 -0
  147. package/src/nodes/vote.ts +354 -0
  148. package/src/runtime/auth.ts +63 -0
  149. package/src/runtime/dry-run.ts +303 -0
  150. package/src/runtime/queue.ts +53 -0
  151. package/src/runtime/redis.ts +38 -0
  152. package/src/runtime/worker.ts +167 -0
  153. package/src/tools/alert-history.ts +120 -0
  154. package/src/tools/betterstack-log.ts +102 -0
  155. package/src/tools/index.ts +23 -0
  156. package/src/tools/read-sources.ts +86 -0
  157. package/src/tools/resolve-area.ts +202 -0
  158. package/tsconfig.json +14 -0
@@ -0,0 +1,661 @@
1
+ /**
2
+ * Tests for enrichment pipeline functions.
3
+ *
4
+ * Split into two parts:
5
+ * 1. Unit tests — no LLM, no network. Test pure functions from extract, vote, message, helpers.
6
+ * 2. Integration tests — call real OpenRouter API (skipped without OPENROUTER_API_KEY env).
7
+ *
8
+ * Run with integration: OPENROUTER_API_KEY=sk-or-... npm test
9
+ */
10
+
11
+ import {
12
+ emptyEnrichmentData,
13
+ type CitedSource,
14
+ type InlineCite,
15
+ type ValidatedExtraction,
16
+ type VotedResult,
17
+ } from "@easyoref/shared";
18
+ import { describe, expect, it, vi } from "vitest";
19
+
20
+ // ── Mocks — minimal, only for config & logger ─────────
21
+
22
+ vi.mock("@easyoref/shared", async () => {
23
+ const actual = await vi.importActual("@easyoref/shared");
24
+ return {
25
+ ...actual,
26
+ config: {
27
+ agent: {
28
+ filterModel: "google/gemini-2.5-flash-lite",
29
+ extractModel: "google/gemini-3.1-flash-lite-preview",
30
+ apiKey: process.env.OPENROUTER_API_KEY ?? "test-key",
31
+ mcpTools: false,
32
+ clarifyFetchCount: 3,
33
+ confidenceThreshold: 0.6,
34
+ channels: ["@idf_telegram", "@N12LIVE", "@kann_news"],
35
+ areaLabels: { הרצליה: "Герцлия" },
36
+ },
37
+ botToken: "",
38
+ areas: ["הרצליה", "תל אביב - דרום העיר ויפו"],
39
+ language: "ru",
40
+ orefApiUrl: "https://mock.oref.api/alerts",
41
+ orefHistoryUrl: "",
42
+ logtailToken: "",
43
+ },
44
+ getRedis: vi.fn().mockReturnValue({
45
+ lpush: vi.fn(),
46
+ expire: vi.fn(),
47
+ }),
48
+ pushSessionPost: vi.fn(),
49
+ pushChannelPost: vi.fn(),
50
+ getActiveSession: vi.fn().mockResolvedValue(null),
51
+ getChannelPosts: vi.fn().mockResolvedValue([]),
52
+ getEnrichmentData: vi.fn().mockResolvedValue(null),
53
+ saveEnrichmentData: vi.fn(),
54
+ getCachedExtractions: vi.fn().mockResolvedValue(new Map()),
55
+ saveCachedExtractions: vi.fn(),
56
+ getLastUpdateTs: vi.fn().mockResolvedValue(0),
57
+ setLastUpdateTs: vi.fn(),
58
+ };
59
+ });
60
+
61
+ vi.mock("@easyoref/monitoring", () => ({
62
+ info: vi.fn(),
63
+ warn: vi.fn(),
64
+ error: vi.fn(),
65
+ debug: vi.fn(),
66
+ }));
67
+
68
+ // ── Imports (after mocks are hoisted) ──────────────────
69
+
70
+ import { textHash, toIsraelTime } from "@easyoref/shared";
71
+ import { postFilter } from "../src/nodes/extract-node.js";
72
+ import {
73
+ buildEnrichedMessage,
74
+ buildEnrichmentFromVote,
75
+ buildGlobalCiteMap,
76
+ CERTAIN,
77
+ COUNTRY_RU,
78
+ extractCites,
79
+ inlineCites,
80
+ inlineCitesFromData,
81
+ insertBeforeTimeLine,
82
+ renderCitesGlobal,
83
+ SKIP,
84
+ UNCERTAIN,
85
+ } from "../src/nodes/message.js";
86
+ import { vote } from "../src/nodes/vote-node.js";
87
+
88
+ // ═══════════════════════════════════════════════════════
89
+ // PART 1: UNIT TESTS (pure functions, no LLM)
90
+ // ═══════════════════════════════════════════════════════
91
+
92
+ // ─── textHash ──────────────────────────────────────────
93
+
94
+ describe("textHash", () => {
95
+ it("returns stable hash for same input", () => {
96
+ const h1 = textHash("hello world");
97
+ const h2 = textHash("hello world");
98
+ expect(h1).toBe(h2);
99
+ expect(h1).toMatch(/^[a-f0-9]{16,32}$/);
100
+ });
101
+
102
+ it("returns different hash for different input", () => {
103
+ expect(textHash("a")).not.toBe(textHash("b"));
104
+ });
105
+ });
106
+
107
+ // ─── toIsraelTime ──────────────────────────────────────
108
+
109
+ describe("toIsraelTime", () => {
110
+ it("formats timestamp in Israel timezone", () => {
111
+ const ts = new Date("2024-01-15T12:00:00Z").getTime();
112
+ const result = toIsraelTime(ts);
113
+ expect(result).toMatch(/14:00/);
114
+ });
115
+ });
116
+
117
+ // ─── postFilter ────────────────────────────────────────
118
+
119
+ describe("postFilter", () => {
120
+ function makeExtraction(
121
+ overrides: Partial<ValidatedExtraction> = {},
122
+ ): ValidatedExtraction {
123
+ return {
124
+ channel: "@test",
125
+ regionRelevance: 0.9,
126
+ sourceTrust: 0.8,
127
+ tone: "calm" as const,
128
+ timeRelevance: 0.9,
129
+ countryOrigin: "Iran",
130
+ rocketCount: 10,
131
+ isCassette: undefined,
132
+ intercepted: undefined,
133
+ interceptedQual: undefined,
134
+ seaImpact: undefined,
135
+ seaImpactQual: undefined,
136
+ openAreaImpact: undefined,
137
+ openAreaImpactQual: undefined,
138
+ hitsConfirmed: undefined,
139
+ casualties: undefined,
140
+ injuries: undefined,
141
+ etaRefinedMinutes: undefined,
142
+ rocketDetail: undefined,
143
+ confidence: 0.7,
144
+ valid: true,
145
+ ...overrides,
146
+ };
147
+ }
148
+
149
+ it("passes valid extraction", () => {
150
+ const result = postFilter([makeExtraction()], "test-1");
151
+ expect(result[0].valid).toBe(true);
152
+ });
153
+
154
+ it("rejects stale posts (timeRelevance < 0.5)", () => {
155
+ const result = postFilter(
156
+ [makeExtraction({ timeRelevance: 0.3 })],
157
+ "test-1",
158
+ );
159
+ expect(result[0].valid).toBe(false);
160
+ expect(result[0].rejectReason).toBe("stale_post");
161
+ });
162
+
163
+ it("rejects region_irrelevant posts", () => {
164
+ const result = postFilter(
165
+ [makeExtraction({ regionRelevance: 0.2 })],
166
+ "test-1",
167
+ );
168
+ expect(result[0].valid).toBe(false);
169
+ expect(result[0].rejectReason).toBe("region_irrelevant");
170
+ });
171
+
172
+ it("rejects untrusted sources", () => {
173
+ const result = postFilter([makeExtraction({ sourceTrust: 0.2 })], "test-1");
174
+ expect(result[0].valid).toBe(false);
175
+ expect(result[0].rejectReason).toBe("untrusted_source");
176
+ });
177
+
178
+ it("rejects alarmist tone", () => {
179
+ const result = postFilter([makeExtraction({ tone: "alarmist" })], "test-1");
180
+ expect(result[0].valid).toBe(false);
181
+ expect(result[0].rejectReason).toBe("alarmist_tone");
182
+ });
183
+
184
+ it("rejects extraction with no data fields", () => {
185
+ const result = postFilter(
186
+ [
187
+ makeExtraction({
188
+ countryOrigin: undefined,
189
+ rocketCount: undefined,
190
+ isCassette: undefined,
191
+ intercepted: undefined,
192
+ interceptedQual: undefined,
193
+ hitsConfirmed: undefined,
194
+ casualties: undefined,
195
+ injuries: undefined,
196
+ etaRefinedMinutes: undefined,
197
+ }),
198
+ ],
199
+ "test-1",
200
+ );
201
+ expect(result[0].valid).toBe(false);
202
+ expect(result[0].rejectReason).toBe("no_data");
203
+ });
204
+
205
+ it("rejects low confidence", () => {
206
+ const result = postFilter([makeExtraction({ confidence: 0.1 })], "test-1");
207
+ expect(result[0].valid).toBe(false);
208
+ expect(result[0].rejectReason).toBe("low_confidence");
209
+ });
210
+
211
+ it("timeRelevance is checked FIRST (before region)", () => {
212
+ const result = postFilter(
213
+ [makeExtraction({ timeRelevance: 0.1, regionRelevance: 0.1 })],
214
+ "test-1",
215
+ );
216
+ expect(result[0].rejectReason).toBe("stale_post");
217
+ });
218
+ });
219
+
220
+ // ─── vote ──────────────────────────────────────────────
221
+
222
+ describe("vote", () => {
223
+ function makeValidExtraction(
224
+ overrides: Partial<ValidatedExtraction> = {},
225
+ ): ValidatedExtraction {
226
+ return {
227
+ channel: "@test",
228
+ regionRelevance: 0.9,
229
+ sourceTrust: 0.8,
230
+ tone: "calm" as const,
231
+ timeRelevance: 0.9,
232
+ countryOrigin: "Iran",
233
+ rocketCount: 10,
234
+ isCassette: undefined,
235
+ intercepted: undefined,
236
+ interceptedQual: undefined,
237
+ seaImpact: undefined,
238
+ seaImpactQual: undefined,
239
+ openAreaImpact: undefined,
240
+ openAreaImpactQual: undefined,
241
+ hitsConfirmed: undefined,
242
+ casualties: undefined,
243
+ injuries: undefined,
244
+ etaRefinedMinutes: undefined,
245
+ rocketDetail: undefined,
246
+ confidence: 0.8,
247
+ valid: true,
248
+ messageUrl: "https://t.me/test/1",
249
+ ...overrides,
250
+ };
251
+ }
252
+
253
+ it("returns undefined for empty valid extractions", () => {
254
+ const result = vote([makeValidExtraction({ valid: false })], "test-1");
255
+ expect(result).toBeUndefined();
256
+ });
257
+
258
+ it("aggregates country origins from multiple sources", () => {
259
+ const result = vote(
260
+ [
261
+ makeValidExtraction({
262
+ channel: "@a",
263
+ countryOrigin: "Iran",
264
+ messageUrl: "https://t.me/a/1",
265
+ }),
266
+ makeValidExtraction({
267
+ channel: "@b",
268
+ countryOrigin: "Iran",
269
+ messageUrl: "https://t.me/b/1",
270
+ }),
271
+ ],
272
+ "test-1",
273
+ );
274
+ expect(result).not.toBeNull();
275
+ expect(result!.countryOrigins).toHaveLength(1);
276
+ expect(result!.countryOrigins![0].name).toBe("Iran");
277
+ expect(result!.countryOrigins![0].citations).toHaveLength(2);
278
+ });
279
+
280
+ it("computes rocket count range", () => {
281
+ const result = vote(
282
+ [
283
+ makeValidExtraction({ rocketCount: 10 }),
284
+ makeValidExtraction({ rocketCount: 15 }),
285
+ ],
286
+ "test-1",
287
+ );
288
+ expect(result!.rocketCountMin).toBe(10);
289
+ expect(result!.rocketCountMax).toBe(15);
290
+ expect(result!.rocketCitations).toHaveLength(2);
291
+ });
292
+
293
+ it("median injuries from multiple sources", () => {
294
+ const result = vote(
295
+ [
296
+ makeValidExtraction({ injuries: 5 }),
297
+ makeValidExtraction({ injuries: 3 }),
298
+ makeValidExtraction({ injuries: 8 }),
299
+ ],
300
+ "test-1",
301
+ );
302
+ expect(result!.injuries).toBe(5);
303
+ expect(result!.injuriesCitations).toHaveLength(3);
304
+ });
305
+
306
+ it("sets citedSources with 1-based indices", () => {
307
+ const result = vote(
308
+ [
309
+ makeValidExtraction({
310
+ channel: "@a",
311
+ messageUrl: "https://t.me/a/1",
312
+ }),
313
+ makeValidExtraction({
314
+ channel: "@b",
315
+ messageUrl: "https://t.me/b/1",
316
+ }),
317
+ ],
318
+ "test-1",
319
+ );
320
+ expect(result!.citedSources).toHaveLength(2);
321
+ expect(result!.citedSources[0].index).toBe(1);
322
+ expect(result!.citedSources[1].index).toBe(2);
323
+ });
324
+ });
325
+
326
+ // ─── inlineCites ───────────────────────────────────────
327
+
328
+ describe("inlineCites", () => {
329
+ const sources: CitedSource[] = [
330
+ { index: 1, channel: "@a", messageUrl: "https://t.me/a/1" },
331
+ { index: 2, channel: "@b", messageUrl: "https://t.me/b/2" },
332
+ { index: 3, channel: "@c", messageUrl: undefined },
333
+ ];
334
+
335
+ it("returns HTML links for indices with URLs", () => {
336
+ const result = inlineCites([1, 2], sources);
337
+ expect(result).toContain('<a href="https://t.me/a/1">[1]</a>');
338
+ expect(result).toContain('<a href="https://t.me/b/2">[2]</a>');
339
+ });
340
+
341
+ it("skips indices without URLs", () => {
342
+ const result = inlineCites([3], sources);
343
+ expect(result).toBe("");
344
+ });
345
+
346
+ it("returns empty string for no indices", () => {
347
+ const result = inlineCites([], sources);
348
+ expect(result).toBe("");
349
+ });
350
+ });
351
+
352
+ // ─── inlineCitesFromData ───────────────────────────────
353
+
354
+ describe("inlineCitesFromData", () => {
355
+ it("renders InlineCite array to HTML", () => {
356
+ const cites: InlineCite[] = [
357
+ { url: "https://t.me/a/1", channel: "@a" },
358
+ { url: "https://t.me/b/2", channel: "@b" },
359
+ ];
360
+ const result = inlineCitesFromData(cites);
361
+ expect(result).toContain('<a href="https://t.me/a/1">[1]</a>');
362
+ expect(result).toContain('<a href="https://t.me/b/2">[2]</a>');
363
+ });
364
+
365
+ it("returns empty for empty array", () => {
366
+ expect(inlineCitesFromData([])).toBe("");
367
+ });
368
+ });
369
+
370
+ // ─── buildGlobalCiteMap + renderCitesGlobal ────────────
371
+
372
+ describe("buildGlobalCiteMap", () => {
373
+ it("assigns unique sequential indices by URL", () => {
374
+ const data = { ...emptyEnrichmentData };
375
+ data.originCites = [{ url: "https://t.me/a/1", channel: "@a" }];
376
+ data.rocketCites = [
377
+ { url: "https://t.me/a/1", channel: "@a" },
378
+ { url: "https://t.me/b/2", channel: "@b" },
379
+ ];
380
+ const map = buildGlobalCiteMap(data);
381
+ expect(map.get("https://t.me/a/1")).toBe(1);
382
+ expect(map.get("https://t.me/b/2")).toBe(2);
383
+ expect(map.size).toBe(2);
384
+ });
385
+
386
+ it("returns empty map for empty enrichment", () => {
387
+ const map = buildGlobalCiteMap(emptyEnrichmentData);
388
+ expect(map.size).toBe(0);
389
+ });
390
+ });
391
+
392
+ describe("renderCitesGlobal", () => {
393
+ it("renders citations with global indices", () => {
394
+ const globalMap = new Map([
395
+ ["https://t.me/a/1", 1],
396
+ ["https://t.me/b/2", 3],
397
+ ]);
398
+ const cites: InlineCite[] = [{ url: "https://t.me/b/2", channel: "@b" }];
399
+ const result = renderCitesGlobal(cites, globalMap);
400
+ expect(result).toBe(' <a href="https://t.me/b/2">[3]</a>');
401
+ });
402
+
403
+ it("returns empty for empty cites", () => {
404
+ const map = new Map([["https://t.me/a/1", 1]]);
405
+ expect(renderCitesGlobal([], map)).toBe("");
406
+ });
407
+ });
408
+
409
+ // ─── extractCites ──────────────────────────────────────
410
+
411
+ describe("extractCites", () => {
412
+ const sources: CitedSource[] = [
413
+ { index: 1, channel: "@a", messageUrl: "https://t.me/a/1" },
414
+ { index: 2, channel: "@b" },
415
+ ];
416
+
417
+ it("returns InlineCite objects with url and channel", () => {
418
+ const cites = extractCites([1], sources);
419
+ expect(cites).toHaveLength(1);
420
+ expect(cites[0].url).toBe("https://t.me/a/1");
421
+ expect(cites[0].channel).toBe("@a");
422
+ });
423
+
424
+ it("skips sources without messageUrl", () => {
425
+ const cites = extractCites([2], sources);
426
+ expect(cites).toHaveLength(0);
427
+ });
428
+ });
429
+
430
+ // ─── buildEnrichmentFromVote ───────────────────────────
431
+
432
+ describe("buildEnrichmentFromVote", () => {
433
+ const alertTs = new Date("2024-03-09T16:00:00Z").getTime();
434
+
435
+ function makeVoted(overrides: Partial<VotedResult> = {}): VotedResult {
436
+ return {
437
+ etaRefinedMinutes: undefined,
438
+ etaCitations: [],
439
+ countryOrigins: [{ name: "Iran", citations: [1] }],
440
+ rocketCountMin: 10,
441
+ rocketCountMax: 15,
442
+ rocketCitations: [1, 2],
443
+ rocketConfidence: 0.8,
444
+ isCassette: undefined,
445
+ isCassetteConfidence: 0,
446
+ intercepted: 8,
447
+ interceptedQual: undefined,
448
+ interceptedConfidence: 0.7,
449
+ seaImpact: undefined,
450
+ seaImpactQual: undefined,
451
+ seaConfidence: 0,
452
+ openAreaImpact: undefined,
453
+ openAreaImpactQual: undefined,
454
+ openAreaConfidence: 0,
455
+ hitsConfirmed: 1,
456
+ hitsCitations: [2],
457
+ hitsConfidence: 0.7,
458
+ noImpacts: false,
459
+ noImpactsCitations: [],
460
+ interceptedCitations: [1],
461
+ rocketDetail: undefined,
462
+ casualties: undefined,
463
+ casualtiesCitations: [],
464
+ casualtiesConfidence: 0,
465
+ injuries: 3,
466
+ injuriesCitations: [1],
467
+ injuriesConfidence: 0.7,
468
+ confidence: 0.75,
469
+ sourcesCount: 2,
470
+ citedSources: [
471
+ { index: 1, channel: "@a", messageUrl: "https://t.me/a/1" },
472
+ { index: 2, channel: "@b", messageUrl: "https://t.me/b/2" },
473
+ ],
474
+ ...overrides,
475
+ };
476
+ }
477
+
478
+ it("sets origin from voted countryOrigins", () => {
479
+ const data = buildEnrichmentFromVote(
480
+ makeVoted(),
481
+ emptyEnrichmentData,
482
+ "early_warning",
483
+ alertTs,
484
+ );
485
+ expect(data.origin).toBe("Иран");
486
+ expect(data.originCites).toHaveLength(1);
487
+ expect(data.originCites[0].url).toBe("https://t.me/a/1");
488
+ });
489
+
490
+ it("preserves carry-forward data from prev", () => {
491
+ const prev = emptyEnrichmentData;
492
+ prev.origin = "Йемен";
493
+ prev.originCites = [{ url: "https://t.me/old/1", channel: "@old" }];
494
+
495
+ const voted = makeVoted({ countryOrigins: undefined });
496
+ const data = buildEnrichmentFromVote(voted, prev, "red_alert", alertTs);
497
+ expect(data.origin).toBe("Йемен");
498
+ expect(data.originCites).toHaveLength(1);
499
+ });
500
+
501
+ it("sets ETA for early_warning", () => {
502
+ const voted = makeVoted({ etaRefinedMinutes: 5 });
503
+ const data = buildEnrichmentFromVote(
504
+ voted,
505
+ emptyEnrichmentData,
506
+ "early_warning",
507
+ alertTs,
508
+ );
509
+ expect(data.etaAbsolute).toMatch(/^~\d{2}:\d{2}$/);
510
+ });
511
+
512
+ it("sets injuries for resolved phase", () => {
513
+ const voted = makeVoted({ injuries: 3, injuriesConfidence: 0.95 });
514
+ const data = buildEnrichmentFromVote(
515
+ voted,
516
+ emptyEnrichmentData,
517
+ "resolved",
518
+ alertTs,
519
+ );
520
+ expect(data.injuries).toBe("3");
521
+ expect(data.injuriesCites).toHaveLength(1);
522
+ });
523
+
524
+ it("shows uncertainty marker for injuries at sub-certain confidence", () => {
525
+ const voted = makeVoted({ injuries: 3, injuriesConfidence: 0.8 });
526
+ const data = buildEnrichmentFromVote(
527
+ voted,
528
+ emptyEnrichmentData,
529
+ "resolved",
530
+ alertTs,
531
+ );
532
+ expect(data.injuries).toBe("3 (?)");
533
+ });
534
+
535
+ it("records earlyWarningTime on first early_warning", () => {
536
+ const data = buildEnrichmentFromVote(
537
+ makeVoted(),
538
+ emptyEnrichmentData,
539
+ "early_warning",
540
+ alertTs,
541
+ );
542
+ expect(data.earlyWarningTime).toBeTruthy();
543
+ expect(data.earlyWarningTime).toMatch(/\d{2}:\d{2}/);
544
+ });
545
+ });
546
+
547
+ // ─── buildEnrichedMessage ──────────────────────────────
548
+
549
+ describe("buildEnrichedMessage", () => {
550
+ const alertTs = new Date("2024-03-09T16:00:00Z").getTime();
551
+
552
+ it("inserts origin before time line", () => {
553
+ const enrichment = emptyEnrichmentData;
554
+ enrichment.origin = "Иран";
555
+ enrichment.originCites = [{ url: "https://t.me/a/1", channel: "@a" }];
556
+
557
+ const text =
558
+ "🔴 Тревога!\nОбласть: Герцлия\n<b>Время оповещения:</b> 18:00";
559
+ const result = buildEnrichedMessage(
560
+ text,
561
+ "early_warning",
562
+ alertTs,
563
+ enrichment,
564
+ );
565
+ expect(result).toContain("<b>Откуда:</b> Иран");
566
+ expect(result).toContain('<a href="https://t.me/a/1">[1]</a>');
567
+ const originIdx = result.indexOf("Откуда:");
568
+ const timeIdx = result.indexOf("Время оповещения:");
569
+ expect(originIdx).toBeLessThan(timeIdx);
570
+ });
571
+
572
+ it("inserts rocket count and intercepted as separate lines", () => {
573
+ const enrichment = emptyEnrichmentData;
574
+ enrichment.rocketCount = "~10–15";
575
+ enrichment.intercepted = "8";
576
+ enrichment.rocketCites = [{ url: "https://t.me/a/1", channel: "@a" }];
577
+ enrichment.interceptedCites = [{ url: "https://t.me/b/1", channel: "@b" }];
578
+
579
+ const text = "🔴 Тревога!\n<b>Время оповещения:</b> 18:00";
580
+ const result = buildEnrichedMessage(text, "red_alert", alertTs, enrichment);
581
+ expect(result).toContain("<b>Ракет:</b> ~10–15");
582
+ expect(result).toContain("<b>Перехваты:</b> 8");
583
+ expect(result).not.toContain("из них");
584
+ });
585
+
586
+ it("inserts casualties/injuries only for resolved", () => {
587
+ const enrichment = emptyEnrichmentData;
588
+ enrichment.casualties = "2";
589
+ enrichment.injuries = "5";
590
+ enrichment.casualtiesCites = [{ url: "https://t.me/a/1", channel: "@a" }];
591
+ enrichment.injuriesCites = [{ url: "https://t.me/b/1", channel: "@b" }];
592
+
593
+ const text = "✅ Отбой\n<b>Время оповещения:</b> 18:00";
594
+ const resultResolved = buildEnrichedMessage(
595
+ text,
596
+ "resolved",
597
+ alertTs,
598
+ enrichment,
599
+ );
600
+ expect(resultResolved).toContain("<b>Погибшие:</b> 2");
601
+ expect(resultResolved).toContain("<b>Пострадавшие:</b> 5");
602
+
603
+ const resultSiren = buildEnrichedMessage(
604
+ text,
605
+ "red_alert",
606
+ alertTs,
607
+ enrichment,
608
+ );
609
+ expect(resultSiren).not.toContain("Погибшие:");
610
+ expect(resultSiren).not.toContain("Пострадавшие:");
611
+ });
612
+
613
+ it("does not insert early warning time in red_alert phase (replaced by reply chain)", () => {
614
+ const enrichment = emptyEnrichmentData;
615
+ enrichment.earlyWarningTime = "17:55";
616
+
617
+ const text = "🟡 Сирена!\n<b>Время оповещения:</b> 18:00";
618
+ const result = buildEnrichedMessage(text, "red_alert", alertTs, enrichment);
619
+ expect(result).not.toContain("Раннее предупреждение:");
620
+ });
621
+ });
622
+
623
+ // ─── insertBeforeTimeLine ──────────────────────────────
624
+
625
+ describe("insertBeforeTimeLine", () => {
626
+ it("inserts before Время оповещения line", () => {
627
+ const text = "Header\n<b>Время оповещения:</b> 18:00";
628
+ const result = insertBeforeTimeLine(text, "NEW LINE");
629
+ expect(result.indexOf("NEW LINE")).toBeLessThan(
630
+ result.indexOf("Время оповещения:"),
631
+ );
632
+ });
633
+
634
+ it("inserts before last line if no time pattern", () => {
635
+ const text = "Line1\nLine2\nLine3";
636
+ const result = insertBeforeTimeLine(text, "NEW");
637
+ const lines = result.split("\n");
638
+ expect(lines[lines.length - 2]).toBe("NEW");
639
+ });
640
+ });
641
+
642
+ // ─── COUNTRY_RU translations ───────────────────────────
643
+
644
+ describe("COUNTRY_RU", () => {
645
+ it("maps all expected countries", () => {
646
+ expect(COUNTRY_RU["Iran"]).toBe("Иран");
647
+ expect(COUNTRY_RU["Yemen"]).toBe("Йемен");
648
+ expect(COUNTRY_RU["Lebanon"]).toBe("Ливан");
649
+ expect(COUNTRY_RU["Gaza"]).toBe("Газа");
650
+ });
651
+ });
652
+
653
+ // ─── Confidence thresholds ─────────────────────────────
654
+
655
+ describe("confidence thresholds", () => {
656
+ it("SKIP=0.6, UNCERTAIN=0.75, CERTAIN=0.95", () => {
657
+ expect(SKIP).toBe(0.6);
658
+ expect(UNCERTAIN).toBe(0.75);
659
+ expect(CERTAIN).toBe(0.95);
660
+ });
661
+ });
package/dist/auth.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * GramJS QR Auth — run once to generate session_string.
4
+ *
5
+ * Usage:
6
+ * npx tsx packages/bot/src/agent/auth.ts
7
+ *
8
+ * Scan QR with burner phone → session_string printed → done.
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":";AACA;;;;;;;GAOG"}