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.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
@@ -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
@@ -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> — задать вопрос по договору.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user