Привет, коллеги! Хочу поделиться экспериментом, в котором я подключил локальную нейросеть к 1С, чтобы она автоматически анализировала отмены записей пациентов.
Не то чтобы я решил какую-то глобальную проблему — скорее, проверил, насколько вообще реально такое сочетание.
Спойлер: работает, но с костылями.
Что я проверил
- Можно ли без танцев с бубном заставить 1С и AI общаться.
- Насколько адекватно нейросеть понимает структурированные данные.
- Будет ли это хоть сколько-то быстро работать.
Как это устроено?
1С — собирает данные пациентов (возраст, причины отмен, услуги).
- Локальная нейросеть (deepseek-r1 через Ollama) — анализирует и после идет обработка полученных данных.
- HTTP-запросы — как мост между ними.
По сути, схема такая:
- 1С выгружает данные в JSON.
- Нейросеть получает их + жесткий промпт
- Ответ парсится в 1С и выводится в отчет.
Что получилось?
Плюсы
- Гибкость
- Нейросеть сама группирует данные по возрасту и причинам — не надо прописывать сложные SQL-запросы.
- Можно менять логику анализа просто правкой промпта (без изменений кода). - Автоматические рекомендации
AI не просто выдает цифры, но и пишет советы : "Пациенты 30-45 лет чаще отменяют из-за цены → предложите скидки или рассрочку" - Работает локально
Не нужен OpenAI — deepseek-r1 справляется на обычном ПК. Нет утечки данных.
Проблемы
- Нейросеть иногда выдает не то что ожидаем
- Путает "0.00%" и "0%".
- В 5% случаев выдает ответ не в JSON, а с мусором (приходится чистить). - Скорость
- Скорость оставляет желать лучшего... Ну или с моей видеокартой только так. P 106 100. Не самое верное решение для таких обработок — для интерактивного отчета мощность маловата. - Зависимость от промпта
Если не прописать жесткие правила , AI начинает выдумывать:
- Меняет названия полей ("Group" вместо "Группа").
- Игнорирует пустые категории. - Время разработки
На разработку промпта и подбор LLM ушло около 8 часов. (Разработка промпта, поиск подходящей модели, которую сможет потянуть моя гпу) - Как видно на скриншоте, как я не боролся, дипсик выводит китайские символы...
Вывод: стоит ли игра свеч?
- Где это можно использовать? (мой взгляд)
Нестандартная аналитика — когда встроенных отчетов 1С не хватает.
Быстрые прототипы — чтобы не городить сложные SQL-запросы. - Где не подойдет
Там, где важна точность — нейросеть может ошибаться в расчетах.
Для больших данных — 10к записей будут обрабатываться очень долго или может сработать ограничение LLM и часть данных не будет обработана.
Спасибо всем за внимание!
Текст промпта, который был разработан.
Проанализируй следующие данные пациентов и выведи ТОЛЬКО JSON-объект в точности по указанной схеме без любых других слов, комментариев или пояснений. Ответ должен начинаться с { и заканчиваться } без каких-либо дополнительных символов или текста до/после.
Схема вывода:
{
"ВозрастныеГруппы": [
{
"Группа": "20-30 лет",
"КоличествоПациентов": N,
"Доля": "X.XX%",
"Причины": ["Причина1", "Причина2"],
"Рекомендация": "Текст"
},
{
"Группа": "30-45 лет",
"КоличествоПациентов": N,
"Доля": "X.XX%",
"Причины": ["Причина1", "Причина2"],
"Рекомендация": "Текст"
},
{
"Группа": "45-80 лет",
"КоличествоПациентов": N,
"Доля": "X.XX%",
"Причины": ["Причина1", "Причина2"],
"Рекомендация": "Текст"
}
]
}
Строгие правила обработки:
Строгие правила:
0. **Точное соответствие входным данным**. ПРИМЕР - Если пациент только один (62 года), то:
- "20-30 лет": 0 пациентов
- "30-45 лет": 0 пациентов
- "45-80 лет": 1 пациент
1. Рекомендации должны строго соответствовать указанным причинам и содержать от 3 до 15 слов. Пример:
- Причина: "Дорого" → Рекомендация: "Предложить альтернативные бюджетные варианты"
2. Возрастные группы должны обрабатываться следующим образом:
- 20-30 лет: возраст ≥20 и <30
- 30-45 лет: возраст ≥30 и <45
- 45-80 лет: возраст ≥45 и ≤80
3. Расчет долей: Доля = (КоличествоПациентовВГруппе / ОбщееКоличествоПациентовВОбработанныхГруппах) * 100. Округлить до двух знаков (формат: "XX.XX%").
4. Причины ТОЛЬКО из списка: <%ПРИЧИНЫ_ОТКАЗА%>
5. Для пустых групп использовать строго:
{
"Группа": "НАЗВАНИЕ_ГРУППЫ",
"КоличествоПациентов": 0,
"Доля": "0.00%",
"Причины": ["Нет таких пациентов"],
"Рекомендация": "Не требуется"
}
6. Все названия полей должны быть точно как в схеме, использовать только кириллицу ("Группа", не "Group" и не "ГROUP").
Требования к формату:
- Ответ должен содержать ТОЛЬКО валидный JSON без каких-либо дополнительных текстов или символов и китайских иероглифов
- Начинаться с { и заканчиваться }
- Строго соблюдать структуру и названия полей из схемы
Данные для обработки:
=== НАЧАЛО ДАННЫХ ===
<%ДАННЫЕ%>
=== КОНЕЦ ДАННЫХ ===
Пример корректного ответа для данных (1 пациент 62 года):
```json
{
"ВозрастныеГруппы": [
{
"Группа": "20-30 лет",
"КоличествоПациентов": 0,
"Доля": "0.00%",
"Причины": ["Нет таких пациентов"],
"Рекомендация": "Не требуется"
},
{
"Группа": "30-45 лет",
"КоличествоПациентов": 0,
"Доля": "0.00%",
"Причины": ["Нет таких пациентов"],
"Рекомендация": "Не требуется"
},
{
"Группа": "45-80 лет",
"КоличествоПациентов": 1,
"Доля": "100.00%",
"Причины": ["Дорого"],
"Рекомендация": "Предложить скидки или альтернативы"
}
]
}
Код, который был разработан.
#Область ОбработчикиСобытий
Процедура ПриКомпоновкеРезультата(ДокументРезультат, ДанныеРасшифровки, СтандартнаяОбработка)
НастройкиОтчета = ЭтотОбъект.КомпоновщикНастроек.ПолучитьНастройки();
// Получаем данные пациентов
ПациентыДляОбработки = ДанныеПациентов();
Если ПациентыДляОбработки <> Неопределено Тогда
// Подготавливаем данные для отправки в AI
ДанныеДляАнализа = ВходныеДанныеОтменыПациентов(ПациентыДляОбработки);
ДанныеДляПромпта = ПреобразоватьВJSON(ДанныеДляАнализа);
Иначе
ДанныеДляПромпта = "Данные для анализа отсутствуют"
КонецЕсли;
// Формируем промпт
ТекстПромпта = ПромптВозрастныеКатегорииОтменаУслуг("АнализОтменыДанныхПациент");
ТекстПромпта = СтрЗаменить(ТекстПромпта, "<%ДАННЫЕ%>", ДанныеДляПромпта);
ТекстПромпта = СтрЗаменить(ТекстПромпта, "<%ПРИЧИНЫ_ОТКАЗА%>", ПричиныОтказа());
// Отправляем запрос к AI
ОтветAI = ОтправитьЗапросКAI(ТекстПромпта);
// Обрабатываем ответ и формируем результат
ИсточникДанных = СформироватьТабЧасть(ОтветAI);
// Выводим результат в документ
ВывестиРезультатВДокумент(ДокументРезультат, НастройкиОтчета, ИсточникДанных);
СтандартнаяОбработка = Ложь;
КонецПроцедуры
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#Область РаботаСДаннымиПациентов
Функция ВходныеДанныеОтменыПациентов(Пациенты) Экспорт
ДанныеДляПередачи = Новый Структура("ВходныеДанные", Новый Массив);
Для Каждого ДанныеПациента Из Пациенты Цикл
ИнформацияОтмены = ДанныеПациентаВозрастныеГруппы(ДанныеПациента);
ДанныеДляПередачи.ВходныеДанные.Добавить(ИнформацияОтмены);
КонецЦикла;
Возврат ДанныеДляПередачи;
КонецФункции
Функция ДанныеПациентов()
ШаблонПациента = Новый Структура("Возраст, Местоположение, Пол, ФИО, ПричинаОтмены, НаименованиеУслуги");
МассивПациентов = Новый Массив;
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| ДанныеПациентовСрезПоследних.Фамилия + "" "" + ДанныеПациентовСрезПоследних.Имя + "" "" + ДанныеПациентовСрезПоследних.Отчество КАК ФИО,
| ПРЕДСТАВЛЕНИЕ(ДанныеПациентовСрезПоследних.Пол) КАК Пол,
| РАЗНОСТЬДАТ(ДанныеПациентовСрезПоследних.ДатаРождения, &Период, ГОД) КАК Возраст,
| ПРЕДСТАВЛЕНИЕ(ОтменаУслугЗаказаПациентаМедицинскиеУслуги.ПричинаОтменыУслугиЗаказаПациента) КАК ПричинаОтмены,
| ПРЕДСТАВЛЕНИЕ(ОтменаУслугЗаказаПациентаМедицинскиеУслуги.Номенклатура) КАК НаименованиеУслуги
|ИЗ
| РегистрСведений.ДанныеПациентов.СрезПоследних(&Период, ) КАК ДанныеПациентовСрезПоследних
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ Документ.ОтменаУслугЗаказаПациента.МедицинскиеУслуги КАК ОтменаУслугЗаказаПациентаМедицинскиеУслуги
| ПО ДанныеПациентовСрезПоследних.Пациент = ОтменаУслугЗаказаПациентаМедицинскиеУслуги.Ссылка.Пациент";
Запрос.УстановитьПараметр("Период", ТекущаяДатаСеанса());
РезультатЗапроса = Запрос.Выполнить();
Если РезультатЗапроса.Пустой() Тогда
Возврат Неопределено;
КонецЕсли;
Выборка = РезультатЗапроса.Выбрать();
Пока Выборка.Следующий() Цикл
ДанныеПациента = ОбменДаннымиСобытия.СкопироватьСтруктуру(ШаблонПациента);
ДанныеПациента.Местоположение = МестоположениеОрганизации();
ЗаполнитьЗначенияСвойств(ДанныеПациента, Выборка);
МассивПациентов.Добавить(ДанныеПациента);
КонецЦикла;
Возврат МассивПациентов;
КонецФункции
Функция ДанныеПациентаВозрастныеГруппы(ДанныеДляОбработки)
ПроверитьПолнотуДанных(ДанныеДляОбработки, "Возраст, Пол, Местоположение, ФИО, ПричинаОтмены, НаименованиеУслуги");
ДанныеПациента = Новый Структура;
ДанныеПациента.Вставить("Возраст", ДанныеДляОбработки.Возраст);
ДанныеПациента.Вставить("Пол", ДанныеДляОбработки.Пол);
ДанныеПациента.Вставить("Местоположение", ДанныеДляОбработки.Местоположение);
ДанныеПациента.Вставить("ФИО", ДанныеДляОбработки.ФИО);
Отмена = Новый Структура;
Отмена.Вставить("ПричинаОтмены", ДанныеДляОбработки.ПричинаОтмены);
СведенияОПриеме = Новый Структура;
СведенияОПриеме.Вставить("НаименованиеУслуги", ДанныеДляОбработки.НаименованиеУслуги);
Возврат Новый Структура(
"ДанныеПациента, Отмена, СведенияОЗапланированномПриеме",
ДанныеПациента, Отмена, СведенияОПриеме
);
КонецФункции
#КонецОбласти
#Область ВзаимодействиеСAI
Функция ОтправитьЗапросКAI(ТекстПромпта)
Подключение = Новый Структура;
Подключение.Вставить("АдресСервера", "localhost");
Подключение.Вставить("ПортСервера", 11434);
Подключение.Вставить("КонечнаяТочка", "/api/generate");
Подключение.Вставить("МодельAI", "deepseek-r1");
ТелоЗапроса = Новый Структура;
ТелоЗапроса.Вставить("model", Подключение.МодельAI);
ТелоЗапроса.Вставить("prompt", ТекстПромпта);
ТелоЗапроса.Вставить("stream", Ложь);
СтрокаЗапроса = ПреобразоватьВJSON(ТелоЗапроса);
HTTP = Новый HTTPСоединение(Подключение.АдресСервера, Подключение.ПортСервера);
HTTPЗапрос = Новый HTTPЗапрос(Подключение.КонечнаяТочка);
HTTPЗапрос.УстановитьТелоИзСтроки(СтрокаЗапроса, КодировкаТекста.UTF8, ИспользованиеByteOrderMark.НеИспользовать);
Попытка
Ответ = HTTP.ОтправитьДляОбработки(HTTPЗапрос);
Исключение
ВызватьИсключение "Ошибка при отправке запроса к AI: " + ОписаниеОшибки();
КонецПопытки;
Если Ответ = Неопределено Или Ответ.КодСостояния <> 200 Тогда
ВызватьИсключение "Ошибка при выполнении запроса. Код состояния: " + Ответ.КодСостояния;
КонецЕсли;
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
ДанныеОтвета = ПрочитатьJSON(ЧтениеJSON, Истина);
ОтветСтрока = ДанныеОтвета["response"];
Если ОтветСтрока = Неопределено Тогда
ВызватьИсключение "Некорректный ответ от AI: отсутствует поле response";
КонецЕсли;
ОбработатьОтветОтНейронки(ОтветСтрока);
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(ОтветСтрока);
ОтветAI = ПрочитатьJSON(ЧтениеJSON, Истина);
Возврат ОтветAI;
КонецФункции
Процедура ОбработатьОтветОтНейронки(ТекстОтвета)
НачалоПозиция = СтрНайти(ТекстОтвета, "{");
КонецПозиция = СтрНайти(ТекстОтвета, "}", НаправлениеПоиска.СКонца);
Если НачалоПозиция = 0 Или КонецПозиция = 0 Тогда
ВызватьИсключение "Некорректный json";
КонецЕсли;
ДлинаJson = КонецПозиция - НачалоПозиция + 1;
Json = Сред(ТекстОтвета, НачалоПозиция, ДлинаJson);
ТекстОтвета = Json;
КонецПроцедуры
#КонецОбласти
#Область ФормированиеРезультатов
Функция СформироватьТабЧасть(ДанныеAI)
ИсточникДанных = Новый ТаблицаЗначений;
ИсточникДанных.Колонки.Добавить("Группа", Новый ОписаниеТипов("Строка"));
ИсточникДанных.Колонки.Добавить("КоличествоПациентов", Новый ОписаниеТипов("Число"));
ИсточникДанных.Колонки.Добавить("Доля", Новый ОписаниеТипов("Строка"));
ИсточникДанных.Колонки.Добавить("Причины", Новый ОписаниеТипов("Строка"));
ИсточникДанных.Колонки.Добавить("Рекомендация", Новый ОписаниеТипов("Строка"));
ВозрастныеГруппы = ДанныеAI["ВозрастныеГруппы"];
Для Каждого Группа Из ВозрастныеГруппы Цикл
НоваяСтрока = ИсточникДанных.Добавить();
НоваяСтрока.Группа = Группа["Группа"];
НоваяСтрока.КоличествоПациентов = Группа["КоличествоПациентов"];
НоваяСтрока.Доля = Группа["Доля"];
НоваяСтрока.Причины = СтрСоединить(Группа["Причины"], Символы.ПС);
НоваяСтрока.Рекомендация = Группа["Рекомендация"];
КонецЦикла;
Возврат ИсточникДанных;
КонецФункции
Процедура ВывестиРезультатВДокумент(ДокументРезультат, НастройкиОтчета, ИсточникДанных)
ВнешнийНаборДанных = Новый Структура("ВозрастныеГруппы", ИсточникДанных);
КомпоновщикМакета = Новый КомпоновщикМакетаКомпоновкиДанных;
МакетКомпоновки = КомпоновщикМакета.Выполнить(
ЭтотОбъект.СхемаКомпоновкиДанных,
НастройкиОтчета
);
Процессор = Новый ПроцессорКомпоновкиДанных;
Процессор.Инициализировать(МакетКомпоновки, ВнешнийНаборДанных, , Истина);
ПроцессорВывода = Новый ПроцессорВыводаРезультатаКомпоновкиДанныхВТабличныйДокумент;
ПроцессорВывода.УстановитьДокумент(ДокументРезультат);
ПроцессорВывода.Вывести(Процессор);
КонецПроцедуры
#КонецОбласти
#Область ВспомогательныеФункции
Функция МестоположениеОрганизации()
ГлавнаяОрганизация = Справочники.Организации.ОсновнаяОрганизация;
ПредставлениеОрганизации = ФормированиеПечатныхФорм.ОписаниеОрганизации(
ФормированиеПечатныхФорм.СведенияОЮрФизЛице(
ГлавнаяОрганизация,
ТекущаяДатаСеанса()
),
"ФактическийАдрес"
);
Возврат ПредставлениеОрганизации;
КонецФункции
Функция ПреобразоватьВJSON(Данные)
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
Возврат ЗаписьJSON.Закрыть();
КонецФункции
Функция ПричиныОтказа()
Результат = "";
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| ПричиныОтменыУслугЗаказаПациента.Представление КАК ПричинаОтмены
|ИЗ
| Справочник.ПричиныОтменыУслугЗаказаПациента КАК ПричиныОтменыУслугЗаказаПациента";
РезультатЗапроса = Запрос.Выполнить();
Выборка = РезультатЗапроса.Выбрать();
Если Выборка.Количество() = 0 Тогда
Результат = "Предопределнных причин в системе нет";
КонецЕсли;
Пока Выборка.Следующий() Цикл
Результат = Результат + ", " + Выборка.ПричинаОтмены;
КонецЦикла;
Возврат Результат;
КонецФункции
Процедура ПроверитьПолнотуДанных(ДанныеДляПроверки, СписокОбязательныхСвойств)
МассивСвойств = СтрРазделить(СписокОбязательныхСвойств, ", ", Ложь);
Для Каждого ИмяСвойства Из МассивСвойств Цикл
Если Не ДанныеДляПроверки.Свойство(ИмяСвойства) Тогда
ВызватьИсключение СтрШаблон("Отсутствует обязательное свойство: %1", ИмяСвойства);
КонецЕсли;
КонецЦикла;
КонецПроцедуры
Функция ПромптВозрастныеКатегорииОтменаУслуг(НаименованиеПромпта) Экспорт
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| Промпты.ТекстПромпт КАК ТекстПромпт
|ИЗ
| Справочник.Промпты КАК Промпты
|ГДЕ
| Промпты.Наименование = &Наименование";
Запрос.УстановитьПараметр("Наименование", НаименованиеПромпта);
РезультатЗапроса = Запрос.Выполнить();
Выборка = РезультатЗапроса.Выбрать();
Если Выборка.Количество() = 0 Тогда
ВызватьИсключение СтрШаблон("Промпт с наименованием '%1' не найден", НаименованиеПромпта);
КонецЕсли;
Выборка.Следующий();
Возврат Выборка.ТекстПромпт;
КонецФункции
#КонецОбласти
#КонецОбласти