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.parse import parse
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()
@@ -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/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/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)
+37 -6
View File
@@ -12,21 +12,37 @@ import json
import llm_client
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)
Returns:
{"rows": [{row_num, name, price, qty, sum, date_start}, ...], "unresolved": [...]}
или
{"error": "...", "raw_response": "..."}
{"rows": [...], "unresolved": [...]} или {"error": "..."}
"""
# ── Промпт ──────────────────────────────────────────────────
prompt = f"""Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера.
prompt = custom_prompt or DEFAULT_PROMPT
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-массив.
@@ -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 ────────────────────────────────────────────────
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"
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:
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")
# ── Хранилище промптов (в памяти) ──────────────────────────────
_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):
"""
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;">
<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить (LLM)
</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>
@@ -135,6 +145,32 @@
<script>
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 parseBtn = document.getElementById('parseBtn');
const fileTable = document.getElementById('fileTable');
@@ -452,6 +488,14 @@
document.getElementById('llmBtn').addEventListener('click', function() {
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');
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-анализ...';
@@ -586,6 +630,41 @@
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');
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
document.head.appendChild(style);