@memoring/cli 0.13.0 → 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.
@@ -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
+ }
@@ -0,0 +1,122 @@
1
+ import { apiFetch } from '../api.js';
2
+ import { fail, green, printJson, printTable, stringFlag } from './shared.js';
3
+ import { formatPercent, integerFlag, object, positiveId, readJson } from './plan-shared.js';
4
+ function examBody(parsed) {
5
+ const file = stringFlag(parsed.flags, 'file');
6
+ if (file) {
7
+ return object(readJson(file), 'a prova deve ser um objeto JSON.');
8
+ }
9
+ const name = stringFlag(parsed.flags, 'name');
10
+ const examDate = stringFlag(parsed.flags, 'date');
11
+ if (!name || !examDate) {
12
+ fail('informe --file prova.json ou --name, --date, --total e --correct.');
13
+ }
14
+ const planId = integerFlag(parsed.flags, 'plan-id', { min: 1 });
15
+ const totalQuestions = integerFlag(parsed.flags, 'total', { required: true, min: 1 });
16
+ const correctAnswers = integerFlag(parsed.flags, 'correct', { required: true, min: 0 });
17
+ const subjectsRaw = stringFlag(parsed.flags, 'subjects');
18
+ let subjectResults;
19
+ if (subjectsRaw !== undefined) {
20
+ try {
21
+ subjectResults = JSON.parse(subjectsRaw);
22
+ }
23
+ catch (error) {
24
+ fail(`--subjects deve ser um array JSON válido: ${error instanceof Error ? error.message : String(error)}`);
25
+ }
26
+ }
27
+ return {
28
+ ...(planId !== undefined ? { planId } : {}),
29
+ name,
30
+ examDate,
31
+ totalQuestions,
32
+ correctAnswers,
33
+ ...(subjectResults !== undefined ? { subjectResults } : {}),
34
+ };
35
+ }
36
+ function validateExam(value) {
37
+ if (typeof value.name !== 'string' || value.name.trim() === '') {
38
+ fail('a prova precisa de name não vazio.');
39
+ }
40
+ if (typeof value.examDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.examDate) || Number.isNaN(Date.parse(`${value.examDate}T00:00:00Z`))) {
41
+ fail('examDate deve estar no formato YYYY-MM-DD.');
42
+ }
43
+ for (const [field, min] of [['totalQuestions', 1], ['correctAnswers', 0]]) {
44
+ const fieldValue = value[field];
45
+ if (typeof fieldValue !== 'number' || !Number.isInteger(fieldValue) || fieldValue < min) {
46
+ fail(`${field} deve ser um inteiro >= ${min}.`);
47
+ }
48
+ }
49
+ if (value.correctAnswers > value.totalQuestions) {
50
+ fail('correctAnswers não pode superar totalQuestions.');
51
+ }
52
+ if (value.planId !== undefined && value.planId !== null &&
53
+ (typeof value.planId !== 'number' || !Number.isInteger(value.planId) || value.planId < 1)) {
54
+ fail('planId deve ser um inteiro positivo.');
55
+ }
56
+ if (value.subjectResults !== undefined && !Array.isArray(value.subjectResults)) {
57
+ fail('subjectResults deve ser um array.');
58
+ }
59
+ return value;
60
+ }
61
+ export async function examsCommand(parsed, rest) {
62
+ const [sub, ...args] = rest;
63
+ switch (sub) {
64
+ case 'list': {
65
+ const exams = (await apiFetch('/planning/exams'));
66
+ if (parsed.flags.json) {
67
+ printJson(exams);
68
+ return;
69
+ }
70
+ if (exams.length === 0) {
71
+ process.stdout.write('Nenhuma prova registrada.\n');
72
+ return;
73
+ }
74
+ printTable(exams.map((exam) => ({
75
+ id: `#${String(exam.id)}`,
76
+ name: exam.name,
77
+ date: exam.examDate,
78
+ result: `${String(exam.correctAnswers)}/${String(exam.totalQuestions)}`,
79
+ accuracy: formatPercent(exam.accuracyPercent),
80
+ plan: exam.planId ? `#${String(exam.planId)}` : '—',
81
+ })), [
82
+ { key: 'id', label: 'ID' },
83
+ { key: 'name', label: 'PROVA' },
84
+ { key: 'date', label: 'DATA' },
85
+ { key: 'result', label: 'ACERTOS' },
86
+ { key: 'accuracy', label: 'DESEMPENHO' },
87
+ { key: 'plan', label: 'PLANO' },
88
+ ]);
89
+ return;
90
+ }
91
+ case 'create': {
92
+ const body = validateExam(examBody(parsed));
93
+ const exam = await apiFetch('/planning/exams', { method: 'POST', body });
94
+ if (parsed.flags.json) {
95
+ printJson(exam);
96
+ return;
97
+ }
98
+ const created = object(exam, 'resposta inválida ao criar prova.');
99
+ process.stdout.write(green(`Prova "${String(created.name)}" registrada (#${String(created.id)}).\n`));
100
+ return;
101
+ }
102
+ case 'delete': {
103
+ const examId = positiveId(args[0], 'examId');
104
+ await apiFetch(`/planning/exams/${examId}`, { method: 'DELETE' });
105
+ if (parsed.flags.json) {
106
+ printJson({ deleted: true, examId });
107
+ return;
108
+ }
109
+ process.stdout.write(green(`Prova #${examId} excluída.\n`));
110
+ return;
111
+ }
112
+ default:
113
+ process.stdout.write('Uso: memoring plan exams <list|create|delete>\n' +
114
+ ' list\n' +
115
+ ' create --file prova.json\n' +
116
+ ' create --name "Simulado 1" --date YYYY-MM-DD --total N --correct N [--plan-id N] [--subjects JSON]\n' +
117
+ ' delete <examId>\n');
118
+ if (sub) {
119
+ process.exitCode = 1;
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,131 @@
1
+ import { apiFetch } from '../api.js';
2
+ import { createDraftHandoff, deleteDraftAfterCreation, readPlanningDraft, requireReviewed, } from './planDrafts.js';
3
+ import { fail, printJson, stringFlag } from './shared.js';
4
+ import { formatPercent, integerFlag, normalized, printActivity, printGoal, } from './plan-shared.js';
5
+ import { ERROR_REASONS } from './plan-analyze.js';
6
+ export async function goalCommand(sub, planId, parsed) {
7
+ let details;
8
+ if (sub === 'current') {
9
+ details = (await apiFetch(`/planning/plans/${planId}/goals/current`));
10
+ }
11
+ else if (parsed.flags.web) {
12
+ if (parsed.flags.draft !== undefined || parsed.flags.activities !== undefined) {
13
+ fail('next --web não aceita --draft nem --activities; a Meta será definida na revisão web.');
14
+ }
15
+ await createDraftHandoff('GOAL', planId, {}, parsed);
16
+ return;
17
+ }
18
+ else {
19
+ let reviewedDraft = null;
20
+ let activityTarget;
21
+ if (parsed.flags.draft !== undefined) {
22
+ if (parsed.flags.activities !== undefined) {
23
+ fail('use --draft ou --activities, não os dois.');
24
+ }
25
+ reviewedDraft = await readPlanningDraft(stringFlag(parsed.flags, 'draft'));
26
+ requireReviewed(reviewedDraft, 'GOAL', planId);
27
+ const value = reviewedDraft.payload.activityTarget;
28
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > 200) {
29
+ fail('o payload revisado precisa de activityTarget inteiro entre 1 e 200.');
30
+ }
31
+ activityTarget = value;
32
+ }
33
+ else {
34
+ activityTarget = integerFlag(parsed.flags, 'activities', { required: true, min: 1, max: 200 });
35
+ }
36
+ details = (await apiFetch(`/planning/plans/${planId}/goals`, {
37
+ method: 'POST',
38
+ body: { activityTarget },
39
+ }));
40
+ if (reviewedDraft) {
41
+ await deleteDraftAfterCreation(reviewedDraft);
42
+ }
43
+ }
44
+ if (parsed.flags.json) {
45
+ printJson(details);
46
+ return;
47
+ }
48
+ if (!details) {
49
+ process.stdout.write(`Nenhuma Meta ativa no planejamento #${planId}. Gere uma com \`memoring plan next ${planId} --activities N\`.\n`);
50
+ return;
51
+ }
52
+ printGoal(details, sub === 'next' ? 'Meta gerada' : 'Meta atual');
53
+ }
54
+ function errorReason(raw) {
55
+ const key = normalized(raw);
56
+ const aliases = {
57
+ UNKNOWN: 'UNKNOWN',
58
+ DESCONHECIDO: 'UNKNOWN',
59
+ NAO_IDENTIFICADO: 'UNKNOWN',
60
+ FORGETTING: 'FORGETTING',
61
+ ESQUECIMENTO: 'FORGETTING',
62
+ CONCEPTUAL_CONFUSION: 'CONCEPTUAL_CONFUSION',
63
+ CONFUSAO_CONCEITUAL: 'CONCEPTUAL_CONFUSION',
64
+ INATTENTION: 'INATTENTION',
65
+ DESATENCAO: 'INATTENTION',
66
+ INTERPRETATION: 'INTERPRETATION',
67
+ INTERPRETACAO: 'INTERPRETATION',
68
+ TIME_SHORTAGE: 'TIME_SHORTAGE',
69
+ FALTA_DE_TEMPO: 'TIME_SHORTAGE',
70
+ };
71
+ const reason = aliases[key];
72
+ if (!reason) {
73
+ fail(`motivo de erro desconhecido: ${raw}. Use ${ERROR_REASONS.join(', ')}.`);
74
+ }
75
+ return reason;
76
+ }
77
+ function parseErrors(raw, incorrectCount) {
78
+ if (raw === undefined) {
79
+ return undefined;
80
+ }
81
+ const entries = raw.split(/[,;]/).map((entry) => entry.trim()).filter(Boolean);
82
+ if (entries.length === 0) {
83
+ fail('--errors vazio; use motivo:N separado por vírgulas.');
84
+ }
85
+ const seen = new Set();
86
+ const errors = entries.map((entry) => {
87
+ const match = /^(.+):(\d+)$/.exec(entry);
88
+ if (!match) {
89
+ fail(`--errors inválido em ${JSON.stringify(entry)}; use motivo:N (ex.: FORGETTING:2).`);
90
+ }
91
+ const reason = errorReason(match[1]);
92
+ const errorCount = Number(match[2]);
93
+ if (errorCount < 1) {
94
+ fail(`a contagem de ${reason} deve ser pelo menos 1.`);
95
+ }
96
+ if (seen.has(reason)) {
97
+ fail(`o motivo ${reason} aparece mais de uma vez em --errors.`);
98
+ }
99
+ seen.add(reason);
100
+ return { reason, errorCount };
101
+ });
102
+ const classified = errors.reduce((sum, error) => sum + error.errorCount, 0);
103
+ if (classified > incorrectCount) {
104
+ fail(`a soma de --errors (${classified}) não pode superar os ${incorrectCount} erros da atividade.`);
105
+ }
106
+ return errors;
107
+ }
108
+ export async function recordResult(activityId, parsed) {
109
+ const questionCount = integerFlag(parsed.flags, 'total', { required: true, min: 1 });
110
+ const correctCount = integerFlag(parsed.flags, 'correct', { required: true, min: 0 });
111
+ const actualSeconds = integerFlag(parsed.flags, 'seconds', { min: 0 });
112
+ if (correctCount > questionCount) {
113
+ fail('--correct não pode superar --total.');
114
+ }
115
+ const errors = parseErrors(stringFlag(parsed.flags, 'errors'), questionCount - correctCount);
116
+ const activity = (await apiFetch(`/planning/activities/${activityId}/result`, {
117
+ method: 'POST',
118
+ body: {
119
+ questionCount,
120
+ correctCount,
121
+ ...(actualSeconds !== undefined ? { actualSeconds } : {}),
122
+ ...(errors !== undefined ? { errors } : {}),
123
+ },
124
+ }));
125
+ if (parsed.flags.json) {
126
+ printJson(activity);
127
+ return;
128
+ }
129
+ printActivity(activity, 'Resultado registrado');
130
+ process.stdout.write(` ${correctCount}/${questionCount} corretas (${formatPercent((100 * correctCount) / questionCount)}).\n`);
131
+ }