2026-06-03 20:19:07 +08:00
|
|
|
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
|
|
|
|
|
2026-06-02 12:38:01 +08:00
|
|
|
export interface ScriptEvalResult {
|
|
|
|
|
totalScore: number;
|
|
|
|
|
grade: string;
|
|
|
|
|
dimensionScores: Record<string, number>;
|
|
|
|
|
summary: string;
|
|
|
|
|
issues: string[];
|
|
|
|
|
highlights: string[];
|
|
|
|
|
suggestions: string[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 01:39:06 +08:00
|
|
|
const MODEL = "qwen3.7-max";
|
|
|
|
|
|
2026-06-02 16:04:26 +08:00
|
|
|
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-02 16:04:26 +08:00
|
|
|
【剧本类型识别】
|
|
|
|
|
收到剧本后,首先判断类型:AIGC短剧/漫剧(单集5-30分钟,竖屏平台,高密度反转、强节奏)或传统影视剧本(单集40分钟以上,长视频平台,完整起承转合)。类型判定将影响各维度的评价侧重点。
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-02 16:04:26 +08:00
|
|
|
【评分体系(100分制,六个维度)】
|
|
|
|
|
1. hook 钩子设计(20分):开篇钩子、集末钩子、场景内钩子、悬念链完整性。短剧前3秒须有即时爆点;长剧第一幕结束前须建立核心悬念。
|
|
|
|
|
2. plot 剧情结构(20分):结构框架、节奏控制、冲突设计、逻辑自洽。短剧"每分钟有事件",反转密度加分;长剧需处理好B线C线与主线交织。
|
|
|
|
|
3. character 角色塑造(18分):主角弧光、角色辨识度、角色动机、配角质量。短剧角色须在前2分钟建立;长剧需要内在矛盾和多阶段成长。
|
|
|
|
|
4. dialogue 台词对白(15分):角色语言区分度、信息传递效率、潜台词与留白、金句与记忆点。
|
|
|
|
|
5. visual 画面表现(15分):场景描写质量、视觉叙事技巧、镜头感与节奏、制作可行性。AIGC需考虑AI生成技术边界与一致性。
|
|
|
|
|
6. content 内容深度(12分):主题表达、情感共鸣、社会/人性洞察。
|
|
|
|
|
|
|
|
|
|
【评分铁律】
|
|
|
|
|
- 扣分必须明确指出剧本中的具体段落/场景/台词。
|
|
|
|
|
- 严禁给出任何维度的满分,必须有扣分理由。
|
|
|
|
|
- 优缺点都要充分展开,不可只批不夸或只夸不批。
|
|
|
|
|
- 不因题材类型偏见降低评分,不因某一方面出色而抬高其他维度(避免光环效应)。
|
|
|
|
|
- 敢于拉开各维度分数差距,避免全部给中等分数。
|
|
|
|
|
|
|
|
|
|
【等级标准】按总分百分比:S≥90 | A 80-89 | B 70-79 | C 60-69 | D<60。
|
|
|
|
|
|
|
|
|
|
请严格按以下 JSON 格式返回(不要包含任何其他文字,不要用代码块包裹以外的说明):
|
2026-06-02 12:38:01 +08:00
|
|
|
{
|
2026-06-02 16:04:26 +08:00
|
|
|
"dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "dialogue": 数字, "visual": 数字, "content": 数字 },
|
|
|
|
|
"summary": "200-300字综合评价,概括整体质量、市场潜力与目标受众匹配度",
|
|
|
|
|
"issues": ["每条指出具体维度的扣分点并引用剧本原文位置", ...],
|
|
|
|
|
"highlights": ["核心亮点,引用剧本具体场景", ...],
|
|
|
|
|
"suggestions": ["按优先级排列的改进建议(最优先/次优先/可优化)", ...]
|
2026-06-02 12:38:01 +08:00
|
|
|
}`;
|
|
|
|
|
|
2026-06-02 16:04:26 +08:00
|
|
|
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
|
|
|
|
|
hook: { maxScore: 20 },
|
|
|
|
|
plot: { maxScore: 20 },
|
|
|
|
|
character: { maxScore: 18 },
|
|
|
|
|
dialogue: { maxScore: 15 },
|
|
|
|
|
visual: { maxScore: 15 },
|
|
|
|
|
content: { maxScore: 12 },
|
2026-06-02 12:38:01 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
|
|
|
|
|
const totalScore = Math.round(
|
|
|
|
|
Object.entries(DIMENSION_WEIGHTS).reduce((sum, [key, dim]) => {
|
2026-06-02 16:04:26 +08:00
|
|
|
return sum + Math.max(0, Math.min(dim.maxScore, scores[key] ?? 0));
|
2026-06-02 12:38:01 +08:00
|
|
|
}, 0),
|
|
|
|
|
);
|
|
|
|
|
const grade = totalScore >= 90 ? "S" : totalScore >= 80 ? "A" : totalScore >= 70 ? "B" : totalScore >= 60 ? "C" : "D";
|
|
|
|
|
return { totalScore, grade };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractJson(text: string): unknown {
|
|
|
|
|
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
|
|
|
const raw = fenced ? fenced[1].trim() : text.trim();
|
|
|
|
|
return JSON.parse(raw);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
2026-06-03 20:19:07 +08:00
|
|
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
2026-06-02 12:38:01 +08:00
|
|
|
method: "POST",
|
2026-06-03 20:19:07 +08:00
|
|
|
headers: buildAuthHeaders(),
|
2026-06-02 12:38:01 +08:00
|
|
|
body: JSON.stringify({
|
2026-06-03 01:39:06 +08:00
|
|
|
model: MODEL,
|
2026-06-02 12:38:01 +08:00
|
|
|
messages: [
|
|
|
|
|
{ role: "system", content: EVAL_SYSTEM_PROMPT },
|
|
|
|
|
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
|
|
|
|
|
],
|
|
|
|
|
stream: false,
|
|
|
|
|
temperature: 0.3,
|
2026-06-03 01:39:06 +08:00
|
|
|
max_tokens: 4096,
|
2026-06-02 12:38:01 +08:00
|
|
|
}),
|
|
|
|
|
signal,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
2026-06-03 01:39:06 +08:00
|
|
|
const errText = await res.text().catch(() => "");
|
|
|
|
|
throw new Error(`评测请求失败 (${res.status}): ${errText.slice(0, 200)}`);
|
2026-06-02 12:38:01 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = await res.json();
|
2026-06-03 20:19:07 +08:00
|
|
|
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
2026-06-02 12:38:01 +08:00
|
|
|
|
|
|
|
|
if (!content) throw new Error("模型未返回有效内容");
|
|
|
|
|
|
|
|
|
|
const parsed = extractJson(content) as Record<string, unknown>;
|
|
|
|
|
const dimensionScores: Record<string, number> = {};
|
|
|
|
|
const rawScores = parsed.dimensionScores as Record<string, number> | undefined;
|
|
|
|
|
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 { totalScore, grade } = computeTotalAndGrade(dimensionScores);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
totalScore,
|
|
|
|
|
grade,
|
|
|
|
|
dimensionScores,
|
|
|
|
|
summary: String(parsed.summary || ""),
|
|
|
|
|
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
|
|
|
|
|
highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [],
|
|
|
|
|
suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.map(String) : [],
|
|
|
|
|
};
|
|
|
|
|
}
|