feat: async task pipeline for media and llm workflows

这个提交包含在:
cryptocommuniums-afk
2026-03-15 00:12:26 +08:00
父节点 1cc863e60e
当前提交 20e183d2da
修改 36 个文件,包含 1961 行新增339 行删除

查看文件

@@ -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,
});
}),
}),