fix: комментарий ИИ из структурированного ответа потока показывается текстом
Поток «Комментарий ИИ» стал отвечать JSON {summary, suppliers_evaluation,
conclusion} вместо текста — в шаге 3 и в SP (а значит и в PDF) лежал
сырой JSON. aiCommentToText разворачивает структуру в текст (вывод,
плюсы/минусы по поставщикам, итог) без Markdown-разметки; прежние
форматы (текст, {comment}) — как раньше. Подключено в СТ, ОИД и КПС —
при ответе потока и при чтении из SP, чтобы старые записи с JSON тоже
показывались текстом.
This commit is contained in:
@@ -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<string, string>)?.comment ??
|
||||
(raw as Record<string, Record<string, string>>)?.body?.comment ??
|
||||
(typeof raw === "string" ? raw : undefined));
|
||||
if (!text) return JSON.stringify(raw);
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return (parsed as Record<string, string>)?.comment ?? text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
// Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
|
||||
return aiCommentToText(text ?? JSON.stringify(raw));
|
||||
}
|
||||
|
||||
async loadCtComment(ctId: string): Promise<string | undefined> {
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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<string, string>)?.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). Пустая строка = очистка.
|
||||
|
||||
@@ -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<string, string>)?.comment ??
|
||||
(raw as Record<string, Record<string, string>>)?.body?.comment ??
|
||||
(typeof raw === "string" ? raw : undefined));
|
||||
if (!text) return JSON.stringify(raw);
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, string>;
|
||||
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<void> {
|
||||
@@ -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<string, unknown>;
|
||||
return (data.comment as string) || undefined;
|
||||
// В SP мог сохраниться сырой JSON потока (до разбора структуры) — показываем текстом.
|
||||
return data.comment ? aiCommentToText(String(data.comment)) : undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user