@indexnetwork/protocol 0.1.0
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/dist/agents/chat.agent.d.ts +218 -0
- package/dist/agents/chat.agent.d.ts.map +1 -0
- package/dist/agents/chat.agent.js +884 -0
- package/dist/agents/chat.agent.js.map +1 -0
- package/dist/agents/chat.prompt.d.ts +18 -0
- package/dist/agents/chat.prompt.d.ts.map +1 -0
- package/dist/agents/chat.prompt.js +372 -0
- package/dist/agents/chat.prompt.js.map +1 -0
- package/dist/agents/chat.prompt.modules.d.ts +61 -0
- package/dist/agents/chat.prompt.modules.d.ts.map +1 -0
- package/dist/agents/chat.prompt.modules.js +366 -0
- package/dist/agents/chat.prompt.modules.js.map +1 -0
- package/dist/agents/chat.title.generator.d.ts +20 -0
- package/dist/agents/chat.title.generator.d.ts.map +1 -0
- package/dist/agents/chat.title.generator.js +66 -0
- package/dist/agents/chat.title.generator.js.map +1 -0
- package/dist/agents/home.categorizer.d.ts +28 -0
- package/dist/agents/home.categorizer.d.ts.map +1 -0
- package/dist/agents/home.categorizer.js +170 -0
- package/dist/agents/home.categorizer.js.map +1 -0
- package/dist/agents/hyde.generator.d.ts +27 -0
- package/dist/agents/hyde.generator.d.ts.map +1 -0
- package/dist/agents/hyde.generator.js +75 -0
- package/dist/agents/hyde.generator.js.map +1 -0
- package/dist/agents/hyde.strategies.d.ts +17 -0
- package/dist/agents/hyde.strategies.d.ts.map +1 -0
- package/dist/agents/hyde.strategies.js +29 -0
- package/dist/agents/hyde.strategies.js.map +1 -0
- package/dist/agents/intent.clarifier.d.ts +29 -0
- package/dist/agents/intent.clarifier.d.ts.map +1 -0
- package/dist/agents/intent.clarifier.js +186 -0
- package/dist/agents/intent.clarifier.js.map +1 -0
- package/dist/agents/intent.indexer.d.ts +77 -0
- package/dist/agents/intent.indexer.d.ts.map +1 -0
- package/dist/agents/intent.indexer.js +164 -0
- package/dist/agents/intent.indexer.js.map +1 -0
- package/dist/agents/intent.inferrer.d.ts +95 -0
- package/dist/agents/intent.inferrer.d.ts.map +1 -0
- package/dist/agents/intent.inferrer.js +238 -0
- package/dist/agents/intent.inferrer.js.map +1 -0
- package/dist/agents/intent.reconciler.d.ts +106 -0
- package/dist/agents/intent.reconciler.d.ts.map +1 -0
- package/dist/agents/intent.reconciler.js +184 -0
- package/dist/agents/intent.reconciler.js.map +1 -0
- package/dist/agents/intent.verifier.d.ts +97 -0
- package/dist/agents/intent.verifier.d.ts.map +1 -0
- package/dist/agents/intent.verifier.js +234 -0
- package/dist/agents/intent.verifier.js.map +1 -0
- package/dist/agents/invite.generator.d.ts +47 -0
- package/dist/agents/invite.generator.d.ts.map +1 -0
- package/dist/agents/invite.generator.js +56 -0
- package/dist/agents/invite.generator.js.map +1 -0
- package/dist/agents/lens.inferrer.d.ts +37 -0
- package/dist/agents/lens.inferrer.d.ts.map +1 -0
- package/dist/agents/lens.inferrer.js +98 -0
- package/dist/agents/lens.inferrer.js.map +1 -0
- package/dist/agents/model.config.d.ts +120 -0
- package/dist/agents/model.config.d.ts.map +1 -0
- package/dist/agents/model.config.js +76 -0
- package/dist/agents/model.config.js.map +1 -0
- package/dist/agents/negotiation.insights.generator.d.ts +32 -0
- package/dist/agents/negotiation.insights.generator.d.ts.map +1 -0
- package/dist/agents/negotiation.insights.generator.js +105 -0
- package/dist/agents/negotiation.insights.generator.js.map +1 -0
- package/dist/agents/negotiation.proposer.d.ts +26 -0
- package/dist/agents/negotiation.proposer.d.ts.map +1 -0
- package/dist/agents/negotiation.proposer.js +67 -0
- package/dist/agents/negotiation.proposer.js.map +1 -0
- package/dist/agents/negotiation.responder.d.ts +26 -0
- package/dist/agents/negotiation.responder.d.ts.map +1 -0
- package/dist/agents/negotiation.responder.js +71 -0
- package/dist/agents/negotiation.responder.js.map +1 -0
- package/dist/agents/opportunity.evaluator.d.ts +253 -0
- package/dist/agents/opportunity.evaluator.d.ts.map +1 -0
- package/dist/agents/opportunity.evaluator.js +413 -0
- package/dist/agents/opportunity.evaluator.js.map +1 -0
- package/dist/agents/opportunity.presenter.d.ts +115 -0
- package/dist/agents/opportunity.presenter.d.ts.map +1 -0
- package/dist/agents/opportunity.presenter.js +524 -0
- package/dist/agents/opportunity.presenter.js.map +1 -0
- package/dist/agents/profile.generator.d.ts +67 -0
- package/dist/agents/profile.generator.d.ts.map +1 -0
- package/dist/agents/profile.generator.js +97 -0
- package/dist/agents/profile.generator.js.map +1 -0
- package/dist/agents/profile.hyde.generator.d.ts +43 -0
- package/dist/agents/profile.hyde.generator.d.ts.map +1 -0
- package/dist/agents/profile.hyde.generator.js +113 -0
- package/dist/agents/profile.hyde.generator.js.map +1 -0
- package/dist/agents/suggestion.generator.d.ts +24 -0
- package/dist/agents/suggestion.generator.d.ts.map +1 -0
- package/dist/agents/suggestion.generator.js +96 -0
- package/dist/agents/suggestion.generator.js.map +1 -0
- package/dist/graphs/chat.graph.d.ts +312 -0
- package/dist/graphs/chat.graph.d.ts.map +1 -0
- package/dist/graphs/chat.graph.js +267 -0
- package/dist/graphs/chat.graph.js.map +1 -0
- package/dist/graphs/home.graph.d.ts +180 -0
- package/dist/graphs/home.graph.d.ts.map +1 -0
- package/dist/graphs/home.graph.js +598 -0
- package/dist/graphs/home.graph.js.map +1 -0
- package/dist/graphs/hyde.graph.d.ts +110 -0
- package/dist/graphs/hyde.graph.d.ts.map +1 -0
- package/dist/graphs/hyde.graph.js +235 -0
- package/dist/graphs/hyde.graph.js.map +1 -0
- package/dist/graphs/index.graph.d.ts +620 -0
- package/dist/graphs/index.graph.d.ts.map +1 -0
- package/dist/graphs/index.graph.js +226 -0
- package/dist/graphs/index.graph.js.map +1 -0
- package/dist/graphs/index_membership.graph.d.ts +250 -0
- package/dist/graphs/index_membership.graph.d.ts.map +1 -0
- package/dist/graphs/index_membership.graph.js +204 -0
- package/dist/graphs/index_membership.graph.js.map +1 -0
- package/dist/graphs/intent.graph.d.ts +490 -0
- package/dist/graphs/intent.graph.d.ts.map +1 -0
- package/dist/graphs/intent.graph.js +787 -0
- package/dist/graphs/intent.graph.js.map +1 -0
- package/dist/graphs/intent_index.graph.d.ts +396 -0
- package/dist/graphs/intent_index.graph.d.ts.map +1 -0
- package/dist/graphs/intent_index.graph.js +331 -0
- package/dist/graphs/intent_index.graph.js.map +1 -0
- package/dist/graphs/maintenance.graph.d.ts +177 -0
- package/dist/graphs/maintenance.graph.d.ts.map +1 -0
- package/dist/graphs/maintenance.graph.js +173 -0
- package/dist/graphs/maintenance.graph.js.map +1 -0
- package/dist/graphs/negotiation.graph.d.ts +819 -0
- package/dist/graphs/negotiation.graph.d.ts.map +1 -0
- package/dist/graphs/negotiation.graph.js +255 -0
- package/dist/graphs/negotiation.graph.js.map +1 -0
- package/dist/graphs/opportunity.graph.d.ts +1082 -0
- package/dist/graphs/opportunity.graph.d.ts.map +1 -0
- package/dist/graphs/opportunity.graph.js +2534 -0
- package/dist/graphs/opportunity.graph.js.map +1 -0
- package/dist/graphs/profile.graph.d.ts +617 -0
- package/dist/graphs/profile.graph.d.ts.map +1 -0
- package/dist/graphs/profile.graph.js +839 -0
- package/dist/graphs/profile.graph.js.map +1 -0
- package/dist/graphs/tests/chat.graph.mocks.d.ts +104 -0
- package/dist/graphs/tests/chat.graph.mocks.d.ts.map +1 -0
- package/dist/graphs/tests/chat.graph.mocks.js +225 -0
- package/dist/graphs/tests/chat.graph.mocks.js.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/auth.interface.d.ts +15 -0
- package/dist/interfaces/auth.interface.d.ts.map +1 -0
- package/dist/interfaces/auth.interface.js +2 -0
- package/dist/interfaces/auth.interface.js.map +1 -0
- package/dist/interfaces/cache.interface.d.ts +43 -0
- package/dist/interfaces/cache.interface.d.ts.map +1 -0
- package/dist/interfaces/cache.interface.js +6 -0
- package/dist/interfaces/cache.interface.js.map +1 -0
- package/dist/interfaces/chat-session.interface.d.ts +11 -0
- package/dist/interfaces/chat-session.interface.d.ts.map +1 -0
- package/dist/interfaces/chat-session.interface.js +2 -0
- package/dist/interfaces/chat-session.interface.js.map +1 -0
- package/dist/interfaces/contact.interface.d.ts +48 -0
- package/dist/interfaces/contact.interface.d.ts.map +1 -0
- package/dist/interfaces/contact.interface.js +2 -0
- package/dist/interfaces/contact.interface.js.map +1 -0
- package/dist/interfaces/database.interface.d.ts +1495 -0
- package/dist/interfaces/database.interface.d.ts.map +1 -0
- package/dist/interfaces/database.interface.js +2 -0
- package/dist/interfaces/database.interface.js.map +1 -0
- package/dist/interfaces/embedder.interface.d.ts +85 -0
- package/dist/interfaces/embedder.interface.d.ts.map +1 -0
- package/dist/interfaces/embedder.interface.js +5 -0
- package/dist/interfaces/embedder.interface.js.map +1 -0
- package/dist/interfaces/enrichment.interface.d.ts +40 -0
- package/dist/interfaces/enrichment.interface.d.ts.map +1 -0
- package/dist/interfaces/enrichment.interface.js +2 -0
- package/dist/interfaces/enrichment.interface.js.map +1 -0
- package/dist/interfaces/integration.interface.d.ts +91 -0
- package/dist/interfaces/integration.interface.d.ts.map +1 -0
- package/dist/interfaces/integration.interface.js +2 -0
- package/dist/interfaces/integration.interface.js.map +1 -0
- package/dist/interfaces/queue.interface.d.ts +17 -0
- package/dist/interfaces/queue.interface.d.ts.map +1 -0
- package/dist/interfaces/queue.interface.js +5 -0
- package/dist/interfaces/queue.interface.js.map +1 -0
- package/dist/interfaces/scraper.interface.d.ts +31 -0
- package/dist/interfaces/scraper.interface.d.ts.map +1 -0
- package/dist/interfaces/scraper.interface.js +2 -0
- package/dist/interfaces/scraper.interface.js.map +1 -0
- package/dist/interfaces/storage.interface.d.ts +46 -0
- package/dist/interfaces/storage.interface.d.ts.map +1 -0
- package/dist/interfaces/storage.interface.js +6 -0
- package/dist/interfaces/storage.interface.js.map +1 -0
- package/dist/mcp/mcp.server.d.ts +29 -0
- package/dist/mcp/mcp.server.d.ts.map +1 -0
- package/dist/mcp/mcp.server.js +171 -0
- package/dist/mcp/mcp.server.js.map +1 -0
- package/dist/states/chat.state.d.ts +126 -0
- package/dist/states/chat.state.d.ts.map +1 -0
- package/dist/states/chat.state.js +112 -0
- package/dist/states/chat.state.js.map +1 -0
- package/dist/states/home.state.d.ts +100 -0
- package/dist/states/home.state.d.ts.map +1 -0
- package/dist/states/home.state.js +74 -0
- package/dist/states/home.state.js.map +1 -0
- package/dist/states/hyde.state.d.ts +54 -0
- package/dist/states/hyde.state.d.ts.map +1 -0
- package/dist/states/hyde.state.js +66 -0
- package/dist/states/hyde.state.js.map +1 -0
- package/dist/states/index.state.d.ts +179 -0
- package/dist/states/index.state.d.ts.map +1 -0
- package/dist/states/index.state.js +56 -0
- package/dist/states/index.state.js.map +1 -0
- package/dist/states/index_membership.state.d.ts +77 -0
- package/dist/states/index_membership.state.d.ts.map +1 -0
- package/dist/states/index_membership.state.js +43 -0
- package/dist/states/index_membership.state.js.map +1 -0
- package/dist/states/intent.state.d.ts +203 -0
- package/dist/states/intent.state.d.ts.map +1 -0
- package/dist/states/intent.state.js +153 -0
- package/dist/states/intent.state.js.map +1 -0
- package/dist/states/intent_index.state.d.ts +148 -0
- package/dist/states/intent_index.state.d.ts.map +1 -0
- package/dist/states/intent_index.state.js +100 -0
- package/dist/states/intent_index.state.js.map +1 -0
- package/dist/states/maintenance.state.d.ts +36 -0
- package/dist/states/maintenance.state.d.ts.map +1 -0
- package/dist/states/maintenance.state.js +56 -0
- package/dist/states/maintenance.state.js.map +1 -0
- package/dist/states/negotiation.state.d.ts +230 -0
- package/dist/states/negotiation.state.d.ts.map +1 -0
- package/dist/states/negotiation.state.js +82 -0
- package/dist/states/negotiation.state.js.map +1 -0
- package/dist/states/opportunity.state.d.ts +300 -0
- package/dist/states/opportunity.state.d.ts.map +1 -0
- package/dist/states/opportunity.state.js +207 -0
- package/dist/states/opportunity.state.js.map +1 -0
- package/dist/states/profile.state.d.ts +172 -0
- package/dist/states/profile.state.d.ts.map +1 -0
- package/dist/states/profile.state.js +133 -0
- package/dist/states/profile.state.js.map +1 -0
- package/dist/streamers/chat.streamer.d.ts +55 -0
- package/dist/streamers/chat.streamer.d.ts.map +1 -0
- package/dist/streamers/chat.streamer.js +186 -0
- package/dist/streamers/chat.streamer.js.map +1 -0
- package/dist/streamers/index.d.ts +3 -0
- package/dist/streamers/index.d.ts.map +1 -0
- package/dist/streamers/index.js +3 -0
- package/dist/streamers/index.js.map +1 -0
- package/dist/streamers/response.streamer.d.ts +36 -0
- package/dist/streamers/response.streamer.d.ts.map +1 -0
- package/dist/streamers/response.streamer.js +46 -0
- package/dist/streamers/response.streamer.js.map +1 -0
- package/dist/support/chat.utils.d.ts +42 -0
- package/dist/support/chat.utils.d.ts.map +1 -0
- package/dist/support/chat.utils.js +89 -0
- package/dist/support/chat.utils.js.map +1 -0
- package/dist/support/debug-meta.sanitizer.d.ts +18 -0
- package/dist/support/debug-meta.sanitizer.d.ts.map +1 -0
- package/dist/support/debug-meta.sanitizer.js +82 -0
- package/dist/support/debug-meta.sanitizer.js.map +1 -0
- package/dist/support/feed.health.d.ts +32 -0
- package/dist/support/feed.health.d.ts.map +1 -0
- package/dist/support/feed.health.js +76 -0
- package/dist/support/feed.health.js.map +1 -0
- package/dist/support/introducer.discovery.d.ts +78 -0
- package/dist/support/introducer.discovery.d.ts.map +1 -0
- package/dist/support/introducer.discovery.js +101 -0
- package/dist/support/introducer.discovery.js.map +1 -0
- package/dist/support/log.d.ts +65 -0
- package/dist/support/log.d.ts.map +1 -0
- package/dist/support/log.js +76 -0
- package/dist/support/log.js.map +1 -0
- package/dist/support/lucide.icon-catalog.d.ts +22 -0
- package/dist/support/lucide.icon-catalog.d.ts.map +1 -0
- package/dist/support/lucide.icon-catalog.js +101 -0
- package/dist/support/lucide.icon-catalog.js.map +1 -0
- package/dist/support/opportunity.card-text.d.ts +39 -0
- package/dist/support/opportunity.card-text.d.ts.map +1 -0
- package/dist/support/opportunity.card-text.js +333 -0
- package/dist/support/opportunity.card-text.js.map +1 -0
- package/dist/support/opportunity.constants.d.ts +9 -0
- package/dist/support/opportunity.constants.d.ts.map +1 -0
- package/dist/support/opportunity.constants.js +11 -0
- package/dist/support/opportunity.constants.js.map +1 -0
- package/dist/support/opportunity.discover.d.ts +144 -0
- package/dist/support/opportunity.discover.d.ts.map +1 -0
- package/dist/support/opportunity.discover.js +610 -0
- package/dist/support/opportunity.discover.js.map +1 -0
- package/dist/support/opportunity.enricher.d.ts +44 -0
- package/dist/support/opportunity.enricher.d.ts.map +1 -0
- package/dist/support/opportunity.enricher.js +245 -0
- package/dist/support/opportunity.enricher.js.map +1 -0
- package/dist/support/opportunity.persist.d.ts +39 -0
- package/dist/support/opportunity.persist.d.ts.map +1 -0
- package/dist/support/opportunity.persist.js +63 -0
- package/dist/support/opportunity.persist.js.map +1 -0
- package/dist/support/opportunity.presentation.d.ts +21 -0
- package/dist/support/opportunity.presentation.d.ts.map +1 -0
- package/dist/support/opportunity.presentation.js +75 -0
- package/dist/support/opportunity.presentation.js.map +1 -0
- package/dist/support/opportunity.sanitize.d.ts +18 -0
- package/dist/support/opportunity.sanitize.d.ts.map +1 -0
- package/dist/support/opportunity.sanitize.js +89 -0
- package/dist/support/opportunity.sanitize.js.map +1 -0
- package/dist/support/opportunity.utils.d.ts +99 -0
- package/dist/support/opportunity.utils.d.ts.map +1 -0
- package/dist/support/opportunity.utils.js +184 -0
- package/dist/support/opportunity.utils.js.map +1 -0
- package/dist/support/performance.d.ts +19 -0
- package/dist/support/performance.d.ts.map +1 -0
- package/dist/support/performance.js +43 -0
- package/dist/support/performance.js.map +1 -0
- package/dist/support/profile.enrichment-display-name.d.ts +16 -0
- package/dist/support/profile.enrichment-display-name.d.ts.map +1 -0
- package/dist/support/profile.enrichment-display-name.js +22 -0
- package/dist/support/profile.enrichment-display-name.js.map +1 -0
- package/dist/support/protocol.logger.d.ts +22 -0
- package/dist/support/protocol.logger.d.ts.map +1 -0
- package/dist/support/protocol.logger.js +44 -0
- package/dist/support/protocol.logger.js.map +1 -0
- package/dist/support/request-context.d.ts +19 -0
- package/dist/support/request-context.d.ts.map +1 -0
- package/dist/support/request-context.js +7 -0
- package/dist/support/request-context.js.map +1 -0
- package/dist/tools/contact.tools.d.ts +7 -0
- package/dist/tools/contact.tools.d.ts.map +1 -0
- package/dist/tools/contact.tools.js +115 -0
- package/dist/tools/contact.tools.js.map +1 -0
- package/dist/tools/index.d.ts +17 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +140 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/index.tools.d.ts +3 -0
- package/dist/tools/index.tools.d.ts.map +1 -0
- package/dist/tools/index.tools.js +423 -0
- package/dist/tools/index.tools.js.map +1 -0
- package/dist/tools/integration.tools.d.ts +13 -0
- package/dist/tools/integration.tools.d.ts.map +1 -0
- package/dist/tools/integration.tools.js +77 -0
- package/dist/tools/integration.tools.js.map +1 -0
- package/dist/tools/intent.tools.d.ts +3 -0
- package/dist/tools/intent.tools.d.ts.map +1 -0
- package/dist/tools/intent.tools.js +458 -0
- package/dist/tools/intent.tools.js.map +1 -0
- package/dist/tools/opportunity.tools.d.ts +44 -0
- package/dist/tools/opportunity.tools.d.ts.map +1 -0
- package/dist/tools/opportunity.tools.js +814 -0
- package/dist/tools/opportunity.tools.js.map +1 -0
- package/dist/tools/profile.tools.d.ts +3 -0
- package/dist/tools/profile.tools.d.ts.map +1 -0
- package/dist/tools/profile.tools.js +513 -0
- package/dist/tools/profile.tools.js.map +1 -0
- package/dist/tools/tool.helpers.d.ts +225 -0
- package/dist/tools/tool.helpers.d.ts.map +1 -0
- package/dist/tools/tool.helpers.js +172 -0
- package/dist/tools/tool.helpers.js.map +1 -0
- package/dist/tools/tool.registry.d.ts +12 -0
- package/dist/tools/tool.registry.d.ts.map +1 -0
- package/dist/tools/tool.registry.js +62 -0
- package/dist/tools/tool.registry.js.map +1 -0
- package/dist/tools/utility.tools.d.ts +3 -0
- package/dist/tools/utility.tools.d.ts.map +1 -0
- package/dist/tools/utility.tools.js +107 -0
- package/dist/tools/utility.tools.js.map +1 -0
- package/dist/types/chat-streaming.types.d.ts +472 -0
- package/dist/types/chat-streaming.types.d.ts.map +1 -0
- package/dist/types/chat-streaming.types.js +260 -0
- package/dist/types/chat-streaming.types.js.map +1 -0
- package/package.json +32 -0
|
@@ -0,0 +1,2534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opportunity Graph: Linear Multi-Step Workflow for Opportunity Discovery
|
|
3
|
+
*
|
|
4
|
+
* Architecture: Follows intent graph pattern with Annotation-based state.
|
|
5
|
+
* Flow: Prep → Scope → Discovery → Evaluation → Ranking → Persist → END
|
|
6
|
+
*
|
|
7
|
+
* Key Constraints:
|
|
8
|
+
* - Opportunities only between intents sharing the same index
|
|
9
|
+
* - Both intents must have hyde documents for semantic matching
|
|
10
|
+
* - Non-indexed intents cannot participate in discovery
|
|
11
|
+
*
|
|
12
|
+
* Constructor injects Database, Embedder, and compiled HyDE graph.
|
|
13
|
+
*/
|
|
14
|
+
import { StateGraph, START, END } from '@langchain/langgraph';
|
|
15
|
+
import { OpportunityGraphState, } from '../states/opportunity.state.js';
|
|
16
|
+
import { OpportunityEvaluator, } from '../agents/opportunity.evaluator.js';
|
|
17
|
+
import { IntentIndexer } from '../agents/intent.indexer.js';
|
|
18
|
+
import { getModelName } from '../agents/model.config.js';
|
|
19
|
+
import { validateOpportunityActors } from '../support/opportunity.utils.js';
|
|
20
|
+
import { persistOpportunities } from '../support/opportunity.persist.js';
|
|
21
|
+
import { negotiateCandidates } from "./negotiation.graph.js";
|
|
22
|
+
import { protocolLogger, withCallLogging } from '../support/protocol.logger.js';
|
|
23
|
+
import { timed } from '../support/performance.js';
|
|
24
|
+
import { requestContext } from "../support/request-context.js";
|
|
25
|
+
const logger = protocolLogger('OpportunityGraph');
|
|
26
|
+
/**
|
|
27
|
+
* Builds a compact text summary of the discoverer's profile and active intents
|
|
28
|
+
* for use as profileContext in HyDE generation.
|
|
29
|
+
* @param profile - The discoverer's profile data (identity, attributes)
|
|
30
|
+
* @param intents - The discoverer's indexed intents (capped at 5)
|
|
31
|
+
* @returns A context string, or undefined if no meaningful data is available
|
|
32
|
+
*/
|
|
33
|
+
export function buildDiscovererContext(profile, intents) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
if (profile) {
|
|
36
|
+
const identity = profile.identity;
|
|
37
|
+
const attrs = profile.attributes;
|
|
38
|
+
if (identity?.name || identity?.bio) {
|
|
39
|
+
lines.push(`Profile: ${[identity.name, identity.bio].filter(Boolean).join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
if (identity?.location) {
|
|
42
|
+
lines.push(`Location: ${identity.location}`);
|
|
43
|
+
}
|
|
44
|
+
if (attrs?.skills?.length) {
|
|
45
|
+
lines.push(`Skills: ${attrs.skills.join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
if (attrs?.interests?.length) {
|
|
48
|
+
lines.push(`Interests: ${attrs.interests.join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (intents?.length) {
|
|
52
|
+
// indexedIntents preserves DB order from getActiveIntents (newest first),
|
|
53
|
+
// so slice(0, 5) is deterministic without an explicit sort.
|
|
54
|
+
const capped = intents.slice(0, 5);
|
|
55
|
+
lines.push('');
|
|
56
|
+
lines.push('Active intents:');
|
|
57
|
+
for (const intent of capped) {
|
|
58
|
+
lines.push(`- ${intent.payload}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return lines.length > 0 ? lines.join('\n') : undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Factory class to build and compile the Opportunity Graph.
|
|
65
|
+
* Uses dependency injection for testability.
|
|
66
|
+
*/
|
|
67
|
+
export class OpportunityGraphFactory {
|
|
68
|
+
constructor(database, embedder, hydeGenerator, optionalEvaluator, queueNotification, negotiationGraph) {
|
|
69
|
+
this.database = database;
|
|
70
|
+
this.embedder = embedder;
|
|
71
|
+
this.hydeGenerator = hydeGenerator;
|
|
72
|
+
this.optionalEvaluator = optionalEvaluator;
|
|
73
|
+
this.queueNotification = queueNotification;
|
|
74
|
+
this.negotiationGraph = negotiationGraph;
|
|
75
|
+
}
|
|
76
|
+
createGraph() {
|
|
77
|
+
const evaluatorAgent = this.optionalEvaluator ?? new OpportunityEvaluator();
|
|
78
|
+
// ═══════════════════════════════════════════════════════════════
|
|
79
|
+
// NODE DEFINITIONS
|
|
80
|
+
// ═══════════════════════════════════════════════════════════════
|
|
81
|
+
/**
|
|
82
|
+
* Wraps a graph node function to emit agent_start/agent_end trace events
|
|
83
|
+
* at its boundaries so the frontend TRACE panel shows real-time progress.
|
|
84
|
+
* @param traceName - Kebab-case agent name (e.g. "opportunity-prep")
|
|
85
|
+
* @param nodeFn - The original node function
|
|
86
|
+
* @param summaryFn - Optional function to derive a summary string from the node result
|
|
87
|
+
*/
|
|
88
|
+
function withNodeTrace(traceName, nodeFn, summaryFn) {
|
|
89
|
+
return async (state) => {
|
|
90
|
+
const traceEmitter = requestContext.getStore()?.traceEmitter;
|
|
91
|
+
const nodeStart = Date.now();
|
|
92
|
+
traceEmitter?.({ type: "agent_start", name: traceName });
|
|
93
|
+
try {
|
|
94
|
+
const result = await nodeFn(state);
|
|
95
|
+
const durationMs = Date.now() - nodeStart;
|
|
96
|
+
const summary = summaryFn?.(result) ?? undefined;
|
|
97
|
+
traceEmitter?.({ type: "agent_end", name: traceName, durationMs, summary });
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
const durationMs = Date.now() - nodeStart;
|
|
102
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
103
|
+
traceEmitter?.({ type: "agent_end", name: traceName, durationMs, summary: `error: ${errMsg}` });
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Node 0: Prep
|
|
110
|
+
* Fetches user's index memberships and validates requirements.
|
|
111
|
+
* Returns empty if user has no index memberships (requirement).
|
|
112
|
+
*/
|
|
113
|
+
const prepNode = withNodeTrace("opportunity-prep", async (state) => timed("OpportunityGraph.prep", async () => withCallLogging(logger, '[Graph:Prep] prepNode', {
|
|
114
|
+
userId: state.userId,
|
|
115
|
+
hasSearchQuery: !!state.searchQuery,
|
|
116
|
+
requestedIndexId: state.indexId ?? undefined,
|
|
117
|
+
}, async () => {
|
|
118
|
+
// Use getIndexMemberships (all memberships) for search scope — NOT getUserIndexIds
|
|
119
|
+
// (which filters by autoAssign=true and is intended only for intent assignment).
|
|
120
|
+
const memberships = await this.database.getIndexMemberships(state.userId);
|
|
121
|
+
const userIndexIds = memberships.map(m => m.indexId);
|
|
122
|
+
if (userIndexIds.length === 0) {
|
|
123
|
+
logger.verbose('[Graph:Prep] User has no index memberships - cannot find opportunities');
|
|
124
|
+
return {
|
|
125
|
+
userIndexes: [],
|
|
126
|
+
sourceProfile: null,
|
|
127
|
+
error: 'You need to join at least one index to find opportunities.',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const discoveryUserId = state.onBehalfOfUserId ?? state.userId;
|
|
131
|
+
const [intents, profile] = await Promise.all([
|
|
132
|
+
this.database.getActiveIntents(discoveryUserId),
|
|
133
|
+
this.database.getProfile(discoveryUserId),
|
|
134
|
+
]);
|
|
135
|
+
const indexedIntents = intents.map((intent) => ({
|
|
136
|
+
intentId: intent.id,
|
|
137
|
+
payload: intent.payload,
|
|
138
|
+
summary: intent.summary ?? undefined,
|
|
139
|
+
indexes: [],
|
|
140
|
+
}));
|
|
141
|
+
const sourceProfile = profile
|
|
142
|
+
? {
|
|
143
|
+
embedding: profile.embedding ?? null,
|
|
144
|
+
identity: profile.identity ?? undefined,
|
|
145
|
+
narrative: profile.narrative ?? undefined,
|
|
146
|
+
attributes: profile.attributes ?? undefined,
|
|
147
|
+
}
|
|
148
|
+
: null;
|
|
149
|
+
return {
|
|
150
|
+
userIndexes: userIndexIds,
|
|
151
|
+
indexedIntents,
|
|
152
|
+
sourceProfile,
|
|
153
|
+
trace: [{
|
|
154
|
+
node: "prep",
|
|
155
|
+
detail: `${userIndexIds.length} index(es), ${intents.length} intent(s), ${profile ? 'profile loaded' : 'no profile'}`,
|
|
156
|
+
}],
|
|
157
|
+
};
|
|
158
|
+
}, { context: { userId: state.userId }, logOutput: true }).catch((error) => {
|
|
159
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
160
|
+
logger.error('[Graph:Prep] Failed', { error });
|
|
161
|
+
return {
|
|
162
|
+
error: 'Failed to prepare opportunity search. Please try again.',
|
|
163
|
+
trace: [{
|
|
164
|
+
node: "prep_fatal",
|
|
165
|
+
detail: `Prep failed: ${errMsg}`,
|
|
166
|
+
data: { error: errMsg },
|
|
167
|
+
}],
|
|
168
|
+
};
|
|
169
|
+
})), (result) => {
|
|
170
|
+
const r = result;
|
|
171
|
+
if (r?.error)
|
|
172
|
+
return `error: ${r.error}`;
|
|
173
|
+
const indexes = r?.userIndexes;
|
|
174
|
+
const intents = r?.indexedIntents;
|
|
175
|
+
return indexes && intents ? `${indexes.length} index(es), ${intents.length} intent(s)` : undefined;
|
|
176
|
+
});
|
|
177
|
+
/**
|
|
178
|
+
* Node 1: Scope
|
|
179
|
+
* Determines which indexes to search within.
|
|
180
|
+
* If indexId provided: searches only that index.
|
|
181
|
+
* Otherwise: searches all user's indexes.
|
|
182
|
+
*/
|
|
183
|
+
const scopeNode = withNodeTrace("opportunity-scope", async (state) => {
|
|
184
|
+
return timed("OpportunityGraph.scope", async () => {
|
|
185
|
+
logger.verbose('[Graph:Scope] Determining search scope', {
|
|
186
|
+
requestedIndexId: state.indexId,
|
|
187
|
+
userIndexesCount: state.userIndexes.length,
|
|
188
|
+
});
|
|
189
|
+
try {
|
|
190
|
+
let targetIndexIds;
|
|
191
|
+
if (state.indexId) {
|
|
192
|
+
// Validate user is member of requested index
|
|
193
|
+
if (!state.userIndexes.includes(state.indexId)) {
|
|
194
|
+
logger.warn('[Graph:Scope] User not member of requested index', {
|
|
195
|
+
indexId: state.indexId,
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
targetIndexes: [],
|
|
199
|
+
error: 'You are not a member of that index.',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
targetIndexIds = [state.indexId];
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
// Search all user's indexes
|
|
206
|
+
targetIndexIds = state.userIndexes;
|
|
207
|
+
}
|
|
208
|
+
// Fetch index details
|
|
209
|
+
const targetIndexes = await Promise.all(targetIndexIds.map(async (indexId) => {
|
|
210
|
+
const index = await this.database.getIndex(indexId);
|
|
211
|
+
const memberCount = await this.database.getIndexMemberCount(indexId);
|
|
212
|
+
return {
|
|
213
|
+
indexId,
|
|
214
|
+
title: index?.title ?? 'Unknown',
|
|
215
|
+
memberCount,
|
|
216
|
+
};
|
|
217
|
+
}));
|
|
218
|
+
logger.verbose('[Graph:Scope] Scope determined', {
|
|
219
|
+
targetIndexesCount: targetIndexes.length,
|
|
220
|
+
indexes: targetIndexes.map(i => i.title),
|
|
221
|
+
});
|
|
222
|
+
// ── Populate index relevancy scores for dedup tie-breaking ──
|
|
223
|
+
let indexRelevancyScores = {};
|
|
224
|
+
if (state.triggerIntentId) {
|
|
225
|
+
// Background path: look up persisted scores from intent_indexes
|
|
226
|
+
try {
|
|
227
|
+
const scores = await this.database.getIntentIndexScores(state.triggerIntentId);
|
|
228
|
+
for (const { indexId, relevancyScore } of scores) {
|
|
229
|
+
if (relevancyScore != null) {
|
|
230
|
+
indexRelevancyScores[indexId] = relevancyScore;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
logger.warn('[Graph:Scope] Failed to load intent index scores', { triggerIntentId: state.triggerIntentId, error: err });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else if (state.searchQuery?.trim()) {
|
|
239
|
+
// Chat path: score query against target indexes in parallel
|
|
240
|
+
try {
|
|
241
|
+
const indexer = new IntentIndexer();
|
|
242
|
+
const scopeAgentTimings = [];
|
|
243
|
+
const scorableIndexes = targetIndexes.filter(ti => ti.title !== 'Unknown');
|
|
244
|
+
const scoringPromises = scorableIndexes.map(async (ti) => {
|
|
245
|
+
try {
|
|
246
|
+
const ctx = await this.database.getIndexMemberContext(ti.indexId, state.userId);
|
|
247
|
+
if (!ctx?.indexPrompt?.trim() && !ctx?.memberPrompt?.trim()) {
|
|
248
|
+
return { indexId: ti.indexId, score: 1.0 };
|
|
249
|
+
}
|
|
250
|
+
const _indexerStart = Date.now();
|
|
251
|
+
const traceEmitter = requestContext.getStore()?.traceEmitter;
|
|
252
|
+
traceEmitter?.({ type: "agent_start", name: "intent-indexer" });
|
|
253
|
+
const result = await indexer.invoke(state.searchQuery, ctx?.indexPrompt ?? null, ctx?.memberPrompt ?? null);
|
|
254
|
+
const _indexerDuration = Date.now() - _indexerStart;
|
|
255
|
+
traceEmitter?.({ type: "agent_end", name: "intent-indexer", durationMs: _indexerDuration, summary: `Scored index ${ti.indexId}` });
|
|
256
|
+
scopeAgentTimings.push({ name: 'intent.indexer', durationMs: _indexerDuration });
|
|
257
|
+
if (!result)
|
|
258
|
+
return { indexId: ti.indexId, score: 1.0 };
|
|
259
|
+
const score = ctx?.indexPrompt && ctx?.memberPrompt
|
|
260
|
+
? result.indexScore * 0.6 + result.memberScore * 0.4
|
|
261
|
+
: ctx?.indexPrompt ? result.indexScore : result.memberScore;
|
|
262
|
+
return { indexId: ti.indexId, score };
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return { indexId: ti.indexId, score: 1.0 };
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
const results = await Promise.all(scoringPromises);
|
|
269
|
+
for (const { indexId, score } of results) {
|
|
270
|
+
indexRelevancyScores[indexId] = score;
|
|
271
|
+
}
|
|
272
|
+
// Accumulate indexer timings into graph state
|
|
273
|
+
if (scopeAgentTimings.length > 0) {
|
|
274
|
+
return {
|
|
275
|
+
targetIndexes,
|
|
276
|
+
indexRelevancyScores,
|
|
277
|
+
agentTimings: scopeAgentTimings,
|
|
278
|
+
trace: [{
|
|
279
|
+
node: "scope",
|
|
280
|
+
detail: `Searching ${targetIndexes.length} index(es): ${targetIndexes.map(i => `${i.title} (${i.memberCount})`).join(', ')}`,
|
|
281
|
+
data: { totalMembers: targetIndexes.reduce((sum, i) => sum + i.memberCount, 0) },
|
|
282
|
+
}],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
logger.warn('[Graph:Scope] Failed to score query against indexes', { error: err });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const totalMembers = targetIndexes.reduce((sum, i) => sum + i.memberCount, 0);
|
|
291
|
+
return {
|
|
292
|
+
targetIndexes,
|
|
293
|
+
indexRelevancyScores,
|
|
294
|
+
trace: [{
|
|
295
|
+
node: "scope",
|
|
296
|
+
detail: `Searching ${targetIndexes.length} index(es): ${targetIndexes.map(i => `${i.title} (${i.memberCount})`).join(', ')}`,
|
|
297
|
+
data: { totalMembers },
|
|
298
|
+
}],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
303
|
+
logger.error('[Graph:Scope] Failed', { error });
|
|
304
|
+
return {
|
|
305
|
+
targetIndexes: [],
|
|
306
|
+
error: 'Failed to determine search scope.',
|
|
307
|
+
trace: [{
|
|
308
|
+
node: "scope_fatal",
|
|
309
|
+
detail: `Scope failed: ${errMsg}`,
|
|
310
|
+
data: { error: errMsg },
|
|
311
|
+
}],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}, (result) => {
|
|
316
|
+
const r = result;
|
|
317
|
+
if (r?.error)
|
|
318
|
+
return `error: ${r.error}`;
|
|
319
|
+
const indexes = r?.targetIndexes;
|
|
320
|
+
return indexes ? `${indexes.length} index(es) in scope` : undefined;
|
|
321
|
+
});
|
|
322
|
+
/**
|
|
323
|
+
* Node 2: Resolve
|
|
324
|
+
* Resolves trigger intent from triggerIntentId or searchQuery vs indexedIntents;
|
|
325
|
+
* sets discoverySource, resolvedTriggerIntentId, resolvedIntentInIndex for routing (path A/B/C).
|
|
326
|
+
*/
|
|
327
|
+
const resolveNode = withNodeTrace("opportunity-resolve", async (state) => {
|
|
328
|
+
return timed("OpportunityGraph.resolve", async () => {
|
|
329
|
+
logger.verbose('[Graph:Resolve] Resolving intent and index membership', {
|
|
330
|
+
triggerIntentId: state.triggerIntentId,
|
|
331
|
+
hasSearchQuery: !!state.searchQuery,
|
|
332
|
+
indexedIntentsCount: state.indexedIntents.length,
|
|
333
|
+
});
|
|
334
|
+
const targetIndexIds = state.targetIndexes.map((t) => t.indexId);
|
|
335
|
+
try {
|
|
336
|
+
let resolvedIntentId;
|
|
337
|
+
if (state.triggerIntentId) {
|
|
338
|
+
const inIndex = await this.database.getIndexIdsForIntent(state.triggerIntentId);
|
|
339
|
+
const inTarget = inIndex.some((id) => targetIndexIds.includes(id));
|
|
340
|
+
resolvedIntentId = state.triggerIntentId;
|
|
341
|
+
const resolvedIntentInIndex = inTarget;
|
|
342
|
+
const discoverySource = resolvedIntentInIndex ? 'intent' : 'profile';
|
|
343
|
+
return {
|
|
344
|
+
resolvedTriggerIntentId: resolvedIntentId,
|
|
345
|
+
resolvedIntentInIndex,
|
|
346
|
+
discoverySource,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (state.searchQuery?.trim() && state.indexedIntents.length > 0) {
|
|
350
|
+
const q = state.searchQuery.trim().toLowerCase();
|
|
351
|
+
const matched = state.indexedIntents.find((i) => i.payload?.toLowerCase().includes(q));
|
|
352
|
+
if (matched) {
|
|
353
|
+
resolvedIntentId = matched.intentId;
|
|
354
|
+
const inIndex = await this.database.getIndexIdsForIntent(matched.intentId);
|
|
355
|
+
const resolvedIntentInIndex = inIndex.some((id) => targetIndexIds.includes(id));
|
|
356
|
+
const discoverySource = resolvedIntentInIndex ? 'intent' : 'profile';
|
|
357
|
+
return {
|
|
358
|
+
resolvedTriggerIntentId: resolvedIntentId,
|
|
359
|
+
resolvedIntentInIndex,
|
|
360
|
+
discoverySource,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
logger.warn('[Graph:Resolve] No intent matched search query; leaving resolvedIntentId unset', {
|
|
364
|
+
searchQuery: state.searchQuery,
|
|
365
|
+
indexedIntentsCount: state.indexedIntents.length,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
resolvedTriggerIntentId: undefined,
|
|
370
|
+
resolvedIntentInIndex: false,
|
|
371
|
+
discoverySource: 'profile',
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
376
|
+
logger.error('[Graph:Resolve] Failed', {
|
|
377
|
+
triggerIntentId: state.triggerIntentId,
|
|
378
|
+
searchQuery: state.searchQuery,
|
|
379
|
+
error: err,
|
|
380
|
+
});
|
|
381
|
+
return {
|
|
382
|
+
resolvedTriggerIntentId: undefined,
|
|
383
|
+
resolvedIntentInIndex: false,
|
|
384
|
+
discoverySource: 'profile',
|
|
385
|
+
error: errMsg || 'Resolve failed',
|
|
386
|
+
trace: [{
|
|
387
|
+
node: "resolve_fatal",
|
|
388
|
+
detail: `Resolve failed: ${errMsg}`,
|
|
389
|
+
data: { error: errMsg },
|
|
390
|
+
}],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}, (result) => {
|
|
395
|
+
const r = result;
|
|
396
|
+
if (r?.error)
|
|
397
|
+
return `error: ${r.error}`;
|
|
398
|
+
return r?.discoverySource ? `source: ${r.discoverySource}` : undefined;
|
|
399
|
+
});
|
|
400
|
+
/**
|
|
401
|
+
* Node 3: Discovery
|
|
402
|
+
* Generates HyDE embeddings and performs semantic search (path A), or profile-as-source search (path B/C).
|
|
403
|
+
*/
|
|
404
|
+
const discoveryNode = withNodeTrace("opportunity-discovery", async (state) => {
|
|
405
|
+
const self = this;
|
|
406
|
+
return timed("OpportunityGraph.discovery", async () => {
|
|
407
|
+
const startTime = Date.now();
|
|
408
|
+
const discoveryUserId = state.onBehalfOfUserId ?? state.userId;
|
|
409
|
+
/** Filter candidates to targetUserId when set (direct-connection mode). */
|
|
410
|
+
const filterByTarget = (candidates) => {
|
|
411
|
+
if (!state.targetUserId)
|
|
412
|
+
return candidates;
|
|
413
|
+
const filtered = candidates.filter(c => c.candidateUserId === state.targetUserId);
|
|
414
|
+
logger.verbose('[Graph:Discovery] targetUserId filter applied', {
|
|
415
|
+
targetUserId: state.targetUserId,
|
|
416
|
+
before: candidates.length,
|
|
417
|
+
after: filtered.length,
|
|
418
|
+
});
|
|
419
|
+
return filtered;
|
|
420
|
+
};
|
|
421
|
+
// Shared variable to capture lens input data from runQueryHydeDiscovery or intent path
|
|
422
|
+
let discoveryLensInput;
|
|
423
|
+
// Shared variable to capture HyDE output (lenses + documents) for trace entries
|
|
424
|
+
let discoveryHydeOutput;
|
|
425
|
+
logger.verbose('[Graph:Discovery] Starting semantic search', {
|
|
426
|
+
targetIndexesCount: state.targetIndexes.length,
|
|
427
|
+
discoverySource: state.discoverySource,
|
|
428
|
+
searchQueryPreview: state.searchQuery?.trim().slice(0, 60) ?? '(none)',
|
|
429
|
+
});
|
|
430
|
+
try {
|
|
431
|
+
if (state.targetIndexes.length === 0) {
|
|
432
|
+
logger.warn('[Graph:Discovery] No target indexes for search');
|
|
433
|
+
return { candidates: [] };
|
|
434
|
+
}
|
|
435
|
+
// ── Direct-connection fast path ──
|
|
436
|
+
// When targetUserId is set (user @-mentioned someone), bypass vector search
|
|
437
|
+
// and construct candidates directly from shared indexes.
|
|
438
|
+
if (state.targetUserId) {
|
|
439
|
+
if (state.targetUserId === discoveryUserId) {
|
|
440
|
+
logger.warn('[Graph:Discovery] Direct-connection target matches discoverer; skipping self-match', {
|
|
441
|
+
targetUserId: state.targetUserId,
|
|
442
|
+
});
|
|
443
|
+
return {
|
|
444
|
+
candidates: [],
|
|
445
|
+
trace: [{
|
|
446
|
+
node: "discovery",
|
|
447
|
+
detail: "Direct connection skipped: target user is discoverer",
|
|
448
|
+
data: { targetUserId: state.targetUserId },
|
|
449
|
+
}],
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
logger.verbose('[Graph:Discovery] Direct-connection mode — bypassing vector search', {
|
|
453
|
+
targetUserId: state.targetUserId,
|
|
454
|
+
});
|
|
455
|
+
const targetMemberships = await this.database.getIndexMemberships(state.targetUserId);
|
|
456
|
+
const targetUserIndexIds = targetMemberships.map(m => m.indexId);
|
|
457
|
+
const sharedIndexIds = state.targetIndexes
|
|
458
|
+
.filter(ti => targetUserIndexIds.includes(ti.indexId))
|
|
459
|
+
.map(ti => ti.indexId);
|
|
460
|
+
if (sharedIndexIds.length === 0) {
|
|
461
|
+
logger.warn('[Graph:Discovery] Target user shares no indexes with discoverer', {
|
|
462
|
+
targetUserId: state.targetUserId,
|
|
463
|
+
discovererIndexes: state.targetIndexes.map(ti => ti.indexId),
|
|
464
|
+
});
|
|
465
|
+
return {
|
|
466
|
+
candidates: [],
|
|
467
|
+
trace: [{
|
|
468
|
+
node: "discovery",
|
|
469
|
+
detail: `Direct connection: target user shares no indexes`,
|
|
470
|
+
data: { targetUserId: state.targetUserId },
|
|
471
|
+
}],
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
// Fetch target user's active intents to build intent-level candidates
|
|
475
|
+
const targetIntents = await this.database.getActiveIntents(state.targetUserId);
|
|
476
|
+
const directCandidates = [];
|
|
477
|
+
if (targetIntents.length > 0) {
|
|
478
|
+
// Build one candidate per intent per shared index it belongs to
|
|
479
|
+
for (const intent of targetIntents) {
|
|
480
|
+
const intentIndexIds = await this.database.getIndexIdsForIntent(intent.id);
|
|
481
|
+
const overlapping = sharedIndexIds.filter(id => intentIndexIds.includes(id));
|
|
482
|
+
for (const indexId of overlapping) {
|
|
483
|
+
directCandidates.push({
|
|
484
|
+
candidateUserId: state.targetUserId,
|
|
485
|
+
candidateIntentId: intent.id,
|
|
486
|
+
indexId,
|
|
487
|
+
similarity: 1.0,
|
|
488
|
+
lens: 'explicit_mention',
|
|
489
|
+
candidatePayload: intent.payload,
|
|
490
|
+
candidateSummary: intent.summary ?? undefined,
|
|
491
|
+
discoverySource: 'query',
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// Always add a profile-level candidate (so evaluation runs even without intents)
|
|
497
|
+
if (directCandidates.length === 0) {
|
|
498
|
+
directCandidates.push({
|
|
499
|
+
candidateUserId: state.targetUserId,
|
|
500
|
+
candidateIntentId: undefined,
|
|
501
|
+
indexId: sharedIndexIds[0],
|
|
502
|
+
similarity: 1.0,
|
|
503
|
+
lens: 'explicit_mention',
|
|
504
|
+
candidatePayload: '',
|
|
505
|
+
candidateSummary: undefined,
|
|
506
|
+
discoverySource: 'query',
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
logger.verbose('[Graph:Discovery] Direct candidates constructed', {
|
|
510
|
+
count: directCandidates.length,
|
|
511
|
+
sharedIndexes: sharedIndexIds.length,
|
|
512
|
+
targetIntents: targetIntents.length,
|
|
513
|
+
});
|
|
514
|
+
return {
|
|
515
|
+
candidates: directCandidates,
|
|
516
|
+
trace: [{
|
|
517
|
+
node: "discovery",
|
|
518
|
+
detail: `Direct connection → ${directCandidates.length} candidate(s) from ${sharedIndexIds.length} shared index(es)`,
|
|
519
|
+
data: {
|
|
520
|
+
targetUserId: state.targetUserId,
|
|
521
|
+
candidateCount: directCandidates.length,
|
|
522
|
+
sharedIndexes: sharedIndexIds.length,
|
|
523
|
+
durationMs: Date.now() - startTime,
|
|
524
|
+
},
|
|
525
|
+
}],
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
// Search limits - fixed values for candidate retrieval
|
|
529
|
+
// (The options.limit controls final output, not search pool)
|
|
530
|
+
const limitPerStrategy = 30;
|
|
531
|
+
const perIndexLimit = 80;
|
|
532
|
+
// Similarity threshold for recall (0.30 = 30% similarity)
|
|
533
|
+
const minScore = 0.3;
|
|
534
|
+
if (state.discoverySource === 'profile') {
|
|
535
|
+
const embedding = state.sourceProfile?.embedding ?? null;
|
|
536
|
+
const vector = Array.isArray(embedding) && embedding.length > 0 && typeof embedding[0] === 'number'
|
|
537
|
+
? embedding
|
|
538
|
+
: Array.isArray(embedding) && Array.isArray(embedding[0])
|
|
539
|
+
? embedding[0]
|
|
540
|
+
: null;
|
|
541
|
+
// ALWAYS run query-based HyDE when we have a search query (e.g., "looking for investors")
|
|
542
|
+
// This ensures we use the right strategies (investor, mentor, etc.) not just mirror
|
|
543
|
+
if (state.searchQuery?.trim()) {
|
|
544
|
+
logger.verbose('[Graph:Discovery] Profile source with searchQuery → running query HyDE path for broader search', {
|
|
545
|
+
searchQuery: state.searchQuery.trim().substring(0, 80),
|
|
546
|
+
hasProfileVector: !!vector,
|
|
547
|
+
});
|
|
548
|
+
const queryCandidates = await runQueryHydeDiscovery();
|
|
549
|
+
logger.verbose('[Graph:Discovery] Query HyDE path complete', { candidatesFound: queryCandidates.length });
|
|
550
|
+
// Build trace entries for this path
|
|
551
|
+
const traceEntries = [];
|
|
552
|
+
// Lens input trace (captured from runQueryHydeDiscovery)
|
|
553
|
+
if (discoveryLensInput) {
|
|
554
|
+
traceEntries.push({
|
|
555
|
+
node: "lens_input",
|
|
556
|
+
detail: "Profile context for lens inference",
|
|
557
|
+
data: discoveryLensInput,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
// Lens output and HyDE document traces (captured from runQueryHydeDiscovery)
|
|
561
|
+
if (discoveryHydeOutput) {
|
|
562
|
+
if (discoveryHydeOutput.lenses.length > 0) {
|
|
563
|
+
traceEntries.push({
|
|
564
|
+
node: "lens_output",
|
|
565
|
+
detail: `Inferred ${discoveryHydeOutput.lenses.length} lens(es): ${discoveryHydeOutput.lenses.map(l => l.label).join(', ')}`,
|
|
566
|
+
data: { lenses: discoveryHydeOutput.lenses, model: getModelName("lensInferrer") },
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
for (const [lens, doc] of Object.entries(discoveryHydeOutput.hydeDocuments)) {
|
|
570
|
+
if (doc?.hydeText) {
|
|
571
|
+
traceEntries.push({
|
|
572
|
+
node: "hyde_query",
|
|
573
|
+
detail: `[${lens}] "${doc.hydeText.slice(0, 120)}${doc.hydeText.length > 120 ? '...' : ''}"`,
|
|
574
|
+
data: { lens, hydeTextPreview: doc.hydeText.slice(0, 300) + (doc.hydeText.length > 300 ? '...' : '') },
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// Compute per-lens stats from deduped candidates
|
|
580
|
+
const lensStats = {};
|
|
581
|
+
for (const c of queryCandidates) {
|
|
582
|
+
const s = c.lens || 'unknown';
|
|
583
|
+
if (!lensStats[s])
|
|
584
|
+
lensStats[s] = { count: 0, avgSimilarity: 0 };
|
|
585
|
+
lensStats[s].count++;
|
|
586
|
+
lensStats[s].avgSimilarity += c.similarity;
|
|
587
|
+
}
|
|
588
|
+
for (const s of Object.values(lensStats)) {
|
|
589
|
+
s.avgSimilarity = s.count > 0 ? Math.round((s.avgSimilarity / s.count) * 1000) / 1000 : 0;
|
|
590
|
+
}
|
|
591
|
+
traceEntries.push({
|
|
592
|
+
node: "discovery",
|
|
593
|
+
detail: `HyDE search → ${queryCandidates.length} candidate(s) from query path`,
|
|
594
|
+
data: {
|
|
595
|
+
candidateCount: queryCandidates.length,
|
|
596
|
+
byLens: lensStats,
|
|
597
|
+
searchQuery: state.searchQuery?.trim().slice(0, 80),
|
|
598
|
+
durationMs: Date.now() - startTime,
|
|
599
|
+
model: getModelName("hydeGenerator"),
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
// If we also have a profile vector, merge with profile-based results
|
|
603
|
+
if (vector && vector.length > 0) {
|
|
604
|
+
const profileCandidates = [];
|
|
605
|
+
for (const targetIndex of state.targetIndexes) {
|
|
606
|
+
const results = await this.embedder.searchWithProfileEmbedding(vector, {
|
|
607
|
+
indexScope: [targetIndex.indexId],
|
|
608
|
+
excludeUserId: discoveryUserId,
|
|
609
|
+
limitPerStrategy: Math.floor(limitPerStrategy / 2),
|
|
610
|
+
limit: Math.floor(perIndexLimit / 2),
|
|
611
|
+
minScore,
|
|
612
|
+
});
|
|
613
|
+
for (const result of results) {
|
|
614
|
+
profileCandidates.push({
|
|
615
|
+
candidateUserId: result.userId,
|
|
616
|
+
candidateIntentId: result.type === 'intent' ? result.id : undefined,
|
|
617
|
+
indexId: targetIndex.indexId,
|
|
618
|
+
similarity: result.score,
|
|
619
|
+
lens: result.matchedVia,
|
|
620
|
+
candidatePayload: '',
|
|
621
|
+
candidateSummary: undefined,
|
|
622
|
+
discoverySource: 'profile-similarity',
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// Merge and dedupe - keep both intent and profile candidates per user
|
|
627
|
+
const byKey = new Map();
|
|
628
|
+
for (const c of [...queryCandidates, ...profileCandidates]) {
|
|
629
|
+
const key = `${c.candidateUserId}:${c.indexId}:${c.candidateIntentId ?? 'profile'}:${c.discoverySource ?? 'unknown'}`;
|
|
630
|
+
if (!byKey.has(key) || c.similarity > (byKey.get(key)?.similarity ?? 0)) {
|
|
631
|
+
byKey.set(key, c);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const merged = Array.from(byKey.values());
|
|
635
|
+
logger.verbose('[Graph:Discovery] Merged HyDE + profile candidates', {
|
|
636
|
+
hydeCandidates: queryCandidates.length,
|
|
637
|
+
profileCandidates: profileCandidates.length,
|
|
638
|
+
merged: merged.length
|
|
639
|
+
});
|
|
640
|
+
traceEntries.push({
|
|
641
|
+
node: "discovery",
|
|
642
|
+
detail: `+ Profile search → ${profileCandidates.length} additional, merged to ${merged.length}`,
|
|
643
|
+
data: {
|
|
644
|
+
profileCandidates: profileCandidates.length,
|
|
645
|
+
merged: merged.length,
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
return { candidates: filterByTarget(merged), trace: traceEntries };
|
|
649
|
+
}
|
|
650
|
+
return { candidates: filterByTarget(queryCandidates), trace: traceEntries };
|
|
651
|
+
}
|
|
652
|
+
// No search query - use profile embedding directly (mirror-only)
|
|
653
|
+
if (!vector || vector.length === 0) {
|
|
654
|
+
return { candidates: [] };
|
|
655
|
+
}
|
|
656
|
+
const allCandidates = [];
|
|
657
|
+
for (const targetIndex of state.targetIndexes) {
|
|
658
|
+
const results = await this.embedder.searchWithProfileEmbedding(vector, {
|
|
659
|
+
indexScope: [targetIndex.indexId],
|
|
660
|
+
excludeUserId: discoveryUserId,
|
|
661
|
+
limitPerStrategy,
|
|
662
|
+
limit: perIndexLimit,
|
|
663
|
+
minScore,
|
|
664
|
+
});
|
|
665
|
+
for (const result of results) {
|
|
666
|
+
if (result.type === 'intent') {
|
|
667
|
+
allCandidates.push({
|
|
668
|
+
candidateUserId: result.userId,
|
|
669
|
+
candidateIntentId: result.id,
|
|
670
|
+
indexId: targetIndex.indexId,
|
|
671
|
+
similarity: result.score,
|
|
672
|
+
lens: result.matchedVia,
|
|
673
|
+
candidatePayload: '',
|
|
674
|
+
candidateSummary: undefined,
|
|
675
|
+
discoverySource: 'profile-similarity',
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
allCandidates.push({
|
|
680
|
+
candidateUserId: result.userId,
|
|
681
|
+
indexId: targetIndex.indexId,
|
|
682
|
+
similarity: result.score,
|
|
683
|
+
lens: result.matchedVia,
|
|
684
|
+
candidatePayload: '',
|
|
685
|
+
candidateSummary: undefined,
|
|
686
|
+
discoverySource: 'profile-similarity',
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const byUserAndIndex = new Map();
|
|
692
|
+
for (const c of allCandidates) {
|
|
693
|
+
const key = `${c.candidateUserId}:${c.indexId}:${c.candidateIntentId ?? 'profile'}`;
|
|
694
|
+
if (!byUserAndIndex.has(key) || c.similarity > (byUserAndIndex.get(key)?.similarity ?? 0)) {
|
|
695
|
+
byUserAndIndex.set(key, c);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const candidates = Array.from(byUserAndIndex.values());
|
|
699
|
+
logger.verbose('[Graph:Discovery] Profile-as-source discovery complete', { candidatesFound: candidates.length });
|
|
700
|
+
// Build trace with individual candidate similarity scores
|
|
701
|
+
const traceEntries = [];
|
|
702
|
+
// Show what the profile search is based on
|
|
703
|
+
const profileBio = state.sourceProfile?.identity?.bio;
|
|
704
|
+
const profileContext = state.sourceProfile?.narrative?.context;
|
|
705
|
+
const profileSummary = profileBio || profileContext || '(profile embedding)';
|
|
706
|
+
// Compute per-lens stats from deduped candidates
|
|
707
|
+
const lensStats = {};
|
|
708
|
+
for (const c of candidates) {
|
|
709
|
+
const s = c.lens || 'unknown';
|
|
710
|
+
if (!lensStats[s])
|
|
711
|
+
lensStats[s] = { count: 0, avgSimilarity: 0 };
|
|
712
|
+
lensStats[s].count++;
|
|
713
|
+
lensStats[s].avgSimilarity += c.similarity;
|
|
714
|
+
}
|
|
715
|
+
for (const s of Object.values(lensStats)) {
|
|
716
|
+
s.avgSimilarity = s.count > 0 ? Math.round((s.avgSimilarity / s.count) * 1000) / 1000 : 0;
|
|
717
|
+
}
|
|
718
|
+
traceEntries.push({
|
|
719
|
+
node: "discovery",
|
|
720
|
+
detail: `Profile-based search → ${candidates.length} candidate(s)`,
|
|
721
|
+
data: {
|
|
722
|
+
source: "profile",
|
|
723
|
+
candidateCount: candidates.length,
|
|
724
|
+
byLens: lensStats,
|
|
725
|
+
durationMs: Date.now() - startTime,
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
traceEntries.push({
|
|
729
|
+
node: "search_query",
|
|
730
|
+
detail: `Searching for matches to: "${profileSummary.slice(0, 150)}${profileSummary.length > 150 ? '...' : ''}"`,
|
|
731
|
+
data: {
|
|
732
|
+
type: "profile_embedding",
|
|
733
|
+
bio: profileBio,
|
|
734
|
+
context: profileContext,
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
// Add top candidates with similarity scores
|
|
738
|
+
const sortedCandidates = [...candidates].sort((a, b) => b.similarity - a.similarity).slice(0, 10);
|
|
739
|
+
for (const c of sortedCandidates) {
|
|
740
|
+
traceEntries.push({
|
|
741
|
+
node: "match",
|
|
742
|
+
detail: `Similarity ${Math.round(c.similarity * 100)}% via ${c.lens}`,
|
|
743
|
+
data: {
|
|
744
|
+
userId: c.candidateUserId,
|
|
745
|
+
similarity: Math.round(c.similarity * 100),
|
|
746
|
+
lens: c.lens,
|
|
747
|
+
hasIntent: !!c.candidateIntentId,
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
candidates: filterByTarget(candidates),
|
|
753
|
+
trace: traceEntries,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
async function runQueryHydeDiscovery() {
|
|
757
|
+
const searchText = state.searchQuery?.trim() ?? '';
|
|
758
|
+
if (!searchText)
|
|
759
|
+
return [];
|
|
760
|
+
logger.verbose('[Graph:Discovery] runQueryHydeDiscovery start', { searchText: searchText.slice(0, 80) });
|
|
761
|
+
const discovererContext = buildDiscovererContext(state.sourceProfile, state.indexedIntents);
|
|
762
|
+
discoveryLensInput = {
|
|
763
|
+
profileContext: discovererContext,
|
|
764
|
+
model: getModelName("lensInferrer"),
|
|
765
|
+
};
|
|
766
|
+
const hydeResult = await self.hydeGenerator.invoke({
|
|
767
|
+
sourceType: 'query',
|
|
768
|
+
sourceText: searchText,
|
|
769
|
+
forceRegenerate: false,
|
|
770
|
+
profileContext: discovererContext,
|
|
771
|
+
});
|
|
772
|
+
const hydeEmbeddings = hydeResult.hydeEmbeddings;
|
|
773
|
+
const lenses = hydeResult.lenses ?? [];
|
|
774
|
+
discoveryHydeOutput = {
|
|
775
|
+
lenses: lenses,
|
|
776
|
+
hydeDocuments: (hydeResult.hydeDocuments ?? {}),
|
|
777
|
+
};
|
|
778
|
+
const embeddingKeys = hydeEmbeddings ? Object.keys(hydeEmbeddings) : [];
|
|
779
|
+
logger.verbose('[Graph:Discovery] HyDE generator result', {
|
|
780
|
+
lensCount: embeddingKeys.length,
|
|
781
|
+
lenses: embeddingKeys,
|
|
782
|
+
});
|
|
783
|
+
if (!hydeEmbeddings || Object.keys(hydeEmbeddings).length === 0)
|
|
784
|
+
return [];
|
|
785
|
+
const lensMap = new Map(lenses.map(l => [l.label, l]));
|
|
786
|
+
const lensEmbeddings = [];
|
|
787
|
+
for (const [label, emb] of Object.entries(hydeEmbeddings)) {
|
|
788
|
+
if (emb?.length) {
|
|
789
|
+
const lens = lensMap.get(label);
|
|
790
|
+
lensEmbeddings.push({ lens: label, corpus: lens?.corpus ?? 'profiles', embedding: emb });
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
const all = [];
|
|
794
|
+
await Promise.all(state.targetIndexes.map(async (targetIndex) => {
|
|
795
|
+
const results = await self.embedder.searchWithHydeEmbeddings(lensEmbeddings, {
|
|
796
|
+
indexScope: [targetIndex.indexId],
|
|
797
|
+
excludeUserId: discoveryUserId,
|
|
798
|
+
limitPerStrategy,
|
|
799
|
+
limit: perIndexLimit,
|
|
800
|
+
minScore,
|
|
801
|
+
});
|
|
802
|
+
for (const r of results.filter((x) => x.type === 'intent')) {
|
|
803
|
+
all.push({
|
|
804
|
+
candidateUserId: r.userId,
|
|
805
|
+
candidateIntentId: r.id,
|
|
806
|
+
indexId: targetIndex.indexId,
|
|
807
|
+
similarity: r.score,
|
|
808
|
+
lens: r.matchedVia,
|
|
809
|
+
candidatePayload: '',
|
|
810
|
+
candidateSummary: undefined,
|
|
811
|
+
discoverySource: 'query',
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
for (const r of results.filter((x) => x.type === 'profile')) {
|
|
815
|
+
all.push({
|
|
816
|
+
candidateUserId: r.userId,
|
|
817
|
+
indexId: targetIndex.indexId,
|
|
818
|
+
similarity: r.score,
|
|
819
|
+
lens: r.matchedVia,
|
|
820
|
+
candidatePayload: '',
|
|
821
|
+
candidateSummary: undefined,
|
|
822
|
+
discoverySource: 'query',
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}));
|
|
826
|
+
const profileCount = all.filter((c) => !c.candidateIntentId).length;
|
|
827
|
+
const intentCount = all.filter((c) => c.candidateIntentId).length;
|
|
828
|
+
logger.verbose('[Graph:Discovery] searchWithHydeEmbeddings raw results', {
|
|
829
|
+
total: all.length,
|
|
830
|
+
fromProfile: profileCount,
|
|
831
|
+
fromIntent: intentCount,
|
|
832
|
+
});
|
|
833
|
+
const byKey = new Map();
|
|
834
|
+
for (const c of all) {
|
|
835
|
+
// Dedup by candidateUserId + intent (or profile), NOT by indexId.
|
|
836
|
+
// Including indexId caused the same user to appear once per index they belong to.
|
|
837
|
+
const key = `${c.candidateUserId}:${c.candidateIntentId ?? 'profile'}`;
|
|
838
|
+
if (!byKey.has(key) || c.similarity > (byKey.get(key)?.similarity ?? 0)) {
|
|
839
|
+
byKey.set(key, c);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return Array.from(byKey.values());
|
|
843
|
+
}
|
|
844
|
+
const resolvedIntent = state.resolvedTriggerIntentId
|
|
845
|
+
? state.indexedIntents.find((i) => i.intentId === state.resolvedTriggerIntentId)
|
|
846
|
+
: state.indexedIntents[0];
|
|
847
|
+
const searchText = state.searchQuery ?? resolvedIntent?.payload ?? '';
|
|
848
|
+
if (!searchText) {
|
|
849
|
+
logger.warn('[Graph:Discovery] No search text available for intent path');
|
|
850
|
+
return { candidates: [] };
|
|
851
|
+
}
|
|
852
|
+
const discovererContext = buildDiscovererContext(state.sourceProfile, state.indexedIntents);
|
|
853
|
+
discoveryLensInput = {
|
|
854
|
+
profileContext: discovererContext,
|
|
855
|
+
model: getModelName("lensInferrer"),
|
|
856
|
+
};
|
|
857
|
+
const hydeResult = await this.hydeGenerator.invoke({
|
|
858
|
+
sourceType: 'query',
|
|
859
|
+
sourceText: searchText,
|
|
860
|
+
forceRegenerate: false,
|
|
861
|
+
profileContext: discovererContext,
|
|
862
|
+
});
|
|
863
|
+
const hydeEmbeddings = hydeResult.hydeEmbeddings;
|
|
864
|
+
const lenses = hydeResult.lenses ?? [];
|
|
865
|
+
if (!hydeEmbeddings || Object.keys(hydeEmbeddings).length === 0) {
|
|
866
|
+
return { hydeEmbeddings: {}, candidates: [] };
|
|
867
|
+
}
|
|
868
|
+
const lensMap = new Map(lenses.map(l => [l.label, l]));
|
|
869
|
+
const lensEmbeddings = [];
|
|
870
|
+
for (const [label, emb] of Object.entries(hydeEmbeddings)) {
|
|
871
|
+
if (emb?.length) {
|
|
872
|
+
const lens = lensMap.get(label);
|
|
873
|
+
lensEmbeddings.push({ lens: label, corpus: lens?.corpus ?? 'profiles', embedding: emb });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
const allCandidates = [];
|
|
877
|
+
await Promise.all(state.targetIndexes.map(async (targetIndex) => {
|
|
878
|
+
const results = await this.embedder.searchWithHydeEmbeddings(lensEmbeddings, {
|
|
879
|
+
indexScope: [targetIndex.indexId],
|
|
880
|
+
excludeUserId: discoveryUserId,
|
|
881
|
+
limitPerStrategy,
|
|
882
|
+
limit: perIndexLimit,
|
|
883
|
+
minScore,
|
|
884
|
+
});
|
|
885
|
+
for (const result of results.filter((r) => r.type === 'intent')) {
|
|
886
|
+
allCandidates.push({
|
|
887
|
+
candidateUserId: result.userId,
|
|
888
|
+
candidateIntentId: result.id,
|
|
889
|
+
indexId: targetIndex.indexId,
|
|
890
|
+
similarity: result.score,
|
|
891
|
+
lens: result.matchedVia,
|
|
892
|
+
candidatePayload: '',
|
|
893
|
+
candidateSummary: undefined,
|
|
894
|
+
discoverySource: 'query',
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
for (const result of results.filter((r) => r.type === 'profile')) {
|
|
898
|
+
allCandidates.push({
|
|
899
|
+
candidateUserId: result.userId,
|
|
900
|
+
indexId: targetIndex.indexId,
|
|
901
|
+
similarity: result.score,
|
|
902
|
+
lens: result.matchedVia,
|
|
903
|
+
candidatePayload: '',
|
|
904
|
+
candidateSummary: undefined,
|
|
905
|
+
discoverySource: 'query',
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
}));
|
|
909
|
+
const byUserAndIndex = new Map();
|
|
910
|
+
for (const c of allCandidates) {
|
|
911
|
+
const key = `${c.candidateUserId}:${c.indexId}:${c.candidateIntentId ?? 'profile'}`;
|
|
912
|
+
if (!byUserAndIndex.has(key) || c.similarity > (byUserAndIndex.get(key)?.similarity ?? 0)) {
|
|
913
|
+
byUserAndIndex.set(key, c);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const candidates = Array.from(byUserAndIndex.values());
|
|
917
|
+
logger.verbose('[Graph:Discovery] Intent-path discovery complete', { candidatesFound: candidates.length });
|
|
918
|
+
const usedLenses = Object.keys(hydeEmbeddings);
|
|
919
|
+
// Build trace with individual candidate similarity scores
|
|
920
|
+
const traceEntries = [];
|
|
921
|
+
// Lens input trace
|
|
922
|
+
if (discoveryLensInput) {
|
|
923
|
+
traceEntries.push({
|
|
924
|
+
node: "lens_input",
|
|
925
|
+
detail: "Profile context for lens inference",
|
|
926
|
+
data: discoveryLensInput,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
// Lens output trace
|
|
930
|
+
if (lenses.length > 0) {
|
|
931
|
+
traceEntries.push({
|
|
932
|
+
node: "lens_output",
|
|
933
|
+
detail: `Inferred ${lenses.length} lens(es): ${lenses.map(l => l.label).join(', ')}`,
|
|
934
|
+
data: { lenses, model: getModelName("lensInferrer") },
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
// Compute per-lens stats from deduped candidates
|
|
938
|
+
const lensStats = {};
|
|
939
|
+
for (const c of candidates) {
|
|
940
|
+
const s = c.lens || 'unknown';
|
|
941
|
+
if (!lensStats[s])
|
|
942
|
+
lensStats[s] = { count: 0, avgSimilarity: 0 };
|
|
943
|
+
lensStats[s].count++;
|
|
944
|
+
lensStats[s].avgSimilarity += c.similarity;
|
|
945
|
+
}
|
|
946
|
+
for (const s of Object.values(lensStats)) {
|
|
947
|
+
s.avgSimilarity = s.count > 0 ? Math.round((s.avgSimilarity / s.count) * 1000) / 1000 : 0;
|
|
948
|
+
}
|
|
949
|
+
traceEntries.push({
|
|
950
|
+
node: "discovery",
|
|
951
|
+
detail: `Query: "${searchText.slice(0, 50)}${searchText.length > 50 ? '...' : ''}" → ${candidates.length} candidate(s)`,
|
|
952
|
+
data: {
|
|
953
|
+
query: searchText.slice(0, 100),
|
|
954
|
+
lenses: usedLenses,
|
|
955
|
+
candidateCount: candidates.length,
|
|
956
|
+
byLens: lensStats,
|
|
957
|
+
durationMs: Date.now() - startTime,
|
|
958
|
+
model: getModelName("hydeGenerator"),
|
|
959
|
+
},
|
|
960
|
+
});
|
|
961
|
+
// Show the HyDE-generated hypothetical documents used for search
|
|
962
|
+
const hydeDocuments = hydeResult.hydeDocuments;
|
|
963
|
+
if (hydeDocuments) {
|
|
964
|
+
for (const [lens, doc] of Object.entries(hydeDocuments)) {
|
|
965
|
+
if (doc?.hydeText) {
|
|
966
|
+
traceEntries.push({
|
|
967
|
+
node: "hyde_query",
|
|
968
|
+
detail: `[${lens}] "${doc.hydeText.slice(0, 120)}${doc.hydeText.length > 120 ? '...' : ''}"`,
|
|
969
|
+
data: {
|
|
970
|
+
lens,
|
|
971
|
+
hydeTextPreview: doc.hydeText.slice(0, 160) + (doc.hydeText.length > 160 ? '...' : ''),
|
|
972
|
+
},
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
// Add top candidates with similarity scores
|
|
978
|
+
const sortedCandidates = [...candidates].sort((a, b) => b.similarity - a.similarity).slice(0, 10);
|
|
979
|
+
for (const c of sortedCandidates) {
|
|
980
|
+
traceEntries.push({
|
|
981
|
+
node: "match",
|
|
982
|
+
detail: `Similarity ${Math.round(c.similarity * 100)}% via ${c.lens}`,
|
|
983
|
+
data: {
|
|
984
|
+
userId: c.candidateUserId,
|
|
985
|
+
similarity: Math.round(c.similarity * 100),
|
|
986
|
+
lens: c.lens,
|
|
987
|
+
hasIntent: !!c.candidateIntentId,
|
|
988
|
+
},
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
return {
|
|
992
|
+
hydeEmbeddings: hydeEmbeddings,
|
|
993
|
+
candidates: filterByTarget(candidates),
|
|
994
|
+
trace: traceEntries,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
catch (error) {
|
|
998
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
999
|
+
logger.error('[Graph:Discovery] Failed', { error });
|
|
1000
|
+
return {
|
|
1001
|
+
candidates: [],
|
|
1002
|
+
error: 'Failed to search for candidates.',
|
|
1003
|
+
trace: [{
|
|
1004
|
+
node: "discovery_fatal",
|
|
1005
|
+
detail: `Discovery failed: ${errMsg}`,
|
|
1006
|
+
data: { error: errMsg },
|
|
1007
|
+
}],
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
}, (result) => {
|
|
1012
|
+
const r = result;
|
|
1013
|
+
if (r?.error)
|
|
1014
|
+
return `error: ${r.error}`;
|
|
1015
|
+
const candidates = r?.candidates;
|
|
1016
|
+
return candidates ? `Found ${candidates.length} candidate(s)` : undefined;
|
|
1017
|
+
});
|
|
1018
|
+
/**
|
|
1019
|
+
* Node 3: Evaluation (Entity bundle)
|
|
1020
|
+
* Builds entity bundle from source + candidates, invokes entity-bundle evaluator, maps to EvaluatedOpportunity with indexId from entities.
|
|
1021
|
+
*/
|
|
1022
|
+
const evaluationNode = async (state) => {
|
|
1023
|
+
return timed("OpportunityGraph.evaluation", async () => {
|
|
1024
|
+
const startTime = Date.now();
|
|
1025
|
+
logger.verbose('[Graph:Evaluation] Starting evaluation', {
|
|
1026
|
+
candidatesCount: state.candidates.length,
|
|
1027
|
+
});
|
|
1028
|
+
if (state.candidates.length === 0) {
|
|
1029
|
+
logger.verbose('[Graph:Evaluation] No candidates to evaluate');
|
|
1030
|
+
return { evaluatedOpportunities: [], agentTimings: [] };
|
|
1031
|
+
}
|
|
1032
|
+
// Batch candidates to avoid timeout - evaluate top 25 per batch, store remaining
|
|
1033
|
+
const EVAL_BATCH_SIZE = 25;
|
|
1034
|
+
const sortedCandidates = [...state.candidates]
|
|
1035
|
+
.sort((a, b) => b.similarity - a.similarity);
|
|
1036
|
+
// Dedup by userId — when same similarity, prefer index with highest relevancyScore
|
|
1037
|
+
const bestByUser = new Map();
|
|
1038
|
+
for (const c of sortedCandidates) {
|
|
1039
|
+
const existing = bestByUser.get(c.candidateUserId);
|
|
1040
|
+
if (!existing) {
|
|
1041
|
+
bestByUser.set(c.candidateUserId, c);
|
|
1042
|
+
}
|
|
1043
|
+
else if (c.similarity > existing.similarity) {
|
|
1044
|
+
bestByUser.set(c.candidateUserId, c);
|
|
1045
|
+
}
|
|
1046
|
+
else if (c.similarity === existing.similarity) {
|
|
1047
|
+
// Tie-break: prefer index with higher relevancy score
|
|
1048
|
+
const cScore = state.indexRelevancyScores[c.indexId] ?? 0;
|
|
1049
|
+
const existingScore = state.indexRelevancyScores[existing.indexId] ?? 0;
|
|
1050
|
+
if (cScore > existingScore) {
|
|
1051
|
+
bestByUser.set(c.candidateUserId, c);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const dedupedCandidates = Array.from(bestByUser.values());
|
|
1056
|
+
// Re-sort by similarity descending (Map iteration order doesn't guarantee sort)
|
|
1057
|
+
dedupedCandidates.sort((a, b) => b.similarity - a.similarity);
|
|
1058
|
+
if (dedupedCandidates.length < sortedCandidates.length) {
|
|
1059
|
+
logger.info("[Graph:Evaluation] Deduped candidates by userId", {
|
|
1060
|
+
before: sortedCandidates.length,
|
|
1061
|
+
after: dedupedCandidates.length,
|
|
1062
|
+
removed: sortedCandidates.length - dedupedCandidates.length,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
const batchToEvaluate = dedupedCandidates.slice(0, EVAL_BATCH_SIZE);
|
|
1066
|
+
const remaining = dedupedCandidates.slice(EVAL_BATCH_SIZE);
|
|
1067
|
+
// Early termination: if search was query-driven and no query-sourced candidates remain,
|
|
1068
|
+
// clear remaining to prevent pointless pagination through profile-similarity leftovers
|
|
1069
|
+
const isQueryDriven = !!state.searchQuery?.trim();
|
|
1070
|
+
const queryRemaining = remaining.filter((c) => c.discoverySource === 'query' || c.discoverySource == null);
|
|
1071
|
+
const effectiveRemaining = isQueryDriven && queryRemaining.length === 0 ? [] : remaining;
|
|
1072
|
+
if (isQueryDriven && remaining.length > 0 && queryRemaining.length === 0) {
|
|
1073
|
+
logger.info("[Graph:Evaluation] Early termination: no query-sourced candidates remain", {
|
|
1074
|
+
droppedProfileCandidates: remaining.length,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
if (effectiveRemaining.length > 0) {
|
|
1078
|
+
logger.verbose('[Graph:Evaluation] Batched candidates for evaluation', {
|
|
1079
|
+
evaluating: batchToEvaluate.length,
|
|
1080
|
+
remaining: effectiveRemaining.length,
|
|
1081
|
+
total: sortedCandidates.length,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
const agentTimingsAccum = [];
|
|
1085
|
+
try {
|
|
1086
|
+
const discoveryUserId = state.onBehalfOfUserId ?? state.userId;
|
|
1087
|
+
const sourceProfile = await this.database.getProfile(discoveryUserId);
|
|
1088
|
+
const sourceEntity = {
|
|
1089
|
+
userId: discoveryUserId,
|
|
1090
|
+
profile: {
|
|
1091
|
+
name: sourceProfile?.identity?.name,
|
|
1092
|
+
bio: sourceProfile?.identity?.bio,
|
|
1093
|
+
location: sourceProfile?.identity?.location,
|
|
1094
|
+
interests: sourceProfile?.attributes?.interests,
|
|
1095
|
+
skills: sourceProfile?.attributes?.skills,
|
|
1096
|
+
context: sourceProfile?.narrative?.context,
|
|
1097
|
+
},
|
|
1098
|
+
intents: state.indexedIntents.slice(0, 5).map((i) => ({
|
|
1099
|
+
intentId: i.intentId,
|
|
1100
|
+
payload: i.payload,
|
|
1101
|
+
summary: i.summary,
|
|
1102
|
+
})),
|
|
1103
|
+
indexId: '', // Placeholder — overwritten per-pairing below
|
|
1104
|
+
ragScore: undefined,
|
|
1105
|
+
matchedVia: undefined,
|
|
1106
|
+
};
|
|
1107
|
+
const candidateEntities = await Promise.all(batchToEvaluate.map(async (c) => {
|
|
1108
|
+
const profile = await this.database.getProfile(c.candidateUserId);
|
|
1109
|
+
let intentPayload = c.candidatePayload;
|
|
1110
|
+
let intentSummary = c.candidateSummary;
|
|
1111
|
+
if (c.candidateIntentId != null && (!intentPayload || intentPayload === '')) {
|
|
1112
|
+
const intent = await this.database.getIntent(c.candidateIntentId);
|
|
1113
|
+
if (intent) {
|
|
1114
|
+
intentPayload = intent.payload;
|
|
1115
|
+
intentSummary = intent.summary ?? undefined;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return {
|
|
1119
|
+
userId: c.candidateUserId,
|
|
1120
|
+
profile: {
|
|
1121
|
+
name: profile?.identity?.name,
|
|
1122
|
+
bio: profile?.identity?.bio,
|
|
1123
|
+
location: profile?.identity?.location,
|
|
1124
|
+
interests: profile?.attributes?.interests,
|
|
1125
|
+
skills: profile?.attributes?.skills,
|
|
1126
|
+
context: profile?.narrative?.context,
|
|
1127
|
+
},
|
|
1128
|
+
intents: c.candidateIntentId != null
|
|
1129
|
+
? [{ intentId: c.candidateIntentId, payload: intentPayload ?? '', summary: intentSummary }]
|
|
1130
|
+
: undefined,
|
|
1131
|
+
indexId: c.indexId,
|
|
1132
|
+
ragScore: c.similarity * 100,
|
|
1133
|
+
matchedVia: c.lens,
|
|
1134
|
+
};
|
|
1135
|
+
}));
|
|
1136
|
+
const userIdToIndexId = new Map();
|
|
1137
|
+
for (const e of candidateEntities) {
|
|
1138
|
+
if (!userIdToIndexId.has(e.userId))
|
|
1139
|
+
userIdToIndexId.set(e.userId, e.indexId);
|
|
1140
|
+
}
|
|
1141
|
+
// Lower default threshold to 50 for better recall
|
|
1142
|
+
const minScore = state.options.minScore ?? 50;
|
|
1143
|
+
const evaluator = typeof evaluatorAgent.invokeEntityBundle === 'function'
|
|
1144
|
+
? evaluatorAgent
|
|
1145
|
+
: new OpportunityEvaluator();
|
|
1146
|
+
const runParallel = process.env.RUN_OPPORTUNITY_EVAL_IN_PARALLEL === 'true';
|
|
1147
|
+
// Declare trace entries early so both parallel and serial paths can push error entries
|
|
1148
|
+
const traceEntries = [];
|
|
1149
|
+
const parallelErrors = [];
|
|
1150
|
+
let pairwiseOpportunities;
|
|
1151
|
+
if (runParallel) {
|
|
1152
|
+
// Experimental: one LLM call per candidate, all fired in parallel
|
|
1153
|
+
logger.verbose('[Graph:Evaluation] Running parallel evaluation', { candidates: candidateEntities.length });
|
|
1154
|
+
const parallelResults = await Promise.all(candidateEntities.map((candidateEntity) => {
|
|
1155
|
+
const input = {
|
|
1156
|
+
discovererId: discoveryUserId,
|
|
1157
|
+
entities: [sourceEntity, candidateEntity],
|
|
1158
|
+
existingOpportunities: state.options.existingOpportunities,
|
|
1159
|
+
...(state.searchQuery?.trim() ? { discoveryQuery: state.searchQuery.trim() } : {}),
|
|
1160
|
+
};
|
|
1161
|
+
const _evalStart = Date.now();
|
|
1162
|
+
const _traceEmitter = requestContext.getStore()?.traceEmitter;
|
|
1163
|
+
_traceEmitter?.({ type: "agent_start", name: "opportunity-evaluator" });
|
|
1164
|
+
const _candidateName = candidateEntity.profile?.name ?? "Unknown";
|
|
1165
|
+
return evaluator.invokeEntityBundle(input, { minScore, returnAll: true })
|
|
1166
|
+
.then((res) => {
|
|
1167
|
+
const _evalDuration = Date.now() - _evalStart;
|
|
1168
|
+
agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
|
|
1169
|
+
const _topScore = res.length > 0 ? Math.max(...res.map(r => r.score)) : -1;
|
|
1170
|
+
const _summary = _topScore < 0 ? `${_candidateName}: no match` : `${_candidateName}: ${_topScore}`;
|
|
1171
|
+
_traceEmitter?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: _summary });
|
|
1172
|
+
return res;
|
|
1173
|
+
})
|
|
1174
|
+
.catch((err) => {
|
|
1175
|
+
const _evalDuration = Date.now() - _evalStart;
|
|
1176
|
+
const _errMsg = err instanceof Error ? err.message : String(err);
|
|
1177
|
+
agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
|
|
1178
|
+
_traceEmitter?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `${_candidateName}: error — ${_errMsg}` });
|
|
1179
|
+
logger.warn('[Graph:Evaluation] Parallel eval failed for candidate', {
|
|
1180
|
+
candidateUserId: candidateEntity.userId,
|
|
1181
|
+
error: err,
|
|
1182
|
+
});
|
|
1183
|
+
parallelErrors.push({
|
|
1184
|
+
candidateUserId: candidateEntity.userId,
|
|
1185
|
+
candidateName: _candidateName,
|
|
1186
|
+
error: _errMsg,
|
|
1187
|
+
durationMs: _evalDuration,
|
|
1188
|
+
});
|
|
1189
|
+
return [];
|
|
1190
|
+
});
|
|
1191
|
+
}));
|
|
1192
|
+
// Each call is already pairwise (source + 1 candidate) — flatten directly
|
|
1193
|
+
pairwiseOpportunities = parallelResults.flat();
|
|
1194
|
+
// Record trace entries for candidates that failed during parallel evaluation
|
|
1195
|
+
if (parallelErrors.length > 0) {
|
|
1196
|
+
traceEntries.push({
|
|
1197
|
+
node: "evaluation_errors",
|
|
1198
|
+
detail: `${parallelErrors.length}/${candidateEntities.length} candidate evaluation(s) failed`,
|
|
1199
|
+
data: {
|
|
1200
|
+
failedCount: parallelErrors.length,
|
|
1201
|
+
totalCandidates: candidateEntities.length,
|
|
1202
|
+
errors: parallelErrors.map(e => ({
|
|
1203
|
+
candidateUserId: e.candidateUserId,
|
|
1204
|
+
candidateName: e.candidateName,
|
|
1205
|
+
error: e.error,
|
|
1206
|
+
durationMs: e.durationMs,
|
|
1207
|
+
})),
|
|
1208
|
+
},
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
// Default: single bundled LLM call with all candidates
|
|
1214
|
+
const entities = [sourceEntity, ...candidateEntities];
|
|
1215
|
+
const input = {
|
|
1216
|
+
discovererId: discoveryUserId,
|
|
1217
|
+
entities,
|
|
1218
|
+
existingOpportunities: state.options.existingOpportunities,
|
|
1219
|
+
...(state.searchQuery?.trim() ? { discoveryQuery: state.searchQuery.trim() } : {}),
|
|
1220
|
+
};
|
|
1221
|
+
// Get ALL scored results for tracing (returnAll: true), filter for persistence later
|
|
1222
|
+
const _evalStart = Date.now();
|
|
1223
|
+
const _traceEmitterSerial = requestContext.getStore()?.traceEmitter;
|
|
1224
|
+
_traceEmitterSerial?.({ type: "agent_start", name: "opportunity-evaluator" });
|
|
1225
|
+
let opportunitiesWithActors;
|
|
1226
|
+
try {
|
|
1227
|
+
opportunitiesWithActors = await evaluator.invokeEntityBundle(input, { minScore, returnAll: true });
|
|
1228
|
+
const _evalDuration = Date.now() - _evalStart;
|
|
1229
|
+
agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
|
|
1230
|
+
_traceEmitterSerial?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `Evaluated ${candidateEntities.length} candidate(s)` });
|
|
1231
|
+
}
|
|
1232
|
+
catch (serialErr) {
|
|
1233
|
+
const _evalDuration = Date.now() - _evalStart;
|
|
1234
|
+
const _errMsg = serialErr instanceof Error ? serialErr.message : String(serialErr);
|
|
1235
|
+
agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
|
|
1236
|
+
_traceEmitterSerial?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `error — ${_errMsg}` });
|
|
1237
|
+
throw serialErr; // Re-throw for the outer catch to handle
|
|
1238
|
+
}
|
|
1239
|
+
// Split multi-actor evaluator results into pairwise (viewer + candidate).
|
|
1240
|
+
// Each persisted discovery opportunity should have exactly 2 actors.
|
|
1241
|
+
// When splitting, build per-candidate reasoning from entity data because
|
|
1242
|
+
// the shared reasoning typically describes only one candidate.
|
|
1243
|
+
pairwiseOpportunities = [];
|
|
1244
|
+
for (const op of opportunitiesWithActors) {
|
|
1245
|
+
const pairwiseSourceId = state.onBehalfOfUserId ?? state.userId;
|
|
1246
|
+
const nonViewerActors = op.actors.filter(a => a.userId !== pairwiseSourceId);
|
|
1247
|
+
if (nonViewerActors.length <= 1) {
|
|
1248
|
+
pairwiseOpportunities.push(op);
|
|
1249
|
+
}
|
|
1250
|
+
else {
|
|
1251
|
+
logger.warn('[Graph:Evaluation] Splitting multi-actor opportunity; LLM returned bundled actors instead of one-per-candidate', {
|
|
1252
|
+
actorCount: nonViewerActors.length,
|
|
1253
|
+
userIds: nonViewerActors.map(a => a.userId),
|
|
1254
|
+
});
|
|
1255
|
+
const viewerActor = op.actors.find(a => a.userId === pairwiseSourceId);
|
|
1256
|
+
for (const candidate of nonViewerActors) {
|
|
1257
|
+
const entity = candidateEntities.find(e => e.userId === candidate.userId);
|
|
1258
|
+
const candidateName = entity?.profile?.name ?? '';
|
|
1259
|
+
const reasoningLower = op.reasoning.toLowerCase();
|
|
1260
|
+
const mentionsCandidate = candidateName !== '' &&
|
|
1261
|
+
reasoningLower.includes(candidateName.toLowerCase());
|
|
1262
|
+
const mentionsOtherCandidate = nonViewerActors
|
|
1263
|
+
.filter((actor) => actor.userId !== candidate.userId)
|
|
1264
|
+
.map((actor) => candidateEntities.find((e) => e.userId === actor.userId)?.profile?.name?.toLowerCase())
|
|
1265
|
+
.some((name) => name != null && reasoningLower.includes(name));
|
|
1266
|
+
let reasoning;
|
|
1267
|
+
if (mentionsCandidate && !mentionsOtherCandidate) {
|
|
1268
|
+
reasoning = op.reasoning;
|
|
1269
|
+
}
|
|
1270
|
+
else if (entity?.profile) {
|
|
1271
|
+
const p = entity.profile;
|
|
1272
|
+
const parts = [p.name, p.bio].filter(Boolean);
|
|
1273
|
+
if (p.skills?.length)
|
|
1274
|
+
parts.push(`Skills: ${p.skills.join(', ')}`);
|
|
1275
|
+
if (p.interests?.length)
|
|
1276
|
+
parts.push(`Interests: ${p.interests.join(', ')}`);
|
|
1277
|
+
reasoning = parts.join('. ') || op.reasoning;
|
|
1278
|
+
}
|
|
1279
|
+
else {
|
|
1280
|
+
reasoning = op.reasoning;
|
|
1281
|
+
}
|
|
1282
|
+
pairwiseOpportunities.push({
|
|
1283
|
+
reasoning,
|
|
1284
|
+
score: op.score,
|
|
1285
|
+
actors: [
|
|
1286
|
+
viewerActor ?? { userId: pairwiseSourceId, role: 'patient', intentId: null },
|
|
1287
|
+
candidate,
|
|
1288
|
+
],
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
const evaluatedOpportunities = pairwiseOpportunities.map((op) => ({
|
|
1295
|
+
reasoning: op.reasoning,
|
|
1296
|
+
score: op.score,
|
|
1297
|
+
actors: op.actors.map((a) => {
|
|
1298
|
+
const isSource = a.userId === discoveryUserId;
|
|
1299
|
+
if (isSource) {
|
|
1300
|
+
// Source actor inherits the counterpart's indexId (shared match context)
|
|
1301
|
+
const counterpart = op.actors.find((other) => other.userId !== a.userId);
|
|
1302
|
+
const counterpartIndexId = counterpart
|
|
1303
|
+
? userIdToIndexId.get(counterpart.userId) ?? candidateEntities.find((e) => e.userId === counterpart.userId)?.indexId
|
|
1304
|
+
: undefined;
|
|
1305
|
+
return {
|
|
1306
|
+
userId: a.userId,
|
|
1307
|
+
role: a.role,
|
|
1308
|
+
intentId: a.intentId,
|
|
1309
|
+
indexId: counterpartIndexId ?? userIdToIndexId.get(a.userId) ?? '',
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
return {
|
|
1313
|
+
userId: a.userId,
|
|
1314
|
+
role: a.role,
|
|
1315
|
+
intentId: a.intentId,
|
|
1316
|
+
indexId: userIdToIndexId.get(a.userId) ?? candidateEntities.find((e) => e.userId === a.userId)?.indexId,
|
|
1317
|
+
};
|
|
1318
|
+
}),
|
|
1319
|
+
}));
|
|
1320
|
+
const passed = evaluatedOpportunities.filter((o) => o.score >= minScore);
|
|
1321
|
+
logger.verbose('[Graph:Evaluation] Evaluation complete', {
|
|
1322
|
+
evaluatedCount: evaluatedOpportunities.length,
|
|
1323
|
+
passed: passed.length,
|
|
1324
|
+
});
|
|
1325
|
+
// Build detailed trace entries for each evaluated candidate
|
|
1326
|
+
// Threshold filter trace: how many candidates in this batch were above/below similarity threshold
|
|
1327
|
+
const aboveThreshold = batchToEvaluate.filter(c => c.similarity >= 0.40).length;
|
|
1328
|
+
const belowThreshold = batchToEvaluate.length - aboveThreshold;
|
|
1329
|
+
traceEntries.push({
|
|
1330
|
+
node: "threshold_filter",
|
|
1331
|
+
detail: `${aboveThreshold} above 0.40, ${belowThreshold} below (batch of ${batchToEvaluate.length})`,
|
|
1332
|
+
data: {
|
|
1333
|
+
aboveThreshold,
|
|
1334
|
+
belowThreshold,
|
|
1335
|
+
minScore: 0.40,
|
|
1336
|
+
batchSize: batchToEvaluate.length,
|
|
1337
|
+
},
|
|
1338
|
+
});
|
|
1339
|
+
// Create a map of evaluated candidates by userId for quick lookup.
|
|
1340
|
+
// Use discoveryUserId (which accounts for onBehalfOfUserId in introducer flow)
|
|
1341
|
+
// rather than state.userId (which is the introducer, not present in pairwise actors).
|
|
1342
|
+
const evaluatedByUserId = new Map();
|
|
1343
|
+
for (const opp of evaluatedOpportunities) {
|
|
1344
|
+
const candidateActor = opp.actors.find(a => a.userId !== discoveryUserId);
|
|
1345
|
+
if (candidateActor) {
|
|
1346
|
+
evaluatedByUserId.set(candidateActor.userId, { score: opp.score, reasoning: opp.reasoning });
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
// Summary entry
|
|
1350
|
+
traceEntries.push({
|
|
1351
|
+
node: "evaluation",
|
|
1352
|
+
detail: `Evaluated ${candidateEntities.length} candidate(s) → ${passed.length} passed (min score ${minScore})`,
|
|
1353
|
+
data: {
|
|
1354
|
+
inputCandidates: batchToEvaluate.length,
|
|
1355
|
+
returnedFromEvaluator: evaluatedOpportunities.length,
|
|
1356
|
+
passedCount: passed.length,
|
|
1357
|
+
minScore,
|
|
1358
|
+
remaining: effectiveRemaining.length,
|
|
1359
|
+
batchNumber: 1,
|
|
1360
|
+
durationMs: Date.now() - startTime,
|
|
1361
|
+
model: getModelName("opportunityEvaluator"),
|
|
1362
|
+
},
|
|
1363
|
+
});
|
|
1364
|
+
// Individual candidate entries - show ALL candidates that went to evaluator
|
|
1365
|
+
for (const entity of candidateEntities) {
|
|
1366
|
+
const candidateName = entity.profile?.name || entity.userId.slice(0, 8);
|
|
1367
|
+
const candidateBio = entity.profile?.bio;
|
|
1368
|
+
const evaluated = evaluatedByUserId.get(entity.userId);
|
|
1369
|
+
const score = evaluated?.score;
|
|
1370
|
+
const reasoning = evaluated?.reasoning;
|
|
1371
|
+
const didPass = score !== undefined && score >= minScore;
|
|
1372
|
+
const status = score !== undefined
|
|
1373
|
+
? (didPass ? '✓ passed' : `✗ score ${score}`)
|
|
1374
|
+
: '✗ not scored';
|
|
1375
|
+
traceEntries.push({
|
|
1376
|
+
node: "candidate",
|
|
1377
|
+
detail: `${candidateName}: ${status}`,
|
|
1378
|
+
data: {
|
|
1379
|
+
userId: entity.userId,
|
|
1380
|
+
name: candidateName,
|
|
1381
|
+
bio: candidateBio,
|
|
1382
|
+
score: score,
|
|
1383
|
+
passed: didPass,
|
|
1384
|
+
reasoning: reasoning || 'No evaluation returned for this candidate',
|
|
1385
|
+
matchedVia: entity.matchedVia,
|
|
1386
|
+
ragScore: entity.ragScore,
|
|
1387
|
+
model: getModelName("opportunityEvaluator"),
|
|
1388
|
+
intents: entity.intents?.map((i) => ({
|
|
1389
|
+
intentId: i.intentId,
|
|
1390
|
+
summary: (i.summary || i.payload || '').slice(0, 100),
|
|
1391
|
+
})),
|
|
1392
|
+
profile: entity.profile ? {
|
|
1393
|
+
name: entity.profile.name,
|
|
1394
|
+
location: entity.profile.location,
|
|
1395
|
+
} : undefined,
|
|
1396
|
+
},
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
// Only pass opportunities that passed the threshold to downstream nodes
|
|
1400
|
+
const passedOpportunities = evaluatedOpportunities.filter((o) => o.score >= minScore);
|
|
1401
|
+
return {
|
|
1402
|
+
evaluatedOpportunities: passedOpportunities,
|
|
1403
|
+
remainingCandidates: effectiveRemaining,
|
|
1404
|
+
trace: traceEntries,
|
|
1405
|
+
agentTimings: agentTimingsAccum,
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
catch (error) {
|
|
1409
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
1410
|
+
logger.error('[Graph:Evaluation] Failed', { error });
|
|
1411
|
+
return {
|
|
1412
|
+
evaluatedOpportunities: [],
|
|
1413
|
+
error: 'Failed to evaluate candidates.',
|
|
1414
|
+
trace: [{
|
|
1415
|
+
node: "evaluation_fatal",
|
|
1416
|
+
detail: `Evaluation failed: ${errMsg}`,
|
|
1417
|
+
data: {
|
|
1418
|
+
error: errMsg,
|
|
1419
|
+
candidateCount: state.candidates?.length ?? 0,
|
|
1420
|
+
durationMs: Date.now() - startTime,
|
|
1421
|
+
},
|
|
1422
|
+
}],
|
|
1423
|
+
agentTimings: agentTimingsAccum,
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
};
|
|
1428
|
+
/**
|
|
1429
|
+
* Node 3b: Negotiate
|
|
1430
|
+
* Runs bilateral negotiation between source user and each evaluated candidate.
|
|
1431
|
+
* Filters out candidates that fail to produce an opportunity; updates scores for those that pass.
|
|
1432
|
+
*/
|
|
1433
|
+
const negotiateNode = async (state) => {
|
|
1434
|
+
if (!this.negotiationGraph)
|
|
1435
|
+
return {};
|
|
1436
|
+
const traceEmitter = requestContext.getStore()?.traceEmitter;
|
|
1437
|
+
const graphStart = Date.now();
|
|
1438
|
+
traceEmitter?.({ type: "graph_start", name: "Negotiation graph" });
|
|
1439
|
+
try {
|
|
1440
|
+
// Use the same discoveryUserId pattern as evaluationNode
|
|
1441
|
+
const discoveryUserId = (state.onBehalfOfUserId ?? state.userId);
|
|
1442
|
+
const sourceAccount = await this.database.getUser(discoveryUserId).catch(() => null);
|
|
1443
|
+
const sourceUser = {
|
|
1444
|
+
id: discoveryUserId,
|
|
1445
|
+
intents: state.indexedIntents?.slice(0, 5).map(i => ({
|
|
1446
|
+
id: i.intentId,
|
|
1447
|
+
title: i.summary ?? '',
|
|
1448
|
+
description: i.payload ?? '',
|
|
1449
|
+
confidence: 1,
|
|
1450
|
+
})) ?? [],
|
|
1451
|
+
profile: {
|
|
1452
|
+
name: state.sourceProfile?.identity?.name ?? sourceAccount?.name,
|
|
1453
|
+
bio: state.sourceProfile?.identity?.bio ?? sourceAccount?.intro ?? undefined,
|
|
1454
|
+
location: state.sourceProfile?.identity?.location ?? sourceAccount?.location ?? undefined,
|
|
1455
|
+
skills: state.sourceProfile?.attributes?.skills,
|
|
1456
|
+
interests: state.sourceProfile?.attributes?.interests,
|
|
1457
|
+
},
|
|
1458
|
+
};
|
|
1459
|
+
// Build candidates with enriched context from database.
|
|
1460
|
+
// Each actor carries its own indexId — use it for per-candidate index context.
|
|
1461
|
+
const candidateEntries = state.evaluatedOpportunities
|
|
1462
|
+
.map(opp => {
|
|
1463
|
+
const candidateActor = opp.actors.find(a => a.userId !== discoveryUserId);
|
|
1464
|
+
if (!candidateActor)
|
|
1465
|
+
return null;
|
|
1466
|
+
return { opp, candidateActor };
|
|
1467
|
+
})
|
|
1468
|
+
.filter((e) => e !== null);
|
|
1469
|
+
const candidates = await Promise.all(candidateEntries.map(async ({ opp, candidateActor }) => {
|
|
1470
|
+
const userId = candidateActor.userId;
|
|
1471
|
+
const [profile, user, activeIntents, intent] = await Promise.all([
|
|
1472
|
+
this.database.getProfile(userId).catch(() => null),
|
|
1473
|
+
this.database.getUser(userId).catch(() => null),
|
|
1474
|
+
this.database.getActiveIntents(userId).catch(() => []),
|
|
1475
|
+
candidateActor.intentId
|
|
1476
|
+
? this.database.getIntent(candidateActor.intentId).catch(() => null)
|
|
1477
|
+
: null,
|
|
1478
|
+
]);
|
|
1479
|
+
// Prefer active intents (capped at 5, trigger intent first); fall back to single intent.
|
|
1480
|
+
// If the trigger intent was archived but we fetched it by ID, prepend it so negotiation
|
|
1481
|
+
// always includes the intent that produced the opportunity match.
|
|
1482
|
+
const toNegIntent = (ai) => ({
|
|
1483
|
+
id: (ai.id ?? candidateActor.intentId),
|
|
1484
|
+
title: ai.summary ?? '',
|
|
1485
|
+
description: ai.payload ?? '',
|
|
1486
|
+
confidence: 1,
|
|
1487
|
+
});
|
|
1488
|
+
const triggerInActive = activeIntents.some(ai => ai.id === candidateActor.intentId);
|
|
1489
|
+
const triggerFallback = !triggerInActive && intent ? [toNegIntent(intent)] : [];
|
|
1490
|
+
const candidateIntents = [
|
|
1491
|
+
...triggerFallback,
|
|
1492
|
+
...activeIntents.filter(ai => ai.id === candidateActor.intentId).map(toNegIntent),
|
|
1493
|
+
...activeIntents.filter(ai => ai.id !== candidateActor.intentId).map(toNegIntent),
|
|
1494
|
+
].slice(0, 5);
|
|
1495
|
+
return {
|
|
1496
|
+
userId,
|
|
1497
|
+
score: opp.score,
|
|
1498
|
+
reasoning: opp.reasoning,
|
|
1499
|
+
valencyRole: candidateActor.role ?? 'peer',
|
|
1500
|
+
indexId: candidateActor.indexId,
|
|
1501
|
+
candidateUser: {
|
|
1502
|
+
id: userId,
|
|
1503
|
+
intents: candidateIntents,
|
|
1504
|
+
profile: {
|
|
1505
|
+
name: profile?.identity?.name ?? user?.name,
|
|
1506
|
+
bio: profile?.identity?.bio ?? user?.intro ?? undefined,
|
|
1507
|
+
location: profile?.identity?.location ?? user?.location ?? undefined,
|
|
1508
|
+
skills: profile?.attributes?.skills,
|
|
1509
|
+
interests: profile?.attributes?.interests,
|
|
1510
|
+
},
|
|
1511
|
+
},
|
|
1512
|
+
};
|
|
1513
|
+
}));
|
|
1514
|
+
const isChatPath = !!state.options?.conversationId;
|
|
1515
|
+
const maxTurns = isChatPath ? 4 : 6;
|
|
1516
|
+
// Fetch per-candidate index context (group by indexId to avoid duplicate lookups)
|
|
1517
|
+
const uniqueIndexIds = [...new Set(candidates.map(c => c.indexId).filter((id) => !!id))];
|
|
1518
|
+
const indexContextMap = new Map();
|
|
1519
|
+
await Promise.all(uniqueIndexIds.map(async (indexId) => {
|
|
1520
|
+
const ctx = await this.database.getIndexMemberContext(indexId, discoveryUserId).catch(() => null);
|
|
1521
|
+
const prompt = [ctx?.indexPrompt, ctx?.memberPrompt]
|
|
1522
|
+
.filter((v) => !!v?.trim())
|
|
1523
|
+
.join('\n\n');
|
|
1524
|
+
if (prompt)
|
|
1525
|
+
indexContextMap.set(indexId, prompt);
|
|
1526
|
+
}));
|
|
1527
|
+
// Run negotiations per candidate with their actual index context
|
|
1528
|
+
const acceptedResults = await negotiateCandidates(this.negotiationGraph, sourceUser, candidates, { indexId: '', prompt: '' }, // base context, overridden per-candidate below
|
|
1529
|
+
{ maxTurns, traceEmitter: traceEmitter ?? undefined,
|
|
1530
|
+
indexContextOverrides: indexContextMap });
|
|
1531
|
+
// Filter opportunities to only those with an opportunity outcome, update scores
|
|
1532
|
+
const acceptedMap = new Map(acceptedResults.map(r => [r.userId, r]));
|
|
1533
|
+
const updatedOpportunities = state.evaluatedOpportunities
|
|
1534
|
+
.filter(opp => {
|
|
1535
|
+
const candidateActor = opp.actors.find(a => a.userId !== discoveryUserId);
|
|
1536
|
+
return candidateActor && acceptedMap.has(candidateActor.userId);
|
|
1537
|
+
})
|
|
1538
|
+
.map(opp => {
|
|
1539
|
+
const candidateActor = opp.actors.find(a => a.userId !== discoveryUserId);
|
|
1540
|
+
const negResult = candidateActor && acceptedMap.get(candidateActor.userId);
|
|
1541
|
+
return negResult ? { ...opp, score: negResult.negotiationScore } : opp;
|
|
1542
|
+
});
|
|
1543
|
+
traceEmitter?.({ type: "graph_end", name: "Negotiation graph", durationMs: Date.now() - graphStart });
|
|
1544
|
+
return { evaluatedOpportunities: updatedOpportunities };
|
|
1545
|
+
}
|
|
1546
|
+
catch (err) {
|
|
1547
|
+
logger.error("[Graph:Negotiate] Negotiation stage failed", { error: err });
|
|
1548
|
+
traceEmitter?.({ type: "graph_end", name: "Negotiation graph", durationMs: Date.now() - graphStart });
|
|
1549
|
+
return { evaluatedOpportunities: [] };
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
/**
|
|
1553
|
+
* Node 4: Ranking
|
|
1554
|
+
* Sorts evaluated opportunities by score, applies limit, dedupes by actor-set hash.
|
|
1555
|
+
*/
|
|
1556
|
+
const rankingNode = withNodeTrace("opportunity-ranking", async (state) => {
|
|
1557
|
+
return timed("OpportunityGraph.ranking", async () => {
|
|
1558
|
+
logger.verbose('[Graph:Ranking] Starting ranking', {
|
|
1559
|
+
evaluatedCount: state.evaluatedOpportunities.length,
|
|
1560
|
+
});
|
|
1561
|
+
try {
|
|
1562
|
+
const sorted = [...state.evaluatedOpportunities].sort((a, b) => b.score - a.score);
|
|
1563
|
+
const limit = state.options.limit ?? 20;
|
|
1564
|
+
const ranked = sorted.slice(0, limit);
|
|
1565
|
+
const actorSetKey = (opp) => opp.actors
|
|
1566
|
+
.map((a) => `${a.userId}:${a.indexId}`)
|
|
1567
|
+
.sort()
|
|
1568
|
+
.join('|');
|
|
1569
|
+
const seen = new Set();
|
|
1570
|
+
const deduplicated = ranked.filter((opp) => {
|
|
1571
|
+
const key = actorSetKey(opp);
|
|
1572
|
+
if (seen.has(key))
|
|
1573
|
+
return false;
|
|
1574
|
+
seen.add(key);
|
|
1575
|
+
return true;
|
|
1576
|
+
});
|
|
1577
|
+
logger.verbose('[Graph:Ranking] Ranking complete', {
|
|
1578
|
+
sorted: sorted.length,
|
|
1579
|
+
afterLimit: ranked.length,
|
|
1580
|
+
afterDedup: deduplicated.length,
|
|
1581
|
+
});
|
|
1582
|
+
return { evaluatedOpportunities: deduplicated };
|
|
1583
|
+
}
|
|
1584
|
+
catch (error) {
|
|
1585
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
1586
|
+
logger.error('[Graph:Ranking] Failed', { error });
|
|
1587
|
+
return {
|
|
1588
|
+
evaluatedOpportunities: [],
|
|
1589
|
+
error: 'Failed to rank opportunities.',
|
|
1590
|
+
trace: [{
|
|
1591
|
+
node: "ranking_fatal",
|
|
1592
|
+
detail: `Ranking failed: ${errMsg}`,
|
|
1593
|
+
data: { error: errMsg },
|
|
1594
|
+
}],
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
}, (result) => {
|
|
1599
|
+
const r = result;
|
|
1600
|
+
if (r?.error)
|
|
1601
|
+
return `error: ${r.error}`;
|
|
1602
|
+
const opps = r?.evaluatedOpportunities;
|
|
1603
|
+
return opps ? `Ranked ${opps.length} opportunity(ies)` : undefined;
|
|
1604
|
+
});
|
|
1605
|
+
/**
|
|
1606
|
+
* Node: intro_validation (create_introduction path)
|
|
1607
|
+
* Validates index scope, membership for introducer and all party users, and no existing opportunity.
|
|
1608
|
+
*/
|
|
1609
|
+
const introValidationNode = async (state) => {
|
|
1610
|
+
return timed("OpportunityGraph.introValidation", async () => {
|
|
1611
|
+
logger.verbose('[Graph:IntroValidation] Starting', {
|
|
1612
|
+
userId: state.userId,
|
|
1613
|
+
indexId: state.indexId,
|
|
1614
|
+
entitiesCount: state.introductionEntities?.length ?? 0,
|
|
1615
|
+
});
|
|
1616
|
+
try {
|
|
1617
|
+
const entities = state.introductionEntities ?? [];
|
|
1618
|
+
const primaryIndexId = (state.indexId ?? entities[0]?.indexId);
|
|
1619
|
+
const partyUserIds = [...new Set(entities.map((e) => e.userId).filter((id) => id !== state.userId))];
|
|
1620
|
+
if (!primaryIndexId || partyUserIds.length < 1) {
|
|
1621
|
+
return {
|
|
1622
|
+
error: 'Introduction requires indexId and at least two entities (introducer + one counterpart).',
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
if (state.requiredIndexId && primaryIndexId !== state.requiredIndexId) {
|
|
1626
|
+
return {
|
|
1627
|
+
error: 'This chat is scoped to a different community. You can only introduce members of the current community.',
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
const introducerIsMember = await this.database.isIndexMember(primaryIndexId, state.userId);
|
|
1631
|
+
if (!introducerIsMember) {
|
|
1632
|
+
return {
|
|
1633
|
+
error: 'One or more users are not members of the specified community. You can only introduce members who share an index.',
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
const partyMemberships = await Promise.all(partyUserIds.map((userId) => this.database.isIndexMember(primaryIndexId, userId)));
|
|
1637
|
+
const allPartyMembers = partyMemberships.every(Boolean);
|
|
1638
|
+
if (!allPartyMembers) {
|
|
1639
|
+
return {
|
|
1640
|
+
error: 'One or more users are not members of the specified community. You can only introduce members who share an index.',
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
const exists = await this.database.opportunityExistsBetweenActors(partyUserIds, primaryIndexId);
|
|
1644
|
+
if (exists) {
|
|
1645
|
+
return { error: 'An opportunity already exists between these people.' };
|
|
1646
|
+
}
|
|
1647
|
+
logger.verbose('[Graph:IntroValidation] Validation passed');
|
|
1648
|
+
return {};
|
|
1649
|
+
}
|
|
1650
|
+
catch (err) {
|
|
1651
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1652
|
+
logger.error('[Graph:IntroValidation] Failed', {
|
|
1653
|
+
userId: state.userId,
|
|
1654
|
+
indexId: state.indexId,
|
|
1655
|
+
error: err,
|
|
1656
|
+
});
|
|
1657
|
+
return {
|
|
1658
|
+
error: 'Introduction validation failed.',
|
|
1659
|
+
trace: [{
|
|
1660
|
+
node: "intro_validation_fatal",
|
|
1661
|
+
detail: `IntroValidation failed: ${errMsg}`,
|
|
1662
|
+
data: { error: errMsg },
|
|
1663
|
+
}],
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1667
|
+
};
|
|
1668
|
+
/**
|
|
1669
|
+
* Build fallback reasoning and actors when evaluator returns empty or throws.
|
|
1670
|
+
*/
|
|
1671
|
+
function buildIntroFallback(entities, state, primaryIndexId, introducerName) {
|
|
1672
|
+
const reasoning = `${introducerName ?? 'A member'} believes these people should connect.` +
|
|
1673
|
+
(state.introductionHint ? ` Context: ${state.introductionHint}` : '');
|
|
1674
|
+
const score = 70;
|
|
1675
|
+
const partyUserIds = entities.map((e) => e.userId).filter((id) => id !== state.userId);
|
|
1676
|
+
const actors = partyUserIds.map((uid) => ({
|
|
1677
|
+
userId: uid,
|
|
1678
|
+
role: 'peer',
|
|
1679
|
+
indexId: primaryIndexId,
|
|
1680
|
+
}));
|
|
1681
|
+
return { reasoning, score, actors };
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Node: intro_evaluation (create_introduction path)
|
|
1685
|
+
* Runs entity-bundle evaluator and sets evaluatedOpportunities (one) + introductionContext.
|
|
1686
|
+
*/
|
|
1687
|
+
const introEvaluationNode = async (state) => {
|
|
1688
|
+
return timed("OpportunityGraph.introEvaluation", async () => {
|
|
1689
|
+
logger.verbose('[Graph:IntroEvaluation] Starting', { userId: state.userId });
|
|
1690
|
+
if (state.error) {
|
|
1691
|
+
return { evaluatedOpportunities: [], agentTimings: [] };
|
|
1692
|
+
}
|
|
1693
|
+
const entities = state.introductionEntities ?? [];
|
|
1694
|
+
const primaryIndexId = (state.indexId ?? entities[0]?.indexId);
|
|
1695
|
+
if (!primaryIndexId || entities.length < 2) {
|
|
1696
|
+
return { evaluatedOpportunities: [], error: 'Missing entities or index for introduction.', agentTimings: [] };
|
|
1697
|
+
}
|
|
1698
|
+
const agentTimingsAccum = [];
|
|
1699
|
+
let introducerName;
|
|
1700
|
+
let reasoning;
|
|
1701
|
+
let score;
|
|
1702
|
+
let actors = [];
|
|
1703
|
+
const _traceEmitterIntro = requestContext.getStore()?.traceEmitter;
|
|
1704
|
+
let _introEvalStarted = false;
|
|
1705
|
+
let _evalStart = Date.now();
|
|
1706
|
+
try {
|
|
1707
|
+
const introducerUser = await this.database.getUser(state.userId);
|
|
1708
|
+
introducerName = introducerUser?.name ?? undefined;
|
|
1709
|
+
const input = {
|
|
1710
|
+
discovererId: state.userId,
|
|
1711
|
+
entities,
|
|
1712
|
+
introductionMode: true,
|
|
1713
|
+
introducerName,
|
|
1714
|
+
introductionHint: state.introductionHint ?? undefined,
|
|
1715
|
+
};
|
|
1716
|
+
_evalStart = Date.now();
|
|
1717
|
+
_traceEmitterIntro?.({ type: "agent_start", name: "intro-evaluator" });
|
|
1718
|
+
_introEvalStarted = true;
|
|
1719
|
+
const evaluated = await evaluatorAgent.invokeEntityBundle(input, { minScore: 0 });
|
|
1720
|
+
const _introDuration = Date.now() - _evalStart;
|
|
1721
|
+
agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _introDuration });
|
|
1722
|
+
_traceEmitterIntro?.({ type: "agent_end", name: "intro-evaluator", durationMs: _introDuration, summary: "Evaluated introduction" });
|
|
1723
|
+
if (evaluated.length > 0) {
|
|
1724
|
+
const best = evaluated[0];
|
|
1725
|
+
reasoning = best.reasoning;
|
|
1726
|
+
score = best.score;
|
|
1727
|
+
actors = best.actors.map((a) => ({
|
|
1728
|
+
userId: a.userId,
|
|
1729
|
+
role: a.role,
|
|
1730
|
+
intentId: a.intentId ?? undefined,
|
|
1731
|
+
indexId: primaryIndexId,
|
|
1732
|
+
}));
|
|
1733
|
+
}
|
|
1734
|
+
else {
|
|
1735
|
+
const fallback = buildIntroFallback(entities, state, primaryIndexId, introducerName);
|
|
1736
|
+
reasoning = fallback.reasoning;
|
|
1737
|
+
score = fallback.score;
|
|
1738
|
+
actors = fallback.actors;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
catch (evalErr) {
|
|
1742
|
+
const errMsg = evalErr instanceof Error ? evalErr.message : String(evalErr);
|
|
1743
|
+
// Close the intro-evaluator span if it was started before the error
|
|
1744
|
+
if (_introEvalStarted) {
|
|
1745
|
+
const _introErrDuration = Date.now() - _evalStart;
|
|
1746
|
+
_traceEmitterIntro?.({ type: "agent_end", name: "intro-evaluator", durationMs: _introErrDuration, summary: `error — ${errMsg}` });
|
|
1747
|
+
agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _introErrDuration });
|
|
1748
|
+
}
|
|
1749
|
+
logger.warn('[Graph:IntroEvaluation] Evaluator or getUser failed, using fallback', { error: evalErr });
|
|
1750
|
+
const fallback = buildIntroFallback(entities, state, primaryIndexId, introducerName);
|
|
1751
|
+
reasoning = fallback.reasoning;
|
|
1752
|
+
score = fallback.score;
|
|
1753
|
+
actors = fallback.actors;
|
|
1754
|
+
return {
|
|
1755
|
+
evaluatedOpportunities: [{ actors, score, reasoning }],
|
|
1756
|
+
introductionContext: { createdByName: introducerName },
|
|
1757
|
+
options: { ...state.options, initialStatus: state.options.initialStatus ?? 'latent' },
|
|
1758
|
+
agentTimings: agentTimingsAccum,
|
|
1759
|
+
trace: [{
|
|
1760
|
+
node: "intro_evaluation_fatal",
|
|
1761
|
+
detail: `IntroEvaluation failed (using fallback): ${errMsg}`,
|
|
1762
|
+
data: { error: errMsg },
|
|
1763
|
+
}],
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
const evaluatedOpportunity = {
|
|
1767
|
+
actors,
|
|
1768
|
+
score,
|
|
1769
|
+
reasoning,
|
|
1770
|
+
};
|
|
1771
|
+
return {
|
|
1772
|
+
evaluatedOpportunities: [evaluatedOpportunity],
|
|
1773
|
+
introductionContext: { createdByName: introducerName },
|
|
1774
|
+
options: { ...state.options, initialStatus: state.options.initialStatus ?? 'latent' },
|
|
1775
|
+
agentTimings: agentTimingsAccum,
|
|
1776
|
+
};
|
|
1777
|
+
});
|
|
1778
|
+
};
|
|
1779
|
+
/**
|
|
1780
|
+
* Node 5: Persist
|
|
1781
|
+
* Creates opportunities from evaluator-proposed actors (indexId, userId, role, optional intent).
|
|
1782
|
+
*/
|
|
1783
|
+
const persistNode = withNodeTrace("opportunity-persist", async (state) => {
|
|
1784
|
+
return timed("OpportunityGraph.persist", async () => {
|
|
1785
|
+
const startTime = Date.now();
|
|
1786
|
+
logger.verbose('[Graph:Persist] Starting persistence (dedup-v2)', {
|
|
1787
|
+
opportunitiesToCreate: state.evaluatedOpportunities.length,
|
|
1788
|
+
initialStatus: state.options.initialStatus ?? 'pending',
|
|
1789
|
+
});
|
|
1790
|
+
if (state.evaluatedOpportunities.length === 0) {
|
|
1791
|
+
logger.verbose('[Graph:Persist] No opportunities to persist');
|
|
1792
|
+
return { opportunities: [] };
|
|
1793
|
+
}
|
|
1794
|
+
try {
|
|
1795
|
+
const itemsToPersist = [];
|
|
1796
|
+
const reactivatedOpportunities = [];
|
|
1797
|
+
const existingBetweenActors = [];
|
|
1798
|
+
const now = new Date().toISOString();
|
|
1799
|
+
const initialStatus = state.options.initialStatus ?? 'pending';
|
|
1800
|
+
// Only skip 'draft' (chat-only) opportunities during dedup.
|
|
1801
|
+
// 'latent' must NOT be skipped — background discovery creates latent opportunities,
|
|
1802
|
+
// and excluding them causes the same user pair to get duplicate opportunities
|
|
1803
|
+
// when multiple intents trigger separate discovery jobs (IND-166).
|
|
1804
|
+
const DEDUP_SKIP_STATUSES = ['draft'];
|
|
1805
|
+
const introducerUserForOnBehalf = state.onBehalfOfUserId
|
|
1806
|
+
? await this.database.getUser(state.userId)
|
|
1807
|
+
: null;
|
|
1808
|
+
for (const evaluated of state.evaluatedOpportunities) {
|
|
1809
|
+
const indexIdForActors = state.indexId ?? evaluated.actors[0]?.indexId;
|
|
1810
|
+
let actors;
|
|
1811
|
+
let data;
|
|
1812
|
+
logger.verbose('[Graph:Persist:PathSelect]', {
|
|
1813
|
+
isIntroduction: !!state.introductionContext,
|
|
1814
|
+
stateUserId: state.userId,
|
|
1815
|
+
stateIndexId: state.indexId,
|
|
1816
|
+
evaluatedActorUserIds: evaluated.actors.map(a => a.userId),
|
|
1817
|
+
});
|
|
1818
|
+
if (state.introductionContext) {
|
|
1819
|
+
if (indexIdForActors === undefined) {
|
|
1820
|
+
logger.warn('[Graph:Persist] Introduction path missing indexId; skipping opportunity', {
|
|
1821
|
+
userId: state.userId,
|
|
1822
|
+
actorsCount: evaluated.actors.length,
|
|
1823
|
+
});
|
|
1824
|
+
continue;
|
|
1825
|
+
}
|
|
1826
|
+
// Introduction path: manual detection, introducer actor, curator_judgment signal.
|
|
1827
|
+
const evaluatorActors = evaluated.actors.map((a) => ({
|
|
1828
|
+
indexId: a.indexId ?? indexIdForActors,
|
|
1829
|
+
userId: a.userId,
|
|
1830
|
+
role: a.role,
|
|
1831
|
+
...(a.intentId ? { intent: a.intentId } : {}),
|
|
1832
|
+
}));
|
|
1833
|
+
const viewerAlreadyInActors = evaluatorActors.some(a => a.userId === state.userId);
|
|
1834
|
+
actors = viewerAlreadyInActors
|
|
1835
|
+
? evaluatorActors
|
|
1836
|
+
: [
|
|
1837
|
+
...evaluatorActors,
|
|
1838
|
+
{ indexId: indexIdForActors, userId: state.userId, role: 'introducer' },
|
|
1839
|
+
];
|
|
1840
|
+
data = {
|
|
1841
|
+
detection: {
|
|
1842
|
+
source: 'manual',
|
|
1843
|
+
createdBy: state.userId,
|
|
1844
|
+
createdByName: state.introductionContext.createdByName,
|
|
1845
|
+
timestamp: now,
|
|
1846
|
+
},
|
|
1847
|
+
actors,
|
|
1848
|
+
interpretation: {
|
|
1849
|
+
category: 'collaboration',
|
|
1850
|
+
reasoning: evaluated.reasoning,
|
|
1851
|
+
confidence: evaluated.score / 100,
|
|
1852
|
+
signals: [
|
|
1853
|
+
{
|
|
1854
|
+
type: 'curator_judgment',
|
|
1855
|
+
weight: 1,
|
|
1856
|
+
detail: `Introduction by ${state.introductionContext.createdByName ?? 'a member'} via chat`,
|
|
1857
|
+
},
|
|
1858
|
+
],
|
|
1859
|
+
},
|
|
1860
|
+
context: {
|
|
1861
|
+
indexId: state.indexId ?? indexIdForActors,
|
|
1862
|
+
...(state.options.conversationId ? { conversationId: state.options.conversationId } : {}),
|
|
1863
|
+
},
|
|
1864
|
+
confidence: String(evaluated.score / 100),
|
|
1865
|
+
status: initialStatus,
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
else if (state.onBehalfOfUserId) {
|
|
1869
|
+
if (indexIdForActors === undefined) {
|
|
1870
|
+
logger.warn('[Graph:Persist] Introducer discovery path missing indexId; skipping opportunity', {
|
|
1871
|
+
userId: state.userId,
|
|
1872
|
+
actorsCount: evaluated.actors.length,
|
|
1873
|
+
});
|
|
1874
|
+
continue;
|
|
1875
|
+
}
|
|
1876
|
+
// Introducer discovery path: manual detection, introducer is state.userId, target is onBehalfOfUserId.
|
|
1877
|
+
const evaluatorActors = evaluated.actors.map((a) => ({
|
|
1878
|
+
indexId: a.indexId ?? indexIdForActors,
|
|
1879
|
+
userId: a.userId,
|
|
1880
|
+
role: a.role,
|
|
1881
|
+
...(a.intentId ? { intent: a.intentId } : {}),
|
|
1882
|
+
}));
|
|
1883
|
+
const viewerAlreadyInActors = evaluatorActors.some(a => a.userId === state.userId);
|
|
1884
|
+
actors = viewerAlreadyInActors
|
|
1885
|
+
? evaluatorActors
|
|
1886
|
+
: [
|
|
1887
|
+
...evaluatorActors,
|
|
1888
|
+
{ indexId: indexIdForActors, userId: state.userId, role: 'introducer' },
|
|
1889
|
+
];
|
|
1890
|
+
const candidateUserId = evaluated.actors.find((a) => a.userId !== state.onBehalfOfUserId)?.userId;
|
|
1891
|
+
const overlapping = candidateUserId
|
|
1892
|
+
? await this.database.findOverlappingOpportunities([state.onBehalfOfUserId, candidateUserId], { excludeStatuses: DEDUP_SKIP_STATUSES })
|
|
1893
|
+
: [];
|
|
1894
|
+
if (overlapping.length > 0) {
|
|
1895
|
+
const existing = overlapping[0];
|
|
1896
|
+
const sameIntroducer = existing.actors?.some((actor) => actor.role === 'introducer' && actor.userId === state.userId);
|
|
1897
|
+
if (existing.status === 'expired' && sameIntroducer) {
|
|
1898
|
+
const reactivated = await this.database.updateOpportunityStatus(existing.id, 'draft');
|
|
1899
|
+
if (reactivated)
|
|
1900
|
+
reactivatedOpportunities.push(reactivated);
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
if (existing.status === 'latent') {
|
|
1904
|
+
// Upgrade latent to draft for introduction path
|
|
1905
|
+
const upgraded = await this.database.updateOpportunityStatus(existing.id, 'draft');
|
|
1906
|
+
if (upgraded) {
|
|
1907
|
+
logger.verbose('[Graph:Persist] Upgraded latent opportunity to draft (introduction path)', {
|
|
1908
|
+
opportunityId: existing.id,
|
|
1909
|
+
candidateUserId,
|
|
1910
|
+
});
|
|
1911
|
+
reactivatedOpportunities.push(upgraded);
|
|
1912
|
+
}
|
|
1913
|
+
continue;
|
|
1914
|
+
}
|
|
1915
|
+
if (existing.status !== 'expired' && candidateUserId) {
|
|
1916
|
+
existingBetweenActors.push({
|
|
1917
|
+
candidateUserId: candidateUserId,
|
|
1918
|
+
indexId: (state.indexId ?? indexIdForActors ?? ''),
|
|
1919
|
+
existingOpportunityId: existing.id,
|
|
1920
|
+
existingStatus: existing.status,
|
|
1921
|
+
});
|
|
1922
|
+
continue;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
data = {
|
|
1926
|
+
detection: {
|
|
1927
|
+
source: 'manual',
|
|
1928
|
+
createdBy: state.userId,
|
|
1929
|
+
createdByName: introducerUserForOnBehalf?.name ?? undefined,
|
|
1930
|
+
timestamp: now,
|
|
1931
|
+
},
|
|
1932
|
+
actors,
|
|
1933
|
+
interpretation: {
|
|
1934
|
+
category: 'collaboration',
|
|
1935
|
+
reasoning: evaluated.reasoning,
|
|
1936
|
+
confidence: evaluated.score / 100,
|
|
1937
|
+
signals: [{
|
|
1938
|
+
type: 'curator_judgment',
|
|
1939
|
+
weight: 1,
|
|
1940
|
+
detail: `Discovery on behalf of another user by ${introducerUserForOnBehalf?.name ?? 'a member'} via chat`,
|
|
1941
|
+
}],
|
|
1942
|
+
},
|
|
1943
|
+
context: {
|
|
1944
|
+
indexId: state.indexId ?? indexIdForActors,
|
|
1945
|
+
...(state.options.conversationId ? { conversationId: state.options.conversationId } : {}),
|
|
1946
|
+
},
|
|
1947
|
+
confidence: String(evaluated.score / 100),
|
|
1948
|
+
status: initialStatus,
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
else {
|
|
1952
|
+
// Discovery path: opportunity_graph source, no introducer, lifecycle guard for agent/patient.
|
|
1953
|
+
const evaluatorActors = evaluated.actors.map((a) => ({
|
|
1954
|
+
indexId: a.indexId ?? indexIdForActors,
|
|
1955
|
+
userId: a.userId,
|
|
1956
|
+
role: a.role,
|
|
1957
|
+
...(a.intentId ? { intent: a.intentId } : {}),
|
|
1958
|
+
}));
|
|
1959
|
+
actors = evaluatorActors;
|
|
1960
|
+
const hasIntroducerActor = actors.some(a => a.role === 'introducer');
|
|
1961
|
+
if (!hasIntroducerActor) {
|
|
1962
|
+
const discovererIdx = actors.findIndex(a => a.userId === state.userId);
|
|
1963
|
+
if (discovererIdx >= 0 && actors[discovererIdx].role === 'agent') {
|
|
1964
|
+
const counterpartIdx = actors.findIndex((a, i) => i !== discovererIdx && a.role === 'patient');
|
|
1965
|
+
actors[discovererIdx] = { ...actors[discovererIdx], role: 'patient' };
|
|
1966
|
+
if (counterpartIdx >= 0) {
|
|
1967
|
+
actors[counterpartIdx] = { ...actors[counterpartIdx], role: 'agent' };
|
|
1968
|
+
}
|
|
1969
|
+
logger.verbose('[Graph:Persist] Swapped discoverer from agent to patient for lifecycle visibility', {
|
|
1970
|
+
discovererId: state.userId,
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
// Index-agnostic dedup: find ANY existing opportunity between these users,
|
|
1975
|
+
// regardless of which index it was created in or whether context.indexId is set.
|
|
1976
|
+
const candidateUserId = evaluated.actors.find((a) => a.userId !== state.userId)?.userId;
|
|
1977
|
+
logger.verbose('[Graph:Persist:Dedup] Checking overlapping opportunities', {
|
|
1978
|
+
stateUserId: state.userId,
|
|
1979
|
+
candidateUserId: candidateUserId ?? 'NONE',
|
|
1980
|
+
evaluatedActors: evaluated.actors.map(a => ({ userId: a.userId, role: a.role })),
|
|
1981
|
+
});
|
|
1982
|
+
const overlapping = candidateUserId
|
|
1983
|
+
? await this.database.findOverlappingOpportunities([state.userId, candidateUserId], { excludeStatuses: DEDUP_SKIP_STATUSES })
|
|
1984
|
+
: [];
|
|
1985
|
+
logger.verbose('[Graph:Persist:Dedup] findOverlappingOpportunities result', {
|
|
1986
|
+
count: overlapping.length,
|
|
1987
|
+
results: overlapping.map(o => ({ id: o.id, status: o.status, actors: o.actors?.map((a) => ({ userId: a.userId, role: a.role })) })),
|
|
1988
|
+
});
|
|
1989
|
+
if (overlapping.length > 0) {
|
|
1990
|
+
const existing = overlapping[0];
|
|
1991
|
+
const existingIndexId = (existing.context?.indexId ?? state.indexId ?? state.userIndexes?.[0] ?? '');
|
|
1992
|
+
if (existing.status === 'expired') {
|
|
1993
|
+
const reactivated = await this.database.updateOpportunityStatus(existing.id, initialStatus);
|
|
1994
|
+
if (reactivated) {
|
|
1995
|
+
logger.verbose('[Graph:Persist] Reactivated expired opportunity', {
|
|
1996
|
+
opportunityId: existing.id,
|
|
1997
|
+
candidateUserId,
|
|
1998
|
+
newStatus: initialStatus,
|
|
1999
|
+
});
|
|
2000
|
+
reactivatedOpportunities.push(reactivated);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
else if (existing.status === 'latent' && initialStatus !== 'latent') {
|
|
2004
|
+
// Upgrade latent (background-discovered) to the higher-priority status (e.g. pending)
|
|
2005
|
+
const upgraded = await this.database.updateOpportunityStatus(existing.id, initialStatus);
|
|
2006
|
+
if (upgraded) {
|
|
2007
|
+
logger.verbose('[Graph:Persist] Upgraded latent opportunity to higher-priority status', {
|
|
2008
|
+
opportunityId: existing.id,
|
|
2009
|
+
candidateUserId,
|
|
2010
|
+
previousStatus: 'latent',
|
|
2011
|
+
newStatus: initialStatus,
|
|
2012
|
+
});
|
|
2013
|
+
reactivatedOpportunities.push(upgraded);
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
else if (candidateUserId) {
|
|
2017
|
+
existingBetweenActors.push({
|
|
2018
|
+
candidateUserId: candidateUserId,
|
|
2019
|
+
indexId: existingIndexId,
|
|
2020
|
+
existingOpportunityId: existing.id,
|
|
2021
|
+
existingStatus: existing.status,
|
|
2022
|
+
});
|
|
2023
|
+
logger.verbose('[Graph:Persist] Skipping duplicate; opportunity already exists between actors', {
|
|
2024
|
+
candidateUserId,
|
|
2025
|
+
existingStatus: existing.status,
|
|
2026
|
+
existingOpportunityId: existing.id,
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
data = {
|
|
2032
|
+
detection: {
|
|
2033
|
+
source: 'opportunity_graph',
|
|
2034
|
+
createdBy: 'agent-opportunity-finder',
|
|
2035
|
+
...(state.discoverySource === 'intent' && state.resolvedTriggerIntentId
|
|
2036
|
+
? { triggeredBy: state.resolvedTriggerIntentId }
|
|
2037
|
+
: {}),
|
|
2038
|
+
timestamp: now,
|
|
2039
|
+
},
|
|
2040
|
+
actors,
|
|
2041
|
+
interpretation: {
|
|
2042
|
+
category: 'collaboration',
|
|
2043
|
+
reasoning: evaluated.reasoning,
|
|
2044
|
+
confidence: evaluated.score / 100,
|
|
2045
|
+
signals: [
|
|
2046
|
+
{
|
|
2047
|
+
type: evaluated.actors.some((a) => a.intentId) ? 'intent_match' : 'profile_match',
|
|
2048
|
+
weight: evaluated.score / 100,
|
|
2049
|
+
detail: 'Entity-bundle evaluator',
|
|
2050
|
+
},
|
|
2051
|
+
],
|
|
2052
|
+
},
|
|
2053
|
+
context: {
|
|
2054
|
+
...(state.indexId ? { indexId: state.indexId } : {}),
|
|
2055
|
+
...(state.options.conversationId ? { conversationId: state.options.conversationId } : {}),
|
|
2056
|
+
},
|
|
2057
|
+
confidence: String(evaluated.score / 100),
|
|
2058
|
+
status: initialStatus,
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
try {
|
|
2062
|
+
validateOpportunityActors(data.actors);
|
|
2063
|
+
}
|
|
2064
|
+
catch (err) {
|
|
2065
|
+
logger.warn('[Graph:Persist] Skipping opportunity with invalid actors', {
|
|
2066
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2067
|
+
opportunityReasoning: evaluated.reasoning?.slice(0, 80),
|
|
2068
|
+
});
|
|
2069
|
+
continue;
|
|
2070
|
+
}
|
|
2071
|
+
itemsToPersist.push(data);
|
|
2072
|
+
}
|
|
2073
|
+
const { created: createdList } = await persistOpportunities({
|
|
2074
|
+
database: this.database,
|
|
2075
|
+
embedder: this.embedder,
|
|
2076
|
+
items: itemsToPersist,
|
|
2077
|
+
});
|
|
2078
|
+
const allOpportunities = [...reactivatedOpportunities, ...createdList];
|
|
2079
|
+
logger.verbose('[Graph:Persist] Persistence complete', {
|
|
2080
|
+
created: createdList.length,
|
|
2081
|
+
reactivated: reactivatedOpportunities.length,
|
|
2082
|
+
existingBetweenActorsCount: existingBetweenActors.length,
|
|
2083
|
+
status: initialStatus,
|
|
2084
|
+
});
|
|
2085
|
+
return {
|
|
2086
|
+
opportunities: allOpportunities,
|
|
2087
|
+
existingBetweenActors,
|
|
2088
|
+
trace: [{
|
|
2089
|
+
node: "persist",
|
|
2090
|
+
detail: `Created ${createdList.length}, reactivated ${reactivatedOpportunities.length}, ${existingBetweenActors.length} existing skipped`,
|
|
2091
|
+
data: {
|
|
2092
|
+
created: createdList.length,
|
|
2093
|
+
reactivated: reactivatedOpportunities.length,
|
|
2094
|
+
existingSkipped: existingBetweenActors.length,
|
|
2095
|
+
totalOutput: allOpportunities.length,
|
|
2096
|
+
durationMs: Date.now() - startTime,
|
|
2097
|
+
},
|
|
2098
|
+
}],
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
catch (error) {
|
|
2102
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
2103
|
+
logger.error('[Graph:Persist] Failed', { error });
|
|
2104
|
+
return {
|
|
2105
|
+
opportunities: [],
|
|
2106
|
+
existingBetweenActors: [],
|
|
2107
|
+
error: 'Failed to persist opportunities.',
|
|
2108
|
+
trace: [{
|
|
2109
|
+
node: "persist_fatal",
|
|
2110
|
+
detail: `Persist failed: ${errMsg}`,
|
|
2111
|
+
data: { error: errMsg },
|
|
2112
|
+
}],
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
});
|
|
2116
|
+
}, (result) => {
|
|
2117
|
+
const r = result;
|
|
2118
|
+
if (r?.error)
|
|
2119
|
+
return `error: ${r.error}`;
|
|
2120
|
+
const opps = r?.opportunities;
|
|
2121
|
+
return opps ? `Persisted ${opps.length} opportunity(ies)` : undefined;
|
|
2122
|
+
});
|
|
2123
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2124
|
+
// CRUD NODES (read, update, delete, send)
|
|
2125
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2126
|
+
/**
|
|
2127
|
+
* Read Node: List opportunities for the user, optionally filtered by indexId.
|
|
2128
|
+
* Fast path — no LLM calls.
|
|
2129
|
+
*/
|
|
2130
|
+
const readNode = async (state) => {
|
|
2131
|
+
return timed("OpportunityGraph.read", async () => {
|
|
2132
|
+
logger.verbose('[Graph:Read] Listing opportunities', {
|
|
2133
|
+
userId: state.userId,
|
|
2134
|
+
indexId: state.indexId,
|
|
2135
|
+
});
|
|
2136
|
+
try {
|
|
2137
|
+
let indexIdFilter;
|
|
2138
|
+
if (state.indexId) {
|
|
2139
|
+
const isMember = await this.database.isIndexMember(state.indexId, state.userId);
|
|
2140
|
+
if (!isMember) {
|
|
2141
|
+
return {
|
|
2142
|
+
readResult: { count: 0, opportunities: [], message: 'Index not found or you are not a member.' },
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
indexIdFilter = state.indexId;
|
|
2146
|
+
}
|
|
2147
|
+
const rawList = await this.database.getOpportunitiesForUser(state.userId, {
|
|
2148
|
+
limit: 30,
|
|
2149
|
+
...(indexIdFilter ? { indexId: indexIdFilter } : {}),
|
|
2150
|
+
});
|
|
2151
|
+
const list = rawList.filter((opp) => opp.status !== 'expired');
|
|
2152
|
+
if (list.length === 0) {
|
|
2153
|
+
return {
|
|
2154
|
+
readResult: {
|
|
2155
|
+
count: 0,
|
|
2156
|
+
message: 'You have no opportunities yet. Use create_opportunities to search for connections.',
|
|
2157
|
+
opportunities: [],
|
|
2158
|
+
},
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
// Dedupe by counterpart set (same people = one row) so chat does not show "You and X" per index
|
|
2162
|
+
const counterpartKey = (opp) => opp.actors
|
|
2163
|
+
.filter((a) => a.userId !== state.userId && a.role !== 'introducer')
|
|
2164
|
+
.map((a) => a.userId)
|
|
2165
|
+
.sort()
|
|
2166
|
+
.join(',');
|
|
2167
|
+
const byKey = new Map();
|
|
2168
|
+
for (const opp of list) {
|
|
2169
|
+
const key = counterpartKey(opp);
|
|
2170
|
+
const existing = byKey.get(key);
|
|
2171
|
+
const conf = Number(opp.interpretation?.confidence ?? opp.confidence ?? 0);
|
|
2172
|
+
const existingConf = existing ? Number(existing.interpretation?.confidence ?? existing.confidence ?? 0) : 0;
|
|
2173
|
+
const oppTime = opp.updatedAt instanceof Date ? opp.updatedAt.getTime() : new Date(opp.updatedAt).getTime();
|
|
2174
|
+
const existingTime = existing
|
|
2175
|
+
? (existing.updatedAt instanceof Date ? existing.updatedAt.getTime() : new Date(existing.updatedAt).getTime())
|
|
2176
|
+
: 0;
|
|
2177
|
+
if (!existing || conf > existingConf || (conf === existingConf && oppTime > existingTime)) {
|
|
2178
|
+
byKey.set(key, opp);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
const dedupedList = [...byKey.values()];
|
|
2182
|
+
const sourceLabel = {
|
|
2183
|
+
chat: 'Suggested in chat',
|
|
2184
|
+
opportunity_graph: 'System match',
|
|
2185
|
+
manual: 'Manual',
|
|
2186
|
+
cron: 'Scheduled',
|
|
2187
|
+
member_added: 'Member added',
|
|
2188
|
+
};
|
|
2189
|
+
const enriched = await Promise.all(dedupedList.map(async (opp) => {
|
|
2190
|
+
// "Other parties" = all actors who are not the current user (exclude introducer for suggestedBy).
|
|
2191
|
+
// Opportunity graph persists roles as 'agent'|'patient'|'peer'; manual/createManual use 'party'.
|
|
2192
|
+
const otherParties = opp.actors.filter((a) => a.userId !== state.userId && a.role !== 'introducer');
|
|
2193
|
+
const introducer = opp.actors.find((a) => a.role === 'introducer');
|
|
2194
|
+
const partyIds = otherParties.map((a) => a.userId);
|
|
2195
|
+
const idsToResolve = introducer ? [...partyIds, introducer.userId] : partyIds;
|
|
2196
|
+
// Use the counterpart's (non-viewer) indexId — it reflects where the match was found.
|
|
2197
|
+
// actors[0] is typically the viewer with an arbitrary first-target-index value.
|
|
2198
|
+
const counterpartActor = opp.actors.find((a) => a.userId !== state.userId);
|
|
2199
|
+
const actorIndexId = counterpartActor?.indexId ?? opp.actors[0]?.indexId;
|
|
2200
|
+
const [indexRecord, ...profileAndUserPairs] = await Promise.all([
|
|
2201
|
+
actorIndexId ? this.database.getIndex(actorIndexId) : Promise.resolve(null),
|
|
2202
|
+
...idsToResolve.map(async (uid) => {
|
|
2203
|
+
const [profile, user] = await Promise.all([
|
|
2204
|
+
this.database.getProfile(uid),
|
|
2205
|
+
this.database.getUser(uid),
|
|
2206
|
+
]);
|
|
2207
|
+
return (profile?.identity?.name ?? user?.name ?? 'Unknown');
|
|
2208
|
+
}),
|
|
2209
|
+
]);
|
|
2210
|
+
const connectedWith = profileAndUserPairs.slice(0, partyIds.length);
|
|
2211
|
+
const suggestedBy = introducer ? profileAndUserPairs[partyIds.length] ?? null : null;
|
|
2212
|
+
const category = opp.interpretation?.category ?? 'connection';
|
|
2213
|
+
const confidence = opp.interpretation?.confidence ?? (opp.confidence ? Number(opp.confidence) : null);
|
|
2214
|
+
const source = opp.detection?.source ? (sourceLabel[opp.detection.source] ?? opp.detection.source) : null;
|
|
2215
|
+
return {
|
|
2216
|
+
id: opp.id,
|
|
2217
|
+
indexName: indexRecord?.title ?? (actorIndexId ?? ''),
|
|
2218
|
+
connectedWith,
|
|
2219
|
+
suggestedBy,
|
|
2220
|
+
reasoning: opp.interpretation?.reasoning ?? 'Connection opportunity',
|
|
2221
|
+
status: opp.status,
|
|
2222
|
+
category,
|
|
2223
|
+
confidence: confidence != null ? confidence : null,
|
|
2224
|
+
source,
|
|
2225
|
+
};
|
|
2226
|
+
}));
|
|
2227
|
+
return {
|
|
2228
|
+
readResult: {
|
|
2229
|
+
count: enriched.length,
|
|
2230
|
+
message: `You have ${enriched.length} opportunity(ies).`,
|
|
2231
|
+
opportunities: enriched,
|
|
2232
|
+
},
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
catch (err) {
|
|
2236
|
+
logger.error('[Graph:Read] Failed', { error: err });
|
|
2237
|
+
return {
|
|
2238
|
+
readResult: { count: 0, opportunities: [], message: 'Failed to list opportunities.' },
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
});
|
|
2242
|
+
};
|
|
2243
|
+
/**
|
|
2244
|
+
* Update Node: Change opportunity status (accept, reject, etc.).
|
|
2245
|
+
*/
|
|
2246
|
+
const updateNode = async (state) => {
|
|
2247
|
+
return timed("OpportunityGraph.update", async () => {
|
|
2248
|
+
logger.verbose('[Graph:Update] Updating opportunity status', {
|
|
2249
|
+
userId: state.userId,
|
|
2250
|
+
opportunityId: state.opportunityId,
|
|
2251
|
+
newStatus: state.newStatus,
|
|
2252
|
+
});
|
|
2253
|
+
try {
|
|
2254
|
+
if (!state.opportunityId) {
|
|
2255
|
+
return { mutationResult: { success: false, error: 'opportunityId is required.' } };
|
|
2256
|
+
}
|
|
2257
|
+
if (!state.newStatus || !['accepted', 'rejected', 'expired'].includes(state.newStatus)) {
|
|
2258
|
+
return { mutationResult: { success: false, error: 'newStatus must be one of: accepted, rejected, expired.' } };
|
|
2259
|
+
}
|
|
2260
|
+
const opp = await this.database.getOpportunity(state.opportunityId);
|
|
2261
|
+
if (!opp) {
|
|
2262
|
+
return { mutationResult: { success: false, error: 'Opportunity not found.' } };
|
|
2263
|
+
}
|
|
2264
|
+
const isActor = opp.actors.some((a) => a.userId === state.userId);
|
|
2265
|
+
if (!isActor) {
|
|
2266
|
+
return { mutationResult: { success: false, error: 'You are not part of this opportunity.' } };
|
|
2267
|
+
}
|
|
2268
|
+
await this.database.updateOpportunityStatus(state.opportunityId, state.newStatus);
|
|
2269
|
+
return {
|
|
2270
|
+
mutationResult: {
|
|
2271
|
+
success: true,
|
|
2272
|
+
opportunityId: state.opportunityId,
|
|
2273
|
+
message: `Opportunity status updated to ${state.newStatus}.`,
|
|
2274
|
+
},
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
catch (err) {
|
|
2278
|
+
logger.error('[Graph:Update] Failed', { error: err });
|
|
2279
|
+
return { mutationResult: { success: false, error: 'Failed to update opportunity.' } };
|
|
2280
|
+
}
|
|
2281
|
+
});
|
|
2282
|
+
};
|
|
2283
|
+
/**
|
|
2284
|
+
* Delete Node: Expire/archive an opportunity.
|
|
2285
|
+
*/
|
|
2286
|
+
const deleteNode = async (state) => {
|
|
2287
|
+
return timed("OpportunityGraph.delete", async () => {
|
|
2288
|
+
logger.verbose('[Graph:Delete] Expiring opportunity', {
|
|
2289
|
+
userId: state.userId,
|
|
2290
|
+
opportunityId: state.opportunityId,
|
|
2291
|
+
});
|
|
2292
|
+
try {
|
|
2293
|
+
if (!state.opportunityId) {
|
|
2294
|
+
return { mutationResult: { success: false, error: 'opportunityId is required.' } };
|
|
2295
|
+
}
|
|
2296
|
+
const opp = await this.database.getOpportunity(state.opportunityId);
|
|
2297
|
+
if (!opp) {
|
|
2298
|
+
return { mutationResult: { success: false, error: 'Opportunity not found.' } };
|
|
2299
|
+
}
|
|
2300
|
+
const isActor = opp.actors.some((a) => a.userId === state.userId);
|
|
2301
|
+
if (!isActor) {
|
|
2302
|
+
return { mutationResult: { success: false, error: 'You are not part of this opportunity.' } };
|
|
2303
|
+
}
|
|
2304
|
+
await this.database.updateOpportunityStatus(state.opportunityId, 'expired');
|
|
2305
|
+
return {
|
|
2306
|
+
mutationResult: {
|
|
2307
|
+
success: true,
|
|
2308
|
+
opportunityId: state.opportunityId,
|
|
2309
|
+
message: 'Opportunity archived (expired).',
|
|
2310
|
+
},
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
catch (err) {
|
|
2314
|
+
logger.error('[Graph:Delete] Failed', { error: err });
|
|
2315
|
+
return { mutationResult: { success: false, error: 'Failed to delete opportunity.' } };
|
|
2316
|
+
}
|
|
2317
|
+
});
|
|
2318
|
+
};
|
|
2319
|
+
/**
|
|
2320
|
+
* Send Node: Promote latent or draft opportunity to pending + queue notification.
|
|
2321
|
+
*/
|
|
2322
|
+
const sendNode = async (state) => {
|
|
2323
|
+
return timed("OpportunityGraph.send", async () => {
|
|
2324
|
+
logger.verbose('[Graph:Send] Sending opportunity', {
|
|
2325
|
+
userId: state.userId,
|
|
2326
|
+
opportunityId: state.opportunityId,
|
|
2327
|
+
});
|
|
2328
|
+
try {
|
|
2329
|
+
if (!state.opportunityId) {
|
|
2330
|
+
return { mutationResult: { success: false, error: 'opportunityId is required.' } };
|
|
2331
|
+
}
|
|
2332
|
+
const opp = await this.database.getOpportunity(state.opportunityId);
|
|
2333
|
+
if (!opp) {
|
|
2334
|
+
return { mutationResult: { success: false, error: 'Opportunity not found.' } };
|
|
2335
|
+
}
|
|
2336
|
+
const canSendStatus = opp.status === 'latent' || opp.status === 'draft';
|
|
2337
|
+
if (!canSendStatus) {
|
|
2338
|
+
return {
|
|
2339
|
+
mutationResult: {
|
|
2340
|
+
success: false,
|
|
2341
|
+
error: `Opportunity is already ${opp.status}; only latent or draft opportunities can be sent.`,
|
|
2342
|
+
},
|
|
2343
|
+
};
|
|
2344
|
+
}
|
|
2345
|
+
const senderActor = opp.actors.find((a) => a.userId === state.userId);
|
|
2346
|
+
const hasIntroducer = opp.actors.some((a) => a.role === 'introducer');
|
|
2347
|
+
const canSend = senderActor?.role === 'introducer' ||
|
|
2348
|
+
senderActor?.role === 'peer' ||
|
|
2349
|
+
(senderActor?.role === 'patient' && !hasIntroducer) ||
|
|
2350
|
+
(senderActor?.role === 'party' && !hasIntroducer);
|
|
2351
|
+
if (!senderActor) {
|
|
2352
|
+
return { mutationResult: { success: false, error: 'You are not part of this opportunity.' } };
|
|
2353
|
+
}
|
|
2354
|
+
if (!canSend) {
|
|
2355
|
+
return { mutationResult: { success: false, error: 'You cannot send this opportunity.' } };
|
|
2356
|
+
}
|
|
2357
|
+
await this.database.updateOpportunityStatus(state.opportunityId, 'pending');
|
|
2358
|
+
// Notify only the role that becomes visible at the next tier
|
|
2359
|
+
let recipients;
|
|
2360
|
+
if (senderActor.role === 'introducer') {
|
|
2361
|
+
recipients = opp.actors.filter((a) => a.role === 'patient' || a.role === 'party');
|
|
2362
|
+
}
|
|
2363
|
+
else if (senderActor.role === 'peer') {
|
|
2364
|
+
recipients = opp.actors.filter((a) => a.role === 'peer' && a.userId !== state.userId);
|
|
2365
|
+
}
|
|
2366
|
+
else {
|
|
2367
|
+
recipients = opp.actors.filter((a) => a.role === 'agent');
|
|
2368
|
+
}
|
|
2369
|
+
// queueNotification is injected via constructor; if not provided, notifications are skipped.
|
|
2370
|
+
const notifier = this.queueNotification;
|
|
2371
|
+
if (notifier) {
|
|
2372
|
+
for (const recipient of recipients) {
|
|
2373
|
+
await notifier(opp.id, recipient.userId, 'high');
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
const recipientIds = recipients.map((a) => a.userId);
|
|
2377
|
+
return {
|
|
2378
|
+
mutationResult: {
|
|
2379
|
+
success: true,
|
|
2380
|
+
opportunityId: opp.id,
|
|
2381
|
+
notified: recipientIds,
|
|
2382
|
+
message: 'Opportunity sent. The other person has been notified.',
|
|
2383
|
+
},
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
2386
|
+
catch (err) {
|
|
2387
|
+
logger.error('[Graph:Send] Failed', { error: err });
|
|
2388
|
+
return { mutationResult: { success: false, error: 'Failed to send opportunity.' } };
|
|
2389
|
+
}
|
|
2390
|
+
});
|
|
2391
|
+
};
|
|
2392
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2393
|
+
// CONDITIONAL ROUTING FUNCTIONS
|
|
2394
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2395
|
+
/**
|
|
2396
|
+
* Router: Decides which path based on operationMode.
|
|
2397
|
+
*/
|
|
2398
|
+
const routeByMode = (state) => {
|
|
2399
|
+
const mode = state.operationMode ?? 'create';
|
|
2400
|
+
if (mode === 'read')
|
|
2401
|
+
return 'read';
|
|
2402
|
+
if (mode === 'update')
|
|
2403
|
+
return 'update';
|
|
2404
|
+
if (mode === 'delete')
|
|
2405
|
+
return 'delete_opp';
|
|
2406
|
+
if (mode === 'send')
|
|
2407
|
+
return 'send';
|
|
2408
|
+
if (mode === 'create_introduction')
|
|
2409
|
+
return 'intro_validation';
|
|
2410
|
+
// 'create' is the default discovery pipeline
|
|
2411
|
+
return 'prep';
|
|
2412
|
+
};
|
|
2413
|
+
/**
|
|
2414
|
+
* After prep: check if user has indexed intents.
|
|
2415
|
+
* Early exit if none (cannot find opportunities).
|
|
2416
|
+
*/
|
|
2417
|
+
const shouldContinueAfterPrep = (state) => {
|
|
2418
|
+
if (state.error) {
|
|
2419
|
+
logger.verbose('[Graph:Routing] Error in prep - ending early');
|
|
2420
|
+
return END;
|
|
2421
|
+
}
|
|
2422
|
+
// Continuation mode: skip scope/resolve/discovery, go straight to evaluation
|
|
2423
|
+
if (state.operationMode === 'continue_discovery') {
|
|
2424
|
+
logger.verbose('[Graph:Routing] Continue discovery → skipping to evaluation', {
|
|
2425
|
+
candidatesLoaded: state.candidates.length,
|
|
2426
|
+
});
|
|
2427
|
+
return 'evaluation';
|
|
2428
|
+
}
|
|
2429
|
+
logger.verbose('[Graph:Routing] Continuing to scope');
|
|
2430
|
+
return 'scope';
|
|
2431
|
+
};
|
|
2432
|
+
/**
|
|
2433
|
+
* After scope: check if we have target indexes.
|
|
2434
|
+
*/
|
|
2435
|
+
const shouldContinueAfterScope = (state) => {
|
|
2436
|
+
if (state.error || state.targetIndexes.length === 0) {
|
|
2437
|
+
logger.verbose('[Graph:Routing] No target indexes - ending early');
|
|
2438
|
+
return END;
|
|
2439
|
+
}
|
|
2440
|
+
logger.verbose('[Graph:Routing] Continuing to resolve');
|
|
2441
|
+
return 'resolve';
|
|
2442
|
+
};
|
|
2443
|
+
/**
|
|
2444
|
+
* After discovery: if create-intent signal was set, end so tool can return it; else continue to evaluation.
|
|
2445
|
+
*/
|
|
2446
|
+
const shouldContinueAfterDiscovery = (state) => {
|
|
2447
|
+
if (state.createIntentSuggested) {
|
|
2448
|
+
logger.verbose('[Graph:Routing] Create-intent suggested - ending for tool signal');
|
|
2449
|
+
return END;
|
|
2450
|
+
}
|
|
2451
|
+
return 'evaluation';
|
|
2452
|
+
};
|
|
2453
|
+
/**
|
|
2454
|
+
* After intro_validation: if validation set state.error, end early; else continue to intro_evaluation.
|
|
2455
|
+
*/
|
|
2456
|
+
const routeAfterIntroValidation = (state) => {
|
|
2457
|
+
if (state.error) {
|
|
2458
|
+
logger.verbose('[Graph:Routing] Intro validation error - ending early');
|
|
2459
|
+
return END;
|
|
2460
|
+
}
|
|
2461
|
+
return 'intro_evaluation';
|
|
2462
|
+
};
|
|
2463
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2464
|
+
// GRAPH ASSEMBLY
|
|
2465
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2466
|
+
const workflow = new StateGraph(OpportunityGraphState)
|
|
2467
|
+
// Add all nodes
|
|
2468
|
+
.addNode('prep', prepNode)
|
|
2469
|
+
.addNode('scope', scopeNode)
|
|
2470
|
+
.addNode('resolve', resolveNode)
|
|
2471
|
+
.addNode('discovery', discoveryNode)
|
|
2472
|
+
.addNode('evaluation', evaluationNode)
|
|
2473
|
+
.addNode('ranking', rankingNode)
|
|
2474
|
+
.addNode('intro_validation', introValidationNode)
|
|
2475
|
+
.addNode('intro_evaluation', introEvaluationNode)
|
|
2476
|
+
.addNode('persist', persistNode)
|
|
2477
|
+
// CRUD nodes
|
|
2478
|
+
.addNode('read', readNode)
|
|
2479
|
+
.addNode('update', updateNode)
|
|
2480
|
+
.addNode('delete_opp', deleteNode)
|
|
2481
|
+
.addNode('send', sendNode)
|
|
2482
|
+
// Route by operation mode from START
|
|
2483
|
+
.addConditionalEdges(START, routeByMode, {
|
|
2484
|
+
prep: 'prep',
|
|
2485
|
+
intro_validation: 'intro_validation',
|
|
2486
|
+
read: 'read',
|
|
2487
|
+
update: 'update',
|
|
2488
|
+
delete_opp: 'delete_opp',
|
|
2489
|
+
send: 'send',
|
|
2490
|
+
})
|
|
2491
|
+
// Introduction path: validation -> evaluation -> persist (or END on validation error)
|
|
2492
|
+
.addConditionalEdges('intro_validation', routeAfterIntroValidation, {
|
|
2493
|
+
intro_evaluation: 'intro_evaluation',
|
|
2494
|
+
[END]: END,
|
|
2495
|
+
})
|
|
2496
|
+
.addEdge('intro_evaluation', 'persist')
|
|
2497
|
+
// CRUD fast paths -> END
|
|
2498
|
+
.addEdge('read', END)
|
|
2499
|
+
.addEdge('update', END)
|
|
2500
|
+
.addEdge('delete_opp', END)
|
|
2501
|
+
.addEdge('send', END)
|
|
2502
|
+
// Conditional routing: early exit if no indexed intents
|
|
2503
|
+
.addConditionalEdges('prep', shouldContinueAfterPrep, {
|
|
2504
|
+
scope: 'scope',
|
|
2505
|
+
evaluation: 'evaluation',
|
|
2506
|
+
[END]: END,
|
|
2507
|
+
})
|
|
2508
|
+
// Conditional routing: early exit if no target indexes
|
|
2509
|
+
.addConditionalEdges('scope', shouldContinueAfterScope, {
|
|
2510
|
+
resolve: 'resolve',
|
|
2511
|
+
[END]: END,
|
|
2512
|
+
})
|
|
2513
|
+
.addEdge('resolve', 'discovery')
|
|
2514
|
+
.addConditionalEdges('discovery', shouldContinueAfterDiscovery, {
|
|
2515
|
+
evaluation: 'evaluation',
|
|
2516
|
+
[END]: END,
|
|
2517
|
+
})
|
|
2518
|
+
// Negotiation step (optional, skipped for continue_discovery or when no negotiation graph)
|
|
2519
|
+
.addNode('negotiate', negotiateNode)
|
|
2520
|
+
.addConditionalEdges('evaluation', (state) => {
|
|
2521
|
+
if (state.operationMode === 'continue_discovery')
|
|
2522
|
+
return 'ranking';
|
|
2523
|
+
return 'negotiate';
|
|
2524
|
+
}, {
|
|
2525
|
+
negotiate: 'negotiate',
|
|
2526
|
+
ranking: 'ranking',
|
|
2527
|
+
})
|
|
2528
|
+
.addEdge('negotiate', 'ranking')
|
|
2529
|
+
.addEdge('ranking', 'persist')
|
|
2530
|
+
.addEdge('persist', END);
|
|
2531
|
+
return workflow.compile();
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
//# sourceMappingURL=opportunity.graph.js.map
|