文件
tennis-training-hub/server/trainingPlan.ts
2026-03-14 23:16:19 +08:00

201 行
6.0 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;
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 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 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)
)
: [];
for (const exercise of sectionExercises) {
exercises.push(normalizeExercise(day, exercise, section));
}
}
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 normalizedPlanSchema.parse(raw);
}
return normalizeDayMapPlan(raw, params.fallbackTitle);
}
export function normalizeAdjustedPlanResponse(params: {
content: unknown;
fallbackTitle: string;
}): NormalizedAdjustedPlan {
const raw = parseJsonContent(params.content);
if (Array.isArray(raw.exercises)) {
return normalizedAdjustedPlanSchema.parse({
...raw,
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()
: "已根据最近分析结果调整训练内容。",
});
}