@indexnetwork/protocol 8.3.0-rc.424.1 → 8.4.0-rc.426.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 (51) hide show
  1. package/IMPLEMENTATION.md +2 -2
  2. package/dist/capabilities/opportunities.facade.d.ts +5 -5
  3. package/dist/capabilities/opportunities.facade.js +4 -4
  4. package/dist/index.d.ts +6 -6
  5. package/dist/index.js +4 -4
  6. package/dist/maintenance/maintenance.graph.d.ts +9 -9
  7. package/dist/maintenance/maintenance.graph.js +20 -20
  8. package/dist/maintenance/maintenance.state.d.ts +5 -5
  9. package/dist/maintenance/maintenance.state.js +3 -3
  10. package/dist/opportunity/application/delivery-card.cache.js +1 -1
  11. package/dist/opportunity/application/index.d.ts +2 -3
  12. package/dist/opportunity/application/index.js +3 -4
  13. package/dist/opportunity/application/negotiation-context.loader.d.ts +1 -1
  14. package/dist/opportunity/application/negotiation-context.loader.js +1 -1
  15. package/dist/opportunity/application/opportunity.enricher.js +2 -2
  16. package/dist/opportunity/application/opportunity.presenter.d.ts +15 -15
  17. package/dist/opportunity/application/opportunity.presenter.js +17 -17
  18. package/dist/opportunity/application/opportunity.tools.d.ts +1 -1
  19. package/dist/opportunity/application/opportunity.tools.js +3 -3
  20. package/dist/opportunity/discriminator/discriminator.adjustments.js +1 -1
  21. package/dist/opportunity/discriminator/discriminator.env.d.ts +1 -1
  22. package/dist/opportunity/discriminator/discriminator.env.js +1 -1
  23. package/dist/opportunity/domain/index.d.ts +3 -3
  24. package/dist/opportunity/domain/index.js +3 -3
  25. package/dist/opportunity/domain/opportunity.presentation-cache.d.ts +1 -2
  26. package/dist/opportunity/domain/opportunity.presentation-cache.js +2 -5
  27. package/dist/opportunity/domain/opportunity.presentation.d.ts +1 -1
  28. package/dist/opportunity/domain/opportunity.presentation.js +1 -1
  29. package/dist/opportunity/domain/opportunity.safe-presentation.d.ts +2 -2
  30. package/dist/opportunity/domain/opportunity.safe-presentation.js +1 -1
  31. package/dist/opportunity/domain/opportunity.utils.d.ts +5 -5
  32. package/dist/opportunity/domain/opportunity.utils.js +8 -8
  33. package/dist/opportunity/index.d.ts +1 -1
  34. package/dist/opportunity/index.js +1 -1
  35. package/dist/opportunity/opportunity.presenter.d.ts +3 -3
  36. package/dist/opportunity/opportunity.presenter.js +2 -2
  37. package/dist/opportunity/public/index.d.ts +5 -5
  38. package/dist/opportunity/public/index.js +6 -6
  39. package/dist/opportunity/{feed/feed.graph.d.ts → radar/radar.graph.d.ts} +42 -73
  40. package/dist/opportunity/{feed/feed.graph.js → radar/radar.graph.js} +60 -222
  41. package/dist/opportunity/{feed/feed.health.d.ts → radar/radar.health.d.ts} +7 -7
  42. package/dist/opportunity/{feed/feed.health.js → radar/radar.health.js} +8 -8
  43. package/dist/opportunity/{feed/feed.state.d.ts → radar/radar.state.d.ts} +21 -47
  44. package/dist/opportunity/{feed/feed.state.js → radar/radar.state.js} +15 -24
  45. package/dist/shared/agent/tool.helpers.d.ts +1 -1
  46. package/dist/shared/interfaces/database.interface.d.ts +3 -3
  47. package/package.json +1 -1
  48. package/dist/opportunity/feed/feed.categorizer.d.ts +0 -27
  49. package/dist/opportunity/feed/feed.categorizer.js +0 -168
  50. package/dist/shared/ui/lucide.icon-catalog.d.ts +0 -21
  51. package/dist/shared/ui/lucide.icon-catalog.js +0 -100
