@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.
- package/IMPLEMENTATION.md +2 -2
- package/dist/capabilities/opportunities.facade.d.ts +5 -5
- package/dist/capabilities/opportunities.facade.js +4 -4
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/maintenance/maintenance.graph.d.ts +9 -9
- package/dist/maintenance/maintenance.graph.js +20 -20
- package/dist/maintenance/maintenance.state.d.ts +5 -5
- package/dist/maintenance/maintenance.state.js +3 -3
- package/dist/opportunity/application/delivery-card.cache.js +1 -1
- package/dist/opportunity/application/index.d.ts +2 -3
- package/dist/opportunity/application/index.js +3 -4
- package/dist/opportunity/application/negotiation-context.loader.d.ts +1 -1
- package/dist/opportunity/application/negotiation-context.loader.js +1 -1
- package/dist/opportunity/application/opportunity.enricher.js +2 -2
- package/dist/opportunity/application/opportunity.presenter.d.ts +15 -15
- package/dist/opportunity/application/opportunity.presenter.js +17 -17
- package/dist/opportunity/application/opportunity.tools.d.ts +1 -1
- package/dist/opportunity/application/opportunity.tools.js +3 -3
- package/dist/opportunity/discriminator/discriminator.adjustments.js +1 -1
- package/dist/opportunity/discriminator/discriminator.env.d.ts +1 -1
- package/dist/opportunity/discriminator/discriminator.env.js +1 -1
- package/dist/opportunity/domain/index.d.ts +3 -3
- package/dist/opportunity/domain/index.js +3 -3
- package/dist/opportunity/domain/opportunity.presentation-cache.d.ts +1 -2
- package/dist/opportunity/domain/opportunity.presentation-cache.js +2 -5
- package/dist/opportunity/domain/opportunity.presentation.d.ts +1 -1
- package/dist/opportunity/domain/opportunity.presentation.js +1 -1
- package/dist/opportunity/domain/opportunity.safe-presentation.d.ts +2 -2
- package/dist/opportunity/domain/opportunity.safe-presentation.js +1 -1
- package/dist/opportunity/domain/opportunity.utils.d.ts +5 -5
- package/dist/opportunity/domain/opportunity.utils.js +8 -8
- package/dist/opportunity/index.d.ts +1 -1
- package/dist/opportunity/index.js +1 -1
- package/dist/opportunity/opportunity.presenter.d.ts +3 -3
- package/dist/opportunity/opportunity.presenter.js +2 -2
- package/dist/opportunity/public/index.d.ts +5 -5
- package/dist/opportunity/public/index.js +6 -6
- package/dist/opportunity/{feed/feed.graph.d.ts → radar/radar.graph.d.ts} +42 -73
- package/dist/opportunity/{feed/feed.graph.js → radar/radar.graph.js} +60 -222
- package/dist/opportunity/{feed/feed.health.d.ts → radar/radar.health.d.ts} +7 -7
- package/dist/opportunity/{feed/feed.health.js → radar/radar.health.js} +8 -8
- package/dist/opportunity/{feed/feed.state.d.ts → radar/radar.state.d.ts} +21 -47
- package/dist/opportunity/{feed/feed.state.js → radar/radar.state.js} +15 -24
- package/dist/shared/agent/tool.helpers.d.ts +1 -1
- package/dist/shared/interfaces/database.interface.d.ts +3 -3
- package/package.json +1 -1
- package/dist/opportunity/feed/feed.categorizer.d.ts +0 -27
- package/dist/opportunity/feed/feed.categorizer.js +0 -168
- package/dist/shared/ui/lucide.icon-catalog.d.ts +0 -21
- package/dist/shared/ui/lucide.icon-catalog.js +0 -100
|
@@ -1,39 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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]
|
|
6
|
-
* →
|
|
6
|
+
* loadOpportunities → checkPresenterCache → [generateCardText if misses]
|
|
7
|
+
* → cachePresenterResults → normalizeItems
|
|
7
8
|
*
|
|
8
|
-
* Uses OpportunityPresenter for card text and
|
|
9
|
-
*
|
|
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 {
|
|
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 {
|
|
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('
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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 ??
|
|
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:
|
|
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
|
|
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 = [...
|
|
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 = [...
|
|
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('
|
|
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("
|
|
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) =>
|
|
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("
|
|
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
|
|
372
|
-
return { cards: [], agentTimings: [], meta: { totalOpportunities: 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
|
|
433
|
+
// cache miss that keeps the whole radar slow (~9s per load).
|
|
445
434
|
if (userName === 'Unknown' || !userName?.trim()) {
|
|
446
|
-
logger.verbose('[
|
|
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
|
|
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.
|
|
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('
|
|
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
|
|
578
|
+
generateCardTextLog.verbose('exit', { totalOpportunities: state.opportunities.length });
|
|
590
579
|
return {
|
|
591
580
|
cards,
|
|
592
581
|
agentTimings: agentTimingsAccum,
|
|
593
|
-
meta: { totalOpportunities: state.opportunities.length
|
|
582
|
+
meta: { totalOpportunities: state.opportunities.length },
|
|
594
583
|
};
|
|
595
584
|
});
|
|
596
585
|
};
|
|
597
586
|
const cachePresenterResultsNode = async (state) => {
|
|
598
|
-
return timed("
|
|
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 || !
|
|
606
|
+
if (!status || !isRadarPresentationCacheable(card, status))
|
|
618
607
|
return Promise.resolve();
|
|
619
|
-
return this.cache.set(
|
|
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
|
|
629
|
+
meta: { totalOpportunities: state.opportunities.length },
|
|
641
630
|
};
|
|
642
631
|
});
|
|
643
632
|
};
|
|
644
|
-
const
|
|
645
|
-
return timed("
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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(
|
|
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('
|
|
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', '
|
|
815
|
-
.
|
|
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
|
|
2
|
-
export interface
|
|
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
|
|
15
|
-
export interface
|
|
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
|
|
25
|
+
* Compute radar health score (0–1) from current radar state.
|
|
26
26
|
* Pure function, no side effects.
|
|
27
27
|
*
|
|
28
|
-
* @param input - Current
|
|
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
|
|
31
|
+
export declare function computeRadarHealth(input: RadarHealthInput): RadarHealthResult;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
14
|
-
{ actual: connectorFlowCount, target:
|
|
15
|
-
{ actual: expiredCount, target:
|
|
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
|
|
48
|
+
* Compute radar health score (0–1) from current radar state.
|
|
49
49
|
* Pure function, no side effects.
|
|
50
50
|
*
|
|
51
|
-
* @param input - Current
|
|
51
|
+
* @param input - Current radar composition and timing data
|
|
52
52
|
* @returns Health score with breakdown and maintenance recommendation
|
|
53
53
|
*/
|
|
54
|
-
export function
|
|
54
|
+
export function computeRadarHealth(input) {
|
|
55
55
|
const { connectionCount, connectorFlowCount, expiredCount, totalActionable, lastRediscoveryAt, freshnessWindowMs, threshold = DEFAULT_THRESHOLD, } = input;
|
|
56
|
-
// Empty
|
|
56
|
+
// Empty radar is always unhealthy
|
|
57
57
|
if (totalActionable === 0 && expiredCount === 0) {
|
|
58
58
|
return {
|
|
59
59
|
score: 0,
|