@indexnetwork/protocol 14.3.2-rc.477.1 → 16.1.1-rc.478.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/IMPLEMENTATION.md +50 -52
- package/STABILITY.md +3 -3
- package/dist/chat/chat.agent.js +2 -2
- package/dist/chat/chat.graph.d.ts +41 -1
- package/dist/chat/chat.graph.js +108 -127
- package/dist/discovery/hyde.frame.d.ts +4 -4
- package/dist/discovery/hyde.graph.d.ts +71 -12
- package/dist/discovery/hyde.graph.js +337 -335
- package/dist/discovery/lens.inferrer.d.ts +6 -6
- package/dist/enrichment/enrichment.graph.d.ts +127 -9
- package/dist/enrichment/enrichment.graph.js +633 -655
- package/dist/enrichment/enrichment.state.d.ts +1 -1
- package/dist/enrichment/enrichment.tools.context-read.d.ts +10 -0
- package/dist/enrichment/enrichment.tools.context-read.js +353 -0
- package/dist/enrichment/enrichment.tools.context-write.d.ts +9 -0
- package/dist/enrichment/enrichment.tools.context-write.js +417 -0
- package/dist/enrichment/enrichment.tools.d.ts +11 -1
- package/dist/enrichment/enrichment.tools.helpers.d.ts +139 -0
- package/dist/enrichment/enrichment.tools.helpers.js +234 -0
- package/dist/enrichment/enrichment.tools.js +16 -975
- package/dist/index.d.ts +27 -48
- package/dist/index.js +17 -27
- package/dist/intents/application/intent.graph.d.ts +71 -67
- package/dist/intents/application/intent.graph.execute.d.ts +59 -0
- package/dist/intents/application/intent.graph.execute.js +301 -0
- package/dist/intents/application/intent.graph.infer.d.ts +44 -0
- package/dist/intents/application/intent.graph.infer.js +97 -0
- package/dist/intents/application/intent.graph.js +96 -888
- package/dist/intents/application/intent.graph.reconcile.d.ts +71 -0
- package/dist/intents/application/intent.graph.reconcile.js +274 -0
- package/dist/intents/application/intent.graph.shared.d.ts +70 -0
- package/dist/intents/application/intent.graph.shared.js +153 -0
- package/dist/intents/domain/intent.state.d.ts +1 -1
- package/dist/maintenance/maintenance.graph.d.ts +66 -3
- package/dist/maintenance/maintenance.graph.js +155 -156
- package/dist/mcp/mcp.authorization-policy.d.ts +24 -28
- package/dist/mcp/mcp.authorization-policy.js +14 -34
- package/dist/mcp/mcp.server.d.ts +3 -3
- package/dist/mcp/mcp.server.js +12 -16
- package/dist/negotiations/application/negotiation.candidates.d.ts +83 -0
- package/dist/negotiations/application/negotiation.candidates.js +162 -0
- package/dist/negotiations/application/negotiation.graph.d.ts +91 -157
- package/dist/negotiations/application/negotiation.graph.finalize.d.ts +5 -0
- package/dist/negotiations/application/negotiation.graph.finalize.js +280 -0
- package/dist/negotiations/application/negotiation.graph.init.d.ts +54 -0
- package/dist/negotiations/application/negotiation.graph.init.js +227 -0
- package/dist/negotiations/application/negotiation.graph.js +69 -1388
- package/dist/negotiations/application/negotiation.graph.screen.d.ts +23 -0
- package/dist/negotiations/application/negotiation.graph.screen.js +108 -0
- package/dist/negotiations/application/negotiation.graph.shared.d.ts +75 -0
- package/dist/negotiations/application/negotiation.graph.shared.js +125 -0
- package/dist/negotiations/application/negotiation.graph.turn.d.ts +105 -0
- package/dist/negotiations/application/negotiation.graph.turn.js +484 -0
- package/dist/negotiations/domain/negotiation.state.d.ts +1 -1
- package/dist/negotiations/domain/negotiation.state.js +0 -1
- package/dist/networks/application/indexer.graph.d.ts +165 -7
- package/dist/networks/application/indexer.graph.js +339 -388
- package/dist/networks/application/indexer.state.d.ts +1 -1
- package/dist/networks/application/membership.graph.d.ts +83 -5
- package/dist/networks/application/membership.graph.js +177 -207
- package/dist/networks/application/network.graph.d.ts +148 -5
- package/dist/networks/application/network.graph.js +249 -278
- package/dist/networks/domain/membership.state.d.ts +1 -1
- package/dist/networks/domain/network.state.d.ts +1 -1
- package/dist/opportunities/application/delivery-card.cache.d.ts +1 -1
- package/dist/opportunities/application/delivery-card.cache.js +2 -2
- package/dist/opportunities/application/index.d.ts +2 -2
- package/dist/opportunities/application/index.js +2 -2
- package/dist/opportunities/application/opportunity.evaluator.js +1 -1
- package/dist/opportunities/application/opportunity.graph.d.ts +686 -473
- package/dist/opportunities/application/opportunity.graph.discovery-strategies.d.ts +109 -0
- package/dist/opportunities/application/opportunity.graph.discovery-strategies.js +451 -0
- package/dist/opportunities/application/opportunity.graph.discovery.d.ts +69 -0
- package/dist/opportunities/application/opportunity.graph.discovery.js +397 -0
- package/dist/opportunities/application/opportunity.graph.evaluation.d.ts +82 -0
- package/dist/opportunities/application/opportunity.graph.evaluation.js +608 -0
- package/dist/opportunities/application/opportunity.graph.js +120 -3690
- package/dist/opportunities/application/opportunity.graph.modes.d.ts +538 -0
- package/dist/opportunities/application/opportunity.graph.modes.js +538 -0
- package/dist/opportunities/application/opportunity.graph.negotiate.d.ts +109 -0
- package/dist/opportunities/application/opportunity.graph.negotiate.js +392 -0
- package/dist/opportunities/application/opportunity.graph.persist-node.d.ts +93 -0
- package/dist/opportunities/application/opportunity.graph.persist-node.js +765 -0
- package/dist/opportunities/application/opportunity.graph.prep.d.ts +150 -0
- package/dist/opportunities/application/opportunity.graph.prep.js +381 -0
- package/dist/opportunities/application/opportunity.graph.shared.d.ts +173 -0
- package/dist/opportunities/application/opportunity.graph.shared.js +199 -0
- package/dist/opportunities/application/opportunity.presentation.d.ts +399 -0
- package/dist/opportunities/application/{opportunity.presenter.js → opportunity.presentation.js} +735 -9
- package/dist/opportunities/application/opportunity.tools.cards.d.ts +152 -0
- package/dist/opportunities/application/opportunity.tools.cards.js +235 -0
- package/dist/opportunities/application/opportunity.tools.d.ts +8 -114
- package/dist/opportunities/application/opportunity.tools.js +28 -711
- package/dist/opportunities/application/opportunity.tools.list.d.ts +10 -0
- package/dist/opportunities/application/opportunity.tools.list.js +493 -0
- package/dist/opportunities/domain/index.d.ts +3 -3
- package/dist/opportunities/domain/index.js +3 -3
- package/dist/opportunities/domain/opportunity.state.d.ts +19 -19
- package/dist/opportunities/index.d.ts +6 -6
- package/dist/opportunities/index.js +4 -4
- package/dist/opportunities/ports/opportunity.tools.port.d.ts +1 -1
- package/dist/opportunities/radar/radar.graph.d.ts +57 -13
- package/dist/opportunities/radar/radar.graph.js +470 -471
- package/dist/premises/premise.graph.d.ts +88 -20
- package/dist/premises/premise.graph.js +207 -218
- package/dist/questions/domain/question.schema.d.ts +40 -40
- package/dist/shared/agent/tool.helpers.d.ts +21 -0
- package/dist/shared/agent/tool.registry.d.ts +4 -3
- package/dist/shared/agent/tool.registry.js +1 -1
- package/dist/shared/interfaces/database.capabilities.d.ts +100 -0
- package/dist/shared/interfaces/database.capabilities.js +7 -0
- package/dist/shared/interfaces/database.entities.d.ts +533 -0
- package/dist/shared/interfaces/database.entities.js +10 -0
- package/dist/shared/interfaces/database.identity-queries.d.ts +294 -0
- package/dist/shared/interfaces/database.identity-queries.js +4 -0
- package/dist/shared/interfaces/database.interface.d.ts +15 -2277
- package/dist/shared/interfaces/database.interface.js +8 -0
- package/dist/shared/interfaces/database.member-queries.d.ts +228 -0
- package/dist/shared/interfaces/database.member-queries.js +4 -0
- package/dist/shared/interfaces/database.negotiation.d.ts +305 -0
- package/dist/shared/interfaces/database.negotiation.js +26 -0
- package/dist/shared/interfaces/database.network-queries.d.ts +277 -0
- package/dist/shared/interfaces/database.network-queries.js +4 -0
- package/dist/shared/interfaces/database.opportunity-queries.d.ts +285 -0
- package/dist/shared/interfaces/database.opportunity-queries.js +4 -0
- package/dist/shared/interfaces/database.port.d.ts +322 -0
- package/dist/shared/interfaces/database.port.js +29 -0
- package/dist/shared/schemas/discovery-question.schema.d.ts +14 -14
- package/dist/shared/schemas/negotiation-digest.schema.d.ts +2 -2
- package/package.json +2 -15
- package/dist/opportunities/application/opportunity.card-presentation.d.ts +0 -44
- package/dist/opportunities/application/opportunity.card-presentation.js +0 -93
- package/dist/opportunities/application/opportunity.presenter.d.ts +0 -156
- package/dist/opportunities/domain/opportunity.presentation-cache.d.ts +0 -5
- package/dist/opportunities/domain/opportunity.presentation-cache.js +0 -11
- package/dist/opportunities/domain/opportunity.presentation.d.ts +0 -76
- package/dist/opportunities/domain/opportunity.presentation.js +0 -516
- package/dist/opportunities/domain/opportunity.safe-presentation.d.ts +0 -104
- package/dist/opportunities/domain/opportunity.safe-presentation.js +0 -102
|
@@ -35,718 +35,696 @@ function isMeaningfulProfileInput(input) {
|
|
|
35
35
|
return false;
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
39
|
-
* Factory class to build and compile the Profile Generation Graph.
|
|
40
|
-
*
|
|
41
|
-
* Flow:
|
|
42
|
-
* 1. check_state - Detect whether profile needs generation
|
|
43
|
-
* 2. Conditional routing based on operation mode and missing components:
|
|
44
|
-
* - Query mode: Return immediately (fast path)
|
|
45
|
-
* - Write mode: Generate only what's needed
|
|
46
|
-
* 3. Profile generation (if needed)
|
|
47
|
-
* 4. Save profile to DB
|
|
48
|
-
*
|
|
49
|
-
* Key Features:
|
|
50
|
-
* - Read/Write separation (query vs write)
|
|
51
|
-
* - Conditional generation (skip generation if profile already exists)
|
|
52
|
-
*/
|
|
53
38
|
export class EnrichmentGraphFactory {
|
|
54
39
|
constructor(database, scraper, enricher, premiseGraph) {
|
|
55
|
-
this.
|
|
56
|
-
this.scraper = scraper;
|
|
57
|
-
this.enricher = enricher;
|
|
58
|
-
this.premiseGraph = premiseGraph;
|
|
40
|
+
this.deps = { database, scraper, enricher, premiseGraph, premiseDecomposer: new PremiseDecomposer() };
|
|
59
41
|
}
|
|
60
42
|
createGraph() {
|
|
61
|
-
const
|
|
43
|
+
const deps = this.deps;
|
|
62
44
|
// ─────────────────────────────────────────────────────────
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// - Profile missing
|
|
66
|
-
// - User information insufficient for scraping
|
|
45
|
+
// GRAPH ASSEMBLY
|
|
46
|
+
// Conditional flow based on operation mode and detected needs
|
|
67
47
|
// ─────────────────────────────────────────────────────────
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
48
|
+
const workflow = new StateGraph(EnrichmentGraphState)
|
|
49
|
+
// Add all nodes
|
|
50
|
+
.addNode("check_state", (state) => checkStateNode(state, deps))
|
|
51
|
+
.addNode("scrape", (state) => scrapeNode(state, deps))
|
|
52
|
+
.addNode("decompose_premises", (state) => decomposePremisesNode(state, deps))
|
|
53
|
+
.addNode("auto_generate", (state) => autoGenerateNode(state, deps))
|
|
54
|
+
// Start with state check
|
|
55
|
+
.addEdge(START, "check_state")
|
|
56
|
+
// Conditional routing from check_state
|
|
57
|
+
.addConditionalEdges("check_state", (state) => checkStateCondition(state, deps), {
|
|
58
|
+
auto_generate: "auto_generate", // Generate mode -> Chat API enrichment
|
|
59
|
+
decompose_premises: "decompose_premises", // Write mode + input + premise graph -> decompose
|
|
60
|
+
scrape: "scrape", // Need premises, no input -> scrape first
|
|
61
|
+
[END]: END // Query mode, no premise graph, or everything exists
|
|
62
|
+
})
|
|
63
|
+
// Decompose premises creates premises as a side effect, then ends.
|
|
64
|
+
// user_context regeneration is handled downstream by premise lifecycle events.
|
|
65
|
+
.addEdge("decompose_premises", END)
|
|
66
|
+
// Auto-generate routes to premise decomposition (when premise graph
|
|
67
|
+
// available + enrichment produced input), otherwise ends.
|
|
68
|
+
.addConditionalEdges("auto_generate", (state) => {
|
|
69
|
+
if (state.input && deps.premiseGraph) {
|
|
70
|
+
logger.verbose("Enrichment produced input — routing to premise decomposition");
|
|
71
|
+
return "decompose_premises";
|
|
72
|
+
}
|
|
73
|
+
logger.verbose("Enrichment ended without usable input (ghost soft-deleted, error, or no premise graph) — done");
|
|
74
|
+
return END;
|
|
75
|
+
}, {
|
|
76
|
+
decompose_premises: "decompose_premises",
|
|
77
|
+
[END]: END,
|
|
78
|
+
})
|
|
79
|
+
// Scrape -> decompose_premises (when premise graph available), else ends.
|
|
80
|
+
.addConditionalEdges("scrape", (_state) => {
|
|
81
|
+
if (deps.premiseGraph)
|
|
82
|
+
return "decompose_premises";
|
|
83
|
+
return END;
|
|
84
|
+
}, {
|
|
85
|
+
decompose_premises: "decompose_premises",
|
|
86
|
+
[END]: END,
|
|
87
|
+
});
|
|
88
|
+
logger.verbose("Graph built successfully");
|
|
89
|
+
return workflow.compile();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// ─────────────────────────────────────────────────────────
|
|
93
|
+
// NODE: Check State
|
|
94
|
+
// Loads existing profile from DB and detects what needs generation:
|
|
95
|
+
// - Profile missing
|
|
96
|
+
// - User information insufficient for scraping
|
|
97
|
+
// ─────────────────────────────────────────────────────────
|
|
98
|
+
export async function checkStateNode(state, deps) {
|
|
99
|
+
return timed("ProfileGraph.checkState", async () => {
|
|
100
|
+
if (!state.userId) {
|
|
101
|
+
logger.error("Missing userId");
|
|
102
|
+
return {
|
|
103
|
+
error: "userId is required"
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
logger.verbose("Checking profile state...", {
|
|
107
|
+
userId: state.userId,
|
|
108
|
+
operationMode: state.operationMode,
|
|
109
|
+
forceUpdate: state.forceUpdate
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
const profile = await deps.database.getProfile(state.userId);
|
|
113
|
+
// "Has a profile" now means the user has been enriched into ACTIVE premises
|
|
114
|
+
// (the user_profiles replacement). `getProfile` returns a users-sourced row
|
|
115
|
+
// for every existing user, so its presence no longer signals enrichment --
|
|
116
|
+
// the premise graph is the source of truth for whether generation has run.
|
|
117
|
+
const hasBeenEnriched = (await deps.database.getPremisesForUser(state.userId, 'ACTIVE')).length > 0;
|
|
118
|
+
// Query mode: Just return the profile (fast path)
|
|
119
|
+
if (state.operationMode === 'query') {
|
|
120
|
+
logger.verbose("🚀 Query mode - returning existing profile (fast path)", {
|
|
121
|
+
hasProfile: hasBeenEnriched
|
|
122
|
+
});
|
|
123
|
+
const profileWithId = hasBeenEnriched ? await deps.database.getProfileByUserId(state.userId) : null;
|
|
124
|
+
return {
|
|
125
|
+
profile: hasBeenEnriched ? (profile || undefined) : undefined,
|
|
126
|
+
readResult: hasBeenEnriched
|
|
127
|
+
? {
|
|
128
|
+
hasProfile: true,
|
|
129
|
+
// Thin identity only. The structured skills/interests attributes are
|
|
130
|
+
// retired (user_profiles removal, WS6); the rich identity text now
|
|
131
|
+
// comes from the global user_context, injected by read_user_contexts.
|
|
132
|
+
profile: {
|
|
133
|
+
id: profileWithId?.id,
|
|
134
|
+
name: profile?.identity?.name,
|
|
135
|
+
bio: profile?.identity?.bio,
|
|
136
|
+
location: profile?.identity?.location,
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
: {
|
|
140
|
+
hasProfile: false,
|
|
141
|
+
message: "You don't have a profile yet. Would you like to create one? You can share your LinkedIn, GitHub, or X/Twitter profile, or just tell me about yourself.",
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Write mode: Detect what needs generation
|
|
146
|
+
// Treat confirmation-only input (e.g. "Yes") as no input so we ask for info / use scraper
|
|
147
|
+
const hasMeaningfulInput = !!state.input && isMeaningfulProfileInput(state.input);
|
|
148
|
+
const needsProfileGeneration = !hasBeenEnriched || (state.forceUpdate && hasMeaningfulInput);
|
|
149
|
+
// Check if we need to scrape (profile generation needed but no meaningful input provided)
|
|
150
|
+
const willNeedScraping = needsProfileGeneration && !hasMeaningfulInput;
|
|
151
|
+
// If we need to scrape, check if we have sufficient user information
|
|
152
|
+
let needsUserInfo = false;
|
|
153
|
+
const missingUserInfo = [];
|
|
154
|
+
if (willNeedScraping) {
|
|
155
|
+
logger.verbose("Will need scraping - checking user information...");
|
|
156
|
+
const user = await deps.database.getUser(state.userId);
|
|
157
|
+
if (!user) {
|
|
158
|
+
logger.error("User not found", { userId: state.userId });
|
|
72
159
|
return {
|
|
73
|
-
error:
|
|
160
|
+
error: `User not found: ${state.userId}`
|
|
74
161
|
};
|
|
75
162
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
hasProfile: true,
|
|
99
|
-
// Thin identity only. The structured skills/interests attributes are
|
|
100
|
-
// retired (user_profiles removal, WS6); the rich identity text now
|
|
101
|
-
// comes from the global user_context, injected by read_user_contexts.
|
|
102
|
-
profile: {
|
|
103
|
-
id: profileWithId?.id,
|
|
104
|
-
name: profile?.identity?.name,
|
|
105
|
-
bio: profile?.identity?.bio,
|
|
106
|
-
location: profile?.identity?.location,
|
|
107
|
-
},
|
|
108
|
-
}
|
|
109
|
-
: {
|
|
110
|
-
hasProfile: false,
|
|
111
|
-
message: "You don't have a profile yet. Would you like to create one? You can share your LinkedIn, GitHub, or X/Twitter profile, or just tell me about yourself.",
|
|
112
|
-
},
|
|
113
|
-
};
|
|
163
|
+
// Check what information we have from the user table (schema: users)
|
|
164
|
+
// Required fields: email, name (always present)
|
|
165
|
+
// Optional fields: intro, avatar, location, socials
|
|
166
|
+
const socials = await deps.database.getUserSocials(state.userId);
|
|
167
|
+
const hasSocials = socials.length > 0;
|
|
168
|
+
// Check if name is a full name (not just email username)
|
|
169
|
+
// For scraping to work well, we need first + last name
|
|
170
|
+
const hasMeaningfulName = user.name &&
|
|
171
|
+
user.name.trim() !== '' &&
|
|
172
|
+
!user.name.includes('@') &&
|
|
173
|
+
user.name.split(/\s+/).filter(Boolean).length >= 2;
|
|
174
|
+
const hasLocation = !!(user.location && user.location.trim() !== '');
|
|
175
|
+
// Minimum requirement for accurate scraping:
|
|
176
|
+
// - At least ONE social link (preferred - most reliable for finding the right person)
|
|
177
|
+
// - OR a full name (first + last) - less reliable but workable
|
|
178
|
+
// Location helps disambiguate but is not required
|
|
179
|
+
const hasMinimumInfo = hasSocials || hasMeaningfulName;
|
|
180
|
+
if (!hasMinimumInfo) {
|
|
181
|
+
needsUserInfo = true;
|
|
182
|
+
// Build precise list of what's missing and would help
|
|
183
|
+
if (!hasSocials) {
|
|
184
|
+
missingUserInfo.push('social_urls');
|
|
114
185
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const hasMeaningfulInput = !!state.input && isMeaningfulProfileInput(state.input);
|
|
118
|
-
const needsProfileGeneration = !hasBeenEnriched || (state.forceUpdate && hasMeaningfulInput);
|
|
119
|
-
// Check if we need to scrape (profile generation needed but no meaningful input provided)
|
|
120
|
-
const willNeedScraping = needsProfileGeneration && !hasMeaningfulInput;
|
|
121
|
-
// If we need to scrape, check if we have sufficient user information
|
|
122
|
-
let needsUserInfo = false;
|
|
123
|
-
const missingUserInfo = [];
|
|
124
|
-
if (willNeedScraping) {
|
|
125
|
-
logger.verbose("Will need scraping - checking user information...");
|
|
126
|
-
const user = await this.database.getUser(state.userId);
|
|
127
|
-
if (!user) {
|
|
128
|
-
logger.error("User not found", { userId: state.userId });
|
|
129
|
-
return {
|
|
130
|
-
error: `User not found: ${state.userId}`
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
// Check what information we have from the user table (schema: users)
|
|
134
|
-
// Required fields: email, name (always present)
|
|
135
|
-
// Optional fields: intro, avatar, location, socials
|
|
136
|
-
const socials = await this.database.getUserSocials(state.userId);
|
|
137
|
-
const hasSocials = socials.length > 0;
|
|
138
|
-
// Check if name is a full name (not just email username)
|
|
139
|
-
// For scraping to work well, we need first + last name
|
|
140
|
-
const hasMeaningfulName = user.name &&
|
|
141
|
-
user.name.trim() !== '' &&
|
|
142
|
-
!user.name.includes('@') &&
|
|
143
|
-
user.name.split(/\s+/).filter(Boolean).length >= 2;
|
|
144
|
-
const hasLocation = !!(user.location && user.location.trim() !== '');
|
|
145
|
-
// Minimum requirement for accurate scraping:
|
|
146
|
-
// - At least ONE social link (preferred - most reliable for finding the right person)
|
|
147
|
-
// - OR a full name (first + last) - less reliable but workable
|
|
148
|
-
// Location helps disambiguate but is not required
|
|
149
|
-
const hasMinimumInfo = hasSocials || hasMeaningfulName;
|
|
150
|
-
if (!hasMinimumInfo) {
|
|
151
|
-
needsUserInfo = true;
|
|
152
|
-
// Build precise list of what's missing and would help
|
|
153
|
-
if (!hasSocials) {
|
|
154
|
-
missingUserInfo.push('social_urls');
|
|
155
|
-
}
|
|
156
|
-
if (!hasMeaningfulName) {
|
|
157
|
-
missingUserInfo.push('full_name');
|
|
158
|
-
}
|
|
159
|
-
if (!hasLocation) {
|
|
160
|
-
missingUserInfo.push('location'); // Nice to have
|
|
161
|
-
}
|
|
162
|
-
logger.verbose("⚠️ Insufficient user information for scraping", {
|
|
163
|
-
hasSocials,
|
|
164
|
-
hasMeaningfulName,
|
|
165
|
-
hasLocation,
|
|
166
|
-
currentName: user.name,
|
|
167
|
-
missingUserInfo
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
else {
|
|
171
|
-
logger.verbose("✅ Sufficient user information for scraping", {
|
|
172
|
-
hasSocials,
|
|
173
|
-
hasMeaningfulName,
|
|
174
|
-
hasLocation,
|
|
175
|
-
willProceedWith: hasSocials ? 'social links' : 'full name'
|
|
176
|
-
});
|
|
177
|
-
}
|
|
186
|
+
if (!hasMeaningfulName) {
|
|
187
|
+
missingUserInfo.push('full_name');
|
|
178
188
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
});
|
|
188
|
-
return {
|
|
189
|
-
// Only treat the (users-sourced) profile as existing state when the user has
|
|
190
|
-
// actually been enriched; un-enriched users keep `undefined` so the generate
|
|
191
|
-
// path runs from scratch rather than merging into an empty users row.
|
|
192
|
-
profile: hasBeenEnriched ? (profile || undefined) : undefined,
|
|
193
|
-
needsProfileGeneration,
|
|
194
|
-
needsUserInfo,
|
|
189
|
+
if (!hasLocation) {
|
|
190
|
+
missingUserInfo.push('location'); // Nice to have
|
|
191
|
+
}
|
|
192
|
+
logger.verbose("⚠️ Insufficient user information for scraping", {
|
|
193
|
+
hasSocials,
|
|
194
|
+
hasMeaningfulName,
|
|
195
|
+
hasLocation,
|
|
196
|
+
currentName: user.name,
|
|
195
197
|
missingUserInfo
|
|
196
|
-
};
|
|
198
|
+
});
|
|
197
199
|
}
|
|
198
|
-
|
|
199
|
-
logger.
|
|
200
|
-
|
|
200
|
+
else {
|
|
201
|
+
logger.verbose("✅ Sufficient user information for scraping", {
|
|
202
|
+
hasSocials,
|
|
203
|
+
hasMeaningfulName,
|
|
204
|
+
hasLocation,
|
|
205
|
+
willProceedWith: hasSocials ? 'social links' : 'full name'
|
|
201
206
|
});
|
|
202
|
-
return {
|
|
203
|
-
profile: undefined,
|
|
204
|
-
error: `Failed to load profile from database: ${error instanceof Error ? error.message : String(error)}`
|
|
205
|
-
};
|
|
206
207
|
}
|
|
208
|
+
}
|
|
209
|
+
logger.verbose("📊 State detection complete", {
|
|
210
|
+
hasProfile: hasBeenEnriched,
|
|
211
|
+
needsProfileGeneration,
|
|
212
|
+
needsUserInfo,
|
|
213
|
+
missingUserInfo,
|
|
214
|
+
forceUpdate: state.forceUpdate,
|
|
215
|
+
hasInput: !!state.input,
|
|
216
|
+
hasMeaningfulInput,
|
|
207
217
|
});
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
+
return {
|
|
219
|
+
// Only treat the (users-sourced) profile as existing state when the user has
|
|
220
|
+
// actually been enriched; un-enriched users keep `undefined` so the generate
|
|
221
|
+
// path runs from scratch rather than merging into an empty users row.
|
|
222
|
+
profile: hasBeenEnriched ? (profile || undefined) : undefined,
|
|
223
|
+
needsProfileGeneration,
|
|
224
|
+
needsUserInfo,
|
|
225
|
+
missingUserInfo
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
logger.error("Failed to load profile", {
|
|
230
|
+
error: error instanceof Error ? error.message : String(error)
|
|
231
|
+
});
|
|
232
|
+
return {
|
|
233
|
+
profile: undefined,
|
|
234
|
+
error: `Failed to load profile from database: ${error instanceof Error ? error.message : String(error)}`
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// ─────────────────────────────────────────────────────────
|
|
240
|
+
// NODE: Scrape
|
|
241
|
+
// Scrapes data from web if input is not provided
|
|
242
|
+
// ─────────────────────────────────────────────────────────
|
|
243
|
+
export async function scrapeNode(state, deps) {
|
|
244
|
+
return timed("ProfileGraph.scrape", async () => {
|
|
245
|
+
if (state.input && isMeaningfulProfileInput(state.input)) {
|
|
246
|
+
logger.verbose("Meaningful input already provided - skipping scrape");
|
|
247
|
+
return {};
|
|
248
|
+
}
|
|
249
|
+
logger.verbose("Starting web scrape...", {
|
|
250
|
+
userId: state.userId
|
|
251
|
+
});
|
|
252
|
+
try {
|
|
253
|
+
// Fetch user details to construct objective for web scraping
|
|
254
|
+
const user = await deps.database.getUser(state.userId);
|
|
255
|
+
if (!user) {
|
|
256
|
+
logger.error("User not found", { userId: state.userId });
|
|
257
|
+
return {
|
|
258
|
+
error: `User not found: ${state.userId}`
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
// Build scraping objective from available user information
|
|
262
|
+
// Priority: social links (most reliable) > name + location > email
|
|
263
|
+
const socialParts = [];
|
|
264
|
+
const socials = await deps.database.getUserSocials(state.userId);
|
|
265
|
+
for (const s of socials) {
|
|
266
|
+
switch (s.label) {
|
|
267
|
+
case 'twitter':
|
|
268
|
+
socialParts.push(`X/Twitter: ${s.value}`);
|
|
269
|
+
break;
|
|
270
|
+
case 'linkedin':
|
|
271
|
+
socialParts.push(`LinkedIn: ${s.value}`);
|
|
272
|
+
break;
|
|
273
|
+
case 'github':
|
|
274
|
+
socialParts.push(`GitHub: ${s.value}`);
|
|
275
|
+
break;
|
|
276
|
+
case 'telegram':
|
|
277
|
+
socialParts.push(`Telegram: ${s.value}`);
|
|
278
|
+
break;
|
|
279
|
+
default:
|
|
280
|
+
socialParts.push(`Website: ${s.value}`);
|
|
281
|
+
break;
|
|
218
282
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
283
|
+
}
|
|
284
|
+
// Construct objective based on what we have
|
|
285
|
+
let objective = `Find information about ${user.name || 'this person'}`;
|
|
286
|
+
if (user.location) {
|
|
287
|
+
objective += ` located in ${user.location}`;
|
|
288
|
+
}
|
|
289
|
+
objective += '.\n\n';
|
|
290
|
+
if (socialParts.length > 0) {
|
|
291
|
+
objective += `Their social profiles:\n${socialParts.join('\n')}\n\n`;
|
|
292
|
+
objective += 'Use these links to find accurate information about their professional background, skills, and interests.';
|
|
293
|
+
}
|
|
294
|
+
else if (user.email) {
|
|
295
|
+
objective += `Their email: ${user.email}\n\n`;
|
|
296
|
+
objective += 'Search for professional information, skills, and background about this person.';
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
objective += 'Search for professional information and background about this person.';
|
|
300
|
+
}
|
|
301
|
+
logger.verbose("Constructed scraping objective", {
|
|
302
|
+
hasSocials: socialParts.length > 0,
|
|
303
|
+
hasLocation: !!user.location,
|
|
304
|
+
objectivePreview: objective.substring(0, 100)
|
|
305
|
+
});
|
|
306
|
+
const scrapedData = await deps.scraper.scrape(objective);
|
|
307
|
+
logger.verbose("✅ Scrape complete", {
|
|
308
|
+
dataLength: scrapedData?.length || 0
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
objective,
|
|
312
|
+
input: scrapedData,
|
|
313
|
+
activeSocialIds: socials.map(s => s.id),
|
|
314
|
+
operationsPerformed: { scraped: true }
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
logger.error("Scrape failed", {
|
|
319
|
+
error: error instanceof Error ? error.message : String(error)
|
|
320
|
+
});
|
|
321
|
+
return {
|
|
322
|
+
error: `Web scrape failed: ${error instanceof Error ? error.message : String(error)}`
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
// ─────────────────────────────────────────────────────────
|
|
328
|
+
// NODE: Auto-Generate (Parallel Chat API enrichment)
|
|
329
|
+
// Calls enrichUserProfile to get structured data, then builds a
|
|
330
|
+
// text blob as input for the decompose → aggregate → generate
|
|
331
|
+
// pipeline. This ensures enriched users get premises with
|
|
332
|
+
// embeddings, making them discoverable via premise-to-premise
|
|
333
|
+
// matching.
|
|
334
|
+
// On failure, falls back to basic user info for LLM generation.
|
|
335
|
+
// Used in 'generate' mode only.
|
|
336
|
+
// ─────────────────────────────────────────────────────────
|
|
337
|
+
export async function autoGenerateNode(state, deps) {
|
|
338
|
+
return timed("ProfileGraph.autoGenerate", async () => {
|
|
339
|
+
logger.verbose("Starting auto-generate via Chat API enrichment", {
|
|
340
|
+
userId: state.userId,
|
|
341
|
+
});
|
|
342
|
+
try {
|
|
343
|
+
const user = await deps.database.getUser(state.userId);
|
|
344
|
+
if (!user) {
|
|
345
|
+
logger.error("User not found for auto-generate", { userId: state.userId });
|
|
346
|
+
return { error: `User not found: ${state.userId}` };
|
|
347
|
+
}
|
|
348
|
+
const socials = await deps.database.getUserSocials(state.userId);
|
|
349
|
+
const enrichmentSocials = socialsToEnrichmentRequest(socials);
|
|
350
|
+
const request = {
|
|
351
|
+
name: user.name || undefined,
|
|
352
|
+
email: user.email || undefined,
|
|
353
|
+
linkedin: enrichmentSocials.linkedin || undefined,
|
|
354
|
+
twitter: enrichmentSocials.twitter || undefined,
|
|
355
|
+
github: enrichmentSocials.github || undefined,
|
|
356
|
+
telegram: enrichmentSocials.telegram || undefined,
|
|
357
|
+
websites: enrichmentSocials.websites?.length ? enrichmentSocials.websites : undefined,
|
|
358
|
+
};
|
|
359
|
+
const buildBasicInfo = () => {
|
|
360
|
+
const parts = [
|
|
361
|
+
user.name ? `Name: ${user.name}` : '',
|
|
362
|
+
user.location ? `Location: ${user.location}` : '',
|
|
363
|
+
user.intro ? `Bio: ${user.intro}` : '',
|
|
364
|
+
].filter(Boolean).join('\n');
|
|
365
|
+
return parts || "No information available";
|
|
366
|
+
};
|
|
367
|
+
if (!deps.enricher) {
|
|
368
|
+
logger.warn("No enricher configured — falling back to basic info", { userId: state.userId });
|
|
369
|
+
return {
|
|
370
|
+
input: buildBasicInfo(),
|
|
371
|
+
needsUserInfo: false,
|
|
372
|
+
needsProfileGeneration: true,
|
|
373
|
+
operationsPerformed: { scraped: true },
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const enrichment = await deps.enricher.enrichUserProfile(request);
|
|
378
|
+
if (enrichment && !enrichment.isHuman) {
|
|
379
|
+
logger.info("Enrichment detected non-human entity, soft-deleting ghost", { userId: state.userId });
|
|
380
|
+
await deps.database.softDeleteGhost(state.userId);
|
|
381
|
+
return { error: "Non-human entity detected" };
|
|
382
|
+
}
|
|
383
|
+
const hasMeaningfulEnrichment = !!enrichment &&
|
|
384
|
+
enrichment.confidentMatch &&
|
|
385
|
+
(enrichment.identity.bio.trim().length > 0 ||
|
|
386
|
+
enrichment.narrative.context.trim().length > 0 ||
|
|
387
|
+
enrichment.attributes.skills.length > 0 ||
|
|
388
|
+
enrichment.attributes.interests.length > 0);
|
|
389
|
+
if (hasMeaningfulEnrichment) {
|
|
390
|
+
if (user.isGhost && !isEnrichedNameMeaningful(user.email || '', enrichment.identity.name || '')) {
|
|
391
|
+
logger.info("Enrichment has content but no real name for ghost, soft-deleting", { userId: state.userId });
|
|
392
|
+
await deps.database.softDeleteGhost(state.userId);
|
|
393
|
+
return { error: "No real name found for ghost user" };
|
|
230
394
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
break;
|
|
243
|
-
case 'github':
|
|
244
|
-
socialParts.push(`GitHub: ${s.value}`);
|
|
245
|
-
break;
|
|
246
|
-
case 'telegram':
|
|
247
|
-
socialParts.push(`Telegram: ${s.value}`);
|
|
248
|
-
break;
|
|
249
|
-
default:
|
|
250
|
-
socialParts.push(`Website: ${s.value}`);
|
|
251
|
-
break;
|
|
252
|
-
}
|
|
395
|
+
logger.verbose("Chat API enrichment succeeded", {
|
|
396
|
+
userId: state.userId,
|
|
397
|
+
skillsCount: enrichment.attributes.skills.length,
|
|
398
|
+
interestsCount: enrichment.attributes.interests.length,
|
|
399
|
+
});
|
|
400
|
+
// Update user record with enriched data
|
|
401
|
+
const updatePayload = {};
|
|
402
|
+
const enrichedName = enrichment.identity.name?.trim();
|
|
403
|
+
if (enrichedName &&
|
|
404
|
+
shouldEnrichGhostDisplayNameFromParallel({ isGhost: !!user.isGhost, name: user.name ?? '', email: user.email ?? '' }, enrichedName)) {
|
|
405
|
+
updatePayload.name = enrichedName;
|
|
253
406
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
407
|
+
if (enrichment.identity.bio?.trim())
|
|
408
|
+
updatePayload.intro = enrichment.identity.bio.trim();
|
|
409
|
+
if (enrichment.identity.location?.trim())
|
|
410
|
+
updatePayload.location = enrichment.identity.location.trim();
|
|
411
|
+
const enrichedSocials = [];
|
|
412
|
+
if (enrichment.socials.twitter)
|
|
413
|
+
enrichedSocials.push({ label: 'twitter', value: enrichment.socials.twitter });
|
|
414
|
+
if (enrichment.socials.linkedin)
|
|
415
|
+
enrichedSocials.push({ label: 'linkedin', value: enrichment.socials.linkedin });
|
|
416
|
+
if (enrichment.socials.github)
|
|
417
|
+
enrichedSocials.push({ label: 'github', value: enrichment.socials.github });
|
|
418
|
+
if (enrichment.socials.telegram)
|
|
419
|
+
enrichedSocials.push({ label: 'telegram', value: enrichment.socials.telegram });
|
|
420
|
+
if (enrichment.socials.websites?.length) {
|
|
421
|
+
for (const w of enrichment.socials.websites)
|
|
422
|
+
enrichedSocials.push({ label: 'custom', value: w });
|
|
258
423
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
424
|
+
if (enrichedSocials.length > 0) {
|
|
425
|
+
const existingSocials = await deps.database.getUserSocials(state.userId);
|
|
426
|
+
const enrichedLabels = new Set(enrichedSocials.map(s => s.label));
|
|
427
|
+
const kept = existingSocials
|
|
428
|
+
.filter(s => !enrichedLabels.has(s.label) || s.label === 'custom')
|
|
429
|
+
.map(s => ({ label: s.label, value: s.value }));
|
|
430
|
+
const merged = enrichedLabels.has('custom')
|
|
431
|
+
? [...kept.filter(s => s.label !== 'custom'), ...enrichedSocials]
|
|
432
|
+
: [...kept, ...enrichedSocials];
|
|
433
|
+
await deps.database.setUserSocials(state.userId, merged);
|
|
263
434
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
objective += 'Search for professional information, skills, and background about this person.';
|
|
435
|
+
if (Object.keys(updatePayload).length > 0) {
|
|
436
|
+
await deps.database.updateUser(state.userId, updatePayload);
|
|
267
437
|
}
|
|
268
|
-
|
|
269
|
-
|
|
438
|
+
// Post-enrichment dedup: check if this ghost matches an existing user
|
|
439
|
+
if (user.isGhost) {
|
|
440
|
+
const currentSocials = await deps.database.getUserSocials(state.userId);
|
|
441
|
+
const duplicate = await deps.database.findDuplicateUser(state.userId, currentSocials);
|
|
442
|
+
if (duplicate) {
|
|
443
|
+
logger.info("Post-enrichment dedup: merging ghost into existing user", {
|
|
444
|
+
ghostId: state.userId,
|
|
445
|
+
targetId: duplicate.id,
|
|
446
|
+
});
|
|
447
|
+
await deps.database.mergeGhostUser(state.userId, duplicate.id);
|
|
448
|
+
return { error: `Merged as duplicate of user ${duplicate.id}` };
|
|
449
|
+
}
|
|
270
450
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
451
|
+
// Build a text blob from the enrichment result so it flows
|
|
452
|
+
// through premise decomposition (when available) rather than
|
|
453
|
+
// bypassing premises via prePopulatedProfile.
|
|
454
|
+
const enrichmentParts = [
|
|
455
|
+
enrichment.identity.name ? `My name is ${enrichment.identity.name}.` : '',
|
|
456
|
+
enrichment.identity.location ? `I am based in ${enrichment.identity.location}.` : '',
|
|
457
|
+
enrichment.identity.bio || '',
|
|
458
|
+
enrichment.narrative.context || '',
|
|
459
|
+
enrichment.attributes.skills.length ? `My skills include ${enrichment.attributes.skills.join(', ')}.` : '',
|
|
460
|
+
enrichment.attributes.interests.length ? `My interests include ${enrichment.attributes.interests.join(', ')}.` : '',
|
|
461
|
+
].filter(Boolean).join('\n');
|
|
280
462
|
return {
|
|
281
|
-
|
|
282
|
-
|
|
463
|
+
input: enrichmentParts,
|
|
464
|
+
needsUserInfo: false,
|
|
465
|
+
needsProfileGeneration: true,
|
|
466
|
+
forceUpdate: true,
|
|
283
467
|
activeSocialIds: socials.map(s => s.id),
|
|
284
|
-
operationsPerformed: { scraped: true }
|
|
468
|
+
operationsPerformed: { scraped: true },
|
|
285
469
|
};
|
|
286
470
|
}
|
|
287
|
-
|
|
288
|
-
logger.
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
return {
|
|
292
|
-
error: `Web scrape failed: ${error instanceof Error ? error.message : String(error)}`
|
|
293
|
-
};
|
|
471
|
+
if (user.isGhost) {
|
|
472
|
+
logger.info("Low-confidence enrichment for ghost, soft-deleting", { userId: state.userId });
|
|
473
|
+
await deps.database.softDeleteGhost(state.userId);
|
|
474
|
+
return { error: "Enrichment not confident for ghost user" };
|
|
294
475
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
// On failure, falls back to basic user info for LLM generation.
|
|
305
|
-
// Used in 'generate' mode only.
|
|
306
|
-
// ─────────────────────────────────────────────────────────
|
|
307
|
-
const autoGenerateNode = async (state) => {
|
|
308
|
-
return timed("ProfileGraph.autoGenerate", async () => {
|
|
309
|
-
logger.verbose("Starting auto-generate via Chat API enrichment", {
|
|
476
|
+
logger.warn("Chat API returned low-signal enrichment, falling back to basic info", { userId: state.userId });
|
|
477
|
+
}
|
|
478
|
+
catch (enrichErr) {
|
|
479
|
+
if (user.isGhost) {
|
|
480
|
+
logger.info("Enrichment failed for ghost, soft-deleting", { userId: state.userId });
|
|
481
|
+
await deps.database.softDeleteGhost(state.userId);
|
|
482
|
+
return { error: "Enrichment failed for ghost user" };
|
|
483
|
+
}
|
|
484
|
+
logger.warn("Chat API enrichment failed, falling back to basic info", {
|
|
310
485
|
userId: state.userId,
|
|
486
|
+
error: enrichErr instanceof Error ? enrichErr.message : String(enrichErr),
|
|
311
487
|
});
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
input: buildBasicInfo(),
|
|
491
|
+
needsUserInfo: false,
|
|
492
|
+
needsProfileGeneration: true,
|
|
493
|
+
operationsPerformed: { scraped: true },
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
logger.error("Auto-generate failed", {
|
|
498
|
+
error: err instanceof Error ? err.message : String(err),
|
|
499
|
+
});
|
|
500
|
+
return { error: `Auto-generate failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
// ─────────────────────────────────────────────────────────
|
|
505
|
+
// NODE: Decompose Premises
|
|
506
|
+
// Decomposes free-text input (chat or scraped) into individual premises
|
|
507
|
+
// and creates each via the premise graph. Premise creation is the terminal
|
|
508
|
+
// effect: premise lifecycle events drive user_context regeneration downstream
|
|
509
|
+
// (the legacy aggregate→generate→save profile tail was removed in WS8/IND-365,
|
|
510
|
+
// along with the user_profiles table it wrote to).
|
|
511
|
+
// ─────────────────────────────────────────────────────────
|
|
512
|
+
export async function decomposePremisesNode(state, deps) {
|
|
513
|
+
return timed("ProfileGraph.decomposePremises", async () => {
|
|
514
|
+
if (!state.input) {
|
|
515
|
+
logger.error("No input for premise decomposition");
|
|
516
|
+
return { error: "Input required for premise decomposition" };
|
|
517
|
+
}
|
|
518
|
+
if (!deps.premiseGraph) {
|
|
519
|
+
// No premise graph injected — nothing to decompose into. End the run.
|
|
520
|
+
logger.warn("No premise graph injected — skipping premise decomposition");
|
|
521
|
+
return {};
|
|
522
|
+
}
|
|
523
|
+
logger.verbose("Decomposing input into premises...", {
|
|
524
|
+
userId: state.userId,
|
|
525
|
+
inputLength: state.input.length,
|
|
526
|
+
});
|
|
527
|
+
const agentTimingsAccum = [];
|
|
528
|
+
try {
|
|
529
|
+
const _traceEmitter = requestContext.getStore()?.traceEmitter;
|
|
530
|
+
// Offer the user's ACTIVE premises to the decomposer so removal/denial
|
|
531
|
+
// instructions ("remove all mentions of X", "I have nothing to do with Y")
|
|
532
|
+
// can be resolved to concrete retractions instead of being silently dropped.
|
|
533
|
+
// Only when the adapter supports retraction — otherwise skip the lookup.
|
|
534
|
+
let activePremises = [];
|
|
535
|
+
if (typeof deps.database.updatePremise === 'function') {
|
|
312
536
|
try {
|
|
313
|
-
|
|
314
|
-
if (!user) {
|
|
315
|
-
logger.error("User not found for auto-generate", { userId: state.userId });
|
|
316
|
-
return { error: `User not found: ${state.userId}` };
|
|
317
|
-
}
|
|
318
|
-
const socials = await this.database.getUserSocials(state.userId);
|
|
319
|
-
const enrichmentSocials = socialsToEnrichmentRequest(socials);
|
|
320
|
-
const request = {
|
|
321
|
-
name: user.name || undefined,
|
|
322
|
-
email: user.email || undefined,
|
|
323
|
-
linkedin: enrichmentSocials.linkedin || undefined,
|
|
324
|
-
twitter: enrichmentSocials.twitter || undefined,
|
|
325
|
-
github: enrichmentSocials.github || undefined,
|
|
326
|
-
telegram: enrichmentSocials.telegram || undefined,
|
|
327
|
-
websites: enrichmentSocials.websites?.length ? enrichmentSocials.websites : undefined,
|
|
328
|
-
};
|
|
329
|
-
const buildBasicInfo = () => {
|
|
330
|
-
const parts = [
|
|
331
|
-
user.name ? `Name: ${user.name}` : '',
|
|
332
|
-
user.location ? `Location: ${user.location}` : '',
|
|
333
|
-
user.intro ? `Bio: ${user.intro}` : '',
|
|
334
|
-
].filter(Boolean).join('\n');
|
|
335
|
-
return parts || "No information available";
|
|
336
|
-
};
|
|
337
|
-
if (!this.enricher) {
|
|
338
|
-
logger.warn("No enricher configured — falling back to basic info", { userId: state.userId });
|
|
339
|
-
return {
|
|
340
|
-
input: buildBasicInfo(),
|
|
341
|
-
needsUserInfo: false,
|
|
342
|
-
needsProfileGeneration: true,
|
|
343
|
-
operationsPerformed: { scraped: true },
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
try {
|
|
347
|
-
const enrichment = await this.enricher.enrichUserProfile(request);
|
|
348
|
-
if (enrichment && !enrichment.isHuman) {
|
|
349
|
-
logger.info("Enrichment detected non-human entity, soft-deleting ghost", { userId: state.userId });
|
|
350
|
-
await this.database.softDeleteGhost(state.userId);
|
|
351
|
-
return { error: "Non-human entity detected" };
|
|
352
|
-
}
|
|
353
|
-
const hasMeaningfulEnrichment = !!enrichment &&
|
|
354
|
-
enrichment.confidentMatch &&
|
|
355
|
-
(enrichment.identity.bio.trim().length > 0 ||
|
|
356
|
-
enrichment.narrative.context.trim().length > 0 ||
|
|
357
|
-
enrichment.attributes.skills.length > 0 ||
|
|
358
|
-
enrichment.attributes.interests.length > 0);
|
|
359
|
-
if (hasMeaningfulEnrichment) {
|
|
360
|
-
if (user.isGhost && !isEnrichedNameMeaningful(user.email || '', enrichment.identity.name || '')) {
|
|
361
|
-
logger.info("Enrichment has content but no real name for ghost, soft-deleting", { userId: state.userId });
|
|
362
|
-
await this.database.softDeleteGhost(state.userId);
|
|
363
|
-
return { error: "No real name found for ghost user" };
|
|
364
|
-
}
|
|
365
|
-
logger.verbose("Chat API enrichment succeeded", {
|
|
366
|
-
userId: state.userId,
|
|
367
|
-
skillsCount: enrichment.attributes.skills.length,
|
|
368
|
-
interestsCount: enrichment.attributes.interests.length,
|
|
369
|
-
});
|
|
370
|
-
// Update user record with enriched data
|
|
371
|
-
const updatePayload = {};
|
|
372
|
-
const enrichedName = enrichment.identity.name?.trim();
|
|
373
|
-
if (enrichedName &&
|
|
374
|
-
shouldEnrichGhostDisplayNameFromParallel({ isGhost: !!user.isGhost, name: user.name ?? '', email: user.email ?? '' }, enrichedName)) {
|
|
375
|
-
updatePayload.name = enrichedName;
|
|
376
|
-
}
|
|
377
|
-
if (enrichment.identity.bio?.trim())
|
|
378
|
-
updatePayload.intro = enrichment.identity.bio.trim();
|
|
379
|
-
if (enrichment.identity.location?.trim())
|
|
380
|
-
updatePayload.location = enrichment.identity.location.trim();
|
|
381
|
-
const enrichedSocials = [];
|
|
382
|
-
if (enrichment.socials.twitter)
|
|
383
|
-
enrichedSocials.push({ label: 'twitter', value: enrichment.socials.twitter });
|
|
384
|
-
if (enrichment.socials.linkedin)
|
|
385
|
-
enrichedSocials.push({ label: 'linkedin', value: enrichment.socials.linkedin });
|
|
386
|
-
if (enrichment.socials.github)
|
|
387
|
-
enrichedSocials.push({ label: 'github', value: enrichment.socials.github });
|
|
388
|
-
if (enrichment.socials.telegram)
|
|
389
|
-
enrichedSocials.push({ label: 'telegram', value: enrichment.socials.telegram });
|
|
390
|
-
if (enrichment.socials.websites?.length) {
|
|
391
|
-
for (const w of enrichment.socials.websites)
|
|
392
|
-
enrichedSocials.push({ label: 'custom', value: w });
|
|
393
|
-
}
|
|
394
|
-
if (enrichedSocials.length > 0) {
|
|
395
|
-
const existingSocials = await this.database.getUserSocials(state.userId);
|
|
396
|
-
const enrichedLabels = new Set(enrichedSocials.map(s => s.label));
|
|
397
|
-
const kept = existingSocials
|
|
398
|
-
.filter(s => !enrichedLabels.has(s.label) || s.label === 'custom')
|
|
399
|
-
.map(s => ({ label: s.label, value: s.value }));
|
|
400
|
-
const merged = enrichedLabels.has('custom')
|
|
401
|
-
? [...kept.filter(s => s.label !== 'custom'), ...enrichedSocials]
|
|
402
|
-
: [...kept, ...enrichedSocials];
|
|
403
|
-
await this.database.setUserSocials(state.userId, merged);
|
|
404
|
-
}
|
|
405
|
-
if (Object.keys(updatePayload).length > 0) {
|
|
406
|
-
await this.database.updateUser(state.userId, updatePayload);
|
|
407
|
-
}
|
|
408
|
-
// Post-enrichment dedup: check if this ghost matches an existing user
|
|
409
|
-
if (user.isGhost) {
|
|
410
|
-
const currentSocials = await this.database.getUserSocials(state.userId);
|
|
411
|
-
const duplicate = await this.database.findDuplicateUser(state.userId, currentSocials);
|
|
412
|
-
if (duplicate) {
|
|
413
|
-
logger.info("Post-enrichment dedup: merging ghost into existing user", {
|
|
414
|
-
ghostId: state.userId,
|
|
415
|
-
targetId: duplicate.id,
|
|
416
|
-
});
|
|
417
|
-
await this.database.mergeGhostUser(state.userId, duplicate.id);
|
|
418
|
-
return { error: `Merged as duplicate of user ${duplicate.id}` };
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
// Build a text blob from the enrichment result so it flows
|
|
422
|
-
// through premise decomposition (when available) rather than
|
|
423
|
-
// bypassing premises via prePopulatedProfile.
|
|
424
|
-
const enrichmentParts = [
|
|
425
|
-
enrichment.identity.name ? `My name is ${enrichment.identity.name}.` : '',
|
|
426
|
-
enrichment.identity.location ? `I am based in ${enrichment.identity.location}.` : '',
|
|
427
|
-
enrichment.identity.bio || '',
|
|
428
|
-
enrichment.narrative.context || '',
|
|
429
|
-
enrichment.attributes.skills.length ? `My skills include ${enrichment.attributes.skills.join(', ')}.` : '',
|
|
430
|
-
enrichment.attributes.interests.length ? `My interests include ${enrichment.attributes.interests.join(', ')}.` : '',
|
|
431
|
-
].filter(Boolean).join('\n');
|
|
432
|
-
return {
|
|
433
|
-
input: enrichmentParts,
|
|
434
|
-
needsUserInfo: false,
|
|
435
|
-
needsProfileGeneration: true,
|
|
436
|
-
forceUpdate: true,
|
|
437
|
-
activeSocialIds: socials.map(s => s.id),
|
|
438
|
-
operationsPerformed: { scraped: true },
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
if (user.isGhost) {
|
|
442
|
-
logger.info("Low-confidence enrichment for ghost, soft-deleting", { userId: state.userId });
|
|
443
|
-
await this.database.softDeleteGhost(state.userId);
|
|
444
|
-
return { error: "Enrichment not confident for ghost user" };
|
|
445
|
-
}
|
|
446
|
-
logger.warn("Chat API returned low-signal enrichment, falling back to basic info", { userId: state.userId });
|
|
447
|
-
}
|
|
448
|
-
catch (enrichErr) {
|
|
449
|
-
if (user.isGhost) {
|
|
450
|
-
logger.info("Enrichment failed for ghost, soft-deleting", { userId: state.userId });
|
|
451
|
-
await this.database.softDeleteGhost(state.userId);
|
|
452
|
-
return { error: "Enrichment failed for ghost user" };
|
|
453
|
-
}
|
|
454
|
-
logger.warn("Chat API enrichment failed, falling back to basic info", {
|
|
455
|
-
userId: state.userId,
|
|
456
|
-
error: enrichErr instanceof Error ? enrichErr.message : String(enrichErr),
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
return {
|
|
460
|
-
input: buildBasicInfo(),
|
|
461
|
-
needsUserInfo: false,
|
|
462
|
-
needsProfileGeneration: true,
|
|
463
|
-
operationsPerformed: { scraped: true },
|
|
464
|
-
};
|
|
537
|
+
activePremises = await deps.database.getPremisesForUser(state.userId, 'ACTIVE');
|
|
465
538
|
}
|
|
466
539
|
catch (err) {
|
|
467
|
-
logger.
|
|
540
|
+
logger.warn("Failed to load active premises for retraction matching — proceeding without", {
|
|
468
541
|
error: err instanceof Error ? err.message : String(err),
|
|
469
542
|
});
|
|
470
|
-
return { error: `Auto-generate failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
471
543
|
}
|
|
544
|
+
}
|
|
545
|
+
const decomposeStart = Date.now();
|
|
546
|
+
_traceEmitter?.({ type: "agent_start", name: "premise-decomposer" });
|
|
547
|
+
// Offer the stored bio (users.intro) as well: it is a separate identity
|
|
548
|
+
// field that premises never touch, so removal instructions must also be
|
|
549
|
+
// able to rewrite it — otherwise disavowed facts survive in the bio and
|
|
550
|
+
// resurface in every prompt that includes the profile identity.
|
|
551
|
+
const currentBio = state.profile?.identity?.bio ?? '';
|
|
552
|
+
const result = await deps.premiseDecomposer.invoke(state.input, activePremises.map((p) => ({ id: p.id, text: p.assertion.text })), currentBio);
|
|
553
|
+
const decomposeMs = Date.now() - decomposeStart;
|
|
554
|
+
const retractionIds = result.retractedPremiseIds ?? [];
|
|
555
|
+
agentTimingsAccum.push({ name: "premise.decomposer", durationMs: decomposeMs });
|
|
556
|
+
_traceEmitter?.({
|
|
557
|
+
type: "agent_end",
|
|
558
|
+
name: "premise-decomposer",
|
|
559
|
+
durationMs: decomposeMs,
|
|
560
|
+
summary: `Decomposed into ${result.premises.length} premise(s), ${retractionIds.length} retraction(s)`,
|
|
472
561
|
});
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
return { error: "Input required for premise decomposition" };
|
|
562
|
+
// Apply retractions FIRST so a premise that is simultaneously disavowed and
|
|
563
|
+
// re-asserted in corrected form does not dedupe the new create against the
|
|
564
|
+
// stale active row.
|
|
565
|
+
let retracted = 0;
|
|
566
|
+
for (const premiseId of retractionIds) {
|
|
567
|
+
if (!deps.database.updatePremise)
|
|
568
|
+
break;
|
|
569
|
+
try {
|
|
570
|
+
await deps.database.updatePremise(premiseId, {
|
|
571
|
+
status: 'RETRACTED',
|
|
572
|
+
retractedAt: new Date(),
|
|
573
|
+
});
|
|
574
|
+
retracted++;
|
|
487
575
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
576
|
+
catch (err) {
|
|
577
|
+
logger.warn("Premise retraction failed", {
|
|
578
|
+
premiseId,
|
|
579
|
+
error: err instanceof Error ? err.message : String(err),
|
|
580
|
+
});
|
|
492
581
|
}
|
|
493
|
-
|
|
582
|
+
}
|
|
583
|
+
if (retracted > 0) {
|
|
584
|
+
logger.verbose('Retracted premises disavowed by input', {
|
|
585
|
+
retracted,
|
|
586
|
+
requested: retractionIds.length,
|
|
494
587
|
userId: state.userId,
|
|
495
|
-
inputLength: state.input.length,
|
|
496
588
|
});
|
|
497
|
-
|
|
589
|
+
}
|
|
590
|
+
// Apply the bio revision (before the no-new-premises early return: a pure
|
|
591
|
+
// removal instruction extracts zero premises but still rewrites the bio).
|
|
592
|
+
let bioRevised = false;
|
|
593
|
+
const revisedBio = result.revisedBio?.trim();
|
|
594
|
+
if (revisedBio && revisedBio !== currentBio.trim()) {
|
|
498
595
|
try {
|
|
499
|
-
|
|
500
|
-
// Offer the user's ACTIVE premises to the decomposer so removal/denial
|
|
501
|
-
// instructions ("remove all mentions of X", "I have nothing to do with Y")
|
|
502
|
-
// can be resolved to concrete retractions instead of being silently dropped.
|
|
503
|
-
// Only when the adapter supports retraction — otherwise skip the lookup.
|
|
504
|
-
let activePremises = [];
|
|
505
|
-
if (typeof this.database.updatePremise === 'function') {
|
|
506
|
-
try {
|
|
507
|
-
activePremises = await this.database.getPremisesForUser(state.userId, 'ACTIVE');
|
|
508
|
-
}
|
|
509
|
-
catch (err) {
|
|
510
|
-
logger.warn("Failed to load active premises for retraction matching — proceeding without", {
|
|
511
|
-
error: err instanceof Error ? err.message : String(err),
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
const decomposeStart = Date.now();
|
|
516
|
-
_traceEmitter?.({ type: "agent_start", name: "premise-decomposer" });
|
|
517
|
-
// Offer the stored bio (users.intro) as well: it is a separate identity
|
|
518
|
-
// field that premises never touch, so removal instructions must also be
|
|
519
|
-
// able to rewrite it — otherwise disavowed facts survive in the bio and
|
|
520
|
-
// resurface in every prompt that includes the profile identity.
|
|
521
|
-
const currentBio = state.profile?.identity?.bio ?? '';
|
|
522
|
-
const result = await premiseDecomposer.invoke(state.input, activePremises.map((p) => ({ id: p.id, text: p.assertion.text })), currentBio);
|
|
523
|
-
const decomposeMs = Date.now() - decomposeStart;
|
|
524
|
-
const retractionIds = result.retractedPremiseIds ?? [];
|
|
525
|
-
agentTimingsAccum.push({ name: "premise.decomposer", durationMs: decomposeMs });
|
|
526
|
-
_traceEmitter?.({
|
|
527
|
-
type: "agent_end",
|
|
528
|
-
name: "premise-decomposer",
|
|
529
|
-
durationMs: decomposeMs,
|
|
530
|
-
summary: `Decomposed into ${result.premises.length} premise(s), ${retractionIds.length} retraction(s)`,
|
|
531
|
-
});
|
|
532
|
-
// Apply retractions FIRST so a premise that is simultaneously disavowed and
|
|
533
|
-
// re-asserted in corrected form does not dedupe the new create against the
|
|
534
|
-
// stale active row.
|
|
535
|
-
let retracted = 0;
|
|
536
|
-
for (const premiseId of retractionIds) {
|
|
537
|
-
if (!this.database.updatePremise)
|
|
538
|
-
break;
|
|
539
|
-
try {
|
|
540
|
-
await this.database.updatePremise(premiseId, {
|
|
541
|
-
status: 'RETRACTED',
|
|
542
|
-
retractedAt: new Date(),
|
|
543
|
-
});
|
|
544
|
-
retracted++;
|
|
545
|
-
}
|
|
546
|
-
catch (err) {
|
|
547
|
-
logger.warn("Premise retraction failed", {
|
|
548
|
-
premiseId,
|
|
549
|
-
error: err instanceof Error ? err.message : String(err),
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
if (retracted > 0) {
|
|
554
|
-
logger.verbose('Retracted premises disavowed by input', {
|
|
555
|
-
retracted,
|
|
556
|
-
requested: retractionIds.length,
|
|
557
|
-
userId: state.userId,
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
// Apply the bio revision (before the no-new-premises early return: a pure
|
|
561
|
-
// removal instruction extracts zero premises but still rewrites the bio).
|
|
562
|
-
let bioRevised = false;
|
|
563
|
-
const revisedBio = result.revisedBio?.trim();
|
|
564
|
-
if (revisedBio && revisedBio !== currentBio.trim()) {
|
|
565
|
-
try {
|
|
566
|
-
await this.database.saveProfile(state.userId, {
|
|
567
|
-
userId: state.userId,
|
|
568
|
-
// Empty name/location are skipped by the identity persister — only
|
|
569
|
-
// the bio is written.
|
|
570
|
-
identity: { name: '', bio: revisedBio, location: '' },
|
|
571
|
-
context: '',
|
|
572
|
-
});
|
|
573
|
-
bioRevised = true;
|
|
574
|
-
logger.verbose("Revised stored bio per removal/correction instruction", { userId: state.userId });
|
|
575
|
-
}
|
|
576
|
-
catch (err) {
|
|
577
|
-
logger.warn("Bio revision failed", {
|
|
578
|
-
error: err instanceof Error ? err.message : String(err),
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
if (result.premises.length === 0) {
|
|
583
|
-
logger.verbose("No premises extracted — nothing to create");
|
|
584
|
-
return {
|
|
585
|
-
agentTimings: agentTimingsAccum,
|
|
586
|
-
...(retracted > 0 || bioRevised ? { operationsPerformed: { decomposedPremises: true } } : {}),
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
logger.verbose('Creating premises via premise graph', {
|
|
590
|
-
count: result.premises.length,
|
|
591
|
-
userId: state.userId,
|
|
592
|
-
});
|
|
593
|
-
let created = 0;
|
|
594
|
-
let skippedDuplicates = 0;
|
|
595
|
-
for (const p of result.premises) {
|
|
596
|
-
try {
|
|
597
|
-
// Contextual premises carry an LLM-inferred validity window and are
|
|
598
|
-
// volatile (auto-retract on expiry); assertive premises do not expire.
|
|
599
|
-
const isContextual = p.tier === 'contextual';
|
|
600
|
-
const validUntil = isContextual && p.validityDays
|
|
601
|
-
? new Date(Date.now() + p.validityDays * 24 * 60 * 60 * 1000).toISOString()
|
|
602
|
-
: undefined;
|
|
603
|
-
const premiseResult = await invokeWithAbortSignal(this.premiseGraph, {
|
|
604
|
-
userId: state.userId,
|
|
605
|
-
assertionText: p.text,
|
|
606
|
-
tier: p.tier,
|
|
607
|
-
operationMode: 'create',
|
|
608
|
-
volatile: isContextual,
|
|
609
|
-
...(validUntil ? { validUntil } : {}),
|
|
610
|
-
...(state.activeSocialIds?.length
|
|
611
|
-
? { provenanceSource: 'integration', provenanceSourceId: state.activeSocialIds[0] }
|
|
612
|
-
: {}),
|
|
613
|
-
});
|
|
614
|
-
if (premiseResult.premise) {
|
|
615
|
-
created++;
|
|
616
|
-
}
|
|
617
|
-
else if (premiseResult.duplicateOf) {
|
|
618
|
-
skippedDuplicates++;
|
|
619
|
-
}
|
|
620
|
-
else if (premiseResult.error) {
|
|
621
|
-
logger.warn("Premise creation failed", {
|
|
622
|
-
text: p.text.substring(0, 60),
|
|
623
|
-
error: premiseResult.error,
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
catch (err) {
|
|
628
|
-
logger.warn("Premise creation threw", {
|
|
629
|
-
text: p.text.substring(0, 60),
|
|
630
|
-
error: err instanceof Error ? err.message : String(err),
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
logger.verbose('Created premises', {
|
|
635
|
-
created,
|
|
636
|
-
requested: result.premises.length,
|
|
637
|
-
skippedDuplicates,
|
|
596
|
+
await deps.database.saveProfile(state.userId, {
|
|
638
597
|
userId: state.userId,
|
|
598
|
+
// Empty name/location are skipped by the identity persister — only
|
|
599
|
+
// the bio is written.
|
|
600
|
+
identity: { name: '', bio: revisedBio, location: '' },
|
|
601
|
+
context: '',
|
|
639
602
|
});
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
operationsPerformed: { decomposedPremises: true },
|
|
643
|
-
};
|
|
603
|
+
bioRevised = true;
|
|
604
|
+
logger.verbose("Revised stored bio per removal/correction instruction", { userId: state.userId });
|
|
644
605
|
}
|
|
645
606
|
catch (err) {
|
|
646
|
-
logger.
|
|
607
|
+
logger.warn("Bio revision failed", {
|
|
647
608
|
error: err instanceof Error ? err.message : String(err),
|
|
648
609
|
});
|
|
649
|
-
return {
|
|
650
|
-
agentTimings: agentTimingsAccum,
|
|
651
|
-
};
|
|
652
610
|
}
|
|
653
|
-
});
|
|
654
|
-
};
|
|
655
|
-
// ─────────────────────────────────────────────────────────
|
|
656
|
-
// ROUTING CONDITIONS
|
|
657
|
-
// Smart conditional routing based on operation mode and missing components
|
|
658
|
-
// ─────────────────────────────────────────────────────────
|
|
659
|
-
/**
|
|
660
|
-
* Route from check_state to next step based on operation mode and detected needs.
|
|
661
|
-
*/
|
|
662
|
-
const checkStateCondition = (state) => {
|
|
663
|
-
// Query mode: Return immediately (fast path)
|
|
664
|
-
if (state.operationMode === 'query') {
|
|
665
|
-
logger.verbose("Query mode - ending (fast path)");
|
|
666
|
-
return END;
|
|
667
611
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
612
|
+
if (result.premises.length === 0) {
|
|
613
|
+
logger.verbose("No premises extracted — nothing to create");
|
|
614
|
+
return {
|
|
615
|
+
agentTimings: agentTimingsAccum,
|
|
616
|
+
...(retracted > 0 || bioRevised ? { operationsPerformed: { decomposedPremises: true } } : {}),
|
|
617
|
+
};
|
|
672
618
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
619
|
+
logger.verbose('Creating premises via premise graph', {
|
|
620
|
+
count: result.premises.length,
|
|
621
|
+
userId: state.userId,
|
|
622
|
+
});
|
|
623
|
+
let created = 0;
|
|
624
|
+
let skippedDuplicates = 0;
|
|
625
|
+
for (const p of result.premises) {
|
|
626
|
+
try {
|
|
627
|
+
// Contextual premises carry an LLM-inferred validity window and are
|
|
628
|
+
// volatile (auto-retract on expiry); assertive premises do not expire.
|
|
629
|
+
const isContextual = p.tier === 'contextual';
|
|
630
|
+
const validUntil = isContextual && p.validityDays
|
|
631
|
+
? new Date(Date.now() + p.validityDays * 24 * 60 * 60 * 1000).toISOString()
|
|
632
|
+
: undefined;
|
|
633
|
+
const premiseResult = await invokeWithAbortSignal(deps.premiseGraph, {
|
|
634
|
+
userId: state.userId,
|
|
635
|
+
assertionText: p.text,
|
|
636
|
+
tier: p.tier,
|
|
637
|
+
operationMode: 'create',
|
|
638
|
+
volatile: isContextual,
|
|
639
|
+
...(validUntil ? { validUntil } : {}),
|
|
640
|
+
...(state.activeSocialIds?.length
|
|
641
|
+
? { provenanceSource: 'integration', provenanceSourceId: state.activeSocialIds[0] }
|
|
642
|
+
: {}),
|
|
643
|
+
});
|
|
644
|
+
if (premiseResult.premise) {
|
|
645
|
+
created++;
|
|
646
|
+
}
|
|
647
|
+
else if (premiseResult.duplicateOf) {
|
|
648
|
+
skippedDuplicates++;
|
|
649
|
+
}
|
|
650
|
+
else if (premiseResult.error) {
|
|
651
|
+
logger.warn("Premise creation failed", {
|
|
652
|
+
text: p.text.substring(0, 60),
|
|
653
|
+
error: premiseResult.error,
|
|
654
|
+
});
|
|
692
655
|
}
|
|
693
|
-
logger.verbose("Profile generation needed but no premise graph injected — ending");
|
|
694
|
-
return END;
|
|
695
656
|
}
|
|
696
|
-
|
|
697
|
-
logger.
|
|
698
|
-
|
|
657
|
+
catch (err) {
|
|
658
|
+
logger.warn("Premise creation threw", {
|
|
659
|
+
text: p.text.substring(0, 60),
|
|
660
|
+
error: err instanceof Error ? err.message : String(err),
|
|
661
|
+
});
|
|
699
662
|
}
|
|
700
663
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
.
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
664
|
+
logger.verbose('Created premises', {
|
|
665
|
+
created,
|
|
666
|
+
requested: result.premises.length,
|
|
667
|
+
skippedDuplicates,
|
|
668
|
+
userId: state.userId,
|
|
669
|
+
});
|
|
670
|
+
return {
|
|
671
|
+
agentTimings: agentTimingsAccum,
|
|
672
|
+
operationsPerformed: { decomposedPremises: true },
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
logger.error("Premise decomposition failed", {
|
|
677
|
+
error: err instanceof Error ? err.message : String(err),
|
|
678
|
+
});
|
|
679
|
+
return {
|
|
680
|
+
agentTimings: agentTimingsAccum,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Route from check_state to next step based on operation mode and detected needs.
|
|
687
|
+
*/
|
|
688
|
+
export function checkStateCondition(state, deps) {
|
|
689
|
+
// Query mode: Return immediately (fast path)
|
|
690
|
+
if (state.operationMode === 'query') {
|
|
691
|
+
logger.verbose("Query mode - ending (fast path)");
|
|
692
|
+
return END;
|
|
693
|
+
}
|
|
694
|
+
// Generate mode: use enrichUserProfile Chat API to auto-generate
|
|
695
|
+
if (state.operationMode === 'generate') {
|
|
696
|
+
logger.verbose("Generate mode - routing to auto_generate");
|
|
697
|
+
return "auto_generate";
|
|
698
|
+
}
|
|
699
|
+
// Check if user information is insufficient for scraping
|
|
700
|
+
// Return early so chat graph can request the missing information
|
|
701
|
+
if (state.needsUserInfo) {
|
|
702
|
+
logger.verbose("⚠️ Insufficient user info - requesting from user", {
|
|
703
|
+
missingInfo: state.missingUserInfo
|
|
704
|
+
});
|
|
705
|
+
return END;
|
|
706
|
+
}
|
|
707
|
+
// Write mode: Check what needs generation
|
|
708
|
+
if (state.needsProfileGeneration) {
|
|
709
|
+
// Only use provided input if it's meaningful (not just "Yes" / confirmation)
|
|
710
|
+
if (state.input && isMeaningfulProfileInput(state.input)) {
|
|
711
|
+
// Route through premise decomposition when a premise graph is available.
|
|
712
|
+
// The decompose node extracts atomic premises and creates them; premise
|
|
713
|
+
// events drive user_context regeneration. Without a premise graph there
|
|
714
|
+
// is nothing to do, so end the run.
|
|
715
|
+
if (deps.premiseGraph) {
|
|
716
|
+
logger.verbose("Profile generation needed — decomposing input into premises");
|
|
732
717
|
return "decompose_premises";
|
|
733
718
|
}
|
|
734
|
-
logger.verbose("
|
|
719
|
+
logger.verbose("Profile generation needed but no premise graph injected — ending");
|
|
735
720
|
return END;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
.addConditionalEdges("scrape", (_state) => {
|
|
742
|
-
if (this.premiseGraph)
|
|
743
|
-
return "decompose_premises";
|
|
744
|
-
return END;
|
|
745
|
-
}, {
|
|
746
|
-
decompose_premises: "decompose_premises",
|
|
747
|
-
[END]: END,
|
|
748
|
-
});
|
|
749
|
-
logger.verbose("Graph built successfully");
|
|
750
|
-
return workflow.compile();
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
logger.verbose("Profile generation needed - scraping first (no meaningful input)");
|
|
724
|
+
return "scrape";
|
|
725
|
+
}
|
|
751
726
|
}
|
|
727
|
+
// Everything exists and is up to date
|
|
728
|
+
logger.verbose("All components exist - ending");
|
|
729
|
+
return END;
|
|
752
730
|
}
|