v3.3.0: чат с договором — задать вопрос по spec_rows
This commit is contained in:
+2
-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
|
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
@@ -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", "Нет ответа")})
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user