Files
contracts-flask/site/llm_prompt.py
T
2026-07-15 11:23:13 +04:00

282 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)."""
try:
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", "")
except Exception:
pass # DB unavailable — use 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, ""