first commit
Some checks failed
Some checks failed
This commit is contained in:
252
app/lib/testcase-backend/qwen-service.ts
Normal file
252
app/lib/testcase-backend/qwen-service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 阿里云通义大模型服务(DashScope API)
|
||||
* 用于测试点生成的 AI 输入数据生成
|
||||
*/
|
||||
|
||||
const DASHSCOPE_BASE =
|
||||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation";
|
||||
|
||||
export interface AITestCase {
|
||||
input: string;
|
||||
expectedOutput?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface GenerateTestCasesParams {
|
||||
title: string;
|
||||
englishName: string;
|
||||
description: string;
|
||||
standardCode: string;
|
||||
testCaseCount: number;
|
||||
serviceLevel?: string;
|
||||
}
|
||||
|
||||
function getApiKey(): string | undefined {
|
||||
return process.env.DASHSCOPE_API_KEY;
|
||||
}
|
||||
|
||||
function getModel(): string {
|
||||
return process.env.QWEN_MODEL || "qwen-max";
|
||||
}
|
||||
|
||||
function isAIEnabled(): boolean {
|
||||
const key = getApiKey();
|
||||
return (
|
||||
process.env.ENABLE_AI === "true" &&
|
||||
!!key &&
|
||||
key !== "your_dashscope_api_key_here"
|
||||
);
|
||||
}
|
||||
|
||||
function buildPrompt(params: GenerateTestCasesParams): string {
|
||||
const { title, description, standardCode, testCaseCount } = params;
|
||||
return `你是一个专业的信息学奥林匹克竞赛测试数据生成专家。请根据以下题目信息,生成 ${testCaseCount} 组测试输入数据。
|
||||
|
||||
## 题目信息
|
||||
**标题**: ${title}
|
||||
|
||||
**题目描述**:
|
||||
${description}
|
||||
|
||||
**标准程序** (仅作参考,输出将由标准程序计算):
|
||||
\`\`\`cpp
|
||||
${standardCode}
|
||||
\`\`\`
|
||||
|
||||
## 要求
|
||||
1. 严格按照题目描述中的**输入格式**生成,注意多行顺序
|
||||
2. 所有数据必须满足题目约束(范围、单调性等)
|
||||
3. 不要使用省略号、占位符或「此处省略」
|
||||
4. 覆盖边界:最小值、最大值、小规模、大规模
|
||||
5. 每组测试用例的输入数据要完整、可直接作为程序 stdin
|
||||
|
||||
## 输出格式(二选一)
|
||||
|
||||
**方式一 JSON**(推荐):
|
||||
\`\`\`json
|
||||
{
|
||||
"test_cases": [
|
||||
{ "input": "第一组输入的完整内容,多行用\\n连接" },
|
||||
{ "input": "第二组输入..." }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**方式二 文本**:
|
||||
测试用例1:
|
||||
[第一组输入的完整内容,多行按行写]
|
||||
|
||||
测试用例2:
|
||||
[第二组输入...]
|
||||
|
||||
请生成 ${testCaseCount} 组测试输入数据:`;
|
||||
}
|
||||
|
||||
async function callAPI(prompt: string): Promise<string> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error("未配置 DASHSCOPE_API_KEY 环境变量");
|
||||
}
|
||||
|
||||
const res = await fetch(DASHSCOPE_BASE, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: getModel(),
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"你是信息学奥林匹克竞赛测试数据生成专家。根据题目描述生成测试输入,严格按要求的 JSON 或文本格式返回,不要省略或占位。",
|
||||
},
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
max_tokens: 8000,
|
||||
temperature: 0.3,
|
||||
result_format: "message",
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(180_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`DashScope API 错误 ${res.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
output?: { text?: string; choices?: Array<{ message?: { content?: string } }> };
|
||||
};
|
||||
const text =
|
||||
data.output?.text ??
|
||||
data.output?.choices?.[0]?.message?.content ??
|
||||
"";
|
||||
return text;
|
||||
}
|
||||
|
||||
function processTestCases(raw: unknown[], expectedCount: number): AITestCase[] {
|
||||
const result: AITestCase[] = [];
|
||||
for (let i = 0; i < Math.min(raw.length, expectedCount); i++) {
|
||||
const item = raw[i];
|
||||
if (typeof item === "string" && item.trim()) {
|
||||
result.push({ input: item.trim() });
|
||||
continue;
|
||||
}
|
||||
if (item && typeof item === "object" && "input" in item) {
|
||||
const obj = item as { input?: unknown; expectedOutput?: unknown };
|
||||
const input = obj.input;
|
||||
if (typeof input === "string" && input.trim()) {
|
||||
result.push({
|
||||
input: input.trim().replace(/\\n/g, "\n"),
|
||||
...(typeof obj.expectedOutput === "string" && obj.expectedOutput
|
||||
? { expectedOutput: obj.expectedOutput }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseJSONResponse(aiResponse: string, expectedCount: number): AITestCase[] {
|
||||
// 1) 直接解析整段
|
||||
const trimmed = aiResponse.trim();
|
||||
try {
|
||||
const direct = JSON.parse(trimmed) as { test_cases?: unknown[]; testCases?: unknown[] };
|
||||
const arr = direct.test_cases ?? direct.testCases;
|
||||
if (Array.isArray(arr) && arr.length > 0) {
|
||||
return processTestCases(arr, expectedCount);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 2) ```json ... ```
|
||||
const jsonBlock = aiResponse.match(/```json\s*([\s\S]*?)\s*```/);
|
||||
if (jsonBlock) {
|
||||
try {
|
||||
const data = JSON.parse(jsonBlock[1].trim()) as {
|
||||
test_cases?: unknown[];
|
||||
testCases?: unknown[];
|
||||
};
|
||||
const arr = data.test_cases ?? data.testCases;
|
||||
if (Array.isArray(arr) && arr.length > 0) {
|
||||
return processTestCases(arr, expectedCount);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseTextResponse(text: string, expectedCount: number): AITestCase[] {
|
||||
const cases: AITestCase[] = [];
|
||||
const parts = text.split(/(?:测试用例|测试数据|Test\s*Case)\s*\d+\s*[::]?\s*\n/i);
|
||||
for (let i = 1; i < parts.length && cases.length < expectedCount; i++) {
|
||||
const block = parts[i]!.trim();
|
||||
const nextCase = block.search(/(?:测试用例|测试数据|Test\s*Case)\s*\d+\s*[::]/i);
|
||||
const content = nextCase >= 0 ? block.slice(0, nextCase).trim() : block;
|
||||
if (content && !content.includes("...") && !content.includes("省略")) {
|
||||
cases.push({ input: content });
|
||||
}
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
function parseAIResponse(aiResponse: string, expectedCount: number): AITestCase[] {
|
||||
const fromJson = parseJSONResponse(aiResponse, expectedCount);
|
||||
if (fromJson.length > 0) return fromJson;
|
||||
return parseTextResponse(aiResponse, expectedCount);
|
||||
}
|
||||
|
||||
function validateParsedCases(cases: AITestCase[]): AITestCase[] {
|
||||
return cases.filter((c) => {
|
||||
const input = c.input ?? "";
|
||||
if (!input.trim()) return false;
|
||||
if (input.includes("...") || input.includes("......") || input.includes("省略")) return false;
|
||||
if (input.includes("占位符") || input.includes("placeholder")) return false;
|
||||
if (/^\s*[{\[]/.test(input) && (input.includes('"input"') || input.includes("test_cases"))) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function isQwenAvailable(): boolean {
|
||||
return isAIEnabled();
|
||||
}
|
||||
|
||||
export async function generateTestCases(params: GenerateTestCasesParams): Promise<AITestCase[]> {
|
||||
if (!isAIEnabled()) {
|
||||
throw new Error("AI 未启用或未配置 DASHSCOPE_API_KEY");
|
||||
}
|
||||
|
||||
const maxRetries = 2;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
||||
try {
|
||||
const prompt = buildPrompt(params);
|
||||
const response = await callAPI(prompt);
|
||||
const parsed = parseAIResponse(response, params.testCaseCount);
|
||||
const valid = validateParsedCases(parsed);
|
||||
|
||||
if (valid.length > 0) {
|
||||
return valid.slice(0, params.testCaseCount);
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (attempt <= maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("AI 生成测试用例失败");
|
||||
}
|
||||
Reference in New Issue
Block a user