ManimCat / src /services /problem-framing.ts
Bin29's picture
Sync from main: c1ef036 chore: document docker persistence volumes
94e1b2f
import { createCustomOpenAIClient } from './openai-client-factory'
import { createChatCompletionText } from './openai-stream'
import { buildTokenParams } from '../utils/reasoning-model'
import { createLogger } from '../utils/logger'
import { getRoleSystemPrompt, getRoleUserPrompt } from '../prompts'
import { buildVisionUserMessage, shouldRetryWithoutImages } from './concept-designer-utils'
import type { CustomApiConfig, PromptLocale, PromptOverrides, ReferenceImage } from '../types'
const logger = createLogger('ProblemFraming')
const PLANNER_TEMPERATURE = parseFloat(process.env.PROBLEM_FRAMING_TEMPERATURE || '0.7')
const PLANNER_MAX_TOKENS = parseInt(process.env.PROBLEM_FRAMING_MAX_TOKENS || '2400', 10)
const PLANNER_THINKING_TOKENS = parseInt(process.env.PROBLEM_FRAMING_THINKING_TOKENS || '4000', 10)
export interface ProblemFramingStep {
title: string
content: string
}
export interface ProblemFramingPlan {
mode: 'clarify' | 'invent'
headline: string
summary: string
steps: ProblemFramingStep[]
visualMotif: string
designerHint: string
}
interface ProblemFramingParams {
concept: string
feedback?: string
feedbackHistory?: string[]
currentPlan?: ProblemFramingPlan
referenceImages?: ReferenceImage[]
customApiConfig: CustomApiConfig
locale?: PromptLocale
promptOverrides?: PromptOverrides
}
function stripCodeFence(text: string): string {
return text
.replace(/^```json\s*/i, '')
.replace(/^```\s*/i, '')
.replace(/\s*```$/, '')
.trim()
}
function extractJsonObject(text: string): string {
const cleaned = stripCodeFence(text)
if (/^\s*<!DOCTYPE\s+html/i.test(cleaned) || /^\s*<html/i.test(cleaned)) {
throw new Error('Problem framing response was HTML, not JSON')
}
const start = cleaned.indexOf('{')
const end = cleaned.lastIndexOf('}')
if (start === -1 || end === -1 || end <= start) {
throw new Error('Problem framing response did not contain a JSON object')
}
return cleaned.slice(start, end + 1)
}
function sanitizeString(value: unknown, fallback: string): string {
if (typeof value !== 'string') {
return fallback
}
const normalized = value.trim().replace(/\s+/g, ' ')
return normalized || fallback
}
function normalizePlan(raw: unknown, locale: PromptLocale): ProblemFramingPlan {
if (!raw || typeof raw !== 'object') {
throw new Error('Problem framing response was not an object')
}
const input = raw as {
mode?: unknown
headline?: unknown
summary?: unknown
steps?: unknown
visualMotif?: unknown
visual_motif?: unknown
designerHint?: unknown
designer_hint?: unknown
}
const fallbackStepTitle = locale === 'en-US' ? 'Step' : '步骤'
const fallbackStepContent =
locale === 'en-US'
? 'Continue clarifying the visual direction and storytelling order for this part.'
: '继续细化这一段的可视化表达和叙事顺序。'
const fallbackHeadline = locale === 'en-US' ? 'A fresh visualization plan' : '新的可视化方案'
const fallbackSummary = locale === 'en-US' ? 'The expression path has been organized more clearly.' : '整理出一个更清晰的表达路径。'
const fallbackMotif = locale === 'en-US' ? 'Cat paws are sorting the steps across the card.' : '猫爪在卡片上整理出步骤。'
const fallbackHint = locale === 'en-US' ? 'The next designer stage should expand these three steps into concrete animation design.' : '下一阶段继续把三步扩成具体动画设计。'
const steps = Array.isArray(input.steps) ? input.steps : []
const normalizedSteps = steps
.slice(0, 5)
.map((step, index) => {
const item = step && typeof step === 'object' ? step as { title?: unknown; content?: unknown } : {}
return {
title: sanitizeString(item.title, `${fallbackStepTitle} ${index + 1}`),
content: sanitizeString(item.content, '')
}
})
.filter((step) => step.content)
while (normalizedSteps.length < 3) {
normalizedSteps.push({
title: `${fallbackStepTitle} ${normalizedSteps.length + 1}`,
content: fallbackStepContent
})
}
return {
mode: input.mode === 'clarify' ? 'clarify' : 'invent',
headline: sanitizeString(input.headline, fallbackHeadline),
summary: sanitizeString(input.summary, fallbackSummary),
steps: normalizedSteps,
visualMotif: sanitizeString(input.visualMotif ?? input.visual_motif, fallbackMotif),
designerHint: sanitizeString(input.designerHint ?? input.designer_hint, fallbackHint)
}
}
export async function generateProblemFramingPlan(params: ProblemFramingParams): Promise<ProblemFramingPlan> {
const locale = params.locale === 'en-US' ? 'en-US' : 'zh-CN'
const client = createCustomOpenAIClient(params.customApiConfig)
const model = (params.customApiConfig.model || '').trim()
if (!model) {
throw new Error('No model available')
}
logger.info('Problem framing started', {
locale,
conceptLength: params.concept.length,
hasFeedback: !!params.feedback,
hasCurrentPlan: !!params.currentPlan,
hasImages: !!params.referenceImages?.length
})
const promptOverrides: PromptOverrides = { ...params.promptOverrides, locale }
const systemPrompt = getRoleSystemPrompt('problemFraming', promptOverrides)
const userPrompt = getRoleUserPrompt(
'problemFraming',
{
concept: params.concept,
instructions: params.feedback,
feedbackHistory: params.feedbackHistory?.length ? params.feedbackHistory.map((item, index) => `${index + 1}. ${item}`).join('\n') : undefined,
sceneDesign: params.currentPlan ? JSON.stringify(params.currentPlan, null, 2) : undefined
},
promptOverrides
)
let response: Awaited<ReturnType<typeof createChatCompletionText>>
try {
response = await createChatCompletionText(
client,
{
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: buildVisionUserMessage(userPrompt, params.referenceImages) }
],
temperature: PLANNER_TEMPERATURE,
...buildTokenParams(PLANNER_THINKING_TOKENS, PLANNER_MAX_TOKENS)
},
{ fallbackToNonStream: true, usageLabel: 'problem-framing' }
)
} catch (error) {
if (params.referenceImages && params.referenceImages.length > 0 && shouldRetryWithoutImages(error)) {
logger.warn('Problem framing model does not support reference images, retrying with text only', {
concept: params.concept,
error: error instanceof Error ? error.message : String(error)
})
response = await createChatCompletionText(
client,
{
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: PLANNER_TEMPERATURE,
...buildTokenParams(PLANNER_THINKING_TOKENS, PLANNER_MAX_TOKENS)
},
{ fallbackToNonStream: true, usageLabel: 'problem-framing-text-fallback' }
)
} else {
throw error
}
}
const parsed = JSON.parse(extractJsonObject(response.content))
const plan = normalizePlan(parsed, locale)
logger.info('Problem framing completed', {
mode: plan.mode,
headline: plan.headline,
stepCount: plan.steps.length
})
return plan
}