feat: async task pipeline for media and llm workflows
这个提交包含在:
@@ -4,53 +4,34 @@ import { systemRouter } from "./_core/systemRouter";
|
||||
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
||||
import { z } from "zod";
|
||||
import { sdk } from "./_core/sdk";
|
||||
import { invokeLLM } from "./_core/llm";
|
||||
import { storagePut } from "./storage";
|
||||
import * as db from "./db";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
normalizeAdjustedPlanResponse,
|
||||
normalizeTrainingPlanResponse,
|
||||
} from "./trainingPlan";
|
||||
import { getRemoteMediaSession } from "./mediaService";
|
||||
import { prepareCorrectionImageUrls } from "./taskWorker";
|
||||
import { toPublicUrl } from "./publicUrl";
|
||||
|
||||
async function invokeStructuredPlan<T>(params: {
|
||||
baseMessages: Array<{ role: "system" | "user"; content: string }>;
|
||||
responseFormat: {
|
||||
type: "json_schema";
|
||||
json_schema: {
|
||||
name: string;
|
||||
strict: true;
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
parse: (content: unknown) => T;
|
||||
async function enqueueTask(params: {
|
||||
userId: number;
|
||||
type: "media_finalize" | "training_plan_generate" | "training_plan_adjust" | "analysis_corrections" | "pose_correction_multimodal";
|
||||
title: string;
|
||||
payload: Record<string, unknown>;
|
||||
message: string;
|
||||
}) {
|
||||
let lastError: unknown;
|
||||
const taskId = nanoid();
|
||||
await db.createBackgroundTask({
|
||||
id: taskId,
|
||||
userId: params.userId,
|
||||
type: params.type,
|
||||
title: params.title,
|
||||
message: params.message,
|
||||
payload: params.payload,
|
||||
progress: 0,
|
||||
maxAttempts: params.type === "media_finalize" ? 90 : 3,
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const retryHint =
|
||||
attempt === 0 || !(lastError instanceof Error)
|
||||
? []
|
||||
: [{
|
||||
role: "user" as const,
|
||||
content:
|
||||
`上一次输出无法被系统解析,错误是:${lastError.message}。` +
|
||||
"请只返回一个合法、完整、可解析的 JSON 对象,不要包含额外说明、注释或 Markdown 代码块。",
|
||||
}];
|
||||
|
||||
const response = await invokeLLM({
|
||||
messages: [...params.baseMessages, ...retryHint],
|
||||
response_format: params.responseFormat,
|
||||
});
|
||||
|
||||
try {
|
||||
return params.parse(response.choices[0]?.message?.content);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("Failed to parse structured LLM response");
|
||||
const task = await db.getBackgroundTaskById(taskId);
|
||||
return { taskId, task };
|
||||
}
|
||||
|
||||
export const appRouter = router({
|
||||
@@ -104,86 +85,13 @@ export const appRouter = router({
|
||||
focusAreas: z.array(z.string()).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.user;
|
||||
// Get user's recent analyses for personalization
|
||||
const analyses = await db.getUserAnalyses(user.id);
|
||||
const recentScores = analyses.slice(0, 5).map(a => ({
|
||||
score: a.overallScore,
|
||||
issues: a.detectedIssues,
|
||||
exerciseType: a.exerciseType,
|
||||
shotCount: a.shotCount,
|
||||
strokeConsistency: a.strokeConsistency,
|
||||
footworkScore: a.footworkScore,
|
||||
}));
|
||||
|
||||
const prompt = `你是一位网球教练。请为一位${
|
||||
input.skillLevel === "beginner" ? "初级" : input.skillLevel === "intermediate" ? "中级" : "高级"
|
||||
}水平的网球学员生成一个${input.durationDays}天的训练计划。
|
||||
|
||||
要求:
|
||||
- 只需要球拍,不需要球场和球网
|
||||
- 包含影子挥拍、墙壁练习、脚步移动、体能训练等
|
||||
- 每天训练30-60分钟
|
||||
${input.focusAreas?.length ? `- 重点关注: ${input.focusAreas.join(", ")}` : ""}
|
||||
${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(recentScores)}` : ""}
|
||||
|
||||
请返回JSON格式,包含每天的训练内容。`;
|
||||
|
||||
const parsed = await invokeStructuredPlan({
|
||||
baseMessages: [
|
||||
{ role: "system", content: "你是网球训练计划生成器。返回严格的JSON格式。" },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
responseFormat: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "training_plan",
|
||||
strict: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "训练计划标题" },
|
||||
exercises: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
day: { type: "number" },
|
||||
name: { type: "string" },
|
||||
category: { type: "string" },
|
||||
duration: { type: "number", description: "分钟" },
|
||||
description: { type: "string" },
|
||||
tips: { type: "string" },
|
||||
sets: { type: "number" },
|
||||
reps: { type: "number" },
|
||||
},
|
||||
required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["title", "exercises"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
parse: (content) => normalizeTrainingPlanResponse({
|
||||
content,
|
||||
fallbackTitle: `${input.durationDays}天训练计划`,
|
||||
}),
|
||||
return enqueueTask({
|
||||
userId: ctx.user.id,
|
||||
type: "training_plan_generate",
|
||||
title: `${input.durationDays}天训练计划生成`,
|
||||
message: "训练计划已加入后台队列",
|
||||
payload: input,
|
||||
});
|
||||
|
||||
const planId = await db.createTrainingPlan({
|
||||
userId: user.id,
|
||||
title: parsed.title,
|
||||
skillLevel: input.skillLevel,
|
||||
durationDays: input.durationDays,
|
||||
exercises: parsed.exercises,
|
||||
isActive: 1,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
return { planId, plan: parsed };
|
||||
}),
|
||||
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -197,78 +105,15 @@ ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(rec
|
||||
adjust: protectedProcedure
|
||||
.input(z.object({ planId: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const analyses = await db.getUserAnalyses(ctx.user.id);
|
||||
const recentAnalyses = analyses.slice(0, 5);
|
||||
const currentPlan = (await db.getUserTrainingPlans(ctx.user.id)).find(p => p.id === input.planId);
|
||||
if (!currentPlan) throw new Error("Plan not found");
|
||||
|
||||
const prompt = `基于以下用户的姿势分析结果,调整训练计划:
|
||||
|
||||
当前计划: ${JSON.stringify(currentPlan.exercises)}
|
||||
最近分析结果: ${JSON.stringify(recentAnalyses.map(a => ({
|
||||
score: a.overallScore,
|
||||
issues: a.detectedIssues,
|
||||
corrections: a.corrections,
|
||||
shotCount: a.shotCount,
|
||||
strokeConsistency: a.strokeConsistency,
|
||||
footworkScore: a.footworkScore,
|
||||
fluidityScore: a.fluidityScore,
|
||||
})))}
|
||||
|
||||
请根据分析结果调整训练计划,增加针对薄弱环节的训练,返回与原计划相同格式的JSON。`;
|
||||
|
||||
const parsed = await invokeStructuredPlan({
|
||||
baseMessages: [
|
||||
{ role: "system", content: "你是网球训练计划调整器。返回严格的JSON格式。" },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
responseFormat: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "adjusted_plan",
|
||||
strict: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
adjustmentNotes: { type: "string", description: "调整说明" },
|
||||
exercises: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
day: { type: "number" },
|
||||
name: { type: "string" },
|
||||
category: { type: "string" },
|
||||
duration: { type: "number" },
|
||||
description: { type: "string" },
|
||||
tips: { type: "string" },
|
||||
sets: { type: "number" },
|
||||
reps: { type: "number" },
|
||||
},
|
||||
required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["title", "adjustmentNotes", "exercises"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
parse: (content) => normalizeAdjustedPlanResponse({
|
||||
content,
|
||||
fallbackTitle: currentPlan.title,
|
||||
}),
|
||||
return enqueueTask({
|
||||
userId: ctx.user.id,
|
||||
type: "training_plan_adjust",
|
||||
title: `${currentPlan.title} 调整`,
|
||||
message: "训练计划调整任务已加入后台队列",
|
||||
payload: input,
|
||||
});
|
||||
|
||||
await db.updateTrainingPlan(input.planId, {
|
||||
exercises: parsed.exercises,
|
||||
adjustmentNotes: parsed.adjustmentNotes,
|
||||
version: (currentPlan.version || 1) + 1,
|
||||
});
|
||||
|
||||
return { success: true, adjustmentNotes: parsed.adjustmentNotes };
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -287,19 +132,20 @@ ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(rec
|
||||
const fileKey = `videos/${ctx.user.id}/${nanoid()}.${input.format}`;
|
||||
const contentType = input.format === "webm" ? "video/webm" : "video/mp4";
|
||||
const { url } = await storagePut(fileKey, fileBuffer, contentType);
|
||||
const publicUrl = toPublicUrl(url);
|
||||
|
||||
const videoId = await db.createVideo({
|
||||
userId: ctx.user.id,
|
||||
title: input.title,
|
||||
fileKey,
|
||||
url,
|
||||
url: publicUrl,
|
||||
format: input.format,
|
||||
fileSize: input.fileSize,
|
||||
exerciseType: input.exerciseType || null,
|
||||
analysisStatus: "pending",
|
||||
});
|
||||
|
||||
return { videoId, url };
|
||||
return { videoId, url: publicUrl };
|
||||
}),
|
||||
|
||||
registerExternal: protectedProcedure
|
||||
@@ -313,11 +159,12 @@ ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(rec
|
||||
exerciseType: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const publicUrl = toPublicUrl(input.url);
|
||||
const videoId = await db.createVideo({
|
||||
userId: ctx.user.id,
|
||||
title: input.title,
|
||||
fileKey: input.fileKey,
|
||||
url: input.url,
|
||||
url: publicUrl,
|
||||
format: input.format,
|
||||
fileSize: input.fileSize ?? null,
|
||||
duration: input.duration ?? null,
|
||||
@@ -325,7 +172,7 @@ ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(rec
|
||||
analysisStatus: "completed",
|
||||
});
|
||||
|
||||
return { videoId, url: input.url };
|
||||
return { videoId, url: publicUrl };
|
||||
}),
|
||||
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -399,32 +246,70 @@ ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(rec
|
||||
poseMetrics: z.any(),
|
||||
exerciseType: z.string(),
|
||||
detectedIssues: z.any(),
|
||||
imageUrls: z.array(z.string()).optional(),
|
||||
imageDataUrls: z.array(z.string()).max(4).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const response = await invokeLLM({
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "你是一位网球动作分析员。根据MediaPipe姿势分析数据,给出具体的姿势矫正建议。用中文回答。",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `分析以下网球动作数据并给出矫正建议:
|
||||
动作类型: ${input.exerciseType}
|
||||
姿势指标: ${JSON.stringify(input.poseMetrics)}
|
||||
检测到的问题: ${JSON.stringify(input.detectedIssues)}
|
||||
|
||||
请给出:
|
||||
1. 每个问题的具体矫正方法
|
||||
2. 推荐的练习动作
|
||||
3. 需要注意的关键点`,
|
||||
},
|
||||
],
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const imageUrls = await prepareCorrectionImageUrls({
|
||||
userId: ctx.user.id,
|
||||
imageUrls: input.imageUrls,
|
||||
imageDataUrls: input.imageDataUrls,
|
||||
});
|
||||
|
||||
return {
|
||||
corrections: response.choices[0]?.message?.content || "暂无建议",
|
||||
};
|
||||
return enqueueTask({
|
||||
userId: ctx.user.id,
|
||||
type: imageUrls.length > 0 ? "pose_correction_multimodal" : "analysis_corrections",
|
||||
title: `${input.exerciseType} 动作纠正`,
|
||||
message: imageUrls.length > 0 ? "多模态动作纠正任务已加入后台队列" : "动作纠正任务已加入后台队列",
|
||||
payload: {
|
||||
poseMetrics: input.poseMetrics,
|
||||
exerciseType: input.exerciseType,
|
||||
detectedIssues: input.detectedIssues,
|
||||
imageUrls,
|
||||
},
|
||||
});
|
||||
}),
|
||||
}),
|
||||
|
||||
task: router({
|
||||
list: protectedProcedure
|
||||
.input(z.object({ limit: z.number().min(1).max(50).default(20) }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
return db.listUserBackgroundTasks(ctx.user.id, input?.limit ?? 20);
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.input(z.object({ taskId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return db.getUserBackgroundTaskById(ctx.user.id, input.taskId);
|
||||
}),
|
||||
|
||||
retry: protectedProcedure
|
||||
.input(z.object({ taskId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const task = await db.retryBackgroundTask(ctx.user.id, input.taskId);
|
||||
return { task };
|
||||
}),
|
||||
|
||||
createMediaFinalize: protectedProcedure
|
||||
.input(z.object({
|
||||
sessionId: z.string().min(1),
|
||||
title: z.string().min(1).max(256),
|
||||
exerciseType: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getRemoteMediaSession(input.sessionId);
|
||||
if (session.userId !== String(ctx.user.id)) {
|
||||
throw new Error("Media session not found");
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
userId: ctx.user.id,
|
||||
type: "media_finalize",
|
||||
title: `${input.title} 归档`,
|
||||
message: "录制文件归档任务已加入后台队列",
|
||||
payload: input,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户