@indexnetwork/protocol 66.0.0-rc.608.1 → 66.0.0-rc.609.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/index.d.ts +5 -4
- package/dist/index.js +3 -3
- 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/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
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
- Opportunity copy comes only from the LLM presenter. `OpportunityPresenter.present()` and `presentCard()` throw on LLM failure, timeout, or invalid output instead of returning fallback copy; `isFallback` and `fallbackReason` are gone from their results.
|
|
8
8
|
- Remove `presentOpportunity`, `stripUuids`, `truncateAtBoundary`, `safeFallbackSummary`, `DEFAULT_FALLBACK_HEADLINE`, `hasUnsupportedOpportunityClaim`, `stripUnsupportedOpportunityClaims`, and `stripUnsupportedOpportunityClaimsText` from the public entry point. The regex claim-safety guard is deleted; the presenter prompts carry the rule.
|
|
9
|
-
- `readOpportunities` items no longer carry `reasoning`.
|
|
9
|
+
- `readOpportunities` items no longer carry `reasoning`.
|
|
10
|
+
- Remove `RadarGraphFactory` and the radar graph. Use `listOpportunityCards(deps, { viewerId, statuses, networkId?, intentId?, limit, noCache?, skeleton? })` for a presented card list and `presentOpportunityCard(deps, opportunity, viewerId, { intentId?, skeleton? })` for one card; both return `OpportunityCard`. `statuses` is required, and the list is newest first with one card per counterpart. A card whose presenter call fails is dropped from the list; `presentOpportunityCard` throws.
|
|
11
|
+
- Rename `RadarGraphDatabase` to `OpportunityCardsDatabase` and `buildRadarCardPresentationCacheKey` to `buildOpportunityCardCacheKey` (keys now start with `card:`). Remove `RADAR_SOFT_TARGETS` and `selectByComposition`.
|
|
10
12
|
|
|
11
13
|
## 65.0.0
|
|
12
14
|
|
package/IMPLEMENTATION.md
CHANGED
|
@@ -128,26 +128,25 @@ negotiation state and presentation contracts.
|
|
|
128
128
|
Optional capabilities default to a
|
|
129
129
|
degraded-but-functional mode when omitted.
|
|
130
130
|
|
|
131
|
-
##
|
|
131
|
+
## Opportunity cards
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
Presented opportunity cards are plain async functions over
|
|
134
|
+
`{ database: OpportunityCardsDatabase, cache: OpportunityCache, presenter: OpportunityPresenter }`:
|
|
134
135
|
|
|
135
136
|
```typescript
|
|
136
137
|
import {
|
|
137
|
-
|
|
138
|
+
listOpportunityCards,
|
|
139
|
+
presentOpportunityCard,
|
|
138
140
|
} from "@indexnetwork/protocol";
|
|
139
141
|
```
|
|
140
142
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
+
`listOpportunityCards` returns the viewer's opportunities in the requested
|
|
144
|
+
statuses, newest first with one card per counterpart, serving cached cards and
|
|
145
|
+
dropping any whose presenter call fails. `presentOpportunityCard` presents one
|
|
146
|
+
opportunity and throws when the presenter fails.
|
|
143
147
|
|
|
144
|
-
The intent and community graphs are
|
|
145
|
-
|
|
146
|
-
[Intents](#intents) and [Networks](#networks) below).
|
|
147
|
-
|
|
148
|
-
| Factory | Workflow |
|
|
149
|
-
|---|---|
|
|
150
|
-
| `RadarGraphFactory` | Build the radar view: flat presenter-card list, optionally intent-scoped |
|
|
148
|
+
The intent and community graphs are reached through the `Intents` and `Networks`
|
|
149
|
+
module classes (see [Intents](#intents) and [Networks](#networks) below).
|
|
151
150
|
|
|
152
151
|
## On-demand discovery
|
|
153
152
|
|
package/STABILITY.md
CHANGED
|
@@ -46,7 +46,6 @@ Covered by SemVer below. Breaking changes require a **major** bump.
|
|
|
46
46
|
| **Public API** | Model config helpers, request scope helpers (`deriveAllowedNetworkIds`, `deriveDiscoveryNetworkIds`), `requestContext`. |
|
|
47
47
|
| **Interfaces** | Every port you implement to inject infrastructure (databases, embedder, cache, scraper, integration, …). |
|
|
48
48
|
| **Shared schemas** | Zod schemas + inferred types that cross the boundary (underspecification, identity, network-assignment, chat-context, …). |
|
|
49
|
-
| **Graph factories** | `*GraphFactory` classes (for example, `RadarGraphFactory`). |
|
|
50
49
|
| **Intents** | `Intents` — the whole signal capability as one class (lifecycle graph, verification, preparation) plus `IntentsDeps` and the `Prepare*` / `RecoveryField*` types. Replaced the six separate intent exports in 18.0.0. |
|
|
51
50
|
| **Agents** | Structured LLM agents (`OpportunityEvaluator`, …). |
|
|
52
51
|
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { requestContext, setRequestContextStore } from "./internal/shared/observ
|
|
|
6
6
|
export { setLoggerFactory } from "./internal/shared/observability/log.js";
|
|
7
7
|
export { setTimingWrapper } from "./internal/shared/observability/performance.js";
|
|
8
8
|
export type { Cache, CacheOptions, OpportunityCache } from "./platform/discovery/cache.js";
|
|
9
|
-
export type { CompositeDatabase, UserDatabase, SystemDatabase, OpportunityDatabase, OpportunityControllerDatabase,
|
|
9
|
+
export type { CompositeDatabase, UserDatabase, SystemDatabase, OpportunityDatabase, OpportunityControllerDatabase, OpportunityCardsDatabase, IntentGraphDatabase, Opportunity, OpportunityActor, OpportunityStatus, AssignmentNetworkMembership, IntentNetworkFinalAssignmentResult, CreateOpportunityData, IntentLifecycleStatus, TransitionLifecycleResult, NegotiationContextDatabase, NegotiationContextRecord, NegotiationContextOutcome, NegotiationContextTurn, } from "./platform/database.js";
|
|
10
10
|
export type { Embedder, VectorStoreOption, VectorSearchResult } from "./platform/discovery/embedder.js";
|
|
11
11
|
export type { IntentFollowUp } from "./platform/runtime/follow-up.js";
|
|
12
12
|
export type { Scraper } from "./platform/discovery/scraper.js";
|
|
@@ -44,10 +44,11 @@ export type { CreateIntentCounterpartyData, OpenedNegotiation, } from "./interna
|
|
|
44
44
|
export { gatherPresenterContext, OpportunityPresenter, } from "./internal/opportunities/opportunity.presentation.js";
|
|
45
45
|
export type { PresenterDatabase, } from "./internal/opportunities/opportunity.presentation.js";
|
|
46
46
|
export { getPrimaryActionLabel, } from "./internal/opportunities/opportunity.labels.js";
|
|
47
|
-
export { buildApiChatCardPresentationCacheKey,
|
|
47
|
+
export { buildApiChatCardPresentationCacheKey, buildOpportunityCardCacheKey, } from "./internal/opportunities/opportunity.presentation.js";
|
|
48
48
|
export type { UserInfo, } from "./internal/opportunities/opportunity.presentation.js";
|
|
49
|
-
export { canUserSeeOpportunity, classifyOpportunity, isActionableForViewer,
|
|
50
|
-
export {
|
|
49
|
+
export { canUserSeeOpportunity, classifyOpportunity, isActionableForViewer, validateOpportunityActors, } from "./internal/opportunities/opportunity.utils.js";
|
|
50
|
+
export { listOpportunityCards, presentOpportunityCard, } from "./internal/opportunities/opportunity.cards.js";
|
|
51
|
+
export type { OpportunityCard, } from "./internal/opportunities/opportunity.cards.js";
|
|
51
52
|
export { readOpportunities } from './internal/opportunities/opportunity.graph.modes.js';
|
|
52
53
|
export { admitOpportunityEvent, projectOpportunity } from './internal/opportunities/opportunity.events.js';
|
|
53
54
|
export type { OpportunityAdmission, OpportunityEventType, OpportunityLogEvent, OpportunityProjection, OpportunityProjectionStatus } from './internal/opportunities/opportunity.events.js';
|
package/dist/index.js
CHANGED
|
@@ -48,9 +48,9 @@ export { normalizeTelegramHandle } from './internal/shared/utils/telegram-handle
|
|
|
48
48
|
export { pairKeyOf, } from "./internal/opportunities/opportunity.counterparties.js";
|
|
49
49
|
export { gatherPresenterContext, OpportunityPresenter, } from "./internal/opportunities/opportunity.presentation.js";
|
|
50
50
|
export { getPrimaryActionLabel, } from "./internal/opportunities/opportunity.labels.js";
|
|
51
|
-
export { buildApiChatCardPresentationCacheKey,
|
|
52
|
-
export { canUserSeeOpportunity, classifyOpportunity, isActionableForViewer,
|
|
53
|
-
export {
|
|
51
|
+
export { buildApiChatCardPresentationCacheKey, buildOpportunityCardCacheKey, } from "./internal/opportunities/opportunity.presentation.js";
|
|
52
|
+
export { canUserSeeOpportunity, classifyOpportunity, isActionableForViewer, validateOpportunityActors, } from "./internal/opportunities/opportunity.utils.js";
|
|
53
|
+
export { listOpportunityCards, presentOpportunityCard, } from "./internal/opportunities/opportunity.cards.js";
|
|
54
54
|
export { readOpportunities } from './internal/opportunities/opportunity.graph.modes.js';
|
|
55
55
|
export { admitOpportunityEvent, projectOpportunity } from './internal/opportunities/opportunity.events.js';
|
|
56
56
|
export { resolveDiscoveryNetworkScope, renderDiscoveryNetworkContext } from './protocol/discovery.rules.js';
|
|
@@ -0,0 +1,78 @@
|
|
|
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 type { Opportunity, OpportunityCardsDatabase, OpportunityStatus } from '../../platform/database.js';
|
|
7
|
+
import type { OpportunityCache } from '../../platform/discovery/cache.js';
|
|
8
|
+
import { OpportunityPresenter } from './opportunity.presentation.js';
|
|
9
|
+
/** One opportunity with its presenter-driven display contract. */
|
|
10
|
+
export interface OpportunityCard {
|
|
11
|
+
opportunityId: string;
|
|
12
|
+
createdAt?: string;
|
|
13
|
+
/** Lifecycle status of the underlying opportunity at render time. */
|
|
14
|
+
status?: OpportunityStatus;
|
|
15
|
+
userId: string;
|
|
16
|
+
name: string;
|
|
17
|
+
avatar: string | null;
|
|
18
|
+
mainText: string;
|
|
19
|
+
cta: string;
|
|
20
|
+
headline?: string;
|
|
21
|
+
primaryActionLabel: string;
|
|
22
|
+
secondaryActionLabel: string;
|
|
23
|
+
mutualIntentsLabel: string;
|
|
24
|
+
narratorChip?: {
|
|
25
|
+
name: string;
|
|
26
|
+
text: string;
|
|
27
|
+
avatar?: string | null;
|
|
28
|
+
userId?: string;
|
|
29
|
+
};
|
|
30
|
+
/** Viewer's role in this opportunity (e.g. 'party', 'agent', 'patient', 'peer'). */
|
|
31
|
+
viewerRole?: string;
|
|
32
|
+
/**
|
|
33
|
+
* True for a skeleton card: identity fields are real but mainText/cta are
|
|
34
|
+
* empty because the presenter was skipped. Never cached.
|
|
35
|
+
*/
|
|
36
|
+
presentationPending?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** Everything card presentation reaches for. */
|
|
39
|
+
export interface OpportunityCardsDeps {
|
|
40
|
+
database: OpportunityCardsDatabase;
|
|
41
|
+
cache: OpportunityCache;
|
|
42
|
+
presenter: OpportunityPresenter;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Present one opportunity as a card for the viewer.
|
|
46
|
+
*
|
|
47
|
+
* @param deps - Database, cache, and presenter.
|
|
48
|
+
* @param opportunity - The opportunity to present.
|
|
49
|
+
* @param viewerId - The viewing user.
|
|
50
|
+
* @param options - `intentId` focuses viewer context on one intent; `skeleton` skips the presenter.
|
|
51
|
+
* @returns The card, or null when the counterpart's name cannot be resolved.
|
|
52
|
+
* @throws When the presenter fails.
|
|
53
|
+
*/
|
|
54
|
+
export declare function presentOpportunityCard(deps: OpportunityCardsDeps, opportunity: Opportunity, viewerId: string, options?: {
|
|
55
|
+
intentId?: string;
|
|
56
|
+
skeleton?: boolean;
|
|
57
|
+
}): Promise<OpportunityCard | null>;
|
|
58
|
+
/**
|
|
59
|
+
* List the viewer's opportunities in the given statuses as presented cards,
|
|
60
|
+
* newest first, one card per counterpart. Cards whose presentation fails are
|
|
61
|
+
* dropped.
|
|
62
|
+
*
|
|
63
|
+
* @param deps - Database, cache, and presenter.
|
|
64
|
+
* @param input - Viewer, statuses, optional network/intent scope, limit, cache bypass, skeleton mode.
|
|
65
|
+
* @returns The cards and the number of opportunities selected.
|
|
66
|
+
*/
|
|
67
|
+
export declare function listOpportunityCards(deps: OpportunityCardsDeps, input: {
|
|
68
|
+
viewerId: string;
|
|
69
|
+
statuses: OpportunityStatus[];
|
|
70
|
+
networkId?: string;
|
|
71
|
+
intentId?: string;
|
|
72
|
+
limit: number;
|
|
73
|
+
noCache?: boolean;
|
|
74
|
+
skeleton?: boolean;
|
|
75
|
+
}): Promise<{
|
|
76
|
+
cards: OpportunityCard[];
|
|
77
|
+
totalOpportunities: number;
|
|
78
|
+
}>;
|
|
@@ -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
|
-
}
|
|
@@ -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
|
-
}
|