127 lines
4.9 KiB
TypeScript
127 lines
4.9 KiB
TypeScript
import { BadRequestException, Injectable } from '@nestjs/common';
|
||
import { uuidV7 } from '../common/uuid-v7';
|
||
import type { AiReviewColumn, AiReviewRow } from './entities/ai-review.entity';
|
||
|
||
const MAX_TITLE = 50;
|
||
const MAX_COLUMNS = 10;
|
||
const MIN_COLUMNS = 2;
|
||
const MAX_ROWS = 500;
|
||
const MAX_CELL_LENGTH = 200;
|
||
|
||
const COLUMN_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/;
|
||
const CHART_TYPES = new Set(['line', 'bar', 'pie', 'area', 'scatter', 'radar', 'gauge', 'funnel']);
|
||
const SCHEMA_KEYS = new Set(['title', 'chartType', 'columns', 'rows']);
|
||
const COLUMN_KEYS_ALLOWED = new Set(['key', 'title']);
|
||
|
||
export interface AiChart {
|
||
id: string;
|
||
title: string;
|
||
chartType: 'line' | 'bar' | 'pie' | 'area' | 'scatter' | 'radar' | 'gauge' | 'funnel';
|
||
columns: AiReviewColumn[];
|
||
rows: AiReviewRow[];
|
||
}
|
||
|
||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||
}
|
||
|
||
function requireString(value: unknown, label: string, max: number): string {
|
||
if (typeof value !== 'string' || !value.trim()) {
|
||
throw new BadRequestException(`${label}必须是字符串`);
|
||
}
|
||
const trimmed = value.trim();
|
||
if (trimmed.length > max) {
|
||
throw new BadRequestException(`${label}长度不能超过 ${max}`);
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
function assertKeys(raw: Record<string, unknown>, allowed: Set<string>, label: string): void {
|
||
for (const key of Object.keys(raw)) {
|
||
if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Validates the `render_chart` tool arguments. The model sends a
|
||
* whitelisted tabular shape (columns + rows); the frontend converts it
|
||
* into an ECharts option, so no arbitrary option objects reach the client.
|
||
*/
|
||
@Injectable()
|
||
export class AiChartService {
|
||
createChart(rawArgs: unknown): AiChart {
|
||
if (!isPlainRecord(rawArgs)) throw new BadRequestException('图表参数必须是对象');
|
||
assertKeys(rawArgs, SCHEMA_KEYS, '图表');
|
||
const title = requireString(rawArgs.title, '图表标题', MAX_TITLE);
|
||
if (typeof rawArgs.chartType !== 'string' || !CHART_TYPES.has(rawArgs.chartType)) {
|
||
throw new BadRequestException('图表类型不支持');
|
||
}
|
||
const chartType = rawArgs.chartType as AiChart['chartType'];
|
||
|
||
if (!Array.isArray(rawArgs.columns) || rawArgs.columns.length < MIN_COLUMNS) {
|
||
throw new BadRequestException('图表至少需要 2 列(类别/名称 + 数值)');
|
||
}
|
||
if (rawArgs.chartType === 'scatter' && rawArgs.columns.length < 3) {
|
||
throw new BadRequestException('散点图需要 3 列:名称、X 数值、Y 数值');
|
||
}
|
||
if (rawArgs.columns.length > MAX_COLUMNS) {
|
||
throw new BadRequestException(`图表列数不能超过 ${MAX_COLUMNS}`);
|
||
}
|
||
const seenColumns = new Set<string>();
|
||
const columns = rawArgs.columns.map((column, index) => {
|
||
if (!isPlainRecord(column)) {
|
||
throw new BadRequestException(`图表第 ${index + 1} 列格式无效`);
|
||
}
|
||
assertKeys(column, COLUMN_KEYS_ALLOWED, `图表第 ${index + 1} 列`);
|
||
const key = requireString(column.key, `图表第 ${index + 1} 列名`, 50);
|
||
if (!COLUMN_KEY_RE.test(key)) {
|
||
throw new BadRequestException(`图表列名 ${key} 只能包含字母、数字、下划线`);
|
||
}
|
||
if (seenColumns.has(key)) throw new BadRequestException(`图表列名重复: ${key}`);
|
||
seenColumns.add(key);
|
||
const columnTitle = requireString(column.title, `图表列「${key}」标题`, 50);
|
||
return { key, title: columnTitle };
|
||
});
|
||
|
||
if (!Array.isArray(rawArgs.rows) || rawArgs.rows.length > MAX_ROWS) {
|
||
throw new BadRequestException(`图表行数不能超过 ${MAX_ROWS}`);
|
||
}
|
||
const rows = rawArgs.rows.map((row, index) => this.validateRow(row, index, seenColumns));
|
||
return { id: uuidV7(), title, chartType, columns, rows };
|
||
}
|
||
|
||
serialize(chart: AiChart): AiChart {
|
||
return chart;
|
||
}
|
||
|
||
private validateRow(raw: unknown, index: number, knownColumns: Set<string>): AiReviewRow {
|
||
if (!isPlainRecord(raw)) throw new BadRequestException(`图表第 ${index + 1} 行格式无效`);
|
||
const row: AiReviewRow = {};
|
||
for (const [key, value] of Object.entries(raw)) {
|
||
if (!knownColumns.has(key)) continue;
|
||
if (value === null || typeof value === 'boolean') {
|
||
row[key] = value;
|
||
continue;
|
||
}
|
||
if (typeof value === 'number') {
|
||
if (!Number.isFinite(value)) {
|
||
throw new BadRequestException(`图表第 ${index + 1} 行 ${key} 必须是有效数字`);
|
||
}
|
||
row[key] = value;
|
||
continue;
|
||
}
|
||
if (typeof value === 'string') {
|
||
if (value.length > MAX_CELL_LENGTH) {
|
||
throw new BadRequestException(
|
||
`图表第 ${index + 1} 行 ${key} 长度超过 ${MAX_CELL_LENGTH}`,
|
||
);
|
||
}
|
||
row[key] = value;
|
||
continue;
|
||
}
|
||
throw new BadRequestException(`图表第 ${index + 1} 行 ${key} 类型不支持`);
|
||
}
|
||
return row;
|
||
}
|
||
}
|