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 { 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user