@@ -1,39 +1,35 @@
1
1
  /**
2
- * Home Graph: Build the opportunity home view with dynamic sections.
2
+ * Radar Graph: Build the opportunity radar view a flat, presenter-texted
3
+ * list of opportunity cards for a viewer, optionally scoped to one intent.
3
4
  *
4
5
  * Independent of ChatGraph. Flow:
5
- * loadOpportunities → checkPresenterCache → [generateCardText if misses] → cachePresenterResults
6
- * → checkCategorizerCache[categorizeDynamically if miss] → cacheCategorizerResults → normalizeAndSort
6
+ * loadOpportunities → checkPresenterCache → [generateCardText if misses]
7
+ * → cachePresenterResultsnormalizeItems
7
8
  *
8
- * Uses OpportunityPresenter for card text and an LLM to categorize cards into dynamic sections
9
- * with titles and Lucide icon names. Caches presenter and categorizer results via OpportunityCache.
9
+ * Uses OpportunityPresenter for card text and caches presenter results via
10
+ * OpportunityCache. Responses are a flat `items` array; clients bucket by
11
+ * lifecycle status themselves.
10
12
  */
11
- import { createHash } from 'crypto';
12
13
  import { StateGraph, START, END } from '@langchain/langgraph';
13
- import { HomeGraphState } from './feed.state.js';
14
+ import { RadarGraphState } from './radar.state.js';
14
15
  import { OpportunityPresenter, gatherPresenterContext } from '../opportunity.presenter.js';
15
16
  import { loadNegotiationContext } from '../negotiation-context.loader.js';
16
- import { HomeCategorizerAgent } from './feed.categorizer.js';
17
17
  import { canUserSeeOpportunity, isActionableForViewer, selectByComposition } from '../opportunity.utils.js';
18
- import { resolveHomeSectionIcon, DEFAULT_HOME_SECTION_ICON } from '../../shared/ui/lucide.icon-catalog.js';
19
18
  import { getPrimaryActionLabel, SECONDARY_ACTION_LABEL } from '../opportunity.labels.js';
20
19
  import { safeFallbackSummary } from '../opportunity.safe-presentation.js';
21
- import { buildHomeCardPresentationCacheKey, buildHomeCategoryPresentationCacheKey } from '../opportunity.presentation-cache.js';
20
+ import { buildRadarCardPresentationCacheKey } from '../opportunity.presentation-cache.js';
22
21
  import { protocolLogger } from '../../shared/observability/protocol.logger.js';
23
22
  import { timed } from '../../shared/observability/performance.js';
24
23
  import { requestContext } from "../../shared/observability/request-context.js";
25
24
  import { adjustedConfidence, latestPoolDemotionDetail, readActivePoolAdjustments } from '../discriminator/discriminator.adjustments.js';
26
25
  import { poolQuestionsRanking } from '../discriminator/discriminator.env.js';
27
- const logger = protocolLogger('HomeGraph');
28
- const checkCategorizerCacheLog = protocolLogger('HomeGraph:checkCategorizerCache');
29
- const cacheCategorizerResultsLog = protocolLogger('HomeGraph:cacheCategorizerResults');
30
- const checkPresenterCacheLog = protocolLogger('HomeGraph:checkPresenterCache');
31
- const cachePresenterResultsLog = protocolLogger('HomeGraph:cachePresenterResults');
32
- const categorizeDynamicallyLog = protocolLogger('HomeGraph:categorizeDynamically');
33
- const normalizeAndSortLog = protocolLogger('HomeGraph:normalizeAndSort');
34
- const generateCardTextLog = protocolLogger('HomeGraph:generateCardText');
35
- /** Default home-feed statuses: the lifecycle stages a viewer can act on today. */
36
- export const DEFAULT_HOME_STATUSES = ['latent', 'pending'];
26
+ const logger = protocolLogger('RadarGraph');
27
+ const checkPresenterCacheLog = protocolLogger('RadarGraph:checkPresenterCache');
28
+ const cachePresenterResultsLog = protocolLogger('RadarGraph:cachePresenterResults');
29
+ const generateCardTextLog = protocolLogger('RadarGraph:generateCardText');
30
+ const normalizeItemsLog = protocolLogger('RadarGraph:normalizeItems');
31
+ /** Default radar statuses: the lifecycle stages a viewer can act on today. */
32
+ export const DEFAULT_RADAR_STATUSES = ['latent', 'pending'];
37
33
  // Exhaustive registry — keys must cover every OpportunityStatus union member.
38
34
  // Adding a new status to OpportunityStatus without adding a key here is a TS error,
39
35
  // which is the whole point: prevents ALL_OPPORTUNITY_STATUSES from silently drifting.
@@ -47,14 +43,13 @@ const OPPORTUNITY_STATUS_REGISTRY = {
47
43
  rejected: true,
48
44
  expired: true,
49
45
  };
50
- /** Full status enumeration. Pass this to `HomeGraphInvokeInput.statuses` to restore pre-Issue-3 (unfiltered) behavior. */
46
+ /** Full status enumeration. Pass this to `RadarGraphInvokeInput.statuses` to restore pre-Issue-3 (unfiltered) behavior. */
51
47
  export const ALL_OPPORTUNITY_STATUSES = Object.keys(OPPORTUNITY_STATUS_REGISTRY);
