@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.
- package/__tests__/clarify.test.ts +827 -0
- package/__tests__/config.test.ts +304 -0
- package/__tests__/enrichment.integration.test.ts +871 -0
- package/__tests__/graph.test.ts +661 -0
- package/dist/auth.d.ts +11 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +54 -0
- package/dist/auth.js.map +1 -0
- package/dist/dry-run.d.ts +12 -0
- package/dist/dry-run.d.ts.map +1 -0
- package/dist/dry-run.js +236 -0
- package/dist/dry-run.js.map +1 -0
- package/dist/extract.d.ts +180 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +210 -0
- package/dist/extract.js.map +1 -0
- package/dist/graph.d.ts +4083 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/graph.js +162 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/models.d.ts +7 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +18 -0
- package/dist/models.js.map +1 -0
- package/dist/nodes/clarify-node.d.ts +132 -0
- package/dist/nodes/clarify-node.d.ts.map +1 -0
- package/dist/nodes/clarify-node.js +118 -0
- package/dist/nodes/clarify-node.js.map +1 -0
- package/dist/nodes/clarify.d.ts +6 -0
- package/dist/nodes/clarify.d.ts.map +1 -0
- package/dist/nodes/clarify.js +124 -0
- package/dist/nodes/clarify.js.map +1 -0
- package/dist/nodes/edit-node.d.ts +71 -0
- package/dist/nodes/edit-node.d.ts.map +1 -0
- package/dist/nodes/edit-node.js +496 -0
- package/dist/nodes/edit-node.js.map +1 -0
- package/dist/nodes/edit.d.ts +6 -0
- package/dist/nodes/edit.d.ts.map +1 -0
- package/dist/nodes/edit.js +22 -0
- package/dist/nodes/edit.js.map +1 -0
- package/dist/nodes/extract-node.d.ts +174 -0
- package/dist/nodes/extract-node.d.ts.map +1 -0
- package/dist/nodes/extract-node.js +233 -0
- package/dist/nodes/extract-node.js.map +1 -0
- package/dist/nodes/extract.d.ts +6 -0
- package/dist/nodes/extract.d.ts.map +1 -0
- package/dist/nodes/extract.js +49 -0
- package/dist/nodes/extract.js.map +1 -0
- package/dist/nodes/filter-agent.d.ts +11 -0
- package/dist/nodes/filter-agent.d.ts.map +1 -0
- package/dist/nodes/filter-agent.js +60 -0
- package/dist/nodes/filter-agent.js.map +1 -0
- package/dist/nodes/filter-node.d.ts +9 -0
- package/dist/nodes/filter-node.d.ts.map +1 -0
- package/dist/nodes/filter-node.js +111 -0
- package/dist/nodes/filter-node.js.map +1 -0
- package/dist/nodes/filters.d.ts +13 -0
- package/dist/nodes/filters.d.ts.map +1 -0
- package/dist/nodes/filters.js +111 -0
- package/dist/nodes/filters.js.map +1 -0
- package/dist/nodes/message-node.d.ts +71 -0
- package/dist/nodes/message-node.d.ts.map +1 -0
- package/dist/nodes/message-node.js +491 -0
- package/dist/nodes/message-node.js.map +1 -0
- package/dist/nodes/message.d.ts +71 -0
- package/dist/nodes/message.d.ts.map +1 -0
- package/dist/nodes/message.js +496 -0
- package/dist/nodes/message.js.map +1 -0
- package/dist/nodes/vote-node.d.ts +13 -0
- package/dist/nodes/vote-node.d.ts.map +1 -0
- package/dist/nodes/vote-node.js +232 -0
- package/dist/nodes/vote-node.js.map +1 -0
- package/dist/nodes/vote.d.ts +13 -0
- package/dist/nodes/vote.d.ts.map +1 -0
- package/dist/nodes/vote.js +232 -0
- package/dist/nodes/vote.js.map +1 -0
- package/dist/queue.d.ts +15 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +41 -0
- package/dist/queue.js.map +1 -0
- package/dist/redis.d.ts +8 -0
- package/dist/redis.d.ts.map +1 -0
- package/dist/redis.js +33 -0
- package/dist/redis.js.map +1 -0
- package/dist/runtime/auth.d.ts +11 -0
- package/dist/runtime/auth.d.ts.map +1 -0
- package/dist/runtime/auth.js +54 -0
- package/dist/runtime/auth.js.map +1 -0
- package/dist/runtime/dry-run.d.ts +12 -0
- package/dist/runtime/dry-run.d.ts.map +1 -0
- package/dist/runtime/dry-run.js +236 -0
- package/dist/runtime/dry-run.js.map +1 -0
- package/dist/runtime/queue.d.ts +15 -0
- package/dist/runtime/queue.d.ts.map +1 -0
- package/dist/runtime/queue.js +41 -0
- package/dist/runtime/queue.js.map +1 -0
- package/dist/runtime/redis.d.ts +8 -0
- package/dist/runtime/redis.d.ts.map +1 -0
- package/dist/runtime/redis.js +33 -0
- package/dist/runtime/redis.js.map +1 -0
- package/dist/runtime/worker.d.ts +14 -0
- package/dist/runtime/worker.d.ts.map +1 -0
- package/dist/runtime/worker.js +135 -0
- package/dist/runtime/worker.js.map +1 -0
- package/dist/tools/alert-history.d.ts +18 -0
- package/dist/tools/alert-history.d.ts.map +1 -0
- package/dist/tools/alert-history.js +98 -0
- package/dist/tools/alert-history.js.map +1 -0
- package/dist/tools/betterstack-log.d.ts +15 -0
- package/dist/tools/betterstack-log.d.ts.map +1 -0
- package/dist/tools/betterstack-log.js +80 -0
- package/dist/tools/betterstack-log.js.map +1 -0
- package/dist/tools/index.d.ts +44 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +20 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/read-sources.d.ts +15 -0
- package/dist/tools/read-sources.d.ts.map +1 -0
- package/dist/tools/read-sources.js +67 -0
- package/dist/tools/read-sources.js.map +1 -0
- package/dist/tools/resolve-area.d.ts +19 -0
- package/dist/tools/resolve-area.d.ts.map +1 -0
- package/dist/tools/resolve-area.js +147 -0
- package/dist/tools/resolve-area.js.map +1 -0
- package/dist/tools.d.ts +115 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +439 -0
- package/dist/tools.js.map +1 -0
- package/dist/worker.d.ts +14 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +135 -0
- package/dist/worker.js.map +1 -0
- package/package.json +26 -0
- package/src/graph.ts +200 -0
- package/src/index.ts +27 -0
- package/src/models.ts +20 -0
- package/src/nodes/clarify-node.ts +172 -0
- package/src/nodes/edit-node.ts +695 -0
- package/src/nodes/extract-node.ts +299 -0
- package/src/nodes/filter-node.ts +139 -0
- package/src/nodes/message.ts +695 -0
- package/src/nodes/vote-node.ts +354 -0
- package/src/nodes/vote.ts +354 -0
- package/src/runtime/auth.ts +63 -0
- package/src/runtime/dry-run.ts +303 -0
- package/src/runtime/queue.ts +53 -0
- package/src/runtime/redis.ts +38 -0
- package/src/runtime/worker.ts +167 -0
- package/src/tools/alert-history.ts +120 -0
- package/src/tools/betterstack-log.ts +102 -0
- package/src/tools/index.ts +23 -0
- package/src/tools/read-sources.ts +86 -0
- package/src/tools/resolve-area.ts +202 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the enrichment pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* - Deterministic tests (always run): post-filter, vote, message building
|
|
6
|
+
* - LLM tests (need OPENROUTER_API_KEY): real extraction via OpenRouter
|
|
7
|
+
*
|
|
8
|
+
* Run with real API:
|
|
9
|
+
* OPENROUTER_API_KEY=sk-or-... npx vitest run enrichment.integration
|
|
10
|
+
*
|
|
11
|
+
* The API key is read from config.yaml automatically if present.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
emptyEnrichmentData,
|
|
16
|
+
type CitedSource,
|
|
17
|
+
type ValidatedExtraction,
|
|
18
|
+
type VotedResult,
|
|
19
|
+
} from "@easyoref/shared";
|
|
20
|
+
import { describe, expect, it } from "vitest";
|
|
21
|
+
|
|
22
|
+
// ── Load config for API key ────────────────────────────
|
|
23
|
+
|
|
24
|
+
let API_KEY = process.env.OPENROUTER_API_KEY ?? "";
|
|
25
|
+
|
|
26
|
+
// Try to read from config.yaml if not in env
|
|
27
|
+
if (!API_KEY) {
|
|
28
|
+
try {
|
|
29
|
+
const { readFileSync } = await import("node:fs");
|
|
30
|
+
const { load } = await import("js-yaml");
|
|
31
|
+
const raw = readFileSync("config.yaml", "utf-8");
|
|
32
|
+
const cfg = load(raw) as Record<string, unknown>;
|
|
33
|
+
const ai = cfg?.ai as Record<string, unknown> | undefined;
|
|
34
|
+
API_KEY = (ai?.openrouter_api_key as string) ?? "";
|
|
35
|
+
} catch {
|
|
36
|
+
// No config.yaml — skip LLM tests
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HAS_API = Boolean(API_KEY);
|
|
41
|
+
|
|
42
|
+
// ── Mock config for graph.ts imports ───────────────────
|
|
43
|
+
|
|
44
|
+
// Mock the config module BEFORE importing graph.ts
|
|
45
|
+
import { vi } from "vitest";
|
|
46
|
+
|
|
47
|
+
vi.mock("../config.js", () => ({
|
|
48
|
+
config: {
|
|
49
|
+
agent: {
|
|
50
|
+
filterModel: "google/gemini-2.5-flash-lite",
|
|
51
|
+
extractModel: "google/gemini-3.1-flash-lite-preview",
|
|
52
|
+
apiKey: "", // Will be set dynamically
|
|
53
|
+
mcpTools: false,
|
|
54
|
+
confidenceThreshold: 0.65,
|
|
55
|
+
enrichDelayMs: 20_000,
|
|
56
|
+
windowMinutes: 2,
|
|
57
|
+
timeoutMinutes: 15,
|
|
58
|
+
areaLabels: { דן: "Дан центр" },
|
|
59
|
+
clarifyFetchCount: 3,
|
|
60
|
+
redisUrl: "redis://localhost:6379",
|
|
61
|
+
},
|
|
62
|
+
areas: ["תל אביב - דרום העיר ויפו"],
|
|
63
|
+
language: "ru",
|
|
64
|
+
botToken: "",
|
|
65
|
+
chatId: "",
|
|
66
|
+
orefApiUrl: "",
|
|
67
|
+
orefHistoryUrl: "",
|
|
68
|
+
logtailToken: "",
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
vi.mock("../logger.js", () => ({
|
|
73
|
+
info: vi.fn(),
|
|
74
|
+
warn: vi.fn(),
|
|
75
|
+
error: vi.fn(),
|
|
76
|
+
debug: vi.fn(),
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
vi.mock("../agent/redis.js", () => ({
|
|
80
|
+
getRedis: vi.fn().mockReturnValue({
|
|
81
|
+
get: vi.fn().mockResolvedValue(undefined),
|
|
82
|
+
setex: vi.fn(),
|
|
83
|
+
lpush: vi.fn(),
|
|
84
|
+
expire: vi.fn(),
|
|
85
|
+
lrange: vi.fn().mockResolvedValue([]),
|
|
86
|
+
del: vi.fn(),
|
|
87
|
+
}),
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
vi.mock("../agent/store.js", () => ({
|
|
91
|
+
getChannelPosts: vi.fn().mockResolvedValue([]),
|
|
92
|
+
getEnrichmentData: vi.fn().mockResolvedValue(undefined),
|
|
93
|
+
getActiveSession: vi.fn().mockResolvedValue(undefined),
|
|
94
|
+
saveEnrichmentData: vi.fn(),
|
|
95
|
+
pushSessionPost: vi.fn(),
|
|
96
|
+
getCachedExtractions: vi.fn().mockResolvedValue(new Map()),
|
|
97
|
+
saveCachedExtractions: vi.fn(),
|
|
98
|
+
getLastUpdateTs: vi.fn().mockResolvedValue(0),
|
|
99
|
+
setLastUpdateTs: vi.fn(),
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
vi.mock("../agent/clarify.js", () => ({
|
|
103
|
+
runClarify: vi.fn(),
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
// Import AFTER mocks
|
|
107
|
+
import { config, textHash, toIsraelTime } from "@easyoref/shared";
|
|
108
|
+
import { extractAgent, postFilter } from "../src/nodes/extract-node.js";
|
|
109
|
+
import {
|
|
110
|
+
buildEnrichedMessage,
|
|
111
|
+
buildEnrichmentFromVote,
|
|
112
|
+
inlineCites,
|
|
113
|
+
inlineCitesFromData,
|
|
114
|
+
} from "../src/nodes/message.js";
|
|
115
|
+
import { vote } from "../src/nodes/vote-node.js";
|
|
116
|
+
|
|
117
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
118
|
+
// Fixtures — real posts from the March 9, 2026 incident
|
|
119
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
120
|
+
|
|
121
|
+
/** Correct post: Iran launches detected (from @N12LIVE at 14:30 UTC / 16:30 IL) */
|
|
122
|
+
const POST_IRAN_LAUNCH = {
|
|
123
|
+
channel: "@N12LIVE",
|
|
124
|
+
text: "🔴 דיווח ראשון: זוהו שיגורים מאיראן לעבר ישראל. צפויות להתקבל התרעות באזורים שונים ברחבי הארץ",
|
|
125
|
+
ts: Date.parse("2026-03-09T14:30:30.000Z"),
|
|
126
|
+
messageUrl: "https://t.me/N12LIVE/167775",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** STALE post: IDF Lebanon ops — NOT about current attack */
|
|
130
|
+
const POST_LEBANON_STALE = {
|
|
131
|
+
channel: "@idf_telegram",
|
|
132
|
+
text: '🇱🇧 צה"ל תקף מוקדם יותר היום מטרות של חיזבאללה בדרום לבנון. הותקפו תשתיות טרור, מנהרות ומחסני נשק. כוחות צה"ל פועלים בהתאם להערכות המודיעין',
|
|
133
|
+
ts: Date.parse("2026-03-09T12:00:00.000Z"), // 2.5 hours before the alert
|
|
134
|
+
messageUrl: "https://t.me/idf_telegram/5432",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Siren phase: interception report (from @yediotnews25) */
|
|
138
|
+
const POST_INTERCEPTION = {
|
|
139
|
+
channel: "@yediotnews25",
|
|
140
|
+
text: "עדכון: מערכת כיפת ברזל וחץ יירטו את רוב הטילים שנורו מאיראן לעבר מרכז ישראל. דיווחים על נפילות בשטחים פתוחים באזור השרון. אין דיווחים על נפגעים",
|
|
141
|
+
ts: Date.parse("2026-03-09T14:45:00.000Z"),
|
|
142
|
+
messageUrl: "https://t.me/yediotnews25/88901",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Resolved phase: damage assessment (from @N12LIVE) */
|
|
146
|
+
const POST_RESOLVED = {
|
|
147
|
+
channel: "@N12LIVE",
|
|
148
|
+
text: "סיכום אירוע הטילים מאיראן: כ-15 טילים שוגרו, 12 יורטו על ידי מערכות ההגנה. 2 נפלו בשטח פתוח באזור השרון, 1 פגע במבנה ריק ברמת גן. 3 פצועים קל ממעידה",
|
|
149
|
+
ts: Date.parse("2026-03-09T15:30:00.000Z"),
|
|
150
|
+
messageUrl: "https://t.me/N12LIVE/167790",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** Alert timestamp: 14:30 UTC = 16:30 Israel */
|
|
154
|
+
const ALERT_TS = Date.parse("2026-03-09T14:30:00.000Z");
|
|
155
|
+
const ALERT_AREAS = ["תל אביב - דרום העיר ויפו"];
|
|
156
|
+
|
|
157
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
158
|
+
// Deterministic tests (no API needed)
|
|
159
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
160
|
+
|
|
161
|
+
describe("toIsraelTime", () => {
|
|
162
|
+
it("formats UTC timestamp to Israel time", () => {
|
|
163
|
+
// 14:30 UTC = 16:30 IST (UTC+2 winter) or 17:30 IDT (UTC+3 summer)
|
|
164
|
+
const formatted = toIsraelTime(ALERT_TS);
|
|
165
|
+
expect(formatted).toMatch(/^\d{2}:\d{2}$/);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("textHash", () => {
|
|
170
|
+
it("returns consistent md5 hash", () => {
|
|
171
|
+
const h1 = textHash("hello");
|
|
172
|
+
const h2 = textHash("hello");
|
|
173
|
+
const h3 = textHash("world");
|
|
174
|
+
expect(h1).toBe(h2);
|
|
175
|
+
expect(h1).not.toBe(h3);
|
|
176
|
+
expect(h1).toHaveLength(32);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── Post-filter ────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
describe("postFilter", () => {
|
|
183
|
+
it("rejects stale posts (timeRelevance < 0.5)", () => {
|
|
184
|
+
const ext: ValidatedExtraction = {
|
|
185
|
+
channel: "@idf_telegram",
|
|
186
|
+
regionRelevance: 0.9,
|
|
187
|
+
sourceTrust: 0.9,
|
|
188
|
+
tone: "calm",
|
|
189
|
+
timeRelevance: 0.2, // ← stale!
|
|
190
|
+
countryOrigin: "Lebanon",
|
|
191
|
+
rocketCount: undefined,
|
|
192
|
+
isCassette: undefined,
|
|
193
|
+
intercepted: undefined,
|
|
194
|
+
interceptedQual: undefined,
|
|
195
|
+
seaImpact: undefined,
|
|
196
|
+
seaImpactQual: undefined,
|
|
197
|
+
openAreaImpact: undefined,
|
|
198
|
+
openAreaImpactQual: undefined,
|
|
199
|
+
hitsConfirmed: undefined,
|
|
200
|
+
casualties: undefined,
|
|
201
|
+
injuries: undefined,
|
|
202
|
+
etaRefinedMinutes: undefined,
|
|
203
|
+
confidence: 0.9,
|
|
204
|
+
valid: true,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const result = postFilter([ext], "test");
|
|
208
|
+
expect(result[0]!.valid).toBe(false);
|
|
209
|
+
expect(result[0]!.rejectReason).toBe("stale_post");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("rejects region-irrelevant posts", () => {
|
|
213
|
+
const ext: ValidatedExtraction = {
|
|
214
|
+
channel: "@N12LIVE",
|
|
215
|
+
regionRelevance: 0.2,
|
|
216
|
+
sourceTrust: 0.9,
|
|
217
|
+
tone: "calm",
|
|
218
|
+
timeRelevance: 1.0,
|
|
219
|
+
countryOrigin: "Iran",
|
|
220
|
+
rocketCount: 10,
|
|
221
|
+
isCassette: undefined,
|
|
222
|
+
intercepted: undefined,
|
|
223
|
+
interceptedQual: undefined,
|
|
224
|
+
seaImpact: undefined,
|
|
225
|
+
seaImpactQual: undefined,
|
|
226
|
+
openAreaImpact: undefined,
|
|
227
|
+
openAreaImpactQual: undefined,
|
|
228
|
+
hitsConfirmed: undefined,
|
|
229
|
+
casualties: undefined,
|
|
230
|
+
injuries: undefined,
|
|
231
|
+
etaRefinedMinutes: undefined,
|
|
232
|
+
confidence: 0.9,
|
|
233
|
+
valid: true,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const result = postFilter([ext], "test");
|
|
237
|
+
expect(result[0]!.rejectReason).toBe("region_irrelevant");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("rejects no-data posts", () => {
|
|
241
|
+
const ext: ValidatedExtraction = {
|
|
242
|
+
channel: "@N12LIVE",
|
|
243
|
+
regionRelevance: 0.9,
|
|
244
|
+
sourceTrust: 0.9,
|
|
245
|
+
tone: "calm",
|
|
246
|
+
timeRelevance: 1.0,
|
|
247
|
+
countryOrigin: undefined,
|
|
248
|
+
rocketCount: undefined,
|
|
249
|
+
isCassette: undefined,
|
|
250
|
+
intercepted: undefined,
|
|
251
|
+
interceptedQual: undefined,
|
|
252
|
+
seaImpact: undefined,
|
|
253
|
+
seaImpactQual: undefined,
|
|
254
|
+
openAreaImpact: undefined,
|
|
255
|
+
openAreaImpactQual: undefined,
|
|
256
|
+
hitsConfirmed: undefined,
|
|
257
|
+
casualties: undefined,
|
|
258
|
+
injuries: undefined,
|
|
259
|
+
etaRefinedMinutes: undefined,
|
|
260
|
+
confidence: 0.9,
|
|
261
|
+
valid: true,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const result = postFilter([ext], "test");
|
|
265
|
+
expect(result[0]!.rejectReason).toBe("no_data");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("passes valid extraction with all checks", () => {
|
|
269
|
+
const ext: ValidatedExtraction = {
|
|
270
|
+
channel: "@N12LIVE",
|
|
271
|
+
regionRelevance: 0.9,
|
|
272
|
+
sourceTrust: 0.9,
|
|
273
|
+
tone: "calm",
|
|
274
|
+
timeRelevance: 1.0,
|
|
275
|
+
countryOrigin: "Iran",
|
|
276
|
+
rocketCount: 10,
|
|
277
|
+
isCassette: undefined,
|
|
278
|
+
intercepted: undefined,
|
|
279
|
+
interceptedQual: undefined,
|
|
280
|
+
seaImpact: undefined,
|
|
281
|
+
seaImpactQual: undefined,
|
|
282
|
+
openAreaImpact: undefined,
|
|
283
|
+
openAreaImpactQual: undefined,
|
|
284
|
+
hitsConfirmed: undefined,
|
|
285
|
+
casualties: undefined,
|
|
286
|
+
injuries: undefined,
|
|
287
|
+
etaRefinedMinutes: undefined,
|
|
288
|
+
confidence: 0.8,
|
|
289
|
+
valid: true,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const result = postFilter([ext], "test");
|
|
293
|
+
expect(result[0]!.valid).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ── Vote ───────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
describe("vote", () => {
|
|
300
|
+
it("returns undefined for empty extractions", () => {
|
|
301
|
+
const result = vote([], "test");
|
|
302
|
+
expect(result).toBeUndefined();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("returns undefined when all extractions are invalid", () => {
|
|
306
|
+
const ext: ValidatedExtraction = {
|
|
307
|
+
channel: "@N12LIVE",
|
|
308
|
+
regionRelevance: 0.9,
|
|
309
|
+
sourceTrust: 0.9,
|
|
310
|
+
tone: "calm",
|
|
311
|
+
timeRelevance: 0.1,
|
|
312
|
+
countryOrigin: "Iran",
|
|
313
|
+
rocketCount: undefined,
|
|
314
|
+
isCassette: undefined,
|
|
315
|
+
intercepted: undefined,
|
|
316
|
+
interceptedQual: undefined,
|
|
317
|
+
seaImpact: undefined,
|
|
318
|
+
seaImpactQual: undefined,
|
|
319
|
+
openAreaImpact: undefined,
|
|
320
|
+
openAreaImpactQual: undefined,
|
|
321
|
+
hitsConfirmed: undefined,
|
|
322
|
+
casualties: undefined,
|
|
323
|
+
injuries: undefined,
|
|
324
|
+
etaRefinedMinutes: undefined,
|
|
325
|
+
confidence: 0.8,
|
|
326
|
+
valid: false,
|
|
327
|
+
rejectReason: "stale_post",
|
|
328
|
+
};
|
|
329
|
+
const result = vote([ext], "test");
|
|
330
|
+
expect(result).toBeUndefined();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("aggregates country origins from multiple sources", () => {
|
|
334
|
+
const ext1: ValidatedExtraction = {
|
|
335
|
+
channel: "@N12LIVE",
|
|
336
|
+
regionRelevance: 0.9,
|
|
337
|
+
sourceTrust: 0.9,
|
|
338
|
+
tone: "calm",
|
|
339
|
+
timeRelevance: 1.0,
|
|
340
|
+
countryOrigin: "Iran",
|
|
341
|
+
rocketCount: 15,
|
|
342
|
+
isCassette: undefined,
|
|
343
|
+
intercepted: undefined,
|
|
344
|
+
interceptedQual: undefined,
|
|
345
|
+
seaImpact: undefined,
|
|
346
|
+
seaImpactQual: undefined,
|
|
347
|
+
openAreaImpact: undefined,
|
|
348
|
+
openAreaImpactQual: undefined,
|
|
349
|
+
hitsConfirmed: undefined,
|
|
350
|
+
casualties: undefined,
|
|
351
|
+
injuries: undefined,
|
|
352
|
+
etaRefinedMinutes: undefined,
|
|
353
|
+
confidence: 0.9,
|
|
354
|
+
valid: true,
|
|
355
|
+
messageUrl: "https://t.me/N12LIVE/167775",
|
|
356
|
+
};
|
|
357
|
+
const ext2: ValidatedExtraction = {
|
|
358
|
+
...ext1,
|
|
359
|
+
channel: "@yediotnews25",
|
|
360
|
+
countryOrigin: "Iran",
|
|
361
|
+
rocketCount: 12,
|
|
362
|
+
confidence: 0.85,
|
|
363
|
+
messageUrl: "https://t.me/yediotnews25/88901",
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const result = vote([ext1, ext2], "test");
|
|
367
|
+
const voted = result!;
|
|
368
|
+
|
|
369
|
+
expect(voted).not.toBeUndefined();
|
|
370
|
+
expect(voted.countryOrigins).toHaveLength(1);
|
|
371
|
+
expect(voted.countryOrigins![0]!.name).toBe("Iran");
|
|
372
|
+
expect(voted.countryOrigins![0]!.citations).toEqual([1, 2]);
|
|
373
|
+
expect(voted.rocketCountMin).toBe(12);
|
|
374
|
+
expect(voted.rocketCountMax).toBe(15);
|
|
375
|
+
expect(voted.sourcesCount).toBe(2);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("handles casualties in resolved phase", () => {
|
|
379
|
+
const ext: ValidatedExtraction = {
|
|
380
|
+
channel: "@N12LIVE",
|
|
381
|
+
regionRelevance: 0.9,
|
|
382
|
+
sourceTrust: 0.9,
|
|
383
|
+
tone: "calm",
|
|
384
|
+
timeRelevance: 1.0,
|
|
385
|
+
countryOrigin: "Iran",
|
|
386
|
+
rocketCount: 15,
|
|
387
|
+
isCassette: undefined,
|
|
388
|
+
intercepted: 12,
|
|
389
|
+
interceptedQual: undefined,
|
|
390
|
+
seaImpact: 2,
|
|
391
|
+
seaImpactQual: undefined,
|
|
392
|
+
openAreaImpact: undefined,
|
|
393
|
+
openAreaImpactQual: undefined,
|
|
394
|
+
hitsConfirmed: 1,
|
|
395
|
+
casualties: 0,
|
|
396
|
+
injuries: 3,
|
|
397
|
+
etaRefinedMinutes: undefined,
|
|
398
|
+
confidence: 0.9,
|
|
399
|
+
valid: true,
|
|
400
|
+
messageUrl: "https://t.me/N12LIVE/167790",
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const result = vote([ext], "test");
|
|
404
|
+
const voted = result!;
|
|
405
|
+
|
|
406
|
+
expect(voted.intercepted).toBe(12);
|
|
407
|
+
expect(voted.seaImpact).toBe(2);
|
|
408
|
+
expect(voted.hitsConfirmed).toBe(1);
|
|
409
|
+
expect(voted.injuries).toBe(3);
|
|
410
|
+
expect(voted.casualties).toBeUndefined(); // 0 is not > 0
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ── buildEnrichmentFromVote (carry-forward) ────────────
|
|
415
|
+
|
|
416
|
+
describe("buildEnrichmentFromVote", () => {
|
|
417
|
+
it("carries forward origin from early_warning to red_alert", () => {
|
|
418
|
+
const earlyEnrichment = emptyEnrichmentData;
|
|
419
|
+
earlyEnrichment.origin = "Иран";
|
|
420
|
+
earlyEnrichment.originCites = [
|
|
421
|
+
{ url: "https://t.me/N12LIVE/167775", channel: "@N12LIVE" },
|
|
422
|
+
];
|
|
423
|
+
earlyEnrichment.earlyWarningTime = "16:30";
|
|
424
|
+
|
|
425
|
+
// Siren vote has interception data but no origin
|
|
426
|
+
const sirenVote: VotedResult = {
|
|
427
|
+
etaRefinedMinutes: undefined,
|
|
428
|
+
etaCitations: [],
|
|
429
|
+
countryOrigins: [],
|
|
430
|
+
rocketCountMin: undefined,
|
|
431
|
+
rocketCountMax: undefined,
|
|
432
|
+
rocketCitations: [],
|
|
433
|
+
rocketConfidence: 0,
|
|
434
|
+
isCassette: undefined,
|
|
435
|
+
isCassetteConfidence: 0,
|
|
436
|
+
intercepted: 8,
|
|
437
|
+
interceptedQual: undefined,
|
|
438
|
+
interceptedConfidence: 0.8,
|
|
439
|
+
seaImpact: undefined,
|
|
440
|
+
seaImpactQual: undefined,
|
|
441
|
+
seaConfidence: 0,
|
|
442
|
+
openAreaImpact: undefined,
|
|
443
|
+
openAreaImpactQual: undefined,
|
|
444
|
+
openAreaConfidence: 0,
|
|
445
|
+
hitsConfirmed: undefined,
|
|
446
|
+
hitsCitations: [],
|
|
447
|
+
hitsConfidence: 0,
|
|
448
|
+
noImpacts: false,
|
|
449
|
+
noImpactsCitations: [],
|
|
450
|
+
interceptedCitations: [1],
|
|
451
|
+
rocketDetail: undefined,
|
|
452
|
+
casualties: undefined,
|
|
453
|
+
casualtiesCitations: [],
|
|
454
|
+
casualtiesConfidence: 0,
|
|
455
|
+
injuries: undefined,
|
|
456
|
+
injuriesCitations: [],
|
|
457
|
+
injuriesConfidence: 0,
|
|
458
|
+
confidence: 0.8,
|
|
459
|
+
sourcesCount: 1,
|
|
460
|
+
citedSources: [
|
|
461
|
+
{
|
|
462
|
+
index: 1,
|
|
463
|
+
channel: "@yediotnews25",
|
|
464
|
+
messageUrl: "https://t.me/yediotnews25/88901",
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const result = buildEnrichmentFromVote(
|
|
470
|
+
sirenVote,
|
|
471
|
+
earlyEnrichment,
|
|
472
|
+
"red_alert",
|
|
473
|
+
ALERT_TS,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// Origin carried from early
|
|
477
|
+
expect(result.origin).toBe("Иран");
|
|
478
|
+
expect(result.originCites).toHaveLength(1);
|
|
479
|
+
// Interception added from siren vote
|
|
480
|
+
expect(result.intercepted).toBe("8");
|
|
481
|
+
// Early warning time preserved
|
|
482
|
+
expect(result.earlyWarningTime).toBe("16:30");
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// ── buildEnrichedMessage ───────────────────────────────
|
|
487
|
+
|
|
488
|
+
describe("buildEnrichedMessage", () => {
|
|
489
|
+
const baseMessage = [
|
|
490
|
+
"<b>🚀 Раннее предупреждение</b>",
|
|
491
|
+
"Обнаружены запуски ракет по Израилю",
|
|
492
|
+
"",
|
|
493
|
+
"<b>Район:</b> Тель-Авив — Южный район и Яффо",
|
|
494
|
+
"<b>Подлётное время:</b> ~5–12 мин",
|
|
495
|
+
"<b>Время оповещения:</b> 16:30",
|
|
496
|
+
].join("\n");
|
|
497
|
+
|
|
498
|
+
it("inserts origin before time line with inline cites", () => {
|
|
499
|
+
const enrichment = emptyEnrichmentData;
|
|
500
|
+
enrichment.origin = "Иран";
|
|
501
|
+
enrichment.originCites = [
|
|
502
|
+
{ url: "https://t.me/N12LIVE/167775", channel: "@N12LIVE" },
|
|
503
|
+
];
|
|
504
|
+
|
|
505
|
+
const result = buildEnrichedMessage(
|
|
506
|
+
baseMessage,
|
|
507
|
+
"early_warning",
|
|
508
|
+
ALERT_TS,
|
|
509
|
+
enrichment,
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
expect(result).toContain("<b>Откуда:</b> Иран");
|
|
513
|
+
expect(result).toContain('href="https://t.me/N12LIVE/167775"');
|
|
514
|
+
// No superscripts
|
|
515
|
+
expect(result).not.toMatch(/[⁰¹²³⁴⁵⁶⁷⁸⁹]/);
|
|
516
|
+
// No "Источники:" footer
|
|
517
|
+
expect(result).not.toContain("Источники:");
|
|
518
|
+
// Origin appears before "Время оповещения"
|
|
519
|
+
const originIdx = result.indexOf("Откуда:");
|
|
520
|
+
const timeIdx = result.indexOf("Время оповещения:");
|
|
521
|
+
expect(originIdx).toBeLessThan(timeIdx);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("replaces ~5–12 мин with absolute ETA", () => {
|
|
525
|
+
const enrichment = emptyEnrichmentData;
|
|
526
|
+
enrichment.etaAbsolute = "~16:42";
|
|
527
|
+
enrichment.etaCites = [
|
|
528
|
+
{ url: "https://t.me/N12LIVE/167775", channel: "@N12LIVE" },
|
|
529
|
+
];
|
|
530
|
+
|
|
531
|
+
const result = buildEnrichedMessage(
|
|
532
|
+
baseMessage,
|
|
533
|
+
"early_warning",
|
|
534
|
+
ALERT_TS,
|
|
535
|
+
enrichment,
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
expect(result).not.toContain("~5–12 мин");
|
|
539
|
+
expect(result).toContain("~16:42");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("does NOT show early warning time for siren phase (replaced by reply chain)", () => {
|
|
543
|
+
const redAlertMessage = [
|
|
544
|
+
"<b>🚨 Сирена</b>",
|
|
545
|
+
"",
|
|
546
|
+
"<b>Район:</b> Тель-Авив — Южный район и Яффо",
|
|
547
|
+
"<b>Подлётное время:</b> 1.5 мин",
|
|
548
|
+
"<b>Время оповещения:</b> 16:34",
|
|
549
|
+
].join("\n");
|
|
550
|
+
|
|
551
|
+
const enrichment = emptyEnrichmentData;
|
|
552
|
+
enrichment.origin = "Иран";
|
|
553
|
+
enrichment.originCites = [];
|
|
554
|
+
enrichment.earlyWarningTime = "16:30";
|
|
555
|
+
|
|
556
|
+
const result = buildEnrichedMessage(
|
|
557
|
+
redAlertMessage,
|
|
558
|
+
"red_alert",
|
|
559
|
+
ALERT_TS,
|
|
560
|
+
enrichment,
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
expect(result).not.toContain("Раннее предупреждение:");
|
|
564
|
+
expect(result).toContain("<b>Откуда:</b> Иран");
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("adds casualties in resolved phase", () => {
|
|
568
|
+
const resolvedMessage = [
|
|
569
|
+
"<b>😮💨 Инцидент завершён</b>",
|
|
570
|
+
"Можно покинуть защищённое помещение.",
|
|
571
|
+
"",
|
|
572
|
+
"<b>Район:</b> Тель-Авив — Южный район и Яффо",
|
|
573
|
+
"<b>Время оповещения:</b> 17:00",
|
|
574
|
+
].join("\n");
|
|
575
|
+
|
|
576
|
+
const enrichment = emptyEnrichmentData;
|
|
577
|
+
enrichment.origin = "Иран";
|
|
578
|
+
enrichment.originCites = [];
|
|
579
|
+
enrichment.intercepted = "12";
|
|
580
|
+
enrichment.interceptedCites = [];
|
|
581
|
+
enrichment.hitsConfirmed = "1";
|
|
582
|
+
enrichment.hitsCites = [];
|
|
583
|
+
enrichment.injuries = "3";
|
|
584
|
+
enrichment.injuriesCites = [
|
|
585
|
+
{ url: "https://t.me/N12LIVE/167790", channel: "@N12LIVE" },
|
|
586
|
+
];
|
|
587
|
+
|
|
588
|
+
const result = buildEnrichedMessage(
|
|
589
|
+
resolvedMessage,
|
|
590
|
+
"resolved",
|
|
591
|
+
ALERT_TS,
|
|
592
|
+
enrichment,
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
expect(result).toContain("<b>Пострадавшие:</b> 3");
|
|
596
|
+
expect(result).toContain('href="https://t.me/N12LIVE/167790"');
|
|
597
|
+
// Resolved doesn't show rocket count breakdown if no rocketCount
|
|
598
|
+
expect(result).toContain("<b>Перехваты:</b> 12");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("does NOT show casualties in siren phase", () => {
|
|
602
|
+
const redAlertMessage = [
|
|
603
|
+
"<b>🚨 Сирена</b>",
|
|
604
|
+
"",
|
|
605
|
+
"<b>Район:</b> Тель-Авив — Южный район и Яффо",
|
|
606
|
+
"<b>Подлётное время:</b> 1.5 мин",
|
|
607
|
+
"<b>Время оповещения:</b> 16:34",
|
|
608
|
+
].join("\n");
|
|
609
|
+
|
|
610
|
+
const enrichment = emptyEnrichmentData;
|
|
611
|
+
enrichment.casualties = "2";
|
|
612
|
+
enrichment.casualtiesCites = [];
|
|
613
|
+
|
|
614
|
+
const result = buildEnrichedMessage(
|
|
615
|
+
redAlertMessage,
|
|
616
|
+
"red_alert",
|
|
617
|
+
ALERT_TS,
|
|
618
|
+
enrichment,
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
expect(result).not.toContain("Погибшие:");
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// ── inline citations format ────────────────────────────
|
|
626
|
+
|
|
627
|
+
describe("inlineCites / inlineCitesFromData", () => {
|
|
628
|
+
it("formats inline [[1]](url) style", () => {
|
|
629
|
+
const sources: CitedSource[] = [
|
|
630
|
+
{
|
|
631
|
+
index: 1,
|
|
632
|
+
channel: "@N12LIVE",
|
|
633
|
+
messageUrl: "https://t.me/N12LIVE/167775",
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
index: 2,
|
|
637
|
+
channel: "@yediotnews25",
|
|
638
|
+
messageUrl: "https://t.me/yediotnews25/88901",
|
|
639
|
+
},
|
|
640
|
+
];
|
|
641
|
+
|
|
642
|
+
const result = inlineCites([1, 2], sources);
|
|
643
|
+
expect(result).toContain('<a href="https://t.me/N12LIVE/167775">[1]</a>');
|
|
644
|
+
expect(result).toContain(
|
|
645
|
+
'<a href="https://t.me/yediotnews25/88901">[2]</a>',
|
|
646
|
+
);
|
|
647
|
+
// No superscript characters
|
|
648
|
+
expect(result).not.toMatch(/[⁰¹²³⁴⁵⁶⁷⁸⁹]/);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("inlineCitesFromData formats carry-forward cites", () => {
|
|
652
|
+
const cites = [{ url: "https://t.me/N12LIVE/167775", channel: "@N12LIVE" }];
|
|
653
|
+
|
|
654
|
+
const result = inlineCitesFromData(cites);
|
|
655
|
+
expect(result).toContain('<a href="https://t.me/N12LIVE/167775">[1]</a>');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("returns empty string for no cites", () => {
|
|
659
|
+
expect(inlineCites([], [])).toBe("");
|
|
660
|
+
expect(inlineCitesFromData([])).toBe("");
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
665
|
+
// LLM Integration Tests (need OPENROUTER_API_KEY)
|
|
666
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
667
|
+
|
|
668
|
+
describe.skipIf(!HAS_API)("LLM extraction (real API)", () => {
|
|
669
|
+
// Set the API key for real calls
|
|
670
|
+
if (HAS_API) {
|
|
671
|
+
(config.agent as { apiKey: string }).apiKey = API_KEY;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Call the real LLM for extraction.
|
|
676
|
+
* Mirrors extractAndValidate but for a single post.
|
|
677
|
+
*/
|
|
678
|
+
async function extractPost(
|
|
679
|
+
post: { channel: string; text: string; ts: number },
|
|
680
|
+
alertType: "early_warning" | "red_alert" | "resolved" = "early_warning",
|
|
681
|
+
): Promise<Record<string, unknown>> {
|
|
682
|
+
const alertTime = toIsraelTime(ALERT_TS);
|
|
683
|
+
const postTime = toIsraelTime(post.ts);
|
|
684
|
+
const now = toIsraelTime(Date.now());
|
|
685
|
+
const regionHint = ALERT_AREAS.join(", ");
|
|
686
|
+
|
|
687
|
+
const phaseInstructions = {
|
|
688
|
+
early_warning:
|
|
689
|
+
"PHASE: EARLY WARNING. Focus on countryOrigin, eta_refined_minutes, rocketCount, isCassette.",
|
|
690
|
+
siren:
|
|
691
|
+
"PHASE: SIREN. Focus on countryOrigin, rocketCount, intercepted, seaImpact, open_area_impact.",
|
|
692
|
+
resolved:
|
|
693
|
+
"PHASE: RESOLVED. All fields valid. Prioritize confirmed official reports.",
|
|
694
|
+
}[alertType];
|
|
695
|
+
|
|
696
|
+
const context = `${phaseInstructions}
|
|
697
|
+
|
|
698
|
+
Alert time: ${alertTime} (Israel)
|
|
699
|
+
Post time: ${postTime} (Israel)
|
|
700
|
+
Current time: ${now} (Israel)
|
|
701
|
+
Alert region: ${regionHint}
|
|
702
|
+
|
|
703
|
+
Channel: ${post.channel}
|
|
704
|
+
|
|
705
|
+
Message:
|
|
706
|
+
${post.text}`;
|
|
707
|
+
|
|
708
|
+
const result = await extractAgent.invoke({
|
|
709
|
+
messages: [context],
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
if (result.structuredResponse) {
|
|
713
|
+
return result.structuredResponse;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
717
|
+
const messages = result.messages as any[];
|
|
718
|
+
const lastMessage = messages[messages.length - 1];
|
|
719
|
+
const rawContent = lastMessage?.kwargs?.content ?? lastMessage?.content;
|
|
720
|
+
const content = String(rawContent ?? "");
|
|
721
|
+
|
|
722
|
+
if (content.trim().startsWith("{")) {
|
|
723
|
+
try {
|
|
724
|
+
return JSON.parse(content);
|
|
725
|
+
} catch {
|
|
726
|
+
return {};
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
731
|
+
if (jsonMatch) {
|
|
732
|
+
try {
|
|
733
|
+
return JSON.parse(jsonMatch[0]);
|
|
734
|
+
} catch {
|
|
735
|
+
return {};
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return {};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
it("correctly identifies Iran as origin from N12 launch report", async () => {
|
|
742
|
+
const result = await extractPost(POST_IRAN_LAUNCH);
|
|
743
|
+
|
|
744
|
+
expect(result.countryOrigin).toMatch(/iran|иран/i);
|
|
745
|
+
expect(result.timeRelevance).toBeGreaterThanOrEqual(0.7);
|
|
746
|
+
expect(result.regionRelevance).toBeGreaterThanOrEqual(0.5);
|
|
747
|
+
expect(result.confidence).toBeGreaterThanOrEqual(0.5);
|
|
748
|
+
}, 30_000);
|
|
749
|
+
|
|
750
|
+
it("REJECTS stale IDF Lebanon ops post (timeRelevance < 0.5)", async () => {
|
|
751
|
+
const result = await extractPost(POST_LEBANON_STALE);
|
|
752
|
+
|
|
753
|
+
// This is THE LEBANON BUG — the LLM should recognize this post is
|
|
754
|
+
// from 2.5 hours before the alert and NOT about the current attack
|
|
755
|
+
expect(result.timeRelevance).toBeLessThan(0.5);
|
|
756
|
+
}, 30_000);
|
|
757
|
+
|
|
758
|
+
it(
|
|
759
|
+
"extracts interception data in siren phase",
|
|
760
|
+
{ timeout: 60_000 },
|
|
761
|
+
async () => {
|
|
762
|
+
const result = await extractPost(POST_INTERCEPTION, "red_alert");
|
|
763
|
+
console.log(
|
|
764
|
+
"DEBUG interception result:",
|
|
765
|
+
JSON.stringify(result, null, 2),
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
expect(result.timeRelevance).toBeGreaterThanOrEqual(0.7);
|
|
769
|
+
expect(result.countryOrigin).toMatch(/iran|иран/i);
|
|
770
|
+
// Should have some interception data
|
|
771
|
+
expect(
|
|
772
|
+
result.intercepted !== undefined ||
|
|
773
|
+
result.interceptedQual !== undefined,
|
|
774
|
+
).toBe(true);
|
|
775
|
+
},
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
it("extracts full damage report in resolved phase", async () => {
|
|
779
|
+
const result = await extractPost(POST_RESOLVED, "resolved");
|
|
780
|
+
|
|
781
|
+
expect(result.timeRelevance).toBeGreaterThanOrEqual(0.7);
|
|
782
|
+
expect(result.countryOrigin).toMatch(/iran|иран/i);
|
|
783
|
+
expect(result.rocketCount).toBeDefined();
|
|
784
|
+
// LLM may include interception info in rocketDetail or as separate fields
|
|
785
|
+
const hasInterception =
|
|
786
|
+
result.intercepted !== undefined ||
|
|
787
|
+
result.interceptedQual !== undefined ||
|
|
788
|
+
result.seaImpact !== undefined ||
|
|
789
|
+
result.openAreaImpact !== undefined ||
|
|
790
|
+
(result.rocketDetail as string | undefined)?.includes("intercepted");
|
|
791
|
+
expect(hasInterception).toBe(true);
|
|
792
|
+
}, 30_000);
|
|
793
|
+
|
|
794
|
+
it("phase-awareness: early_warning message omits interception data", async () => {
|
|
795
|
+
// LLM may still extract interception data from the text regardless
|
|
796
|
+
// of phase instructions — phase filtering is enforced by buildEnrichedMessage.
|
|
797
|
+
// Verify the message builder respects the phase.
|
|
798
|
+
const enrichment = emptyEnrichmentData;
|
|
799
|
+
enrichment.origin = "Иран";
|
|
800
|
+
enrichment.originCites = [];
|
|
801
|
+
enrichment.intercepted = "12";
|
|
802
|
+
enrichment.interceptedCites = [];
|
|
803
|
+
enrichment.rocketCount = "15";
|
|
804
|
+
enrichment.rocketCites = [];
|
|
805
|
+
|
|
806
|
+
const earlyMessage = [
|
|
807
|
+
"<b>🚀 Раннее предупреждение</b>",
|
|
808
|
+
"Обнаружены запуски ракет по Израилю",
|
|
809
|
+
"",
|
|
810
|
+
"<b>Район:</b> Тель-Авив — Южный район и Яффо",
|
|
811
|
+
"<b>Подлётное время:</b> ~5–12 мин",
|
|
812
|
+
"<b>Время оповещения:</b> 16:30",
|
|
813
|
+
].join("\n");
|
|
814
|
+
|
|
815
|
+
const result = buildEnrichedMessage(
|
|
816
|
+
earlyMessage,
|
|
817
|
+
"early_warning",
|
|
818
|
+
ALERT_TS,
|
|
819
|
+
enrichment,
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
// Early warning should show origin but NOT interception/hits
|
|
823
|
+
expect(result).toContain("Откуда:");
|
|
824
|
+
expect(result).not.toContain("Перехвачено:");
|
|
825
|
+
expect(result).not.toContain("Попадания:");
|
|
826
|
+
}, 30_000);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
830
|
+
// Lebanon Bug Regression Test (end-to-end with real API)
|
|
831
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
832
|
+
|
|
833
|
+
describe.skipIf(!HAS_API)("Lebanon bug regression (real API)", () => {
|
|
834
|
+
if (HAS_API) {
|
|
835
|
+
(config.agent as { apiKey: string }).apiKey = API_KEY;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
it("should NOT produce Lebanon when stale IDF post + fresh Iran post coexist", async () => {
|
|
839
|
+
const posts = [POST_LEBANON_STALE, POST_IRAN_LAUNCH];
|
|
840
|
+
const extractions: ValidatedExtraction[] = [];
|
|
841
|
+
|
|
842
|
+
for (const post of posts) {
|
|
843
|
+
const result = await extractAgent.invoke({
|
|
844
|
+
messages: [`Channel: ${post.channel}\n\nMessage:\n${post.text}`],
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
extractions.push({
|
|
848
|
+
...result.structuredResponse,
|
|
849
|
+
channel: post.channel,
|
|
850
|
+
messageUrl: post.messageUrl,
|
|
851
|
+
timeRelevance: result.structuredResponse?.timeRelevance ?? 0.5,
|
|
852
|
+
valid: true,
|
|
853
|
+
} as ValidatedExtraction);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const filtered = postFilter(extractions, "test-regression");
|
|
857
|
+
const voted = vote(filtered, "test-regression");
|
|
858
|
+
|
|
859
|
+
if (voted) {
|
|
860
|
+
const origins = voted.countryOrigins;
|
|
861
|
+
if (origins && origins.length > 0) {
|
|
862
|
+
const hasLebanon = origins.some((o) => o.name === "Lebanon");
|
|
863
|
+
const hasIran = origins.some((o) => o.name === "Iran");
|
|
864
|
+
expect(
|
|
865
|
+
hasIran || !hasLebanon,
|
|
866
|
+
"Regression: Lebanon appeared as origin instead of Iran",
|
|
867
|
+
).toBe(true);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}, 60_000);
|
|
871
|
+
});
|