@indexnetwork/protocol 66.0.0-rc.608.1 → 66.0.0-rc.610.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 +3 -1
- package/IMPLEMENTATION.md +11 -12
- package/STABILITY.md +0 -1
- package/dist/capabilities/intents.d.ts +7 -3
- package/dist/capabilities/intents.js +10 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.js +3 -3
- package/dist/internal/intents/graph/intent.graph.reconcile.js +1 -1
- package/dist/internal/intents/intent.admission.d.ts +22 -1
- package/dist/internal/intents/intent.admission.js +29 -8
- package/dist/internal/intents/intent.preparer.d.ts +29 -4
- package/dist/internal/intents/intent.preparer.js +125 -18
- package/dist/internal/opportunities/opportunity.cards.d.ts +78 -0
- package/dist/internal/opportunities/opportunity.cards.js +165 -0
- package/dist/internal/opportunities/opportunity.presentation.d.ts +2 -2
- package/dist/internal/opportunities/opportunity.presentation.js +3 -3
- package/dist/internal/opportunities/opportunity.utils.d.ts +0 -42
- package/dist/internal/opportunities/opportunity.utils.js +0 -102
- package/dist/internal/shared/agent/decision.client.d.ts +42 -0
- package/dist/internal/shared/agent/decision.client.js +61 -0
- package/dist/platform/database/capabilities.d.ts +2 -2
- package/dist/platform/database/entities.d.ts +1 -1
- package/package.json +1 -1
- package/dist/internal/opportunities/opportunity.feed-selection.d.ts +0 -24
- package/dist/internal/opportunities/opportunity.feed-selection.js +0 -40
- package/dist/internal/opportunities/radar/radar.graph.d.ts +0 -230
- package/dist/internal/opportunities/radar/radar.graph.js +0 -506
- package/dist/internal/opportunities/radar/radar.state.d.ts +0 -86
- package/dist/internal/opportunities/radar/radar.state.js +0 -87
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opportunity cards: presenter-texted cards for a viewer, as a list or one at
|
|
3
|
+
* a time. The list loads, filters, and dedupes the viewer's opportunities,
|
|
4
|
+
* serves cached cards, and presents the misses through OpportunityPresenter.
|
|
5
|
+
*/
|
|
6
|
+
import { protocolLogger } from '../shared/observability/protocol.logger.js';
|
|
7
|
+
import { buildOpportunityCardCacheKey, gatherPresenterContext } from './opportunity.presentation.js';
|
|
8
|
+
import { loadNegotiationContext } from './negotiation-context.loader.js';
|
|
9
|
+
import { canUserSeeOpportunity, isActionableForViewer } from './opportunity.utils.js';
|
|
10
|
+
import { getPrimaryActionLabel, SECONDARY_ACTION_LABEL } from './opportunity.labels.js';
|
|
11
|
+
const logger = protocolLogger('OpportunityCards');
|
|
12
|
+
const PRESENTATION_CONCURRENCY = 50;
|
|
13
|
+
const CARD_CACHE_TTL = 24 * 60 * 60;
|
|
14
|
+
const ROLE_PRIORITY = new Map([
|
|
15
|
+
['patient', 0],
|
|
16
|
+
['party', 1],
|
|
17
|
+
['agent', 2],
|
|
18
|
+
['peer', 3],
|
|
19
|
+
]);
|
|
20
|
+
/** Prefer direct counterpart roles, then a stable sort by user id. */
|
|
21
|
+
function pickDisplayCounterpartActor(opportunity, viewerId) {
|
|
22
|
+
const candidates = opportunity.actors.filter((actor) => actor.userId !== viewerId);
|
|
23
|
+
return [...candidates].sort((a, b) => {
|
|
24
|
+
const aPriority = ROLE_PRIORITY.get(a.role) ?? 99;
|
|
25
|
+
const bPriority = ROLE_PRIORITY.get(b.role) ?? 99;
|
|
26
|
+
if (aPriority !== bPriority)
|
|
27
|
+
return aPriority - bPriority;
|
|
28
|
+
return a.userId.localeCompare(b.userId);
|
|
29
|
+
})[0] ?? null;
|
|
30
|
+
}
|
|
31
|
+
function counterpartUserIds(opportunity, viewerId) {
|
|
32
|
+
return new Set(opportunity.actors.map((a) => a.userId).filter((id) => id && id !== viewerId));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Present one opportunity as a card for the viewer.
|
|
36
|
+
*
|
|
37
|
+
* @param deps - Database, cache, and presenter.
|
|
38
|
+
* @param opportunity - The opportunity to present.
|
|
39
|
+
* @param viewerId - The viewing user.
|
|
40
|
+
* @param options - `intentId` focuses viewer context on one intent; `skeleton` skips the presenter.
|
|
41
|
+
* @returns The card, or null when the counterpart's name cannot be resolved.
|
|
42
|
+
* @throws When the presenter fails.
|
|
43
|
+
*/
|
|
44
|
+
export async function presentOpportunityCard(deps, opportunity, viewerId, options = {}) {
|
|
45
|
+
const { database } = deps;
|
|
46
|
+
const viewerRole = opportunity.actors.find((a) => a.userId === viewerId)?.role ?? 'party';
|
|
47
|
+
const counterpart = pickDisplayCounterpartActor(opportunity, viewerId);
|
|
48
|
+
if (!counterpart)
|
|
49
|
+
return null;
|
|
50
|
+
const user = await database.getUser(counterpart.userId).catch(() => null);
|
|
51
|
+
let name = user?.name?.trim();
|
|
52
|
+
if (!name) {
|
|
53
|
+
const profile = await database.getProfile(counterpart.userId).catch(() => null);
|
|
54
|
+
name = profile?.identity?.name?.trim();
|
|
55
|
+
}
|
|
56
|
+
if (!name)
|
|
57
|
+
return null;
|
|
58
|
+
const identity = {
|
|
59
|
+
opportunityId: opportunity.id,
|
|
60
|
+
createdAt: opportunity.createdAt.toISOString(),
|
|
61
|
+
status: opportunity.status,
|
|
62
|
+
userId: counterpart.userId,
|
|
63
|
+
name,
|
|
64
|
+
avatar: user?.avatar ?? null,
|
|
65
|
+
primaryActionLabel: getPrimaryActionLabel(viewerRole),
|
|
66
|
+
secondaryActionLabel: SECONDARY_ACTION_LABEL,
|
|
67
|
+
viewerRole,
|
|
68
|
+
};
|
|
69
|
+
if (options.skeleton) {
|
|
70
|
+
return { ...identity, mainText: '', cta: '', mutualIntentsLabel: 'Shared interests', presentationPending: true };
|
|
71
|
+
}
|
|
72
|
+
const [context, negotiationContext] = await Promise.all([
|
|
73
|
+
gatherPresenterContext(database, opportunity, viewerId, counterpart.userId, options.intentId),
|
|
74
|
+
loadNegotiationContext(database, opportunity.id, opportunity.status, viewerId),
|
|
75
|
+
]);
|
|
76
|
+
const presentation = await deps.presenter.presentCard({
|
|
77
|
+
...context,
|
|
78
|
+
opportunityStatus: opportunity.status,
|
|
79
|
+
...(negotiationContext ? { negotiationContext } : {}),
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
...identity,
|
|
83
|
+
mainText: presentation.personalizedSummary,
|
|
84
|
+
cta: presentation.suggestedAction,
|
|
85
|
+
headline: presentation.headline,
|
|
86
|
+
mutualIntentsLabel: presentation.mutualIntentsLabel,
|
|
87
|
+
narratorChip: { name: 'Index', text: presentation.narratorRemark },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* List the viewer's opportunities in the given statuses as presented cards,
|
|
92
|
+
* newest first, one card per counterpart. Cards whose presentation fails are
|
|
93
|
+
* dropped.
|
|
94
|
+
*
|
|
95
|
+
* @param deps - Database, cache, and presenter.
|
|
96
|
+
* @param input - Viewer, statuses, optional network/intent scope, limit, cache bypass, skeleton mode.
|
|
97
|
+
* @returns The cards and the number of opportunities selected.
|
|
98
|
+
*/
|
|
99
|
+
export async function listOpportunityCards(deps, input) {
|
|
100
|
+
const { viewerId, intentId } = input;
|
|
101
|
+
const raw = await deps.database.getOpportunitiesForUser(viewerId, {
|
|
102
|
+
limit: Math.min(150, Math.max(50, input.limit * 3)),
|
|
103
|
+
statuses: input.statuses,
|
|
104
|
+
...(input.networkId ? { networkId: input.networkId } : {}),
|
|
105
|
+
...(intentId ? { scopeType: 'intent', scopeId: intentId } : {}),
|
|
106
|
+
});
|
|
107
|
+
const requested = new Set(input.statuses);
|
|
108
|
+
const visible = raw
|
|
109
|
+
.filter((opp) => requested.has(opp.status) && canUserSeeOpportunity(opp.actors, opp.status, viewerId))
|
|
110
|
+
.filter((opp) => opp.status !== 'pending'
|
|
111
|
+
|| isActionableForViewer(opp.actors, opp.status, viewerId, opp.committedActorIds ?? []))
|
|
112
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const opportunities = visible.filter((opp) => {
|
|
115
|
+
const ids = counterpartUserIds(opp, viewerId);
|
|
116
|
+
if ([...ids].some((id) => seen.has(id)))
|
|
117
|
+
return false;
|
|
118
|
+
for (const id of ids)
|
|
119
|
+
seen.add(id);
|
|
120
|
+
return true;
|
|
121
|
+
}).slice(0, input.limit);
|
|
122
|
+
const cacheable = (opp) => opp.status !== 'negotiating';
|
|
123
|
+
const keyFor = (opp) => buildOpportunityCardCacheKey(opp.id, opp.status, viewerId, intentId);
|
|
124
|
+
const cacheableOpps = input.noCache ? [] : opportunities.filter(cacheable);
|
|
125
|
+
const cached = new Map();
|
|
126
|
+
if (cacheableOpps.length > 0) {
|
|
127
|
+
try {
|
|
128
|
+
const hits = await deps.cache.mget(cacheableOpps.map(keyFor));
|
|
129
|
+
cacheableOpps.forEach((opp, i) => {
|
|
130
|
+
const hit = hits[i];
|
|
131
|
+
if (hit)
|
|
132
|
+
cached.set(opp.id, { ...hit, status: opp.status });
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
logger.warn('card cache read failed, presenting all', { error });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const cards = [];
|
|
140
|
+
for (let i = 0; i < opportunities.length; i += PRESENTATION_CONCURRENCY) {
|
|
141
|
+
const chunk = opportunities.slice(i, i + PRESENTATION_CONCURRENCY);
|
|
142
|
+
cards.push(...await Promise.all(chunk.map(async (opp) => {
|
|
143
|
+
const hit = cached.get(opp.id);
|
|
144
|
+
if (hit)
|
|
145
|
+
return hit;
|
|
146
|
+
try {
|
|
147
|
+
const card = await presentOpportunityCard(deps, opp, viewerId, { intentId, skeleton: input.skeleton });
|
|
148
|
+
if (card && !card.presentationPending && cacheable(opp)) {
|
|
149
|
+
await deps.cache.set(keyFor(opp), card, { ttl: CARD_CACHE_TTL }).catch((error) => {
|
|
150
|
+
logger.warn('card cache write failed', { opportunityId: opp.id, error });
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return card;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
logger.warn('presenter failed, dropping card', { opportunityId: opp.id, error });
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
})));
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
cards: cards.filter((card) => card !== null),
|
|
163
|
+
totalOpportunities: opportunities.length,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -20,7 +20,7 @@ export interface UserInfo {
|
|
|
20
20
|
}
|
|
21
21
|
/** Cache namespace for opportunity presentation copy. Bump to invalidate copy safety changes. */
|
|
22
22
|
export declare const OPPORTUNITY_PRESENTATION_CACHE_VERSION = "v2";
|
|
23
|
-
export declare function
|
|
23
|
+
export declare function buildOpportunityCardCacheKey(opportunityId: string, status: string, viewerId: string, focusedViewerIntentId?: string): string;
|
|
24
24
|
export declare function buildApiChatCardPresentationCacheKey(opportunityId: string, viewerId: string): string;
|
|
25
25
|
/**
|
|
26
26
|
* Opportunity Presenter Agent
|
|
@@ -159,7 +159,7 @@ export declare function summarizeSignalsForPresenter(signals: Opportunity['inter
|
|
|
159
159
|
* Gather all context needed for the presenter from the database.
|
|
160
160
|
* Fetches viewer profile, viewer intents, other party profile(s), and network in parallel.
|
|
161
161
|
*
|
|
162
|
-
* @param displayCounterpartUserId - When set (e.g. for
|
|
162
|
+
* @param displayCounterpartUserId - When set (e.g. for an opportunity card), only this counterpart is included in otherPartyContext so the presenter writes about the person on the card.
|
|
163
163
|
* @param focusedViewerIntentId - When set, include only that active intent in viewer context.
|
|
164
164
|
*/
|
|
165
165
|
export declare function gatherPresenterContext(database: PresenterDatabase, opportunity: Opportunity, viewerId: string, displayCounterpartUserId?: string, focusedViewerIntentId?: string): Promise<PresenterInput>;
|
|
@@ -27,9 +27,9 @@ import { createStructuredModel } from "../shared/agent/model.config.js";
|
|
|
27
27
|
// ──────────────────────────────────────────────────────────────────────
|
|
28
28
|
/** Cache namespace for opportunity presentation copy. Bump to invalidate copy safety changes. */
|
|
29
29
|
export const OPPORTUNITY_PRESENTATION_CACHE_VERSION = "v2";
|
|
30
|
-
export function
|
|
30
|
+
export function buildOpportunityCardCacheKey(opportunityId, status, viewerId, focusedViewerIntentId) {
|
|
31
31
|
const scope = focusedViewerIntentId ? `:intent:${focusedViewerIntentId}` : "";
|
|
32
|
-
return `
|
|
32
|
+
return `card:${OPPORTUNITY_PRESENTATION_CACHE_VERSION}:${opportunityId}:${status}:${viewerId}${scope}`;
|
|
33
33
|
}
|
|
34
34
|
export function buildApiChatCardPresentationCacheKey(opportunityId, viewerId) {
|
|
35
35
|
return `chat:${OPPORTUNITY_PRESENTATION_CACHE_VERSION}:card:${opportunityId}:${viewerId}`;
|
|
@@ -387,7 +387,7 @@ export function summarizeSignalsForPresenter(signals) {
|
|
|
387
387
|
* Gather all context needed for the presenter from the database.
|
|
388
388
|
* Fetches viewer profile, viewer intents, other party profile(s), and network in parallel.
|
|
389
389
|
*
|
|
390
|
-
* @param displayCounterpartUserId - When set (e.g. for
|
|
390
|
+
* @param displayCounterpartUserId - When set (e.g. for an opportunity card), only this counterpart is included in otherPartyContext so the presenter writes about the person on the card.
|
|
391
391
|
* @param focusedViewerIntentId - When set, include only that active intent in viewer context.
|
|
392
392
|
*/
|
|
393
393
|
export async function gatherPresenterContext(database, opportunity, viewerId, displayCounterpartUserId, focusedViewerIntentId) {
|
|
@@ -46,11 +46,6 @@ export declare function isActionableForViewer(actors: Array<{
|
|
|
46
46
|
}>, status: string, viewerId: string, committedActorIds?: readonly string[]): boolean;
|
|
47
47
|
/** Feed category for home composition. */
|
|
48
48
|
export type FeedCategory = 'connection' | 'expired';
|
|
49
|
-
/** Soft targets for radar composition. */
|
|
50
|
-
export declare const RADAR_SOFT_TARGETS: {
|
|
51
|
-
readonly connection: 5;
|
|
52
|
-
readonly expired: 2;
|
|
53
|
-
};
|
|
54
49
|
/**
|
|
55
50
|
* Classify an actionable opportunity into a feed category.
|
|
56
51
|
* Assumes the opportunity already passed isActionableForViewer or is expired.
|
|
@@ -66,40 +61,3 @@ export declare function classifyOpportunity(opp: {
|
|
|
66
61
|
}>;
|
|
67
62
|
status: string;
|
|
68
63
|
}, _viewerId: string): FeedCategory;
|
|
69
|
-
/**
|
|
70
|
-
* Select opportunities for the radar using soft composition targets.
|
|
71
|
-
* Fills each category up to its target, then redistributes unused slots
|
|
72
|
-
* to categories that have more items available. Preserves input order.
|
|
73
|
-
*
|
|
74
|
-
* @param opportunities - Pre-sorted opportunities (by confidence/recency)
|
|
75
|
-
* @param viewerId - The viewing user's ID
|
|
76
|
-
* @returns Composition-balanced subset
|
|
77
|
-
*/
|
|
78
|
-
export declare function selectByComposition<T extends {
|
|
79
|
-
actors: Array<{
|
|
80
|
-
userId: string;
|
|
81
|
-
role: string;
|
|
82
|
-
}>;
|
|
83
|
-
status: string;
|
|
84
|
-
}>(opportunities: T[], viewerId: string): T[];
|
|
85
|
-
/**
|
|
86
|
-
* Deduplicate opportunities so each counterpart appears at most once.
|
|
87
|
-
* Keeps the opportunity with the highest interpretation.confidence per
|
|
88
|
-
* counterpart userId. On ties, the first encountered wins (stable).
|
|
89
|
-
*
|
|
90
|
-
* Counterpart = first actor whose userId !== viewerId.
|
|
91
|
-
* Opportunities without a derivable counterpart pass through undeduped.
|
|
92
|
-
*
|
|
93
|
-
* @param opportunities - Pre-sorted opportunities (e.g. by confidence/recency)
|
|
94
|
-
* @param viewerId - The viewing user's ID
|
|
95
|
-
* @returns Deduped subset preserving original input order among winners
|
|
96
|
-
*/
|
|
97
|
-
export declare function deduplicateByPerson<T extends {
|
|
98
|
-
actors: Array<{
|
|
99
|
-
userId: string;
|
|
100
|
-
role: string;
|
|
101
|
-
}>;
|
|
102
|
-
interpretation?: {
|
|
103
|
-
confidence?: number;
|
|
104
|
-
} | null;
|
|
105
|
-
}>(opportunities: T[], viewerId: string): T[];
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { log } from '../shared/observability/log.js';
|
|
2
|
-
const logger = log.graph.from('SelectByComposition');
|
|
3
|
-
const dedupeByPersonLog = log.graph.from('DeduplicateByPerson');
|
|
4
1
|
/**
|
|
5
2
|
* Validates opportunity actors.
|
|
6
3
|
*
|
|
@@ -51,14 +48,6 @@ export function isActionableForViewer(actors, status, viewerId, committedActorId
|
|
|
51
48
|
return false;
|
|
52
49
|
return !committedActorIds.includes(viewerId);
|
|
53
50
|
}
|
|
54
|
-
/** Soft targets for radar composition. */
|
|
55
|
-
export const RADAR_SOFT_TARGETS = {
|
|
56
|
-
// 3 + 2 in a second category before that category was removed. The slots move to
|
|
57
|
-
// connections rather than shrinking the feed: the total a radar can hold is
|
|
58
|
-
// unchanged, and connections are the only kind of card left.
|
|
59
|
-
connection: 5,
|
|
60
|
-
expired: 2,
|
|
61
|
-
};
|
|
62
51
|
/**
|
|
63
52
|
* Classify an actionable opportunity into a feed category.
|
|
64
53
|
* Assumes the opportunity already passed isActionableForViewer or is expired.
|
|
@@ -72,94 +61,3 @@ export function classifyOpportunity(opp, _viewerId) {
|
|
|
72
61
|
return 'expired';
|
|
73
62
|
return 'connection';
|
|
74
63
|
}
|
|
75
|
-
/**
|
|
76
|
-
* Select opportunities for the radar using soft composition targets.
|
|
77
|
-
* Fills each category up to its target, then redistributes unused slots
|
|
78
|
-
* to categories that have more items available. Preserves input order.
|
|
79
|
-
*
|
|
80
|
-
* @param opportunities - Pre-sorted opportunities (by confidence/recency)
|
|
81
|
-
* @param viewerId - The viewing user's ID
|
|
82
|
-
* @returns Composition-balanced subset
|
|
83
|
-
*/
|
|
84
|
-
export function selectByComposition(opportunities, viewerId) {
|
|
85
|
-
const buckets = { connection: [], expired: [] };
|
|
86
|
-
for (const opp of opportunities) {
|
|
87
|
-
buckets[classifyOpportunity(opp, viewerId)].push(opp);
|
|
88
|
-
}
|
|
89
|
-
const targets = {
|
|
90
|
-
connection: RADAR_SOFT_TARGETS.connection,
|
|
91
|
-
expired: RADAR_SOFT_TARGETS.expired,
|
|
92
|
-
};
|
|
93
|
-
// First pass: fill each category up to its target.
|
|
94
|
-
const selected = {
|
|
95
|
-
connection: buckets.connection.slice(0, targets.connection),
|
|
96
|
-
expired: buckets.expired.slice(0, targets.expired),
|
|
97
|
-
};
|
|
98
|
-
// Second pass: redistribute unused slots, connection before expired.
|
|
99
|
-
let unusedSlots = (targets.connection + targets.expired)
|
|
100
|
-
- (selected.connection.length + selected.expired.length);
|
|
101
|
-
for (const category of ['connection', 'expired']) {
|
|
102
|
-
if (unusedSlots <= 0)
|
|
103
|
-
break;
|
|
104
|
-
const remaining = buckets[category].slice(selected[category].length);
|
|
105
|
-
const take = Math.min(remaining.length, unusedSlots);
|
|
106
|
-
selected[category].push(...remaining.slice(0, take));
|
|
107
|
-
unusedSlots -= take;
|
|
108
|
-
}
|
|
109
|
-
// Within each category, preserve original input order.
|
|
110
|
-
const indexMap = new Map(opportunities.map((opp, i) => [opp, i]));
|
|
111
|
-
const sortByOriginal = (a, b) => (indexMap.get(a) ?? 0) - (indexMap.get(b) ?? 0);
|
|
112
|
-
selected.connection.sort(sortByOriginal);
|
|
113
|
-
selected.expired.sort(sortByOriginal);
|
|
114
|
-
logger.info('Selected opportunities by composition', {
|
|
115
|
-
input: opportunities.length,
|
|
116
|
-
buckets: { connection: buckets.connection.length, expired: buckets.expired.length },
|
|
117
|
-
selected: { connection: selected.connection.length, expired: selected.expired.length },
|
|
118
|
-
});
|
|
119
|
-
return [...selected.connection, ...selected.expired];
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Deduplicate opportunities so each counterpart appears at most once.
|
|
123
|
-
* Keeps the opportunity with the highest interpretation.confidence per
|
|
124
|
-
* counterpart userId. On ties, the first encountered wins (stable).
|
|
125
|
-
*
|
|
126
|
-
* Counterpart = first actor whose userId !== viewerId.
|
|
127
|
-
* Opportunities without a derivable counterpart pass through undeduped.
|
|
128
|
-
*
|
|
129
|
-
* @param opportunities - Pre-sorted opportunities (e.g. by confidence/recency)
|
|
130
|
-
* @param viewerId - The viewing user's ID
|
|
131
|
-
* @returns Deduped subset preserving original input order among winners
|
|
132
|
-
*/
|
|
133
|
-
export function deduplicateByPerson(opportunities, viewerId) {
|
|
134
|
-
const bestByCounterpart = new Map();
|
|
135
|
-
const noCounterpart = [];
|
|
136
|
-
for (let i = 0; i < opportunities.length; i++) {
|
|
137
|
-
const opp = opportunities[i];
|
|
138
|
-
const counterpart = opp.actors.find((a) => a.userId !== viewerId);
|
|
139
|
-
if (!counterpart) {
|
|
140
|
-
noCounterpart.push({ opp, index: i });
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
const key = counterpart.userId;
|
|
144
|
-
const existing = bestByCounterpart.get(key);
|
|
145
|
-
if (!existing) {
|
|
146
|
-
bestByCounterpart.set(key, { opp, index: i });
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
const newConf = opp.interpretation?.confidence ?? -1;
|
|
150
|
-
const oldConf = existing.opp.interpretation?.confidence ?? -1;
|
|
151
|
-
if (newConf > oldConf) {
|
|
152
|
-
bestByCounterpart.set(key, { opp, index: i });
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
const all = [...bestByCounterpart.values(), ...noCounterpart];
|
|
156
|
-
all.sort((a, b) => a.index - b.index);
|
|
157
|
-
const result = all.map((entry) => entry.opp);
|
|
158
|
-
if (result.length < opportunities.length) {
|
|
159
|
-
dedupeByPersonLog.info('Deduped opportunities by person', {
|
|
160
|
-
input: opportunities.length,
|
|
161
|
-
output: result.length,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
return result;
|
|
165
|
-
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type DecisionQuestion = {
|
|
2
|
+
type: "noul";
|
|
3
|
+
instructions: string;
|
|
4
|
+
criteria?: {
|
|
5
|
+
true: string;
|
|
6
|
+
false: string;
|
|
7
|
+
};
|
|
8
|
+
} | {
|
|
9
|
+
type: "choice";
|
|
10
|
+
instructions: string;
|
|
11
|
+
criteria: Record<string, string>;
|
|
12
|
+
} | {
|
|
13
|
+
type: "score";
|
|
14
|
+
instructions: string;
|
|
15
|
+
criteria: string[];
|
|
16
|
+
};
|
|
17
|
+
export type DecisionAnswer = {
|
|
18
|
+
type: "noul";
|
|
19
|
+
noul: number;
|
|
20
|
+
} | {
|
|
21
|
+
type: "choice";
|
|
22
|
+
choice: string;
|
|
23
|
+
} | {
|
|
24
|
+
type: "score";
|
|
25
|
+
score: number;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Jev, through OpenRouter's Decisions API. It returns typed answers, not text.
|
|
29
|
+
* A missing key, a bad response, or a timeout throws. Callers keep their own fallback.
|
|
30
|
+
*/
|
|
31
|
+
export declare class DecisionClient {
|
|
32
|
+
private readonly apiKey?;
|
|
33
|
+
private readonly timeout;
|
|
34
|
+
constructor(apiKey?: string | undefined, timeout?: number);
|
|
35
|
+
/**
|
|
36
|
+
* @param state - The draft and whatever else the questions refer to.
|
|
37
|
+
* @param questions - Named noul, choice, or score questions.
|
|
38
|
+
* @param signal - Cancels this call. A request-scoped signal is used when this is omitted.
|
|
39
|
+
* @returns One answer per question key.
|
|
40
|
+
*/
|
|
41
|
+
decide(state: unknown, questions: Record<string, DecisionQuestion>, signal?: AbortSignal): Promise<Record<string, DecisionAnswer>>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getAbortSignalConfig } from "./model-signal.js";
|
|
2
|
+
const DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
|
|
3
|
+
const JEV_MODEL = "typesafe/jev-1.13";
|
|
4
|
+
const TIMEOUT_MS = 60000;
|
|
5
|
+
/**
|
|
6
|
+
* Jev, through OpenRouter's Decisions API. It returns typed answers, not text.
|
|
7
|
+
* A missing key, a bad response, or a timeout throws. Callers keep their own fallback.
|
|
8
|
+
*/
|
|
9
|
+
export class DecisionClient {
|
|
10
|
+
constructor(apiKey, timeout = TIMEOUT_MS) {
|
|
11
|
+
this.apiKey = apiKey;
|
|
12
|
+
this.timeout = timeout;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* @param state - The draft and whatever else the questions refer to.
|
|
16
|
+
* @param questions - Named noul, choice, or score questions.
|
|
17
|
+
* @param signal - Cancels this call. A request-scoped signal is used when this is omitted.
|
|
18
|
+
* @returns One answer per question key.
|
|
19
|
+
*/
|
|
20
|
+
async decide(state, questions, signal) {
|
|
21
|
+
const apiKey = this.apiKey ?? process.env.OPENROUTER_API_KEY;
|
|
22
|
+
if (!apiKey?.trim())
|
|
23
|
+
throw new Error("DecisionClient: OPENROUTER_API_KEY is required.");
|
|
24
|
+
const caller = signal ?? getAbortSignalConfig()?.signal;
|
|
25
|
+
const deadline = AbortSignal.timeout(this.timeout);
|
|
26
|
+
const stop = caller ? AbortSignal.any([caller, deadline]) : deadline;
|
|
27
|
+
const response = await fetch(DECISIONS_URL, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
signal: stop,
|
|
30
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify({ model: JEV_MODEL, state, questions }),
|
|
32
|
+
});
|
|
33
|
+
const body = await response.text();
|
|
34
|
+
let data = {};
|
|
35
|
+
try {
|
|
36
|
+
data = JSON.parse(body);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
if (response.ok)
|
|
40
|
+
throw new Error(`Jev returned a non-JSON response: ${body.slice(0, 500)}`);
|
|
41
|
+
}
|
|
42
|
+
if (!response.ok || data.error || !data.answers) {
|
|
43
|
+
throw new Error(`Jev request failed (${response.status}): ${(data.error?.message ?? body).slice(0, 500)}`);
|
|
44
|
+
}
|
|
45
|
+
return readAnswers(data.answers);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function readAnswers(raw) {
|
|
49
|
+
const answers = {};
|
|
50
|
+
for (const [key, answer] of Object.entries(raw)) {
|
|
51
|
+
if (answer?.type === "noul" && typeof answer.noul === "number")
|
|
52
|
+
answers[key] = { type: "noul", noul: answer.noul };
|
|
53
|
+
else if (answer?.type === "choice" && typeof answer.choice === "string")
|
|
54
|
+
answers[key] = { type: "choice", choice: answer.choice };
|
|
55
|
+
else if (answer?.type === "score" && typeof answer.score === "number")
|
|
56
|
+
answers[key] = { type: "score", score: answer.score };
|
|
57
|
+
else
|
|
58
|
+
throw new Error(`Jev answer "${key}" was unusable`);
|
|
59
|
+
}
|
|
60
|
+
return answers;
|
|
61
|
+
}
|
|
@@ -51,9 +51,9 @@ export type IntentNetworkGraphDatabase = Pick<Database, 'getIntentForIndexing' |
|
|
|
51
51
|
*/
|
|
52
52
|
export type NetworkMembershipGraphDatabase = Pick<Database, 'isNetworkMember' | 'isNetworkOwner' | 'getNetworkWithPermissions' | 'addMemberToNetwork' | 'removeMemberFromNetwork' | 'getNetworkMembersForMember'>;
|
|
53
53
|
/**
|
|
54
|
-
* Database interface for
|
|
54
|
+
* Database interface for opportunity cards.
|
|
55
55
|
* Load opportunities, enrich with profile/network, and support presenter context.
|
|
56
56
|
*
|
|
57
57
|
* Access layer: UserDatabase (own opportunities and profile)
|
|
58
58
|
*/
|
|
59
|
-
export type
|
|
59
|
+
export type OpportunityCardsDatabase = Pick<Database, 'getOpportunitiesForUser' | 'getOpportunity' | 'getProfile' | 'getActiveIntents' | 'getNetwork' | 'getUser'> & NegotiationContextDatabase;
|
|
@@ -485,6 +485,6 @@ export interface OpportunityQueryOptions {
|
|
|
485
485
|
actorUserId?: string;
|
|
486
486
|
limit?: number;
|
|
487
487
|
offset?: number;
|
|
488
|
-
/** When set, include draft opportunities for this chat session. When unset, exclude all draft opportunities (e.g.
|
|
488
|
+
/** When set, include draft opportunities for this chat session. When unset, exclude all draft opportunities (e.g. opportunity cards, API). */
|
|
489
489
|
conversationId?: string;
|
|
490
490
|
}
|
package/package.json
CHANGED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import type { CompositeDatabase, Opportunity } from '../../platform/database.js';
|
|
2
|
-
export interface OpportunityFeedSelectionInput {
|
|
3
|
-
reader: Pick<CompositeDatabase, 'getOpportunitiesForUser'>;
|
|
4
|
-
viewerId: string;
|
|
5
|
-
networkId?: string;
|
|
6
|
-
intentScope: {
|
|
7
|
-
scopeType?: 'intent';
|
|
8
|
-
scopeId?: string;
|
|
9
|
-
};
|
|
10
|
-
displayLimit: number;
|
|
11
|
-
warn: (message: string, data: Record<string, unknown>) => void;
|
|
12
|
-
}
|
|
13
|
-
export interface OpportunityFeedSelection {
|
|
14
|
-
opportunities: Opportunity[];
|
|
15
|
-
dedupedCount: number;
|
|
16
|
-
skippedIds: string[];
|
|
17
|
-
fetchedCount: number;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Selects actionable opportunities for the chat/feed surface. The caller actor
|
|
21
|
-
* check is deliberately the first post-read filter so non-actor rows cannot
|
|
22
|
-
* affect selection or downstream profile/presentation reads.
|
|
23
|
-
*/
|
|
24
|
-
export declare function selectOpportunityFeed(args: OpportunityFeedSelectionInput): Promise<OpportunityFeedSelection>;
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { deduplicateByPerson, selectByComposition } from './opportunity.utils.js';
|
|
2
|
-
const ACTIONABLE_FEED_STATUSES = ['pending'];
|
|
3
|
-
const FEED_FETCH_LIMIT = 30;
|
|
4
|
-
/**
|
|
5
|
-
* Selects actionable opportunities for the chat/feed surface. The caller actor
|
|
6
|
-
* check is deliberately the first post-read filter so non-actor rows cannot
|
|
7
|
-
* affect selection or downstream profile/presentation reads.
|
|
8
|
-
*/
|
|
9
|
-
export async function selectOpportunityFeed(args) {
|
|
10
|
-
const fetched = await args.reader.getOpportunitiesForUser(args.viewerId, {
|
|
11
|
-
networkId: args.networkId,
|
|
12
|
-
...args.intentScope,
|
|
13
|
-
statuses: ACTIONABLE_FEED_STATUSES,
|
|
14
|
-
limit: FEED_FETCH_LIMIT,
|
|
15
|
-
});
|
|
16
|
-
const skippedIds = [];
|
|
17
|
-
const callerScoped = fetched.filter((opportunity) => {
|
|
18
|
-
if (opportunity.actors.some((actor) => actor.userId === args.viewerId))
|
|
19
|
-
return true;
|
|
20
|
-
args.warn('Opportunity feed: skipping opportunity where caller is not an actor', {
|
|
21
|
-
opportunityId: opportunity.id,
|
|
22
|
-
viewerId: args.viewerId,
|
|
23
|
-
actorUserIds: opportunity.actors
|
|
24
|
-
.map((actor) => actor.userId)
|
|
25
|
-
.filter((userId) => typeof userId === 'string'),
|
|
26
|
-
});
|
|
27
|
-
skippedIds.push(opportunity.id);
|
|
28
|
-
return false;
|
|
29
|
-
});
|
|
30
|
-
const deduped = deduplicateByPerson(callerScoped, args.viewerId);
|
|
31
|
-
const selected = deduped.length > 0
|
|
32
|
-
? selectByComposition(deduped, args.viewerId)
|
|
33
|
-
: deduped;
|
|
34
|
-
return {
|
|
35
|
-
opportunities: selected.slice(0, args.displayLimit),
|
|
36
|
-
dedupedCount: deduped.length,
|
|
37
|
-
skippedIds,
|
|
38
|
-
fetchedCount: fetched.length,
|
|
39
|
-
};
|
|
40
|
-
}
|