feat: add task lifecycle management and improve generation reliability
Centralize timeout policies, stall detection, and error classification for image/video/text generation tasks. Improve ecommerce OSS upload flow and add script evaluation enhancements. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,12 @@ export interface ChatInput {
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface ChatUsage {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
}
|
||||
|
||||
export interface AiTaskStatus {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
@@ -500,6 +506,7 @@ export const aiGenerationClient = {
|
||||
input: ChatInput,
|
||||
onChunk: (text: string) => void,
|
||||
signal?: AbortSignal,
|
||||
onUsage?: (usage: ChatUsage) => void,
|
||||
): Promise<void> {
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
@@ -529,8 +536,24 @@ export const aiGenerationClient = {
|
||||
const payload = line.slice(6).trim();
|
||||
if (!payload) continue;
|
||||
try {
|
||||
const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string };
|
||||
const chunk = JSON.parse(payload) as {
|
||||
delta?: string;
|
||||
done?: boolean;
|
||||
error?: string;
|
||||
usage?: ChatUsage & {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
if (chunk.error) throw new Error(chunk.error);
|
||||
if (chunk.usage) {
|
||||
onUsage?.({
|
||||
promptTokens: chunk.usage.promptTokens ?? chunk.usage.prompt_tokens,
|
||||
completionTokens: chunk.usage.completionTokens ?? chunk.usage.completion_tokens,
|
||||
totalTokens: chunk.usage.totalTokens ?? chunk.usage.total_tokens,
|
||||
});
|
||||
}
|
||||
if (chunk.delta) onChunk(chunk.delta);
|
||||
if (chunk.done) return;
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface ScriptEvalResult {
|
||||
totalScore: number;
|
||||
grade: string;
|
||||
dimensionScores: Record<string, number>;
|
||||
subScores?: Record<string, Record<string, number>>;
|
||||
evidence?: Record<string, string[]>;
|
||||
summary: string;
|
||||
issues: string[];
|
||||
highlights: string[];
|
||||
@@ -12,6 +14,33 @@ export interface ScriptEvalResult {
|
||||
|
||||
const MODEL = "qwen3.7-max";
|
||||
|
||||
const EVAL_OUTPUT_CONTRACT = `
|
||||
强制输出 JSON,主维度键名必须严格为:
|
||||
hook(20), plot(20), character(15), logic(15), visual(15), content(15)。
|
||||
不要把 dialogue 作为主维度返回;台词对白作为 character/plot/content 的证据和子项分析。
|
||||
|
||||
同时返回 subScores 和 evidence:
|
||||
- subScores:每个主维度 3-5 个细分参数,分值按该维度满分拆分。
|
||||
- evidence:每个主维度 1-3 条具体证据,必须指向场景、台词、设定、冲突或段落。
|
||||
|
||||
返回结构:
|
||||
{
|
||||
"dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "logic": 数字, "visual": 数字, "content": 数字 },
|
||||
"subScores": {
|
||||
"hook": { "openingImpact": 数字, "suspenseChain": 数字, "sceneHook": 数字 },
|
||||
"plot": { "structure": 数字, "rhythm": 数字, "conflict": 数字, "reversal": 数字 },
|
||||
"character": { "motivation": 数字, "arc": 数字, "voice": 数字, "relationship": 数字 },
|
||||
"logic": { "causality": 数字, "worldRules": 数字, "foreshadowing": 数字, "continuity": 数字 },
|
||||
"visual": { "sceneDetail": 数字, "shotPotential": 数字, "aigcFeasibility": 数字 },
|
||||
"content": { "theme": 数字, "emotion": 数字, "marketFit": 数字, "originality": 数字 }
|
||||
},
|
||||
"evidence": { "hook": ["..."], "plot": ["..."], "character": ["..."], "logic": ["..."], "visual": ["..."], "content": ["..."] },
|
||||
"summary": "200-300字综合评价",
|
||||
"issues": ["具体扣分点,带维度和证据", ...],
|
||||
"highlights": ["具体亮点,带维度和证据", ...],
|
||||
"suggestions": ["按优先级排列的改稿建议", ...]
|
||||
}`;
|
||||
|
||||
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
|
||||
|
||||
【剧本类型识别】
|
||||
@@ -46,10 +75,10 @@ const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有
|
||||
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
|
||||
hook: { maxScore: 20 },
|
||||
plot: { maxScore: 20 },
|
||||
character: { maxScore: 18 },
|
||||
dialogue: { maxScore: 15 },
|
||||
character: { maxScore: 15 },
|
||||
logic: { maxScore: 15 },
|
||||
visual: { maxScore: 15 },
|
||||
content: { maxScore: 12 },
|
||||
content: { maxScore: 15 },
|
||||
};
|
||||
|
||||
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
|
||||
@@ -68,6 +97,48 @@ function extractJson(text: string): unknown {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function normalizeScoreValue(value: unknown, maxScore: number): number {
|
||||
const score = Number(value);
|
||||
if (!Number.isFinite(score)) return 0;
|
||||
return Math.max(0, Math.min(maxScore, Math.round(score * 10) / 10));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function normalizeNestedScores(value: unknown): Record<string, Record<string, number>> {
|
||||
if (!isRecord(value)) return {};
|
||||
|
||||
const normalized: Record<string, Record<string, number>> = {};
|
||||
for (const [dimensionKey, dimension] of Object.entries(DIMENSION_WEIGHTS)) {
|
||||
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
|
||||
if (!isRecord(source)) continue;
|
||||
|
||||
const entries = Object.entries(source)
|
||||
.map(([key, score]) => [key, normalizeScoreValue(score, dimension.maxScore)] as const)
|
||||
.filter(([, score]) => score > 0);
|
||||
if (entries.length > 0) normalized[dimensionKey] = Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeEvidence(value: unknown): Record<string, string[]> {
|
||||
if (!isRecord(value)) return {};
|
||||
|
||||
const normalized: Record<string, string[]> = {};
|
||||
for (const dimensionKey of Object.keys(DIMENSION_WEIGHTS)) {
|
||||
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
|
||||
if (!Array.isArray(source)) continue;
|
||||
|
||||
const items = source.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3);
|
||||
if (items.length > 0) normalized[dimensionKey] = items;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
@@ -76,6 +147,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: EVAL_SYSTEM_PROMPT },
|
||||
{ role: "system", content: EVAL_OUTPUT_CONTRACT },
|
||||
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
|
||||
],
|
||||
stream: false,
|
||||
@@ -101,8 +173,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
||||
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
|
||||
|
||||
for (const key of Object.keys(DIMENSION_WEIGHTS)) {
|
||||
const val = Number(rawScores[key] ?? 0);
|
||||
dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val));
|
||||
const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key];
|
||||
dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore);
|
||||
}
|
||||
|
||||
const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
|
||||
@@ -111,6 +183,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
||||
totalScore,
|
||||
grade,
|
||||
dimensionScores,
|
||||
subScores: normalizeNestedScores(parsed.subScores),
|
||||
evidence: normalizeEvidence(parsed.evidence),
|
||||
summary: String(parsed.summary || ""),
|
||||
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
|
||||
highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [],
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { aiGenerationClient } from "./aiGenerationClient";
|
||||
import {
|
||||
buildLocalTimeoutMessage,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "../utils/taskLifecycle";
|
||||
|
||||
export interface TaskProgressEvent {
|
||||
taskId: string;
|
||||
@@ -12,16 +17,28 @@ export interface WaitForTaskOptions {
|
||||
onProgress?: (event: TaskProgressEvent) => void;
|
||||
abortRef?: { current: boolean };
|
||||
timeoutMs?: number;
|
||||
noProgressTimeoutMs?: number;
|
||||
startedAt?: number;
|
||||
kind?: "image" | "video" | "text";
|
||||
model?: string | null;
|
||||
operation?: string | null;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 3000;
|
||||
const DEFAULT_TIMEOUT = 30 * 60 * 1000;
|
||||
|
||||
export function waitForTask(
|
||||
taskId: string,
|
||||
options: WaitForTaskOptions = {},
|
||||
): Promise<string | null> {
|
||||
const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = options;
|
||||
const { onProgress, abortRef } = options;
|
||||
const timeoutPolicy = getTaskTimeoutPolicy({
|
||||
kind: options.kind,
|
||||
model: options.model,
|
||||
operation: options.operation,
|
||||
});
|
||||
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
|
||||
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
|
||||
const startedAt = options.startedAt ?? Date.now();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
@@ -29,6 +46,8 @@ export function waitForTask(
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let sseConnected = false;
|
||||
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastProgress = 0;
|
||||
let lastProgressAt = startedAt;
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
@@ -40,7 +59,7 @@ export function waitForTask(
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(
|
||||
() => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))),
|
||||
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
@@ -50,6 +69,11 @@ export function waitForTask(
|
||||
settle(() => resolve(null));
|
||||
return;
|
||||
}
|
||||
const progress = Number(event.progress || 0);
|
||||
if (progress > lastProgress || event.status === "completed") {
|
||||
lastProgress = Math.max(lastProgress, progress);
|
||||
lastProgressAt = Date.now();
|
||||
}
|
||||
onProgress?.(event);
|
||||
if (event.status === "completed") {
|
||||
settle(() => resolve(event.resultUrl || null));
|
||||
@@ -76,6 +100,16 @@ export function waitForTask(
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
|
||||
if (settled || abortRef?.current) return;
|
||||
const timeoutReason = isTaskLocallyTimedOut({
|
||||
startedAt,
|
||||
lastProgressAt,
|
||||
progress: lastProgress,
|
||||
policy: { ...timeoutPolicy, noProgressTimeoutMs },
|
||||
});
|
||||
if (timeoutReason) {
|
||||
settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const task = await aiGenerationClient.getTaskStatus(taskId);
|
||||
handleUpdate({
|
||||
@@ -90,7 +124,7 @@ export function waitForTask(
|
||||
}
|
||||
}
|
||||
};
|
||||
poll();
|
||||
void poll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user