git add . && git commit -m
This commit is contained in:
parent
ee0e62160f
commit
d2fbee3a12
103
app/assistant.py
Normal file
103
app/assistant.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from .models import Event, GlobalStatus
|
||||||
|
from .db import engine
|
||||||
|
|
||||||
|
def get_response(text: str) -> str:
|
||||||
|
"""Принимает вопрос, определяет интент и возвращает ответ."""
|
||||||
|
text = text.lower().strip()
|
||||||
|
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
# Получаем текущий статус
|
||||||
|
current_status = db_session.exec(select(GlobalStatus)).first()
|
||||||
|
|
||||||
|
# --- ИНТЕНТ: ГДЕ СЕЙЧАС / СТАТУС ---
|
||||||
|
if re.search(r'где|статус|сейчас', text):
|
||||||
|
if current_status.status == 'on_place':
|
||||||
|
return "Никита Сергеевич сейчас на месте (в аудитории)."
|
||||||
|
elif current_status.status == 'delayed':
|
||||||
|
return f"Никита Сергеевич опаздывает на {current_status.delay_minutes} мин. Причина: {current_status.message or 'не указана'}."
|
||||||
|
elif current_status.status == 'away':
|
||||||
|
return "Никита Сергеевич вне вуза."
|
||||||
|
elif current_status.status == 'cancelled':
|
||||||
|
return f"Занятия отменены! {current_status.message or ''}"
|
||||||
|
else:
|
||||||
|
return "Никита Сергеевич сейчас свободен / вне расписания."
|
||||||
|
|
||||||
|
# --- ИНТЕНТ: ОПАЗДЫВАЕТ? ---
|
||||||
|
if re.search(r'опаздыва|задержива', text):
|
||||||
|
if current_status.status == 'delayed':
|
||||||
|
return f"Да, опаздывает на {current_status.delay_minutes} мин. Причина: {current_status.message or 'не указана'}."
|
||||||
|
else:
|
||||||
|
return "По текущему статусу, он не опаздывает."
|
||||||
|
|
||||||
|
# --- ИНТЕНТ: КОГДА СЛЕДУЮЩАЯ ПАРА / БЛИЖАЙШЕЕ ---
|
||||||
|
if re.search(r'когда|следующ|ближайш', text):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
next_event = db_session.exec(
|
||||||
|
select(Event)
|
||||||
|
.where(Event.is_public == True)
|
||||||
|
.where(Event.start_time >= now)
|
||||||
|
.order_by(Event.start_time)
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if next_event:
|
||||||
|
local_time = next_event.start_time.strftime('%d.%m в %H:%M')
|
||||||
|
return f"Ближайшее событие: {next_event.title} ({local_time}). Место: {next_event.location or 'не указано'}."
|
||||||
|
else:
|
||||||
|
return "В ближайшее время событий в расписании нет."
|
||||||
|
|
||||||
|
# --- ИНТЕНТ: РАСПИСАНИЕ НА СЕГОДНЯ ---
|
||||||
|
if re.search(r'сегодня|седня', text):
|
||||||
|
# Берем начало сегодняшнего дня и конец сегодняшнего дня
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_of_day = start_of_day + timedelta(days=1)
|
||||||
|
|
||||||
|
events = db_session.exec(
|
||||||
|
select(Event)
|
||||||
|
.where(Event.is_public == True)
|
||||||
|
.where(Event.start_time >= start_of_day)
|
||||||
|
.where(Event.start_time < end_of_day)
|
||||||
|
.order_by(Event.start_time)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
return "На сегодня событий в расписании нет."
|
||||||
|
|
||||||
|
res = "Расписание на сегодня:\n"
|
||||||
|
for e in events:
|
||||||
|
res += f"• {e.start_time.strftime('%H:%M')} - {e.title} ({e.location or 'без места'})\n"
|
||||||
|
return res
|
||||||
|
|
||||||
|
# --- ИНТЕНТ: РАСПИСАНИЕ НА НЕДЕЛЮ ---
|
||||||
|
if re.search(r'недел|7 дней', text):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
end_of_week = now + timedelta(days=7)
|
||||||
|
|
||||||
|
events = db_session.exec(
|
||||||
|
select(Event)
|
||||||
|
.where(Event.is_public == True)
|
||||||
|
.where(Event.start_time >= now)
|
||||||
|
.where(Event.start_time <= end_of_week)
|
||||||
|
.order_by(Event.start_time)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
return "На ближайшую неделю событий нет."
|
||||||
|
|
||||||
|
res = "Расписание на неделю:\n"
|
||||||
|
for e in events[:5]: # Показываем первые 5, чтобы не спамить
|
||||||
|
res += f"• {e.start_time.strftime('%d.%m %H:%M')} - {e.title}\n"
|
||||||
|
if len(events) > 5:
|
||||||
|
res += "...и другие события (смотрите на странице)."
|
||||||
|
return res
|
||||||
|
|
||||||
|
# --- ИНТЕНТ: КОНТАКТЫ ---
|
||||||
|
if re.search(r'связаться|контакт|телеграм|почта|кабинет', text):
|
||||||
|
return "Связаться с Никитой Сергеевичем можно по почте: freezer.nik@gmail.com (или в Telegram)."
|
||||||
|
|
||||||
|
# --- ЕСЛИ НИЧЕГО НЕ ПОНЯЛ ---
|
||||||
|
return "Извините, я пока не понимаю этот вопрос. Попробуйте спросить: 'Где сейчас?', 'Когда следующая пара?', 'Что сегодня?' или 'Как связаться?'."
|
||||||
15
app/main.py
15
app/main.py
|
|
@ -1,6 +1,6 @@
|
||||||
from flask import Flask, render_template, jsonify, redirect, url_for, request, session
|
from flask import Flask, render_template, jsonify, redirect, url_for, request, session
|
||||||
from .db import init_db, engine
|
from .db import init_db, engine
|
||||||
from . import config, auth
|
from . import config, auth, assistant
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from .models import User, Event, GlobalStatus
|
from .models import User, Event, GlobalStatus
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
@ -156,6 +156,18 @@ def public_schedule():
|
||||||
|
|
||||||
return render_template("schedule.html", status=current_status, events=events)
|
return render_template("schedule.html", status=current_status, events=events)
|
||||||
|
|
||||||
|
# --- АССИСТЕНТ: API ---
|
||||||
|
@app.route("/api/assistant", methods=["POST"])
|
||||||
|
def api_assistant():
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or "message" not in data:
|
||||||
|
return jsonify({"error": "Нет сообщения"}), 400
|
||||||
|
|
||||||
|
user_message = data["message"]
|
||||||
|
reply = assistant.get_response(user_message)
|
||||||
|
|
||||||
|
return jsonify({"reply": reply})
|
||||||
|
|
||||||
# --- РАСПИСАНИЕ: АДМИН-ПАНЕЛЬ ---
|
# --- РАСПИСАНИЕ: АДМИН-ПАНЕЛЬ ---
|
||||||
@app.route("/admin/schedule")
|
@app.route("/admin/schedule")
|
||||||
@require_role(["admin", "professor"])
|
@require_role(["admin", "professor"])
|
||||||
|
|
@ -200,7 +212,6 @@ def add_event():
|
||||||
description = request.form.get("description")
|
description = request.form.get("description")
|
||||||
is_public = request.form.get("is_public") == "on"
|
is_public = request.form.get("is_public") == "on"
|
||||||
|
|
||||||
# Парсим дату из input type="datetime-local"
|
|
||||||
start_time = datetime.strptime(start_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
|
start_time = datetime.strptime(start_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
|
||||||
end_time = datetime.strptime(end_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
|
end_time = datetime.strptime(end_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<style>
|
<style>
|
||||||
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 40px 20px; }
|
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 40px 20px; }
|
||||||
.container { max-width: 800px; margin: 0 auto; }
|
.container { max-width: 800px; margin: 0 auto; }
|
||||||
|
|
||||||
.status-banner { padding: 20px; border-radius: 10px; text-align: center; margin-bottom: 30px; }
|
.status-banner { padding: 20px; border-radius: 10px; text-align: center; margin-bottom: 30px; }
|
||||||
.status-on_place { background: rgba(35, 134, 54, 0.2); border: 1px solid #238636; color: #3fb950; }
|
.status-on_place { background: rgba(35, 134, 54, 0.2); border: 1px solid #238636; color: #3fb950; }
|
||||||
.status-delayed { background: rgba(210, 153, 34, 0.2); border: 1px solid #d29922; color: #d29922; }
|
.status-delayed { background: rgba(210, 153, 34, 0.2); border: 1px solid #d29922; color: #d29922; }
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
.status-free { background: rgba(139, 148, 158, 0.2); border: 1px solid #8b949e; color: #8b949e; }
|
.status-free { background: rgba(139, 148, 158, 0.2); border: 1px solid #8b949e; color: #8b949e; }
|
||||||
.status-title { font-size: 1.5rem; font-weight: bold; margin-bottom: 5px; }
|
.status-title { font-size: 1.5rem; font-weight: bold; margin-bottom: 5px; }
|
||||||
.status-message { font-size: 1.1rem; }
|
.status-message { font-size: 1.1rem; }
|
||||||
|
|
||||||
.event-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 15px; display: flex; gap: 20px; }
|
.event-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 15px; display: flex; gap: 20px; }
|
||||||
.event-time { font-family: monospace; font-size: 1.1rem; color: #58a6ff; min-width: 120px; }
|
.event-time { font-family: monospace; font-size: 1.1rem; color: #58a6ff; min-width: 120px; }
|
||||||
.event-details { flex: 1; }
|
.event-details { flex: 1; }
|
||||||
|
|
@ -21,8 +23,21 @@
|
||||||
.event-meta { color: #8b949e; font-size: 0.9rem; margin-bottom: 5px; }
|
.event-meta { color: #8b949e; font-size: 0.9rem; margin-bottom: 5px; }
|
||||||
.event-type { display: inline-block; background: #21262d; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; text-transform: uppercase; }
|
.event-type { display: inline-block; background: #21262d; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; text-transform: uppercase; }
|
||||||
.event-desc { font-size: 0.95rem; margin-top: 10px; }
|
.event-desc { font-size: 0.95rem; margin-top: 10px; }
|
||||||
|
|
||||||
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||||
.back-link:hover { color: #58a6ff; }
|
.back-link:hover { color: #58a6ff; }
|
||||||
|
|
||||||
|
/* Стили для чата */
|
||||||
|
.chat-container { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-top: 40px; display: flex; flex-direction: column; height: 400px; }
|
||||||
|
.chat-header { padding: 15px; border-bottom: 1px solid #30363d; background: #21262d; border-radius: 8px 8px 0 0; font-weight: bold; }
|
||||||
|
.chat-messages { flex: 1; padding: 15px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.msg { max-width: 80%; padding: 10px 15px; border-radius: 8px; font-size: 0.95rem; line-height: 1.4; white-space: pre-wrap; }
|
||||||
|
.msg.bot { background: #21262d; align-self: flex-start; border: 1px solid #30363d; }
|
||||||
|
.msg.user { background: #1f6feb; color: white; align-self: flex-end; }
|
||||||
|
.chat-input-area { padding: 15px; border-top: 1px solid #30363d; display: flex; gap: 10px; }
|
||||||
|
.chat-input-area input { flex: 1; padding: 10px; border-radius: 5px; border: 1px solid #30363d; background: #0d1117; color: white; outline: none; }
|
||||||
|
.chat-input-area button { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
|
||||||
|
.chat-input-area button:hover { background: #2ea043; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -67,6 +82,62 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="color: #8b949e;">Пока нет запланированных событий.</p>
|
<p style="color: #8b949e;">Пока нет запланированных событий.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Блок Чат-бота -->
|
||||||
|
<div class="chat-container">
|
||||||
|
<div class="chat-header">🤖 Ассистент расписания</div>
|
||||||
|
<div class="chat-messages" id="chat-messages">
|
||||||
|
<div class="msg bot">Привет! Я могу подсказать статус Никиты Сергеевича, его расписание или контакты. Спроси меня о чем-нибудь.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<form class="chat-input-area" id="chat-form">
|
||||||
|
<input type="text" id="chat-input" placeholder="Например: Где сейчас? Что на неделе?" autocomplete="off">
|
||||||
|
<button type="submit">Отправить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const chatForm = document.getElementById('chat-form');
|
||||||
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
|
||||||
|
chatForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = chatInput.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
// Добавляем сообщение пользователя
|
||||||
|
addMessage(text, 'user');
|
||||||
|
chatInput.value = '';
|
||||||
|
|
||||||
|
// Добавляем индикатор загрузки
|
||||||
|
const loadingMsg = addMessage('...', 'bot');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/assistant', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: text })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Заменяем индикатор загрузки на ответ бота
|
||||||
|
loadingMsg.textContent = data.reply || 'Ошибка: пустой ответ';
|
||||||
|
} catch (err) {
|
||||||
|
loadingMsg.textContent = 'Ошибка соединения с сервером.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function addMessage(text, sender) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `msg ${sender}`;
|
||||||
|
div.textContent = text;
|
||||||
|
chatMessages.appendChild(div);
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user