Fix training plan generation flow
这个提交包含在:
200
server/trainingPlan.ts
普通文件
200
server/trainingPlan.ts
普通文件
@@ -0,0 +1,200 @@
|
||||
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()
|
||||
: "已根据最近分析结果调整训练内容。",
|
||||
});
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户