ff1-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/config.json.example +78 -0
- package/dist/index.js +627 -0
- package/dist/src/ai-orchestrator/index.js +870 -0
- package/dist/src/ai-orchestrator/registry.js +96 -0
- package/dist/src/config.js +352 -0
- package/dist/src/intent-parser/index.js +1342 -0
- package/dist/src/intent-parser/utils.js +108 -0
- package/dist/src/logger.js +72 -0
- package/dist/src/main.js +393 -0
- package/dist/src/types.js +5 -0
- package/dist/src/utilities/address-validator.js +242 -0
- package/dist/src/utilities/domain-resolver.js +291 -0
- package/dist/src/utilities/feed-fetcher.js +387 -0
- package/dist/src/utilities/ff1-device.js +176 -0
- package/dist/src/utilities/functions.js +325 -0
- package/dist/src/utilities/index.js +372 -0
- package/dist/src/utilities/nft-indexer.js +1013 -0
- package/dist/src/utilities/playlist-builder.js +522 -0
- package/dist/src/utilities/playlist-publisher.js +131 -0
- package/dist/src/utilities/playlist-send.js +241 -0
- package/dist/src/utilities/playlist-signer.js +171 -0
- package/dist/src/utilities/playlist-verifier.js +156 -0
- package/dist/src/utils.js +48 -0
- package/docs/CONFIGURATION.md +178 -0
- package/docs/EXAMPLES.md +331 -0
- package/docs/FUNCTION_CALLING.md +92 -0
- package/docs/README.md +267 -0
- package/docs/RELEASING.md +22 -0
- package/package.json +75 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator - Function Calling Declarations
|
|
3
|
+
* Contains function schemas and orchestration logic for AI-driven playlist building
|
|
4
|
+
*/
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const registry = require('./registry');
|
|
7
|
+
function sleep(ms) {
|
|
8
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
}
|
|
10
|
+
async function createCompletionWithRetry(client, requestParams, maxRetries = 0) {
|
|
11
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
12
|
+
try {
|
|
13
|
+
return await client.chat.completions.create(requestParams);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
const status = error?.response?.status ?? error?.status;
|
|
17
|
+
if (status === 429 && attempt < maxRetries) {
|
|
18
|
+
const retryAfterHeader = error?.response?.headers?.['retry-after'] || error?.response?.headers?.['Retry-After'];
|
|
19
|
+
const retryAfterMs = retryAfterHeader ? Number(retryAfterHeader) * 1000 : null;
|
|
20
|
+
const backoffMs = Math.min(10000, 2000 * Math.pow(2, attempt));
|
|
21
|
+
const delayMs = retryAfterMs && !Number.isNaN(retryAfterMs) ? retryAfterMs : backoffMs;
|
|
22
|
+
await sleep(delayMs);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
throw new Error('Failed to create chat completion');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Function schemas for playlist building
|
|
32
|
+
*/
|
|
33
|
+
const functionSchemas = [
|
|
34
|
+
{
|
|
35
|
+
type: 'function',
|
|
36
|
+
function: {
|
|
37
|
+
name: 'query_requirement',
|
|
38
|
+
description: 'Query data for a requirement. Supports build_playlist (blockchain NFTs), query_address (all NFTs from address), and fetch_feed (feed playlists) types.',
|
|
39
|
+
parameters: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
requirement: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
description: 'The COMPLETE requirement object from params. Pass ALL fields from the original requirement without modification, truncation, or omission.',
|
|
45
|
+
properties: {
|
|
46
|
+
type: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
enum: ['build_playlist', 'fetch_feed', 'query_address'],
|
|
49
|
+
description: 'Type of requirement',
|
|
50
|
+
},
|
|
51
|
+
blockchain: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: 'Blockchain network (REQUIRED for build_playlist)',
|
|
54
|
+
},
|
|
55
|
+
contractAddress: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description: 'FULL NFT contract address without truncation (REQUIRED for build_playlist)',
|
|
58
|
+
},
|
|
59
|
+
tokenIds: {
|
|
60
|
+
type: 'array',
|
|
61
|
+
description: 'COMPLETE array of ALL token IDs without truncation (REQUIRED for build_playlist)',
|
|
62
|
+
items: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
ownerAddress: {
|
|
67
|
+
type: 'string',
|
|
68
|
+
description: 'Owner wallet address (0x... for Ethereum, tz... for Tezos) - REQUIRED for query_address',
|
|
69
|
+
},
|
|
70
|
+
playlistName: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
description: 'Feed playlist name (REQUIRED for fetch_feed)',
|
|
73
|
+
},
|
|
74
|
+
quantity: {
|
|
75
|
+
type: ['number', 'string'],
|
|
76
|
+
description: 'Maximum number of items to fetch. Can be a number for specific count, or "all" to fetch all available tokens with pagination (optional for all types, enables random selection for query_address when numeric)',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
required: ['type'],
|
|
80
|
+
},
|
|
81
|
+
duration: {
|
|
82
|
+
type: 'number',
|
|
83
|
+
description: 'Display duration per item in seconds',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
required: ['requirement', 'duration'],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'function',
|
|
92
|
+
function: {
|
|
93
|
+
name: 'search_feed_playlist',
|
|
94
|
+
description: 'Search for playlists across ALL configured feeds by name. Uses fuzzy matching to automatically find and return the BEST matching playlist name.',
|
|
95
|
+
parameters: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
properties: {
|
|
98
|
+
playlistName: {
|
|
99
|
+
type: 'string',
|
|
100
|
+
description: 'Playlist name to search for',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
required: ['playlistName'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
type: 'function',
|
|
109
|
+
function: {
|
|
110
|
+
name: 'fetch_feed_playlist_items',
|
|
111
|
+
description: 'Fetch items from a specific feed playlist by NAME (not ID). Pass the exact playlist name you selected. Items will be shuffled and randomly selected based on quantity.',
|
|
112
|
+
parameters: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
playlistName: {
|
|
116
|
+
type: 'string',
|
|
117
|
+
description: 'Exact playlist name (title) selected from search results',
|
|
118
|
+
},
|
|
119
|
+
quantity: {
|
|
120
|
+
type: 'number',
|
|
121
|
+
description: 'Number of random items to fetch (will be shuffled)',
|
|
122
|
+
},
|
|
123
|
+
duration: {
|
|
124
|
+
type: 'number',
|
|
125
|
+
description: 'Duration per item in seconds',
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
required: ['playlistName', 'quantity', 'duration'],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
type: 'function',
|
|
134
|
+
function: {
|
|
135
|
+
name: 'build_playlist',
|
|
136
|
+
description: 'Build a DP1 v1.0.0 compliant playlist from collected item IDs. Pass the id field from each item returned by query_requirement.',
|
|
137
|
+
parameters: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
itemIds: {
|
|
141
|
+
type: 'array',
|
|
142
|
+
description: 'Array of item IDs (from id field) collected from query_requirement calls. Example: ["uuid-1", "uuid-2"]',
|
|
143
|
+
items: {
|
|
144
|
+
type: 'string',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
title: {
|
|
148
|
+
type: ['string', 'null'],
|
|
149
|
+
description: 'Playlist title. Pass null for auto-generation.',
|
|
150
|
+
},
|
|
151
|
+
slug: {
|
|
152
|
+
type: ['string', 'null'],
|
|
153
|
+
description: 'Playlist slug. Pass null for auto-generation.',
|
|
154
|
+
},
|
|
155
|
+
shuffle: {
|
|
156
|
+
type: 'boolean',
|
|
157
|
+
description: 'Whether to shuffle items',
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
required: ['itemIds'],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
type: 'function',
|
|
166
|
+
function: {
|
|
167
|
+
name: 'send_to_device',
|
|
168
|
+
description: 'Send verified playlist to an FF1 device. Pass the playlistId from build_playlist.',
|
|
169
|
+
parameters: {
|
|
170
|
+
type: 'object',
|
|
171
|
+
properties: {
|
|
172
|
+
playlistId: {
|
|
173
|
+
type: 'string',
|
|
174
|
+
description: 'Playlist ID from build_playlist',
|
|
175
|
+
},
|
|
176
|
+
deviceName: {
|
|
177
|
+
type: ['string', 'null'],
|
|
178
|
+
description: 'Device name (pass null for first device)',
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
required: ['playlistId'],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
type: 'function',
|
|
187
|
+
function: {
|
|
188
|
+
name: 'resolve_domains',
|
|
189
|
+
description: 'Resolve blockchain domain names to their wallet addresses. Supports ENS (.eth) and TNS (.tez) domains. Processes domains in batch for efficiency.',
|
|
190
|
+
parameters: {
|
|
191
|
+
type: 'object',
|
|
192
|
+
properties: {
|
|
193
|
+
domains: {
|
|
194
|
+
type: 'array',
|
|
195
|
+
description: 'Array of domain names to resolve (e.g., ["vitalik.eth", "alice.tez"])',
|
|
196
|
+
items: {
|
|
197
|
+
type: 'string',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
displayResults: {
|
|
201
|
+
type: 'boolean',
|
|
202
|
+
description: 'Whether to display resolution results to user (default: true)',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
required: ['domains'],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
type: 'function',
|
|
211
|
+
function: {
|
|
212
|
+
name: 'verify_playlist',
|
|
213
|
+
description: 'Verify a playlist against the DP-1 specification. Pass the playlistId returned from build_playlist.',
|
|
214
|
+
parameters: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: {
|
|
217
|
+
playlistId: {
|
|
218
|
+
type: 'string',
|
|
219
|
+
description: 'Playlist ID returned from build_playlist (e.g., the playlistId field)',
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
required: ['playlistId'],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
// Store playlistMap across function calls
|
|
228
|
+
let globalPlaylistMap = {};
|
|
229
|
+
/**
|
|
230
|
+
* Execute a function call
|
|
231
|
+
*
|
|
232
|
+
* @param {string} functionName - Function name
|
|
233
|
+
* @param {Object} args - Function arguments
|
|
234
|
+
* @returns {Promise<any>} Function result
|
|
235
|
+
*/
|
|
236
|
+
async function executeFunction(functionName, args) {
|
|
237
|
+
const utilities = require('../utilities');
|
|
238
|
+
switch (functionName) {
|
|
239
|
+
case 'query_requirement': {
|
|
240
|
+
const items = await utilities.queryRequirement(args.requirement, args.duration);
|
|
241
|
+
// Store full items in registry
|
|
242
|
+
items.forEach((item) => {
|
|
243
|
+
if (item.id) {
|
|
244
|
+
registry.storeItem(item.id, item);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
// Return only minimal metadata for AI context
|
|
248
|
+
return items.map((item) => ({
|
|
249
|
+
id: item.id,
|
|
250
|
+
title: item.title,
|
|
251
|
+
source: item.source?.substring(0, 50) + '...',
|
|
252
|
+
duration: item.duration,
|
|
253
|
+
license: item.license,
|
|
254
|
+
provenance: item.provenance
|
|
255
|
+
? {
|
|
256
|
+
type: item.provenance.type,
|
|
257
|
+
contract: item.provenance.contract
|
|
258
|
+
? {
|
|
259
|
+
chain: item.provenance.contract.chain,
|
|
260
|
+
address: item.provenance.contract.address?.substring(0, 10) + '...',
|
|
261
|
+
tokenId: item.provenance.contract.tokenId,
|
|
262
|
+
}
|
|
263
|
+
: undefined,
|
|
264
|
+
}
|
|
265
|
+
: undefined,
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
case 'search_feed_playlist': {
|
|
269
|
+
const result = await utilities.feedFetcher.searchFeedPlaylists(args.playlistName);
|
|
270
|
+
// Store playlistMap for later lookup
|
|
271
|
+
if (result.playlistMap) {
|
|
272
|
+
globalPlaylistMap = result.playlistMap;
|
|
273
|
+
}
|
|
274
|
+
// Return best match found by fuzzy matching
|
|
275
|
+
return {
|
|
276
|
+
success: result.success,
|
|
277
|
+
bestMatch: result.bestMatch,
|
|
278
|
+
searchTerm: result.searchTerm,
|
|
279
|
+
error: result.error,
|
|
280
|
+
message: result.bestMatch
|
|
281
|
+
? `Found best matching playlist: "${result.bestMatch}"`
|
|
282
|
+
: undefined,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
case 'fetch_feed_playlist_items':
|
|
286
|
+
return await utilities.feedFetcher.fetchPlaylistItems(args.playlistName, args.quantity, args.duration, globalPlaylistMap);
|
|
287
|
+
case 'build_playlist': {
|
|
288
|
+
// Retrieve full items from registry using IDs
|
|
289
|
+
const itemIds = args.itemIds;
|
|
290
|
+
if (!Array.isArray(itemIds) || itemIds.length === 0) {
|
|
291
|
+
throw new Error('build_playlist requires itemIds array');
|
|
292
|
+
}
|
|
293
|
+
const fullItems = itemIds
|
|
294
|
+
.map((id) => registry.getItem(id))
|
|
295
|
+
.filter((item) => item !== undefined);
|
|
296
|
+
if (fullItems.length === 0) {
|
|
297
|
+
throw new Error('No valid items found in registry for provided IDs');
|
|
298
|
+
}
|
|
299
|
+
// Apply shuffle if requested
|
|
300
|
+
const items = args.shuffle ? utilities.shuffleArray([...fullItems]) : fullItems;
|
|
301
|
+
// Build playlist
|
|
302
|
+
const title = args.title === 'null' || args.title === null ? null : args.title;
|
|
303
|
+
const slug = args.slug === 'null' || args.slug === null ? null : args.slug;
|
|
304
|
+
const playlist = await utilities.buildDP1Playlist(items, title, slug);
|
|
305
|
+
// Store in registry
|
|
306
|
+
registry.storePlaylist(playlist.id, playlist);
|
|
307
|
+
// Return minimal metadata
|
|
308
|
+
return {
|
|
309
|
+
playlistId: playlist.id,
|
|
310
|
+
itemCount: playlist.items.length,
|
|
311
|
+
title: playlist.title,
|
|
312
|
+
dpVersion: playlist.dpVersion,
|
|
313
|
+
hasSigned: !!playlist.signature,
|
|
314
|
+
slug: playlist.slug,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
case 'send_to_device': {
|
|
318
|
+
// Retrieve playlist from registry
|
|
319
|
+
const playlistId = args.playlistId;
|
|
320
|
+
if (!playlistId || !registry.hasPlaylist(playlistId)) {
|
|
321
|
+
throw new Error('Invalid playlistId or playlist not found in registry');
|
|
322
|
+
}
|
|
323
|
+
const playlist = registry.getPlaylist(playlistId);
|
|
324
|
+
const result = await utilities.sendToDevice(playlist, args.deviceName);
|
|
325
|
+
// Return minimal response
|
|
326
|
+
return {
|
|
327
|
+
success: result.success,
|
|
328
|
+
deviceName: result.deviceName,
|
|
329
|
+
message: result.message,
|
|
330
|
+
error: result.error,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
case 'resolve_domains':
|
|
334
|
+
return await utilities.resolveDomains(args);
|
|
335
|
+
case 'verify_playlist': {
|
|
336
|
+
const { verifyPlaylist } = require('../utilities/functions');
|
|
337
|
+
// Retrieve playlist from registry
|
|
338
|
+
const playlistId = args.playlistId;
|
|
339
|
+
if (!playlistId || !registry.hasPlaylist(playlistId)) {
|
|
340
|
+
throw new Error('Invalid playlistId or playlist not found in registry');
|
|
341
|
+
}
|
|
342
|
+
const playlist = registry.getPlaylist(playlistId);
|
|
343
|
+
const result = await verifyPlaylist({ playlist });
|
|
344
|
+
// Return minimal response
|
|
345
|
+
if (result.valid) {
|
|
346
|
+
return {
|
|
347
|
+
valid: true,
|
|
348
|
+
playlistId: playlistId,
|
|
349
|
+
itemCount: playlist.items.length,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
// Only return first 3 errors to save context
|
|
354
|
+
return {
|
|
355
|
+
valid: false,
|
|
356
|
+
playlistId: playlistId,
|
|
357
|
+
error: result.error,
|
|
358
|
+
details: result.details?.slice(0, 3) || [],
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
default:
|
|
363
|
+
throw new Error(`Unknown function: ${functionName}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Build system prompt for AI orchestrator
|
|
368
|
+
*
|
|
369
|
+
* @param {Object} params - Validated parameters from intent parser
|
|
370
|
+
* @returns {string} System prompt
|
|
371
|
+
*/
|
|
372
|
+
function buildOrchestratorSystemPrompt(params) {
|
|
373
|
+
const { requirements, playlistSettings } = params;
|
|
374
|
+
const requirementsText = requirements
|
|
375
|
+
.map((req, i) => {
|
|
376
|
+
if (req.type === 'fetch_feed') {
|
|
377
|
+
return `${i + 1}. Fetch ${req.quantity || 5} items from playlist "${req.playlistName}"`;
|
|
378
|
+
}
|
|
379
|
+
else if (req.type === 'query_address') {
|
|
380
|
+
const quantityText = req.quantity === 'all' ? 'all ' : req.quantity ? req.quantity + ' random ' : 'all ';
|
|
381
|
+
return `${i + 1}. Query ${quantityText}tokens from address ${req.ownerAddress}`;
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
return (`${i + 1}. ${req.blockchain} - ${req.tokenIds?.length || 0} tokens` +
|
|
385
|
+
(req.contractAddress ? ` from ${req.contractAddress.substring(0, 10)}...` : ''));
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
.join('\n');
|
|
389
|
+
const hasDevice = playlistSettings.deviceName !== undefined;
|
|
390
|
+
const sendStep = hasDevice
|
|
391
|
+
? `6) If verification passed → you MUST call send_to_device({ playlistId: <the_playlistId>, deviceName: "${playlistSettings.deviceName || 'first-device'}" }) before finishing.
|
|
392
|
+
CRITICAL: Pass the playlistId string from step 4.`
|
|
393
|
+
: `6) Verification passed → you're done. Do not send to device.`;
|
|
394
|
+
return `SYSTEM: FF1 Orchestrator (Function-Calling)
|
|
395
|
+
|
|
396
|
+
ROLE
|
|
397
|
+
- Execute parsed requirements deterministically and build a DP‑1 playlist. Keep outputs concise and operational.
|
|
398
|
+
|
|
399
|
+
REQUIREMENTS
|
|
400
|
+
${requirementsText}
|
|
401
|
+
|
|
402
|
+
PLAYLIST SETTINGS
|
|
403
|
+
- durationPerItem: ${playlistSettings.durationPerItem || 10}
|
|
404
|
+
- title: ${playlistSettings.title || 'auto'}
|
|
405
|
+
- slug: ${playlistSettings.slug || 'auto'}
|
|
406
|
+
- preserveOrder: ${playlistSettings.preserveOrder !== false ? 'true' : 'false'}
|
|
407
|
+
${hasDevice ? `- deviceName: ${playlistSettings.deviceName || 'first-device'}` : ''}
|
|
408
|
+
|
|
409
|
+
REASONING (private scratchpad)
|
|
410
|
+
- Use Plan→Check→Act→Reflect for each step.
|
|
411
|
+
- Default to a single deterministic path.
|
|
412
|
+
- Only branch in two cases:
|
|
413
|
+
1) Multiple plausible feed candidates after search.
|
|
414
|
+
2) Verification failure requiring targeted repair.
|
|
415
|
+
- When branching, keep BEAM_WIDTH=2, DEPTH_LIMIT=2.
|
|
416
|
+
- Score candidates by: correctness, coverage, determinism, freshness, cost.
|
|
417
|
+
- Keep reasoning hidden; publicly print one status sentence before each tool call.
|
|
418
|
+
|
|
419
|
+
KEY RULES
|
|
420
|
+
- Domains: ".eth" and ".tez" are OWNER DOMAINS. Resolve to addresses before querying ownership.
|
|
421
|
+
- Do not fabricate or truncate contract addresses or tokenIds.
|
|
422
|
+
- Title/slug: when calling build_playlist, pass actual null (not string "null"):
|
|
423
|
+
• If title provided in settings → pass settings.title as-is
|
|
424
|
+
• If title NOT provided → pass null (NOT the string "null")
|
|
425
|
+
• If slug provided in settings → pass settings.slug as-is
|
|
426
|
+
• If slug NOT provided → pass null (NOT the string "null")
|
|
427
|
+
- Shuffle: set shuffle = ${playlistSettings.preserveOrder === false ? 'true' : 'false'}.
|
|
428
|
+
- Build → Verify${hasDevice ? ' → Send' : ''} (MANDATORY to verify before${hasDevice ? ' sending' : ' finishing'}).
|
|
429
|
+
|
|
430
|
+
DECISION LOOP
|
|
431
|
+
1) For each requirement in order:
|
|
432
|
+
- build_playlist: call query_requirement(requirement, duration=${playlistSettings.durationPerItem || 10}).
|
|
433
|
+
Returns array with minimal item data including id field. Collect the id values.
|
|
434
|
+
- query_address:
|
|
435
|
+
• if ownerAddress endsWith .eth/.tez → resolve_domains([domain]); if resolved → use returned address; if not → mark failed and continue.
|
|
436
|
+
• if ownerAddress is 0x…/tz… → call query_requirement(requirement, duration=${playlistSettings.durationPerItem || 10}).
|
|
437
|
+
- fetch_feed: search_feed_playlist(name) → take bestMatch → fetch_feed_playlist_items(bestMatch, quantity, duration=${playlistSettings.durationPerItem || 10}).
|
|
438
|
+
- Collect item IDs across all steps in an array (let's call it collectedItemIds).
|
|
439
|
+
2) If zero items → explain briefly and finish.
|
|
440
|
+
3) If some requirements failed and interactive mode → ask user; otherwise proceed with available items.
|
|
441
|
+
4) Call build_playlist({ itemIds: collectedItemIds, title: settings.title || null, slug: settings.slug || null, shuffle }).
|
|
442
|
+
CRITICAL:
|
|
443
|
+
- Pass itemIds array containing the id field from each item
|
|
444
|
+
- Pass actual null values for title/slug, NOT the string "null"
|
|
445
|
+
- Returns: { playlistId, itemCount, title, dpVersion, hasSigned, slug }
|
|
446
|
+
- Store the playlistId in a variable.
|
|
447
|
+
5) Call verify_playlist({ playlistId: <the_playlistId_from_step_4> }).
|
|
448
|
+
CRITICAL: Pass the playlistId string, not an object.
|
|
449
|
+
Returns: { valid: true/false, playlistId, itemCount } or { valid: false, error, details }
|
|
450
|
+
If invalid ≤3 attempts, analyze error.details and rebuild; otherwise stop with clear error.
|
|
451
|
+
${sendStep}
|
|
452
|
+
|
|
453
|
+
KEY RULES
|
|
454
|
+
- NEVER pass full item objects or playlist objects to functions
|
|
455
|
+
- ALWAYS use item IDs (strings) and playlist IDs (strings)
|
|
456
|
+
- The registry system handles full objects internally
|
|
457
|
+
|
|
458
|
+
OUTPUT RULES
|
|
459
|
+
- Before each function call, print exactly one sentence: "→ I'm …" describing the action.
|
|
460
|
+
- Then call exactly one function with JSON arguments.
|
|
461
|
+
- No chain‑of‑thought or extra narration; keep public output minimal.
|
|
462
|
+
|
|
463
|
+
STOPPING CONDITIONS
|
|
464
|
+
- Finish only after: (items built → playlist built → verified${hasDevice ? ' → sent' : ''}) or after explaining why no progress is possible.`;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Build playlist using AI orchestration (natural language path)
|
|
468
|
+
*
|
|
469
|
+
* @param {Object} params - Validated parameters from intent parser
|
|
470
|
+
* @param {Object} options - Build options
|
|
471
|
+
* @param {boolean} options.interactive - Whether in interactive mode (can ask user)
|
|
472
|
+
* @returns {Promise<Object>} Result with playlist
|
|
473
|
+
*/
|
|
474
|
+
async function buildPlaylistWithAI(params, options = {}) {
|
|
475
|
+
const { modelName, verbose = false, outputPath = 'playlist.json', interactive = false, conversationContext = null, } = options;
|
|
476
|
+
const OpenAI = require('openai');
|
|
477
|
+
const { getModelConfig } = require('../config');
|
|
478
|
+
const modelConfig = getModelConfig(modelName);
|
|
479
|
+
const client = new OpenAI({
|
|
480
|
+
apiKey: modelConfig.apiKey,
|
|
481
|
+
baseURL: modelConfig.baseURL,
|
|
482
|
+
timeout: modelConfig.timeout,
|
|
483
|
+
maxRetries: modelConfig.maxRetries,
|
|
484
|
+
});
|
|
485
|
+
let messages;
|
|
486
|
+
if (conversationContext && conversationContext.messages) {
|
|
487
|
+
// Continue from existing conversation
|
|
488
|
+
messages = [...conversationContext.messages];
|
|
489
|
+
messages.push({
|
|
490
|
+
role: 'user',
|
|
491
|
+
content: conversationContext.userResponse,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
// Start new conversation
|
|
496
|
+
const systemPrompt = buildOrchestratorSystemPrompt(params);
|
|
497
|
+
const interactiveNote = interactive
|
|
498
|
+
? '\n\nYou are in INTERACTIVE MODE. You can ask the user for confirmation when some requirements fail.'
|
|
499
|
+
: '\n\nYou are in NON-INTERACTIVE MODE. If some requirements fail, automatically proceed with available items without asking.';
|
|
500
|
+
// Build detailed user message with the actual requirements
|
|
501
|
+
const requirementsDetail = params.requirements
|
|
502
|
+
.map((req, i) => {
|
|
503
|
+
if (req.type === 'fetch_feed') {
|
|
504
|
+
return `${i + 1}. Fetch ${req.quantity || 5} items from playlist "${req.playlistName}"`;
|
|
505
|
+
}
|
|
506
|
+
else if (req.type === 'query_address') {
|
|
507
|
+
const quantityDesc = req.quantity === 'all'
|
|
508
|
+
? 'all tokens (with pagination)'
|
|
509
|
+
: req.quantity
|
|
510
|
+
? `${req.quantity} (random selection)`
|
|
511
|
+
: 'all tokens';
|
|
512
|
+
return `${i + 1}. Query tokens from address:\n - ownerAddress: "${req.ownerAddress}"\n - quantity: ${quantityDesc}`;
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
return `${i + 1}. Query tokens:\n - blockchain: "${req.blockchain}"\n - contractAddress: "${req.contractAddress}"\n - tokenIds: ${JSON.stringify(req.tokenIds)}\n - quantity: ${req.quantity}`;
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
.join('\n');
|
|
519
|
+
messages = [
|
|
520
|
+
{ role: 'system', content: systemPrompt + interactiveNote },
|
|
521
|
+
{
|
|
522
|
+
role: 'user',
|
|
523
|
+
content: `Execute these requirements now. Use the EXACT values provided - do not modify or make up different values:\n\n${requirementsDetail}\n\nStart by calling query_requirement for each requirement with these EXACT values.`,
|
|
524
|
+
},
|
|
525
|
+
];
|
|
526
|
+
}
|
|
527
|
+
let finalPlaylist = null;
|
|
528
|
+
let iterationCount = 0;
|
|
529
|
+
let collectedItems = [];
|
|
530
|
+
let verificationFailures = 0;
|
|
531
|
+
let sentToDevice = false;
|
|
532
|
+
const maxIterations = 20;
|
|
533
|
+
const maxVerificationRetries = 3;
|
|
534
|
+
while (iterationCount < maxIterations) {
|
|
535
|
+
iterationCount++;
|
|
536
|
+
const requestParams = {
|
|
537
|
+
model: modelConfig.model,
|
|
538
|
+
messages,
|
|
539
|
+
tools: functionSchemas,
|
|
540
|
+
tool_choice: 'auto',
|
|
541
|
+
stream: false,
|
|
542
|
+
};
|
|
543
|
+
if (modelConfig.temperature !== undefined) {
|
|
544
|
+
requestParams.temperature = modelConfig.temperature;
|
|
545
|
+
}
|
|
546
|
+
if (modelConfig.model.startsWith('gpt-')) {
|
|
547
|
+
requestParams.max_completion_tokens = 4000;
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
requestParams.max_tokens = 4000;
|
|
551
|
+
}
|
|
552
|
+
let response;
|
|
553
|
+
try {
|
|
554
|
+
response = await createCompletionWithRetry(client, requestParams, modelConfig.maxRetries);
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
const status = error?.response?.status ?? error?.status;
|
|
558
|
+
const statusText = error?.response?.statusText;
|
|
559
|
+
const responseDetails = error?.response?.data && typeof error.response.data === 'string'
|
|
560
|
+
? error.response.data
|
|
561
|
+
: error?.response?.data
|
|
562
|
+
? JSON.stringify(error.response.data)
|
|
563
|
+
: null;
|
|
564
|
+
const detailParts = [
|
|
565
|
+
error.message,
|
|
566
|
+
status ? `status ${status}${statusText ? ` ${statusText}` : ''}` : null,
|
|
567
|
+
responseDetails ? `response ${responseDetails}` : null,
|
|
568
|
+
].filter(Boolean);
|
|
569
|
+
const hint = status === 429 ? 'rate limited by model provider' : null;
|
|
570
|
+
throw new Error(`AI orchestrator failed (model=${modelConfig.model}, baseURL=${modelConfig.baseURL}): ${detailParts.join(' | ')}${hint ? ` | ${hint}` : ''}`);
|
|
571
|
+
}
|
|
572
|
+
const message = response.choices[0].message;
|
|
573
|
+
// Gemini workaround: If AI finished without calling build_playlist despite having items
|
|
574
|
+
// This handles cases where:
|
|
575
|
+
// - finish_reason is 'stop' but no content/tool_calls
|
|
576
|
+
// - finish_reason includes 'MALFORMED_FUNCTION_CALL' (Gemini tried but failed)
|
|
577
|
+
// - Any other case where we have items but no playlist
|
|
578
|
+
if (verbose) {
|
|
579
|
+
console.log(chalk.gray(`→ finish_reason: ${response.choices[0].finish_reason}`));
|
|
580
|
+
console.log(chalk.gray(`→ has content: ${!!message.content}`));
|
|
581
|
+
console.log(chalk.gray(`→ has tool_calls: ${!!message.tool_calls}`));
|
|
582
|
+
console.log(chalk.gray(`→ collectedItems: ${collectedItems.length}, finalPlaylist: ${!!finalPlaylist}`));
|
|
583
|
+
}
|
|
584
|
+
if (!message.tool_calls && collectedItems.length > 0 && !finalPlaylist) {
|
|
585
|
+
const finishReason = response.choices[0].finish_reason || '';
|
|
586
|
+
// If Gemini keeps failing with MALFORMED_FUNCTION_CALL, call build_playlist directly
|
|
587
|
+
if (finishReason.includes('MALFORMED_FUNCTION_CALL') || finishReason.includes('filter')) {
|
|
588
|
+
if (verbose) {
|
|
589
|
+
console.log(chalk.yellow(`⚠️ AI's function call is malformed - calling build_playlist directly...`));
|
|
590
|
+
}
|
|
591
|
+
// Call build_playlist directly with the collected item IDs
|
|
592
|
+
try {
|
|
593
|
+
const utilities = require('../utilities');
|
|
594
|
+
// Retrieve full items from registry using IDs
|
|
595
|
+
const fullItems = collectedItems
|
|
596
|
+
.map((id) => registry.getItem(id))
|
|
597
|
+
.filter((item) => item !== undefined);
|
|
598
|
+
if (fullItems.length > 0) {
|
|
599
|
+
const result = await utilities.buildDP1Playlist(fullItems, params.playlistSettings?.title || null, params.playlistSettings?.slug || null);
|
|
600
|
+
if (result.dpVersion) {
|
|
601
|
+
finalPlaylist = result;
|
|
602
|
+
const { savePlaylist } = require('../utils');
|
|
603
|
+
await savePlaylist(result, outputPath);
|
|
604
|
+
if (verbose) {
|
|
605
|
+
console.log(chalk.green(`✓ Successfully built playlist directly`));
|
|
606
|
+
}
|
|
607
|
+
break; // Exit the loop
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
if (verbose) {
|
|
613
|
+
console.log(chalk.red(`✗ Failed to build playlist directly: ${error.message}`));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
else if (iterationCount < maxIterations - 1) {
|
|
618
|
+
// Try one more time with a system message
|
|
619
|
+
if (verbose) {
|
|
620
|
+
console.log(chalk.yellow(`⚠️ AI stopped without calling build_playlist (reason: ${finishReason}) - forcing it to continue...`));
|
|
621
|
+
}
|
|
622
|
+
messages.push({
|
|
623
|
+
role: 'system',
|
|
624
|
+
content: `CRITICAL: You have collected ${collectedItems.length} items but have NOT called build_playlist yet. You MUST call the build_playlist function NOW with these items.`,
|
|
625
|
+
});
|
|
626
|
+
continue; // Go to next iteration
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
messages.push(message);
|
|
630
|
+
// Always print AI content when present
|
|
631
|
+
if (message.content) {
|
|
632
|
+
console.log(chalk.cyan(message.content));
|
|
633
|
+
}
|
|
634
|
+
if (verbose) {
|
|
635
|
+
console.log(chalk.gray(`\nIteration ${iterationCount}:`));
|
|
636
|
+
}
|
|
637
|
+
// Execute function calls if any
|
|
638
|
+
if (message.tool_calls && message.tool_calls.length > 0) {
|
|
639
|
+
if (verbose) {
|
|
640
|
+
console.log(chalk.gray(`→ Executing ${message.tool_calls.length} function(s)...`));
|
|
641
|
+
}
|
|
642
|
+
for (const toolCall of message.tool_calls) {
|
|
643
|
+
const functionName = toolCall.function.name;
|
|
644
|
+
const args = JSON.parse(toolCall.function.arguments);
|
|
645
|
+
if (verbose) {
|
|
646
|
+
console.log(chalk.gray(`\n • Function: ${chalk.bold(functionName)}`));
|
|
647
|
+
console.log(chalk.gray(` Input: ${JSON.stringify(args, null, 2).split('\n').join('\n ')}`));
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const result = await executeFunction(functionName, args);
|
|
651
|
+
if (verbose) {
|
|
652
|
+
console.log(chalk.gray(` Output: ${JSON.stringify(result, null, 2).split('\n').join('\n ')}`));
|
|
653
|
+
}
|
|
654
|
+
// Track collected item IDs from query_requirement
|
|
655
|
+
if (functionName === 'query_requirement' && Array.isArray(result)) {
|
|
656
|
+
// Result now contains minimal item objects with id field
|
|
657
|
+
const itemIds = result.map((item) => item.id).filter((id) => id);
|
|
658
|
+
collectedItems = collectedItems.concat(itemIds); // Now storing IDs, not full items
|
|
659
|
+
if (verbose) {
|
|
660
|
+
console.log(chalk.green(` ✓ Collected ${result.length} item IDs (total: ${collectedItems.length})`));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// Track final playlist by retrieving it from registry
|
|
664
|
+
if (functionName === 'build_playlist' && result.playlistId) {
|
|
665
|
+
// Retrieve full playlist from registry
|
|
666
|
+
finalPlaylist = registry.getPlaylist(result.playlistId);
|
|
667
|
+
// Save playlist
|
|
668
|
+
const { savePlaylist } = require('../utils');
|
|
669
|
+
await savePlaylist(finalPlaylist, outputPath);
|
|
670
|
+
}
|
|
671
|
+
// Track device sending
|
|
672
|
+
if (functionName === 'send_to_device') {
|
|
673
|
+
if (result && result.success) {
|
|
674
|
+
sentToDevice = true;
|
|
675
|
+
if (verbose) {
|
|
676
|
+
console.log(chalk.green(`✓ Playlist sent to device`));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// Handle verification results
|
|
681
|
+
if (functionName === 'verify_playlist') {
|
|
682
|
+
if (result.valid) {
|
|
683
|
+
if (verbose) {
|
|
684
|
+
console.log(chalk.green(`✓ Playlist verification passed`));
|
|
685
|
+
}
|
|
686
|
+
// Check if verification passed - don't break yet, let AI continue (may need to call send_to_device)
|
|
687
|
+
// The loop will naturally end when AI has no more tool calls or we hit iteration limit
|
|
688
|
+
// if (verificationPassed) {
|
|
689
|
+
// break;
|
|
690
|
+
// }
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
verificationFailures++;
|
|
694
|
+
if (verbose) {
|
|
695
|
+
console.log(chalk.yellow(`⚠️ Playlist verification failed (attempt ${verificationFailures}/${maxVerificationRetries})`));
|
|
696
|
+
}
|
|
697
|
+
// Check if we've exceeded max retries
|
|
698
|
+
if (verificationFailures >= maxVerificationRetries) {
|
|
699
|
+
if (verbose) {
|
|
700
|
+
console.log(chalk.red(`✗ Playlist validation failed after ${maxVerificationRetries} retries`));
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
success: false,
|
|
704
|
+
error: `Playlist validation failed: ${result.error}`,
|
|
705
|
+
details: result.details,
|
|
706
|
+
playlist: null,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
// Add verification error to messages so AI can fix it
|
|
710
|
+
messages.push({
|
|
711
|
+
role: 'tool',
|
|
712
|
+
tool_call_id: toolCall.id,
|
|
713
|
+
content: JSON.stringify({
|
|
714
|
+
valid: false,
|
|
715
|
+
error: result.error,
|
|
716
|
+
details: result.details,
|
|
717
|
+
message: `Playlist validation failed. Please fix the issues and rebuild the playlist.\n\nErrors: ${JSON.stringify(result.details, null, 2)}`,
|
|
718
|
+
}),
|
|
719
|
+
});
|
|
720
|
+
// Ask AI to fix and rebuild
|
|
721
|
+
const fixPrompt = `The playlist validation failed with these errors:\n\n${result.error}\n\nDetails:\n${result.details ? result.details.map((d) => `- ${d.path}: ${d.message}`).join('\n') : 'N/A'}\n\nPlease fix these issues and rebuild the playlist. You can rebuild it by calling build_playlist again with corrected data.`;
|
|
722
|
+
messages.push({
|
|
723
|
+
role: 'user',
|
|
724
|
+
content: fixPrompt,
|
|
725
|
+
});
|
|
726
|
+
continue; // Don't finish, let AI try again
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
messages.push({
|
|
730
|
+
role: 'tool',
|
|
731
|
+
tool_call_id: toolCall.id,
|
|
732
|
+
content: JSON.stringify(result),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
if (verbose) {
|
|
737
|
+
console.log(chalk.red(` Error: ${error.message}`));
|
|
738
|
+
}
|
|
739
|
+
messages.push({
|
|
740
|
+
role: 'tool',
|
|
741
|
+
tool_call_id: toolCall.id,
|
|
742
|
+
content: JSON.stringify({ error: error.message, success: false }),
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// Check if verification passed - don't break yet, let AI continue (may need to call send_to_device)
|
|
747
|
+
// The loop will naturally end when AI has no more tool calls or we hit iteration limit
|
|
748
|
+
// if (verificationPassed) {
|
|
749
|
+
// break;
|
|
750
|
+
// }
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
// AI has finished
|
|
754
|
+
if (verbose) {
|
|
755
|
+
console.log(chalk.gray('\n→ AI has finished (no more tool calls)'));
|
|
756
|
+
if (!message.content) {
|
|
757
|
+
console.log(chalk.red('→ AI sent NO content and NO tool calls!'));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (finalPlaylist) {
|
|
761
|
+
// Deterministic fallback: if user requested device sending but AI forgot,
|
|
762
|
+
// send here before returning (only if NOT already sent by AI)
|
|
763
|
+
try {
|
|
764
|
+
const deviceNameRequested = params.playlistSettings && params.playlistSettings.deviceName !== undefined;
|
|
765
|
+
if (deviceNameRequested && !sentToDevice) {
|
|
766
|
+
console.log(chalk.cyan('\n→ Sending to device...'));
|
|
767
|
+
const utilities = require('../utilities');
|
|
768
|
+
const sendResult = await utilities.sendToDevice(finalPlaylist, params.playlistSettings.deviceName || null);
|
|
769
|
+
if (sendResult && sendResult.success) {
|
|
770
|
+
sentToDevice = true;
|
|
771
|
+
console.log(chalk.green(`✓ Sent to device: ${sendResult.deviceName}`));
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
// Device sending failed - this is a failure condition
|
|
775
|
+
return {
|
|
776
|
+
success: false,
|
|
777
|
+
error: `Failed to send playlist to device: ${sendResult?.error || 'Unknown error'}`,
|
|
778
|
+
playlist: finalPlaylist,
|
|
779
|
+
sentToDevice: false,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
catch (error) {
|
|
785
|
+
return {
|
|
786
|
+
success: false,
|
|
787
|
+
error: `Failed to send to device: ${error && error.message ? error.message : error}`,
|
|
788
|
+
playlist: finalPlaylist,
|
|
789
|
+
sentToDevice: false,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
// Publish to feed server if requested
|
|
793
|
+
let publishResult = null;
|
|
794
|
+
if (params.playlistSettings && params.playlistSettings.feedServer) {
|
|
795
|
+
console.log(chalk.cyan('\n→ Publishing to feed server...'));
|
|
796
|
+
try {
|
|
797
|
+
const { publishPlaylist } = require('../utilities/playlist-publisher');
|
|
798
|
+
publishResult = await publishPlaylist(outputPath, params.playlistSettings.feedServer.baseUrl, params.playlistSettings.feedServer.apiKey);
|
|
799
|
+
if (publishResult.success) {
|
|
800
|
+
console.log(chalk.green(`✓ Published to feed server`));
|
|
801
|
+
if (publishResult.playlistId) {
|
|
802
|
+
console.log(chalk.gray(` Playlist ID: ${publishResult.playlistId}`));
|
|
803
|
+
}
|
|
804
|
+
if (publishResult.feedServer) {
|
|
805
|
+
console.log(chalk.gray(` Server: ${publishResult.feedServer}`));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
console.error(chalk.red(`✗ Failed to publish: ${publishResult.error}`));
|
|
810
|
+
if (publishResult.message) {
|
|
811
|
+
console.error(chalk.gray(` ${publishResult.message}`));
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
console.error(chalk.red(`✗ Failed to publish: ${error.message}`));
|
|
817
|
+
if (verbose) {
|
|
818
|
+
console.error(chalk.gray(error.stack));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
// Clear registries after successful build
|
|
823
|
+
registry.clearRegistries();
|
|
824
|
+
return {
|
|
825
|
+
playlist: finalPlaylist,
|
|
826
|
+
sentToDevice,
|
|
827
|
+
published: publishResult?.success || false,
|
|
828
|
+
publishResult,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
// AI finished without building a playlist - check if it provided an explanation
|
|
832
|
+
if (message.content) {
|
|
833
|
+
// Check if AI is asking for confirmation (interactive mode)
|
|
834
|
+
const isAskingConfirmation = interactive &&
|
|
835
|
+
message.content.toLowerCase().includes('would you like') &&
|
|
836
|
+
(message.content.toLowerCase().includes('proceed') ||
|
|
837
|
+
message.content.toLowerCase().includes('build') ||
|
|
838
|
+
message.content.toLowerCase().includes('cancel'));
|
|
839
|
+
if (isAskingConfirmation) {
|
|
840
|
+
// Return with needsConfirmation flag
|
|
841
|
+
return {
|
|
842
|
+
needsConfirmation: true,
|
|
843
|
+
question: message.content,
|
|
844
|
+
messages: messages,
|
|
845
|
+
params: params,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
// AI has explained why no playlist was built (e.g., no matching items found)
|
|
849
|
+
// Content already printed above, just return the result
|
|
850
|
+
return {
|
|
851
|
+
success: false,
|
|
852
|
+
message: message.content,
|
|
853
|
+
playlist: null,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (!finalPlaylist) {
|
|
860
|
+
registry.clearRegistries(); // Clear on failure
|
|
861
|
+
throw new Error('Failed to build playlist - No items found or AI did not complete the task. Check if the requirements match any available data.');
|
|
862
|
+
}
|
|
863
|
+
return { playlist: finalPlaylist, sentToDevice };
|
|
864
|
+
}
|
|
865
|
+
module.exports = {
|
|
866
|
+
functionSchemas,
|
|
867
|
+
executeFunction,
|
|
868
|
+
buildOrchestratorSystemPrompt,
|
|
869
|
+
buildPlaylistWithAI,
|
|
870
|
+
};
|