52
- const MAX_ITEMS_PER_SECTION = 20;
53
48
  const PRESENTATION_CONCURRENCY = 50;
54
49
  const MAX_REASONING_SNIPPET_LENGTH = 240;
55
- const HOME_CACHE_TTL = 24 * 60 * 60; // 24 hours in seconds
50
+ const RADAR_CACHE_TTL = 24 * 60 * 60; // 24 hours in seconds
56
51
  /** Pure cache policy for presenter cards; degraded current-request copy retries later. */
57
- export function isHomePresentationCacheable(card, status) {
52
+ export function isRadarPresentationCacheable(card, status) {
58
53
  return Boolean(status &&
59
54
  status !== 'negotiating' &&
60
55
  !card.presentationPending &&
@@ -62,12 +57,6 @@ export function isHomePresentationCacheable(card, status) {
62
57
  card.name &&
63
58
  card.name !== 'Unknown');
64
59
  }
65
- /** Redis key for the categorizer cache, derived from the user and the ordered opportunity-id set. */
66
- function buildCategorizerCacheKey(userId, cards) {
67
- const oppIds = cards.map((c) => c.opportunityId).join(',');
68
- const hash = createHash('sha256').update(oppIds).digest('hex').slice(0, 16);
69
- return buildHomeCategoryPresentationCacheKey(userId, hash);
70
- }
71
60
  /**
72
61
  * Strip leading narrator name from remark when the UI already prepends "Name: " to the chip.
73
62
  * Avoids duplication like "Yankı Ekin Yüksel: Yankı Ekin Yüksel introduced you two..."
@@ -102,7 +91,8 @@ const safeParseDate = (value) => {
102
91
  return value;
103
92
  if (typeof value === 'string') {
104
93
  const t = new Date(value).getTime();
105
- return Number.isFinite(t) ? t : 0;
94
+ if (!Number.isNaN(t))
95
+ return t;
106
96
  }
107
97
  return 0;
108
98
  };
@@ -129,7 +119,7 @@ const getRawConfidence = (opp) => {
129
119
  /**
130
120
  * Sort confidence, optionally pool-adjusted (IND-419): when
131
121
  * POOL_QUESTIONS_RANKING=on, answered discriminators multiply confidence by
132
- * their stored factors (floor 0.3) so the user's answers re-rank the feed.
122
+ * their stored factors (floor 0.3) so the user's answers re-rank the radar.
133
123
  * Flag off → identical to raw confidence (adjustments are write-only).
134
124
  */
135
125
  const getPoolRankingProvenance = (state) => {
@@ -178,25 +168,24 @@ const pickDisplayCounterpartActor = (opportunity, viewerId) => {
178
168
  });
179
169
  return sorted[0] ?? null;
180
170
  };
181
- export class HomeGraphFactory {
171
+ export class RadarGraphFactory {
182
172
  constructor(database, cache) {
183
173
  this.database = database;
184
174
  this.cache = cache;
185
175
  }
186
176
  createGraph() {
187
177
  const presenter = new OpportunityPresenter();
188
- const categorizer = new HomeCategorizerAgent();
189
178
  const loadOpportunitiesNode = async (state) => {
190
- return timed("HomeGraph.loadOpportunities", async () => {
179
+ return timed("RadarGraph.loadOpportunities", async () => {
191
180
  if (!state.userId) {
192
181
  return { error: 'userId is required' };
193
182
  }
194
183
  try {
195
- // Minimum of 50 ensures enough candidates across all feed categories
184
+ // Minimum of 50 ensures enough candidates across all radar categories
196
185
  // (connection, connector-flow, expired) for selectByComposition to fill
197
186
  // its soft targets, even after visibility filtering and dedup.
198
187
  const fetchLimit = Math.min(150, Math.max(50, state.limit * 3));
199
- const statuses = state.statuses ?? DEFAULT_HOME_STATUSES;
188
+ const statuses = state.statuses ?? DEFAULT_RADAR_STATUSES;
200
189
  const poolRankingProvenance = getPoolRankingProvenance(state);
201
190
  const options = {
202
191
  limit: fetchLimit,
@@ -208,7 +197,7 @@ export class HomeGraphFactory {
208
197
  options.scopeType = 'intent';
209
198
  options.scopeId = state.scopeId;
210
199
  }
211
- // Do not pass conversationId: home view excludes draft opportunities (chat-only drafts).
200
+ // Do not pass conversationId: radar view excludes draft opportunities (chat-only drafts).
212
201
  const raw = await this.database.getOpportunitiesForUser(state.userId, options);
213
202
  const visible = raw.filter((opp) => canUserSeeOpportunity(opp.actors, opp.status, state.userId));
214
203
  // Actionability only gates the live statuses a viewer could act on:
@@ -220,7 +209,7 @@ export class HomeGraphFactory {
220
209
  // The requested-status membership check is defense-in-depth: rows
221
210
  // outside the requested set are dropped even if the adapter drifts.
222
211
  const requestedStatuses = new Set(statuses);
223
- const visibleForFeed = visible.filter((opp) => {
212
+ const visibleForRadar = visible.filter((opp) => {
224
213
  if (!requestedStatuses.has(opp.status))
225
214
  return false;
226
215
  if (opp.status === 'latent' || opp.status === 'pending') {
@@ -234,7 +223,7 @@ export class HomeGraphFactory {
234
223
  // dedup keeps each person's most recent state (an accepted
235
224
  // opportunity supersedes an older pending one), no composition
236
225
  // capping — the caller wants the full pipeline up to `limit`.
237
- const newestFirst = [...visibleForFeed].sort((a, b) => safeParseDate(b.updatedAt) - safeParseDate(a.updatedAt));
226
+ const newestFirst = [...visibleForRadar].sort((a, b) => safeParseDate(b.updatedAt) - safeParseDate(a.updatedAt));
238
227
  const seenIds = new Set();
239
228
  const dedupedByCounterpart = newestFirst.filter((opp) => {
240
229
  const counterpartIds = getUniqueCounterpartUserIds(opp, state.userId);
@@ -261,7 +250,7 @@ export class HomeGraphFactory {
261
250
  });
262
251
  return { opportunities: adjustedOrder.slice(0, state.limit) };
263
252
  }
264
- const sorted = [...visibleForFeed].sort((a, b) => {
253
+ const sorted = [...visibleForRadar].sort((a, b) => {
265
254
  // Connections before connector-flow so dedup claims counterpart IDs
266
255
  // for direct connections first — prevents introducer cards from
267
256
  // shadowing a user's own connection opportunities.
@@ -291,13 +280,13 @@ export class HomeGraphFactory {
291
280
  return { opportunities };
292
281
  }
293
282
  catch (e) {
294
- logger.error('HomeGraph loadOpportunities failed', { error: e });
283
+ logger.error('RadarGraph loadOpportunities failed', { error: e });
295
284
  return { error: 'Failed to load opportunities', opportunities: [] };
296
285
  }
297
286
  });
298
287
  };
299
288
  const checkPresenterCacheNode = async (state) => {
300
- return timed("HomeGraph.checkPresenterCache", async () => {
289
+ return timed("RadarGraph.checkPresenterCache", async () => {
301
290
  const { opportunities, userId } = state;
302
291
  const poolRankingProvenance = getPoolRankingProvenance(state);
303
292
  if (opportunities.length === 0) {
@@ -317,7 +306,7 @@ export class HomeGraphFactory {
317
306
  // transitions (e.g. negotiating → pending) don't serve stale cards.
318
307
  const cacheable = opportunities.filter((opp) => opp.status !== 'negotiating');
319
308
  const liveNegotiating = opportunities.filter((opp) => opp.status === 'negotiating');
320
- const keys = cacheable.map((opp) => buildHomeCardPresentationCacheKey(opp.id, opp.status, userId));
309
+ const keys = cacheable.map((opp) => buildRadarCardPresentationCacheKey(opp.id, opp.status, userId));
321
310
  const results = keys.length > 0 ? await this.cache.mget(keys) : [];
322
311
  const cachedCards = new Map();
323
312
  const uncachedOpportunities = [...liveNegotiating];
@@ -362,14 +351,14 @@ export class HomeGraphFactory {
362
351
  return 'skip';
363
352
  };
364
353
  const generateCardTextNode = async (state) => {
365
- return timed("HomeGraph.generateCardText", async () => {
354
+ return timed("RadarGraph.generateCardText", async () => {
366
355
  const opportunities = state.uncachedOpportunities.length > 0
367
356
  ? state.uncachedOpportunities
368
357
  : state.opportunities;
369
358
  generateCardTextLog.verbose('entry', { opportunitiesLength: opportunities.length, userId: state.userId });
370
359
  if (opportunities.length === 0) {
371
- generateCardTextLog.verbose('exit', { totalOpportunities: 0, totalSections: 0 });
372
- return { cards: [], agentTimings: [], meta: { totalOpportunities: 0, totalSections: 0 } };
360
+ generateCardTextLog.verbose('exit', { totalOpportunities: 0 });
361
+ return { cards: [], agentTimings: [], meta: { totalOpportunities: 0 } };
373
362
  }
374
363
  const db = this.database;
375
364
  const cards = [];
@@ -441,9 +430,9 @@ export class HomeGraphFactory {
441
430
  // "Unknown" placeholder: such cards are unusable, excluded from the
442
431
  // presenter cache (see cachePresenterResults), and would otherwise
443
432
  // trigger a fresh presenter LLM call on every request — a permanent
444
- // cache miss that keeps the whole feed slow (~9s per load).
433
+ // cache miss that keeps the whole radar slow (~9s per load).
445
434
  if (userName === 'Unknown' || !userName?.trim()) {
446
- logger.verbose('[HomeGraph:generateCardText] dropping card with unresolvable counterpart', {
435
+ logger.verbose('[RadarGraph:generateCardText] dropping card with unresolvable counterpart', {
447
436
  opportunityId: opportunity.id,
448
437
  otherActorUserId: otherActor?.userId,
449
438
  });
@@ -524,7 +513,7 @@ export class HomeGraphFactory {
524
513
  gatherPresenterContext(db, opportunity, state.userId, otherActor?.userId),
525
514
  loadNegotiationContext(db, opportunity.id, opportunity.status),
526
515
  ]);
527
- const homeInput = {
516
+ const presenterInput = {
528
517
  ...ctx,
529
518
  mutualIntentCount: undefined,
530
519
  opportunityStatus: opportunity.status,
@@ -533,7 +522,7 @@ export class HomeGraphFactory {
533
522
  const _traceEmitterPresenter = requestContext.getStore()?.traceEmitter;
534
523
  const presenterStart = Date.now();
535
524
  _traceEmitterPresenter?.({ type: "agent_start", name: "opportunity-presenter" });
536
- const presentation = await presenter.presentHomeCard(homeInput);
525
+ const presentation = await presenter.presentCard(presenterInput);
537
526
  const _presenterDuration = Date.now() - presenterStart;
538
527
  agentTimingsAccum.push({ name: 'opportunity.presenter', durationMs: _presenterDuration });
539
528
  _traceEmitterPresenter?.({ type: "agent_end", name: "opportunity-presenter", durationMs: _presenterDuration, summary: `Presented: ${userName}` });
@@ -580,22 +569,22 @@ export class HomeGraphFactory {
580
569
  };
581
570
  }
582
571
  catch (e) {
583
- logger.warn('HomeGraph presenter failed for opportunity', { opportunityId: opportunity.id, error: e });
572
+ logger.warn('RadarGraph presenter failed for opportunity', { opportunityId: opportunity.id, error: e });
584
573
  return fallbackCard();
585
574
  }
586
575
  }));
587
576
  cards.push(...chunkCards.filter((c) => c !== null));
588
577
  }
589
- generateCardTextLog.verbose('exit', { totalOpportunities: state.opportunities.length, totalSections: 0 });
578
+ generateCardTextLog.verbose('exit', { totalOpportunities: state.opportunities.length });
590
579
  return {
591
580
  cards,
592
581
  agentTimings: agentTimingsAccum,
593
- meta: { totalOpportunities: state.opportunities.length, totalSections: 0 },
582
+ meta: { totalOpportunities: state.opportunities.length },
594
583
  };
595
584
  });
596
585
  };
597
586
  const cachePresenterResultsNode = async (state) => {
598
- return timed("HomeGraph.cachePresenterResults", async () => {
587
+ return timed("RadarGraph.cachePresenterResults", async () => {
599
588
  const { cards, cachedCards, userId, opportunities } = state;
600
589
  const poolRankingProvenance = getPoolRankingProvenance(state);
601
590
  const liveById = new Map(opportunities.map((opportunity) => [opportunity.id, opportunity]));
@@ -614,9 +603,9 @@ export class HomeGraphFactory {
614
603
  const status = statusById.get(card.opportunityId);
615
604
  // Negotiating, skeleton, fallback, and unresolved-name cards are
616
605
  // safe for the current response but must not become 24h entries.
617
- if (!status || !isHomePresentationCacheable(card, status))
606
+ if (!status || !isRadarPresentationCacheable(card, status))
618
607
  return Promise.resolve();
619
- return this.cache.set(buildHomeCardPresentationCacheKey(card.opportunityId, status, userId), card, { ttl: HOME_CACHE_TTL });
608
+ return this.cache.set(buildRadarCardPresentationCacheKey(card.opportunityId, status, userId), card, { ttl: RADAR_CACHE_TTL });
620
609
  }));
621
610
  }
622
611
  catch (e) {
@@ -637,173 +626,28 @@ export class HomeGraphFactory {
637
626
  });
638
627
  return {
639
628
  cards: allCards,
640
- meta: { totalOpportunities: state.opportunities.length, totalSections: 0 },
629
+ meta: { totalOpportunities: state.opportunities.length },
641
630
  };
642
631
  });
643
632
  };
644
- const checkCategorizerCacheNode = async (state) => {
645
- return timed("HomeGraph.checkCategorizerCache", async () => {
646
- if (state.cards.length === 0) {
647
- return { categoryCacheHit: false };
648
- }
649
- // Skeleton runs never categorize (some cards have no text to categorize
650
- // and the response must stay LLM-free). Ranking-enabled lifecycle views
651
- // also stay flat: the intent Radar flattens sections and buckets by
652
- // status, so dynamic categorization would erase the adjusted order on
653
- // the full second-phase response. Flag off retains the legacy path.
654
- // Chunk by MAX_ITEMS_PER_SECTION because normalizeAndSort caps sections.
655
- const poolRankingProvenance = getPoolRankingProvenance(state);
656
- const preserveAdjustedLifecycleOrder = (state.statuses?.length ?? 0) > 0 &&
657
- poolRankingProvenance !== null &&
658
- state.opportunities.some((opportunity) => hasPoolAdjustment(opportunity, poolRankingProvenance));
659
- if (state.presentation === 'skeleton' || preserveAdjustedLifecycleOrder) {
660
- const sectionProposals = [];
661
- for (let start = 0; start < state.cards.length; start += MAX_ITEMS_PER_SECTION) {
662
- sectionProposals.push({
663
- id: `all-${sectionProposals.length + 1}`,
664
- title: 'All matches',
665
- iconName: DEFAULT_HOME_SECTION_ICON,
666
- itemIndices: state.cards.slice(start, start + MAX_ITEMS_PER_SECTION).map((_, i) => start + i),
667
- });
668
- }
669
- return { sectionProposals, categoryCacheHit: true };
670
- }
671
- if (state.noCache) {
672
- checkCategorizerCacheLog.verbose('noCache=true, skipping cache');
673
- return { categoryCacheHit: false };
674
- }
675
- try {
676
- const key = buildCategorizerCacheKey(state.userId, state.cards);
677
- const cached = await this.cache.get(key);
678
- if (cached) {
679
- checkCategorizerCacheLog.verbose('cache hit');
680
- return { sectionProposals: cached, categoryCacheHit: true };
681
- }
682
- checkCategorizerCacheLog.verbose('cache miss');
683
- }
684
- catch (e) {
685
- checkCategorizerCacheLog.warn('cache unavailable, skipping', { error: e });
686
- }
687
- return { categoryCacheHit: false };
688
- });
689
- };
690
- const shouldCategorize = (state) => {
691
- if (state.categoryCacheHit) {
692
- logger.verbose('Categorizer results cached, skipping');
693
- return 'skip';
694
- }
695
- return 'categorize';
696
- };
697
- const categorizeDynamicallyNode = async (state) => {
698
- return timed("HomeGraph.categorizeDynamically", async () => {
699
- categorizeDynamicallyLog.verbose('entry', { cardsLength: state.cards.length });
700
- if (state.cards.length === 0) {
701
- categorizeDynamicallyLog.verbose('exit', { sectionProposalsCount: 0 });
702
- return { sectionProposals: [], agentTimings: [] };
703
- }
704
- const agentTimingsAccum = [];
705
- const categorizerInput = state.cards.map((c) => ({
706
- index: c._cardIndex,
707
- headline: c.headline,
708
- mainText: c.mainText,
709
- name: c.name,
710
- viewerRole: c.viewerRole === 'introducer' ? 'introducer' : undefined,
711
- opportunityStatus: c.viewerRole === 'introducer' ? 'pending' : undefined,
712
- }));
713
- const _traceEmitterCategorizer = requestContext.getStore()?.traceEmitter;
714
- const categorizerStart = Date.now();
715
- _traceEmitterCategorizer?.({ type: "agent_start", name: "home-categorizer" });
716
- const { sections } = await categorizer.categorize(categorizerInput);
717
- const _categorizerDuration = Date.now() - categorizerStart;
718
- agentTimingsAccum.push({ name: 'home.categorizer', durationMs: _categorizerDuration });
719
- _traceEmitterCategorizer?.({ type: "agent_end", name: "home-categorizer", durationMs: _categorizerDuration, summary: `Categorized into ${sections.length} section(s)` });
720
- const proposals = sections.map((s) => ({
721
- ...s,
722
- itemIndices: s.itemIndices.filter((i) => i >= 0 && i < state.cards.length),
723
- }));
724
- categorizeDynamicallyLog.verbose('exit', { sectionProposalsCount: proposals.length });
725
- return { sectionProposals: proposals, agentTimings: agentTimingsAccum };
726
- });
727
- };
728
- const cacheCategorizerResultsNode = async (state) => {
729
- return timed("HomeGraph.cacheCategorizerResults", async () => {
730
- if (state.categoryCacheHit ||
731
- state.sectionProposals.length === 0 ||
732
- state.cards.some((card) => card._presentationFallback)) {
733
- return {};
734
- }
735
- try {
736
- const key = buildCategorizerCacheKey(state.userId, state.cards);
737
- await this.cache.set(key, state.sectionProposals, { ttl: HOME_CACHE_TTL });
738
- cacheCategorizerResultsLog.verbose('cached', {
739
- sectionCount: state.sectionProposals.length,
740
- });
741
- }
742
- catch (e) {
743
- cacheCategorizerResultsLog.warn('cache write failed, continuing', { error: e });
744
- }
745
- return {};
746
- });
747
- };
748
- const normalizeAndSortNode = async (state) => {
749
- return timed("HomeGraph.normalizeAndSort", async () => {
750
- const cards = state.cards;
751
- const proposals = state.sectionProposals;
752
- normalizeAndSortLog.verbose('entry', { cardsLength: cards.length, proposalsLength: proposals.length });
753
- if (cards.length === 0) {
754
- normalizeAndSortLog.verbose('exit', { totalOpportunities: 0, totalSections: 0 });
755
- return { sections: [], meta: { totalOpportunities: 0, totalSections: 0 } };
756
- }
757
- const usedIndices = new Set();
758
- const sections = proposals.map((p) => {
759
- const iconName = resolveHomeSectionIcon(p.iconName);
760
- const items = p.itemIndices
761
- .filter((i) => i >= 0 && i < cards.length && !usedIndices.has(i))
762
- .slice(0, MAX_ITEMS_PER_SECTION)
763
- .map((i) => {
764
- usedIndices.add(i);
765
- const card = cards[i];
766
- const { _cardIndex, _presentationFallback, ...rest } = card;
767
- return rest;
768
- });
769
- return {
770
- id: p.id,
771
- title: p.title,
772
- subtitle: p.subtitle,
773
- iconName,
774
- items,
775
- };
633
+ const normalizeItemsNode = async (state) => {
634
+ return timed("RadarGraph.normalizeItems", async () => {
635
+ normalizeItemsLog.verbose('entry', { cardsLength: state.cards.length });
636
+ const items = state.cards.map((card) => {
637
+ const { _cardIndex, _presentationFallback, ...rest } = card;
638
+ return rest;
776
639
  });
777
- // Enforce category ordering: sections with connections first, then
778
- // connector-flow only, then expired only. This prevents the LLM
779
- // categorizer from placing introducer sections before connection sections.
780
- const sectionCategoryPriority = (section) => {
781
- const hasConnection = section.items.some((item) => item.viewerRole !== 'introducer');
782
- if (hasConnection)
783
- return 0; // mixed or connection-only sections first
784
- const hasConnectorFlow = section.items.some((item) => item.viewerRole === 'introducer');
785
- if (hasConnectorFlow)
786
- return 1; // connector-flow only sections next
787
- return 2; // empty or expired sections last
788
- };
789
- sections.sort((a, b) => sectionCategoryPriority(a) - sectionCategoryPriority(b));
790
- const meta = {
791
- totalOpportunities: state.opportunities.length,
792
- totalSections: sections.length,
793
- };
794
- normalizeAndSortLog.verbose('exit', { totalOpportunities: meta.totalOpportunities, totalSections: meta.totalSections });
795
- return { sections, meta };
640
+ const meta = { totalOpportunities: state.opportunities.length };
641
+ normalizeItemsLog.verbose('exit', { totalOpportunities: meta.totalOpportunities, totalItems: items.length });
642
+ return { items, meta };
796
643
  });
797
644
  };
798
- const graph = new StateGraph(HomeGraphState)
645
+ const graph = new StateGraph(RadarGraphState)
799
646
  .addNode('loadOpportunities', loadOpportunitiesNode)
800
647
  .addNode('checkPresenterCache', checkPresenterCacheNode)
801
648
  .addNode('generateCardText', generateCardTextNode)
802
649
  .addNode('cachePresenterResults', cachePresenterResultsNode)
803
- .addNode('checkCategorizerCache', checkCategorizerCacheNode)
804
- .addNode('categorizeDynamically', categorizeDynamicallyNode)
805
- .addNode('cacheCategorizerResults', cacheCategorizerResultsNode)
806
- .addNode('normalizeAndSort', normalizeAndSortNode)
650
+ .addNode('normalizeItems', normalizeItemsNode)
807
651
  .addEdge(START, 'loadOpportunities')
808
652
  .addEdge('loadOpportunities', 'checkPresenterCache')
809
653
  .addConditionalEdges('checkPresenterCache', shouldGenerateCards, {
@@ -811,14 +655,8 @@ export class HomeGraphFactory {
811
655
  skip: 'cachePresenterResults',
812
656
  })
813
657
  .addEdge('generateCardText', 'cachePresenterResults')
814
- .addEdge('cachePresenterResults', 'checkCategorizerCache')
815
- .addConditionalEdges('checkCategorizerCache', shouldCategorize, {
816
- categorize: 'categorizeDynamically',
817
- skip: 'normalizeAndSort',
818
- })
819
- .addEdge('categorizeDynamically', 'cacheCategorizerResults')
820
- .addEdge('cacheCategorizerResults', 'normalizeAndSort')
821
- .addEdge('normalizeAndSort', END);
658
+ .addEdge('cachePresenterResults', 'normalizeItems')
659
+ .addEdge('normalizeItems', END);
822
660
  return graph.compile();
823
661
  }
824
662
  }
@@ -1,5 +1,5 @@
1
- /** Input for computing feed health score. */
2
- export interface FeedHealthInput {
1
+ /** Input for computing radar health score. */
2
+ export interface RadarHealthInput {
3
3
  connectionCount: number;
4
4
  connectorFlowCount: number;
5
5
  expiredCount: number;
@@ -11,8 +11,8 @@ export interface FeedHealthInput {
11
11
  /** Score threshold below which shouldMaintain is true. Default 0.5. */
12
12
  threshold?: number;
13
13
  }
14
- /** Output of feed health computation. */
15
- export interface FeedHealthResult {
14
+ /** Output of radar health computation. */
15
+ export interface RadarHealthResult {
16
16
  score: number;
17
17
  breakdown: {
18
18
  composition: number;
@@ -22,10 +22,10 @@ export interface FeedHealthResult {
22
22
  shouldMaintain: boolean;
23
23
  }
24
24
  /**
25
- * Compute feed health score (0–1) from current feed state.
25
+ * Compute radar health score (0–1) from current radar state.
26
26
  * Pure function, no side effects.
27
27
  *
28
- * @param input - Current feed composition and timing data
28
+ * @param input - Current radar composition and timing data
29
29
  * @returns Health score with breakdown and maintenance recommendation
30
30
  */
31
- export declare function computeFeedHealth(input: FeedHealthInput): FeedHealthResult;
31
+ export declare function computeRadarHealth(input: RadarHealthInput): RadarHealthResult;
@@ -1,4 +1,4 @@
1
- import { FEED_SOFT_TARGETS } from '../opportunity.utils.js';
1
+ import { RADAR_SOFT_TARGETS } from '../opportunity.utils.js';
2
2
  const WEIGHT_COMPOSITION = 0.4;
3
3
  const WEIGHT_FRESHNESS = 0.3;
4
4
  const WEIGHT_EXPIRATION = 0.3;
@@ -10,9 +10,9 @@ const DEFAULT_THRESHOLD = 0.5;
10
10
  */
11
11
  function scoreComposition(connectionCount, connectorFlowCount, expiredCount) {
12
12
  const categories = [
13
- { actual: connectionCount, target: FEED_SOFT_TARGETS.connection },
14
- { actual: connectorFlowCount, target: FEED_SOFT_TARGETS.connectorFlow },
15
- { actual: expiredCount, target: FEED_SOFT_TARGETS.expired },
13
+ { actual: connectionCount, target: RADAR_SOFT_TARGETS.connection },
14
+ { actual: connectorFlowCount, target: RADAR_SOFT_TARGETS.connectorFlow },
15
+ { actual: expiredCount, target: RADAR_SOFT_TARGETS.expired },
16
16
  ];
17
17
  let totalScore = 0;
18
18
  for (const { actual, target } of categories) {
@@ -45,15 +45,15 @@ function scoreExpirationRatio(expiredCount, totalActionable) {
45
45
  return 1 - expiredCount / total;
46
46
  }
47
47
  /**
48
- * Compute feed health score (0–1) from current feed state.
48
+ * Compute radar health score (0–1) from current radar state.
49
49
  * Pure function, no side effects.
50
50
  *
51
- * @param input - Current feed composition and timing data
51
+ * @param input - Current radar composition and timing data
52
52
  * @returns Health score with breakdown and maintenance recommendation
53
53
  */
54
- export function computeFeedHealth(input) {
54
+ export function computeRadarHealth(input) {
55
55
  const { connectionCount, connectorFlowCount, expiredCount, totalActionable, lastRediscoveryAt, freshnessWindowMs, threshold = DEFAULT_THRESHOLD, } = input;
56
- // Empty feed is always unhealthy
56
+ // Empty radar is always unhealthy
57
57
  if (totalActionable === 0 && expiredCount === 0) {
58
58
  return {
59
59
  score: 0,