@indexnetwork/protocol 14.3.2-rc.477.1 → 17.0.0-rc.479.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/CHANGELOG.md +141 -0
- package/IMPLEMENTATION.md +50 -52
- package/STABILITY.md +3 -3
- package/dist/chat/chat.agent.js +2 -3
- package/dist/chat/chat.graph.d.ts +41 -1
- package/dist/chat/chat.graph.js +108 -127
- package/dist/chat/negotiator.persona.d.ts +1 -1
- package/dist/chat/negotiator.persona.js +1 -4
- package/dist/chat/negotiator.prompt.js +1 -2
- package/dist/chat/onboarding.persona.js +0 -1
- package/dist/chat/reporter.persona.js +0 -1
- package/dist/chat/signal.persona.js +0 -1
- package/dist/contacts/application/contact.tools.d.ts +6 -9
- package/dist/contacts/application/contact.tools.js +11 -92
- package/dist/contacts/application/index.d.ts +2 -14
- package/dist/contacts/application/index.js +2 -13
- package/dist/contacts/domain/contact.types.d.ts +0 -23
- package/dist/contacts/domain/index.d.ts +2 -6
- package/dist/contacts/index.d.ts +1 -1
- package/dist/contacts/index.js +1 -1
- package/dist/contacts/ports/contact.repository.port.d.ts +5 -12
- package/dist/contacts/ports/contact.repository.port.js +4 -6
- package/dist/contacts/ports/contact.tools.port.d.ts +1 -3
- package/dist/contacts/ports/contact.tools.port.js +1 -2
- package/dist/contacts/ports/index.d.ts +1 -1
- package/dist/contacts/ports/index.js +1 -1
- package/dist/discovery/hyde.frame.d.ts +4 -4
- package/dist/discovery/hyde.graph.d.ts +71 -12
- package/dist/discovery/hyde.graph.js +337 -335
- package/dist/discovery/lens.inferrer.d.ts +6 -6
- package/dist/enrichment/enrichment.graph.d.ts +127 -9
- package/dist/enrichment/enrichment.graph.js +633 -655
- package/dist/enrichment/enrichment.state.d.ts +1 -1
- package/dist/enrichment/enrichment.tools.context-read.d.ts +10 -0
- package/dist/enrichment/enrichment.tools.context-read.js +353 -0
- package/dist/enrichment/enrichment.tools.context-write.d.ts +9 -0
- package/dist/enrichment/enrichment.tools.context-write.js +417 -0
- package/dist/enrichment/enrichment.tools.d.ts +11 -1
- package/dist/enrichment/enrichment.tools.helpers.d.ts +139 -0
- package/dist/enrichment/enrichment.tools.helpers.js +234 -0
- package/dist/enrichment/enrichment.tools.js +16 -975
- package/dist/index.d.ts +27 -50
- package/dist/index.js +17 -28
- package/dist/intents/application/intent.graph.d.ts +71 -67
- package/dist/intents/application/intent.graph.execute.d.ts +59 -0
- package/dist/intents/application/intent.graph.execute.js +301 -0
- package/dist/intents/application/intent.graph.infer.d.ts +44 -0
- package/dist/intents/application/intent.graph.infer.js +97 -0
- package/dist/intents/application/intent.graph.js +96 -888
- package/dist/intents/application/intent.graph.reconcile.d.ts +71 -0
- package/dist/intents/application/intent.graph.reconcile.js +274 -0
- package/dist/intents/application/intent.graph.shared.d.ts +70 -0
- package/dist/intents/application/intent.graph.shared.js +153 -0
- package/dist/intents/domain/intent.state.d.ts +1 -1
- package/dist/maintenance/maintenance.graph.d.ts +66 -3
- package/dist/maintenance/maintenance.graph.js +155 -156
- package/dist/mcp/mcp.authorization-policy.d.ts +24 -28
- package/dist/mcp/mcp.authorization-policy.js +14 -34
- package/dist/mcp/mcp.server.d.ts +3 -3
- package/dist/mcp/mcp.server.js +14 -20
- package/dist/negotiations/application/negotiation.candidates.d.ts +83 -0
- package/dist/negotiations/application/negotiation.candidates.js +162 -0
- package/dist/negotiations/application/negotiation.graph.d.ts +91 -157
- package/dist/negotiations/application/negotiation.graph.finalize.d.ts +5 -0
- package/dist/negotiations/application/negotiation.graph.finalize.js +280 -0
- package/dist/negotiations/application/negotiation.graph.init.d.ts +54 -0
- package/dist/negotiations/application/negotiation.graph.init.js +227 -0
- package/dist/negotiations/application/negotiation.graph.js +69 -1388
- package/dist/negotiations/application/negotiation.graph.screen.d.ts +23 -0
- package/dist/negotiations/application/negotiation.graph.screen.js +108 -0
- package/dist/negotiations/application/negotiation.graph.shared.d.ts +75 -0
- package/dist/negotiations/application/negotiation.graph.shared.js +125 -0
- package/dist/negotiations/application/negotiation.graph.turn.d.ts +105 -0
- package/dist/negotiations/application/negotiation.graph.turn.js +484 -0
- package/dist/negotiations/domain/negotiation.state.d.ts +1 -1
- package/dist/negotiations/domain/negotiation.state.js +0 -1
- package/dist/networks/application/indexer.graph.d.ts +165 -7
- package/dist/networks/application/indexer.graph.js +339 -388
- package/dist/networks/application/indexer.state.d.ts +1 -1
- package/dist/networks/application/membership.graph.d.ts +83 -5
- package/dist/networks/application/membership.graph.js +177 -207
- package/dist/networks/application/network.graph.d.ts +148 -5
- package/dist/networks/application/network.graph.js +249 -278
- package/dist/networks/domain/membership.state.d.ts +1 -1
- package/dist/networks/domain/network.state.d.ts +1 -1
- package/dist/opportunities/application/delivery-card.cache.d.ts +1 -1
- package/dist/opportunities/application/delivery-card.cache.js +2 -2
- package/dist/opportunities/application/index.d.ts +2 -2
- package/dist/opportunities/application/index.js +2 -2
- package/dist/opportunities/application/opportunity.evaluator.js +1 -1
- package/dist/opportunities/application/opportunity.graph.d.ts +686 -473
- package/dist/opportunities/application/opportunity.graph.discovery-strategies.d.ts +109 -0
- package/dist/opportunities/application/opportunity.graph.discovery-strategies.js +451 -0
- package/dist/opportunities/application/opportunity.graph.discovery.d.ts +69 -0
- package/dist/opportunities/application/opportunity.graph.discovery.js +397 -0
- package/dist/opportunities/application/opportunity.graph.evaluation.d.ts +82 -0
- package/dist/opportunities/application/opportunity.graph.evaluation.js +608 -0
- package/dist/opportunities/application/opportunity.graph.js +120 -3690
- package/dist/opportunities/application/opportunity.graph.modes.d.ts +538 -0
- package/dist/opportunities/application/opportunity.graph.modes.js +538 -0
- package/dist/opportunities/application/opportunity.graph.negotiate.d.ts +109 -0
- package/dist/opportunities/application/opportunity.graph.negotiate.js +392 -0
- package/dist/opportunities/application/opportunity.graph.persist-node.d.ts +93 -0
- package/dist/opportunities/application/opportunity.graph.persist-node.js +765 -0
- package/dist/opportunities/application/opportunity.graph.prep.d.ts +150 -0
- package/dist/opportunities/application/opportunity.graph.prep.js +381 -0
- package/dist/opportunities/application/opportunity.graph.shared.d.ts +173 -0
- package/dist/opportunities/application/opportunity.graph.shared.js +199 -0
- package/dist/opportunities/application/opportunity.presentation.d.ts +399 -0
- package/dist/opportunities/application/{opportunity.presenter.js → opportunity.presentation.js} +735 -9
- package/dist/opportunities/application/opportunity.tools.cards.d.ts +152 -0
- package/dist/opportunities/application/opportunity.tools.cards.js +235 -0
- package/dist/opportunities/application/opportunity.tools.d.ts +8 -114
- package/dist/opportunities/application/opportunity.tools.js +28 -711
- package/dist/opportunities/application/opportunity.tools.list.d.ts +10 -0
- package/dist/opportunities/application/opportunity.tools.list.js +493 -0
- package/dist/opportunities/domain/index.d.ts +3 -3
- package/dist/opportunities/domain/index.js +3 -3
- package/dist/opportunities/domain/opportunity.state.d.ts +19 -19
- package/dist/opportunities/index.d.ts +6 -6
- package/dist/opportunities/index.js +4 -4
- package/dist/opportunities/ports/opportunity.tools.port.d.ts +1 -1
- package/dist/opportunities/radar/radar.graph.d.ts +57 -13
- package/dist/opportunities/radar/radar.graph.js +470 -471
- package/dist/premises/premise.graph.d.ts +88 -20
- package/dist/premises/premise.graph.js +207 -218
- package/dist/questions/domain/question.schema.d.ts +40 -40
- package/dist/shared/agent/tool.factory.js +0 -8
- package/dist/shared/agent/tool.helpers.d.ts +21 -45
- package/dist/shared/agent/tool.helpers.js +1 -2
- package/dist/shared/agent/tool.registry.d.ts +4 -3
- package/dist/shared/agent/tool.registry.js +2 -4
- package/dist/shared/agent/tool.runtime.js +0 -2
- package/dist/shared/agent/utility.tools.js +9 -13
- package/dist/shared/interfaces/database.capabilities.d.ts +100 -0
- package/dist/shared/interfaces/database.capabilities.js +7 -0
- package/dist/shared/interfaces/database.entities.d.ts +533 -0
- package/dist/shared/interfaces/database.entities.js +10 -0
- package/dist/shared/interfaces/database.identity-queries.d.ts +294 -0
- package/dist/shared/interfaces/database.identity-queries.js +4 -0
- package/dist/shared/interfaces/database.interface.d.ts +15 -2277
- package/dist/shared/interfaces/database.interface.js +8 -0
- package/dist/shared/interfaces/database.member-queries.d.ts +221 -0
- package/dist/shared/interfaces/database.member-queries.js +4 -0
- package/dist/shared/interfaces/database.negotiation.d.ts +305 -0
- package/dist/shared/interfaces/database.negotiation.js +26 -0
- package/dist/shared/interfaces/database.network-queries.d.ts +277 -0
- package/dist/shared/interfaces/database.network-queries.js +4 -0
- package/dist/shared/interfaces/database.opportunity-queries.d.ts +285 -0
- package/dist/shared/interfaces/database.opportunity-queries.js +4 -0
- package/dist/shared/interfaces/database.port.d.ts +322 -0
- package/dist/shared/interfaces/database.port.js +29 -0
- package/dist/shared/schemas/discovery-question.schema.d.ts +14 -14
- package/dist/shared/schemas/negotiation-digest.schema.d.ts +2 -2
- package/package.json +2 -15
- package/dist/contacts/application/contact.inviter.d.ts +0 -49
- package/dist/contacts/application/contact.inviter.js +0 -64
- package/dist/integrations/application/index.d.ts +0 -14
- package/dist/integrations/application/index.js +0 -14
- package/dist/integrations/application/integration.tools.d.ts +0 -27
- package/dist/integrations/application/integration.tools.js +0 -98
- package/dist/integrations/domain/index.d.ts +0 -16
- package/dist/integrations/domain/index.js +0 -1
- package/dist/integrations/domain/integration.types.d.ts +0 -51
- package/dist/integrations/domain/integration.types.js +0 -9
- package/dist/integrations/index.d.ts +0 -10
- package/dist/integrations/index.js +0 -8
- package/dist/integrations/ports/index.d.ts +0 -25
- package/dist/integrations/ports/index.js +0 -23
- package/dist/integrations/ports/integration.adapter.port.d.ts +0 -62
- package/dist/integrations/ports/integration.adapter.port.js +0 -15
- package/dist/integrations/ports/integration.importer.port.d.ts +0 -31
- package/dist/integrations/ports/integration.importer.port.js +0 -15
- package/dist/integrations/ports/integration.tools.port.d.ts +0 -25
- package/dist/integrations/ports/integration.tools.port.js +0 -18
- package/dist/opportunities/application/opportunity.card-presentation.d.ts +0 -44
- package/dist/opportunities/application/opportunity.card-presentation.js +0 -93
- package/dist/opportunities/application/opportunity.presenter.d.ts +0 -156
- package/dist/opportunities/domain/opportunity.presentation-cache.d.ts +0 -5
- package/dist/opportunities/domain/opportunity.presentation-cache.js +0 -11
- package/dist/opportunities/domain/opportunity.presentation.d.ts +0 -76
- package/dist/opportunities/domain/opportunity.presentation.js +0 -516
- package/dist/opportunities/domain/opportunity.safe-presentation.d.ts +0 -104
- package/dist/opportunities/domain/opportunity.safe-presentation.js +0 -102
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { StateGraph, START, END } from '@langchain/langgraph';
|
|
14
14
|
import { RadarGraphState } from './radar.state.js';
|
|
15
|
-
import { OpportunityPresenter, gatherPresenterContext } from '../application/opportunity.
|
|
15
|
+
import { OpportunityPresenter, gatherPresenterContext } from '../application/opportunity.presentation.js';
|
|
16
16
|
import { loadNegotiationContext } from '../application/negotiation-context.loader.js';
|
|
17
17
|
import { canUserSeeOpportunity, isActionableForViewer, selectByComposition } from '../domain/opportunity.utils.js';
|
|
18
18
|
import { getPrimaryActionLabel, SECONDARY_ACTION_LABEL } from '../domain/opportunity.labels.js';
|
|
19
|
-
import { safeFallbackSummary } from '../
|
|
20
|
-
import { buildRadarCardPresentationCacheKey } from '../
|
|
19
|
+
import { safeFallbackSummary } from '../application/opportunity.presentation.js';
|
|
20
|
+
import { buildRadarCardPresentationCacheKey } from '../application/opportunity.presentation.js';
|
|
21
21
|
import { protocolLogger } from '../../shared/observability/protocol.logger.js';
|
|
22
22
|
import { timed } from '../../shared/observability/performance.js';
|
|
23
23
|
import { requestContext } from "../../shared/observability/request-context.js";
|
|
@@ -170,493 +170,492 @@ const pickDisplayCounterpartActor = (opportunity, viewerId) => {
|
|
|
170
170
|
};
|
|
171
171
|
export class RadarGraphFactory {
|
|
172
172
|
constructor(database, cache) {
|
|
173
|
-
this.
|
|
174
|
-
this.cache = cache;
|
|
173
|
+
this.deps = { database, cache, presenter: new OpportunityPresenter() };
|
|
175
174
|
}
|
|
176
175
|
createGraph() {
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return true;
|
|
236
|
-
});
|
|
237
|
-
// Preserve byte-identical newest-first behavior while the flag is
|
|
238
|
-
// off. When on, re-order the already-deduped lifecycle set by
|
|
239
|
-
// adjusted confidence so each client-side radar bucket reflects
|
|
240
|
-
// pool answers immediately.
|
|
241
|
-
if (!poolRankingProvenance ||
|
|
242
|
-
!dedupedByCounterpart.some((opportunity) => hasPoolAdjustment(opportunity, poolRankingProvenance))) {
|
|
243
|
-
return { opportunities: dedupedByCounterpart.slice(0, state.limit) };
|
|
244
|
-
}
|
|
245
|
-
const adjustedOrder = [...dedupedByCounterpart].sort((a, b) => {
|
|
246
|
-
const confidenceDelta = getConfidence(b, poolRankingProvenance) - getConfidence(a, poolRankingProvenance);
|
|
247
|
-
if (confidenceDelta !== 0)
|
|
248
|
-
return confidenceDelta;
|
|
249
|
-
return safeParseDate(b.updatedAt) - safeParseDate(a.updatedAt);
|
|
250
|
-
});
|
|
251
|
-
return { opportunities: adjustedOrder.slice(0, state.limit) };
|
|
252
|
-
}
|
|
253
|
-
const sorted = [...visibleForRadar].sort((a, b) => {
|
|
254
|
-
// Connections before connector-flow so dedup claims counterpart IDs
|
|
255
|
-
// for direct connections first — prevents introducer cards from
|
|
256
|
-
// shadowing a user's own connection opportunities.
|
|
257
|
-
const aIsIntroducer = a.actors.some((ac) => ac.userId === state.userId && ac.role === 'introducer');
|
|
258
|
-
const bIsIntroducer = b.actors.some((ac) => ac.userId === state.userId && ac.role === 'introducer');
|
|
259
|
-
if (aIsIntroducer !== bIsIntroducer)
|
|
260
|
-
return aIsIntroducer ? 1 : -1;
|
|
261
|
-
const confA = getConfidence(a, poolRankingProvenance);
|
|
262
|
-
const confB = getConfidence(b, poolRankingProvenance);
|
|
263
|
-
if (confB !== confA)
|
|
264
|
-
return confB - confA;
|
|
265
|
-
const aTime = safeParseDate(a.updatedAt);
|
|
266
|
-
const bTime = safeParseDate(b.updatedAt);
|
|
267
|
-
return bTime - aTime;
|
|
268
|
-
});
|
|
269
|
-
const seenUserIds = new Set();
|
|
270
|
-
const deduped = sorted.filter((opp) => {
|
|
271
|
-
const counterpartIds = getUniqueCounterpartUserIds(opp, state.userId);
|
|
272
|
-
const hasOverlap = [...counterpartIds].some((id) => seenUserIds.has(id));
|
|
273
|
-
if (hasOverlap)
|
|
274
|
-
return false;
|
|
275
|
-
for (const id of counterpartIds)
|
|
276
|
-
seenUserIds.add(id);
|
|
277
|
-
return true;
|
|
278
|
-
});
|
|
279
|
-
const opportunities = selectByComposition(deduped, state.userId);
|
|
280
|
-
return { opportunities };
|
|
281
|
-
}
|
|
282
|
-
catch (e) {
|
|
283
|
-
logger.error('RadarGraph loadOpportunities failed', { error: e });
|
|
284
|
-
return { error: 'Failed to load opportunities', opportunities: [] };
|
|
176
|
+
const deps = this.deps;
|
|
177
|
+
const graph = new StateGraph(RadarGraphState)
|
|
178
|
+
.addNode('loadOpportunities', (state) => loadOpportunitiesNode(state, deps))
|
|
179
|
+
.addNode('checkPresenterCache', (state) => checkPresenterCacheNode(state, deps))
|
|
180
|
+
.addNode('generateCardText', (state) => generateCardTextNode(state, deps))
|
|
181
|
+
.addNode('cachePresenterResults', (state) => cachePresenterResultsNode(state, deps))
|
|
182
|
+
.addNode('normalizeItems', normalizeItemsNode)
|
|
183
|
+
.addEdge(START, 'loadOpportunities')
|
|
184
|
+
.addEdge('loadOpportunities', 'checkPresenterCache')
|
|
185
|
+
.addConditionalEdges('checkPresenterCache', (state) => shouldGenerateCards(state, deps), {
|
|
186
|
+
generate: 'generateCardText',
|
|
187
|
+
skip: 'cachePresenterResults',
|
|
188
|
+
})
|
|
189
|
+
.addEdge('generateCardText', 'cachePresenterResults')
|
|
190
|
+
.addEdge('cachePresenterResults', 'normalizeItems')
|
|
191
|
+
.addEdge('normalizeItems', END);
|
|
192
|
+
return graph.compile();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export async function loadOpportunitiesNode(state, deps) {
|
|
196
|
+
return timed("RadarGraph.loadOpportunities", async () => {
|
|
197
|
+
if (!state.userId) {
|
|
198
|
+
return { error: 'userId is required' };
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
// Minimum of 50 ensures enough candidates across all radar categories
|
|
202
|
+
// (connection, connector-flow, expired) for selectByComposition to fill
|
|
203
|
+
// its soft targets, even after visibility filtering and dedup.
|
|
204
|
+
const fetchLimit = Math.min(150, Math.max(50, state.limit * 3));
|
|
205
|
+
const statuses = state.statuses ?? DEFAULT_RADAR_STATUSES;
|
|
206
|
+
const poolRankingProvenance = getPoolRankingProvenance(state);
|
|
207
|
+
const options = {
|
|
208
|
+
limit: fetchLimit,
|
|
209
|
+
statuses,
|
|
210
|
+
};
|
|
211
|
+
if (state.networkId)
|
|
212
|
+
options.networkId = state.networkId;
|
|
213
|
+
if (state.scopeType === 'intent' && state.scopeId) {
|
|
214
|
+
options.scopeType = 'intent';
|
|
215
|
+
options.scopeId = state.scopeId;
|
|
216
|
+
}
|
|
217
|
+
// Do not pass conversationId: radar view excludes draft opportunities (chat-only drafts).
|
|
218
|
+
const raw = await deps.database.getOpportunitiesForUser(state.userId, options);
|
|
219
|
+
const visible = raw.filter((opp) => canUserSeeOpportunity(opp.actors, opp.status, state.userId));
|
|
220
|
+
// Actionability only gates the live statuses a viewer could act on:
|
|
221
|
+
// latent/pending cards the viewer cannot act on are noise, but
|
|
222
|
+
// terminal/internal statuses (accepted, rejected, expired, negotiating,
|
|
223
|
+
// stalled, draft) are deliberate history — when a caller explicitly
|
|
224
|
+
// requests them via `statuses`, they must pass through (they are never
|
|
225
|
+
// actionable by rule 5, so filtering them here would return nothing).
|
|
226
|
+
// The requested-status membership check is defense-in-depth: rows
|
|
227
|
+
// outside the requested set are dropped even if the adapter drifts.
|
|
228
|
+
const requestedStatuses = new Set(statuses);
|
|
229
|
+
const visibleForRadar = visible.filter((opp) => {
|
|
230
|
+
if (!requestedStatuses.has(opp.status))
|
|
231
|
+
return false;
|
|
232
|
+
if (opp.status === 'latent' || opp.status === 'pending') {
|
|
233
|
+
return isActionableForViewer(opp.actors, opp.status, state.userId);
|
|
285
234
|
}
|
|
235
|
+
return true;
|
|
286
236
|
});
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
237
|
+
const explicitStatuses = (state.statuses?.length ?? 0) > 0;
|
|
238
|
+
if (explicitStatuses) {
|
|
239
|
+
// Lifecycle view (e.g. intent radar): newest-first so counterpart
|
|
240
|
+
// dedup keeps each person's most recent state (an accepted
|
|
241
|
+
// opportunity supersedes an older pending one), no composition
|
|
242
|
+
// capping — the caller wants the full pipeline up to `limit`.
|
|
243
|
+
const newestFirst = [...visibleForRadar].sort((a, b) => safeParseDate(b.updatedAt) - safeParseDate(a.updatedAt));
|
|
244
|
+
const seenIds = new Set();
|
|
245
|
+
const dedupedByCounterpart = newestFirst.filter((opp) => {
|
|
246
|
+
const counterpartIds = getUniqueCounterpartUserIds(opp, state.userId);
|
|
247
|
+
const hasOverlap = [...counterpartIds].some((id) => seenIds.has(id));
|
|
248
|
+
if (hasOverlap)
|
|
249
|
+
return false;
|
|
250
|
+
for (const id of counterpartIds)
|
|
251
|
+
seenIds.add(id);
|
|
252
|
+
return true;
|
|
253
|
+
});
|
|
254
|
+
// Preserve byte-identical newest-first behavior while the flag is
|
|
255
|
+
// off. When on, re-order the already-deduped lifecycle set by
|
|
256
|
+
// adjusted confidence so each client-side radar bucket reflects
|
|
257
|
+
// pool answers immediately.
|
|
258
|
+
if (!poolRankingProvenance ||
|
|
259
|
+
!dedupedByCounterpart.some((opportunity) => hasPoolAdjustment(opportunity, poolRankingProvenance))) {
|
|
260
|
+
return { opportunities: dedupedByCounterpart.slice(0, state.limit) };
|
|
298
261
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
262
|
+
const adjustedOrder = [...dedupedByCounterpart].sort((a, b) => {
|
|
263
|
+
const confidenceDelta = getConfidence(b, poolRankingProvenance) - getConfidence(a, poolRankingProvenance);
|
|
264
|
+
if (confidenceDelta !== 0)
|
|
265
|
+
return confidenceDelta;
|
|
266
|
+
return safeParseDate(b.updatedAt) - safeParseDate(a.updatedAt);
|
|
267
|
+
});
|
|
268
|
+
return { opportunities: adjustedOrder.slice(0, state.limit) };
|
|
269
|
+
}
|
|
270
|
+
const sorted = [...visibleForRadar].sort((a, b) => {
|
|
271
|
+
// Connections before connector-flow so dedup claims counterpart IDs
|
|
272
|
+
// for direct connections first — prevents introducer cards from
|
|
273
|
+
// shadowing a user's own connection opportunities.
|
|
274
|
+
const aIsIntroducer = a.actors.some((ac) => ac.userId === state.userId && ac.role === 'introducer');
|
|
275
|
+
const bIsIntroducer = b.actors.some((ac) => ac.userId === state.userId && ac.role === 'introducer');
|
|
276
|
+
if (aIsIntroducer !== bIsIntroducer)
|
|
277
|
+
return aIsIntroducer ? 1 : -1;
|
|
278
|
+
const confA = getConfidence(a, poolRankingProvenance);
|
|
279
|
+
const confB = getConfidence(b, poolRankingProvenance);
|
|
280
|
+
if (confB !== confA)
|
|
281
|
+
return confB - confA;
|
|
282
|
+
const aTime = safeParseDate(a.updatedAt);
|
|
283
|
+
const bTime = safeParseDate(b.updatedAt);
|
|
284
|
+
return bTime - aTime;
|
|
285
|
+
});
|
|
286
|
+
const seenUserIds = new Set();
|
|
287
|
+
const deduped = sorted.filter((opp) => {
|
|
288
|
+
const counterpartIds = getUniqueCounterpartUserIds(opp, state.userId);
|
|
289
|
+
const hasOverlap = [...counterpartIds].some((id) => seenUserIds.has(id));
|
|
290
|
+
if (hasOverlap)
|
|
291
|
+
return false;
|
|
292
|
+
for (const id of counterpartIds)
|
|
293
|
+
seenUserIds.add(id);
|
|
294
|
+
return true;
|
|
295
|
+
});
|
|
296
|
+
const opportunities = selectByComposition(deduped, state.userId);
|
|
297
|
+
return { opportunities };
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
logger.error('RadarGraph loadOpportunities failed', { error: e });
|
|
301
|
+
return { error: 'Failed to load opportunities', opportunities: [] };
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
export async function checkPresenterCacheNode(state, deps) {
|
|
306
|
+
return timed("RadarGraph.checkPresenterCache", async () => {
|
|
307
|
+
const { opportunities, userId } = state;
|
|
308
|
+
const poolRankingProvenance = getPoolRankingProvenance(state);
|
|
309
|
+
if (opportunities.length === 0) {
|
|
310
|
+
return { cachedCards: new Map(), uncachedOpportunities: [] };
|
|
311
|
+
}
|
|
312
|
+
if (state.noCache) {
|
|
313
|
+
checkPresenterCacheLog.verbose('noCache=true, skipping cache');
|
|
314
|
+
return { cachedCards: new Map(), uncachedOpportunities: opportunities };
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
// Negotiating cards are templated (no LLM call) and their text
|
|
318
|
+
// depends on the live turn count, which changes between requests
|
|
319
|
+
// without changing the opportunity status. Skip cache entirely
|
|
320
|
+
// for them so each render reflects the current turn.
|
|
321
|
+
//
|
|
322
|
+
// For all other statuses, include status in the key so status
|
|
323
|
+
// transitions (e.g. negotiating → pending) don't serve stale cards.
|
|
324
|
+
const cacheable = opportunities.filter((opp) => opp.status !== 'negotiating');
|
|
325
|
+
const liveNegotiating = opportunities.filter((opp) => opp.status === 'negotiating');
|
|
326
|
+
const keys = cacheable.map((opp) => buildRadarCardPresentationCacheKey(opp.id, opp.status, userId));
|
|
327
|
+
const results = keys.length > 0 ? await deps.cache.mget(keys) : [];
|
|
328
|
+
const cachedCards = new Map();
|
|
329
|
+
const uncachedOpportunities = [...liveNegotiating];
|
|
330
|
+
for (let i = 0; i < cacheable.length; i++) {
|
|
331
|
+
const cached = results[i];
|
|
332
|
+
if (cached) {
|
|
333
|
+
const originalIndex = opportunities.indexOf(cacheable[i]);
|
|
334
|
+
// Stamp the live status: pre-status cache entries lack the field,
|
|
335
|
+
// and the key already guarantees it matches the current status.
|
|
336
|
+
const deprioritizedReason = poolRankingProvenance
|
|
337
|
+
? latestPoolDemotionDetail(cacheable[i].metadata, poolRankingProvenance)
|
|
338
|
+
: undefined;
|
|
339
|
+
cachedCards.set(cacheable[i].id, {
|
|
340
|
+
...cached,
|
|
341
|
+
status: cacheable[i].status,
|
|
342
|
+
deprioritizedReason,
|
|
343
|
+
_cardIndex: originalIndex,
|
|
337
344
|
});
|
|
338
|
-
return { cachedCards, uncachedOpportunities };
|
|
339
345
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return { cachedCards: new Map(), uncachedOpportunities: opportunities };
|
|
346
|
+
else {
|
|
347
|
+
uncachedOpportunities.push(cacheable[i]);
|
|
343
348
|
}
|
|
349
|
+
}
|
|
350
|
+
checkPresenterCacheLog.verbose('', {
|
|
351
|
+
total: opportunities.length,
|
|
352
|
+
cacheHits: cachedCards.size,
|
|
353
|
+
cacheMisses: uncachedOpportunities.length,
|
|
344
354
|
});
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
355
|
+
return { cachedCards, uncachedOpportunities };
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
checkPresenterCacheLog.warn('cache unavailable, skipping', { error: e });
|
|
359
|
+
return { cachedCards: new Map(), uncachedOpportunities: opportunities };
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
export function shouldGenerateCards(state, deps) {
|
|
364
|
+
if (state.uncachedOpportunities.length > 0) {
|
|
365
|
+
return 'generate';
|
|
366
|
+
}
|
|
367
|
+
logger.verbose('All presenter results cached, skipping generation');
|
|
368
|
+
return 'skip';
|
|
369
|
+
}
|
|
370
|
+
export async function generateCardTextNode(state, deps) {
|
|
371
|
+
return timed("RadarGraph.generateCardText", async () => {
|
|
372
|
+
const opportunities = state.uncachedOpportunities.length > 0
|
|
373
|
+
? state.uncachedOpportunities
|
|
374
|
+
: state.opportunities;
|
|
375
|
+
generateCardTextLog.verbose('entry', { opportunitiesLength: opportunities.length, userId: state.userId });
|
|
376
|
+
if (opportunities.length === 0) {
|
|
377
|
+
generateCardTextLog.verbose('exit', { totalOpportunities: 0 });
|
|
378
|
+
return { cards: [], agentTimings: [], meta: { totalOpportunities: 0 } };
|
|
379
|
+
}
|
|
380
|
+
const db = deps.database;
|
|
381
|
+
const cards = [];
|
|
382
|
+
const relevantActorIds = new Set();
|
|
383
|
+
for (const opp of opportunities) {
|
|
384
|
+
for (const a of opp.actors) {
|
|
385
|
+
if (a.userId)
|
|
386
|
+
relevantActorIds.add(a.userId);
|
|
349
387
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
388
|
+
}
|
|
389
|
+
const userEntries = await Promise.all(Array.from(relevantActorIds).map(async (userId) => {
|
|
390
|
+
try {
|
|
391
|
+
const user = await deps.database.getUser(userId);
|
|
392
|
+
return [userId, user ?? null];
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return [userId, null];
|
|
396
|
+
}
|
|
397
|
+
}));
|
|
398
|
+
const userMap = new Map(userEntries);
|
|
399
|
+
const oppIndexMap = new Map(state.opportunities.map((opp, idx) => [opp.id, idx]));
|
|
400
|
+
const agentTimingsAccum = [];
|
|
401
|
+
for (let i = 0; i < opportunities.length; i += PRESENTATION_CONCURRENCY) {
|
|
402
|
+
const chunk = opportunities.slice(i, i + PRESENTATION_CONCURRENCY);
|
|
403
|
+
const chunkCards = await Promise.all(chunk.map(async (opportunity, offset) => {
|
|
404
|
+
const cardIndex = oppIndexMap.get(opportunity.id) ?? (i + offset);
|
|
405
|
+
const viewerActor = opportunity.actors.find((a) => a.userId === state.userId);
|
|
406
|
+
const viewerRole = viewerActor?.role ?? 'party';
|
|
407
|
+
const isIntroducer = viewerRole === 'introducer';
|
|
408
|
+
const preferredActor = pickDisplayCounterpartActor(opportunity, state.userId)
|
|
409
|
+
?? opportunity.actors.find((a) => a.userId !== state.userId && a.role !== 'introducer');
|
|
410
|
+
const actorWithProfile = opportunity.actors.find((a) => a.userId !== state.userId && a.role !== 'introducer' && !!userMap.get(a.userId));
|
|
411
|
+
const introducer = opportunity.actors.find((a) => a.role === 'introducer');
|
|
412
|
+
let otherActor = (preferredActor && userMap.get(preferredActor.userId))
|
|
413
|
+
? preferredActor
|
|
414
|
+
: (actorWithProfile ?? preferredActor);
|
|
415
|
+
// When the only other participant is the introducer (no separate party), use introducer as display counterpart so the card shows a name instead of "Unknown"
|
|
416
|
+
if (!otherActor && introducer && introducer.userId !== state.userId && introducer.userId) {
|
|
417
|
+
otherActor = { userId: introducer.userId, role: introducer.role };
|
|
362
418
|
}
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
419
|
+
const otherUser = otherActor ? userMap.get(otherActor.userId) ?? null : null;
|
|
420
|
+
const introducerCounterparts = opportunity.actors.filter((a) => a.userId !== state.userId && a.role !== 'introducer');
|
|
421
|
+
// Deduplicate by userId — actors array can contain multiple rows per user
|
|
422
|
+
// (e.g. from different intents), which would produce repeated names.
|
|
423
|
+
const uniqueCounterpartIds = [...new Set(introducerCounterparts.map((a) => a.userId))];
|
|
424
|
+
const participantNames = uniqueCounterpartIds
|
|
425
|
+
.map((uid) => userMap.get(uid)?.name ?? 'Unknown')
|
|
426
|
+
.sort();
|
|
427
|
+
// When secondPartyData will be present (2+ counterparts), use single counterpart name
|
|
428
|
+
// because the frontend arrow layout renders "card.name → secondParty.name".
|
|
429
|
+
// Using the joined "A ↔ B" format here would produce redundant "A ↔ B → B".
|
|
430
|
+
// Only use the joined format when there is a single counterpart (no arrow layout).
|
|
431
|
+
const willHaveSecondParty = isIntroducer && uniqueCounterpartIds.length > 1;
|
|
432
|
+
let userName = isIntroducer && participantNames.length > 0 && !willHaveSecondParty
|
|
433
|
+
? participantNames.join(' ↔ ')
|
|
434
|
+
: (otherUser?.name ?? 'Unknown');
|
|
435
|
+
// Fallback to profile identity name when users.name is missing (e.g. profile has display name, users row does not)
|
|
436
|
+
if ((userName === 'Unknown' || !userName?.trim()) && otherActor?.userId && db.getProfile) {
|
|
437
|
+
const profile = await db.getProfile(otherActor.userId).catch((err) => {
|
|
438
|
+
logger.debug('getProfile fallback failed', { otherActorUserId: otherActor.userId, error: err });
|
|
439
|
+
return null;
|
|
440
|
+
});
|
|
441
|
+
const profileName = profile?.identity?.name?.trim();
|
|
442
|
+
if (profileName)
|
|
443
|
+
userName = profileName;
|
|
371
444
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
445
|
+
// Unresolvable display counterpart (deleted user: no users row, no
|
|
446
|
+
// profile fallback). Drop the card entirely instead of rendering an
|
|
447
|
+
// "Unknown" placeholder: such cards are unusable, excluded from the
|
|
448
|
+
// deps.presenter cache (see cachePresenterResults), and would otherwise
|
|
449
|
+
// trigger a fresh deps.presenter LLM call on every request — a permanent
|
|
450
|
+
// cache miss that keeps the whole radar slow (~9s per load).
|
|
451
|
+
if (userName === 'Unknown' || !userName?.trim()) {
|
|
452
|
+
logger.verbose('[RadarGraph:generateCardText] dropping card with unresolvable counterpart', {
|
|
453
|
+
opportunityId: opportunity.id,
|
|
454
|
+
otherActorUserId: otherActor?.userId,
|
|
455
|
+
});
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
const userAvatar = otherUser?.avatar ?? null;
|
|
459
|
+
// Shared sanitization standard (UUID strip, viewer-centric rewrite,
|
|
460
|
+
// boundary truncation) — raw reasoning must never render verbatim.
|
|
461
|
+
const reasoningSnippet = safeFallbackSummary(typeof opportunity.interpretation?.reasoning === 'string'
|
|
462
|
+
? opportunity.interpretation.reasoning
|
|
463
|
+
: '', {
|
|
464
|
+
counterpartName: userName !== 'Unknown' ? userName : undefined,
|
|
465
|
+
maxChars: MAX_REASONING_SNIPPET_LENGTH,
|
|
466
|
+
emptyText: 'A promising connection.',
|
|
467
|
+
});
|
|
468
|
+
// Build secondParty for introducer arrow layout (the party that isn't the display counterpart)
|
|
469
|
+
let secondPartyData;
|
|
470
|
+
if (isIntroducer && introducerCounterparts.length > 1 && otherActor) {
|
|
471
|
+
const secondActor = introducerCounterparts.find((a) => a.userId !== otherActor.userId);
|
|
472
|
+
if (secondActor) {
|
|
473
|
+
const secondUser = userMap.get(secondActor.userId) ?? null;
|
|
474
|
+
secondPartyData = {
|
|
475
|
+
name: secondUser?.name ?? 'Unknown',
|
|
476
|
+
avatar: secondUser?.avatar ?? null,
|
|
477
|
+
userId: secondActor.userId,
|
|
478
|
+
};
|
|
379
479
|
}
|
|
380
|
-
}));
|
|
381
|
-
const userMap = new Map(userEntries);
|
|
382
|
-
const oppIndexMap = new Map(state.opportunities.map((opp, idx) => [opp.id, idx]));
|
|
383
|
-
const agentTimingsAccum = [];
|
|
384
|
-
for (let i = 0; i < opportunities.length; i += PRESENTATION_CONCURRENCY) {
|
|
385
|
-
const chunk = opportunities.slice(i, i + PRESENTATION_CONCURRENCY);
|
|
386
|
-
const chunkCards = await Promise.all(chunk.map(async (opportunity, offset) => {
|
|
387
|
-
const cardIndex = oppIndexMap.get(opportunity.id) ?? (i + offset);
|
|
388
|
-
const viewerActor = opportunity.actors.find((a) => a.userId === state.userId);
|
|
389
|
-
const viewerRole = viewerActor?.role ?? 'party';
|
|
390
|
-
const isIntroducer = viewerRole === 'introducer';
|
|
391
|
-
const preferredActor = pickDisplayCounterpartActor(opportunity, state.userId)
|
|
392
|
-
?? opportunity.actors.find((a) => a.userId !== state.userId && a.role !== 'introducer');
|
|
393
|
-
const actorWithProfile = opportunity.actors.find((a) => a.userId !== state.userId && a.role !== 'introducer' && !!userMap.get(a.userId));
|
|
394
|
-
const introducer = opportunity.actors.find((a) => a.role === 'introducer');
|
|
395
|
-
let otherActor = (preferredActor && userMap.get(preferredActor.userId))
|
|
396
|
-
? preferredActor
|
|
397
|
-
: (actorWithProfile ?? preferredActor);
|
|
398
|
-
// When the only other participant is the introducer (no separate party), use introducer as display counterpart so the card shows a name instead of "Unknown"
|
|
399
|
-
if (!otherActor && introducer && introducer.userId !== state.userId && introducer.userId) {
|
|
400
|
-
otherActor = { userId: introducer.userId, role: introducer.role };
|
|
401
|
-
}
|
|
402
|
-
const otherUser = otherActor ? userMap.get(otherActor.userId) ?? null : null;
|
|
403
|
-
const introducerCounterparts = opportunity.actors.filter((a) => a.userId !== state.userId && a.role !== 'introducer');
|
|
404
|
-
// Deduplicate by userId — actors array can contain multiple rows per user
|
|
405
|
-
// (e.g. from different intents), which would produce repeated names.
|
|
406
|
-
const uniqueCounterpartIds = [...new Set(introducerCounterparts.map((a) => a.userId))];
|
|
407
|
-
const participantNames = uniqueCounterpartIds
|
|
408
|
-
.map((uid) => userMap.get(uid)?.name ?? 'Unknown')
|
|
409
|
-
.sort();
|
|
410
|
-
// When secondPartyData will be present (2+ counterparts), use single counterpart name
|
|
411
|
-
// because the frontend arrow layout renders "card.name → secondParty.name".
|
|
412
|
-
// Using the joined "A ↔ B" format here would produce redundant "A ↔ B → B".
|
|
413
|
-
// Only use the joined format when there is a single counterpart (no arrow layout).
|
|
414
|
-
const willHaveSecondParty = isIntroducer && uniqueCounterpartIds.length > 1;
|
|
415
|
-
let userName = isIntroducer && participantNames.length > 0 && !willHaveSecondParty
|
|
416
|
-
? participantNames.join(' ↔ ')
|
|
417
|
-
: (otherUser?.name ?? 'Unknown');
|
|
418
|
-
// Fallback to profile identity name when users.name is missing (e.g. profile has display name, users row does not)
|
|
419
|
-
if ((userName === 'Unknown' || !userName?.trim()) && otherActor?.userId && db.getProfile) {
|
|
420
|
-
const profile = await db.getProfile(otherActor.userId).catch((err) => {
|
|
421
|
-
logger.debug('getProfile fallback failed', { otherActorUserId: otherActor.userId, error: err });
|
|
422
|
-
return null;
|
|
423
|
-
});
|
|
424
|
-
const profileName = profile?.identity?.name?.trim();
|
|
425
|
-
if (profileName)
|
|
426
|
-
userName = profileName;
|
|
427
|
-
}
|
|
428
|
-
// Unresolvable display counterpart (deleted user: no users row, no
|
|
429
|
-
// profile fallback). Drop the card entirely instead of rendering an
|
|
430
|
-
// "Unknown" placeholder: such cards are unusable, excluded from the
|
|
431
|
-
// presenter cache (see cachePresenterResults), and would otherwise
|
|
432
|
-
// trigger a fresh presenter LLM call on every request — a permanent
|
|
433
|
-
// cache miss that keeps the whole radar slow (~9s per load).
|
|
434
|
-
if (userName === 'Unknown' || !userName?.trim()) {
|
|
435
|
-
logger.verbose('[RadarGraph:generateCardText] dropping card with unresolvable counterpart', {
|
|
436
|
-
opportunityId: opportunity.id,
|
|
437
|
-
otherActorUserId: otherActor?.userId,
|
|
438
|
-
});
|
|
439
|
-
return null;
|
|
440
|
-
}
|
|
441
|
-
const userAvatar = otherUser?.avatar ?? null;
|
|
442
|
-
// Shared sanitization standard (UUID strip, viewer-centric rewrite,
|
|
443
|
-
// boundary truncation) — raw reasoning must never render verbatim.
|
|
444
|
-
const reasoningSnippet = safeFallbackSummary(typeof opportunity.interpretation?.reasoning === 'string'
|
|
445
|
-
? opportunity.interpretation.reasoning
|
|
446
|
-
: '', {
|
|
447
|
-
counterpartName: userName !== 'Unknown' ? userName : undefined,
|
|
448
|
-
maxChars: MAX_REASONING_SNIPPET_LENGTH,
|
|
449
|
-
emptyText: 'A promising connection.',
|
|
450
|
-
});
|
|
451
|
-
// Build secondParty for introducer arrow layout (the party that isn't the display counterpart)
|
|
452
|
-
let secondPartyData;
|
|
453
|
-
if (isIntroducer && introducerCounterparts.length > 1 && otherActor) {
|
|
454
|
-
const secondActor = introducerCounterparts.find((a) => a.userId !== otherActor.userId);
|
|
455
|
-
if (secondActor) {
|
|
456
|
-
const secondUser = userMap.get(secondActor.userId) ?? null;
|
|
457
|
-
secondPartyData = {
|
|
458
|
-
name: secondUser?.name ?? 'Unknown',
|
|
459
|
-
avatar: secondUser?.avatar ?? null,
|
|
460
|
-
userId: secondActor.userId,
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
const isCounterpartGhost = otherUser?.isGhost ?? false;
|
|
465
|
-
// Skeleton presentation: return an identity-only card without the
|
|
466
|
-
// presenter LLM or negotiation-context load. Name resolution and
|
|
467
|
-
// the unresolvable-counterpart drop above still apply, so the card
|
|
468
|
-
// set matches what the follow-up full request will return.
|
|
469
|
-
if (state.presentation === 'skeleton') {
|
|
470
|
-
return {
|
|
471
|
-
opportunityId: opportunity.id,
|
|
472
|
-
status: opportunity.status,
|
|
473
|
-
userId: otherActor?.userId ?? '',
|
|
474
|
-
name: userName,
|
|
475
|
-
avatar: userAvatar,
|
|
476
|
-
mainText: '',
|
|
477
|
-
cta: '',
|
|
478
|
-
primaryActionLabel: getPrimaryActionLabel(viewerRole),
|
|
479
|
-
secondaryActionLabel: SECONDARY_ACTION_LABEL,
|
|
480
|
-
mutualIntentsLabel: isIntroducer ? 'Connector match' : 'Shared interests',
|
|
481
|
-
viewerRole,
|
|
482
|
-
isGhost: isCounterpartGhost,
|
|
483
|
-
...(secondPartyData ? { secondParty: secondPartyData } : {}),
|
|
484
|
-
presentationPending: true,
|
|
485
|
-
_cardIndex: cardIndex,
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
const isPendingIntroducerFallback = isIntroducer && opportunity.status !== 'latent';
|
|
489
|
-
const fallbackCard = () => ({
|
|
490
|
-
opportunityId: opportunity.id,
|
|
491
|
-
status: opportunity.status,
|
|
492
|
-
userId: otherActor?.userId ?? '',
|
|
493
|
-
name: userName,
|
|
494
|
-
avatar: userAvatar,
|
|
495
|
-
mainText: reasoningSnippet,
|
|
496
|
-
cta: isIntroducer
|
|
497
|
-
? (isPendingIntroducerFallback ? 'Share this introduction to get things started.' : 'Take a look and decide if this is a good match.')
|
|
498
|
-
: 'Take a look and decide whether to reach out.',
|
|
499
|
-
primaryActionLabel: getPrimaryActionLabel(viewerRole),
|
|
500
|
-
secondaryActionLabel: SECONDARY_ACTION_LABEL,
|
|
501
|
-
mutualIntentsLabel: isIntroducer ? 'Connector match' : 'Shared interests',
|
|
502
|
-
narratorChip: isIntroducer
|
|
503
|
-
? { name: 'You', text: 'Worth a look.', userId: state.userId }
|
|
504
|
-
: { name: 'Index', text: 'Worth a look.' },
|
|
505
|
-
viewerRole,
|
|
506
|
-
isGhost: isCounterpartGhost,
|
|
507
|
-
...(secondPartyData ? { secondParty: secondPartyData } : {}),
|
|
508
|
-
_presentationFallback: true,
|
|
509
|
-
_cardIndex: cardIndex,
|
|
510
|
-
});
|
|
511
|
-
try {
|
|
512
|
-
const [ctx, negotiationContext] = await Promise.all([
|
|
513
|
-
gatherPresenterContext(db, opportunity, state.userId, otherActor?.userId),
|
|
514
|
-
loadNegotiationContext(db, opportunity.id, opportunity.status),
|
|
515
|
-
]);
|
|
516
|
-
const presenterInput = {
|
|
517
|
-
...ctx,
|
|
518
|
-
mutualIntentCount: undefined,
|
|
519
|
-
opportunityStatus: opportunity.status,
|
|
520
|
-
...(negotiationContext ? { negotiationContext } : {}),
|
|
521
|
-
};
|
|
522
|
-
const _traceEmitterPresenter = requestContext.getStore()?.traceEmitter;
|
|
523
|
-
const presenterStart = Date.now();
|
|
524
|
-
_traceEmitterPresenter?.({ type: "agent_start", name: "opportunity-presenter" });
|
|
525
|
-
const presentation = await presenter.presentCard(presenterInput);
|
|
526
|
-
const _presenterDuration = Date.now() - presenterStart;
|
|
527
|
-
agentTimingsAccum.push({ name: 'opportunity.presenter', durationMs: _presenterDuration });
|
|
528
|
-
_traceEmitterPresenter?.({ type: "agent_end", name: "opportunity-presenter", durationMs: _presenterDuration, summary: `Presented: ${userName}` });
|
|
529
|
-
if (presentation.isFallback) {
|
|
530
|
-
return fallbackCard();
|
|
531
|
-
}
|
|
532
|
-
let narratorChip;
|
|
533
|
-
// Only show a person as narrator when they are the introducer and not the display counterpart
|
|
534
|
-
// (bad data can have same user as introducer and party, e.g. "Amina introduced you to Amina")
|
|
535
|
-
const introducerIsCounterpart = introducer && otherActor && introducer.userId === otherActor.userId;
|
|
536
|
-
if (introducer && introducer.userId !== state.userId && !introducerIsCounterpart) {
|
|
537
|
-
const introUser = userMap.get(introducer.userId) ?? null;
|
|
538
|
-
const narratorName = introUser?.name ?? 'Someone';
|
|
539
|
-
narratorChip = {
|
|
540
|
-
name: narratorName,
|
|
541
|
-
text: stripLeadingNarratorName(presentation.narratorRemark, narratorName),
|
|
542
|
-
avatar: introUser?.avatar ?? null,
|
|
543
|
-
userId: introducer.userId,
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
else if (introducer?.userId === state.userId) {
|
|
547
|
-
narratorChip = { name: 'You', text: presentation.narratorRemark, userId: state.userId };
|
|
548
|
-
}
|
|
549
|
-
else {
|
|
550
|
-
narratorChip = { name: 'Index', text: presentation.narratorRemark };
|
|
551
|
-
}
|
|
552
|
-
return {
|
|
553
|
-
opportunityId: opportunity.id,
|
|
554
|
-
status: opportunity.status,
|
|
555
|
-
userId: otherActor?.userId ?? '',
|
|
556
|
-
name: userName,
|
|
557
|
-
avatar: userAvatar,
|
|
558
|
-
mainText: presentation.personalizedSummary,
|
|
559
|
-
cta: presentation.suggestedAction,
|
|
560
|
-
headline: presentation.headline,
|
|
561
|
-
primaryActionLabel: getPrimaryActionLabel(viewerRole),
|
|
562
|
-
secondaryActionLabel: SECONDARY_ACTION_LABEL,
|
|
563
|
-
mutualIntentsLabel: presentation.mutualIntentsLabel,
|
|
564
|
-
narratorChip,
|
|
565
|
-
viewerRole,
|
|
566
|
-
isGhost: isCounterpartGhost,
|
|
567
|
-
...(secondPartyData ? { secondParty: secondPartyData } : {}),
|
|
568
|
-
_cardIndex: cardIndex,
|
|
569
|
-
};
|
|
570
|
-
}
|
|
571
|
-
catch (e) {
|
|
572
|
-
logger.warn('RadarGraph presenter failed for opportunity', { opportunityId: opportunity.id, error: e });
|
|
573
|
-
return fallbackCard();
|
|
574
|
-
}
|
|
575
|
-
}));
|
|
576
|
-
cards.push(...chunkCards.filter((c) => c !== null));
|
|
577
480
|
}
|
|
578
|
-
|
|
579
|
-
return
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
?
|
|
595
|
-
|
|
596
|
-
|
|
481
|
+
const isCounterpartGhost = otherUser?.isGhost ?? false;
|
|
482
|
+
// Skeleton presentation: return an identity-only card without the
|
|
483
|
+
// deps.presenter LLM or negotiation-context load. Name resolution and
|
|
484
|
+
// the unresolvable-counterpart drop above still apply, so the card
|
|
485
|
+
// set matches what the follow-up full request will return.
|
|
486
|
+
if (state.presentation === 'skeleton') {
|
|
487
|
+
return {
|
|
488
|
+
opportunityId: opportunity.id,
|
|
489
|
+
status: opportunity.status,
|
|
490
|
+
userId: otherActor?.userId ?? '',
|
|
491
|
+
name: userName,
|
|
492
|
+
avatar: userAvatar,
|
|
493
|
+
mainText: '',
|
|
494
|
+
cta: '',
|
|
495
|
+
primaryActionLabel: getPrimaryActionLabel(viewerRole),
|
|
496
|
+
secondaryActionLabel: SECONDARY_ACTION_LABEL,
|
|
497
|
+
mutualIntentsLabel: isIntroducer ? 'Connector match' : 'Shared interests',
|
|
498
|
+
viewerRole,
|
|
499
|
+
isGhost: isCounterpartGhost,
|
|
500
|
+
...(secondPartyData ? { secondParty: secondPartyData } : {}),
|
|
501
|
+
presentationPending: true,
|
|
502
|
+
_cardIndex: cardIndex,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const isPendingIntroducerFallback = isIntroducer && opportunity.status !== 'latent';
|
|
506
|
+
const fallbackCard = () => ({
|
|
507
|
+
opportunityId: opportunity.id,
|
|
508
|
+
status: opportunity.status,
|
|
509
|
+
userId: otherActor?.userId ?? '',
|
|
510
|
+
name: userName,
|
|
511
|
+
avatar: userAvatar,
|
|
512
|
+
mainText: reasoningSnippet,
|
|
513
|
+
cta: isIntroducer
|
|
514
|
+
? (isPendingIntroducerFallback ? 'Share this introduction to get things started.' : 'Take a look and decide if this is a good match.')
|
|
515
|
+
: 'Take a look and decide whether to reach out.',
|
|
516
|
+
primaryActionLabel: getPrimaryActionLabel(viewerRole),
|
|
517
|
+
secondaryActionLabel: SECONDARY_ACTION_LABEL,
|
|
518
|
+
mutualIntentsLabel: isIntroducer ? 'Connector match' : 'Shared interests',
|
|
519
|
+
narratorChip: isIntroducer
|
|
520
|
+
? { name: 'You', text: 'Worth a look.', userId: state.userId }
|
|
521
|
+
: { name: 'Index', text: 'Worth a look.' },
|
|
522
|
+
viewerRole,
|
|
523
|
+
isGhost: isCounterpartGhost,
|
|
524
|
+
...(secondPartyData ? { secondParty: secondPartyData } : {}),
|
|
525
|
+
_presentationFallback: true,
|
|
526
|
+
_cardIndex: cardIndex,
|
|
597
527
|
});
|
|
598
|
-
// Only cache cards that weren't already from cache
|
|
599
|
-
const newCards = cardsWithAdjustments.filter((card) => !cachedCards.has(card.opportunityId));
|
|
600
|
-
const statusById = new Map(opportunities.map((opp) => [opp.id, opp.status]));
|
|
601
528
|
try {
|
|
602
|
-
await Promise.all(
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
529
|
+
const [ctx, negotiationContext] = await Promise.all([
|
|
530
|
+
gatherPresenterContext(db, opportunity, state.userId, otherActor?.userId),
|
|
531
|
+
loadNegotiationContext(db, opportunity.id, opportunity.status),
|
|
532
|
+
]);
|
|
533
|
+
const presenterInput = {
|
|
534
|
+
...ctx,
|
|
535
|
+
mutualIntentCount: undefined,
|
|
536
|
+
opportunityStatus: opportunity.status,
|
|
537
|
+
...(negotiationContext ? { negotiationContext } : {}),
|
|
538
|
+
};
|
|
539
|
+
const _traceEmitterPresenter = requestContext.getStore()?.traceEmitter;
|
|
540
|
+
const presenterStart = Date.now();
|
|
541
|
+
_traceEmitterPresenter?.({ type: "agent_start", name: "opportunity-presenter" });
|
|
542
|
+
const presentation = await deps.presenter.presentCard(presenterInput);
|
|
543
|
+
const _presenterDuration = Date.now() - presenterStart;
|
|
544
|
+
agentTimingsAccum.push({ name: 'opportunity.presenter', durationMs: _presenterDuration });
|
|
545
|
+
_traceEmitterPresenter?.({ type: "agent_end", name: "opportunity-presenter", durationMs: _presenterDuration, summary: `Presented: ${userName}` });
|
|
546
|
+
if (presentation.isFallback) {
|
|
547
|
+
return fallbackCard();
|
|
548
|
+
}
|
|
549
|
+
let narratorChip;
|
|
550
|
+
// Only show a person as narrator when they are the introducer and not the display counterpart
|
|
551
|
+
// (bad data can have same user as introducer and party, e.g. "Amina introduced you to Amina")
|
|
552
|
+
const introducerIsCounterpart = introducer && otherActor && introducer.userId === otherActor.userId;
|
|
553
|
+
if (introducer && introducer.userId !== state.userId && !introducerIsCounterpart) {
|
|
554
|
+
const introUser = userMap.get(introducer.userId) ?? null;
|
|
555
|
+
const narratorName = introUser?.name ?? 'Someone';
|
|
556
|
+
narratorChip = {
|
|
557
|
+
name: narratorName,
|
|
558
|
+
text: stripLeadingNarratorName(presentation.narratorRemark, narratorName),
|
|
559
|
+
avatar: introUser?.avatar ?? null,
|
|
560
|
+
userId: introducer.userId,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
else if (introducer?.userId === state.userId) {
|
|
564
|
+
narratorChip = { name: 'You', text: presentation.narratorRemark, userId: state.userId };
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
narratorChip = { name: 'Index', text: presentation.narratorRemark };
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
opportunityId: opportunity.id,
|
|
571
|
+
status: opportunity.status,
|
|
572
|
+
userId: otherActor?.userId ?? '',
|
|
573
|
+
name: userName,
|
|
574
|
+
avatar: userAvatar,
|
|
575
|
+
mainText: presentation.personalizedSummary,
|
|
576
|
+
cta: presentation.suggestedAction,
|
|
577
|
+
headline: presentation.headline,
|
|
578
|
+
primaryActionLabel: getPrimaryActionLabel(viewerRole),
|
|
579
|
+
secondaryActionLabel: SECONDARY_ACTION_LABEL,
|
|
580
|
+
mutualIntentsLabel: presentation.mutualIntentsLabel,
|
|
581
|
+
narratorChip,
|
|
582
|
+
viewerRole,
|
|
583
|
+
isGhost: isCounterpartGhost,
|
|
584
|
+
...(secondPartyData ? { secondParty: secondPartyData } : {}),
|
|
585
|
+
_cardIndex: cardIndex,
|
|
586
|
+
};
|
|
610
587
|
}
|
|
611
588
|
catch (e) {
|
|
612
|
-
|
|
589
|
+
logger.warn('RadarGraph presenter failed for opportunity', { opportunityId: opportunity.id, error: e });
|
|
590
|
+
return fallbackCard();
|
|
613
591
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
allCards.sort((a, b) => a._cardIndex - b._cardIndex);
|
|
623
|
-
cachePresenterResultsLog.verbose('', {
|
|
624
|
-
newlyCached: newCards.length,
|
|
625
|
-
totalCards: allCards.length,
|
|
626
|
-
});
|
|
627
|
-
return {
|
|
628
|
-
cards: allCards,
|
|
629
|
-
meta: { totalOpportunities: state.opportunities.length },
|
|
630
|
-
};
|
|
631
|
-
});
|
|
592
|
+
}));
|
|
593
|
+
cards.push(...chunkCards.filter((c) => c !== null));
|
|
594
|
+
}
|
|
595
|
+
generateCardTextLog.verbose('exit', { totalOpportunities: state.opportunities.length });
|
|
596
|
+
return {
|
|
597
|
+
cards,
|
|
598
|
+
agentTimings: agentTimingsAccum,
|
|
599
|
+
meta: { totalOpportunities: state.opportunities.length },
|
|
632
600
|
};
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
export async function cachePresenterResultsNode(state, deps) {
|
|
604
|
+
return timed("RadarGraph.cachePresenterResults", async () => {
|
|
605
|
+
const { cards, cachedCards, userId, opportunities } = state;
|
|
606
|
+
const poolRankingProvenance = getPoolRankingProvenance(state);
|
|
607
|
+
const liveById = new Map(opportunities.map((opportunity) => [opportunity.id, opportunity]));
|
|
608
|
+
const cardsWithAdjustments = cards.map((card) => {
|
|
609
|
+
const opportunity = liveById.get(card.opportunityId);
|
|
610
|
+
const deprioritizedReason = poolRankingProvenance
|
|
611
|
+
? latestPoolDemotionDetail(opportunity?.metadata, poolRankingProvenance)
|
|
612
|
+
: undefined;
|
|
613
|
+
return { ...card, deprioritizedReason };
|
|
614
|
+
});
|
|
615
|
+
// Only cache cards that weren't already from cache
|
|
616
|
+
const newCards = cardsWithAdjustments.filter((card) => !cachedCards.has(card.opportunityId));
|
|
617
|
+
const statusById = new Map(opportunities.map((opp) => [opp.id, opp.status]));
|
|
618
|
+
try {
|
|
619
|
+
await Promise.all(newCards.map((card) => {
|
|
620
|
+
const status = statusById.get(card.opportunityId);
|
|
621
|
+
// Negotiating, skeleton, fallback, and unresolved-name cards are
|
|
622
|
+
// safe for the current response but must not become 24h entries.
|
|
623
|
+
if (!status || !isRadarPresentationCacheable(card, status))
|
|
624
|
+
return Promise.resolve();
|
|
625
|
+
return deps.cache.set(buildRadarCardPresentationCacheKey(card.opportunityId, status, userId), card, { ttl: RADAR_CACHE_TTL });
|
|
626
|
+
}));
|
|
627
|
+
}
|
|
628
|
+
catch (e) {
|
|
629
|
+
cachePresenterResultsLog.warn('cache write failed, continuing', { error: e });
|
|
630
|
+
}
|
|
631
|
+
// Merge cached cards into full card list
|
|
632
|
+
const allCards = [...cardsWithAdjustments];
|
|
633
|
+
for (const [oppId, cachedCard] of cachedCards) {
|
|
634
|
+
if (!cardsWithAdjustments.some((card) => card.opportunityId === oppId)) {
|
|
635
|
+
allCards.push(cachedCard);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// Re-sort by _cardIndex to maintain original ordering
|
|
639
|
+
allCards.sort((a, b) => a._cardIndex - b._cardIndex);
|
|
640
|
+
cachePresenterResultsLog.verbose('', {
|
|
641
|
+
newlyCached: newCards.length,
|
|
642
|
+
totalCards: allCards.length,
|
|
643
|
+
});
|
|
644
|
+
return {
|
|
645
|
+
cards: allCards,
|
|
646
|
+
meta: { totalOpportunities: state.opportunities.length },
|
|
644
647
|
};
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
.addEdge('cachePresenterResults', 'normalizeItems')
|
|
659
|
-
.addEdge('normalizeItems', END);
|
|
660
|
-
return graph.compile();
|
|
661
|
-
}
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
export async function normalizeItemsNode(state) {
|
|
651
|
+
return timed("RadarGraph.normalizeItems", async () => {
|
|
652
|
+
normalizeItemsLog.verbose('entry', { cardsLength: state.cards.length });
|
|
653
|
+
const items = state.cards.map((card) => {
|
|
654
|
+
const { _cardIndex, _presentationFallback, ...rest } = card;
|
|
655
|
+
return rest;
|
|
656
|
+
});
|
|
657
|
+
const meta = { totalOpportunities: state.opportunities.length };
|
|
658
|
+
normalizeItemsLog.verbose('exit', { totalOpportunities: meta.totalOpportunities, totalItems: items.length });
|
|
659
|
+
return { items, meta };
|
|
660
|
+
});
|
|
662
661
|
}
|