v3.3.0: чат с договором — задать вопрос по spec_rows

This commit is contained in:
2026-06-18 09:23:09 +04:00
parent e3b7f283f0
commit 958865c397
3 changed files with 115 additions and 3 deletions
+2 -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 from routes.llm import process as llm_process, chat as llm_chat
load_dotenv() load_dotenv()
@@ -47,6 +47,7 @@ class ContractsApp:
# LLM: страница + SSE-извлечение # LLM: страница + SSE-извлечение
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("/health", "health", health) self.app.add_url_rule("/health", "health", health)
+57 -2
View File
@@ -1,13 +1,14 @@
"""routes/llm.py — LLM-извлечение + сравнение: один SSE-поток.""" """routes/llm.py — LLM-извлечение + сравнение + чат."""
import json as _json import json as _json
import time as _time import time as _time
from flask import Response from flask import Response, request, jsonify
import db import db
import extractor import extractor
import differ import differ
import llm_client
def process(cid): def process(cid):
@@ -137,3 +138,57 @@ def process(cid):
yield f"data: {_json.dumps({'type': 'done', 'total_time_s': total_time, 'all_changes': all_changes})}\n\n" yield f"data: {_json.dumps({'type': 'done', 'total_time_s': total_time, 'all_changes': all_changes})}\n\n"
return Response(generate(), mimetype="text/event-stream") return Response(generate(), mimetype="text/event-stream")
def chat(cid):
"""
POST /llm/chat/<cid> — задать вопрос по договору.
Тело: {"question": "..."}
Контекст: все строки spec_rows этого договора.
Ответ: {"answer": "..."}
"""
data = request.get_json(silent=True) or {}
question = data.get("question", "").strip()
if not question:
return jsonify({"error": "Введите вопрос"}), 400
# ── Собрать контекст: все строки спецификации ──────────
rows_res, _ = db.query(
"SELECT s.type, s.number, d.filename, sr.row_num, sr.name, sr.price, sr.qty, sr.sum, sr.date_start "
"FROM spec_rows sr "
"JOIN supplements s ON sr.supplement_id = s.id "
"JOIN documents d ON s.document_id = d.id "
"WHERE s.contract_id = %s ORDER BY s.created_at, sr.row_num",
(cid,),
)
if not rows_res or not rows_res["rows"]:
return jsonify({"answer": "Нет извлечённых данных. Сначала запустите сравнение."})
# Форматируем контекст
lines = []
for r in rows_res["rows"]:
row = dict(zip(rows_res["columns"], r))
lines.append(
f"[{row['filename']}] строка {row['row_num']}: {row['name']} | "
f"цена={row['price']} | объём={row['qty']} | сумма={row['sum']} | начало={row['date_start']}"
)
context = "\n".join(lines)
# ── Промпт ──────────────────────────────────────────────
prompt = f"""Ты — анализатор договоров облачного провайдера. У тебя есть извлечённые строки спецификации услуг.
ДАННЫЕ:
{context}
ВОПРОС ПОЛЬЗОВАТЕЛЯ:
{question}
Ответь кратко и по делу, опираясь ТОЛЬКО на данные выше. Если данных недостаточно — скажи об этом."""
# ── Вызов LLM ───────────────────────────────────────────
result = llm_client.ask(prompt, max_tokens=1000)
if "error" in result:
return jsonify({"answer": f"Ошибка LLM: {result['error']}"})
return jsonify({"answer": result.get("text", "Нет ответа")})
+56
View File
@@ -104,6 +104,21 @@
</div> </div>
<div class="card-body" id="diffBody"></div> <div class="card-body" id="diffBody"></div>
</div> </div>
<!-- Чат с договором -->
<div class="card" id="chatCard" style="display:none;margin-top:16px;">
<div class="card-header">
<i data-lucide="message-circle" style="width:18px;height:18px;"></i>
Задать вопрос по договору
</div>
<div class="card-body">
<div id="chatMessages" style="margin-bottom:8px;font-size:13px;"></div>
<div style="display:flex;gap:8px;">
<input type="text" id="chatInput" placeholder="Какая общая сумма? Что изменилось? ..." style="flex:1;height:32px;border:1px solid var(--brand-gray);border-radius:6px;padding:0 8px;font-size:13px;">
<button class="btn btn-primary" id="chatSend" style="height:32px;font-size:13px;"></button>
</div>
</div>
</div>
</div> </div>
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)"> <div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
@@ -485,6 +500,10 @@
lucide.createIcons(); lucide.createIcons();
diffStatus.textContent = '✓ ' + d.total_time_s + 'с'; diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
// Показать чат
document.getElementById('chatCard').style.display = 'block';
lucide.createIcons();
var all = d.all_changes || []; var all = d.all_changes || [];
if (all.length === 0) { if (all.length === 0) {
diffBody.innerHTML += '<div style="color:var(--muted);margin-top:8px;">Нет допников для сравнения.</div>'; diffBody.innerHTML += '<div style="color:var(--muted);margin-top:8px;">Нет допников для сравнения.</div>';
@@ -530,6 +549,43 @@
return v != null ? v : '—'; return v != null ? v : '—';
} }
// ── Чат ──────────────────────────────────────────────────────
document.getElementById('chatSend').addEventListener('click', function() {
var input = document.getElementById('chatInput');
var q = input.value.trim();
if (!q || !contractId) return;
var msgs = document.getElementById('chatMessages');
msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>';
input.value = '';
input.disabled = true;
document.getElementById('chatSend').disabled = true;
msgs.innerHTML += '<div style="color:var(--muted);margin-top:2px;">⏳ Думаю...</div>';
fetch('/llm/chat/' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({question: q})
}).then(function(r) { return r.json(); })
.then(function(d) {
msgs.lastChild.remove(); // убрать "Думаю..."
msgs.innerHTML += '<div style="margin-top:2px;"><strong>Ответ:</strong> ' + (d.answer || d.error || '—') + '</div>';
input.disabled = false;
document.getElementById('chatSend').disabled = false;
input.focus();
}).catch(function() {
msgs.lastChild.remove();
msgs.innerHTML += '<div style="color:var(--destructive);margin-top:2px;">Ошибка сети</div>';
input.disabled = false;
document.getElementById('chatSend').disabled = false;
});
});
document.getElementById('chatInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') document.getElementById('chatSend').click();
});
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);