@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
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery pipeline, stages 4–5: evaluation and ranking.
|
|
3
|
+
*
|
|
4
|
+
* Evaluation builds an entity bundle from the discoverer plus the top candidates,
|
|
5
|
+
* asks the evaluator to score them, and maps the verdicts back onto graph state.
|
|
6
|
+
*/
|
|
7
|
+
import { OpportunityEvaluator } from './opportunity.evaluator.js';
|
|
8
|
+
import { getModelName } from '../../shared/agent/model.config.js';
|
|
9
|
+
import { timed } from '../../shared/observability/performance.js';
|
|
10
|
+
import { requestContext } from '../../shared/observability/request-context.js';
|
|
11
|
+
import { getAbortSignalConfig } from '../../shared/agent/model-signal.js';
|
|
12
|
+
import { mergeOpportunityEvidence } from '../domain/opportunity.evidence.js';
|
|
13
|
+
import { DISCOVERY_EVALUATOR_MIN_SCORE_DEFAULT } from '../discovery.env.js';
|
|
14
|
+
import { buildEvaluatorEvidenceKey, buildNetworkContexts, evaluationLog, getRejectionCooldownMs, networkMembershipPairKey, rankingLog, REJECTION_COOLDOWN_SIMILARITY_PENALTY, safeOpportunityGraphError } from './opportunity.graph.shared.js';
|
|
15
|
+
/** Batch size for one evaluator call. Larger batches time out. */
|
|
16
|
+
const EVAL_BATCH_SIZE = 25;
|
|
17
|
+
/**
|
|
18
|
+
* Node 3: Evaluation (Entity bundle)
|
|
19
|
+
* Builds entity bundle from source + candidates, invokes entity-bundle evaluator, maps to EvaluatedOpportunity with networkId from entities.
|
|
20
|
+
*/
|
|
21
|
+
export async function evaluationNode(state, deps) {
|
|
22
|
+
return timed("OpportunityGraph.evaluation", async () => {
|
|
23
|
+
const startTime = Date.now();
|
|
24
|
+
evaluationLog.verbose('Starting evaluation', {
|
|
25
|
+
candidatesCount: state.candidates.length,
|
|
26
|
+
});
|
|
27
|
+
if (state.candidates.length === 0) {
|
|
28
|
+
evaluationLog.verbose('No candidates to evaluate');
|
|
29
|
+
return { evaluatedOpportunities: [], agentTimings: [] };
|
|
30
|
+
}
|
|
31
|
+
const sortedCandidates = [...state.candidates].sort((a, b) => b.similarity - a.similarity);
|
|
32
|
+
const dedupedCandidates = dedupeCandidatesByUser(sortedCandidates, state);
|
|
33
|
+
const discoveryUserId = state.onBehalfOfUserId ?? state.userId;
|
|
34
|
+
let eligibleCandidates;
|
|
35
|
+
try {
|
|
36
|
+
eligibleCandidates = await filterToActiveMemberships(dedupedCandidates, discoveryUserId, deps);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
evaluationLog.error('Active network membership recheck failed; skipping evaluation', { error });
|
|
40
|
+
return {
|
|
41
|
+
candidates: [],
|
|
42
|
+
evaluatedOpportunities: [],
|
|
43
|
+
remainingCandidates: [],
|
|
44
|
+
error: 'Failed to validate candidate network memberships.',
|
|
45
|
+
agentTimings: [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (eligibleCandidates.length < dedupedCandidates.length) {
|
|
49
|
+
evaluationLog.info('Removed candidates without active network pairs before evaluation', {
|
|
50
|
+
before: dedupedCandidates.length,
|
|
51
|
+
after: eligibleCandidates.length,
|
|
52
|
+
removed: dedupedCandidates.length - eligibleCandidates.length,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (eligibleCandidates.length === 0) {
|
|
56
|
+
return { candidates: [], evaluatedOpportunities: [], remainingCandidates: [], agentTimings: [] };
|
|
57
|
+
}
|
|
58
|
+
if (dedupedCandidates.length < sortedCandidates.length) {
|
|
59
|
+
evaluationLog.info("Deduped candidates by userId", {
|
|
60
|
+
before: sortedCandidates.length,
|
|
61
|
+
after: dedupedCandidates.length,
|
|
62
|
+
removed: sortedCandidates.length - dedupedCandidates.length,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const eligibleCandidatesAfterCooldown = await applyRejectionCooldown(eligibleCandidates, discoveryUserId, deps);
|
|
66
|
+
const batchToEvaluate = eligibleCandidatesAfterCooldown.slice(0, EVAL_BATCH_SIZE);
|
|
67
|
+
const remaining = eligibleCandidatesAfterCooldown.slice(EVAL_BATCH_SIZE);
|
|
68
|
+
// Early termination: if search was query-driven and no query-sourced candidates remain,
|
|
69
|
+
// clear remaining to prevent pointless pagination through non-query leftovers
|
|
70
|
+
const isQueryDriven = !!state.searchQuery?.trim();
|
|
71
|
+
const queryRemaining = remaining.filter((c) => c.discoverySource === 'query' || c.discoverySource == null);
|
|
72
|
+
const effectiveRemaining = isQueryDriven && queryRemaining.length === 0 ? [] : remaining;
|
|
73
|
+
if (isQueryDriven && remaining.length > 0 && queryRemaining.length === 0) {
|
|
74
|
+
evaluationLog.info("Early termination: no query-sourced candidates remain", {
|
|
75
|
+
droppedCandidates: remaining.length,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (effectiveRemaining.length > 0) {
|
|
79
|
+
evaluationLog.verbose('Batched candidates for evaluation', {
|
|
80
|
+
evaluating: batchToEvaluate.length,
|
|
81
|
+
remaining: effectiveRemaining.length,
|
|
82
|
+
total: sortedCandidates.length,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const agentTimingsAccum = [];
|
|
86
|
+
try {
|
|
87
|
+
const sourceProfile = await deps.database.getProfile(discoveryUserId);
|
|
88
|
+
const sourceEntity = {
|
|
89
|
+
userId: discoveryUserId,
|
|
90
|
+
profile: {
|
|
91
|
+
name: sourceProfile?.identity?.name,
|
|
92
|
+
bio: sourceProfile?.identity?.bio,
|
|
93
|
+
location: sourceProfile?.identity?.location,
|
|
94
|
+
context: sourceProfile?.context,
|
|
95
|
+
},
|
|
96
|
+
intents: state.indexedIntents.slice(0, 5).map((i) => ({
|
|
97
|
+
intentId: i.intentId,
|
|
98
|
+
payload: i.payload,
|
|
99
|
+
summary: i.summary,
|
|
100
|
+
})),
|
|
101
|
+
networkId: '', // Placeholder — overwritten per-pairing below
|
|
102
|
+
evidenceKey: `${discoveryUserId}::source`,
|
|
103
|
+
ragScore: undefined,
|
|
104
|
+
matchedVia: undefined,
|
|
105
|
+
};
|
|
106
|
+
const candidateEntities = await buildCandidateEntities(batchToEvaluate, deps);
|
|
107
|
+
const userIdToIndexId = new Map();
|
|
108
|
+
const evidenceByEntityKey = new Map();
|
|
109
|
+
const entityKeysByUserId = new Map();
|
|
110
|
+
for (const e of candidateEntities) {
|
|
111
|
+
if (!userIdToIndexId.has(e.userId))
|
|
112
|
+
userIdToIndexId.set(e.userId, e.networkId);
|
|
113
|
+
if (e.evidenceKey) {
|
|
114
|
+
evidenceByEntityKey.set(e.evidenceKey, mergeOpportunityEvidence(evidenceByEntityKey.get(e.evidenceKey), e.evidence));
|
|
115
|
+
entityKeysByUserId.set(e.userId, [...(entityKeysByUserId.get(e.userId) ?? []), e.evidenceKey]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function evidenceForActor(actor) {
|
|
119
|
+
if (actor.evidenceKey)
|
|
120
|
+
return evidenceByEntityKey.get(actor.evidenceKey);
|
|
121
|
+
const keys = entityKeysByUserId.get(actor.userId) ?? [];
|
|
122
|
+
const intentKey = actor.intentId ? keys.find((key) => key.endsWith(`:${actor.intentId}`)) : undefined;
|
|
123
|
+
if (intentKey)
|
|
124
|
+
return evidenceByEntityKey.get(intentKey);
|
|
125
|
+
// Avoid leaking unrelated resource evidence when the evaluator collapsed multiple
|
|
126
|
+
// candidates for the same user into a profile-only actor.
|
|
127
|
+
if (keys.length === 1)
|
|
128
|
+
return evidenceByEntityKey.get(keys[0]);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const minScore = state.targetUserId
|
|
132
|
+
? DISCOVERY_EVALUATOR_MIN_SCORE_DEFAULT
|
|
133
|
+
: deps.evaluatorMinScore;
|
|
134
|
+
const evaluatorSignalConfig = getAbortSignalConfig();
|
|
135
|
+
const evaluator = typeof deps.evaluatorAgent.invokeEntityBundle === 'function'
|
|
136
|
+
? deps.evaluatorAgent
|
|
137
|
+
: new OpportunityEvaluator();
|
|
138
|
+
const runParallel = process.env.RUN_OPPORTUNITY_EVAL_IN_PARALLEL === 'true';
|
|
139
|
+
const networkContexts = await buildNetworkContexts([sourceEntity, ...candidateEntities], deps.database);
|
|
140
|
+
// Declare trace entries early so both parallel and serial paths can push error entries
|
|
141
|
+
const traceEntries = [];
|
|
142
|
+
const pairwiseOpportunities = runParallel
|
|
143
|
+
? await evaluateInParallel({
|
|
144
|
+
evaluator, sourceEntity, candidateEntities, state, discoveryUserId,
|
|
145
|
+
networkContexts, minScore, evaluatorSignalConfig, agentTimingsAccum, traceEntries,
|
|
146
|
+
})
|
|
147
|
+
: splitBundledVerdicts(await evaluateBundled({
|
|
148
|
+
evaluator, sourceEntity, candidateEntities, state, discoveryUserId,
|
|
149
|
+
networkContexts, minScore, evaluatorSignalConfig, agentTimingsAccum,
|
|
150
|
+
}), state, candidateEntities);
|
|
151
|
+
const evaluatedOpportunities = pairwiseOpportunities.map((op) => ({
|
|
152
|
+
reasoning: op.reasoning,
|
|
153
|
+
score: op.score,
|
|
154
|
+
evidence: mergeOpportunityEvidence(...op.actors.map(evidenceForActor)),
|
|
155
|
+
actors: op.actors.map((a) => {
|
|
156
|
+
const isSource = a.userId === discoveryUserId;
|
|
157
|
+
if (isSource) {
|
|
158
|
+
// Source actor inherits the counterpart's networkId (shared match context)
|
|
159
|
+
const counterpart = op.actors.find((other) => other.userId !== a.userId);
|
|
160
|
+
const counterpartIndexId = counterpart
|
|
161
|
+
? userIdToIndexId.get(counterpart.userId) ?? candidateEntities.find((e) => e.userId === counterpart.userId)?.networkId
|
|
162
|
+
: undefined;
|
|
163
|
+
return {
|
|
164
|
+
userId: a.userId,
|
|
165
|
+
role: a.role,
|
|
166
|
+
intentId: a.intentId,
|
|
167
|
+
networkId: counterpartIndexId ?? userIdToIndexId.get(a.userId) ?? '',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
userId: a.userId,
|
|
172
|
+
role: a.role,
|
|
173
|
+
intentId: a.intentId,
|
|
174
|
+
networkId: userIdToIndexId.get(a.userId) ?? candidateEntities.find((e) => e.userId === a.userId)?.networkId,
|
|
175
|
+
};
|
|
176
|
+
}),
|
|
177
|
+
}));
|
|
178
|
+
const passed = evaluatedOpportunities.filter((o) => o.score >= minScore);
|
|
179
|
+
evaluationLog.verbose('Evaluation complete', {
|
|
180
|
+
evaluatedCount: evaluatedOpportunities.length,
|
|
181
|
+
passed: passed.length,
|
|
182
|
+
});
|
|
183
|
+
// Threshold filter trace: how many candidates in this batch were above/below similarity threshold
|
|
184
|
+
const aboveThreshold = batchToEvaluate.filter((candidate) => candidate.similarity >= deps.retrievalMinSimilarity).length;
|
|
185
|
+
const belowThreshold = batchToEvaluate.length - aboveThreshold;
|
|
186
|
+
traceEntries.push({
|
|
187
|
+
node: "threshold_filter",
|
|
188
|
+
detail: `${aboveThreshold} above ${deps.retrievalMinSimilarity}, ${belowThreshold} below (batch of ${batchToEvaluate.length})`,
|
|
189
|
+
data: {
|
|
190
|
+
aboveThreshold,
|
|
191
|
+
belowThreshold,
|
|
192
|
+
minScore: deps.retrievalMinSimilarity,
|
|
193
|
+
retrievalMinSimilarity: deps.retrievalMinSimilarity,
|
|
194
|
+
evaluatorMinScore: minScore,
|
|
195
|
+
batchSize: batchToEvaluate.length,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
// Create a map of evaluated candidates by userId for quick lookup.
|
|
199
|
+
// Use discoveryUserId (which accounts for onBehalfOfUserId in introducer flow)
|
|
200
|
+
// rather than state.userId (which is the introducer, not present in pairwise actors).
|
|
201
|
+
const evaluatedByUserId = new Map();
|
|
202
|
+
for (const opp of evaluatedOpportunities) {
|
|
203
|
+
const candidateActor = opp.actors.find(a => a.userId !== discoveryUserId);
|
|
204
|
+
if (candidateActor) {
|
|
205
|
+
evaluatedByUserId.set(candidateActor.userId, { score: opp.score, reasoning: opp.reasoning });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
traceEntries.push({
|
|
209
|
+
node: "evaluation",
|
|
210
|
+
detail: `Evaluated ${candidateEntities.length} candidate(s) → ${passed.length} passed (min score ${minScore})`,
|
|
211
|
+
data: {
|
|
212
|
+
inputCandidates: batchToEvaluate.length,
|
|
213
|
+
returnedFromEvaluator: evaluatedOpportunities.length,
|
|
214
|
+
passedCount: passed.length,
|
|
215
|
+
minScore,
|
|
216
|
+
remaining: effectiveRemaining.length,
|
|
217
|
+
batchNumber: 1,
|
|
218
|
+
durationMs: Date.now() - startTime,
|
|
219
|
+
model: getModelName("opportunityEvaluator"),
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
// Individual candidate entries - show ALL candidates that went to evaluator
|
|
223
|
+
for (const entity of candidateEntities) {
|
|
224
|
+
const candidateName = entity.profile?.name || entity.userId.slice(0, 8);
|
|
225
|
+
const evaluated = evaluatedByUserId.get(entity.userId);
|
|
226
|
+
const score = evaluated?.score;
|
|
227
|
+
const didPass = score !== undefined && score >= minScore;
|
|
228
|
+
const status = score !== undefined
|
|
229
|
+
? (didPass ? '✓ passed' : `✗ score ${score}`)
|
|
230
|
+
: '✗ not scored';
|
|
231
|
+
traceEntries.push({
|
|
232
|
+
node: "candidate",
|
|
233
|
+
detail: `${candidateName}: ${status}`,
|
|
234
|
+
data: {
|
|
235
|
+
userId: entity.userId,
|
|
236
|
+
name: candidateName,
|
|
237
|
+
bio: entity.profile?.bio,
|
|
238
|
+
score: score,
|
|
239
|
+
passed: didPass,
|
|
240
|
+
reasoning: evaluated?.reasoning || 'No evaluation returned for this candidate',
|
|
241
|
+
matchedVia: entity.matchedVia,
|
|
242
|
+
ragScore: entity.ragScore,
|
|
243
|
+
model: getModelName("opportunityEvaluator"),
|
|
244
|
+
intents: entity.intents?.map((i) => ({
|
|
245
|
+
intentId: i.intentId,
|
|
246
|
+
summary: (i.summary || i.payload || '').slice(0, 100),
|
|
247
|
+
})),
|
|
248
|
+
profile: entity.profile ? {
|
|
249
|
+
name: entity.profile.name,
|
|
250
|
+
location: entity.profile.location,
|
|
251
|
+
} : undefined,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
candidates: eligibleCandidates,
|
|
257
|
+
// Only pass opportunities that passed the threshold to downstream nodes
|
|
258
|
+
evaluatedOpportunities: evaluatedOpportunities.filter((o) => o.score >= minScore),
|
|
259
|
+
remainingCandidates: effectiveRemaining,
|
|
260
|
+
trace: traceEntries,
|
|
261
|
+
agentTimings: agentTimingsAccum,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
const errMsg = safeOpportunityGraphError(error);
|
|
266
|
+
evaluationLog.error('Failed', { error: errMsg });
|
|
267
|
+
return {
|
|
268
|
+
evaluatedOpportunities: [],
|
|
269
|
+
error: 'Failed to evaluate candidates.',
|
|
270
|
+
trace: [{
|
|
271
|
+
node: "evaluation_fatal",
|
|
272
|
+
detail: `Evaluation failed: ${errMsg}`,
|
|
273
|
+
data: {
|
|
274
|
+
error: errMsg,
|
|
275
|
+
candidateCount: state.candidates?.length ?? 0,
|
|
276
|
+
durationMs: Date.now() - startTime,
|
|
277
|
+
},
|
|
278
|
+
}],
|
|
279
|
+
agentTimings: agentTimingsAccum,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/** Dedup by userId — when same similarity, prefer index with highest relevancyScore. */
|
|
285
|
+
function dedupeCandidatesByUser(sortedCandidates, state) {
|
|
286
|
+
const bestByUser = new Map();
|
|
287
|
+
for (const c of sortedCandidates) {
|
|
288
|
+
const existing = bestByUser.get(c.candidateUserId);
|
|
289
|
+
if (!existing) {
|
|
290
|
+
bestByUser.set(c.candidateUserId, c);
|
|
291
|
+
}
|
|
292
|
+
else if (c.similarity > existing.similarity) {
|
|
293
|
+
bestByUser.set(c.candidateUserId, c);
|
|
294
|
+
}
|
|
295
|
+
else if (c.similarity === existing.similarity) {
|
|
296
|
+
// Tie-break: prefer index with higher relevancy score
|
|
297
|
+
const cScore = state.indexRelevancyScores[c.networkId] ?? 0;
|
|
298
|
+
const existingScore = state.indexRelevancyScores[existing.networkId] ?? 0;
|
|
299
|
+
if (cScore > existingScore) {
|
|
300
|
+
bestByUser.set(c.candidateUserId, c);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const deduped = Array.from(bestByUser.values());
|
|
305
|
+
// Re-sort by similarity descending (Map iteration order doesn't guarantee sort)
|
|
306
|
+
deduped.sort((a, b) => b.similarity - a.similarity);
|
|
307
|
+
return deduped;
|
|
308
|
+
}
|
|
309
|
+
/** Both sides of every pairing must still hold an active membership in the shared network. */
|
|
310
|
+
async function filterToActiveMemberships(dedupedCandidates, discoveryUserId, deps) {
|
|
311
|
+
const requestedPairs = dedupedCandidates.flatMap((candidate) => [
|
|
312
|
+
{ userId: discoveryUserId, networkId: candidate.networkId },
|
|
313
|
+
{ userId: candidate.candidateUserId, networkId: candidate.networkId },
|
|
314
|
+
]);
|
|
315
|
+
const activePairs = await deps.database.getActiveNetworkMembershipPairs(requestedPairs);
|
|
316
|
+
const activePairKeys = new Set(activePairs.map((pair) => networkMembershipPairKey(pair.userId, pair.networkId)));
|
|
317
|
+
return dedupedCandidates.filter((candidate) => activePairKeys.has(networkMembershipPairKey(discoveryUserId, candidate.networkId))
|
|
318
|
+
&& activePairKeys.has(networkMembershipPairKey(candidate.candidateUserId, candidate.networkId)));
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* IND-567: Rejection cool-down penalty.
|
|
322
|
+
*
|
|
323
|
+
* Candidates with a recently rejected or stalled opportunity receive a
|
|
324
|
+
* similarity penalty so they are ranked lower (and often pushed out of
|
|
325
|
+
* the evaluation batch). This prevents cross-query re-surfacing of
|
|
326
|
+
* false-positive matches that were already caught downstream.
|
|
327
|
+
* The persist-node dedup is still the hard gate; this is a soft guard
|
|
328
|
+
* that reduces evaluator cost and LLM false-positive rate.
|
|
329
|
+
*/
|
|
330
|
+
async function applyRejectionCooldown(eligibleCandidates, discoveryUserId, deps) {
|
|
331
|
+
const rejectionCooldownIds = new Set();
|
|
332
|
+
if (eligibleCandidates.length > 0
|
|
333
|
+
&& typeof deps.database.getRecentlyRejectedOpportunityCounterparties === 'function') {
|
|
334
|
+
try {
|
|
335
|
+
const cooldownMs = getRejectionCooldownMs();
|
|
336
|
+
const ids = await deps.database.getRecentlyRejectedOpportunityCounterparties(discoveryUserId, eligibleCandidates.map((c) => c.candidateUserId), cooldownMs);
|
|
337
|
+
for (const id of ids)
|
|
338
|
+
rejectionCooldownIds.add(id);
|
|
339
|
+
if (rejectionCooldownIds.size > 0) {
|
|
340
|
+
evaluationLog.info('IND-567 rejection cool-down: applying similarity penalty', {
|
|
341
|
+
affectedCount: rejectionCooldownIds.size,
|
|
342
|
+
cooldownDays: Math.round(cooldownMs / (24 * 60 * 60 * 1000)),
|
|
343
|
+
penalty: REJECTION_COOLDOWN_SIMILARITY_PENALTY,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
evaluationLog.warn('IND-567 rejection cool-down: lookup failed, skipping penalty', {
|
|
349
|
+
error: safeOpportunityGraphError(err),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Apply penalty and re-sort so penalised candidates fall to the back.
|
|
354
|
+
return rejectionCooldownIds.size > 0
|
|
355
|
+
? eligibleCandidates
|
|
356
|
+
.map((c) => rejectionCooldownIds.has(c.candidateUserId)
|
|
357
|
+
? { ...c, similarity: c.similarity * REJECTION_COOLDOWN_SIMILARITY_PENALTY }
|
|
358
|
+
: c)
|
|
359
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
360
|
+
: eligibleCandidates;
|
|
361
|
+
}
|
|
362
|
+
/** Hydrate each candidate into the profile/intent shape the evaluator reads. */
|
|
363
|
+
async function buildCandidateEntities(batchToEvaluate, deps) {
|
|
364
|
+
return Promise.all(batchToEvaluate.map(async (c) => {
|
|
365
|
+
const profile = await deps.database.getProfile(c.candidateUserId);
|
|
366
|
+
let intentPayload = c.candidatePayload;
|
|
367
|
+
let intentSummary = c.candidateSummary;
|
|
368
|
+
let evidence = c.evidence;
|
|
369
|
+
if (c.candidateIntentId != null && (!intentPayload || intentPayload === '')) {
|
|
370
|
+
const intent = await deps.database.getIntent(c.candidateIntentId);
|
|
371
|
+
if (intent) {
|
|
372
|
+
intentPayload = intent.payload;
|
|
373
|
+
intentSummary = intent.summary ?? undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (c.candidatePremiseId != null && (!intentPayload || intentPayload === '')) {
|
|
377
|
+
const premise = await deps.database.getPremise(c.candidatePremiseId);
|
|
378
|
+
if (premise) {
|
|
379
|
+
intentPayload = premise.assertion.text;
|
|
380
|
+
intentSummary = premise.assertion.summary;
|
|
381
|
+
evidence = (c.evidence ?? []).map((item) => item.candidatePremiseId === c.candidatePremiseId
|
|
382
|
+
? { ...item, payload: premise.assertion.text, summary: premise.assertion.summary, assertionText: premise.assertion.text }
|
|
383
|
+
: item);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
userId: c.candidateUserId,
|
|
388
|
+
profile: {
|
|
389
|
+
name: profile?.identity?.name,
|
|
390
|
+
bio: profile?.identity?.bio,
|
|
391
|
+
location: profile?.identity?.location,
|
|
392
|
+
context: profile?.context,
|
|
393
|
+
},
|
|
394
|
+
intents: c.candidateIntentId != null
|
|
395
|
+
? [{ intentId: c.candidateIntentId, payload: intentPayload ?? '', summary: intentSummary }]
|
|
396
|
+
: undefined,
|
|
397
|
+
networkId: c.networkId,
|
|
398
|
+
evidenceKey: buildEvaluatorEvidenceKey(c),
|
|
399
|
+
ragScore: c.similarity * 100,
|
|
400
|
+
matchedVia: c.lens,
|
|
401
|
+
evidence,
|
|
402
|
+
};
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
/** Build the evaluator input for a given entity set. */
|
|
406
|
+
function buildEvaluatorInput(args, entities) {
|
|
407
|
+
return {
|
|
408
|
+
discovererId: args.discoveryUserId,
|
|
409
|
+
entities,
|
|
410
|
+
existingOpportunities: args.state.options.existingOpportunities,
|
|
411
|
+
...(args.state.searchQuery?.trim() ? { discoveryQuery: args.state.searchQuery.trim() } : {}),
|
|
412
|
+
networkContexts: args.networkContexts,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/** Experimental: one LLM call per candidate, all fired in parallel. */
|
|
416
|
+
async function evaluateInParallel(args) {
|
|
417
|
+
const { evaluator, sourceEntity, candidateEntities, minScore, evaluatorSignalConfig, agentTimingsAccum, traceEntries } = args;
|
|
418
|
+
evaluationLog.verbose('Running parallel evaluation', { candidates: candidateEntities.length });
|
|
419
|
+
const parallelErrors = [];
|
|
420
|
+
const parallelResults = await Promise.all(candidateEntities.map((candidateEntity) => {
|
|
421
|
+
const input = buildEvaluatorInput(args, [sourceEntity, candidateEntity]);
|
|
422
|
+
const _evalStart = Date.now();
|
|
423
|
+
const _traceEmitter = requestContext.getStore()?.traceEmitter;
|
|
424
|
+
_traceEmitter?.({ type: "agent_start", name: "opportunity-evaluator" });
|
|
425
|
+
const _candidateName = candidateEntity.profile?.name ?? "Unknown";
|
|
426
|
+
return evaluator.invokeEntityBundle(input, { minScore, returnAll: true, ...evaluatorSignalConfig })
|
|
427
|
+
.then((res) => {
|
|
428
|
+
const _evalDuration = Date.now() - _evalStart;
|
|
429
|
+
agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
|
|
430
|
+
const _topScore = res.length > 0 ? Math.max(...res.map(r => r.score)) : -1;
|
|
431
|
+
const _summary = _topScore < 0 ? `${_candidateName}: no match` : `${_candidateName}: ${_topScore}`;
|
|
432
|
+
_traceEmitter?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: _summary });
|
|
433
|
+
return res;
|
|
434
|
+
})
|
|
435
|
+
.catch((err) => {
|
|
436
|
+
const _evalDuration = Date.now() - _evalStart;
|
|
437
|
+
const _errMsg = safeOpportunityGraphError(err);
|
|
438
|
+
agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
|
|
439
|
+
_traceEmitter?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `${_candidateName}: error — ${_errMsg}` });
|
|
440
|
+
evaluationLog.warn('Parallel eval failed for candidate', {
|
|
441
|
+
candidateUserId: candidateEntity.userId,
|
|
442
|
+
error: _errMsg,
|
|
443
|
+
});
|
|
444
|
+
parallelErrors.push({
|
|
445
|
+
candidateUserId: candidateEntity.userId,
|
|
446
|
+
candidateName: _candidateName,
|
|
447
|
+
error: _errMsg,
|
|
448
|
+
durationMs: _evalDuration,
|
|
449
|
+
});
|
|
450
|
+
return [];
|
|
451
|
+
});
|
|
452
|
+
}));
|
|
453
|
+
// Record trace entries for candidates that failed during parallel evaluation
|
|
454
|
+
if (parallelErrors.length > 0) {
|
|
455
|
+
traceEntries.push({
|
|
456
|
+
node: "evaluation_errors",
|
|
457
|
+
detail: `${parallelErrors.length}/${candidateEntities.length} candidate evaluation(s) failed`,
|
|
458
|
+
data: {
|
|
459
|
+
failedCount: parallelErrors.length,
|
|
460
|
+
totalCandidates: candidateEntities.length,
|
|
461
|
+
errors: parallelErrors.map(e => ({
|
|
462
|
+
candidateUserId: e.candidateUserId,
|
|
463
|
+
candidateName: e.candidateName,
|
|
464
|
+
error: e.error,
|
|
465
|
+
durationMs: e.durationMs,
|
|
466
|
+
})),
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
// Each call is already pairwise (source + 1 candidate) — flatten directly
|
|
471
|
+
return parallelResults.flat();
|
|
472
|
+
}
|
|
473
|
+
/** Default: single bundled LLM call with all candidates. */
|
|
474
|
+
async function evaluateBundled(args) {
|
|
475
|
+
const { evaluator, sourceEntity, candidateEntities, minScore, evaluatorSignalConfig, agentTimingsAccum } = args;
|
|
476
|
+
const input = buildEvaluatorInput(args, [sourceEntity, ...candidateEntities]);
|
|
477
|
+
// Get ALL scored results for tracing (returnAll: true), filter for persistence later
|
|
478
|
+
const _evalStart = Date.now();
|
|
479
|
+
const _traceEmitterSerial = requestContext.getStore()?.traceEmitter;
|
|
480
|
+
_traceEmitterSerial?.({ type: "agent_start", name: "opportunity-evaluator" });
|
|
481
|
+
try {
|
|
482
|
+
const opportunitiesWithActors = await evaluator.invokeEntityBundle(input, { minScore, returnAll: true, ...evaluatorSignalConfig });
|
|
483
|
+
const _evalDuration = Date.now() - _evalStart;
|
|
484
|
+
agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
|
|
485
|
+
_traceEmitterSerial?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `Evaluated ${candidateEntities.length} candidate(s)` });
|
|
486
|
+
return opportunitiesWithActors;
|
|
487
|
+
}
|
|
488
|
+
catch (serialErr) {
|
|
489
|
+
const _evalDuration = Date.now() - _evalStart;
|
|
490
|
+
const _errMsg = safeOpportunityGraphError(serialErr);
|
|
491
|
+
agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
|
|
492
|
+
_traceEmitterSerial?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `error — ${_errMsg}` });
|
|
493
|
+
throw serialErr; // Re-throw for the outer catch to handle
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Split multi-actor evaluator results into pairwise (viewer + candidate).
|
|
498
|
+
* Each persisted discovery opportunity should have exactly 2 actors.
|
|
499
|
+
* When splitting, build per-candidate reasoning from entity data because
|
|
500
|
+
* the shared reasoning typically describes only one candidate.
|
|
501
|
+
*/
|
|
502
|
+
function splitBundledVerdicts(opportunitiesWithActors, state, candidateEntities) {
|
|
503
|
+
const pairwiseOpportunities = [];
|
|
504
|
+
for (const op of opportunitiesWithActors) {
|
|
505
|
+
const pairwiseSourceId = state.onBehalfOfUserId ?? state.userId;
|
|
506
|
+
const nonViewerActors = op.actors.filter(a => a.userId !== pairwiseSourceId);
|
|
507
|
+
if (nonViewerActors.length <= 1) {
|
|
508
|
+
pairwiseOpportunities.push(op);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
evaluationLog.warn('Splitting multi-actor opportunity; LLM returned bundled actors instead of one-per-candidate', {
|
|
512
|
+
actorCount: nonViewerActors.length,
|
|
513
|
+
userIds: nonViewerActors.map(a => a.userId),
|
|
514
|
+
});
|
|
515
|
+
const viewerActor = op.actors.find(a => a.userId === pairwiseSourceId);
|
|
516
|
+
for (const candidate of nonViewerActors) {
|
|
517
|
+
const entity = candidateEntities.find(e => e.userId === candidate.userId);
|
|
518
|
+
const candidateName = entity?.profile?.name ?? '';
|
|
519
|
+
const reasoningLower = op.reasoning.toLowerCase();
|
|
520
|
+
const mentionsCandidate = candidateName !== '' &&
|
|
521
|
+
reasoningLower.includes(candidateName.toLowerCase());
|
|
522
|
+
const mentionsOtherCandidate = nonViewerActors
|
|
523
|
+
.filter((actor) => actor.userId !== candidate.userId)
|
|
524
|
+
.map((actor) => candidateEntities.find((e) => e.userId === actor.userId)?.profile?.name?.toLowerCase())
|
|
525
|
+
.some((name) => name != null && reasoningLower.includes(name));
|
|
526
|
+
let reasoning;
|
|
527
|
+
if (mentionsCandidate && !mentionsOtherCandidate) {
|
|
528
|
+
reasoning = op.reasoning;
|
|
529
|
+
}
|
|
530
|
+
else if (entity?.profile) {
|
|
531
|
+
const p = entity.profile;
|
|
532
|
+
const parts = [p.name, p.bio].filter(Boolean);
|
|
533
|
+
if (p.skills?.length)
|
|
534
|
+
parts.push(`Skills: ${p.skills.join(', ')}`);
|
|
535
|
+
if (p.interests?.length)
|
|
536
|
+
parts.push(`Interests: ${p.interests.join(', ')}`);
|
|
537
|
+
reasoning = parts.join('. ') || op.reasoning;
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
reasoning = op.reasoning;
|
|
541
|
+
}
|
|
542
|
+
pairwiseOpportunities.push({
|
|
543
|
+
reasoning,
|
|
544
|
+
score: op.score,
|
|
545
|
+
actors: [
|
|
546
|
+
viewerActor ?? { userId: pairwiseSourceId, role: 'patient', intentId: null },
|
|
547
|
+
candidate,
|
|
548
|
+
],
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return pairwiseOpportunities;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Node 4: Ranking
|
|
556
|
+
* Sorts evaluated opportunities by score, applies limit, dedupes by actor-set hash.
|
|
557
|
+
*/
|
|
558
|
+
export async function rankingNode(state) {
|
|
559
|
+
return timed("OpportunityGraph.ranking", async () => {
|
|
560
|
+
rankingLog.verbose('Starting ranking', {
|
|
561
|
+
evaluatedCount: state.evaluatedOpportunities.length,
|
|
562
|
+
});
|
|
563
|
+
try {
|
|
564
|
+
const sorted = [...state.evaluatedOpportunities].sort((a, b) => b.score - a.score);
|
|
565
|
+
const limit = state.options.limit ?? 20;
|
|
566
|
+
const ranked = sorted.slice(0, limit);
|
|
567
|
+
const actorSetKey = (opp) => opp.actors
|
|
568
|
+
.map((a) => `${a.userId}:${a.networkId}`)
|
|
569
|
+
.sort()
|
|
570
|
+
.join('|');
|
|
571
|
+
const seen = new Set();
|
|
572
|
+
const deduplicated = ranked.filter((opp) => {
|
|
573
|
+
const key = actorSetKey(opp);
|
|
574
|
+
if (seen.has(key))
|
|
575
|
+
return false;
|
|
576
|
+
seen.add(key);
|
|
577
|
+
return true;
|
|
578
|
+
});
|
|
579
|
+
rankingLog.verbose('Ranking complete', {
|
|
580
|
+
sorted: sorted.length,
|
|
581
|
+
afterLimit: ranked.length,
|
|
582
|
+
afterDedup: deduplicated.length,
|
|
583
|
+
});
|
|
584
|
+
return { evaluatedOpportunities: deduplicated };
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
588
|
+
rankingLog.error('Failed', { error });
|
|
589
|
+
return {
|
|
590
|
+
evaluatedOpportunities: [],
|
|
591
|
+
error: 'Failed to rank opportunities.',
|
|
592
|
+
trace: [{
|
|
593
|
+
node: "ranking_fatal",
|
|
594
|
+
detail: `Ranking failed: ${errMsg}`,
|
|
595
|
+
data: { error: errMsg },
|
|
596
|
+
}],
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
/** Trace summary for {@link rankingNode}. */
|
|
602
|
+
export function rankingTraceSummary(result) {
|
|
603
|
+
const r = result;
|
|
604
|
+
if (r?.error)
|
|
605
|
+
return `error: ${r.error}`;
|
|
606
|
+
const opps = r?.evaluatedOpportunities;
|
|
607
|
+
return opps ? `Ranked ${opps.length} opportunity(ies)` : undefined;
|
|
608
|
+
}
|