fix: комментарий ИИ разбирается при любой форме структурированного ответа
Тот же промпт вернул другую структуру: массив из одного объекта, список поставщиков suppliers_analysis вместо suppliers_evaluation, плюсы/минусы строкой вместо массива — и снова показывался сырой JSON. Разбор стал терпимым: объект или массив объектов, список поставщиков — любое поле- массив объектов с supplier, плюсы/минусы — строка или массив, плюс recommendation.
This commit is contained in:
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user