fix: комментарий ИИ из структурированного ответа потока показывается текстом

Поток «Комментарий ИИ» стал отвечать JSON {summary, suppliers_evaluation,
conclusion} вместо текста — в шаге 3 и в SP (а значит и в PDF) лежал
сырой JSON. aiCommentToText разворачивает структуру в текст (вывод,
плюсы/минусы по поставщикам, итог) без Markdown-разметки; прежние
форматы (текст, {comment}) — как раньше. Подключено в СТ, ОИД и КПС —
при ответе потока и при чтении из SP, чтобы старые записи с JSON тоже
показывались текстом.
This commit is contained in:
Тимур Городецкий
2026-10-23 10:35:38 +05:00
parent a0d9179d9d
commit cfbdfd17c9
5 changed files with 146 additions and 25 deletions
@@ -34,6 +34,7 @@ import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria";
import { pickRowToNotify } from "../components/utils/approvalAccess"; import { pickRowToNotify } from "../components/utils/approvalAccess";
import { buildPositionKeys } from "../components/utils/positionKey"; import { buildPositionKeys } from "../components/utils/positionKey";
import { mapFlowResult } from "../components/utils/flowResult"; import { mapFlowResult } from "../components/utils/flowResult";
import { aiCommentToText } from "../../../shared/aiComment/aiCommentText";
import { import {
NoOfferSupplierEntry, NoOfferSupplierEntry,
NoOfferSupplierRecord, NoOfferSupplierRecord,
@@ -1423,13 +1424,8 @@ export class CtService {
: ((raw as Record<string, string>)?.comment ?? : ((raw as Record<string, string>)?.comment ??
(raw as Record<string, Record<string, string>>)?.body?.comment ?? (raw as Record<string, Record<string, string>>)?.body?.comment ??
(typeof raw === "string" ? raw : undefined)); (typeof raw === "string" ? raw : undefined));
if (!text) return JSON.stringify(raw); // Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
try { return aiCommentToText(text ?? JSON.stringify(raw));
const parsed = JSON.parse(text);
return (parsed as Record<string, string>)?.comment ?? text;
} catch {
return text;
}
} }
async loadCtComment(ctId: string): Promise<string | undefined> { async loadCtComment(ctId: string): Promise<string | undefined> {
@@ -1437,7 +1433,8 @@ export class CtService {
const res = await this.http.get(url, SPHttpClient.configurations.v1); const res = await this.http.get(url, SPHttpClient.configurations.v1);
if (!res.ok) return undefined; if (!res.ok) return undefined;
const data = await res.json(); 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<void> { async updateCtComment(ctId: string, comment: string): Promise<void> {
@@ -12,6 +12,7 @@ import {
IMatrixRow IMatrixRow
} from '../../../shared/approval'; } from '../../../shared/approval';
import { keepLatestOfferRecords, parseOfferItemIndex } from '../../../shared/offers/offerRecords'; import { keepLatestOfferRecords, parseOfferItemIndex } from '../../../shared/offers/offerRecords';
import { aiCommentToText } from '../../../shared/aiComment/aiCommentText';
import type { import type {
CtFormData, CtFormData,
CtStatus, CtStatus,
@@ -1203,14 +1204,8 @@ export class KpsCtFormService {
: ((asRec?.comment as string | undefined) ?? : ((asRec?.comment as string | undefined) ??
(raw as { body?: { comment?: string } })?.body?.comment ?? (raw as { body?: { comment?: string } })?.body?.comment ??
(typeof raw === 'string' ? raw : undefined)); (typeof raw === 'string' ? raw : undefined));
if (!text) { // Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
return JSON.stringify(raw); return aiCommentToText(text ?? JSON.stringify(raw));
}
try {
return ((JSON.parse(text) as Record<string, string>)?.comment ?? text);
} catch {
return text;
}
} }
// Шапка СТ для комментария ИИ: номер/проект (в payload) + сохранённый ранее комментарий (показать/поллинг). // Шапка СТ для комментария ИИ: номер/проект (в payload) + сохранённый ранее комментарий (показать/поллинг).
@@ -1219,7 +1214,9 @@ export class KpsCtFormService {
LISTS.ct, LISTS.ct,
`$select=${CT_FIELDS.number},${CT_FIELDS.project},${CT_FIELDS.comment}&$filter=Id eq ${ctId}&$top=1` `$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). Пустая строка = очистка. // Сохранение комментария ИИ в колонку `comment` шапки (MERGE — как setWinSupplier). Пустая строка = очистка.
@@ -27,6 +27,7 @@ import {
writeRoute, writeRoute,
} from "../../../shared/approval"; } from "../../../shared/approval";
import { findSupplierOfferIds } from "../../../shared/offers/offerRecords"; import { findSupplierOfferIds } from "../../../shared/offers/offerRecords";
import { aiCommentToText } from "../../../shared/aiComment/aiCommentText";
const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed"; const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed";
@@ -1321,13 +1322,8 @@ export class OidService {
: ((raw as Record<string, string>)?.comment ?? : ((raw as Record<string, string>)?.comment ??
(raw as Record<string, Record<string, string>>)?.body?.comment ?? (raw as Record<string, Record<string, string>>)?.body?.comment ??
(typeof raw === "string" ? raw : undefined)); (typeof raw === "string" ? raw : undefined));
if (!text) return JSON.stringify(raw); // Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
try { return aiCommentToText(text ?? JSON.stringify(raw));
const parsed = JSON.parse(text) as Record<string, string>;
return parsed?.comment ?? text;
} catch {
return text;
}
} }
async updateOidComment(oidId: number, comment: string): Promise<void> { async updateOidComment(oidId: number, comment: string): Promise<void> {
@@ -1343,7 +1339,8 @@ export class OidService {
const res = await this.sp.get(url, SPHttpClient.configurations.v1); const res = await this.sp.get(url, SPHttpClient.configurations.v1);
if (!res.ok) return undefined; if (!res.ok) return undefined;
const data = await res.json() as Record<string, unknown>; const data = await res.json() as Record<string, unknown>;
return (data.comment as string) || undefined; // В SP мог сохраниться сырой JSON потока (до разбора структуры) — показываем текстом.
return data.comment ? aiCommentToText(String(data.comment)) : undefined;
} }
async generateOidReport( async generateOidReport(
@@ -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.');
});
});
@@ -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<string, unknown> | 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<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
function isStructured(obj: Record<string, unknown>): 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;
}