newo 3.6.2 ā 3.7.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 +44 -3
- package/README.md +61 -0
- package/dist/cli/commands/check.d.ts +3 -0
- package/dist/cli/commands/check.js +15 -0
- package/dist/cli/commands/format.d.ts +3 -0
- package/dist/cli/commands/format.js +105 -0
- package/dist/cli/commands/help.js +13 -0
- package/dist/cli/commands/lint.d.ts +3 -0
- package/dist/cli/commands/lint.js +195 -0
- package/dist/cli-new/di/tokens.d.ts +1 -1
- package/dist/cli.js +45 -9
- package/dist/domain/strategies/sync/AttributeSyncStrategy.js +38 -8
- package/dist/lint/config.d.ts +4 -0
- package/dist/lint/config.js +14 -0
- package/dist/lint/discovery.d.ts +34 -0
- package/dist/lint/discovery.js +112 -0
- package/dist/lint/live-schema.d.ts +20 -0
- package/dist/lint/live-schema.js +52 -0
- package/dist/lint/reporters/index.d.ts +4 -0
- package/dist/lint/reporters/index.js +19 -0
- package/dist/lint/reporters/json.d.ts +3 -0
- package/dist/lint/reporters/json.js +6 -0
- package/dist/lint/reporters/sarif.d.ts +3 -0
- package/dist/lint/reporters/sarif.js +47 -0
- package/dist/lint/reporters/text.d.ts +3 -0
- package/dist/lint/reporters/text.js +51 -0
- package/dist/lint/reporters/types.d.ts +6 -0
- package/dist/lint/reporters/types.js +2 -0
- package/dist/sync/attributes.js +38 -12
- package/dist/sync/conversations.d.ts +1 -1
- package/dist/sync/conversations.js +240 -193
- package/dist/sync/json-attr-utils.d.ts +67 -0
- package/dist/sync/json-attr-utils.js +98 -0
- package/package.json +3 -1
- package/src/cli/commands/check.ts +21 -0
- package/src/cli/commands/format.ts +131 -0
- package/src/cli/commands/help.ts +13 -0
- package/src/cli/commands/lint.ts +246 -0
- package/src/cli.ts +50 -9
- package/src/domain/strategies/sync/AttributeSyncStrategy.ts +45 -8
- package/src/lint/config.ts +17 -0
- package/src/lint/discovery.ts +148 -0
- package/src/lint/live-schema.ts +62 -0
- package/src/lint/reporters/index.ts +22 -0
- package/src/lint/reporters/json.ts +12 -0
- package/src/lint/reporters/sarif.ts +59 -0
- package/src/lint/reporters/text.ts +58 -0
- package/src/lint/reporters/types.ts +7 -0
- package/src/sync/attributes.ts +43 -14
- package/src/sync/conversations.ts +265 -212
- package/src/sync/json-attr-utils.ts +95 -0
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Conversations synchronization module
|
|
3
|
+
*
|
|
4
|
+
* Incremental/resumable conversation pull:
|
|
5
|
+
* - Writes per-persona JSON files to newo_customers/<idn>/conversations/<persona_id>.json as they arrive
|
|
6
|
+
* - Updates conversations.yaml aggregate after each persona finishes
|
|
7
|
+
* - Skips personas already fully fetched (resume support) unless --force passed via env NEWO_CONV_FORCE=1
|
|
8
|
+
* - Graceful on partial failure: individual persona errors do not abort the batch, state is preserved
|
|
3
9
|
*/
|
|
4
10
|
import { listUserPersonas, getChatHistory } from '../api.js';
|
|
5
11
|
import { writeFileSafe } from '../fsutil.js';
|
|
12
|
+
import fs from 'fs-extra';
|
|
13
|
+
import path from 'path';
|
|
6
14
|
import yaml from 'js-yaml';
|
|
7
15
|
import pLimit from 'p-limit';
|
|
8
16
|
import type { AxiosInstance } from 'axios';
|
|
@@ -16,11 +24,108 @@ import type {
|
|
|
16
24
|
ConversationsData
|
|
17
25
|
} from '../types.js';
|
|
18
26
|
|
|
19
|
-
// Concurrency limit for API calls
|
|
20
27
|
const concurrencyLimit = pLimit(5);
|
|
21
28
|
|
|
29
|
+
type PersonaState = {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
phone: string | null;
|
|
33
|
+
act_count: number;
|
|
34
|
+
acts: ProcessedAct[];
|
|
35
|
+
fetched_at: string;
|
|
36
|
+
complete: boolean;
|
|
37
|
+
last_error?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function personaFilePath(customerIdn: string, personaId: string): string {
|
|
41
|
+
return path.join('newo_customers', customerIdn, 'conversations', `${personaId}.json`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function aggregateYamlPath(customerIdn: string): string {
|
|
45
|
+
return path.join('newo_customers', customerIdn, 'conversations.yaml');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readPersonaState(customerIdn: string, personaId: string): Promise<PersonaState | null> {
|
|
49
|
+
const p = personaFilePath(customerIdn, personaId);
|
|
50
|
+
if (!(await fs.pathExists(p))) return null;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(await fs.readFile(p, 'utf8')) as PersonaState;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function writePersonaState(customerIdn: string, state: PersonaState): Promise<void> {
|
|
59
|
+
await writeFileSafe(personaFilePath(customerIdn, state.id), JSON.stringify(state, null, 2));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function writeAggregateYaml(customerIdn: string): Promise<{ personas: number; acts: number }> {
|
|
63
|
+
const dir = path.join('newo_customers', customerIdn, 'conversations');
|
|
64
|
+
const files = (await fs.pathExists(dir)) ? await fs.readdir(dir) : [];
|
|
65
|
+
const personas: ProcessedPersona[] = [];
|
|
66
|
+
for (const f of files) {
|
|
67
|
+
if (!f.endsWith('.json')) continue;
|
|
68
|
+
try {
|
|
69
|
+
const state = JSON.parse(await fs.readFile(path.join(dir, f), 'utf8')) as PersonaState;
|
|
70
|
+
personas.push({
|
|
71
|
+
id: state.id,
|
|
72
|
+
name: state.name,
|
|
73
|
+
phone: state.phone,
|
|
74
|
+
act_count: state.act_count,
|
|
75
|
+
acts: state.acts
|
|
76
|
+
});
|
|
77
|
+
} catch {
|
|
78
|
+
// skip corrupted file
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
personas.sort((a, b) => {
|
|
83
|
+
const aLatestTime = a.acts.length > 0 ? a.acts[a.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
|
|
84
|
+
const bLatestTime = b.acts.length > 0 ? b.acts[b.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
|
|
85
|
+
return new Date(bLatestTime).getTime() - new Date(aLatestTime).getTime();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const totalActs = personas.reduce((sum, p) => sum + p.acts.length, 0);
|
|
89
|
+
|
|
90
|
+
const data: ConversationsData = {
|
|
91
|
+
personas,
|
|
92
|
+
total_personas: personas.length,
|
|
93
|
+
total_acts: totalActs,
|
|
94
|
+
generated_at: new Date().toISOString()
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const yamlContent = yaml.dump(data, {
|
|
98
|
+
indent: 2,
|
|
99
|
+
quotingType: '"',
|
|
100
|
+
forceQuotes: false,
|
|
101
|
+
lineWidth: 120,
|
|
102
|
+
noRefs: true,
|
|
103
|
+
sortKeys: false,
|
|
104
|
+
flowLevel: -1
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await writeFileSafe(aggregateYamlPath(customerIdn), yamlContent);
|
|
108
|
+
return { personas: personas.length, acts: totalActs };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildProcessedActs(raw: ConversationAct[]): ProcessedAct[] {
|
|
112
|
+
const sorted = [...raw].sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
|
|
113
|
+
return sorted.map(act => {
|
|
114
|
+
const processedAct: ProcessedAct = {
|
|
115
|
+
datetime: act.datetime,
|
|
116
|
+
type: act.reference_idn,
|
|
117
|
+
message: act.source_text
|
|
118
|
+
};
|
|
119
|
+
if (act.contact_information) (processedAct as any).contact_information = act.contact_information;
|
|
120
|
+
if (act.flow_idn && act.flow_idn !== 'unknown') (processedAct as any).flow_idn = act.flow_idn;
|
|
121
|
+
if (act.skill_idn && act.skill_idn !== 'unknown') (processedAct as any).skill_idn = act.skill_idn;
|
|
122
|
+
if (act.session_id && act.session_id !== 'unknown') (processedAct as any).session_id = act.session_id;
|
|
123
|
+
return processedAct;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
22
127
|
/**
|
|
23
|
-
* Pull conversations for a customer and save
|
|
128
|
+
* Pull conversations for a customer and save incrementally.
|
|
24
129
|
*/
|
|
25
130
|
export async function pullConversations(
|
|
26
131
|
client: AxiosInstance,
|
|
@@ -28,230 +133,178 @@ export async function pullConversations(
|
|
|
28
133
|
options: ConversationOptions = {},
|
|
29
134
|
verbose: boolean = false
|
|
30
135
|
): Promise<void> {
|
|
31
|
-
|
|
136
|
+
const force = process.env.NEWO_CONV_FORCE === '1';
|
|
137
|
+
console.log(`š¬ Fetching conversations for ${customer.idn}${force ? ' (force re-fetch)' : ' (resume mode)'}...`);
|
|
138
|
+
|
|
139
|
+
// Ensure output dirs exist
|
|
140
|
+
await fs.ensureDir(path.join('newo_customers', customer.idn, 'conversations'));
|
|
141
|
+
|
|
142
|
+
// 1. Enumerate all personas
|
|
143
|
+
const allPersonas: UserPersona[] = [];
|
|
144
|
+
let page = 1;
|
|
145
|
+
const perPage = 50;
|
|
146
|
+
let hasMore = true;
|
|
147
|
+
|
|
148
|
+
while (hasMore) {
|
|
149
|
+
const response = await listUserPersonas(client, page, perPage);
|
|
150
|
+
allPersonas.push(...response.items);
|
|
151
|
+
if (verbose) console.log(`š Page ${page}: ${response.items.length} personas (${allPersonas.length}/${response.metadata.total})`);
|
|
152
|
+
hasMore = response.items.length === perPage && allPersonas.length < response.metadata.total;
|
|
153
|
+
page++;
|
|
154
|
+
}
|
|
32
155
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
let page = 1;
|
|
37
|
-
const perPage = 50;
|
|
38
|
-
let hasMore = true;
|
|
156
|
+
if (options.maxPersonas && allPersonas.length > options.maxPersonas) {
|
|
157
|
+
allPersonas.splice(options.maxPersonas);
|
|
158
|
+
}
|
|
39
159
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
160
|
+
const total = allPersonas.length;
|
|
161
|
+
console.log(`š„ Found ${total} personas. Processing with concurrency=5...`);
|
|
162
|
+
|
|
163
|
+
let done = 0;
|
|
164
|
+
let skipped = 0;
|
|
165
|
+
let failed = 0;
|
|
166
|
+
|
|
167
|
+
await Promise.all(allPersonas.map(persona => concurrencyLimit(async () => {
|
|
168
|
+
try {
|
|
169
|
+
// Resume: skip if already complete
|
|
170
|
+
const existing = await readPersonaState(customer.idn, persona.id);
|
|
171
|
+
if (!force && existing && existing.complete) {
|
|
172
|
+
skipped++;
|
|
173
|
+
done++;
|
|
174
|
+
if (verbose) console.log(`āļø [${done}/${total}] ${persona.name}: already complete (${existing.acts.length} acts)`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
43
177
|
|
|
44
|
-
|
|
178
|
+
const phoneActor = persona.actors.find(actor =>
|
|
179
|
+
actor.integration_idn === 'newo_voice' &&
|
|
180
|
+
actor.connector_idn === 'newo_voice_connector' &&
|
|
181
|
+
actor.contact_information?.startsWith('+')
|
|
182
|
+
);
|
|
183
|
+
const phone = phoneActor?.contact_information || null;
|
|
45
184
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
185
|
+
const userActors = persona.actors.filter(actor =>
|
|
186
|
+
actor.integration_idn === 'newo_voice' &&
|
|
187
|
+
actor.connector_idn === 'newo_voice_connector'
|
|
188
|
+
);
|
|
49
189
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
190
|
+
if (userActors.length === 0) {
|
|
191
|
+
const state: PersonaState = {
|
|
192
|
+
id: persona.id,
|
|
193
|
+
name: persona.name,
|
|
194
|
+
phone,
|
|
195
|
+
act_count: persona.act_count,
|
|
196
|
+
acts: [],
|
|
197
|
+
fetched_at: new Date().toISOString(),
|
|
198
|
+
complete: true
|
|
199
|
+
};
|
|
200
|
+
await writePersonaState(customer.idn, state);
|
|
201
|
+
done++;
|
|
202
|
+
if (verbose) console.log(`ā [${done}/${total}] ${persona.name}: no voice actors`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
54
205
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// Get acts for this persona
|
|
71
|
-
const allActs: ConversationAct[] = [];
|
|
72
|
-
let actPage = 1;
|
|
73
|
-
const actsPerPage = 100; // Higher limit for acts
|
|
74
|
-
let hasMoreActs = true;
|
|
75
|
-
|
|
76
|
-
// Get user actor IDs from persona actors first
|
|
77
|
-
const userActors = persona.actors.filter(actor =>
|
|
78
|
-
actor.integration_idn === 'newo_voice' &&
|
|
79
|
-
actor.connector_idn === 'newo_voice_connector'
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
if (userActors.length === 0) {
|
|
83
|
-
if (verbose) console.log(` š¤ ${persona.name}: No voice actors found, skipping`);
|
|
84
|
-
// No voice actors, can't get chat history - add persona with empty acts
|
|
85
|
-
processedPersonas.push({
|
|
86
|
-
id: persona.id,
|
|
87
|
-
name: persona.name,
|
|
88
|
-
phone,
|
|
89
|
-
act_count: persona.act_count,
|
|
90
|
-
acts: []
|
|
206
|
+
// Fetch acts paginated
|
|
207
|
+
const allActs: ConversationAct[] = [];
|
|
208
|
+
let actPage = 1;
|
|
209
|
+
const actsPerPage = 100;
|
|
210
|
+
let hasMoreActs = true;
|
|
211
|
+
const maxPages = 50;
|
|
212
|
+
let lastError: string | undefined;
|
|
213
|
+
|
|
214
|
+
while (hasMoreActs && actPage <= maxPages) {
|
|
215
|
+
try {
|
|
216
|
+
const chatResponse = await getChatHistory(client, {
|
|
217
|
+
user_actor_id: userActors[0]!.id,
|
|
218
|
+
page: actPage,
|
|
219
|
+
per: actsPerPage
|
|
91
220
|
});
|
|
92
|
-
if (verbose) console.log(` ā Processed ${persona.name}: 0 acts (no voice actors)`);
|
|
93
|
-
return; // Return from the concurrency function
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Safety mechanism to prevent infinite loops
|
|
97
|
-
const maxPages = 50; // Limit to 50 pages (5000 acts max per persona)
|
|
98
221
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
222
|
+
if (chatResponse.items && chatResponse.items.length > 0) {
|
|
223
|
+
const convertedActs: ConversationAct[] = chatResponse.items.map((item: any) => ({
|
|
224
|
+
id: item.id || `chat_${Math.random()}`,
|
|
225
|
+
command_act_id: null,
|
|
226
|
+
external_event_id: item.external_event_id || 'chat_history',
|
|
227
|
+
arguments: [],
|
|
228
|
+
reference_idn: (item.is_agent === true) ? 'agent_message' : 'user_message',
|
|
229
|
+
runtime_context_id: item.runtime_context_id || 'chat_history',
|
|
230
|
+
source_text: item.payload?.text || item.message || item.content || item.text || '',
|
|
231
|
+
original_text: item.payload?.text || item.message || item.content || item.text || '',
|
|
232
|
+
datetime: item.datetime || item.created_at || item.timestamp || new Date().toISOString(),
|
|
102
233
|
user_actor_id: userActors[0]!.id,
|
|
103
|
-
|
|
104
|
-
|
|
234
|
+
agent_actor_id: null,
|
|
235
|
+
user_persona_id: persona.id,
|
|
236
|
+
user_persona_name: persona.name,
|
|
237
|
+
agent_persona_id: item.agent_persona_id || 'unknown',
|
|
238
|
+
external_id: item.external_id || null,
|
|
239
|
+
integration_idn: 'newo_voice',
|
|
240
|
+
connector_idn: 'newo_voice_connector',
|
|
241
|
+
to_integration_idn: null,
|
|
242
|
+
to_connector_idn: null,
|
|
243
|
+
is_agent: Boolean(item.is_agent === true),
|
|
244
|
+
project_idn: null,
|
|
245
|
+
flow_idn: item.flow_idn || 'unknown',
|
|
246
|
+
skill_idn: item.skill_idn || 'unknown',
|
|
247
|
+
session_id: item.session_id || 'unknown',
|
|
248
|
+
recordings: item.recordings || [],
|
|
249
|
+
contact_information: item.contact_information || null
|
|
250
|
+
}));
|
|
251
|
+
|
|
252
|
+
allActs.push(...convertedActs);
|
|
253
|
+
|
|
254
|
+
// Save partial progress every page
|
|
255
|
+
const partialState: PersonaState = {
|
|
256
|
+
id: persona.id,
|
|
257
|
+
name: persona.name,
|
|
258
|
+
phone,
|
|
259
|
+
act_count: persona.act_count,
|
|
260
|
+
acts: buildProcessedActs(allActs),
|
|
261
|
+
fetched_at: new Date().toISOString(),
|
|
262
|
+
complete: false
|
|
105
263
|
};
|
|
264
|
+
await writePersonaState(customer.idn, partialState);
|
|
106
265
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// Convert chat history format to acts format - create minimal ConversationAct objects
|
|
112
|
-
const convertedActs: ConversationAct[] = chatResponse.items.map((item: any) => ({
|
|
113
|
-
id: item.id || `chat_${Math.random()}`,
|
|
114
|
-
command_act_id: null,
|
|
115
|
-
external_event_id: item.external_event_id || 'chat_history',
|
|
116
|
-
arguments: [],
|
|
117
|
-
reference_idn: (item.is_agent === true) ? 'agent_message' : 'user_message',
|
|
118
|
-
runtime_context_id: item.runtime_context_id || 'chat_history',
|
|
119
|
-
source_text: item.payload?.text || item.message || item.content || item.text || '',
|
|
120
|
-
original_text: item.payload?.text || item.message || item.content || item.text || '',
|
|
121
|
-
datetime: item.datetime || item.created_at || item.timestamp || new Date().toISOString(),
|
|
122
|
-
user_actor_id: userActors[0]!.id,
|
|
123
|
-
agent_actor_id: null,
|
|
124
|
-
user_persona_id: persona.id,
|
|
125
|
-
user_persona_name: persona.name,
|
|
126
|
-
agent_persona_id: item.agent_persona_id || 'unknown',
|
|
127
|
-
external_id: item.external_id || null,
|
|
128
|
-
integration_idn: 'newo_voice',
|
|
129
|
-
connector_idn: 'newo_voice_connector',
|
|
130
|
-
to_integration_idn: null,
|
|
131
|
-
to_connector_idn: null,
|
|
132
|
-
is_agent: Boolean(item.is_agent === true),
|
|
133
|
-
project_idn: null,
|
|
134
|
-
flow_idn: item.flow_idn || 'unknown',
|
|
135
|
-
skill_idn: item.skill_idn || 'unknown',
|
|
136
|
-
session_id: item.session_id || 'unknown',
|
|
137
|
-
recordings: item.recordings || [],
|
|
138
|
-
contact_information: item.contact_information || null
|
|
139
|
-
}));
|
|
140
|
-
|
|
141
|
-
allActs.push(...convertedActs);
|
|
142
|
-
|
|
143
|
-
if (verbose && convertedActs.length > 0) {
|
|
144
|
-
console.log(` š¤ ${persona.name}: Chat History - ${convertedActs.length} messages (${allActs.length} total)`);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Check if we should continue paginating
|
|
148
|
-
const hasMetadata = chatResponse.metadata?.total !== undefined;
|
|
149
|
-
const currentTotal = chatResponse.metadata?.total || 0;
|
|
150
|
-
|
|
151
|
-
hasMoreActs = chatResponse.items.length === actsPerPage &&
|
|
152
|
-
hasMetadata &&
|
|
153
|
-
allActs.length < currentTotal;
|
|
154
|
-
|
|
155
|
-
actPage++;
|
|
156
|
-
|
|
157
|
-
if (verbose) console.log(` š ${persona.name}: Page ${actPage - 1} done, ${allActs.length}/${currentTotal} total acts`);
|
|
158
|
-
} else {
|
|
159
|
-
// No more items
|
|
160
|
-
hasMoreActs = false;
|
|
161
|
-
if (verbose) console.log(` š ${persona.name}: No more chat history items`);
|
|
162
|
-
}
|
|
163
|
-
} catch (chatError) {
|
|
164
|
-
if (verbose) console.log(` ā ļø Chat history failed for ${persona.name}: ${chatError instanceof Error ? chatError.message : String(chatError)}`);
|
|
266
|
+
const currentTotal = chatResponse.metadata?.total || 0;
|
|
267
|
+
hasMoreActs = chatResponse.items.length === actsPerPage && allActs.length < currentTotal;
|
|
268
|
+
actPage++;
|
|
269
|
+
} else {
|
|
165
270
|
hasMoreActs = false;
|
|
166
271
|
}
|
|
272
|
+
} catch (chatError) {
|
|
273
|
+
lastError = chatError instanceof Error ? chatError.message : String(chatError);
|
|
274
|
+
if (verbose) console.log(`ā ļø ${persona.name} page ${actPage}: ${lastError}`);
|
|
275
|
+
hasMoreActs = false;
|
|
167
276
|
}
|
|
168
|
-
|
|
169
|
-
if (actPage > maxPages) {
|
|
170
|
-
if (verbose) console.log(` ā ļø ${persona.name}: Reached max pages limit (${maxPages}), stopping pagination`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Sort acts by datetime ascending (chronological order)
|
|
174
|
-
allActs.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
|
|
175
|
-
|
|
176
|
-
// Process acts into simplified format - exclude redundant fields
|
|
177
|
-
const processedActs: ProcessedAct[] = allActs.map(act => {
|
|
178
|
-
const processedAct: ProcessedAct = {
|
|
179
|
-
datetime: act.datetime,
|
|
180
|
-
type: act.reference_idn,
|
|
181
|
-
message: act.source_text
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
// Only include non-redundant fields
|
|
185
|
-
if (act.contact_information) {
|
|
186
|
-
(processedAct as any).contact_information = act.contact_information;
|
|
187
|
-
}
|
|
188
|
-
if (act.flow_idn && act.flow_idn !== 'unknown') {
|
|
189
|
-
(processedAct as any).flow_idn = act.flow_idn;
|
|
190
|
-
}
|
|
191
|
-
if (act.skill_idn && act.skill_idn !== 'unknown') {
|
|
192
|
-
(processedAct as any).skill_idn = act.skill_idn;
|
|
193
|
-
}
|
|
194
|
-
if (act.session_id && act.session_id !== 'unknown') {
|
|
195
|
-
(processedAct as any).session_id = act.session_id;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return processedAct;
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
processedPersonas.push({
|
|
202
|
-
id: persona.id,
|
|
203
|
-
name: persona.name,
|
|
204
|
-
phone,
|
|
205
|
-
act_count: persona.act_count,
|
|
206
|
-
acts: processedActs
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
if (verbose) console.log(` ā Processed ${persona.name}: ${processedActs.length} acts`);
|
|
210
|
-
} catch (error) {
|
|
211
|
-
console.error(`ā Failed to process persona ${persona.name}:`, error);
|
|
212
|
-
// Continue with other personas
|
|
213
277
|
}
|
|
214
|
-
})));
|
|
215
|
-
|
|
216
|
-
// Sort personas by most recent act time (descending) - use latest act from acts array
|
|
217
|
-
processedPersonas.sort((a, b) => {
|
|
218
|
-
const aLatestTime = a.acts.length > 0 ? a.acts[a.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
|
|
219
|
-
const bLatestTime = b.acts.length > 0 ? b.acts[b.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
|
|
220
|
-
return new Date(bLatestTime).getTime() - new Date(aLatestTime).getTime();
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
// Calculate totals
|
|
224
|
-
const totalActs = processedPersonas.reduce((sum, persona) => sum + persona.acts.length, 0);
|
|
225
|
-
|
|
226
|
-
// Create final conversations data
|
|
227
|
-
const conversationsData: ConversationsData = {
|
|
228
|
-
personas: processedPersonas,
|
|
229
|
-
total_personas: processedPersonas.length,
|
|
230
|
-
total_acts: totalActs,
|
|
231
|
-
generated_at: new Date().toISOString()
|
|
232
|
-
};
|
|
233
278
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
279
|
+
const finalState: PersonaState = {
|
|
280
|
+
id: persona.id,
|
|
281
|
+
name: persona.name,
|
|
282
|
+
phone,
|
|
283
|
+
act_count: persona.act_count,
|
|
284
|
+
acts: buildProcessedActs(allActs),
|
|
285
|
+
fetched_at: new Date().toISOString(),
|
|
286
|
+
complete: !lastError
|
|
287
|
+
};
|
|
288
|
+
if (lastError) finalState.last_error = lastError;
|
|
289
|
+
await writePersonaState(customer.idn, finalState);
|
|
290
|
+
|
|
291
|
+
// Incremental YAML aggregate every persona
|
|
292
|
+
const agg = await writeAggregateYaml(customer.idn);
|
|
293
|
+
|
|
294
|
+
done++;
|
|
295
|
+
if (lastError) failed++;
|
|
296
|
+
console.log(`${lastError ? 'ā ļø ' : 'ā'} [${done}/${total}] ${persona.name}: ${finalState.acts.length} acts${lastError ? ` (partial: ${lastError})` : ''} | total so far: ${agg.personas} personas / ${agg.acts} acts`);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
failed++;
|
|
299
|
+
done++;
|
|
300
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
301
|
+
console.error(`ā [${done}/${total}] ${persona.name}: ${msg}`);
|
|
251
302
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
}
|
|
303
|
+
})));
|
|
304
|
+
|
|
305
|
+
// Final aggregate write
|
|
306
|
+
const final = await writeAggregateYaml(customer.idn);
|
|
307
|
+
console.log(`\nā
Done. ${final.personas} personas, ${final.acts} acts. Skipped ${skipped} (already cached), ${failed} had errors.`);
|
|
308
|
+
console.log(` Aggregate: ${aggregateYamlPath(customer.idn)}`);
|
|
309
|
+
console.log(` Per-persona: newo_customers/${customer.idn}/conversations/<id>.json`);
|
|
310
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-typed attribute helpers.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists:
|
|
5
|
+
*
|
|
6
|
+
* The NEWO platform stores some attributes (e.g.
|
|
7
|
+
* `project_attributes_private_dynamic_workflow_builder_canvas`) as
|
|
8
|
+
* `value_type: json`. The API may return the `value` field as either a
|
|
9
|
+
* STRING containing JSON or as an already-parsed OBJECT.
|
|
10
|
+
*
|
|
11
|
+
* Without normalization, two bugs leak through:
|
|
12
|
+
*
|
|
13
|
+
* 1. When the API returns the value as an OBJECT, `yaml.dump` serializes
|
|
14
|
+
* it as a YAML structure (mappings/sequences). Pushing back then sends
|
|
15
|
+
* `{"value": {...}}` instead of `{"value": "..."}`, breaking the
|
|
16
|
+
* Workflow Builder which expects the canvas as a JSON STRING.
|
|
17
|
+
*
|
|
18
|
+
* 2. The push-time change check used `String(localAttr.value)` for
|
|
19
|
+
* comparison. With objects this collapses to `"[object Object]"` on
|
|
20
|
+
* both sides ā silently masking real changes ā and with mismatched
|
|
21
|
+
* string vs object representations it triggers spurious pushes that
|
|
22
|
+
* overwrite the canvas with the wrong shape (Builder shows blank).
|
|
23
|
+
*
|
|
24
|
+
* The fix is conservative: for `value_type: json` only, always coerce the
|
|
25
|
+
* value to a STRING when persisting and when pushing, and use canonical
|
|
26
|
+
* JSON for comparisons. String-typed values in the wild are left
|
|
27
|
+
* untouched, so no churn for the majority of attributes.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* True if the attribute is a JSON-typed attribute (case- and
|
|
32
|
+
* format-insensitive: handles `json`, `JSON`, `AttributeValueTypes.json`,
|
|
33
|
+
* `ValueType.JSON`, etc.).
|
|
34
|
+
*/
|
|
35
|
+
export function isJsonValueType(valueType: unknown): boolean {
|
|
36
|
+
if (typeof valueType !== 'string') return false;
|
|
37
|
+
const lower = valueType.toLowerCase();
|
|
38
|
+
return lower === 'json' || lower.endsWith('.json');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Coerce a JSON-typed attribute's value to a STRING suitable for storage
|
|
43
|
+
* in attributes.yaml and for sending to the platform.
|
|
44
|
+
*
|
|
45
|
+
* - `null` / `undefined` ā `''`
|
|
46
|
+
* - object ā compact JSON string (`JSON.stringify(value)`)
|
|
47
|
+
* - string ā returned as-is (we trust the platform's existing format)
|
|
48
|
+
* - other ā `String(value)`
|
|
49
|
+
*
|
|
50
|
+
* We deliberately do NOT re-format string values, even when they look
|
|
51
|
+
* like JSON. Many existing canvases are stored pretty-printed and
|
|
52
|
+
* reformatting would create huge spurious diffs in users' repos.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeJsonValueForStorage(value: unknown): string {
|
|
55
|
+
if (value == null) return '';
|
|
56
|
+
if (typeof value === 'string') return value;
|
|
57
|
+
if (typeof value === 'object') {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.stringify(value);
|
|
60
|
+
} catch {
|
|
61
|
+
return String(value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return String(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Canonical comparison for JSON-typed attribute values.
|
|
69
|
+
*
|
|
70
|
+
* Returns the canonical form (compact JSON if parseable, otherwise the
|
|
71
|
+
* raw string). Use this on both sides of a comparison so that pretty- vs
|
|
72
|
+
* compact-printed JSON does not register as a change, and so that an
|
|
73
|
+
* object on one side equals its stringified form on the other side.
|
|
74
|
+
*/
|
|
75
|
+
export function canonicalJsonValue(value: unknown): string {
|
|
76
|
+
const stringified = normalizeJsonValueForStorage(value);
|
|
77
|
+
if (stringified === '') return '';
|
|
78
|
+
try {
|
|
79
|
+
return JSON.stringify(JSON.parse(stringified));
|
|
80
|
+
} catch {
|
|
81
|
+
return stringified;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* True if two JSON-typed attribute values are semantically equal.
|
|
87
|
+
*
|
|
88
|
+
* Handles the four mismatched representations that can occur during a
|
|
89
|
+
* pull/push cycle:
|
|
90
|
+
* string vs string (different whitespace/indent), object vs string,
|
|
91
|
+
* string vs object, object vs object.
|
|
92
|
+
*/
|
|
93
|
+
export function jsonValuesEqual(a: unknown, b: unknown): boolean {
|
|
94
|
+
return canonicalJsonValue(a) === canonicalJsonValue(b);
|
|
95
|
+
}
|