import { endpoints } from '../../api/client' import type { ContentPlan, SavedTechnicalRules } from '../../types' import { t } from '../../lib/i18n' export interface ConversationResult { text: string controlPayload: Record | null usage?: { coins_used?: number } } export type TechnicalSectionMappingSource = 'existing' | 'generated' export type TechnicalFieldConfidence = 'high' | 'medium' | 'low' export type TechnicalFieldFillBehavior = 'always' | 'relevant' | 'never' export type TechnicalStructureFlexibility = 'strict' | 'varied' | 'free' export type TechnicalContentStructureSource = 'existing' | 'new' export interface TechnicalSectionMappingEntry { heading: string source: TechnicalSectionMappingSource meaning: string instructions: string } export interface TechnicalContentStructureSection { heading: string frequency: string type: string approx_length: string source: TechnicalContentStructureSource } export interface TechnicalContentStructure { common_sections: TechnicalContentStructureSection[] structure_flexibility: TechnicalStructureFlexibility note: string } export interface TechnicalFieldDiagnosis { description: string sample_values: string[] fill_rate: string pattern: string gemini_plan: string confidence: TechnicalFieldConfidence needs_clarification: boolean question?: string fill_behavior: TechnicalFieldFillBehavior content_structure?: TechnicalContentStructure } export interface TechnicalFieldMappingEntry { field_key: string field_label: string field_type: string field_source: string diagnosis: TechnicalFieldDiagnosis } export interface TechnicalStructuredState { phase: 'technical' ready: boolean captured_answers: Record[] open_questions: unknown[] technical_summary: string understanding_percent: number question_cards: TechnicalQuestionCard[] question_responses: Record field_mapping: TechnicalFieldMappingEntry[] } export interface TechnicalQuestionOption { id: string label: string description: string requires_text?: boolean } export type TechnicalQuestionSelectionType = 'radio' | 'checkbox' export interface TechnicalQuestionCard { id: string prompt: string help_text: string selection_type: TechnicalQuestionSelectionType allow_multiple: boolean suggested_index?: number suggested_indices?: number[] has_other: boolean options: TechnicalQuestionOption[] } export interface TechnicalQuestionResponse { selected_option_ids: string[] other_text: string } const SECTION_MAPPING_RULE_KIND = '__section_mapping__' const FIELD_MAPPING_RULE_KIND = '__field_mapping__' export const OTHER_QUESTION_OPTION_ID = '__other__' function getQuestionAnswerErrorStatus(error: unknown): number | null { if (!error || typeof error !== 'object') { return null } const status = (error as { status?: unknown }).status return typeof status === 'number' && Number.isFinite(status) ? status : null } function getQuestionAnswerErrorMessage(error: unknown): string { if (!error || typeof error !== 'object') { return '' } const message = (error as { message?: unknown }).message return typeof message === 'string' ? message : '' } function shouldRetryChatRespond(error: unknown): boolean { const status = getQuestionAnswerErrorStatus(error) const message = getQuestionAnswerErrorMessage(error).toLowerCase() if (status === 429) { return true } return status === 400 && ( message.includes('only one active streamed response is allowed per session') || message.includes('please wait one second before sending another message in this session') || message.includes('too many active chat streams for this site') ) } function extractQuestionAnswerLabel(prompt: string): string { const firstSegment = prompt.split(/\s+[–—-]\s+/)[0]?.trim() || prompt.trim() return firstSegment.replace(/\s*\([^)]*\)\s*$/, '').trim() || prompt.trim() } export const DEFAULT_CONTENT_PLAN: ContentPlan = { audience: { primary: '', secondary: [], }, goal: { primary: '', cta: '', }, focus_areas: [], tone_adjustments: [], standards_to_reference: [], must_include: [], avoid: [], cta_style: '', outline_preferences: { intro_angle: '', section_priority: [], faq: false, }, reporter_adjustments: { keep_reporter_defaults: true, override_tone: '', override_depth: '', }, research_enabled: false, research_scope: { directive: '', languages: [], regions: [], source_preferences: [], freshness: '', }, topic_tree: { total_articles: 0, pillars: [], }, summary: '', } export function createEmptyContentPlan(): ContentPlan { return { audience: { primary: '', secondary: [] }, goal: { primary: '', cta: '' }, focus_areas: [], tone_adjustments: [], standards_to_reference: [], must_include: [], avoid: [], cta_style: '', outline_preferences: { intro_angle: '', section_priority: [], faq: false }, reporter_adjustments: { keep_reporter_defaults: true, override_tone: '', override_depth: '' }, research_enabled: false, research_scope: { directive: '', languages: [], regions: [], source_preferences: [], freshness: '', }, topic_tree: { total_articles: 0, pillars: [], }, summary: '', } } export function createEmptyTechnicalState(): TechnicalStructuredState { return { phase: 'technical', ready: false, captured_answers: [], open_questions: [], technical_summary: '', understanding_percent: 0, question_cards: [], question_responses: {}, field_mapping: [], } } export function normalizeUnderstandingPercent(value: unknown): number { const nextValue = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : 0 if (!Number.isFinite(nextValue)) { return 0 } return Math.min(100, Math.max(0, Math.round(nextValue))) } export function normalizeTechnicalQuestionCards(value: unknown): TechnicalQuestionCard[] { if (!Array.isArray(value)) { return [] } const cards: TechnicalQuestionCard[] = [] value.forEach((item, index) => { if (typeof item === 'string') { const prompt = item.trim() if (!prompt) { return } cards.push({ id: `question-${index + 1}`, prompt, help_text: '', selection_type: 'radio', allow_multiple: false, has_other: true, options: [{ id: OTHER_QUESTION_OPTION_ID, label: t('Other'), description: '', requires_text: true, }], }) return } const raw = item && typeof item === 'object' ? item as Record : {} const rawOptions = Array.isArray(raw.options) ? raw.options : [] const options: TechnicalQuestionOption[] = [] rawOptions.forEach((option, optionIndex) => { const rawOption = option && typeof option === 'object' ? option as Record : {} const label = typeof option === 'string' ? option.trim() : typeof rawOption.label === 'string' ? rawOption.label.trim() : '' if (!label) { return } const isOtherOption = ( typeof rawOption.id === 'string' && rawOption.id.trim() === OTHER_QUESTION_OPTION_ID ) || /^other$/i.test(label) || label === t('Other') options.push({ id: isOtherOption ? OTHER_QUESTION_OPTION_ID : typeof rawOption.id === 'string' && rawOption.id.trim() ? rawOption.id.trim() : `option-${optionIndex + 1}`, label: isOtherOption ? t('Other') : label, description: typeof rawOption.description === 'string' ? rawOption.description.trim() : '', requires_text: isOtherOption || Boolean(rawOption.requires_text) || undefined, }) }) const prompt = typeof raw.prompt === 'string' ? raw.prompt.trim() : '' if (!prompt) { return } const selectionType: TechnicalQuestionSelectionType = raw.selection_type === 'checkbox' || Boolean(raw.allow_multiple) ? 'checkbox' : 'radio' const allowMultiple = selectionType === 'checkbox' const optionCountBeforeOther = options.filter((option) => option.id !== OTHER_QUESTION_OPTION_ID).length const suggestedIndex = typeof raw.suggested_index === 'number' && Number.isInteger(raw.suggested_index) ? raw.suggested_index : undefined const suggestedIndices = Array.isArray(raw.suggested_indices) ? raw.suggested_indices .filter((value): value is number => typeof value === 'number' && Number.isInteger(value)) .filter((value, valueIndex, values) => value >= 0 && value < optionCountBeforeOther && values.indexOf(value) === valueIndex) : [] const hasOther = true const normalizedOptions = options.filter((option) => option.id !== OTHER_QUESTION_OPTION_ID) if (hasOther) { normalizedOptions.push({ id: OTHER_QUESTION_OPTION_ID, label: t('Other'), description: '', requires_text: true, }) } cards.push({ id: typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : `question-${index + 1}`, prompt, help_text: typeof raw.help_text === 'string' ? raw.help_text.trim() : '', selection_type: selectionType, allow_multiple: allowMultiple, suggested_index: suggestedIndex !== undefined && suggestedIndex >= 0 && suggestedIndex < optionCountBeforeOther ? suggestedIndex : undefined, suggested_indices: suggestedIndices.length > 0 ? suggestedIndices : suggestedIndex !== undefined && suggestedIndex >= 0 && suggestedIndex < optionCountBeforeOther ? [suggestedIndex] : undefined, has_other: hasOther, options: normalizedOptions.length > 0 ? normalizedOptions : [{ id: OTHER_QUESTION_OPTION_ID, label: t('Other'), description: '', requires_text: true, }], }) }) return cards } export function normalizeOpenQuestions(value: unknown): string[] { if (!Array.isArray(value)) { return [] } return value .map((item) => { if (typeof item === 'string') { return item.trim() } if (item && typeof item === 'object') { const prompt = (item as Record).prompt return typeof prompt === 'string' ? prompt.trim() : '' } return '' }) .filter(Boolean) } export function normalizeTechnicalQuestionResponses(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {} } return Object.entries(value as Record).reduce>((acc, [key, raw]) => { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return acc } const nextRaw = raw as Record acc[key] = { selected_option_ids: Array.isArray(nextRaw.selected_option_ids) ? nextRaw.selected_option_ids.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) : [], other_text: typeof nextRaw.other_text === 'string' ? nextRaw.other_text : '', } return acc }, {}) } export function getSuggestedQuestionOptionIds(card: TechnicalQuestionCard): string[] { const optionIds = card.options.map((option) => option.id) const optionIdsExcludingOther = optionIds.filter((optionId) => optionId !== OTHER_QUESTION_OPTION_ID) if (card.selection_type === 'checkbox') { if (Array.isArray(card.suggested_indices) && card.suggested_indices.length > 0) { return card.suggested_indices .map((index) => optionIdsExcludingOther[index]) .filter((optionId): optionId is string => Boolean(optionId)) } if (typeof card.suggested_index === 'number') { const optionId = optionIdsExcludingOther[card.suggested_index] return optionId ? [optionId] : [] } return [] } if (typeof card.suggested_index === 'number') { const optionId = optionIdsExcludingOther[card.suggested_index] return optionId ? [optionId] : [] } if (Array.isArray(card.suggested_indices) && card.suggested_indices.length > 0) { const optionId = optionIdsExcludingOther[card.suggested_indices[0]] return optionId ? [optionId] : [] } return [] } export function areTechnicalQuestionCardsComplete( questionCards: TechnicalQuestionCard[], responses: Record, ): boolean { if (questionCards.length === 0) { return true } return questionCards.every((card) => { const response = responses[card.id] if (!response || response.selected_option_ids.length === 0) { return false } return response.selected_option_ids.every((optionId) => { const option = card.options.find((item) => item.id === optionId) if (!option?.requires_text) { return true } return response.other_text.trim().length > 0 }) }) } export function buildTechnicalQuestionSubmission( questionCards: TechnicalQuestionCard[], responses: Record, freeText: string, ): string { const lines: string[] = [] questionCards.forEach((card) => { const response = responses[card.id] if (!response || response.selected_option_ids.length === 0) { return } const selectedLabels = response.selected_option_ids .map((optionId) => card.options.find((item) => item.id === optionId)?.label) .filter((label): label is string => Boolean(label)) const answerLabel = extractQuestionAnswerLabel(card.prompt) const filteredLabels = selectedLabels.filter((label) => label !== t('Other')) const selectedText = filteredLabels.join(', ') const customText = response.other_text.trim() if (selectedText && customText) { lines.push(`${answerLabel}: ${selectedText} (${customText})`) } else if (selectedText) { lines.push(`${answerLabel}: ${selectedText}`) } else if (customText) { lines.push(`${answerLabel}: ${customText}`) } else { lines.push(`${answerLabel}: ${t('Other')}`) } }) if (freeText.trim()) { lines.push(freeText.trim()) } return lines.join('\n').trim() } export function normalizeTechnicalSectionMapping(value: unknown): TechnicalSectionMappingEntry[] { if (!Array.isArray(value)) { return [] } return value .map((entry) => { if (!entry || typeof entry !== 'object') { return null } const raw = entry as Record const source = raw.source === 'generated' ? 'generated' : 'existing' return { heading: typeof raw.heading === 'string' ? raw.heading.trim() : '', source, meaning: typeof raw.meaning === 'string' ? raw.meaning.trim() : '', instructions: typeof raw.instructions === 'string' ? raw.instructions.trim() : '', } satisfies TechnicalSectionMappingEntry }) .filter((entry): entry is TechnicalSectionMappingEntry => Boolean( entry && (entry.heading || entry.meaning || entry.instructions), )) } function normalizeFieldConfidence(value: unknown): TechnicalFieldConfidence { return value === 'high' || value === 'low' ? value : 'medium' } function normalizeFieldFillBehavior(value: unknown): TechnicalFieldFillBehavior { return value === 'always' || value === 'never' ? value : 'relevant' } function normalizeStructureFlexibility(value: unknown): TechnicalStructureFlexibility { return value === 'strict' || value === 'free' ? value : 'varied' } function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return [] } return value .map((item) => typeof item === 'string' ? item.trim() : '') .filter(Boolean) } function normalizeContentStructure(value: unknown): TechnicalContentStructure | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined } const raw = value as Record const commonSections = Array.isArray(raw.common_sections) ? raw.common_sections .map((entry) => { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { return null } const rawEntry = entry as Record const heading = typeof rawEntry.heading === 'string' ? rawEntry.heading.trim() : '' const frequency = typeof rawEntry.frequency === 'string' ? rawEntry.frequency.trim() : '' const type = typeof rawEntry.type === 'string' ? rawEntry.type.trim() : '' const approxLength = typeof rawEntry.approx_length === 'string' ? rawEntry.approx_length.trim() : '' const source = rawEntry.source === 'new' ? 'new' : 'existing' if (!heading && !frequency && !type && !approxLength) { return null } return { heading, frequency, type, approx_length: approxLength, source, } satisfies TechnicalContentStructureSection }) .filter((entry): entry is TechnicalContentStructureSection => Boolean(entry)) : [] if ( commonSections.length === 0 && typeof raw.note !== 'string' && typeof raw.structure_flexibility !== 'string' ) { return undefined } return { common_sections: commonSections, structure_flexibility: normalizeStructureFlexibility(raw.structure_flexibility), note: typeof raw.note === 'string' ? raw.note.trim() : '', } } function createEmptyFieldDiagnosis(): TechnicalFieldDiagnosis { return { description: '', sample_values: [], fill_rate: '', pattern: '', gemini_plan: '', confidence: 'medium', needs_clarification: false, fill_behavior: 'relevant', } } function createFallbackFieldMappingFromSections(sectionMapping: TechnicalSectionMappingEntry[]): TechnicalFieldMappingEntry[] { if (sectionMapping.length === 0) { return [] } return [{ field_key: 'post_content', field_label: 'Main Content', field_type: 'wysiwyg', field_source: 'core', diagnosis: { ...createEmptyFieldDiagnosis(), description: 'Legacy content structure rules restored from a previous section mapping.', gemini_plan: sectionMapping .map((entry) => [entry.heading, entry.instructions].filter(Boolean).join(' - ')) .filter(Boolean) .join('\n'), confidence: 'medium', content_structure: { common_sections: sectionMapping.map((entry) => ({ heading: entry.heading, frequency: entry.source === 'existing' ? 'site pattern' : 'generated', type: entry.meaning, approx_length: '', source: entry.source === 'existing' ? 'existing' : 'new', })), structure_flexibility: 'varied', note: 'Recovered from legacy section mapping.', }, }, }] } function buildLegacySectionMappingFromFieldMapping(fieldMapping: TechnicalFieldMappingEntry[]): TechnicalSectionMappingEntry[] { const contentField = fieldMapping.find((entry) => entry.field_key === 'post_content') const commonSections = contentField?.diagnosis?.content_structure?.common_sections || [] const instructions = contentField?.diagnosis?.gemini_plan || '' return commonSections .map((section) => ({ heading: section.heading, source: section.source === 'new' ? 'generated' as TechnicalSectionMappingSource : 'existing' as TechnicalSectionMappingSource, meaning: section.type, instructions, })) .filter((entry) => entry.heading || entry.meaning || entry.instructions) } export function normalizeTechnicalFieldMapping(value: unknown): TechnicalFieldMappingEntry[] { if (!Array.isArray(value)) { return [] } const entries: TechnicalFieldMappingEntry[] = [] value.forEach((entry) => { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { return } const raw = entry as Record const diagnosisRaw = raw.diagnosis && typeof raw.diagnosis === 'object' && !Array.isArray(raw.diagnosis) ? raw.diagnosis as Record : {} const fieldKey = typeof raw.field_key === 'string' ? raw.field_key.trim() : '' if (!fieldKey) { return } entries.push({ field_key: fieldKey, field_label: typeof raw.field_label === 'string' ? raw.field_label.trim() : fieldKey, field_type: typeof raw.field_type === 'string' && raw.field_type.trim() ? raw.field_type.trim() : 'text', field_source: typeof raw.field_source === 'string' && raw.field_source.trim() ? raw.field_source.trim() : 'custom', diagnosis: { description: typeof diagnosisRaw.description === 'string' ? diagnosisRaw.description.trim() : '', sample_values: normalizeStringArray(diagnosisRaw.sample_values), fill_rate: typeof diagnosisRaw.fill_rate === 'string' ? diagnosisRaw.fill_rate.trim() : '', pattern: typeof diagnosisRaw.pattern === 'string' ? diagnosisRaw.pattern.trim() : '', gemini_plan: typeof diagnosisRaw.gemini_plan === 'string' ? diagnosisRaw.gemini_plan.trim() : '', confidence: normalizeFieldConfidence(diagnosisRaw.confidence), needs_clarification: Boolean(diagnosisRaw.needs_clarification), question: typeof diagnosisRaw.question === 'string' ? diagnosisRaw.question.trim() : undefined, fill_behavior: normalizeFieldFillBehavior(diagnosisRaw.fill_behavior), content_structure: normalizeContentStructure(diagnosisRaw.content_structure), }, }) }) return entries } function mergeFieldDiagnoses( base: TechnicalFieldDiagnosis, incoming: TechnicalFieldDiagnosis | undefined, ): TechnicalFieldDiagnosis { if (!incoming) { return base } return { ...base, ...incoming, sample_values: incoming.sample_values.length > 0 ? incoming.sample_values : base.sample_values, content_structure: incoming.content_structure || base.content_structure, } } export function mergeTechnicalFieldMapping( base: TechnicalFieldMappingEntry[], incoming: TechnicalFieldMappingEntry[], ): TechnicalFieldMappingEntry[] { const baseEntries = normalizeTechnicalFieldMapping(base) const incomingEntries = normalizeTechnicalFieldMapping(incoming) const incomingByKey = new Map(incomingEntries.map((entry) => [entry.field_key, entry])) const merged: TechnicalFieldMappingEntry[] = [] baseEntries.forEach((entry) => { const nextEntry = incomingByKey.get(entry.field_key) if (!nextEntry) { merged.push(entry) return } merged.push({ ...entry, ...nextEntry, diagnosis: mergeFieldDiagnoses(entry.diagnosis, nextEntry.diagnosis), }) incomingByKey.delete(entry.field_key) }) incomingByKey.forEach((entry) => { merged.push(entry) }) return merged } export function buildTechnicalRulesPayload(state: Pick): Record[] { const answers = Array.isArray(state.captured_answers) ? [...state.captured_answers] : [] const fieldMapping = normalizeTechnicalFieldMapping(state.field_mapping) if (fieldMapping.length === 0) { return answers } const legacySectionMapping = buildLegacySectionMappingFromFieldMapping(fieldMapping) return [ ...answers, { kind: FIELD_MAPPING_RULE_KIND, entries: fieldMapping, }, ...(legacySectionMapping.length > 0 ? [{ kind: SECTION_MAPPING_RULE_KIND, entries: legacySectionMapping, }] : []), ] } export function extractTechnicalStateFromSavedRules(rules: SavedTechnicalRules | null): TechnicalStructuredState { const emptyState = createEmptyTechnicalState() if (!rules) { return emptyState } const sourceRules = Array.isArray(rules.rules) ? rules.rules : [] const fieldRule = sourceRules.find((rule) => ( rule && typeof rule === 'object' && !Array.isArray(rule) && (rule as Record).kind === FIELD_MAPPING_RULE_KIND )) as Record | undefined const sectionRule = sourceRules.find((rule) => ( rule && typeof rule === 'object' && !Array.isArray(rule) && (rule as Record).kind === SECTION_MAPPING_RULE_KIND )) as Record | undefined const normalizedFieldMapping = normalizeTechnicalFieldMapping(fieldRule?.entries) const legacySectionMapping = normalizeTechnicalSectionMapping(sectionRule?.entries) return { ...emptyState, ready: true, captured_answers: sourceRules.filter((rule) => !( rule && typeof rule === 'object' && !Array.isArray(rule) && ( (rule as Record).kind === SECTION_MAPPING_RULE_KIND || (rule as Record).kind === FIELD_MAPPING_RULE_KIND ) )), technical_summary: rules.summary, understanding_percent: 100, question_cards: [], question_responses: {}, field_mapping: normalizedFieldMapping.length > 0 ? normalizedFieldMapping : createFallbackFieldMappingFromSections(legacySectionMapping), } } function buildFieldMappingFromLegacyControlPayload(controlPayload: Record): TechnicalFieldMappingEntry[] { if (!Array.isArray(controlPayload.section_mapping)) { return [] } return createFallbackFieldMappingFromSections(normalizeTechnicalSectionMapping(controlPayload.section_mapping)) } export function normalizeTechnicalFieldSourceLabel(source: string): string { const normalized = source.trim().toLowerCase() if (!normalized) { return 'custom' } if (normalized === 'yoast') { return 'seo' } return normalized } export function normalizeTechnicalFieldForEditing(entry: TechnicalFieldMappingEntry): TechnicalFieldMappingEntry { return { ...entry, field_label: entry.field_label || entry.field_key, field_type: entry.field_type || 'text', field_source: normalizeTechnicalFieldSourceLabel(entry.field_source), diagnosis: { ...createEmptyFieldDiagnosis(), ...entry.diagnosis, sample_values: normalizeStringArray(entry.diagnosis.sample_values), content_structure: entry.diagnosis.content_structure ? { ...entry.diagnosis.content_structure, common_sections: (entry.diagnosis.content_structure.common_sections || []).map((section) => ({ heading: section.heading || '', frequency: section.frequency || '', type: section.type || '', approx_length: section.approx_length || '', source: section.source === 'new' ? 'new' : 'existing', })), structure_flexibility: normalizeStructureFlexibility(entry.diagnosis.content_structure.structure_flexibility), } : undefined, }, } } export function getTechnicalClarificationCount(fieldMapping: TechnicalFieldMappingEntry[]): number { return normalizeTechnicalFieldMapping(fieldMapping) .filter((entry) => entry.diagnosis.needs_clarification) .length } export function getTechnicalFieldMappingByCategory(fieldMapping: TechnicalFieldMappingEntry[]) { const normalized = normalizeTechnicalFieldMapping(fieldMapping) return { core: normalized.filter((entry) => entry.field_source === 'core'), seo: normalized.filter((entry) => entry.field_source === 'seo' || entry.field_source === 'yoast'), custom: normalized.filter((entry) => entry.field_source !== 'core' && entry.field_source !== 'seo' && entry.field_source !== 'yoast'), } } export function mergeConversationState( current: Record, controlPayload: Record | null, ): Record { if (!controlPayload || typeof controlPayload !== 'object') { return current } const next: Record = { ...current } if (Array.isArray(controlPayload.captured_answers)) { next.captured_answers = controlPayload.captured_answers } if (Array.isArray(controlPayload.open_questions)) { next.open_questions = controlPayload.open_questions if (!Array.isArray(controlPayload.question_cards) || controlPayload.question_cards.length === 0) { const fallbackCards = normalizeTechnicalQuestionCards(controlPayload.open_questions) if (fallbackCards.length > 0) { next.question_cards = fallbackCards } } } if (typeof controlPayload.phase === 'string') { next.phase = controlPayload.phase } if ('ready' in controlPayload) { next.ready = Boolean(controlPayload.ready) } if (typeof controlPayload.technical_summary === 'string') { next.technical_summary = controlPayload.technical_summary } if ('understanding_percent' in controlPayload) { next.understanding_percent = normalizeUnderstandingPercent(controlPayload.understanding_percent) } const hasQuestionCardsKey = Object.prototype.hasOwnProperty.call(controlPayload, 'question_cards') const nextQuestionCards = hasQuestionCardsKey && Array.isArray(controlPayload.question_cards) ? normalizeTechnicalQuestionCards(controlPayload.question_cards) : [] next.question_cards = nextQuestionCards if (nextQuestionCards.length === 0) { next.question_responses = {} } else if (controlPayload.question_responses && typeof controlPayload.question_responses === 'object' && !Array.isArray(controlPayload.question_responses)) { next.question_responses = normalizeTechnicalQuestionResponses(controlPayload.question_responses) } if (Array.isArray(controlPayload.field_mapping)) { next.field_mapping = normalizeTechnicalFieldMapping(controlPayload.field_mapping) } else if (!Array.isArray(next.field_mapping)) { const legacyFieldMapping = buildFieldMappingFromLegacyControlPayload(controlPayload) if (legacyFieldMapping.length > 0) { next.field_mapping = legacyFieldMapping } } if (controlPayload.content_plan && typeof controlPayload.content_plan === 'object' && !Array.isArray(controlPayload.content_plan)) { const currentPlan = (next.content_plan as ContentPlan | undefined) || createEmptyContentPlan() next.content_plan = mergeContentPlan(currentPlan, controlPayload.content_plan as Partial) } return next } export function formatCoins(value: number): string { return value.toFixed(1) } export async function pollResponse( responseId: string, options?: { signal?: AbortSignal }, ): Promise { for (let attempt = 0; attempt < 120; attempt += 1) { if (options?.signal?.aborted) { throw new DOMException('Aborted', 'AbortError') } const res = await endpoints.getChatResponse(responseId) const data = res.data as any if (data.status === 'complete') { return { text: data.text || '', controlPayload: data.control_payload || null, usage: data.usage || undefined, } } if (data.status === 'error') { throw new Error(data.message || t('AI response failed')) } if (options?.signal?.aborted) { throw new DOMException('Aborted', 'AbortError') } await new Promise((resolve) => setTimeout(resolve, 1000)) } throw new Error(t('AI response timed out')) } export function mergeContentPlan(current: ContentPlan, incoming: Partial | null | undefined): ContentPlan { if (!incoming) { return current } return { ...current, ...incoming, audience: { ...current.audience, ...(incoming.audience || {}), secondary: Array.isArray(incoming.audience?.secondary) ? incoming.audience.secondary : current.audience.secondary, }, goal: { ...current.goal, ...(incoming.goal || {}), }, focus_areas: Array.isArray(incoming.focus_areas) ? incoming.focus_areas : current.focus_areas, tone_adjustments: Array.isArray(incoming.tone_adjustments) ? incoming.tone_adjustments : current.tone_adjustments, standards_to_reference: Array.isArray(incoming.standards_to_reference) ? incoming.standards_to_reference : current.standards_to_reference, must_include: Array.isArray(incoming.must_include) ? incoming.must_include : current.must_include, avoid: Array.isArray(incoming.avoid) ? incoming.avoid : current.avoid, outline_preferences: { ...current.outline_preferences, ...(incoming.outline_preferences || {}), section_priority: Array.isArray(incoming.outline_preferences?.section_priority) ? incoming.outline_preferences.section_priority : current.outline_preferences.section_priority, }, reporter_adjustments: { ...current.reporter_adjustments, ...(incoming.reporter_adjustments || {}), }, research_enabled: typeof incoming.research_enabled === 'boolean' ? incoming.research_enabled : current.research_enabled, research_scope: { ...current.research_scope, ...(incoming.research_scope || {}), languages: Array.isArray(incoming.research_scope?.languages) ? incoming.research_scope.languages : current.research_scope.languages, regions: Array.isArray(incoming.research_scope?.regions) ? incoming.research_scope.regions : current.research_scope.regions, source_preferences: Array.isArray(incoming.research_scope?.source_preferences) ? incoming.research_scope.source_preferences : current.research_scope.source_preferences, }, topic_tree: { ...current.topic_tree, ...(incoming.topic_tree || {}), pillars: Array.isArray(incoming.topic_tree?.pillars) ? incoming.topic_tree.pillars : current.topic_tree.pillars, }, } } export async function sendConversationPolling(params: { mode: 'generation_technical' | 'generation_content' sessionId: string userMessage: string context: Record structuredState: Record signal?: AbortSignal }): Promise { for (let attempt = 0; attempt < 3; attempt += 1) { if (params.signal?.aborted) { throw new DOMException('Aborted', 'AbortError') } try { const res = await endpoints.sendChatRespond({ mode: params.mode, session_id: params.sessionId, user_message: params.userMessage, summary: '', structured_state: params.structuredState, recent_messages: [], context: params.context, }) return pollResponse((res.data as any).response_id as string, { signal: params.signal }) } catch (error) { if (!shouldRetryChatRespond(error) || attempt === 2) { throw error } await new Promise((resolve) => setTimeout(resolve, 1200)) } } throw new Error(t('AI response failed')) }