node-red/compiliance-js/prepare-llm-request.js
ruoyunbai d9b08c89ee js
2025-11-17 10:55:25 +08:00

490 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
/**
* Node-RED Function 节点脚本:准备大模型 HTTP 请求。
* 将生成 prompt、HTTP 请求参数,并把上下文写入 msg.llmContext。
*/
const DEFAULT_API_KEY = 'sk-lbGrsUPL1iby86h554FaE536C343435dAa9bA65967A840B2';
const DEFAULT_BASE_URL = 'https://aiproxy.petrotech.cnpc/v1';
const DEFAULT_ENDPOINT_PATH = '/chat/completions';
const DEFAULT_MODEL = 'deepseek-v3';
function prepareLlmRequest(msg, node) {
if (!msg.operationId) {
msg.operationId = 'createResource';
}
try {
const apiDoc = extractOpenApiDocument(msg);
const operationCtx = resolveOperationContext(msg, apiDoc);
if (!operationCtx.operation) {
const err = `未找到匹配的接口定义operationId=${operationCtx.requestedOperationId || '未指定'}, path=${operationCtx.requestedPath || '未指定'}, method=${operationCtx.requestedMethod || '未指定'})`;
node.error(err, msg);
msg.error = err;
return null;
}
const prompt = buildPrompt(operationCtx, apiDoc, msg);
const requestPayload = buildRequestPayload(prompt, msg);
const apiKey = (msg.llm && msg.llm.apiKey) || DEFAULT_API_KEY;
const baseUrl = (msg.llm && msg.llm.baseUrl) || DEFAULT_BASE_URL;
const endpointPath = (msg.llm && msg.llm.endpoint) || DEFAULT_ENDPOINT_PATH;
const url = buildUrl(baseUrl, endpointPath);
msg.method = 'POST';
msg.url = url;
msg.headers = Object.assign({}, msg.headers, {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
});
msg.payload = requestPayload;
msg.rejectUnauthorized = false;
msg.llmContext = {
prompt,
operationCtx,
provider: 'dashscope',
requestConfig: {
baseUrl,
endpointPath,
model: requestPayload.model,
temperature: requestPayload.temperature,
},
};
delete msg.error;
return msg;
} catch (error) {
node.error(`LLM 请求准备失败:${error.message}`, msg);
msg.error = error.message;
return null;
}
}
function extractOpenApiDocument(message) {
const candidate =
message && message.oas_def ? message.oas_def :
message && message.swagger ? message.swagger :
message && message.payload ? message.payload :
null;
if (!candidate) {
throw new Error('未提供 OpenAPI 文档(需要 msg.oas_def / msg.swagger / msg.payload');
}
if (typeof candidate === 'string') {
try {
return JSON.parse(candidate);
} catch (error) {
throw new Error(`OpenAPI JSON 解析失败:${error.message}`);
}
}
if (typeof candidate !== 'object') {
throw new Error('OpenAPI 文档必须是对象或 JSON 字符串');
}
if (!candidate.paths || typeof candidate.paths !== 'object') {
throw new Error('OpenAPI 文档缺少 paths 字段');
}
return candidate;
}
function resolveOperationContext(message, apiDoc) {
const requestedOperationId =
(message && message.operationId) ||
(message && message.req && message.req.body && message.req.body.operationId) ||
null;
const requestedPath =
(message && message.path) ||
(message && message.req && message.req.body && message.req.body.path) ||
null;
const requestedMethodRaw =
(message && message.method) ||
(message && message.req && message.req.body && message.req.body.method) ||
null;
const requestedMethod = requestedMethodRaw ? String(requestedMethodRaw).toLowerCase() : null;
const operations = enumerateOperations(apiDoc);
let matched = null;
if (requestedOperationId) {
matched = operations.find(op => op.operation.operationId === requestedOperationId);
}
if (!matched && requestedPath && requestedMethod) {
matched = operations.find(op => op.path === requestedPath && op.method === requestedMethod);
}
let autoSelected = false;
if (!matched && operations.length > 0) {
matched = operations[0];
autoSelected = true;
}
return {
operation: matched ? matched.operation : undefined,
method: matched ? matched.method : undefined,
pathItem: matched ? matched.pathItem : undefined,
path: matched ? matched.path : undefined,
autoSelected,
candidates: operations.map(op => ({
operationId: op.operation && op.operation.operationId ? op.operation.operationId : '',
method: op.method ? op.method.toUpperCase() : '',
path: op.path,
summary: op.operation && op.operation.summary ? op.operation.summary : '',
})),
requestedOperationId,
requestedPath,
requestedMethod,
};
}
function enumerateOperations(apiDoc) {
const results = [];
const validMethods = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace'];
for (const path of Object.keys(apiDoc.paths || {})) {
const pathItem = apiDoc.paths[path];
if (!pathItem || typeof pathItem !== 'object') {
continue;
}
for (const method of validMethods) {
if (pathItem[method] && typeof pathItem[method] === 'object') {
results.push({
path,
method,
operation: pathItem[method],
pathItem,
});
}
}
}
return results;
}
function buildPrompt(operationCtx, apiDoc, message) {
const operation = operationCtx.operation;
const method = (operationCtx.method || '').toUpperCase();
const path = operationCtx.path || '';
const summary = operation.summary || '';
const description = operation.description || '';
const parameters = gatherParameters(operationCtx, apiDoc);
const requestBodySchema = gatherRequestBodySchema(operation, apiDoc);
const userNotes = message && message.prompt ? String(message.prompt) : '';
const requiredFields = extractRequiredFields(requestBodySchema, apiDoc);
const requiredFieldsText = requiredFields.length > 0
? requiredFields.map(field => `- ${field}`).join('\n')
: '';
const lines = [];
lines.push('你是一名负责生成 HTTP 接口测试参数的助手,请严格输出以下结构的 JSON');
lines.push('{');
lines.push(' "pathParams": { ... },');
lines.push(' "query": { ... },');
lines.push(' "headers": { ... },');
lines.push(' "cookies": { ... },');
lines.push(' "body": { ... },');
lines.push(' "notes": "..."');
lines.push('}');
lines.push('未用到的分区请返回空对象,不要在 JSON 外输出任何文字。');
lines.push('');
lines.push('body.data[0] 必须遵守:');
lines.push('- 覆盖 schema 中声明的全部必填字段,并给出符合字段类型/格式的真实感样例值。');
lines.push('- 非必填字段也尽可能全覆盖');
lines.push('- 不得遗漏必填字段;若 schema 内有嵌套对象/数组的必填字段,同样要补齐。');
lines.push('- 保持数值、日期、字符串等格式,只在 schema 无提示时使用 "sample"、"2025-01-01" 等占位值。');
if (requiredFieldsText) {
lines.push('必填字段清单(必须全部出现在 body.data[0] 中):');
lines.push(requiredFieldsText);
}
lines.push('');
lines.push('若 schema 中存在数组元素或复合结构的必填字段,也要为这些子字段提供值。');
lines.push('');
lines.push(`Target operation: ${method} ${path}`);
if (summary) {
lines.push(`Summary: ${summary}`);
}
if (description) {
lines.push(`Description: ${description}`);
}
if (parameters.length > 0) {
lines.push('Parameters:');
for (const param of parameters) {
lines.push(`- [${param.in}] ${param.name}: ${param.type || 'any'}${param.required ? ' (required)' : ''}${param.description ? ` - ${param.description}` : ''}`);
}
} else {
lines.push('Parameters: none defined.');
}
if (requestBodySchema) {
lines.push('Request body schema (JSON Schema excerpt):');
lines.push(indentSnippet(JSON.stringify(requestBodySchema, null, 2), 2));
} else {
lines.push('Request body: not defined.');
}
if (userNotes) {
lines.push('');
lines.push('User notes:');
lines.push(userNotes);
}
return lines.join('\n');
}
function gatherParameters(operationCtx, apiDoc) {
const aggregated = [];
const seen = new Set();
const sources = [];
if (Array.isArray(operationCtx.pathItem && operationCtx.pathItem.parameters)) {
sources.push(operationCtx.pathItem.parameters);
}
if (Array.isArray(operationCtx.operation.parameters)) {
sources.push(operationCtx.operation.parameters);
}
for (const list of sources) {
for (const param of list) {
if (!param || typeof param !== 'object') {
continue;
}
const resolved = resolveMaybeRef(param, apiDoc);
const key = `${resolved.in}:${resolved.name}`;
if (!resolved.name || seen.has(key)) {
continue;
}
seen.add(key);
aggregated.push({
name: resolved.name,
in: resolved.in || 'query',
required: !!resolved.required,
description: resolved.description || '',
type: resolved.schema ? inferFriendlyType(resolved.schema, apiDoc) : '',
});
}
}
return aggregated;
}
function gatherRequestBodySchema(operation, apiDoc) {
const requestBody = resolveMaybeRef(operation.requestBody, apiDoc);
if (!requestBody || typeof requestBody !== 'object' || !requestBody.content) {
return null;
}
const content = requestBody.content;
const mediaType = Object.keys(content).find(key => key.includes('json')) || Object.keys(content)[0];
if (!mediaType) {
return null;
}
const mediaObject = resolveMaybeRef(content[mediaType], apiDoc);
if (!mediaObject || typeof mediaObject !== 'object' || !mediaObject.schema) {
return null;
}
const schema = resolveMaybeRef(mediaObject.schema, apiDoc);
return resolveSchemaDeep(schema, apiDoc);
}
function inferFriendlyType(schema, apiDoc) {
if (!schema) {
return '';
}
const resolved = resolveMaybeRef(schema, apiDoc);
if (!resolved || typeof resolved !== 'object') {
return '';
}
if (resolved.type) {
if (resolved.type === 'array' && resolved.items) {
const itemType = inferFriendlyType(resolved.items, apiDoc) || 'any';
return `[${itemType}]`;
}
return resolved.type;
}
if (resolved.enum && Array.isArray(resolved.enum)) {
return `enum(${resolved.enum.slice(0, 3).join(', ')}${resolved.enum.length > 3 ? ', …' : ''})`;
}
if (resolved.properties) {
return 'object';
}
return '';
}
function buildRequestPayload(prompt, message) {
const systemPrompt = (message.llm && message.llm.systemPrompt) ||
'You write JSON only. Focus on realistic values for testing HTTP APIs.';
const model = (message.llm && message.llm.model) || DEFAULT_MODEL;
const temperature = (message.llm && typeof message.llm.temperature === 'number')
? message.llm.temperature : 0.2;
return {
model,
temperature,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt },
],
};
}
function buildUrl(baseUrl, endpointPath) {
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
const path = endpointPath.startsWith('/') ? endpointPath.slice(1) : endpointPath;
return `${normalizedBase}${path}`;
}
function resolveMaybeRef(node, apiDoc) {
if (!node || typeof node !== 'object') {
return node;
}
if (!node.$ref) {
return node;
}
const resolved = resolveRef(node.$ref, apiDoc);
if (!resolved || typeof resolved !== 'object') {
return node;
}
const remainder = Object.assign({}, node);
delete remainder.$ref;
return Object.assign({}, clone(resolved), remainder);
}
function resolveRef(ref, apiDoc) {
if (typeof ref !== 'string' || !ref.startsWith('#/')) {
return null;
}
const tokens = ref.slice(2).split('/').map(unescapeRefToken);
let current = apiDoc;
for (const token of tokens) {
if (current && typeof current === 'object' && Object.prototype.hasOwnProperty.call(current, token)) {
current = current[token];
} else {
return null;
}
}
return current;
}
function unescapeRefToken(token) {
return token.replace(/~1/g, '/').replace(/~0/g, '~');
}
function indentSnippet(text, indentLevel, maxLength) {
const trimmed = maxLength && text.length > maxLength ? `${text.slice(0, maxLength)}` : text;
const indent = ' '.repeat(indentLevel * 2);
return trimmed.split('\n').map(line => `${indent}${line}`).join('\n');
}
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function extractRequiredFields(schema, apiDoc) {
const result = new Set();
collectRequiredFields(schema, apiDoc, result, '');
return Array.from(result);
}
function collectRequiredFields(schema, apiDoc, result, pathPrefix) {
if (!schema || typeof schema !== 'object') {
return;
}
if (schema.$ref) {
const resolved = resolveRef(schema.$ref, apiDoc);
if (!resolved) {
return;
}
collectRequiredFields(resolveSchemaDeep(resolved, apiDoc), apiDoc, result, pathPrefix);
return;
}
if (Array.isArray(schema.required) && schema.properties && typeof schema.properties === 'object') {
for (const key of schema.required) {
const nextPath = pathPrefix ? `${pathPrefix}.${key}` : key;
result.add(nextPath);
collectRequiredFields(schema.properties[key], apiDoc, result, nextPath);
}
}
if (schema.type === 'array' && schema.items) {
const nextPrefix = pathPrefix ? `${pathPrefix}[]` : '[]';
collectRequiredFields(schema.items, apiDoc, result, nextPrefix);
}
if (schema.allOf && Array.isArray(schema.allOf)) {
for (const part of schema.allOf) {
collectRequiredFields(part, apiDoc, result, pathPrefix);
}
}
}
function resolveSchemaDeep(schema, apiDoc, seen = new Set()) {
if (!schema || typeof schema !== 'object') {
return schema;
}
if (schema.$ref) {
const ref = schema.$ref;
if (seen.has(ref)) {
return {};
}
seen.add(ref);
const resolved = resolveRef(ref, apiDoc);
if (!resolved) {
return schema;
}
const merged = Object.assign({}, clone(resolved), clone(schema));
delete merged.$ref;
return resolveSchemaDeep(merged, apiDoc, seen);
}
const cloned = clone(schema);
if (cloned.properties && typeof cloned.properties === 'object') {
for (const key of Object.keys(cloned.properties)) {
cloned.properties[key] = resolveSchemaDeep(cloned.properties[key], apiDoc, new Set(seen));
}
}
if (cloned.items) {
cloned.items = resolveSchemaDeep(cloned.items, apiDoc, new Set(seen));
}
if (cloned.allOf && Array.isArray(cloned.allOf)) {
cloned.allOf = cloned.allOf.map(item => resolveSchemaDeep(item, apiDoc, new Set(seen)));
}
if (cloned.oneOf && Array.isArray(cloned.oneOf)) {
cloned.oneOf = cloned.oneOf.map(item => resolveSchemaDeep(item, apiDoc, new Set(seen)));
}
if (cloned.anyOf && Array.isArray(cloned.anyOf)) {
cloned.anyOf = cloned.anyOf.map(item => resolveSchemaDeep(item, apiDoc, new Set(seen)));
}
return cloned;
}
return prepareLlmRequest(msg, node);