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

Тот же промпт вернул другую структуру: массив из одного объекта, список
поставщиков suppliers_analysis вместо suppliers_evaluation, плюсы/минусы
строкой вместо массива — и снова показывался сырой JSON. Разбор стал
терпимым: объект или массив объектов, список поставщиков — любое поле-
массив объектов с supplier, плюсы/минусы — строка или массив, плюс
recommendation.
This commit is contained in:
Тимур Городецкий
2026-10-23 10:44:45 +05:00
parent cfbdfd17c9
commit 0f9afdba7d
2 changed files with 96 additions and 39 deletions
@@ -53,6 +53,38 @@ describe('aiCommentToText (ответ потока «Комментарий ИИ
expect(aiCommentToText(text)).toBe(text); 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('частично заполненная структура — только то, что есть', () => { it('частично заполненная структура — только то, что есть', () => {
expect(aiCommentToText('{"conclusion":"Берём BuildCore."}')).toBe('Вывод: Берём BuildCore.'); expect(aiCommentToText('{"conclusion":"Берём BuildCore."}')).toBe('Вывод: Берём BuildCore.');
}); });
@@ -1,56 +1,75 @@
// Оценка одного поставщика в структурированном ответе потока «Комментарий ИИ». type JsonObject = Record<string, unknown>;
interface SupplierEvaluation {
supplier?: string;
strengths?: string[];
weaknesses?: string[];
}
// Структурированный ответ потока (промпт отвечает JSON, а не текстом).
interface StructuredAiComment {
summary?: string;
suppliers_evaluation?: SupplierEvaluation[];
conclusion?: string;
}
const LABEL_STRENGTHS = 'Плюсы:'; const LABEL_STRENGTHS = 'Плюсы:';
const LABEL_WEAKNESSES = 'Минусы:'; const LABEL_WEAKNESSES = 'Минусы:';
const LABEL_CONCLUSION = 'Вывод:'; const LABEL_CONCLUSION = 'Вывод:';
const LABEL_RECOMMENDATION = 'Рекомендация:';
function tryParseObject(raw: string): Record<string, unknown> | 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(); const trimmed = raw.trim();
if (!trimmed.startsWith('{')) return undefined; if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return undefined;
try { try {
const parsed: unknown = JSON.parse(trimmed); return JSON.parse(trimmed);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
} catch { } catch {
return undefined; return undefined;
} }
} }
function isStructured(obj: Record<string, unknown>): boolean { function asText(value: unknown): string | undefined {
return 'summary' in obj || 'suppliers_evaluation' in obj || 'conclusion' in obj; return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
} }
function bulletList(label: string, items: string[] | undefined): string[] { // Плюсы/минусы модель отдаёт то массивом строк, то одной строкой.
if (!items || items.length === 0) return []; function asList(value: unknown): string[] {
return [label, ...items.map(item => `- ${item}`)]; 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 [ return [
...(evaluation.supplier ? [evaluation.supplier] : []), ...(name ? [name] : []),
...bulletList(LABEL_STRENGTHS, evaluation.strengths), ...bulletList(LABEL_STRENGTHS, asList(supplier.strengths)),
...bulletList(LABEL_WEAKNESSES, evaluation.weaknesses), ...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[][] = [ const blocks: string[][] = [
comment.summary ? [comment.summary] : [], summary ? [summary] : [],
...(comment.suppliers_evaluation ?? []).map(supplierBlock), ...findSupplierList(obj).map(supplierBlock),
comment.conclusion ? [`${LABEL_CONCLUSION} ${comment.conclusion}`] : [], labeled(LABEL_CONCLUSION, obj.conclusion),
labeled(LABEL_RECOMMENDATION, obj.recommendation),
]; ];
return blocks return blocks
.filter(block => block.length > 0) .filter(block => block.length > 0)
@@ -59,13 +78,19 @@ function structuredToText(comment: StructuredAiComment): string {
} }
// Ответ потока «Комментарий ИИ» → текст для показа и для SP (из SP его берёт и PDF-отчёт, поэтому // Ответ потока «Комментарий ИИ» → текст для показа и для SP (из SP его берёт и PDF-отчёт, поэтому
// без Markdown-звёздочек: только строки и списки «- »). Поток отвечал по-разному: просто текстом, // без Markdown-звёздочек: только строки и списки «- »). Поток отвечал по-разному: текстом, JSON
// JSON {comment}, а теперь структурой {summary, suppliers_evaluation, conclusion} — её и разворачиваем. // {comment}, а теперь структурой, форму которой модель каждый раз выбирает сама: объект или массив
// из одного объекта, список поставщиков под разными именами, плюсы/минусы массивом или строкой.
// Всё, что не распознали, возвращаем как есть: лучше показать сырой ответ, чем потерять его. // Всё, что не распознали, возвращаем как есть: лучше показать сырой ответ, чем потерять его.
export function aiCommentToText(raw: string): string { export function aiCommentToText(raw: string): string {
const obj = tryParseObject(raw); const parsed = tryParseJson(raw);
if (!obj) return raw; const objects: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
if (typeof obj.comment === 'string') return obj.comment; if (objects.length === 0 || !objects.every(isObject)) return raw;
if (isStructured(obj)) return structuredToText(obj as StructuredAiComment);
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');
} }