import { nanoid } from "nanoid"; import { ENV } from "./_core/env"; import { invokeLLM, type Message } from "./_core/llm"; import * as db from "./db"; import * as matchStore from "./matchStore"; import { getRemoteMediaSession } from "./mediaService"; import { applyComparablePriceBenchmark, buildMarketSearchQuery, enrichRacketListing, formatMarketPushText, listingMatchesWatchRule, loadMarketConfig, MARKET_SOURCES, searchMarketSource, } from "./market"; import { buildAdjustedTrainingPlanPrompt, buildMultimodalCorrectionPrompt, buildTextCorrectionPrompt, buildTrainingPlanPrompt, multimodalCorrectionSchema, renderMultimodalCorrectionMarkdown, } from "./prompts"; import { toPublicUrl } from "./publicUrl"; import { storagePut } from "./storage"; import { extractStructuredJsonContent, normalizeMultimodalCorrectionReport } from "./vision"; import { normalizeAdjustedPlanResponse, normalizeTrainingPlanResponse, } from "./trainingPlan"; import { refreshAllUsersNtrp, refreshUserNtrp, syncRecordingTrainingData } from "./trainingAutomation"; type TaskRow = Awaited>; type StructuredParams = { model?: string; baseMessages: Array<{ role: "system" | "user"; content: string | Message["content"] }>; responseFormat: { type: "json_schema"; json_schema: { name: string; strict: true; schema: Record; }; }; parse: (content: unknown) => T; timeoutMs?: number; retryCount?: number; }; const TRAINING_PLAN_LLM_TIMEOUT_MS = Math.max(ENV.llmTimeoutMs, 120_000); const TRAINING_PLAN_LLM_RETRY_COUNT = Math.max(ENV.llmRetryCount, 2); async function invokeStructured(params: StructuredParams) { let lastError: unknown; 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({ apiUrl: params.model === ENV.llmVisionModel ? ENV.llmVisionApiUrl : undefined, apiKey: params.model === ENV.llmVisionModel ? ENV.llmVisionApiKey : undefined, model: params.model, messages: [...params.baseMessages, ...retryHint], response_format: params.responseFormat, timeoutMs: params.timeoutMs, retryCount: params.retryCount, }); 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"); } function contentToPlainText(content: Message["content"]) { if (typeof content === "string") { return content; } const parts = Array.isArray(content) ? content : [content]; return parts .map((part) => { if (typeof part === "string") { return part; } if (part.type === "text") { return part.text; } if (part.type === "image_url") { return `[image] ${part.image_url.url}`; } if (part.type === "file_url") { return `[file] ${part.file_url.url}`; } return ""; }) .filter(Boolean) .join("\n"); } function parseDataUrl(input: string) { const match = input.match(/^data:(.+?);base64,(.+)$/); if (!match) { throw new Error("Invalid image data URL"); } return { contentType: match[1], buffer: Buffer.from(match[2], "base64"), }; } async function persistInlineImages(userId: number, imageDataUrls: string[]) { const persistedUrls: string[] = []; for (let index = 0; index < imageDataUrls.length; index++) { const { contentType, buffer } = parseDataUrl(imageDataUrls[index]); const extension = contentType.includes("png") ? "png" : "jpg"; const key = `analysis-images/${userId}/${nanoid()}.${extension}`; const uploaded = await storagePut(key, buffer, contentType); persistedUrls.push(toPublicUrl(uploaded.url)); } return persistedUrls; } export async function prepareCorrectionImageUrls(input: { userId: number; imageUrls?: string[]; imageDataUrls?: string[]; }) { const directUrls = (input.imageUrls ?? []).map((item) => toPublicUrl(item)); const uploadedUrls = input.imageDataUrls?.length ? await persistInlineImages(input.userId, input.imageDataUrls) : []; return [...directUrls, ...uploadedUrls]; } async function runTrainingPlanGenerateTask(task: NonNullable) { const payload = task.payload as { skillLevel: "beginner" | "intermediate" | "advanced"; durationDays: number; focusAreas?: string[]; }; const user = await db.getUserById(task.userId); if (!user) { throw new Error("User not found"); } const latestSnapshot = await db.getLatestNtrpSnapshot(task.userId); const trainingProfileStatus = db.getTrainingProfileStatus(user, latestSnapshot); if (!trainingProfileStatus.isComplete) { const missingLabels = trainingProfileStatus.missingFields.map((field) => db.TRAINING_PROFILE_FIELD_LABELS[field]).join("、"); throw new Error(`训练计划生成前请先完善训练档案:${missingLabels}`); } const analyses = await db.getUserAnalyses(task.userId); const recentScores = analyses.slice(0, 5).map((analysis) => ({ score: analysis.overallScore ?? null, issues: analysis.detectedIssues, exerciseType: analysis.exerciseType ?? null, shotCount: analysis.shotCount ?? null, strokeConsistency: analysis.strokeConsistency ?? null, footworkScore: analysis.footworkScore ?? null, })); const parsed = await invokeStructured({ baseMessages: [ { role: "system", content: "你是网球训练计划生成器。返回严格的 JSON 格式。" }, { role: "user", content: buildTrainingPlanPrompt({ ...payload, recentScores, effectiveNtrpRating: trainingProfileStatus.effectiveNtrp, ntrpSource: trainingProfileStatus.ntrpSource, assessmentSnapshot: trainingProfileStatus.assessmentSnapshot, }), }, ], responseFormat: { type: "json_schema", json_schema: { name: "training_plan", strict: true, schema: { type: "object", properties: { title: { type: "string" }, 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", "exercises"], additionalProperties: false, }, }, }, parse: (content) => normalizeTrainingPlanResponse({ content, fallbackTitle: `${payload.durationDays}天训练计划`, }), timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS, retryCount: TRAINING_PLAN_LLM_RETRY_COUNT, }); const planId = await db.createTrainingPlan({ userId: task.userId, title: parsed.title, skillLevel: payload.skillLevel, durationDays: payload.durationDays, exercises: parsed.exercises, isActive: 1, version: 1, }); return { kind: "training_plan_generate" as const, planId, plan: parsed, }; } async function runTrainingPlanAdjustTask(task: NonNullable) { const payload = task.payload as { planId: number }; const analyses = await db.getUserAnalyses(task.userId); const recentAnalyses = analyses.slice(0, 5); const currentPlan = (await db.getUserTrainingPlans(task.userId)).find((plan) => plan.id === payload.planId); if (!currentPlan) { throw new Error("Plan not found"); } const parsed = await invokeStructured({ baseMessages: [ { role: "system", content: "你是网球训练计划调整器。返回严格的 JSON 格式。" }, { role: "user", content: buildAdjustedTrainingPlanPrompt({ currentExercises: currentPlan.exercises, recentAnalyses: recentAnalyses.map((analysis) => ({ score: analysis.overallScore ?? null, issues: analysis.detectedIssues, corrections: analysis.corrections, shotCount: analysis.shotCount ?? null, strokeConsistency: analysis.strokeConsistency ?? null, footworkScore: analysis.footworkScore ?? null, fluidityScore: analysis.fluidityScore ?? null, })), }), }, ], responseFormat: { type: "json_schema", json_schema: { name: "adjusted_plan", strict: true, schema: { type: "object", properties: { title: { type: "string" }, adjustmentNotes: { type: "string" }, 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, }), timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS, retryCount: TRAINING_PLAN_LLM_RETRY_COUNT, }); await db.updateTrainingPlan(payload.planId, { exercises: parsed.exercises, adjustmentNotes: parsed.adjustmentNotes, version: (currentPlan.version || 1) + 1, }); return { kind: "training_plan_adjust" as const, planId: payload.planId, plan: parsed, adjustmentNotes: parsed.adjustmentNotes, }; } async function runTextCorrectionTask(task: NonNullable) { const payload = task.payload as { exerciseType: string; poseMetrics: unknown; detectedIssues: unknown; }; return createTextCorrectionResult(payload); } async function createTextCorrectionResult(payload: { exerciseType: string; poseMetrics: unknown; detectedIssues: unknown; }) { const response = await invokeLLM({ messages: [ { role: "system", content: "你是一位专业网球技术教练。输出中文 Markdown,内容具体、克制、可执行。", }, { role: "user", content: buildTextCorrectionPrompt(payload), }, ], }); return { kind: "analysis_corrections" as const, corrections: contentToPlainText(response.choices[0]?.message?.content || "暂无建议"), }; } async function runMultimodalCorrectionTask(task: NonNullable) { const payload = task.payload as { exerciseType: string; poseMetrics: unknown; detectedIssues: unknown; imageUrls: string[]; }; try { const report = await invokeStructured({ model: ENV.llmVisionModel, baseMessages: [ { role: "system", content: "你是专业网球教练。请基于图片和结构化姿态指标输出严格 JSON。" }, { role: "user", content: [ { type: "text", text: buildMultimodalCorrectionPrompt({ exerciseType: payload.exerciseType, poseMetrics: payload.poseMetrics, detectedIssues: payload.detectedIssues, imageCount: payload.imageUrls.length, }) }, ...payload.imageUrls.map((url) => ({ type: "image_url" as const, image_url: { url, detail: "high" as const, }, })), ], }, ], responseFormat: { type: "json_schema", json_schema: { name: "pose_correction_multimodal", strict: true, schema: multimodalCorrectionSchema, }, }, parse: (content) => normalizeMultimodalCorrectionReport(extractStructuredJsonContent(content)), }); const result = { kind: "pose_correction_multimodal" as const, imageUrls: payload.imageUrls, report, corrections: renderMultimodalCorrectionMarkdown(report as Parameters[0]), visionStatus: "ok" as const, }; await db.completeVisionTestRun(task.id, { visionStatus: "ok", summary: (report as { summary?: string }).summary ?? null, corrections: result.corrections, report, warning: null, }); return result; } catch (error) { const fallback = await createTextCorrectionResult(payload); const result = { kind: "pose_correction_multimodal" as const, imageUrls: payload.imageUrls, report: null, corrections: fallback.corrections, visionStatus: "fallback" as const, warning: error instanceof Error ? error.message : "Vision model unavailable", }; await db.completeVisionTestRun(task.id, { visionStatus: "fallback", summary: null, corrections: result.corrections, report: null, warning: result.warning, }); return result; } } async function runMediaFinalizeTask(task: NonNullable) { const payload = task.payload as { sessionId: string; title: string; exerciseType?: string; sessionMode?: "practice" | "pk"; durationMinutes?: number; actionCount?: number; actionSummary?: Record; dominantAction?: string; validityStatus?: string; invalidReason?: string; }; const session = await getRemoteMediaSession(payload.sessionId); if (session.userId !== String(task.userId)) { throw new Error("Media session does not belong to the task user"); } if (session.archiveStatus === "queued") { await db.rescheduleBackgroundTask(task.id, { progress: 45, message: "录制文件已入队,等待归档", delayMs: 4_000, }); return null; } if (session.archiveStatus === "processing") { await db.rescheduleBackgroundTask(task.id, { progress: 78, message: "录制文件正在整理与转码", delayMs: 4_000, }); return null; } if (session.archiveStatus === "failed") { throw new Error(session.lastError || "Media archive failed"); } if (!session.playback.ready) { await db.rescheduleBackgroundTask(task.id, { progress: 70, message: "等待回放文件就绪", delayMs: 4_000, }); return null; } const preferredUrl = session.playback.mp4Url || session.playback.webmUrl; const format = session.playback.mp4Url ? "mp4" : "webm"; if (!preferredUrl) { throw new Error("Media session did not expose a playback URL"); } const fileKey = `media/sessions/${session.id}/recording.${format}`; const existing = await db.getVideoByFileKey(task.userId, fileKey); if (existing) { return { kind: "media_finalize" as const, sessionId: session.id, videoId: existing.id, url: existing.url, fileKey, format, }; } const publicUrl = toPublicUrl(preferredUrl); const videoId = await db.createVideo({ userId: task.userId, title: payload.title || session.title, fileKey, url: publicUrl, format, fileSize: format === "mp4" ? (session.playback.mp4Size ?? null) : (session.playback.webmSize ?? null), duration: null, exerciseType: payload.exerciseType || "recording", analysisStatus: "completed", }); await syncRecordingTrainingData({ userId: task.userId, videoId, exerciseType: payload.exerciseType || "unknown", title: payload.title || session.title, sessionMode: payload.sessionMode || "practice", durationMinutes: payload.durationMinutes ?? 5, actionCount: payload.actionCount ?? 0, actionSummary: payload.actionSummary ?? {}, dominantAction: payload.dominantAction ?? null, validityStatus: payload.validityStatus ?? "pending", invalidReason: payload.invalidReason ?? null, }); return { kind: "media_finalize" as const, sessionId: session.id, videoId, url: publicUrl, fileKey, format, }; } async function runNtrpRefreshUserTask(task: NonNullable) { const payload = task.payload as { targetUserId?: number }; const targetUserId = payload.targetUserId ?? task.userId; const snapshot = await refreshUserNtrp(targetUserId, { triggerType: "manual", taskId: task.id }); return { kind: "ntrp_refresh_user" as const, targetUserId, snapshot, }; } async function runNtrpRefreshAllTask(task: NonNullable) { const results = await refreshAllUsersNtrp({ triggerType: "daily", taskId: task.id }); return { kind: "ntrp_refresh_all" as const, refreshedUsers: results.length, results, }; } async function persistMarketListing(rawListing: Parameters[0]) { const enriched = enrichRacketListing(rawListing); const saved = await db.upsertRacketListing(enriched); if (!saved) { throw new Error(`Failed to persist market listing: ${rawListing.source}:${rawListing.sourceListingId}`); } const comparable = await db.listRecentComparableRacketListings({ brand: saved.brand, model: saved.model, excludeId: saved.id, limit: 8, }); const benchmarked = applyComparablePriceBenchmark({ ...enriched, brand: saved.brand, model: saved.model, series: saved.series, category: saved.category, weightGram: saved.weightGram, conditionLevel: saved.conditionLevel, gradeLevel: saved.gradeLevel, gradeReason: saved.gradeReason, isLowPriceCandidate: saved.isLowPriceCandidate, }, comparable.map((item) => item.price)); if ( benchmarked.isLowPriceCandidate !== saved.isLowPriceCandidate || benchmarked.gradeReason !== saved.gradeReason ) { await db.updateRacketListing(saved.id, { isLowPriceCandidate: benchmarked.isLowPriceCandidate, gradeReason: benchmarked.gradeReason, }); return (await db.getRacketListingById(saved.id)) ?? saved; } return saved; } async function queueMarketPushTask(userId: number, hitId: number, title: string) { const taskId = nanoid(); await db.createBackgroundTask({ id: taskId, userId, type: "market_push_delivery", title: `低价推送 · ${title}`.slice(0, 256), message: "低价命中已加入飞书推送队列", payload: { hitId }, progress: 0, maxAttempts: 3, }); return taskId; } async function recordMarketWatchHit(params: { rule: Awaited>[number]; listing: Awaited>; repushDelta: number; }) { if (!params.listing) { throw new Error("Listing is required to create a watch hit"); } const now = new Date(); const existing = await db.getRacketWatchHitByRuleAndListing(params.rule.id, params.listing.id); const pushEnabled = params.rule.pushEnabled === 1; if (!existing) { const created = await db.createRacketWatchHit({ watchRuleId: params.rule.id, userId: params.rule.userId, listingId: params.listing.id, matchedPrice: params.listing.price, status: pushEnabled ? "push_queued" : "suppressed", firstMatchedAt: now, lastMatchedAt: now, lastPushPrice: null, pushedAt: null, pushCount: 0, }); return { hit: created, shouldQueuePush: pushEnabled, }; } const lastPushPrice = existing.lastPushPrice ?? null; const shouldQueuePush = pushEnabled && existing.status !== "push_queued" && ( existing.pushCount === 0 || lastPushPrice == null || params.listing.price <= (lastPushPrice - params.repushDelta) ); await db.updateRacketWatchHit(existing.id, { matchedPrice: params.listing.price, lastMatchedAt: now, status: shouldQueuePush ? "push_queued" : (pushEnabled ? existing.status : "suppressed"), }); const hit = await db.getRacketWatchHitByRuleAndListing(params.rule.id, params.listing.id); return { hit, shouldQueuePush, }; } async function runMarketWatchRefreshTask(task: NonNullable) { const payload = task.payload as { scope?: "user" | "all_users"; ruleIds?: number[]; sources?: Array<(typeof MARKET_SOURCES)[number]>; trigger?: string; }; const config = await loadMarketConfig(); const allowedSources = (payload.sources?.length ? payload.sources.filter((item): item is (typeof MARKET_SOURCES)[number] => MARKET_SOURCES.includes(item)) : [...MARKET_SOURCES]); const rules = payload.scope === "all_users" ? await db.listActiveRacketWatchRules({ ruleIds: payload.ruleIds }) : await db.listActiveRacketWatchRules({ userId: task.userId, ruleIds: payload.ruleIds }); if (rules.length === 0) { return { kind: "market_watch_refresh" as const, trigger: payload.trigger ?? "manual", processedRules: 0, listingsSaved: 0, matchedHits: 0, queuedPushes: 0, sourceReports: [], }; } const sourceReports: Array<{ ruleId: number; ruleTitle: string; source: string; ok: boolean; blocked: boolean; message: string; listings: number; }> = []; let listingsSaved = 0; let matchedHits = 0; let queuedPushes = 0; for (const rule of rules) { const query = buildMarketSearchQuery(rule); let latestMatchedAt: Date | undefined; for (const source of allowedSources) { const result = await searchMarketSource(source, query, config); sourceReports.push({ ruleId: rule.id, ruleTitle: rule.title, source, ok: result.ok, blocked: result.blocked, message: result.message, listings: result.listings.length, }); for (const rawListing of result.listings) { const savedListing = await persistMarketListing(rawListing); listingsSaved += 1; if (!listingMatchesWatchRule(savedListing, rule)) { continue; } latestMatchedAt = new Date(); matchedHits += 1; const { hit, shouldQueuePush } = await recordMarketWatchHit({ rule, listing: savedListing, repushDelta: config.repushDelta, }); if (hit && shouldQueuePush) { await queueMarketPushTask(rule.userId, hit.id, rule.title); queuedPushes += 1; } } } await db.updateRacketWatchRule(rule.userId, rule.id, { lastCheckedAt: new Date(), lastMatchedAt: latestMatchedAt, }); } return { kind: "market_watch_refresh" as const, trigger: payload.trigger ?? "manual", processedRules: rules.length, listingsSaved, matchedHits, queuedPushes, sourceReports, }; } async function runMarketSourceSyncTask(task: NonNullable) { const payload = task.payload as { source: (typeof MARKET_SOURCES)[number]; query: string; }; const config = await loadMarketConfig(); const result = await searchMarketSource(payload.source, payload.query, config); let savedCount = 0; for (const rawListing of result.listings) { await persistMarketListing(rawListing); savedCount += 1; } return { kind: "market_source_sync" as const, source: payload.source, query: payload.query, ok: result.ok, blocked: result.blocked, message: result.message, listingsSaved: savedCount, }; } async function runMarketPushDeliveryTask(task: NonNullable) { const payload = task.payload as { hitId: number }; const hit = await db.getRacketWatchHitDeliveryPayload(payload.hitId); if (!hit) { throw new Error("Market watch hit not found"); } const config = await loadMarketConfig(); if (!config.defaultFeishuWebhook.trim()) { throw new Error("Market Feishu webhook is not configured"); } const text = formatMarketPushText({ ruleTitle: hit.ruleTitle, source: hit.listingSource, title: hit.listingTitle, price: hit.matchedPrice, targetPrice: hit.ruleTargetPrice, brand: hit.listingBrand, model: hit.listingModel, category: hit.listingCategory, weightGram: hit.listingWeightGram, gradeLevel: hit.listingGradeLevel, gradeReason: hit.listingGradeReason, listingUrl: hit.listingUrl, fetchedAt: hit.listingFetchedAt, }); const response = await fetch(config.defaultFeishuWebhook, { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ msg_type: "text", content: { text, }, }), }); if (!response.ok) { const detail = await response.text().catch(() => ""); throw new Error(`Feishu webhook failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`); } await db.updateRacketWatchHit(hit.id, { status: "pushed", lastPushPrice: hit.matchedPrice, pushedAt: new Date(), pushCount: (hit.pushCount ?? 0) + 1, }); await db.createNotification({ userId: hit.userId, notificationType: "racket_price_alert", title: `低价命中 · ${hit.ruleTitle}`.slice(0, 256), message: text, isRead: 0, }); return { kind: "market_push_delivery" as const, hitId: hit.id, delivered: true, destination: "feishu_webhook", }; } async function runMatchScoreSuggestTask(task: NonNullable) { const payload = task.payload as { matchId: number }; const session = await matchStore.getMatchSessionById(payload.matchId); if (!session) { throw new Error("Match session not found"); } const suggestion = await matchStore.generateSuggestedMatchState(payload.matchId); return { kind: "match_score_suggest" as const, matchId: payload.matchId, workflowStatus: "review_pending", score: suggestion.score, metrics: suggestion.metrics, eventCount: suggestion.eventCount, sourceCount: suggestion.sourceCount, }; } async function runMatchFinalizeTask(task: NonNullable) { const payload = task.payload as { matchId: number; finalizedByUserId?: number; }; const finalized = await matchStore.finalizeMatchSettlement( payload.matchId, payload.finalizedByUserId ?? task.userId, ); return { kind: "match_finalize" as const, matchId: payload.matchId, workflowStatus: finalized?.workflowStatus ?? "finalized", finalizedAt: finalized?.finalizedAt ?? new Date(), }; } export async function processBackgroundTask(task: NonNullable) { switch (task.type) { case "training_plan_generate": return runTrainingPlanGenerateTask(task); case "training_plan_adjust": return runTrainingPlanAdjustTask(task); case "analysis_corrections": return runTextCorrectionTask(task); case "pose_correction_multimodal": return runMultimodalCorrectionTask(task); case "media_finalize": return runMediaFinalizeTask(task); case "ntrp_refresh_user": return runNtrpRefreshUserTask(task); case "ntrp_refresh_all": return runNtrpRefreshAllTask(task); case "market_watch_refresh": return runMarketWatchRefreshTask(task); case "market_source_sync": return runMarketSourceSyncTask(task); case "market_push_delivery": return runMarketPushDeliveryTask(task); case "match_score_suggest": return runMatchScoreSuggestTask(task); case "match_finalize": return runMatchFinalizeTask(task); default: throw new Error(`Unsupported task type: ${String(task.type)}`); } }