From cfbdfd17c93dd1816db44a701d4415e6e0878a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Fri, 23 Oct 2026 10:35:38 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D0=B9=20=D0=98=D0=98=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=82=D0=B0=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поток «Комментарий ИИ» стал отвечать JSON {summary, suppliers_evaluation, conclusion} вместо текста — в шаге 3 и в SP (а значит и в PDF) лежал сырой JSON. aiCommentToText разворачивает структуру в текст (вывод, плюсы/минусы по поставщикам, итог) без Markdown-разметки; прежние форматы (текст, {comment}) — как раньше. Подключено в СТ, ОИД и КПС — при ответе потока и при чтении из SP, чтобы старые записи с JSON тоже показывались текстом. --- .../forms/ct/services/CtService.ts | 13 ++-- .../forms/kps/services/KpsCtFormService.ts | 15 ++-- .../forms/oid/services/OidService.ts | 13 ++-- .../shared/aiComment/aiCommentText.test.ts | 59 +++++++++++++++ .../shared/aiComment/aiCommentText.ts | 71 +++++++++++++++++++ 5 files changed, 146 insertions(+), 25 deletions(-) create mode 100644 src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.test.ts create mode 100644 src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.ts diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index 536e5f4..2857406 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -34,6 +34,7 @@ import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria"; import { pickRowToNotify } from "../components/utils/approvalAccess"; import { buildPositionKeys } from "../components/utils/positionKey"; import { mapFlowResult } from "../components/utils/flowResult"; +import { aiCommentToText } from "../../../shared/aiComment/aiCommentText"; import { NoOfferSupplierEntry, NoOfferSupplierRecord, @@ -1423,13 +1424,8 @@ export class CtService { : ((raw as Record)?.comment ?? (raw as Record>)?.body?.comment ?? (typeof raw === "string" ? raw : undefined)); - if (!text) return JSON.stringify(raw); - try { - const parsed = JSON.parse(text); - return (parsed as Record)?.comment ?? text; - } catch { - return text; - } + // Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText. + return aiCommentToText(text ?? JSON.stringify(raw)); } async loadCtComment(ctId: string): Promise { @@ -1437,7 +1433,8 @@ export class CtService { const res = await this.http.get(url, SPHttpClient.configurations.v1); if (!res.ok) return undefined; const data = await res.json(); - return (data.comment as string) || undefined; + // В SP мог сохраниться сырой JSON потока (до разбора структуры) — показываем текстом. + return data.comment ? aiCommentToText(String(data.comment)) : undefined; } async updateCtComment(ctId: string, comment: string): Promise { diff --git a/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts b/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts index 514c5bb..6b36836 100644 --- a/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts +++ b/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts @@ -12,6 +12,7 @@ import { IMatrixRow } from '../../../shared/approval'; import { keepLatestOfferRecords, parseOfferItemIndex } from '../../../shared/offers/offerRecords'; +import { aiCommentToText } from '../../../shared/aiComment/aiCommentText'; import type { CtFormData, CtStatus, @@ -1203,14 +1204,8 @@ export class KpsCtFormService { : ((asRec?.comment as string | undefined) ?? (raw as { body?: { comment?: string } })?.body?.comment ?? (typeof raw === 'string' ? raw : undefined)); - if (!text) { - return JSON.stringify(raw); - } - try { - return ((JSON.parse(text) as Record)?.comment ?? text); - } catch { - return text; - } + // Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText. + return aiCommentToText(text ?? JSON.stringify(raw)); } // Шапка СТ для комментария ИИ: номер/проект (в payload) + сохранённый ранее комментарий (показать/поллинг). @@ -1219,7 +1214,9 @@ export class KpsCtFormService { LISTS.ct, `$select=${CT_FIELDS.number},${CT_FIELDS.project},${CT_FIELDS.comment}&$filter=Id eq ${ctId}&$top=1` ); - return rows[0] ?? {}; + const header = rows[0] ?? {}; + // В SP мог сохраниться сырой JSON потока (до разбора структуры) — показываем текстом. + return header.comment ? { ...header, comment: aiCommentToText(header.comment) } : header; } // Сохранение комментария ИИ в колонку `comment` шапки (MERGE — как setWinSupplier). Пустая строка = очистка. diff --git a/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts b/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts index 2a9f647..36a01c8 100644 --- a/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts +++ b/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts @@ -27,6 +27,7 @@ import { writeRoute, } from "../../../shared/approval"; import { findSupplierOfferIds } from "../../../shared/offers/offerRecords"; +import { aiCommentToText } from "../../../shared/aiComment/aiCommentText"; const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed"; @@ -1321,13 +1322,8 @@ export class OidService { : ((raw as Record)?.comment ?? (raw as Record>)?.body?.comment ?? (typeof raw === "string" ? raw : undefined)); - if (!text) return JSON.stringify(raw); - try { - const parsed = JSON.parse(text) as Record; - return parsed?.comment ?? text; - } catch { - return text; - } + // Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText. + return aiCommentToText(text ?? JSON.stringify(raw)); } async updateOidComment(oidId: number, comment: string): Promise { @@ -1343,7 +1339,8 @@ export class OidService { const res = await this.sp.get(url, SPHttpClient.configurations.v1); if (!res.ok) return undefined; const data = await res.json() as Record; - return (data.comment as string) || undefined; + // В SP мог сохраниться сырой JSON потока (до разбора структуры) — показываем текстом. + return data.comment ? aiCommentToText(String(data.comment)) : undefined; } async generateOidReport( diff --git a/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.test.ts b/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.test.ts new file mode 100644 index 0000000..edbd8d9 --- /dev/null +++ b/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.test.ts @@ -0,0 +1,59 @@ +import { aiCommentToText } from './aiCommentText'; + +describe('aiCommentToText (ответ потока «Комментарий ИИ» → текст)', () => { + // СТ 367: поток стал отвечать структурой {summary, suppliers_evaluation, conclusion} вместо текста — + // в шаге 3 и в SP (а значит и в PDF) лежал сырой JSON. + const structured = JSON.stringify({ + summary: 'Анализ поставщиков по заявке 081/26-10/0028.', + suppliers_evaluation: [ + { + supplier: 'BuildCore Supply Ltd.', + strengths: ['Самые низкие цены.', 'Широкий ассортимент.'], + weaknesses: ['Самый длительный срок поставки.'], + }, + { + supplier: 'ТОО «СтройЛайн KZ»', + strengths: ['Доставка включена в стоимость.'], + weaknesses: [], + }, + ], + conclusion: 'СтройЛайн — оптимальный выбор.', + }); + + it('структурированный ответ — читаемый текст: вывод, плюсы/минусы по поставщикам, итог', () => { + expect(aiCommentToText(structured)).toBe([ + 'Анализ поставщиков по заявке 081/26-10/0028.', + '', + 'BuildCore Supply Ltd.', + 'Плюсы:', + '- Самые низкие цены.', + '- Широкий ассортимент.', + 'Минусы:', + '- Самый длительный срок поставки.', + '', + 'ТОО «СтройЛайн KZ»', + 'Плюсы:', + '- Доставка включена в стоимость.', + '', + 'Вывод: СтройЛайн — оптимальный выбор.', + ].join('\n')); + }); + + it('обычный текст (прежний формат потока) — без изменений', () => { + const text = 'Рекомендуем ТОО «СтройЛайн KZ»:\n- доставка включена'; + expect(aiCommentToText(text)).toBe(text); + }); + + it('JSON с полем comment — берём comment', () => { + expect(aiCommentToText('{"comment":"Готовый текст"}')).toBe('Готовый текст'); + }); + + it('JSON другой формы — без изменений, ничего не теряем', () => { + const text = '{"foo":"bar"}'; + expect(aiCommentToText(text)).toBe(text); + }); + + 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 new file mode 100644 index 0000000..ad65f8f --- /dev/null +++ b/src/webparts/ctFormsPortal/shared/aiComment/aiCommentText.ts @@ -0,0 +1,71 @@ +// Оценка одного поставщика в структурированном ответе потока «Комментарий ИИ». +interface SupplierEvaluation { + supplier?: string; + strengths?: string[]; + weaknesses?: string[]; +} + +// Структурированный ответ потока (промпт отвечает JSON, а не текстом). +interface StructuredAiComment { + summary?: string; + suppliers_evaluation?: SupplierEvaluation[]; + conclusion?: string; +} + +const LABEL_STRENGTHS = 'Плюсы:'; +const LABEL_WEAKNESSES = 'Минусы:'; +const LABEL_CONCLUSION = 'Вывод:'; + +function tryParseObject(raw: string): Record | undefined { + const trimmed = raw.trim(); + if (!trimmed.startsWith('{')) return undefined; + try { + const parsed: unknown = JSON.parse(trimmed); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +function isStructured(obj: Record): boolean { + return 'summary' in obj || 'suppliers_evaluation' in obj || 'conclusion' in obj; +} + +function bulletList(label: string, items: string[] | undefined): string[] { + if (!items || items.length === 0) return []; + return [label, ...items.map(item => `- ${item}`)]; +} + +function supplierBlock(evaluation: SupplierEvaluation): string[] { + return [ + ...(evaluation.supplier ? [evaluation.supplier] : []), + ...bulletList(LABEL_STRENGTHS, evaluation.strengths), + ...bulletList(LABEL_WEAKNESSES, evaluation.weaknesses), + ]; +} + +function structuredToText(comment: StructuredAiComment): string { + const blocks: string[][] = [ + comment.summary ? [comment.summary] : [], + ...(comment.suppliers_evaluation ?? []).map(supplierBlock), + comment.conclusion ? [`${LABEL_CONCLUSION} ${comment.conclusion}`] : [], + ]; + return blocks + .filter(block => block.length > 0) + .map(block => block.join('\n')) + .join('\n\n'); +} + +// Ответ потока «Комментарий ИИ» → текст для показа и для SP (из SP его берёт и PDF-отчёт, поэтому +// без Markdown-звёздочек: только строки и списки «- »). Поток отвечал по-разному: просто текстом, +// JSON {comment}, а теперь структурой {summary, suppliers_evaluation, conclusion} — её и разворачиваем. +// Всё, что не распознали, возвращаем как есть: лучше показать сырой ответ, чем потерять его. +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; +}