v3.3.1: редактор промпта LLM — сохранить/сбросить/по умолчанию

This commit is contained in:
2026-06-18 09:32:58 +04:00
parent 958865c397
commit ce4871227c
4 changed files with 142 additions and 8 deletions
+3 -1
View File
@@ -13,7 +13,7 @@ from api import api_bp
from routes.main import index from routes.main import index
from routes.parse import parse from routes.parse import parse
from routes.misc import health, logs from routes.misc import health, logs
from routes.llm import process as llm_process, chat as llm_chat from routes.llm import process as llm_process, chat as llm_chat, save_prompt, get_prompt
load_dotenv() load_dotenv()
@@ -48,6 +48,8 @@ class ContractsApp:
self.app.add_url_rule("/llm", "llm_page", lambda: __import__('flask').render_template('llm.html')) self.app.add_url_rule("/llm", "llm_page", lambda: __import__('flask').render_template('llm.html'))
self.app.add_url_rule("/llm/process/<cid>", "llm_process", llm_process) self.app.add_url_rule("/llm/process/<cid>", "llm_process", llm_process)
self.app.add_url_rule("/llm/chat/<cid>", "llm_chat", llm_chat, methods=["POST"]) self.app.add_url_rule("/llm/chat/<cid>", "llm_chat", llm_chat, methods=["POST"])
self.app.add_url_rule("/llm/prompt/<cid>", "llm_prompt_get", get_prompt)
self.app.add_url_rule("/llm/prompt/<cid>", "llm_prompt_save", save_prompt, methods=["POST"])
# Служебные # Служебные
self.app.add_url_rule("/health", "health", health) self.app.add_url_rule("/health", "health", health)
+37 -6
View File
@@ -12,21 +12,37 @@ import json
import llm_client import llm_client
def extract(parsed_text: str) -> dict: def extract(parsed_text: str, custom_prompt: str = None) -> dict:
""" """
Извлечь строки спецификации из текста документа через LLM. Извлечь строки спецификации из текста документа через LLM.
Args: Args:
parsed_text: текст документа (выдача textify.py) parsed_text: текст документа (выдача textify.py)
custom_prompt: свой промпт (если None — используется DEFAULT_PROMPT)
Returns: Returns:
{"rows": [{row_num, name, price, qty, sum, date_start}, ...], "unresolved": [...]} {"rows": [...], "unresolved": [...]} или {"error": "..."}
или
{"error": "...", "raw_response": "..."}
""" """
# ── Промпт ────────────────────────────────────────────────── prompt = custom_prompt or DEFAULT_PROMPT
prompt = f"""Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера. prompt = prompt.replace("{parsed_text}", parsed_text)
"""
extractor.py — Извлечение строк спецификации из текста документа.
Берёт распарсенный текст (parsed_text из documents), отправляет LLM,
получает структурированный список строк спецификации.
Не зависит от parser.py, textify.py, upload.py.
Зависит только от llm_client.py.
"""
import json
import llm_client
# ── Промпт по умолчанию ─────────────────────────────────────────
# {parsed_text} будет заменён на текст документа
DEFAULT_PROMPT = """Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера.
Найди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив. Найди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив.
@@ -53,6 +69,21 @@ def extract(parsed_text: str) -> dict:
--- ---
""" """
def extract(parsed_text: str, custom_prompt: str = None) -> dict:
"""
Извлечь строки спецификации из текста документа через LLM.
Args:
parsed_text: текст документа (выдача textify.py)
custom_prompt: свой промпт (если None — DEFAULT_PROMPT).
Должен содержать {parsed_text} для подстановки текста.
Returns:
{"rows": [...], "unresolved": [...]} или {"error": "..."}
"""
prompt = (custom_prompt or DEFAULT_PROMPT).replace("{parsed_text}", parsed_text)
# ── Вызов LLM ──────────────────────────────────────────────── # ── Вызов LLM ────────────────────────────────────────────────
result = llm_client.ask(prompt, max_tokens=8000) result = llm_client.ask(prompt, max_tokens=8000)
+23 -1
View File
@@ -59,7 +59,8 @@ def process(cid):
yield f"data: {_json.dumps({'type': 'extract_start', 'name': s['filename']})}\n\n" yield f"data: {_json.dumps({'type': 'extract_start', 'name': s['filename']})}\n\n"
t0 = _time.time() t0 = _time.time()
result = extractor.extract(s["parsed_text"]) custom_prompt = _prompts.get(cid) or None
result = extractor.extract(s["parsed_text"], custom_prompt=custom_prompt)
if "error" in result: if "error" in result:
yield f"data: {_json.dumps({'type': 'extract_error', 'name': s['filename'], 'error': result['error']})}\n\n" yield f"data: {_json.dumps({'type': 'extract_error', 'name': s['filename'], 'error': result['error']})}\n\n"
@@ -140,6 +141,27 @@ def process(cid):
return Response(generate(), mimetype="text/event-stream") return Response(generate(), mimetype="text/event-stream")
# ── Хранилище промптов (в памяти) ──────────────────────────────
_prompts = {} # cid → prompt
def save_prompt(cid):
"""POST /llm/prompt/<cid> — сохранить промпт. Тело: {"prompt": "..."}"""
data = request.get_json(silent=True) or {}
prompt = data.get("prompt", "").strip()
if prompt:
_prompts[cid] = prompt
return jsonify({"ok": True})
return jsonify({"error": "пустой промпт"}), 400
def get_prompt(cid):
"""GET /llm/prompt/<cid> — получить сохранённый промпт (или DEFAULT_PROMPT)"""
import extractor
return jsonify({"prompt": _prompts.get(cid, "")})
def chat(cid): def chat(cid):
""" """
POST /llm/chat/<cid> — задать вопрос по договору. POST /llm/chat/<cid> — задать вопрос по договору.
+79
View File
@@ -94,6 +94,16 @@
<button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;"> <button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;">
<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить (LLM) <i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить (LLM)
</button> </button>
<details style="margin-top:8px;font-size:12px;">
<summary style="cursor:pointer;color:var(--muted);">⚙ Промпт LLM</summary>
<textarea id="promptEditor" style="width:100%;height:200px;font-family:monospace;font-size:11px;border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin-top:4px;" placeholder="Промпт для LLM..."></textarea>
<div style="display:flex;gap:8px;margin-top:4px;">
<button class="btn" id="promptSave" style="font-size:11px;height:28px;">Сохранить</button>
<button class="btn" id="promptReset" style="font-size:11px;height:28px;">По умолчанию</button>
<span id="promptStatus" style="font-size:11px;color:var(--muted);line-height:28px;"></span>
</div>
</details>
</div> </div>
</div> </div>
@@ -135,6 +145,32 @@
<script> <script>
lucide.createIcons(); lucide.createIcons();
const DEFAULT_PROMPT = `Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера.
Найди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив.
Для каждой строки верни:
- row_num: номер по порядку (целое число)
- name: полное наименование услуги (весь текст из ячейки)
- price: цена за единицу в рублях (число, из колонки "Цена")
- qty: количество/объём (число, из колонки "Объем")
- sum: итоговая сумма (число, из колонки "Сумма")
- date_start: дата начала оказания (строка YYYY-MM-DD)
ПРАВИЛА:
1. Пропускай итоговые строки ("Итого...") и строки с подписями.
2. Если ячейка пустая — ставь null.
3. Если не можешь определить значение — ставь null и добавь в unresolved.
4. Верни ТОЛЬКО JSON, без пояснений.
Пример ответа:
{"rows":[{"row_num":1,"name":"Аренда стойко-места","price":213905.44,"qty":3,"sum":641716.32,"date_start":"2026-04-01"}],"unresolved":[]}
Текст документа:
---
{parsed_text}
---`;
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
const parseBtn = document.getElementById('parseBtn'); const parseBtn = document.getElementById('parseBtn');
const fileTable = document.getElementById('fileTable'); const fileTable = document.getElementById('fileTable');
@@ -452,6 +488,14 @@
document.getElementById('llmBtn').addEventListener('click', function() { document.getElementById('llmBtn').addEventListener('click', function() {
if (!contractId) return; if (!contractId) return;
// Сохранить промпт на сервер перед стартом
var p = promptEditor.value.trim() || DEFAULT_PROMPT;
fetch('/llm/prompt/' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt: p})
});
var llmBtn = document.getElementById('llmBtn'); var llmBtn = document.getElementById('llmBtn');
llmBtn.disabled = true; llmBtn.disabled = true;
llmBtn.innerHTML = '<span style="display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;"></span> LLM-анализ...'; llmBtn.innerHTML = '<span style="display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;"></span> LLM-анализ...';
@@ -586,6 +630,41 @@
if (e.key === 'Enter') document.getElementById('chatSend').click(); if (e.key === 'Enter') document.getElementById('chatSend').click();
}); });
// ── Редактор промпта ──────────────────────────────────────────
var promptEditor = document.getElementById('promptEditor');
// Загрузить из localStorage
var saved = localStorage.getItem('llm_prompt');
promptEditor.value = saved || DEFAULT_PROMPT;
document.getElementById('promptSave').addEventListener('click', function() {
var p = promptEditor.value.trim();
if (!p) return;
localStorage.setItem('llm_prompt', p);
document.getElementById('promptStatus').textContent = '✓ сохранено';
setTimeout(function() { document.getElementById('promptStatus').textContent = ''; }, 1500);
// Отправить на сервер
if (contractId) {
fetch('/llm/prompt/' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt: p})
});
}
});
document.getElementById('promptReset').addEventListener('click', function() {
promptEditor.value = DEFAULT_PROMPT;
localStorage.removeItem('llm_prompt');
document.getElementById('promptStatus').textContent = '✓ сброшен';
setTimeout(function() { document.getElementById('promptStatus').textContent = ''; }, 1500);
});
// При старте LLM — сохранить промпт для этого contractId
var origLlmClick = document.getElementById('llmBtn').onclick;
// Переопределим ниже...
var style = document.createElement('style'); var style = document.createElement('style');
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }'; style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
document.head.appendChild(style); document.head.appendChild(style);