aimakeall-mcp 0.14.2 → 0.14.4
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 +22 -0
- package/lib/api-client.mjs +21 -4
- package/lib/cloud-tools.mjs +11 -6
- package/lib/config.mjs +1 -1
- package/lib/durable-channel-client.mjs +30 -0
- package/lib/durable-channel-contract.mjs +60 -0
- package/lib/durable-channel-journal.mjs +53 -0
- package/lib/durable-channel-request.mjs +375 -0
- package/lib/durable-speech.mjs +16 -2
- package/lib/review-full-analysis.mjs +184 -21
- package/lib/review-native-job-request.mjs +329 -0
- package/lib/review-story-job-request.mjs +131 -0
- package/lib/review-tools.mjs +4 -4
- package/lib/speech-production-profile.mjs +21 -0
- package/lib/tts-alignment.mjs +69 -0
- package/package.json +1 -1
|
@@ -4,6 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
import { savePayloadHandle } from "./media-store.mjs";
|
|
5
5
|
import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
|
|
6
6
|
import { inspectReviewLocalFile, readReviewHandle, reviewPublicSources, validateReviewSources } from "./review-sources.mjs";
|
|
7
|
+
import { createReviewStoryJobClient, REVIEW_STORY_TRANSPORT } from "./review-story-job-request.mjs";
|
|
8
|
+
import { createReviewNativeJobClient, REVIEW_NATIVE_TRANSPORT, unwrapReviewNativeJobResult } from "./review-native-job-request.mjs";
|
|
9
|
+
import { DEFAULT_API_BASE } from "./config.mjs";
|
|
10
|
+
import { matchesApiClientAuthority } from "./api-client.mjs";
|
|
7
11
|
|
|
8
12
|
const KIND = "aimakeall-review-full-analysis";
|
|
9
13
|
const MAX_CHUNK_BYTES = 10 * 1024 * 1024;
|
|
@@ -68,14 +72,64 @@ function lockAnalysis(config, analysisId) {
|
|
|
68
72
|
function receipt(value, stage, chunkId = "") {
|
|
69
73
|
return { stage, chunkId, ...Object.fromEntries(RECEIPT_KEYS.filter(key => value?.[key] !== undefined).map(key => [key, value[key]])) };
|
|
70
74
|
}
|
|
75
|
+
function upsertStoryReceipt(state, value, operationId) {
|
|
76
|
+
upsertStageReceipt(state, value, operationId, "synthesizing");
|
|
77
|
+
}
|
|
78
|
+
function upsertStageReceipt(state, value, operationId, stage, chunkId = "") {
|
|
79
|
+
const item = { ...receipt(value, stage, chunkId), clientOperationId: operationId };
|
|
80
|
+
const index = state.receipts.findIndex(row => row.clientOperationId === operationId);
|
|
81
|
+
if (index < 0) state.receipts.push(item);
|
|
82
|
+
else {
|
|
83
|
+
const previous = state.receipts[index];
|
|
84
|
+
const unique = field => [...new Map([...(previous[field] || []), ...(item[field] || [])]
|
|
85
|
+
.map(event => [event.accountCostEventId || event.eventId || event.id || JSON.stringify(event), event])).values()];
|
|
86
|
+
state.receipts[index] = { ...previous, ...item, usage: item.usage || previous.usage,
|
|
87
|
+
operationId: item.operationId || previous.operationId, costUsd: Math.max(Number(previous.costUsd) || 0, Number(item.costUsd) || 0),
|
|
88
|
+
accountCostEvents: unique("accountCostEvents"), acceptedTaskCosts: unique("acceptedTaskCosts") };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function authorityBinding(config) {
|
|
92
|
+
const scope = new URL(config.apiBase || DEFAULT_API_BASE).href.replace(/\/$/u, "");
|
|
93
|
+
return { apiScope: scope, authorityFingerprint: createHash("sha256").update(JSON.stringify([scope, config.pat || ""])).digest("hex") };
|
|
94
|
+
}
|
|
95
|
+
function assertCheckpointAuthority(config, state) {
|
|
96
|
+
const current = authorityBinding(config);
|
|
97
|
+
if (!state.authorityBinding && (state.inFlight || state.pendingStageResult || state.receipts?.length || Object.keys(state.analyses || {}).length)) {
|
|
98
|
+
throw fail("REVIEW_CHECKPOINT_AUTHORITY_MISSING", "이전 분석 기록에 서버·계정 연결 증명이 없습니다. 기존 파일의 로컬 확인만 가능하며 현재 PAT로 결과를 적용하거나 유료 요청을 다시 보내지 않습니다.");
|
|
99
|
+
}
|
|
100
|
+
if (state.authorityBinding && (state.authorityBinding.apiScope !== current.apiScope || state.authorityBinding.authorityFingerprint !== current.authorityFingerprint)) {
|
|
101
|
+
throw fail("REVIEW_CHECKPOINT_AUTHORITY_CHANGED", "분석을 시작한 서버와 PAT로 기존 작업을 확인해 주세요. 계정이 변경된 상태에서는 업로드·유료 요청·결과 조회를 하지 않습니다.");
|
|
102
|
+
}
|
|
103
|
+
state.authorityBinding ||= current;
|
|
104
|
+
}
|
|
105
|
+
function storyClient(config, api) {
|
|
106
|
+
return createReviewStoryJobClient({ apiScope: config.apiBase || DEFAULT_API_BASE,
|
|
107
|
+
getAuthority: () => transportAuthority(config, api),
|
|
108
|
+
request: (route, options) => api.request(route, { ...options, ...(options.body === undefined ? {} : { body: JSON.parse(options.body) }) }),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function nativeClient(config, api) {
|
|
112
|
+
return createReviewNativeJobClient({ apiScope: config.apiBase || DEFAULT_API_BASE,
|
|
113
|
+
getAuthority: () => transportAuthority(config, api),
|
|
114
|
+
request: (route, options) => api.request(route, { ...options, ...(options.body === undefined ? {} : { body: JSON.parse(options.body) }) }),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function transportAuthority(config, api) {
|
|
118
|
+
if (matchesApiClientAuthority(api, { apiBase: config.apiBase || DEFAULT_API_BASE, pat: config.pat }) === false) return null;
|
|
119
|
+
return config.pat ? JSON.stringify([config.apiBase || DEFAULT_API_BASE, config.pat]) : api;
|
|
120
|
+
}
|
|
121
|
+
function mediaDescriptor(media) {
|
|
122
|
+
const { videoBase64, ...chunk } = media.chunk;
|
|
123
|
+
return { source: media.source, chunk, ...(media.workTitle !== undefined ? { workTitle: media.workTitle } : {}) };
|
|
124
|
+
}
|
|
71
125
|
function safeStageResult(value) {
|
|
72
126
|
if (Array.isArray(value)) return value.map(safeStageResult);
|
|
73
127
|
if (!value || typeof value !== "object") return value;
|
|
74
|
-
return Object.fromEntries(Object.entries(value).filter(([key]) => !/base64|dataUrl|localPath|sourceHandle|apiKey|
|
|
128
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !/base64|dataUrl|localPath|sourceHandle|apiKey|^token$|accessToken|refreshToken|authorization|cookie/i.test(key)).map(([key, item]) => [key, safeStageResult(item)]));
|
|
75
129
|
}
|
|
76
130
|
function billing(state) {
|
|
77
131
|
const costs = state.receipts || [];
|
|
78
|
-
const unique = (field) => [...new Map(costs.flatMap(item => item[field] || []).map(item => [item.eventId || item.id || JSON.stringify(item), item])).values()];
|
|
132
|
+
const unique = (field) => [...new Map(costs.flatMap(item => item[field] || []).map(item => [item.accountCostEventId || item.eventId || item.id || JSON.stringify(item), item])).values()];
|
|
79
133
|
return { accountCostEvents: unique("accountCostEvents"), acceptedTaskCosts: unique("acceptedTaskCosts"),
|
|
80
134
|
costUsd: costs.reduce((sum, item) => sum + (Number(item.costUsd) || 0), 0), stageReceipts: costs };
|
|
81
135
|
}
|
|
@@ -94,12 +148,58 @@ function summary(state, resumePath) {
|
|
|
94
148
|
analyzedTimelineSec: analyzedMs / 1000, totalTimelineSec: state.sources.reduce((sum, item) => sum + item.durationSec, 0) },
|
|
95
149
|
sources: reviewPublicSources(state.sources), ...billing(state),
|
|
96
150
|
warnings: state.warnings || [], error: state.error || undefined,
|
|
151
|
+
...(state.inFlight?.transport === REVIEW_STORY_TRANSPORT ? { storyJob: { requestId: state.inFlight.operationId,
|
|
152
|
+
jobId: state.inFlight.jobId || null, apiScope: state.inFlight.apiScope, recovery: "read-only-known-job", paidReplayAllowed: false } } : {}),
|
|
153
|
+
...(state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT ? { nativeJob: { stage: state.inFlight.stage,
|
|
154
|
+
requestId: state.inFlight.operationId, jobId: state.inFlight.jobId || null, status: state.inFlight.jobStatus || "unknown",
|
|
155
|
+
apiScope: state.inFlight.apiScope, mediaAttemptId: state.inFlight.mediaAttempt?.attemptId || null,
|
|
156
|
+
fileState: state.inFlight.mediaReference ? "ready" : state.inFlight.mediaAttempt ? "reconciliation-required" : "not-uploaded",
|
|
157
|
+
recovery: state.inFlight.jobId ? "read-only-known-job" : state.inFlight.submissionAttempted ? "submission-unknown-no-replay" : "reconcile-upload",
|
|
158
|
+
paidReplayAllowed: false, automaticUploadRetryAllowed: false } } : {}),
|
|
159
|
+
mediaFiles: { ready: Object.keys(state.mediaReferences || {}).length, retainedForVerification: true },
|
|
160
|
+
reviewRequired: state.reviewRequired === true,
|
|
97
161
|
failedStageResultPath: state.failedStageResultPath || undefined,
|
|
98
162
|
requiresHumanReview: true, next: state.stage === "complete" ? "plan_review_video({reviewSourcePath,...})" : state.stage === "blocked" ? "실패 원인·이미 접수된 비용을 확인하세요. 자동 재시도하지 않습니다." : "analyze_review_sources({resumePath,allowCloudVideoUpload:true})로 다음 1개 단계를 계속하세요.",
|
|
99
163
|
};
|
|
100
164
|
}
|
|
101
165
|
export function fullReviewStatus(config, resumePath) { const loaded = loadFullReviewAnalysis(config, resumePath); return summary(loaded.state, loaded.resumePath); }
|
|
102
166
|
|
|
167
|
+
export async function refreshFullReviewStatus(config, api, resumePath) {
|
|
168
|
+
const loaded = loadFullReviewAnalysis(config, resumePath);
|
|
169
|
+
const release = lockAnalysis(config, loaded.state.analysisId);
|
|
170
|
+
try {
|
|
171
|
+
let { state, resumePath: latestPath } = loadFullReviewAnalysis(config, resumePath);
|
|
172
|
+
assertCheckpointAuthority(config, state);
|
|
173
|
+
const operation = state.inFlight;
|
|
174
|
+
if (![REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(operation?.transport)
|
|
175
|
+
|| operation.transport === REVIEW_STORY_TRANSPORT && !operation.jobId) return summary(state, latestPath);
|
|
176
|
+
try {
|
|
177
|
+
const job = operation.transport === REVIEW_STORY_TRANSPORT
|
|
178
|
+
? await storyClient(config, api).read(operation.jobId, { requestId: operation.operationId, apiScope: operation.apiScope })
|
|
179
|
+
: await nativeClient(config, api).read(operation.stage, { ...operation, requestId: operation.operationId,
|
|
180
|
+
onCheckpoint: async metadata => {
|
|
181
|
+
assertCheckpointAuthority(config, state); Object.assign(operation, metadata);
|
|
182
|
+
if (operation.mediaReference) (state.mediaReferences ||= {})[operation.chunkId] = operation.mediaReference;
|
|
183
|
+
latestPath = snapshot(config, state);
|
|
184
|
+
} });
|
|
185
|
+
assertCheckpointAuthority(config, state);
|
|
186
|
+
operation.jobStatus = job.status;
|
|
187
|
+
if (job.status === "completed") {
|
|
188
|
+
if (!job.result || typeof job.result !== "object" || Array.isArray(job.result)) throw fail("REVIEW_STORY_RESULT_MISSING", "저장된 줄거리 결과가 없습니다.");
|
|
189
|
+
const result = operation.transport === REVIEW_STORY_TRANSPORT ? job.result
|
|
190
|
+
: unwrapReviewNativeJobResult(operation.stage, job.result, operation.mediaReference);
|
|
191
|
+
upsertStageReceipt(state, result, operation.operationId, operation.stage, operation.chunkId);
|
|
192
|
+
state.pendingStageResult = safeStageResult(result);
|
|
193
|
+
}
|
|
194
|
+
latestPath = snapshot(config, state);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
state.error = { code: error.code || error.errorCode || "REVIEW_JOB_READ_FAILED", message: String(error.message || error) };
|
|
197
|
+
latestPath = snapshot(config, state);
|
|
198
|
+
}
|
|
199
|
+
return summary(state, latestPath);
|
|
200
|
+
} finally { release(); }
|
|
201
|
+
}
|
|
202
|
+
|
|
103
203
|
// This limits only the editing candidate list, never the full story/transcript
|
|
104
204
|
// evidence. All native analysis remains in the original immutable handle.
|
|
105
205
|
export function selectReviewPlanningCatalog(handle, requestedIds) {
|
|
@@ -188,7 +288,8 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
|
|
|
188
288
|
const health = await companionJson(config, "/health", undefined, 15000);
|
|
189
289
|
if (health.capabilities?.reviewSourceAnalysis !== true || health.capabilities?.reviewSourceChunks !== true || health.capabilities.reviewSourceChunkMaxDurationSec < 300 || health.capabilities.reviewSourceChunkMaxBytes < MAX_CHUNK_BYTES) throw fail("COMPANION_UPDATE_REQUIRED", "전체 영상·음성 분석 청크를 지원하는 로컬 처리기로 업데이트하세요. 대표 프레임으로 우회하지 않습니다.");
|
|
190
290
|
const state = { kind: KIND, version: 1, analysisId: randomUUID(), revision: 0, stage: "analyzing", workTitle: args.workTitle || "",
|
|
191
|
-
sources: [], chunks: [], analyses: {}, verifications: {}, receipts: [],
|
|
291
|
+
sources: [], chunks: [], analyses: {}, verifications: {}, receipts: [], mediaReferences: {}, authorityBinding: authorityBinding(config),
|
|
292
|
+
warnings: ["전체 구간을 압축 영상+오디오로 분석하고 서사 주장을 실제 원본 구간으로 재검증합니다. 공개 권리와 최종 해석은 사람이 확인해야 합니다."], uploadConsent: true };
|
|
192
293
|
const fingerprints = args.sources.map(source => source.kind === "local" ? inspectReviewLocalFile(source.videoPath) : null);
|
|
193
294
|
for (const [index, input] of args.sources.entries()) {
|
|
194
295
|
const id = `review-${randomUUID().slice(0, 8)}`;
|
|
@@ -209,16 +310,27 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
|
|
|
209
310
|
let state, resumePath;
|
|
210
311
|
try {
|
|
211
312
|
({ state, resumePath } = loadFullReviewAnalysis(config, args.resumePath));
|
|
212
|
-
|
|
313
|
+
assertCheckpointAuthority(config, state);
|
|
213
314
|
if (state.stage === "complete") return summary(state, resumePath);
|
|
315
|
+
if (state.reviewRequired) return { ...summary(state, resumePath), requiresExplicitRetryApproval: false };
|
|
214
316
|
if (state.inFlight || state.stage === "blocked") {
|
|
317
|
+
if ([REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(state.inFlight?.transport) || state.pendingStageResult) {
|
|
318
|
+
if (!state.inFlight || !["analyzing", "synthesizing", "verifying"].includes(state.inFlight.stage)
|
|
319
|
+
|| state.inFlight.transport === REVIEW_STORY_TRANSPORT && state.inFlight.stage !== "synthesizing") throw fail("REVIEW_JOB_STATE_INVALID", "저장된 작업 단계가 다릅니다.");
|
|
320
|
+
state.stage = state.inFlight.stage;
|
|
321
|
+
delete state.error;
|
|
322
|
+
} else {
|
|
215
323
|
if (args.retryFailedStage !== true) return { ...summary(state, resumePath), requiresExplicitRetryApproval: true };
|
|
216
324
|
state.stage = state.failedStage || state.inFlight?.stage || "analyzing";
|
|
217
325
|
delete state.inFlight; delete state.error;
|
|
326
|
+
}
|
|
218
327
|
resumePath = snapshot(config, state);
|
|
219
328
|
}
|
|
220
329
|
const refreshed = new Set();
|
|
221
330
|
const getMedia = async (chunk, expectedSha256) => {
|
|
331
|
+
// A known server job / saved result does not need the local source. Only
|
|
332
|
+
// a genuinely new media transfer or explicit legacy recheck reads it.
|
|
333
|
+
validateReviewSources(state.sources);
|
|
222
334
|
const index = state.sources.findIndex(source => source.id === chunk.sourceId);
|
|
223
335
|
try { return await videoChunk(config, companionJson, state.sources[index], chunk, expectedSha256); }
|
|
224
336
|
catch (error) {
|
|
@@ -232,41 +344,87 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
|
|
|
232
344
|
if (!onProgress) return;
|
|
233
345
|
try { await onProgress(summary(state, resumePath)); } catch { /* Progress delivery cannot replay/abort an accepted paid operation. */ }
|
|
234
346
|
};
|
|
235
|
-
const charge = async (stage, chunkId,
|
|
236
|
-
|
|
347
|
+
const charge = async (stage, chunkId, body, options = {}) => {
|
|
348
|
+
assertCheckpointAuthority(config, state);
|
|
349
|
+
const existing = state.inFlight;
|
|
350
|
+
if (existing && (existing.stage !== stage || existing.chunkId !== chunkId)) throw fail("REVIEW_JOB_STATE_INVALID", "체크포인트의 작업 구간이 다릅니다.");
|
|
351
|
+
if (state.pendingStageResult) return state.pendingStageResult;
|
|
352
|
+
state.inFlight = existing || { stage, chunkId, operationId: randomUUID(),
|
|
353
|
+
...(stage === "analyzing" ? { mediaDescriptor: mediaDescriptor(body), priorContext: body.priorContext || "" } : {}) }; state.failedStage = stage;
|
|
237
354
|
resumePath = snapshot(config, state);
|
|
238
355
|
await progress();
|
|
239
356
|
let response;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
357
|
+
const operationId = state.inFlight.operationId;
|
|
358
|
+
try {
|
|
359
|
+
response = stage === "synthesizing"
|
|
360
|
+
? await storyClient(config, api).run(body, {
|
|
361
|
+
requestId: operationId, ...(existing ? { transport: existing.transport, jobId: existing.jobId, apiScope: existing.apiScope } : {}),
|
|
362
|
+
onSubmissionAttempt: async metadata => { Object.assign(state.inFlight, metadata); resumePath = snapshot(config, state); },
|
|
363
|
+
onJobAccepted: async metadata => { Object.assign(state.inFlight, metadata); resumePath = snapshot(config, state); },
|
|
364
|
+
})
|
|
365
|
+
: await nativeClient(config, api).run(stage, body, { ...state.inFlight, requestId: operationId,
|
|
366
|
+
// A resumed operation owns its exact reference and origin. Never
|
|
367
|
+
// repair a damaged/missing binding from the analysis-wide cache.
|
|
368
|
+
...(!existing && options.mediaReference ? { mediaReference: options.mediaReference,
|
|
369
|
+
apiScope: state.authorityBinding.apiScope } : {}),
|
|
370
|
+
getLegacyMedia: options.getLegacyMedia,
|
|
371
|
+
onCheckpoint: async metadata => {
|
|
372
|
+
assertCheckpointAuthority(config, state); Object.assign(state.inFlight, metadata);
|
|
373
|
+
if (state.inFlight.mediaReference) (state.mediaReferences ||= {})[chunkId] = state.inFlight.mediaReference;
|
|
374
|
+
resumePath = snapshot(config, state);
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (stage === "synthesizing") upsertStoryReceipt(state, error, operationId);
|
|
380
|
+
else upsertStageReceipt(state, error, operationId, stage, chunkId);
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
assertCheckpointAuthority(config, state);
|
|
384
|
+
if (stage === "synthesizing") upsertStoryReceipt(state, response, operationId);
|
|
385
|
+
else upsertStageReceipt(state, response, operationId, stage, chunkId);
|
|
386
|
+
if (state.inFlight.transport) state.inFlight.jobStatus = "completed";
|
|
243
387
|
state.pendingStageResult = safeStageResult(response);
|
|
388
|
+
// Commit received output before validation/application can fail or MCP exits.
|
|
389
|
+
resumePath = snapshot(config, state);
|
|
244
390
|
return response;
|
|
245
391
|
};
|
|
246
392
|
for (let step = 0; step < (args.maxSteps ?? 1); step++) {
|
|
247
393
|
if (state.stage === "analyzing") {
|
|
248
394
|
const chunk = state.chunks.find(item => !state.analyses[item.chunkId]);
|
|
249
395
|
if (!chunk) { state.stage = "synthesizing"; resumePath = snapshot(config, state); step--; continue; }
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
396
|
+
let media;
|
|
397
|
+
if (state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT || state.pendingStageResult) {
|
|
398
|
+
media = state.inFlight.mediaDescriptor;
|
|
399
|
+
if (!media?.chunk || media.chunk.chunkId !== chunk.chunkId) throw fail("REVIEW_MEDIA_CHECKPOINT_INVALID", "저장된 미디어 구간 정보가 없습니다. 다시 인코딩하거나 업로드하지 않습니다.");
|
|
400
|
+
} else {
|
|
401
|
+
media = await getMedia(chunk); media.workTitle = state.workTitle;
|
|
402
|
+
const previous = Object.values(state.analyses).filter(item => item.sourceId === chunk.sourceId && item.startMs < chunk.startMs).sort((a, b) => b.startMs - a.startMs)[0];
|
|
403
|
+
if (previous) media.priorContext = JSON.stringify({ previousSummary: previous.summary, observedCharacters: previous.characters }).slice(0, 12000);
|
|
404
|
+
}
|
|
405
|
+
const response = await charge("analyzing", chunk.chunkId, state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT
|
|
406
|
+
? state.inFlight.jobId ? null : { priorContext: state.inFlight.priorContext || "" } : media);
|
|
255
407
|
state.analyses[chunk.chunkId] = checkedAnalysis(response, chunk, media);
|
|
256
408
|
} else if (state.stage === "synthesizing") {
|
|
257
409
|
const body = { sources: reviewPublicSources(state.sources), chunks: Object.values(state.analyses), workTitle: state.workTitle };
|
|
258
410
|
textBudget(body);
|
|
259
|
-
state.storyResult = await charge("synthesizing", "",
|
|
411
|
+
state.storyResult = await charge("synthesizing", "", body);
|
|
260
412
|
state.verifyChunkIds = verificationGroups(state);
|
|
261
413
|
state.stage = "verifying";
|
|
262
414
|
} else if (state.stage === "verifying") {
|
|
263
415
|
const id = state.verifyChunkIds.find(chunkId => !state.verifications[chunkId]);
|
|
264
416
|
if (!id) { state.stage = "finalizing"; step--; continue; }
|
|
265
417
|
const chunk = state.chunks.find(item => item.chunkId === id), analyzed = state.analyses[id];
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
418
|
+
const response = await charge("verifying", id, state.inFlight?.jobId ? null
|
|
419
|
+
: { story: state.storyResult.story, chunks: Object.values(state.analyses), chunkId: id },
|
|
420
|
+
{ mediaReference: state.mediaReferences?.[id], getLegacyMedia: async () => ({ ...await getMedia(chunk, analyzed.sha256), workTitle: state.workTitle }) });
|
|
421
|
+
if (response.chunkId !== id || !Array.isArray(response.verdicts) || !response.verdicts.length
|
|
422
|
+
|| response.verdicts.some(item => !["supported", "contradicted", "insufficient"].includes(item.status))) throw fail("REVIEW_ORIGINAL_VERIFICATION_INVALID", "저장된 원본 재검증 결과가 불완전합니다. 다시 과금하지 않습니다.");
|
|
269
423
|
state.verifications[id] = response;
|
|
424
|
+
if (response.verdicts.some(item => item.status !== "supported")) {
|
|
425
|
+
state.reviewRequired = true;
|
|
426
|
+
throw fail("REVIEW_ORIGINAL_VERIFICATION_FAILED", "원본 재검증에서 모순되거나 근거가 부족한 주장이 발견되었습니다. 판정과 비용은 저장했습니다. 자동 재검증·전체 이해 완료·자동 기획으로 진행하지 않으며 사용자의 검토가 필요합니다.");
|
|
427
|
+
}
|
|
270
428
|
} else if (state.stage === "finalizing") {
|
|
271
429
|
const completed = { kind: "aimakeall-review-sources", version: 1, analysisScope: "full", analysisId: state.analysisId,
|
|
272
430
|
sources: state.sources, sourceCatalog: Object.values(state.analyses).flatMap(chunk => chunk.sceneCatalog), globalStory: state.storyResult.story,
|
|
@@ -290,13 +448,18 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
|
|
|
290
448
|
return summary(state, resumePath);
|
|
291
449
|
} catch (error) {
|
|
292
450
|
if (!state) throw error;
|
|
451
|
+
// Never attach another account's saved costs/results to the caller or write
|
|
452
|
+
// through its journal after PAT/server changes (including mid-request).
|
|
453
|
+
assertCheckpointAuthority(config, state);
|
|
293
454
|
state.failedStage ||= state.stage; state.stage = "blocked";
|
|
294
455
|
state.error = { code: error.errorCode || error.code || "REVIEW_FULL_ANALYSIS_FAILED", message: String(error.message || error) };
|
|
295
|
-
if (state.pendingStageResult) {
|
|
456
|
+
if (state.pendingStageResult && state.inFlight?.transport !== REVIEW_STORY_TRANSPORT) {
|
|
296
457
|
state.failedStageResultPath = savePayloadHandle(config.stateDir, { kind: "aimakeall-review-failed-stage", version: 1, analysisId: state.analysisId, stage: state.failedStage, result: state.pendingStageResult }, "review-failed-stage").filePath;
|
|
297
|
-
|
|
458
|
+
// Keep the received result in the journal too: a restart must apply it,
|
|
459
|
+
// never repeat the paid request (including legacy successful responses).
|
|
298
460
|
}
|
|
299
461
|
resumePath = snapshot(config, state);
|
|
300
|
-
return { ...summary(state, resumePath), requiresExplicitRetryApproval:
|
|
462
|
+
return { ...summary(state, resumePath), requiresExplicitRetryApproval: !state.reviewRequired
|
|
463
|
+
&& ![REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(state.inFlight?.transport) };
|
|
301
464
|
} finally { release(); }
|
|
302
465
|
}
|