201 行
6.0 KiB
TypeScript
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()
|
|
: "已根据最近分析结果调整训练内容。",
|
|
});
|
|
}
|