@memoring/cli 0.12.1 → 0.15.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/README.md +163 -8
- package/dist/api.js +90 -4
- package/dist/args.js +14 -1
- package/dist/auth.js +1 -1
- package/dist/commands/batch.js +327 -0
- package/dist/commands/inbox.js +3 -1
- package/dist/commands/materialReferences.js +359 -0
- package/dist/commands/plan-analyze.js +256 -0
- package/dist/commands/plan-diagnostics.js +65 -0
- package/dist/commands/plan-exams.js +122 -0
- package/dist/commands/plan-goal.js +131 -0
- package/dist/commands/plan-shared.js +133 -0
- package/dist/commands/plan.js +219 -0
- package/dist/commands/planDrafts.js +162 -0
- package/dist/commands/scratch.js +632 -0
- package/dist/commands/stage.js +274 -73
- package/dist/config.js +2 -0
- package/dist/main.js +43 -2
- package/dist/pdf/batchValidation.js +72 -0
- package/dist/pdf/documentCache.js +68 -0
- package/dist/pdf/quoteLocate.js +181 -0
- package/package.json +8 -2
- package/skills/memoring-cli/SKILL.md +58 -7
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { apiFetch, ApiError } from '../api.js';
|
|
3
|
+
import { dim, fail, green, printJson, printTable, stringFlag } from './shared.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Limites e tipos — única fronteira de validação das referências de material
|
|
6
|
+
// (stage create e reference update usam OS MESMOS validadores).
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/** Limites espelhando a validação do backend (V234/V243). */
|
|
9
|
+
export const MATERIAL_DRIVE_FILE_ID_MAX = 255;
|
|
10
|
+
export const MATERIAL_EXCERPT_MAX = 500;
|
|
11
|
+
export const MATERIAL_QUOTE_MAX = 1000;
|
|
12
|
+
export const MATERIAL_CONTEXT_MAX = 500;
|
|
13
|
+
const REGION_EPSILON = 1e-9;
|
|
14
|
+
/**
|
|
15
|
+
* Geometria da região: objeto {x,y,w,h} com números FINITOS, w/h > 0 e retângulo
|
|
16
|
+
* contido no unit square (0 <= x, 0 <= y, x+w <= 1, y+h <= 1). Um overshoot de até
|
|
17
|
+
* REGION_EPSILON é tolerado como ruído de float, mas NUNCA vai no fio: x/y são
|
|
18
|
+
* elevados a 0 e w/h encolhidos até a borda (1-x/1-y) — o resultado sempre satisfaz
|
|
19
|
+
* o CHECK estrito do backend. Overshoot além da tolerância (ou sem espaço p/ w/h > 0)
|
|
20
|
+
* continua erro. Usado pelo stage (materialReferences.region) e pelo reference update.
|
|
21
|
+
*/
|
|
22
|
+
export function validateRegion(raw, where) {
|
|
23
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
24
|
+
fail(`${where}: region deve ser um objeto { x, y, w, h } (veio ${raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw}).`);
|
|
25
|
+
}
|
|
26
|
+
const r = raw;
|
|
27
|
+
for (const key of ['x', 'y', 'w', 'h']) {
|
|
28
|
+
const value = r[key];
|
|
29
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
30
|
+
fail(`${where}: region.${key} deve ser um número finito (veio ${JSON.stringify(value)}).`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const region = r;
|
|
34
|
+
if (region.w <= 0 || region.h <= 0) {
|
|
35
|
+
fail(`${where}: region exige w e h > 0 (veio w=${region.w}, h=${region.h}).`);
|
|
36
|
+
}
|
|
37
|
+
if (region.x < -REGION_EPSILON ||
|
|
38
|
+
region.y < -REGION_EPSILON ||
|
|
39
|
+
region.x + region.w > 1 + REGION_EPSILON ||
|
|
40
|
+
region.y + region.h > 1 + REGION_EPSILON) {
|
|
41
|
+
fail(`${where}: region deve estar contida no unit square (0..1) — veio ` +
|
|
42
|
+
`{ x: ${region.x}, y: ${region.y}, w: ${region.w}, h: ${region.h} }.`);
|
|
43
|
+
}
|
|
44
|
+
// Clamp do overshoot tolerado: o wire nunca carrega valor fora do quadrado nem
|
|
45
|
+
// coordenada negativa (o CHECK do backend é estrito). x > 1 dentro da tolerância
|
|
46
|
+
// deixaria w/h sem espaço — degenera a região, então é rejeitado em vez de inventar
|
|
47
|
+
// coordenada.
|
|
48
|
+
const x = Math.max(0, region.x);
|
|
49
|
+
const y = Math.max(0, region.y);
|
|
50
|
+
const w = Math.min(region.w, 1 - x);
|
|
51
|
+
const h = Math.min(region.h, 1 - y);
|
|
52
|
+
if (w <= 0 || h <= 0) {
|
|
53
|
+
fail(`${where}: region deve estar contida no unit square (0..1) — veio ` +
|
|
54
|
+
`{ x: ${region.x}, y: ${region.y}, w: ${region.w}, h: ${region.h} }.`);
|
|
55
|
+
}
|
|
56
|
+
return { x, y, w, h };
|
|
57
|
+
}
|
|
58
|
+
/** Texto opcional do stage: string trim não-vazia até `max`; vazio normaliza para ausente. */
|
|
59
|
+
function validateStageText(raw, field, max, where) {
|
|
60
|
+
if (typeof raw !== 'string') {
|
|
61
|
+
fail(`${where}: ${field} deve ser uma string (veio ${typeof raw}).`);
|
|
62
|
+
}
|
|
63
|
+
const trimmed = raw.trim();
|
|
64
|
+
if (trimmed.length > max) {
|
|
65
|
+
fail(`${where}: ${field} tem ${trimmed.length} chars (máximo ${max}).`);
|
|
66
|
+
}
|
|
67
|
+
return trimmed === '' ? undefined : trimmed;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Valida e normaliza as referências de material do lote ANTES de qualquer chamada de API:
|
|
71
|
+
* cada entrada precisa ser objeto com driveFileId (string trim não vazia, até 255),
|
|
72
|
+
* page (inteiro >= 1, 1-based), excerpt/quote/contextBefore/contextAfter opcionais
|
|
73
|
+
* (trim nos tetos; vazio vira ausente) e region opcional (geometria validada por
|
|
74
|
+
* validateRegion). `undefined`/`null`/array vazio normalizam para ausente (o campo é
|
|
75
|
+
* omitido no POST, mantendo o payload antigo dos lotes sem referências).
|
|
76
|
+
*/
|
|
77
|
+
export function validateMaterialReferences(raw, where) {
|
|
78
|
+
if (raw === undefined || raw === null) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(raw)) {
|
|
82
|
+
fail(`${where}: materialReferences deve ser um array (veio ${typeof raw}).`);
|
|
83
|
+
}
|
|
84
|
+
if (raw.length === 0) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
return raw.map((entry, index) => {
|
|
88
|
+
const ref = `${where}: materialReferences[${index + 1}]`;
|
|
89
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
90
|
+
fail(`${ref} deve ser um objeto { driveFileId, page, excerpt?, quote?, contextBefore?, contextAfter?, region? }.`);
|
|
91
|
+
}
|
|
92
|
+
const r = entry;
|
|
93
|
+
if (typeof r.driveFileId !== 'string' || r.driveFileId.trim() === '') {
|
|
94
|
+
fail(`${ref}: driveFileId é obrigatório (string não vazia — veja \`memoring scratch list --json\`).`);
|
|
95
|
+
}
|
|
96
|
+
const driveFileId = r.driveFileId.trim();
|
|
97
|
+
if (driveFileId.length > MATERIAL_DRIVE_FILE_ID_MAX) {
|
|
98
|
+
fail(`${ref}: driveFileId tem ${driveFileId.length} chars (máximo ${MATERIAL_DRIVE_FILE_ID_MAX}).`);
|
|
99
|
+
}
|
|
100
|
+
if (typeof r.page !== 'number' || !Number.isInteger(r.page) || r.page < 1) {
|
|
101
|
+
fail(`${ref}: page deve ser um inteiro >= 1 (página visível, 1-based — veio ${JSON.stringify(r.page)}).`);
|
|
102
|
+
}
|
|
103
|
+
let excerpt;
|
|
104
|
+
if (r.excerpt !== undefined && r.excerpt !== null) {
|
|
105
|
+
excerpt = validateStageText(r.excerpt, 'excerpt', MATERIAL_EXCERPT_MAX, ref);
|
|
106
|
+
}
|
|
107
|
+
let quote;
|
|
108
|
+
if (r.quote !== undefined && r.quote !== null) {
|
|
109
|
+
quote = validateStageText(r.quote, 'quote', MATERIAL_QUOTE_MAX, ref);
|
|
110
|
+
}
|
|
111
|
+
let contextBefore;
|
|
112
|
+
if (r.contextBefore !== undefined && r.contextBefore !== null) {
|
|
113
|
+
contextBefore = validateStageText(r.contextBefore, 'contextBefore', MATERIAL_CONTEXT_MAX, ref);
|
|
114
|
+
}
|
|
115
|
+
let contextAfter;
|
|
116
|
+
if (r.contextAfter !== undefined && r.contextAfter !== null) {
|
|
117
|
+
contextAfter = validateStageText(r.contextAfter, 'contextAfter', MATERIAL_CONTEXT_MAX, ref);
|
|
118
|
+
}
|
|
119
|
+
let region;
|
|
120
|
+
if (r.region !== undefined && r.region !== null) {
|
|
121
|
+
region = validateRegion(r.region, ref);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
driveFileId,
|
|
125
|
+
page: r.page,
|
|
126
|
+
...(excerpt !== undefined ? { excerpt } : {}),
|
|
127
|
+
...(quote !== undefined ? { quote } : {}),
|
|
128
|
+
...(contextBefore !== undefined ? { contextBefore } : {}),
|
|
129
|
+
...(contextAfter !== undefined ? { contextAfter } : {}),
|
|
130
|
+
...(region !== undefined ? { region } : {}),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Conversão para o fio (POST /notes/review-inbox): page 1-based vira `pageIndex` 0-based
|
|
136
|
+
* (page NUNCA vaza) e os demais campos são repassados. Campos ausentes são omitidos —
|
|
137
|
+
* lotes antigos (só driveFileId/page/excerpt) saem byte a byte como antes.
|
|
138
|
+
*/
|
|
139
|
+
export function toWireMaterialReference(ref) {
|
|
140
|
+
return {
|
|
141
|
+
driveFileId: ref.driveFileId,
|
|
142
|
+
pageIndex: ref.page - 1,
|
|
143
|
+
...(ref.excerpt !== undefined ? { excerpt: ref.excerpt } : {}),
|
|
144
|
+
...(ref.quote !== undefined ? { quote: ref.quote } : {}),
|
|
145
|
+
...(ref.contextBefore !== undefined ? { contextBefore: ref.contextBefore } : {}),
|
|
146
|
+
...(ref.contextAfter !== undefined ? { contextAfter: ref.contextAfter } : {}),
|
|
147
|
+
...(ref.region !== undefined ? { region: ref.region } : {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Contrato das referências de material no idioma do AGENTE (page 1-based), como o
|
|
152
|
+
* `stage schema` o expõe. No POST cada referência sai como
|
|
153
|
+
* { driveFileId, pageIndex: page - 1, excerpt?, quote?, contextBefore?, contextAfter?, region? }.
|
|
154
|
+
*/
|
|
155
|
+
export const MATERIAL_REFERENCE_CONTRACT = {
|
|
156
|
+
driveFileId: `string (obrigatório) — id do arquivo no Drive; descubra com \`memoring scratch list --json\` (trim, não vazio, até ${MATERIAL_DRIVE_FILE_ID_MAX} chars)`,
|
|
157
|
+
page: 'int 1-based (obrigatório) — página VISÍVEL do PDF (a posição 1-based no documento, como no `scratch extract --pages`); o POST converte para pageIndex 0-based',
|
|
158
|
+
excerpt: `string? — dica curta do trecho que originou o item (trim, até ${MATERIAL_EXCERPT_MAX} chars; vazio = omitido). É SÓ um rótulo humano: NÃO gera destaque no PDF`,
|
|
159
|
+
quote: `string? — trecho LITERAL do material que originou o item (trim, até ${MATERIAL_QUOTE_MAX} chars; vazio = omitido). RECOMENDADO para PDF com texto: é o que o app localiza e destaca na página`,
|
|
160
|
+
contextBefore: `string? — texto imediatamente ANTES do quote no material (trim, até ${MATERIAL_CONTEXT_MAX} chars; vazio = omitido) — desempata ocorrências repetidas do quote`,
|
|
161
|
+
contextAfter: `string? — texto imediatamente DEPOIS do quote no material (trim, até ${MATERIAL_CONTEXT_MAX} chars; vazio = omitido) — desempata ocorrências repetidas do quote`,
|
|
162
|
+
region: 'objeto? { x, y, w, h } — região visual da página, coordenadas normalizadas 0..1 com origem no topo-esquerdo (x,y) e tamanho (w,h); todos números FINITOS, w/h > 0 e retângulo contido no quadrado 0..1 (x+w <= 1, y+h <= 1). Para PDF ESCANEADO/sem texto: selecione a região do recorte em vez de quote. Alternativa ao quote — um ou outro (ou nenhum, só a página)',
|
|
163
|
+
};
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// memoring reference list/update — manutenção das referências de um card.
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
const UPDATE_FIELDS = ['excerpt', 'quote', 'contextBefore', 'contextAfter', 'region'];
|
|
168
|
+
function parseId(arg, what, usage) {
|
|
169
|
+
const id = Number(arg);
|
|
170
|
+
if (!arg || !Number.isInteger(id) || id <= 0) {
|
|
171
|
+
fail(`informe o ${what} numérico: ${usage} (veja os ids com \`memoring reference list <noteId> --json\`).`);
|
|
172
|
+
}
|
|
173
|
+
return id;
|
|
174
|
+
}
|
|
175
|
+
async function fetchReferences(noteId) {
|
|
176
|
+
let body;
|
|
177
|
+
try {
|
|
178
|
+
body = await apiFetch(`/notes/${noteId}/material-references`);
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
if (e instanceof ApiError && e.status === 404) {
|
|
182
|
+
fail(`card #${noteId} não encontrado (ou de outro usuário) — confira o id com \`memoring inbox list\` ou \`memoring deck list\`.`);
|
|
183
|
+
}
|
|
184
|
+
throw e;
|
|
185
|
+
}
|
|
186
|
+
const references = body?.references;
|
|
187
|
+
if (!Array.isArray(references)) {
|
|
188
|
+
fail(`resposta inesperada do servidor ao listar as referências do card #${noteId} (falta o array "references").`);
|
|
189
|
+
}
|
|
190
|
+
return references;
|
|
191
|
+
}
|
|
192
|
+
function truncate(value, max) {
|
|
193
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
|
194
|
+
}
|
|
195
|
+
/** Página de exibição (1-based, o idioma do agente) — '—' quando o servidor não a tem. */
|
|
196
|
+
function pageLabel(ref) {
|
|
197
|
+
return typeof ref.pageIndex === 'number' ? ref.pageIndex + 1 : '—';
|
|
198
|
+
}
|
|
199
|
+
async function listReferences(parsed, noteId) {
|
|
200
|
+
const references = await fetchReferences(noteId);
|
|
201
|
+
if (parsed.flags.json) {
|
|
202
|
+
printJson(references);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (references.length === 0) {
|
|
206
|
+
process.stdout.write(`Card #${noteId} não tem referências de material.\n`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
printTable(references.map((r) => ({
|
|
210
|
+
id: `#${r.id}`,
|
|
211
|
+
page: pageLabel(r),
|
|
212
|
+
file: truncate(r.driveFileId ?? '', 28),
|
|
213
|
+
highlight: r.region ? 'região' : r.quote ? 'quote' : '—',
|
|
214
|
+
summary: truncate(r.excerpt ?? r.quote ?? '—', 48),
|
|
215
|
+
})), [
|
|
216
|
+
{ key: 'id', label: 'ID' },
|
|
217
|
+
{ key: 'page', label: 'PÁGINA' },
|
|
218
|
+
{ key: 'file', label: 'ARQUIVO' },
|
|
219
|
+
{ key: 'highlight', label: 'DESTAQUE' },
|
|
220
|
+
{ key: 'summary', label: 'RESUMO' },
|
|
221
|
+
]);
|
|
222
|
+
process.stdout.write(`\n${references.length} referência(s) no card #${noteId} — página exibida 1-based. ` +
|
|
223
|
+
'Corrija excerpt/quote/contextos/região com `memoring reference update ' +
|
|
224
|
+
`<noteId> <referenceId> --file parcial.json\` (region: null limpa a região).\n`);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Merge de um campo de texto do patch com a referência atual: chave AUSENTE mantém o
|
|
228
|
+
* valor atual; null (ou string que trima para vazio) LIMPA; string válida valida
|
|
229
|
+
* (trim + teto) e substitui.
|
|
230
|
+
*/
|
|
231
|
+
function mergeTextField(current, raw, field, max, where) {
|
|
232
|
+
if (raw === undefined) {
|
|
233
|
+
return current ?? null;
|
|
234
|
+
}
|
|
235
|
+
if (raw === null) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (typeof raw !== 'string') {
|
|
239
|
+
fail(`${where}: ${field} deve ser uma string ou null (veio ${typeof raw}).`);
|
|
240
|
+
}
|
|
241
|
+
const trimmed = raw.trim();
|
|
242
|
+
if (trimmed.length > max) {
|
|
243
|
+
fail(`${where}: ${field} tem ${trimmed.length} chars (máximo ${max}).`);
|
|
244
|
+
}
|
|
245
|
+
return trimmed === '' ? null : trimmed;
|
|
246
|
+
}
|
|
247
|
+
/** Merge da região: chave AUSENTE mantém; null LIMPA; objeto valida pela geometria comum. */
|
|
248
|
+
function mergeRegion(current, raw, where) {
|
|
249
|
+
if (raw === undefined) {
|
|
250
|
+
return current ?? null;
|
|
251
|
+
}
|
|
252
|
+
if (raw === null) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return validateRegion(raw, where);
|
|
256
|
+
}
|
|
257
|
+
function readPatchFile(parsed) {
|
|
258
|
+
const file = stringFlag(parsed.flags, 'file');
|
|
259
|
+
if (!file) {
|
|
260
|
+
fail('informe o patch: memoring reference update <noteId> <referenceId> --file parcial.json ' +
|
|
261
|
+
'(excerpt/quote/contextBefore/contextAfter/region — campos ausentes são mantidos; region: null limpa).');
|
|
262
|
+
}
|
|
263
|
+
let patch;
|
|
264
|
+
try {
|
|
265
|
+
patch = JSON.parse(readFileSync(file, 'utf8'));
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
fail(`JSON inválido em --file: ${e.message}`);
|
|
269
|
+
}
|
|
270
|
+
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
|
|
271
|
+
fail(`--file deve ser um objeto JSON parcial com os campos ${UPDATE_FIELDS.map((f) => `"${f}"`).join(', ')} (veio ${Array.isArray(patch) ? 'array' : typeof patch}).`);
|
|
272
|
+
}
|
|
273
|
+
const record = patch;
|
|
274
|
+
const unknown = Object.keys(record).filter((k) => !UPDATE_FIELDS.includes(k));
|
|
275
|
+
if (unknown.length > 0) {
|
|
276
|
+
fail(`campo(s) desconhecido(s) no patch: ${unknown.map((k) => `"${k}"`).join(', ')} — o update só aceita ` +
|
|
277
|
+
`${UPDATE_FIELDS.map((f) => `"${f}"`).join(', ')} (nota, driveFileId e página NÃO são alterados por aqui).`);
|
|
278
|
+
}
|
|
279
|
+
return record;
|
|
280
|
+
}
|
|
281
|
+
async function updateReference(parsed, noteId, referenceId) {
|
|
282
|
+
const patch = readPatchFile(parsed);
|
|
283
|
+
const references = await fetchReferences(noteId);
|
|
284
|
+
const current = references.find((r) => r.id === referenceId);
|
|
285
|
+
if (!current) {
|
|
286
|
+
fail(`referência #${referenceId} não encontrada no card #${noteId} — veja os ids com \`memoring reference list ${noteId} --json\`.`);
|
|
287
|
+
}
|
|
288
|
+
const where = `referência #${referenceId}`;
|
|
289
|
+
// PUT full replace DESSAS METADADAS: o body sempre leva os 5 campos com o resultado
|
|
290
|
+
// do merge (ausente = manter o atual; null = limpar). Nota, driveFileId e página
|
|
291
|
+
// não entram no body — o servidor não os troca por esse endpoint.
|
|
292
|
+
const body = {
|
|
293
|
+
excerpt: mergeTextField(current.excerpt, patch.excerpt, 'excerpt', MATERIAL_EXCERPT_MAX, where),
|
|
294
|
+
quote: mergeTextField(current.quote, patch.quote, 'quote', MATERIAL_QUOTE_MAX, where),
|
|
295
|
+
contextBefore: mergeTextField(current.contextBefore, patch.contextBefore, 'contextBefore', MATERIAL_CONTEXT_MAX, where),
|
|
296
|
+
contextAfter: mergeTextField(current.contextAfter, patch.contextAfter, 'contextAfter', MATERIAL_CONTEXT_MAX, where),
|
|
297
|
+
region: mergeRegion(current.region, patch.region, where),
|
|
298
|
+
};
|
|
299
|
+
let updated;
|
|
300
|
+
try {
|
|
301
|
+
updated = (await apiFetch(`/notes/${noteId}/material-references/${referenceId}`, {
|
|
302
|
+
method: 'PUT',
|
|
303
|
+
body,
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
if (e instanceof ApiError) {
|
|
308
|
+
if (e.status === 404) {
|
|
309
|
+
fail(`referência #${referenceId} não encontrada no card #${noteId} (ou de outro usuário) — confira com \`memoring reference list ${noteId} --json\`.`);
|
|
310
|
+
}
|
|
311
|
+
fail(`o servidor rejeitou a atualização (${e.status}): ${e.body}`);
|
|
312
|
+
}
|
|
313
|
+
throw e;
|
|
314
|
+
}
|
|
315
|
+
const view = updated ?? { ...current, ...body };
|
|
316
|
+
if (parsed.flags.json) {
|
|
317
|
+
printJson(view);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const showText = (v) => (v ? `"${truncate(v, 60)}"` : '—');
|
|
321
|
+
process.stdout.write(green(`Referência #${referenceId} do card #${noteId} atualizada:\n`));
|
|
322
|
+
process.stdout.write(` página ${pageLabel(view)} (inalterada — não se troca por aqui)\n`);
|
|
323
|
+
process.stdout.write(` arquivo ${view.driveFileId ?? '—'} (inalterado)\n`);
|
|
324
|
+
process.stdout.write(` excerpt ${showText(view.excerpt)}\n`);
|
|
325
|
+
process.stdout.write(` quote ${showText(view.quote)}\n`);
|
|
326
|
+
process.stdout.write(` contextBefore ${showText(view.contextBefore)}\n`);
|
|
327
|
+
process.stdout.write(` contextAfter ${showText(view.contextAfter)}\n`);
|
|
328
|
+
process.stdout.write(` region ${view.region ? `{ x: ${view.region.x}, y: ${view.region.y}, w: ${view.region.w}, h: ${view.region.h} }` : '—'}\n`);
|
|
329
|
+
process.stdout.write(dim('Sem quote nem região o app abre a página sem destaque — excerpt é só uma dica curta.\n'));
|
|
330
|
+
}
|
|
331
|
+
export async function referenceCommand(parsed) {
|
|
332
|
+
const [sub, noteIdArg, referenceIdArg] = parsed._;
|
|
333
|
+
switch (sub) {
|
|
334
|
+
case 'list': {
|
|
335
|
+
const noteId = parseId(noteIdArg, 'id do card', 'memoring reference list <noteId>');
|
|
336
|
+
await listReferences(parsed, noteId);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
case 'update': {
|
|
340
|
+
const noteId = parseId(noteIdArg, 'id do card', 'memoring reference update <noteId> <referenceId> --file parcial.json');
|
|
341
|
+
const referenceId = parseId(referenceIdArg, 'id da referência', 'memoring reference update <noteId> <referenceId> --file parcial.json');
|
|
342
|
+
await updateReference(parsed, noteId, referenceId);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
default:
|
|
346
|
+
process.stdout.write('Uso: memoring reference <list|update> [--json]\n' +
|
|
347
|
+
' list <noteId> referências de material de um card (fontes de estudo)\n' +
|
|
348
|
+
' — GET /notes/{noteId}/material-references; a página sai\n' +
|
|
349
|
+
' 1-based e o DESTAQUE mostra se há quote ou região\n' +
|
|
350
|
+
' update <noteId> <referenceId> corrige excerpt/quote/contextBefore/contextAfter/region\n' +
|
|
351
|
+
' --file parcial.json (merge: campo ausente mantém o atual; null ou string\n' +
|
|
352
|
+
' vazia LIMPA; region valida x,y,w,h em 0..1, w/h > 0).\n' +
|
|
353
|
+
' Não altera nota, arquivo nem página. --json devolve a\n' +
|
|
354
|
+
' referência atualizada\n');
|
|
355
|
+
if (sub) {
|
|
356
|
+
process.exitCode = 1;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { openAsBlob, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, extname, resolve } from 'node:path';
|
|
3
|
+
import { apiFetch, apiFetchMultipart } from '../api.js';
|
|
4
|
+
import { createDraftHandoff } from './planDrafts.js';
|
|
5
|
+
import { fail, green, printJson, stringFlag, yellow } from './shared.js';
|
|
6
|
+
import { normalized, object, readJson, } from './plan-shared.js';
|
|
7
|
+
const MAX_ANALYSIS_FILES = 10;
|
|
8
|
+
const MAX_PDF_BYTES = 50 * 1024 * 1024;
|
|
9
|
+
const ANALYSIS_POLL_INTERVAL_MS = 1_000;
|
|
10
|
+
const ANALYSIS_TIMEOUT_MS = 10 * 60 * 1_000;
|
|
11
|
+
const MATURITIES = ['BEGINNER', 'INTERMEDIATE', 'ADVANCED'];
|
|
12
|
+
export const ERROR_REASONS = ['UNKNOWN', 'FORGETTING', 'CONCEPTUAL_CONFUSION', 'INATTENTION', 'INTERPRETATION', 'TIME_SHORTAGE'];
|
|
13
|
+
export const PLAN_SCHEMA = {
|
|
14
|
+
version: 1,
|
|
15
|
+
description: 'Contrato editável de memoring plan analyze/create. A análise usa IA somente no backend; revise o rascunho antes de criar.',
|
|
16
|
+
maturity: {
|
|
17
|
+
BEGINNER: 'Iniciante',
|
|
18
|
+
INTERMEDIATE: 'Intermediário',
|
|
19
|
+
ADVANCED: 'Avançado',
|
|
20
|
+
},
|
|
21
|
+
errorReasons: ERROR_REASONS,
|
|
22
|
+
plan: {
|
|
23
|
+
name: 'Planejamento para o concurso',
|
|
24
|
+
objective: 'Objetivo revisável do planejamento',
|
|
25
|
+
weeklyMinutes: 600,
|
|
26
|
+
activeSubjectLimit: 4,
|
|
27
|
+
programs: [
|
|
28
|
+
{
|
|
29
|
+
subjectName: 'Direito Constitucional',
|
|
30
|
+
maturity: 'BEGINNER',
|
|
31
|
+
relevance: 3,
|
|
32
|
+
deckId: null,
|
|
33
|
+
materials: [
|
|
34
|
+
{
|
|
35
|
+
name: 'constitucional.pdf',
|
|
36
|
+
pageCount: 120,
|
|
37
|
+
charCount: 240000,
|
|
38
|
+
confidence: 0.85,
|
|
39
|
+
warnings: [],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
topics: [
|
|
43
|
+
{
|
|
44
|
+
title: 'Princípios fundamentais',
|
|
45
|
+
materialIndex: 0,
|
|
46
|
+
prerequisiteTopicIndex: null,
|
|
47
|
+
sourceName: 'constitucional.pdf',
|
|
48
|
+
sourceRef: null,
|
|
49
|
+
tecNotebookId: null,
|
|
50
|
+
pageFrom: 1,
|
|
51
|
+
pageTo: 12,
|
|
52
|
+
charCount: 24000,
|
|
53
|
+
estimatedMinutes: 50,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
exam: {
|
|
60
|
+
planId: 123,
|
|
61
|
+
name: 'Simulado 1',
|
|
62
|
+
examDate: new Date().toISOString().slice(0, 10),
|
|
63
|
+
totalQuestions: 40,
|
|
64
|
+
correctAnswers: 28,
|
|
65
|
+
subjectResults: [{ subjectName: 'Direito Constitucional', totalQuestions: 20, correctAnswers: 15 }],
|
|
66
|
+
},
|
|
67
|
+
notes: [
|
|
68
|
+
'maturity é obrigatória em toda disciplina; os rótulos Iniciante, Intermediário e Avançado também são aceitos no arquivo.',
|
|
69
|
+
'materialIndex e prerequisiteTopicIndex são índices 0-based dentro da mesma disciplina.',
|
|
70
|
+
'next sem handoff exige --activities N (1..200), correspondente ao activityTarget da nova Meta.',
|
|
71
|
+
'analyze --web e next --web criam apenas rascunhos; create/next --draft exigem status REVIEWED.',
|
|
72
|
+
'review <draftId> [--wait] [--out payload.json] lê o status e exporta somente o payload já revisado.',
|
|
73
|
+
'result usa --total N --correct N [--seconds N] [--errors FORGETTING:2,INATTENTION:1].',
|
|
74
|
+
'Regravação sem --seconds/--errors preserva os valores atuais; no backend, errors omitido preserva e errors: [] limpa.',
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
function maturity(value, programIndex) {
|
|
78
|
+
if (typeof value !== 'string') {
|
|
79
|
+
fail(`programs[${programIndex}].maturity é obrigatória: use BEGINNER, INTERMEDIATE ou ADVANCED.`);
|
|
80
|
+
}
|
|
81
|
+
const key = normalized(value);
|
|
82
|
+
const aliases = {
|
|
83
|
+
BEGINNER: 'BEGINNER',
|
|
84
|
+
INICIANTE: 'BEGINNER',
|
|
85
|
+
INTERMEDIATE: 'INTERMEDIATE',
|
|
86
|
+
INTERMEDIARIO: 'INTERMEDIATE',
|
|
87
|
+
ADVANCED: 'ADVANCED',
|
|
88
|
+
AVANCADO: 'ADVANCED',
|
|
89
|
+
};
|
|
90
|
+
const parsed = aliases[key];
|
|
91
|
+
if (!parsed) {
|
|
92
|
+
fail(`programs[${programIndex}].maturity inválida: use Iniciante, Intermediário ou Avançado.`);
|
|
93
|
+
}
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
export function planPayload(value) {
|
|
97
|
+
const body = object(value, 'a proposta deve ser um objeto JSON.');
|
|
98
|
+
if (typeof body.name !== 'string' || body.name.trim() === '') {
|
|
99
|
+
fail('a proposta precisa de name não vazio.');
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(body.programs) || body.programs.length < 1 || body.programs.length > 8) {
|
|
102
|
+
fail('a proposta precisa de programs com 1 a 8 disciplinas.');
|
|
103
|
+
}
|
|
104
|
+
const programs = body.programs.map((programValue, index) => {
|
|
105
|
+
const program = object(programValue, `programs[${index}] deve ser um objeto.`);
|
|
106
|
+
if (typeof program.subjectName !== 'string' || program.subjectName.trim() === '') {
|
|
107
|
+
fail(`programs[${index}].subjectName é obrigatório.`);
|
|
108
|
+
}
|
|
109
|
+
if (!Array.isArray(program.topics) || program.topics.length === 0) {
|
|
110
|
+
fail(`programs[${index}] precisa de ao menos um assunto em topics.`);
|
|
111
|
+
}
|
|
112
|
+
return { ...program, maturity: maturity(program.maturity, index) };
|
|
113
|
+
});
|
|
114
|
+
return { ...body, name: body.name.trim(), programs };
|
|
115
|
+
}
|
|
116
|
+
export function planBody(file) {
|
|
117
|
+
return planPayload(readJson(file));
|
|
118
|
+
}
|
|
119
|
+
function analysisDraft(proposal) {
|
|
120
|
+
const materialsByName = new Map(proposal.materials.map((material) => [material.sourceName, material]));
|
|
121
|
+
const programs = proposal.subjects.map((subject, subjectIndex) => {
|
|
122
|
+
const sourceNames = [...new Set(subject.topics.map((topic) => topic.sourceName))];
|
|
123
|
+
const materials = sourceNames.map((sourceName, materialIndex) => {
|
|
124
|
+
const source = materialsByName.get(sourceName);
|
|
125
|
+
const topics = subject.topics.filter((topic) => topic.sourceName === sourceName);
|
|
126
|
+
const confidence = topics.length
|
|
127
|
+
? Math.round((topics.reduce((sum, topic) => sum + topic.confidence, 0) / topics.length) * 100) / 100
|
|
128
|
+
: undefined;
|
|
129
|
+
const warnings = [
|
|
130
|
+
...(subjectIndex === 0 && materialIndex === 0 ? (proposal.warnings ?? []) : []),
|
|
131
|
+
...(subject.warnings ?? []),
|
|
132
|
+
...topics.flatMap((topic) => topic.warnings ?? []),
|
|
133
|
+
];
|
|
134
|
+
return {
|
|
135
|
+
name: sourceName,
|
|
136
|
+
pageCount: source?.pageCount ?? 0,
|
|
137
|
+
charCount: source?.charCount ?? topics.reduce((sum, topic) => sum + topic.charCount, 0),
|
|
138
|
+
confidence,
|
|
139
|
+
warnings: [...new Set(warnings)],
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
subjectName: subject.name,
|
|
144
|
+
maturity: 'BEGINNER',
|
|
145
|
+
relevance: 3,
|
|
146
|
+
materials,
|
|
147
|
+
topics: subject.topics.map((topic) => ({
|
|
148
|
+
title: topic.title,
|
|
149
|
+
materialIndex: Math.max(0, sourceNames.indexOf(topic.sourceName)),
|
|
150
|
+
sourceName: topic.sourceName,
|
|
151
|
+
pageFrom: topic.pageFrom,
|
|
152
|
+
pageTo: topic.pageTo,
|
|
153
|
+
charCount: topic.charCount,
|
|
154
|
+
estimatedMinutes: topic.estimatedMinutes,
|
|
155
|
+
})),
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
name: 'Planejamento de estudos',
|
|
160
|
+
activeSubjectLimit: Math.min(4, programs.length),
|
|
161
|
+
programs,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async function pollAnalysis(jobId) {
|
|
165
|
+
const deadline = Date.now() + ANALYSIS_TIMEOUT_MS;
|
|
166
|
+
while (Date.now() < deadline) {
|
|
167
|
+
const job = (await apiFetch(`/planning/analysis/${jobId}`));
|
|
168
|
+
if (job.status === 'PROPOSED') {
|
|
169
|
+
if (!job.proposal) {
|
|
170
|
+
fail('a análise terminou sem uma proposta.');
|
|
171
|
+
}
|
|
172
|
+
return job;
|
|
173
|
+
}
|
|
174
|
+
if (job.status === 'FAILED') {
|
|
175
|
+
fail(`a análise falhou: ${job.error ?? 'erro não informado pelo servidor'}`);
|
|
176
|
+
}
|
|
177
|
+
if (job.status !== 'RUNNING') {
|
|
178
|
+
fail(`status desconhecido da análise: ${String(job.status)}`);
|
|
179
|
+
}
|
|
180
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, ANALYSIS_POLL_INTERVAL_MS));
|
|
181
|
+
}
|
|
182
|
+
fail('a análise demorou mais de 10 minutos; tente consultar novamente com PDFs menores.');
|
|
183
|
+
}
|
|
184
|
+
export async function analyze(parsed, files) {
|
|
185
|
+
if (files.length === 0) {
|
|
186
|
+
fail('informe ao menos um PDF: memoring plan analyze <arquivo.pdf...>.');
|
|
187
|
+
}
|
|
188
|
+
if (files.length > MAX_ANALYSIS_FILES) {
|
|
189
|
+
fail(`envie no máximo ${MAX_ANALYSIS_FILES} PDFs por análise.`);
|
|
190
|
+
}
|
|
191
|
+
const fileNames = new Set();
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
const fileName = basename(resolve(file));
|
|
194
|
+
if (fileNames.has(fileName)) {
|
|
195
|
+
fail(`nome de arquivo duplicado: ${fileName}; renomeie um dos PDFs antes do upload.`);
|
|
196
|
+
}
|
|
197
|
+
fileNames.add(fileName);
|
|
198
|
+
}
|
|
199
|
+
const uploads = await Promise.all(files.map(async (file) => {
|
|
200
|
+
const path = resolve(file);
|
|
201
|
+
if (extname(path).toLowerCase() !== '.pdf') {
|
|
202
|
+
fail(`tipo de arquivo não suportado: ${file} (envie somente PDFs).`);
|
|
203
|
+
}
|
|
204
|
+
let size;
|
|
205
|
+
try {
|
|
206
|
+
size = statSync(path).size;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
fail(`não foi possível abrir ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
210
|
+
}
|
|
211
|
+
if (size === 0) {
|
|
212
|
+
fail(`o PDF está vazio: ${file}.`);
|
|
213
|
+
}
|
|
214
|
+
if (size > MAX_PDF_BYTES) {
|
|
215
|
+
fail(`PDF acima de 50 MB: ${file}.`);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
fieldName: 'files',
|
|
219
|
+
fileName: basename(path),
|
|
220
|
+
contentType: 'application/pdf',
|
|
221
|
+
data: await openAsBlob(path, { type: 'application/pdf' }),
|
|
222
|
+
};
|
|
223
|
+
}));
|
|
224
|
+
if (!parsed.flags.json) {
|
|
225
|
+
process.stderr.write(`Enviando ${uploads.length} PDF(s) para análise no Memoring…\n`);
|
|
226
|
+
}
|
|
227
|
+
const started = (await apiFetchMultipart('/planning/analysis', uploads));
|
|
228
|
+
if (!started.jobId) {
|
|
229
|
+
fail('o servidor não devolveu o jobId da análise.');
|
|
230
|
+
}
|
|
231
|
+
const job = await pollAnalysis(started.jobId);
|
|
232
|
+
const draft = analysisDraft(job.proposal);
|
|
233
|
+
const output = stringFlag(parsed.flags, 'out');
|
|
234
|
+
if (output) {
|
|
235
|
+
writeFileSync(resolve(output), `${JSON.stringify(draft, null, 2)}\n`, 'utf8');
|
|
236
|
+
}
|
|
237
|
+
if (parsed.flags.web) {
|
|
238
|
+
await createDraftHandoff('PLAN', null, draft, parsed);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (parsed.flags.json) {
|
|
242
|
+
printJson(draft);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const subjects = job.proposal.subjects.length;
|
|
246
|
+
const topics = job.proposal.subjects.reduce((sum, subject) => sum + subject.topics.length, 0);
|
|
247
|
+
process.stdout.write(green(`Análise concluída: ${subjects} disciplina(s), ${topics} assunto(s).\n`));
|
|
248
|
+
if (output) {
|
|
249
|
+
process.stdout.write(`Proposta salva em ${output}.\n`);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
process.stdout.write('Use --out proposta.json para salvar a proposta editável.\n');
|
|
253
|
+
}
|
|
254
|
+
process.stdout.write(yellow('Revise nome, maturidade de cada disciplina, tempo semanal, baralhos e assuntos antes de criar.\n') +
|
|
255
|
+
`Depois rode: memoring plan create --file ${output ?? 'proposta.json'}\n`);
|
|
256
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { apiFetch } from '../api.js';
|
|
2
|
+
import { bold, printJson, printTable } from './shared.js';
|
|
3
|
+
import { ERROR_LABELS, STATUS_LABELS, formatPercent, formatSeconds, } from './plan-shared.js';
|
|
4
|
+
export async function diagnostics(sub, planId, json) {
|
|
5
|
+
const data = (await apiFetch(`/planning/${planId}/${sub}`));
|
|
6
|
+
if (json) {
|
|
7
|
+
printJson(data);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (sub === 'performance') {
|
|
11
|
+
process.stdout.write(bold(`Desempenho do planejamento #${planId}\n`));
|
|
12
|
+
process.stdout.write(`${String(data.correctCount)}/${String(data.questionCount)} corretas — ${formatPercent(data.accuracyPercent)}; ` +
|
|
13
|
+
`${String(data.errorCount)} erros; ${formatSeconds(data.actualSeconds)} registrados.\n`);
|
|
14
|
+
const subjects = Array.isArray(data.subjects) ? data.subjects : [];
|
|
15
|
+
printTable(subjects.map((subject) => ({
|
|
16
|
+
subject: subject.subjectName,
|
|
17
|
+
result: `${String(subject.correctCount)}/${String(subject.questionCount)}`,
|
|
18
|
+
accuracy: formatPercent(subject.accuracyPercent),
|
|
19
|
+
average: formatSeconds(subject.averageSecondsPerQuestion),
|
|
20
|
+
})), [
|
|
21
|
+
{ key: 'subject', label: 'DISCIPLINA' },
|
|
22
|
+
{ key: 'result', label: 'ACERTOS' },
|
|
23
|
+
{ key: 'accuracy', label: 'DESEMPENHO' },
|
|
24
|
+
{ key: 'average', label: 'TEMPO/QUESTÃO' },
|
|
25
|
+
]);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (sub === 'error-patterns') {
|
|
29
|
+
process.stdout.write(bold(`Padrões de erro do planejamento #${planId}\n`));
|
|
30
|
+
process.stdout.write(`${String(data.totalErrors)} erros; ${String(data.classifiedErrors)} classificados ` +
|
|
31
|
+
`(${formatPercent(data.classificationCoveragePercent)}); ${String(data.unclassifiedErrors)} sem classificação.\n`);
|
|
32
|
+
const reasons = Array.isArray(data.reasons) ? data.reasons : [];
|
|
33
|
+
printTable(reasons.map((entry) => ({
|
|
34
|
+
reason: ERROR_LABELS[String(entry.reason)] ?? entry.reason,
|
|
35
|
+
count: entry.count,
|
|
36
|
+
})), [{ key: 'reason', label: 'MOTIVO' }, { key: 'count', label: 'ERROS' }]);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (sub === 'comparison') {
|
|
40
|
+
process.stdout.write(bold(`Comparação do planejamento #${planId}\n`));
|
|
41
|
+
process.stdout.write(`Meta atual: ${formatPercent(data.currentAccuracyPercent)}\n`);
|
|
42
|
+
process.stdout.write(`Média das Metas anteriores: ${formatPercent(data.previousAverageAccuracyPercent)}\n`);
|
|
43
|
+
process.stdout.write(`Referência: ${formatPercent(data.referenceAccuracyPercent)}\n`);
|
|
44
|
+
process.stdout.write(`Variação: ${typeof data.deltaFromPreviousPercent === 'number' ? `${data.deltaFromPreviousPercent >= 0 ? '+' : ''}${data.deltaFromPreviousPercent.toFixed(2).replace('.', ',')} p.p. vs anterior` : '—'}; ` +
|
|
45
|
+
`${typeof data.deltaFromReferencePercent === 'number' ? `${data.deltaFromReferencePercent >= 0 ? '+' : ''}${data.deltaFromReferencePercent.toFixed(2).replace('.', ',')} p.p. vs referência` : '—'}.\n`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const goals = Array.isArray(data.goals) ? data.goals : [];
|
|
49
|
+
process.stdout.write(bold(`Jornada do planejamento #${planId}\n`));
|
|
50
|
+
printTable(goals.map((goal) => ({
|
|
51
|
+
goal: `#${String(goal.goalNumber)}`,
|
|
52
|
+
status: STATUS_LABELS[String(goal.status)] ?? goal.status,
|
|
53
|
+
activities: `${String(goal.completedActivities)}/${String(goal.activityTarget)}`,
|
|
54
|
+
result: `${String(goal.correctCount)}/${String(goal.questionCount)}`,
|
|
55
|
+
accuracy: formatPercent(goal.accuracyPercent),
|
|
56
|
+
seconds: formatSeconds(goal.actualSeconds),
|
|
57
|
+
})), [
|
|
58
|
+
{ key: 'goal', label: 'META' },
|
|
59
|
+
{ key: 'status', label: 'STATUS' },
|
|
60
|
+
{ key: 'activities', label: 'ATIVIDADES' },
|
|
61
|
+
{ key: 'result', label: 'ACERTOS' },
|
|
62
|
+
{ key: 'accuracy', label: 'DESEMPENHO' },
|
|
63
|
+
{ key: 'seconds', label: 'TEMPO' },
|
|
64
|
+
]);
|
|
65
|
+
}
|