v3.3.1: редактор промпта LLM — сохранить/сбросить/по умолчанию
This commit is contained in:
+3
-1
@@ -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
@@ -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
@@ -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> — задать вопрос по договору.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user