@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,299 @@
1
+ /**
2
+ * Extract Node — LLM extraction from relevant channels.
3
+ */
4
+
5
+ import * as logger from "@easyoref/monitoring";
6
+ import {
7
+ config,
8
+ ExtractionResultSchema,
9
+ getCachedExtractions,
10
+ saveCachedExtractions,
11
+ setLastUpdateTs,
12
+ textHash,
13
+ toIsraelTime,
14
+ type AlertType,
15
+ type NewsMessage,
16
+ type ValidatedExtraction,
17
+ } from "@easyoref/shared";
18
+ import { createAgent, providerStrategy } from "langchain";
19
+ import type { AgentStateType } from "../graph.js";
20
+ import { extractModel } from "../models.js";
21
+ import { filterAgent } from "./filter-node.js";
22
+
23
+ export const extractAgent = createAgent({
24
+ model: extractModel,
25
+ responseFormat: providerStrategy(ExtractionResultSchema),
26
+ systemPrompt: `You analyze Telegram channel messages about a missile/rocket attack on Israel.
27
+ Extract structured data from the message.
28
+
29
+ CRITICAL — TIME VALIDATION:
30
+ - If post discusses events BEFORE alert time → time_relevance=0
31
+ - If post is generic military news not specific to THIS attack → time_relevance=0.2
32
+ - If post discusses current attack → time_relevance=1.0
33
+
34
+ MANDATORY METADATA: time_relevance, region_relevance, confidence, source_trust, tone.
35
+
36
+ PHASE-SPECIFIC:
37
+ - early_warning: Focus on country_origin, eta_refined_minutes, rocket_count, is_cassette. NOT: intercepted, hits, casualties.
38
+ - red_alert: Focus on country_origin, rocket_count, intercepted, sea_impact, open_area_impact. NOT: hits, casualties.
39
+ - resolved: All fields valid. Prioritize confirmed official reports.
40
+
41
+ RULES:
42
+ - Only extract concrete numbers explicitly stated. Never guess.
43
+ - If source says "all intercepted" without count, use intercepted=null, intercepted_qual="all".
44
+ - If message uses excessive caps/exclamations → tone="alarmist".
45
+ - For IDF posts about ongoing operations (not this attack) → time_relevance=0.
46
+ - CASUALTIES: Only set > 0 if text explicitly uses "killed", "dead", "fatality" (Hebrew: נהרג/מת, Russian: погиб/убит, English: killed/dead).`,
47
+ });
48
+
49
+ function getPhaseInstructions(alertType: AlertType): string {
50
+ switch (alertType) {
51
+ case "early_warning":
52
+ return `PHASE: EARLY WARNING. Focus on country_origin, eta_refined_minutes, rocket_count, is_cassette.`;
53
+ case "red_alert":
54
+ return `PHASE: RED ALERT. Focus on country_origin, rocket_count, intercepted, sea_impact, open_area_impact.`;
55
+ case "resolved":
56
+ return `PHASE: RESOLVED. All fields valid. Prioritize confirmed official reports.`;
57
+ }
58
+ }
59
+
60
+ export const postFilter = (
61
+ extractions: ValidatedExtraction[],
62
+ alertId: string,
63
+ ): ValidatedExtraction[] => {
64
+ const validated = extractions.map((ext): ValidatedExtraction => {
65
+ if (ext.timeRelevance < 0.5) {
66
+ return { ...ext, valid: false, rejectReason: "stale_post" };
67
+ }
68
+
69
+ const regionThreshold =
70
+ ext.rocketCount != undefined &&
71
+ ext.intercepted == undefined &&
72
+ ext.interceptedQual == undefined &&
73
+ ext.hitsConfirmed == undefined &&
74
+ ext.casualties == undefined &&
75
+ ext.injuries == undefined
76
+ ? 0.3
77
+ : 0.5;
78
+ if (ext.regionRelevance < regionThreshold) {
79
+ return { ...ext, valid: false, rejectReason: "region_irrelevant" };
80
+ }
81
+
82
+ if (ext.sourceTrust < 0.4) {
83
+ return { ...ext, valid: false, rejectReason: "untrusted_source" };
84
+ }
85
+
86
+ if (ext.tone === "alarmist") {
87
+ return { ...ext, valid: false, rejectReason: "alarmist_tone" };
88
+ }
89
+
90
+ const hasData =
91
+ ext.countryOrigin != undefined ||
92
+ ext.rocketCount != undefined ||
93
+ ext.isCassette != undefined ||
94
+ ext.intercepted != undefined ||
95
+ ext.interceptedQual != undefined ||
96
+ ext.hitsConfirmed != undefined ||
97
+ ext.casualties != undefined ||
98
+ ext.injuries != undefined ||
99
+ ext.etaRefinedMinutes != undefined;
100
+ if (!hasData) {
101
+ return { ...ext, valid: false, rejectReason: "no_data" };
102
+ }
103
+
104
+ const confidenceFloor = ext.rocketCount != undefined ? 0.2 : 0.3;
105
+ if (ext.confidence < confidenceFloor) {
106
+ return { ...ext, valid: false, rejectReason: "low_confidence" };
107
+ }
108
+
109
+ return { ...ext, valid: true };
110
+ });
111
+
112
+ const passed = validated.filter((ext) => ext.valid);
113
+ const rejected = validated.filter((ext) => !ext.valid);
114
+
115
+ logger.info("Agent: post-filter", {
116
+ alertId,
117
+ passed: passed.length,
118
+ rejected: rejected.length,
119
+ reasons: rejected.map((ext) => `${ext.channel}:${ext.rejectReason}`),
120
+ });
121
+
122
+ return validated;
123
+ };
124
+
125
+ export const extractNode = async (
126
+ state: AgentStateType,
127
+ ): Promise<Partial<AgentStateType>> => {
128
+ if (!state.tracking || state.tracking.channelsWithUpdates.length === 0) {
129
+ logger.info("Agent: no channels with updates", { alertId: state.alertId });
130
+ return { extractions: [] };
131
+ }
132
+
133
+ const channels = state.tracking.channelsWithUpdates;
134
+ const channelSummaries = channels
135
+ .map((channel) => {
136
+ const messages = channel.unprocessedMessages
137
+ .map((message) => {
138
+ return ` [${toIsraelTime(message.timestamp)}] ${message.text.slice(
139
+ 0,
140
+ 200,
141
+ )}`;
142
+ })
143
+ .join("\n");
144
+ return `${channel.channel} (${channel.unprocessedMessages.length} new):\n${messages}`;
145
+ })
146
+ .join("\n\n");
147
+
148
+ const regionHint =
149
+ state.alertAreas.length > 0 ? state.alertAreas.join(", ") : "Israel";
150
+ const alertTime = toIsraelTime(state.alertTs);
151
+ const userPrompt = `Alert: ${regionHint} at ${alertTime}, phase: ${state.alertType}\n\nChannels:\n${channelSummaries}`;
152
+
153
+ let relevantChannels: string[] = [];
154
+ try {
155
+ const result = await filterAgent.invoke({ messages: [userPrompt] });
156
+ relevantChannels = result.structuredResponse?.relevantChannels ?? [];
157
+ } catch {
158
+ relevantChannels = channels.map((c) => c.channel);
159
+ }
160
+
161
+ if (relevantChannels.length === 0) {
162
+ return { extractions: [] };
163
+ }
164
+
165
+ const postsToExtract: NewsMessage[] = [];
166
+ for (const channel of channels) {
167
+ const match = relevantChannels.some(
168
+ (rc: string) =>
169
+ rc === channel.channel ||
170
+ rc === `@${channel.channel}` ||
171
+ `@${rc}` === channel.channel,
172
+ );
173
+ if (match) {
174
+ postsToExtract.push(...channel.unprocessedMessages);
175
+ }
176
+ }
177
+
178
+ if (postsToExtract.length === 0) {
179
+ return { extractions: [] };
180
+ }
181
+
182
+ const postHashMap = new Map<string, NewsMessage>();
183
+ for (const post of postsToExtract) {
184
+ const hash = textHash(post.channelId + "|" + post.text.slice(0, 800));
185
+ postHashMap.set(hash, post);
186
+ }
187
+
188
+ const allHashes = [...postHashMap.keys()];
189
+ const cached = await getCachedExtractions(allHashes);
190
+
191
+ const cachedResults: ValidatedExtraction[] = [];
192
+ const newPosts: NewsMessage[] = [];
193
+
194
+ for (const [hash, post] of postHashMap) {
195
+ const cachedJson = cached.get(hash);
196
+ if (cachedJson) {
197
+ cachedResults.push(JSON.parse(cachedJson) as ValidatedExtraction);
198
+ } else {
199
+ newPosts.push(post);
200
+ }
201
+ }
202
+
203
+ if (newPosts.length === 0) {
204
+ const filtered = postFilter(cachedResults, state.alertId);
205
+ return { extractions: filtered };
206
+ }
207
+
208
+ const alertTimeIL = toIsraelTime(state.alertTs);
209
+ const nowIL = toIsraelTime(Date.now());
210
+ const phaseInstructions = getPhaseInstructions(state.alertType);
211
+
212
+ const enrichCtxParts: string[] = [];
213
+ if (state.previousEnrichment?.origin) {
214
+ enrichCtxParts.push(`Origin: ${state.previousEnrichment.origin}`);
215
+ }
216
+ if (state.previousEnrichment?.rocketCount) {
217
+ enrichCtxParts.push(`Rockets: ${state.previousEnrichment.rocketCount}`);
218
+ }
219
+ if (state.previousEnrichment?.intercepted) {
220
+ enrichCtxParts.push(`Intercepted: ${state.previousEnrichment.intercepted}`);
221
+ }
222
+ const enrichCtxLine =
223
+ enrichCtxParts.length > 0
224
+ ? `EXISTING ENRICHMENT: ${enrichCtxParts.join(", ")}\n`
225
+ : "";
226
+
227
+ const newResults = await Promise.all(
228
+ newPosts.map(async (post): Promise<ValidatedExtraction> => {
229
+ const postTimeIL = toIsraelTime(post.timestamp);
230
+ const postAgeMin = Math.round((state.alertTs - post.timestamp) / 60_000);
231
+ const postAgeSuffix =
232
+ postAgeMin > 0
233
+ ? `(${postAgeMin} min BEFORE alert)`
234
+ : postAgeMin < 0
235
+ ? `(${Math.abs(postAgeMin)} min AFTER alert)`
236
+ : "(same time as alert)";
237
+
238
+ const contextHeader =
239
+ `${phaseInstructions}\n\n` +
240
+ `Alert time: ${alertTimeIL} (Israel)\n` +
241
+ `Post time: ${postTimeIL} (Israel) ${postAgeSuffix}\n` +
242
+ `Current time: ${nowIL} (Israel)\n` +
243
+ `Alert region: ${regionHint}\n` +
244
+ `UI language: ${config.language}\n` +
245
+ enrichCtxLine;
246
+
247
+ try {
248
+ const result = await extractAgent.invoke({
249
+ messages: [
250
+ `${contextHeader}Channel: ${
251
+ post.channelId
252
+ }\n\nMessage:\n${post.text.slice(0, 800)}`,
253
+ ],
254
+ });
255
+
256
+ const extracted = result.structuredResponse;
257
+
258
+ return {
259
+ ...extracted,
260
+ channel: post.channelId,
261
+ messageUrl: post.sourceUrl,
262
+ timeRelevance: extracted?.timeRelevance ?? 0.5,
263
+ valid: true,
264
+ } as ValidatedExtraction;
265
+ } catch {
266
+ return {
267
+ channel: post.channelId,
268
+ regionRelevance: 0,
269
+ sourceTrust: 0,
270
+ tone: "neutral" as const,
271
+ timeRelevance: 0,
272
+ confidence: 0,
273
+ valid: false,
274
+ rejectReason: "extraction_error",
275
+ };
276
+ }
277
+ }),
278
+ );
279
+
280
+ const cacheEntries: Record<string, string> = {};
281
+ newPosts.forEach((post, i) => {
282
+ const hash = textHash(post.channelId + "|" + post.text.slice(0, 800));
283
+ cacheEntries[hash] = JSON.stringify(newResults[i]);
284
+ });
285
+ await saveCachedExtractions(cacheEntries);
286
+
287
+ const results = [...cachedResults, ...newResults];
288
+ const filtered = postFilter(results, state.alertId);
289
+
290
+ await setLastUpdateTs(Date.now());
291
+
292
+ return { extractions: filtered };
293
+ };
294
+
295
+ export const _test = {
296
+ extractAgent,
297
+ filterAgent,
298
+ postFilter,
299
+ } as const;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Filter Node — deterministic pre-filters + LLM channel relevance.
3
+ */
4
+
5
+ import * as logger from "@easyoref/monitoring";
6
+ import {
7
+ type ChannelPost,
8
+ type ChannelTracking,
9
+ type NewsChannelWithUpdates,
10
+ type NewsMessage,
11
+ } from "@easyoref/shared";
12
+ import {
13
+ config,
14
+ getActiveSession,
15
+ getChannelPosts,
16
+ getEnrichmentData,
17
+ getLastUpdateTs,
18
+ } from "@easyoref/shared";
19
+ import type { AgentStateType } from "../graph.js";
20
+ import { createAgent, providerStrategy } from "langchain";
21
+ import { filterModel } from "../models.js";
22
+ import { FilterOutputSchema } from "@easyoref/shared";
23
+
24
+ export const filterAgent = createAgent({
25
+ model: filterModel,
26
+ responseFormat: providerStrategy(FilterOutputSchema),
27
+ systemPrompt: `You pre-filter Telegram channels for an Israeli missile alert system.
28
+ Given channels with their latest messages, identify which contain IMPORTANT military intel:
29
+ - Country of origin (where rockets/missiles launched from)
30
+ - Impact location (where they hit)
31
+ - Warhead type / cassette munitions
32
+ - Damage / destruction reports
33
+ - Interception reports (Iron Dome, David's Sling)
34
+ - Casualty / injury reports
35
+
36
+ IGNORE channels that only contain:
37
+ - Panic, speculation, or unverified rumors
38
+ - Rehashes of official alerts without new data
39
+ - General commentary without actionable facts
40
+
41
+ Return relevant channel names.`,
42
+ });
43
+
44
+ const OREF_LINK_PATTERN = /oref\.org\.il/i;
45
+ const OREF_CHANNEL_PATTERN = /pikud|פיקוד|oref/i;
46
+ const IDF_CHANNEL_PATTERN = /idf|צה"?ל|tsahal/i;
47
+
48
+ function isNoise(post: ChannelPost): boolean {
49
+ if (OREF_CHANNEL_PATTERN.test(post.channel) && post.text.length > 300) return true;
50
+ if (OREF_LINK_PATTERN.test(post.text)) return true;
51
+ const commaCount = (post.text.match(/,/g) || []).length;
52
+ if (commaCount >= 8) return true;
53
+ const timeParenCount = (post.text.match(/\(\d{1,2}:\d{2}\)/g) || []).length;
54
+ if (timeParenCount >= 2) return true;
55
+ if (/\d+\s+минут[ыа]?\b/i.test(post.text)) return true;
56
+ if (IDF_CHANNEL_PATTERN.test(post.channel) && post.text.length > 400) return true;
57
+ return false;
58
+ }
59
+
60
+ function toNewsMessage(post: ChannelPost): NewsMessage {
61
+ return {
62
+ channelId: post.channel,
63
+ sourceType: "telegram_channel",
64
+ timestamp: post.ts,
65
+ text: post.text,
66
+ sourceUrl: post.messageUrl,
67
+ };
68
+ }
69
+
70
+ function buildChannelTracking(
71
+ posts: ChannelPost[],
72
+ sessionStartTs: number,
73
+ lastUpdateTs: number,
74
+ ): ChannelTracking {
75
+ const channelMap = new Map<string, { previous: NewsMessage[]; latest: NewsMessage[] }>();
76
+
77
+ for (const post of posts) {
78
+ if (isNoise(post)) continue;
79
+ if (post.ts < sessionStartTs) continue;
80
+
81
+ if (!channelMap.has(post.channel)) {
82
+ channelMap.set(post.channel, { previous: [], latest: [] });
83
+ }
84
+ const bucket = channelMap.get(post.channel)!;
85
+ const newsMessage = toNewsMessage(post);
86
+
87
+ if (lastUpdateTs > 0 && post.ts <= lastUpdateTs) {
88
+ bucket.previous.push(newsMessage);
89
+ } else {
90
+ bucket.latest.push(newsMessage);
91
+ }
92
+ }
93
+
94
+ const channelsWithUpdates: NewsChannelWithUpdates[] = [];
95
+ for (const [channel, { previous, latest }] of channelMap) {
96
+ if (latest.length > 0) {
97
+ channelsWithUpdates.push({
98
+ channel,
99
+ processedMessages: previous.sort((a, b) => a.timestamp - b.timestamp),
100
+ unprocessedMessages: latest.sort((a, b) => a.timestamp - b.timestamp),
101
+ });
102
+ }
103
+ }
104
+
105
+ return {
106
+ trackStartTimestamp: sessionStartTs,
107
+ lastUpdateTimestamp: lastUpdateTs,
108
+ channelsWithUpdates: channelsWithUpdates,
109
+ };
110
+ }
111
+
112
+ export const filterNode = async (
113
+ state: AgentStateType,
114
+ ): Promise<Partial<AgentStateType>> => {
115
+ const posts = await getChannelPosts(state.alertId);
116
+ const previousEnrichment = await getEnrichmentData();
117
+ const session = await getActiveSession();
118
+ const sessionStartTs = session?.sessionStartTs ?? state.alertTs;
119
+ const lastUpdateTs = await getLastUpdateTs();
120
+
121
+ if (posts.length === 0) {
122
+ logger.info("Agent: no posts", { alertId: state.alertId });
123
+ return { tracking: undefined, previousEnrichment };
124
+ }
125
+
126
+ const tracking = buildChannelTracking(posts, sessionStartTs, lastUpdateTs);
127
+
128
+ logger.info("Agent: channel tracking", {
129
+ alertId: state.alertId,
130
+ totalPosts: posts.length,
131
+ channelsWithUpdates: tracking.channelsWithUpdates.length,
132
+ totalNewPosts: tracking.channelsWithUpdates.reduce(
133
+ (total, channel) => total + channel.unprocessedMessages.length,
134
+ 0,
135
+ ),
136
+ });
137
+
138
+ return { tracking, previousEnrichment };
139
+ };