diff --git a/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.test.ts b/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.test.ts index edbd8d9..21bc317 100644 --- a/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.test.ts +++ b/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.test.ts @@ -53,6 +53,38 @@ describe('aiCommentToText (ответ потока «Комментарий ИИ expect(aiCommentToText(text)).toBe(text); }); + // Тот же промпт в другой раз вернул массив, другое имя списка и строки вместо массивов. + it('вариант ответа: массив, suppliers_analysis, плюсы/минусы строкой', () => { + const variant = JSON.stringify([{ + summary: 'Анализ поставщиков по критериям цены и условий поставки', + suppliers_analysis: [ + { supplier: 'BuildCore Supply Ltd.', strengths: 'Самые низкие цены.', weaknesses: 'Самый длительный срок.' }, + ], + conclusion: 'ТОО «СтройЛайн KZ» — оптимальный выбор.', + }]); + + expect(aiCommentToText(variant)).toBe([ + 'Анализ поставщиков по критериям цены и условий поставки', + '', + 'BuildCore Supply Ltd.', + 'Плюсы:', + '- Самые низкие цены.', + 'Минусы:', + '- Самый длительный срок.', + '', + 'Вывод: ТОО «СтройЛайн KZ» — оптимальный выбор.', + ].join('\n')); + }); + + it('рекомендация — отдельной строкой', () => { + expect(aiCommentToText('{"summary":"Итоги.","recommendation":"Берём СтройЛайн."}')) + .toBe('Итоги.\n\nРекомендация: Берём СтройЛайн.'); + }); + + it('массив не из объектов — без изменений', () => { + expect(aiCommentToText('["a","b"]')).toBe('["a","b"]'); + }); + it('частично заполненная структура — только то, что есть', () => { expect(aiCommentToText('{"conclusion":"Берём BuildCore."}')).toBe('Вывод: Берём BuildCore.'); }); diff --git a/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.ts b/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.ts index ad65f8f..1d783f4 100644 --- a/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.ts +++ b/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.ts @@ -1,56 +1,75 @@ -// Оценка одного поставщика в структурированном ответе потока «Комментарий ИИ». -interface SupplierEvaluation { - supplier?: string; - strengths?: string[]; - weaknesses?: string[]; -} - -// Структурированный ответ потока (промпт отвечает JSON, а не текстом). -interface StructuredAiComment { - summary?: string; - suppliers_evaluation?: SupplierEvaluation[]; - conclusion?: string; -} +type JsonObject = Record; const LABEL_STRENGTHS = 'Плюсы:'; const LABEL_WEAKNESSES = 'Минусы:'; const LABEL_CONCLUSION = 'Вывод:'; +const LABEL_RECOMMENDATION = 'Рекомендация:'; -function tryParseObject(raw: string): Record | undefined { +function isObject(value: unknown): value is JsonObject { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function tryParseJson(raw: string): unknown { const trimmed = raw.trim(); - if (!trimmed.startsWith('{')) return undefined; + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return undefined; try { - const parsed: unknown = JSON.parse(trimmed); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : undefined; + return JSON.parse(trimmed); } catch { return undefined; } } -function isStructured(obj: Record): boolean { - return 'summary' in obj || 'suppliers_evaluation' in obj || 'conclusion' in obj; +function asText(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; } -function bulletList(label: string, items: string[] | undefined): string[] { - if (!items || items.length === 0) return []; - return [label, ...items.map(item => `- ${item}`)]; +// Плюсы/минусы модель отдаёт то массивом строк, то одной строкой. +function asList(value: unknown): string[] { + if (Array.isArray(value)) return value.map(asText).filter((item): item is string => item !== undefined); + const text = asText(value); + return text ? [text] : []; } -function supplierBlock(evaluation: SupplierEvaluation): string[] { +// Список поставщиков модель называет по-разному (suppliers_evaluation, suppliers_analysis…) — +// ищем первое поле-массив из объектов с полем supplier. +function findSupplierList(obj: JsonObject): JsonObject[] { + for (const value of Object.values(obj)) { + if (Array.isArray(value) && value.length > 0 && value.every(item => isObject(item) && 'supplier' in item)) { + return value as JsonObject[]; + } + } + return []; +} + +function isStructured(obj: JsonObject): boolean { + return 'summary' in obj || 'conclusion' in obj || 'recommendation' in obj || findSupplierList(obj).length > 0; +} + +function bulletList(label: string, items: string[]): string[] { + return items.length > 0 ? [label, ...items.map(item => `- ${item}`)] : []; +} + +function supplierBlock(supplier: JsonObject): string[] { + const name = asText(supplier.supplier); return [ - ...(evaluation.supplier ? [evaluation.supplier] : []), - ...bulletList(LABEL_STRENGTHS, evaluation.strengths), - ...bulletList(LABEL_WEAKNESSES, evaluation.weaknesses), + ...(name ? [name] : []), + ...bulletList(LABEL_STRENGTHS, asList(supplier.strengths)), + ...bulletList(LABEL_WEAKNESSES, asList(supplier.weaknesses)), ]; } -function structuredToText(comment: StructuredAiComment): string { +function labeled(label: string, value: unknown): string[] { + const text = asText(value); + return text ? [`${label} ${text}`] : []; +} + +function structuredToText(obj: JsonObject): string { + const summary = asText(obj.summary); const blocks: string[][] = [ - comment.summary ? [comment.summary] : [], - ...(comment.suppliers_evaluation ?? []).map(supplierBlock), - comment.conclusion ? [`${LABEL_CONCLUSION} ${comment.conclusion}`] : [], + summary ? [summary] : [], + ...findSupplierList(obj).map(supplierBlock), + labeled(LABEL_CONCLUSION, obj.conclusion), + labeled(LABEL_RECOMMENDATION, obj.recommendation), ]; return blocks .filter(block => block.length > 0) @@ -59,13 +78,19 @@ function structuredToText(comment: StructuredAiComment): string { } // Ответ потока «Комментарий ИИ» → текст для показа и для SP (из SP его берёт и PDF-отчёт, поэтому -// без Markdown-звёздочек: только строки и списки «- »). Поток отвечал по-разному: просто текстом, -// JSON {comment}, а теперь структурой {summary, suppliers_evaluation, conclusion} — её и разворачиваем. +// без Markdown-звёздочек: только строки и списки «- »). Поток отвечал по-разному: текстом, JSON +// {comment}, а теперь структурой, форму которой модель каждый раз выбирает сама: объект или массив +// из одного объекта, список поставщиков под разными именами, плюсы/минусы массивом или строкой. // Всё, что не распознали, возвращаем как есть: лучше показать сырой ответ, чем потерять его. export function aiCommentToText(raw: string): string { - const obj = tryParseObject(raw); - if (!obj) return raw; - if (typeof obj.comment === 'string') return obj.comment; - if (isStructured(obj)) return structuredToText(obj as StructuredAiComment); - return raw; + const parsed = tryParseJson(raw); + const objects: unknown[] = Array.isArray(parsed) ? parsed : [parsed]; + if (objects.length === 0 || !objects.every(isObject)) return raw; + + const texts = (objects as JsonObject[]).map(obj => { + if (typeof obj.comment === 'string') return obj.comment; + return isStructured(obj) ? structuredToText(obj) : undefined; + }); + if (texts.some(text => text === undefined)) return raw; + return texts.join('\n\n'); }