文件
tennis-training-hub/server/trainingPlan.ts
2026-04-07 11:00:03 +08:00

270 行
8.1 KiB
TypeScript

import { z } from "zod";
const exerciseSchema = z.object({
day: z.number().int().min(1),
name: z.string().min(1),
category: z.string().min(1),
duration: z.number().positive(),
description: z.string().min(1),
tips: z.string().min(1),
sets: z.number().int().positive(),
reps: z.number().int().positive(),
});
const normalizedPlanSchema = z.object({
title: z.string().min(1),
exercises: z.array(exerciseSchema).min(1),
});
const normalizedAdjustedPlanSchema = normalizedPlanSchema.extend({
adjustmentNotes: z.string().min(1),
});
type NormalizedExercise = z.infer<typeof exerciseSchema>;
type NormalizedPlan = z.infer<typeof normalizedPlanSchema>;
type NormalizedAdjustedPlan = z.infer<typeof normalizedAdjustedPlanSchema>;
const dayKeyPattern = /^day[_\s-]?(\d+)$/i;
const EMPTY_PLAN_ERROR_MESSAGE = "训练计划结果为空,请重试或缩小训练重点后再生成。";
function extractTextContent(content: unknown) {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
const text = content
.map(item => (item && typeof item === "object" && "text" in item ? String((item as { text?: unknown }).text ?? "") : ""))
.join("")
.trim();
return text.length > 0 ? text : null;
}
return null;
}
function parseJsonContent(content: unknown) {
const text = extractTextContent(content);
if (!text) {
throw new Error("LLM did not return text content");
}
try {
return JSON.parse(text) as Record<string, unknown>;
} catch (error) {
throw new Error(`LLM returned invalid JSON: ${error instanceof Error ? error.message : "unknown error"}`);
}
}
function toPositiveNumber(value: unknown, fallback: number) {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function toPositiveInteger(value: unknown, fallback: number) {
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function firstMeaningfulText(...values: Array<unknown>) {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
function inferCategory(...values: Array<unknown>) {
const text = values
.filter((value): value is string => typeof value === "string")
.join(" ");
if (/(墙|wall)/i.test(text)) return "墙壁练习";
if (/(步|移动|footwork|shuffle|split step)/i.test(text)) return "脚步移动";
if (/(挥拍|shadow|正手|反手|发球|截击)/i.test(text)) return "影子挥拍";
return "体能训练";
}
function normalizeExercise(
day: number,
exercise: Record<string, unknown>,
section?: Record<string, unknown>
): NormalizedExercise {
const name =
typeof exercise.name === "string" && exercise.name.trim().length > 0
? exercise.name.trim()
: typeof section?.focus === "string" && section.focus.trim().length > 0
? section.focus.trim()
: `${day}天训练项目`;
const description =
typeof exercise.description === "string" && exercise.description.trim().length > 0
? exercise.description.trim()
: typeof section?.focus === "string" && section.focus.trim().length > 0
? section.focus.trim()
: `${name}训练`;
const tips =
typeof exercise.tips === "string" && exercise.tips.trim().length > 0
? exercise.tips.trim()
: typeof section?.focus === "string" && section.focus.trim().length > 0
? `重点关注:${section.focus.trim()}`
: "保持动作稳定,注意训练节奏。";
return {
day,
name,
category: inferCategory(exercise.category, name, description, section?.focus),
duration: toPositiveNumber(
exercise.duration ?? exercise.duration_minutes,
toPositiveNumber(section?.duration_minutes, 10)
),
description,
tips,
sets: toPositiveInteger(exercise.sets, 3),
reps: toPositiveInteger(exercise.reps, 10),
};
}
function createFallbackExercise(day: number, section: Record<string, unknown>) {
const focus = firstMeaningfulText(section.focus, section.summary, section.title, section.theme);
if (!focus) return null;
return normalizeExercise(day, {
day,
name: focus,
description: firstMeaningfulText(section.summary, section.description, `${focus}训练`) ?? `${focus}训练`,
duration: section.duration ?? section.duration_minutes ?? 12,
tips: firstMeaningfulText(section.tips, section.notes, `重点关注:${focus}`) ?? `重点关注:${focus}`,
sets: 3,
reps: 10,
}, section);
}
function normalizeCanonicalPlan(
raw: Record<string, unknown>,
fallbackTitle: string,
): NormalizedPlan {
const rawExercises = Array.isArray(raw.exercises)
? raw.exercises.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === "object" && !Array.isArray(item),
)
: [];
const exercises = rawExercises.map((exercise, index) =>
normalizeExercise(
toPositiveInteger(exercise.day, index + 1),
exercise,
),
);
if (exercises.length === 0) {
throw new Error(EMPTY_PLAN_ERROR_MESSAGE);
}
return normalizedPlanSchema.parse({
title:
typeof raw.title === "string" && raw.title.trim().length > 0
? raw.title.trim()
: fallbackTitle,
exercises,
});
}
function normalizeDayMapPlan(
raw: Record<string, unknown>,
fallbackTitle: string
): NormalizedPlan {
const exercises: NormalizedExercise[] = [];
for (const [key, value] of Object.entries(raw)) {
const match = key.match(dayKeyPattern);
if (!match || !value || typeof value !== "object" || Array.isArray(value)) {
continue;
}
const day = Number.parseInt(match[1] ?? "", 10);
if (!Number.isFinite(day) || day <= 0) {
continue;
}
const section = value as Record<string, unknown>;
const sectionExercises = Array.isArray(section.exercises)
? section.exercises.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === "object" && !Array.isArray(item)
)
: [];
if (sectionExercises.length > 0) {
for (const exercise of sectionExercises) {
exercises.push(normalizeExercise(day, exercise, section));
}
continue;
}
const fallbackExercise = createFallbackExercise(day, section);
if (fallbackExercise) {
exercises.push(fallbackExercise);
}
}
if (exercises.length === 0) {
throw new Error(EMPTY_PLAN_ERROR_MESSAGE);
}
return normalizedPlanSchema.parse({
title:
typeof raw.title === "string" && raw.title.trim().length > 0
? raw.title.trim()
: fallbackTitle,
exercises,
});
}
export function normalizeTrainingPlanResponse(params: {
content: unknown;
fallbackTitle: string;
}): NormalizedPlan {
const raw = parseJsonContent(params.content);
if (Array.isArray(raw.exercises)) {
return normalizeCanonicalPlan(raw, params.fallbackTitle);
}
return normalizeDayMapPlan(raw, params.fallbackTitle);
}
export function normalizeAdjustedPlanResponse(params: {
content: unknown;
fallbackTitle: string;
}): NormalizedAdjustedPlan {
const raw = parseJsonContent(params.content);
if (Array.isArray(raw.exercises)) {
const normalized = normalizeCanonicalPlan(raw, params.fallbackTitle);
return normalizedAdjustedPlanSchema.parse({
...normalized,
adjustmentNotes:
typeof raw.adjustmentNotes === "string" && raw.adjustmentNotes.trim().length > 0
? raw.adjustmentNotes.trim()
: "已根据最近分析结果调整训练内容。",
});
}
const normalized = normalizeDayMapPlan(raw, params.fallbackTitle);
return normalizedAdjustedPlanSchema.parse({
...normalized,
adjustmentNotes:
typeof raw.adjustmentNotes === "string" && raw.adjustmentNotes.trim().length > 0
? raw.adjustmentNotes.trim()
: typeof raw.adjustment_notes === "string" && raw.adjustment_notes.trim().length > 0
? raw.adjustment_notes.trim()
: "已根据最近分析结果调整训练内容。",
});
}