"""llm_prompt.py — Формирование промпта для LLM-анализа ДС. Читает активный промпт из БД напрямую (db.prompts). При ошибке — fallback на хардкод.""" import os from db import prompts as db_prompts # ── Fallback-промпты (если БД недоступна) ────────────────────── FALLBACK_EXTRACT = """Ты — анализатор договоров облачного провайдера и ЦОД (дата-центра). Ты разбираешь спецификации услуг colocation, аренды стоек, питания, каналов связи и облачных ресурсов. ЗАДАЧА: ниже текст спецификации услуг из ПЕРВОГО документа (базовый договор). Извлеки ВСЕ строки спецификации, каждую как отдельную ADD-операцию. Верни СТРОГО JSON без пояснений. Не используй markdown-блоки, не добавляй текст до или после JSON. ФОРМАТ: {{ "mode": "partial", "ops": [ {{"action": "ADD", "new_row": {{"name": "полное наименование", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}}, "comment": ""}} ] }} ДОМЕННЫЙ ГЛОССАРИЙ (для корректного разбора): - Единицы измерения: \u2022 кВт — мощность электропитания (номинальная/гарантированная). \u2022 юнит, U — высота места в стойке (1U, 2U, 10U). \u2022 шт. — счётные позиции (IP-адреса, кросс-соединения, порты). \u2022 Мбит/с, Гбит/с — пропускная способность канала связи. \u2022 ГБ, ТБ — объём диска/хранилища; vCPU — виртуальные ядра; RAM ГБ — память. - Типичные услуги: \u2022 «Стойко-место» / «Аренда стойко-места» / «Colocation» — размещение оборудования в стойке ЦОД. \u2022 «Электропитание» / «Питание» — выделенная мощность в кВт. \u2022 «IP-адрес» (IPv4/IPv6) — считается в шт. \u2022 «Канал связи» / «Порт» / «Интернет» — пропускная способность. \u2022 «Кросс-соединение» (cross-connect) — физическая коммутация, шт. \u2022 «Облачные ресурсы» — vCPU, RAM, диск. - Мощность и габариты часто входят В СОСТАВ названия услуги: «Аренда стойко-места, в составе: Номинальная мощность – 10 кВт». Сохраняй такое название ЦЕЛИКОМ. ПРАВИЛА: 1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию. 2. name — полное наименование услуги дословно, со всеми уточнениями (мощность, объём, кол-во в составе). Не сокращай. 3. Пропускай итоговые строки («Итого», «Всего», «НДС», «К оплате») и строки с подписями/реквизитами. 4. Если ячейка пустая или значение не указано — ставь null (НЕ пиши 0). 5. price, qty, sum — ЧИСЛА (без пробелов, без «руб.», точка как десятичный разделитель). «50 000,00 руб.» \u2192 50000. 6. date_start — дата начала оказания услуги в формате YYYY-MM-DD. Если в документе нет — null. 7. Не вычисляй и не «исправляй» суммы. Бери значения как в документе. ПРИМЕР: Текст: «1. Аренда стойко-места, в составе: Номинальная мощность – 10 кВт — 1 шт. — 50 000,00 руб. — 50 000,00 руб. Дата начала: 01.01.2025 2. IP-адрес IPv4 — 8 шт. — 300,00 руб. — 2 400,00 руб.» Ответ: {{ "mode": "partial", "ops": [ {{"action": "ADD", "new_row": {{"name": "Аренда стойко-места, в составе: Номинальная мощность – 10 кВт", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}, "comment": ""}}, {{"action": "ADD", "new_row": {{"name": "IP-адрес IPv4", "price": 300, "qty": 8, "sum": 2400, "date_start": null}}, "comment": ""}} ] }} ТЕКСТ ДОКУМЕНТА: --- {doc_text} ---""" FALLBACK_DIFF = """Ты — анализатор допсоглашений (ДС) к договорам облачного провайдера и ЦОД. У тебя есть ТЕКУЩАЯ спецификация услуг (с готовыми id строк) и текст нового ДС. Задача — определить, какие изменения ДС вносит в текущую спецификацию. Верни СТРОГО JSON без пояснений. Не используй markdown-блоки, не добавляй текст до или после JSON. ФОРМАТ: {{ "mode": "partial" | "full_replace", "ops": [ {{"action": "ADD", "new_row": {{"name": "...", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}}, "comment": "..."}}, {{"action": "UPDATE", "target_id": "rN", "new_values": {{"price": число}}, "comment": "..."}}, {{"action": "DELETE", "target_id": "rN", "comment": "..."}}, {{"action": "UNRESOLVED", "new_values": {{"name": "...", "price": число}}, "reason": "почему не смог сопоставить"}} ] }} ДОМЕННЫЙ ГЛОССАРИЙ: - Единицы: кВт (мощность), юнит/U (высота в стойке), шт. (IP, кросс-соединения, порты), Мбит/с\u00b7Гбит/с (канал), ГБ\u00b7ТБ\u00b7vCPU (облако). - Услуги: стойко-место / colocation (размещение в стойке); электропитание / питание (мощность кВт); IP-адрес IPv4/IPv6 (шт.); канал связи / порт (пропускная способность); кросс-соединение (шт.); облачные ресурсы (vCPU/RAM/диск). - Мощность/объём часто ВНУТРИ названия услуги: «Аренда стойко-места, в составе: Номинальная мощность – 10 кВт». Если ДС меняет мощность (10 кВт \u2192 15 кВт) — это UPDATE той же строки, причём меняется и name, и, как правило, price/sum. СОПОСТАВЛЕНИЕ СТРОК: 1. Для UPDATE/DELETE укажи target_id (r1, r2\u2026) ИЗ списка текущей спецификации ниже. НЕ придумывай новые id. 2. Сопоставляй по СМЫСЛУ услуги, а не по точному совпадению символов. «Аренда стойко-места» = «Размещение оборудования в стойке» = одна услуга. Различие тире/пробелов/кавычек игнорируй. 3. new_values в UPDATE — ТОЛЬКО изменённые поля (не дублируй неизменные). 4. Если ДС увеличивает количество той же услуги (было 8 IP, стало 12) — это UPDATE qty (и sum), а не новая ADD. РЕЖИМ mode: 5. mode = "full_replace" — если ДС полностью переиздаёт приложение/спецификацию. Признаки: «Приложение \u2026 излагается в следующей редакции», «изложить в новой редакции», «заменить приложение \u2116\u2026». При full_replace опиши ВСЕ строки новой редакции как ADD (UPDATE/DELETE не используй). 6. mode = "partial" — если ДС точечно меняет отдельные позиции (изменить цену, добавить/удалить услугу, изменить мощность/кол-во). 7. Если в тексте есть и фраза о новой редакции, и точечные правки — приоритет за «новой редакцией»: full_replace. EDGE-CASES: 8. UNRESOLVED — если ДС упоминает изменение услуги, которой НЕТ в текущей спецификации, ИЛИ название настолько отличается, что нельзя уверенно сопоставить с конкретным id. ВАЖНО: если СОМНЕВАЕШЬСЯ в сопоставлении — делай UNRESOLVED, а НЕ ADD. Лучше unresolved, чем ложный дубликат. В reason укажи причину. 9. Частичные данные: если в ДС нет цены/кол-ва/даты — ставь null для этих полей, не выдумывай. 10. Пропускай итоговые строки («Итого», «НДС», «К оплате») и подписи/реквизиты. 11. price, qty, sum — ЧИСЛА (без «руб.», без пробелов; «55 000,00» \u2192 55000). Не пересчитывай суммы сам — бери из ДС. 12. date_start — YYYY-MM-DD; используй дату вступления изменения в силу из ДС, если она указана. ПРИМЕР 1 (partial, UPDATE цены): Текущая спецификация: [id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01 [id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01 Текст ДС: «С 01.03.2025 стоимость аренды стойко-места устанавливается в размере 55 000,00 руб. в месяц.» Ответ: {{ "mode": "partial", "ops": [ {{"action": "UPDATE", "target_id": "r1", "new_values": {{"price": 55000, "sum": 55000, "date_start": "2025-03-01"}}, "comment": "Изменение стоимости аренды стойко-места"}} ] }} ПРИМЕР 2 (partial: ADD новая услуга + UPDATE количества + UPDATE мощности): Текущая спецификация: [id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01 [id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01 Текст ДС: «С 01.04.2025: 1) увеличить номинальную мощность стойко-места до 15 кВт, стоимость — 70 000,00 руб.; 2) предоставить дополнительно 4 IP-адреса IPv4 (итого 12 шт., сумма 3 600,00 руб.); 3) предоставить услугу "Кросс-соединение" — 2 шт. по 1 500,00 руб., сумма 3 000,00 руб.» Ответ: {{ "mode": "partial", "ops": [ {{"action": "UPDATE", "target_id": "r1", "new_values": {{"name": "Аренда стойко-места, в составе: Номинальная мощность – 15 кВт", "price": 70000, "sum": 70000, "date_start": "2025-04-01"}}, "comment": "Увеличение мощности 10\u219215 кВт"}}, {{"action": "UPDATE", "target_id": "r2", "new_values": {{"qty": 12, "sum": 3600, "date_start": "2025-04-01"}}, "comment": "Увеличение количества IP-адресов 8\u219212"}}, {{"action": "ADD", "new_row": {{"name": "Кросс-соединение", "price": 1500, "qty": 2, "sum": 3000, "date_start": "2025-04-01"}}, "comment": "Новая услуга"}} ] }} ПРИМЕР 3 (full_replace): Текущая спецификация: [id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01 [id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01 Текст ДС: «Приложение №1 (Спецификация услуг) излагается в следующей редакции: 1. Аренда стойко-места, номинальная мощность 15 кВт — 1 шт. — 70 000,00 руб. 2. IP-адрес IPv4 — 12 шт. — 300,00 руб. — 3 600,00 руб. 3. Канал связи 1 Гбит/с — 1 шт. — 20 000,00 руб. Дата: 01.05.2025» Ответ: {{ "mode": "full_replace", "ops": [ {{"action": "ADD", "new_row": {{"name": "Аренда стойко-места, номинальная мощность 15 кВт", "price": 70000, "qty": 1, "sum": 70000, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}}, {{"action": "ADD", "new_row": {{"name": "IP-адрес IPv4", "price": 300, "qty": 12, "sum": 3600, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}}, {{"action": "ADD", "new_row": {{"name": "Канал связи 1 Гбит/с", "price": 20000, "qty": 1, "sum": 20000, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}} ] }} ПРИМЕР 4 (UNRESOLVED): Текущая спецификация: [id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01 Текст ДС: «Снизить стоимость услуги резервного копирования до 4 000,00 руб.» Ответ: {{ "mode": "partial", "ops": [ {{"action": "UNRESOLVED", "new_values": {{"name": "Резервное копирование", "price": 4000}}, "reason": "В текущей спецификации нет услуги резервного копирования — не с чем сопоставить"}} ] }} ТЕКУЩАЯ СПЕЦИФИКАЦИЯ: {spec_current} ТЕКСТ ДОПСОГЛАШЕНИЯ: --- {doc_text} ---""" def _fetch_prompt(role: str) -> dict | None: """Получить активный промпт из БД напрямую (а не через Lucee HTTP).""" try: row = db_prompts.get_active(role) if row and row.get("body"): return {"id": row.get("id", ""), "body": row["body"]} except Exception: pass return None def _build_spec_text(current_spec: list) -> str: """Перечисление строк спецификации для подстановки в {spec_current}.""" lines = [] for i, r in enumerate(current_spec): lines.append( f"[id: r{i+1}] {r.get('name', '?')} | " f"цена={r.get('price', '')} | объём={r.get('qty', '')} | " f"сумма={r.get('sum', '')} | начало={r.get('date_start', '')}" ) return "\n".join(lines) def build_prompt(current_spec: list, doc_text: str) -> tuple: """ Формирует промпт для LLM. 1. Пробует получить активный промпт из БД (Lucee API). 2. При неудаче — fallback на хардкод. Возвращает (текст_промпта, prompt_id). prompt_id — UUID версии промпта из БД, или "" если fallback. """ is_first = len(current_spec) == 0 role = "extract" if is_first else "diff" db = _fetch_prompt(role) if db: template = db["body"] prompt_id = db.get("id", "") else: template = FALLBACK_EXTRACT if is_first else FALLBACK_DIFF prompt_id = "" # Подстановка плейсхолдеров result = template.replace("{doc_text}", doc_text) result = result.replace("{spec_current}", _build_spec_text(current_spec)) return result, prompt_id def build_classify_prompt(header_text): """Build classify prompt. Returns (prompt, prompt_id).""" from db import prompts as db_prompts prompt = db_prompts.get_active("classify") if prompt: body = prompt["body"].replace("{header_text}", header_text) return body, prompt.get("id", "") # Fallback body = """Ты — классификатор договорных документов облачного провайдера НУБЕС. Ниже фрагмент текста документа. Определи: 1. doc_type: - "contract" — договор (заголовок «Договор», «Соглашение», преамбула с условиями) - "supplement" — допсоглашение (ссылается на родительский договор, меняет условия) - "specification" — спецификация / приложение с таблицей услуг (стойко-места, IP, каналы, питание) - "other" — НЕ договорной документ: акт сверки, счёт, счёт-фактура, УПД, акт оказанных услуг, платёжное поручение, доверенность, письмо 2. own_number — номер ЭТОГО документа (например «XXX001-03700», «МЭС-123/2024», «1» для допника). Если номер не указан — null. 3. parent_number — номер родительского договора (для supplement и specification). Для doc_type="contract": ВСЕГДА null. 4. doc_date — дата документа в формате YYYY-MM-DD. Если дата прописью — переведи в цифры. Если нет даты — null. 5. counterparty — название КОНТРАГЕНТА (Заказчика). ВАЖНО: НУБЕС — всегда Исполнитель. НЕ возвращай НУБЕС как counterparty. НУБЕС известен как: «НУБЕС», «ООО НУБЕС», «ООО "НУБЕС"», «Nubes». counterparty — ВСЕГДА другая сторона (Заказчик/Покупатель/Абонент). Если документ не содержит контрагента — null. Верни СТРОГО JSON без пояснений: {"doc_type":"...","own_number":"...","parent_number":"...","doc_date":"...","counterparty":"..."} ПРИМЕР 1 (договор): Текст: «Договор № XXX001-03700 от 15.03.2025. ООО "НУБЕС" (Исполнитель) и ЗАО "ТехноПлюс" (Заказчик)...» Ответ: {"doc_type":"contract","own_number":"XXX001-03700","parent_number":null,"doc_date":"2025-03-15","counterparty":"ЗАО \"ТехноПлюс\""} ПРИМЕР 2 (допсоглашение): Текст: «Допсоглашение №1 к Договору № XXX003-01300 от 05.06.2024...» Ответ: {"doc_type":"supplement","own_number":"1","parent_number":"XXX003-01300","doc_date":"2024-06-05","counterparty":"АО XXX003"} ПРИМЕР 3 (мусор): Текст: «Акт сверки взаимных расчётов за 1 квартал 2025 г. Стороны: НУБЕС и ООО Ромашка. Сальдо 150 000 руб.» Ответ: {"doc_type":"other","own_number":null,"parent_number":null,"doc_date":"2025-03-31","counterparty":"ООО Ромашка"} ДОКУМЕНТ: --- {header_text} ---""".replace("{header_text}", header_text) return body, ""