feat: demo data, AI assistant, UI fixes for presentation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yuriy 2026-02-15 23:33:49 +03:00
parent 0a19a03b6e
commit 48d80137ac
8 changed files with 496 additions and 6 deletions

View File

@ -56,7 +56,7 @@ export default function DashboardPage() {
const personnelIssues = data.personnel?.non_compliant || 0;
return (
<PageLayout title="📊 Дашборд АСУ ТК" subtitle="Калининградский филиал — контроль лётной годности">
<PageLayout title="📊 Дашборд АСУ ТК" subtitle="REFLY — система контроля лётной годности">
{loading ? <div className="text-center py-16 text-gray-400"> Загрузка данных...</div> : (
<div className="space-y-6">
{/* Critical alerts banner */}

View File

@ -5,6 +5,7 @@ import './globals.css'
import { Providers } from './providers'
import ErrorBoundary from '@/components/ErrorBoundary'
import SkipToMain from '@/components/SkipToMain'
import AIAssistant from '@/components/AIAssistant'
export const metadata: Metadata = {
title: 'REFLY — Контроль лётной годности',
@ -30,7 +31,8 @@ export default function RootLayout({
<Providers>
<ErrorBoundary>
<SkipToMain />
{children}
{children}
<AIAssistant />
</ErrorBoundary>
</Providers>
<Script id="sw-register" strategy="afterInteractive">

View File

@ -16,7 +16,7 @@ from .checklist_audits import router as checklist_audits_router
from .inbox import router as inbox_router
from .tasks import router as tasks_router
from .audit import router as audit_router
from .ai import router as ai_router
from .ai_assistant import router as ai_router
from .document_templates import router as document_templates_router
__all__ = [

View File

@ -0,0 +1,77 @@
"""AI-помощник REFLY — чат с контекстом из БД (для демо и докладов)."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.deps import get_current_user
from app.core.config import settings
from app.db.session import SessionLocal
import httpx
router = APIRouter(prefix="/ai", tags=["AI Assistant"])
class ChatRequest(BaseModel):
message: str
class ChatResponse(BaseModel):
reply: str
@router.post("/chat", response_model=ChatResponse)
async def ai_chat(req: ChatRequest, user=Depends(get_current_user)):
api_key = getattr(settings, "ANTHROPIC_API_KEY", None) or ""
if not api_key or api_key == "":
raise HTTPException(400, "AI assistant not configured")
db = SessionLocal()
try:
from app.models.aircraft_db import Aircraft
from app.models.organization import Organization
aircraft_count = db.query(Aircraft).count()
org_count = db.query(Organization).count()
context = (
f"В системе: {aircraft_count} воздушных судов, {org_count} организаций. "
f"Роль пользователя: {user.role}. Имя: {user.display_name}."
)
finally:
db.close()
system_prompt = f"""Ты — AI-помощник системы REFLY АСУ ТК (контроль лётной годности воздушных судов).
Ты помогаешь с вопросами о:
- Состоянии воздушных судов и их лётной годности
- Директивах лётной годности и сервисных бюллетенях
- Планировании ТО и инспекций
- Нормативных документах (ФАП, EASA, ICAO)
- Сертификации и допусках
- Управлении рисками безопасности полётов
Контекст системы: {context}
Отвечай на русском языке. Будь конкретным и профессиональным.
Используй авиационную терминологию где уместно."""
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": getattr(settings, "ANTHROPIC_MODEL", "claude-sonnet-4-20250514"),
"max_tokens": 1024,
"system": system_prompt,
"messages": [{"role": "user", "content": req.message}],
},
)
if resp.status_code != 200:
raise HTTPException(502, f"AI service error: {resp.status_code}")
data = resp.json()
content = data.get("content", [{}])
reply = content[0].get("text", "Ошибка получения ответа") if content else "Ошибка получения ответа"
return ChatResponse(reply=reply)

View File

@ -37,9 +37,9 @@ def _serialize_aircraft(a: Aircraft, db: Session) -> AircraftOut:
"serial_number": a.serial_number,
"manufacture_date": getattr(a, 'manufacture_date', None),
"first_flight_date": getattr(a, 'first_flight_date', None),
"total_time": float(a.total_time) if a.total_time is not None else None,
"total_time": float(a.total_time) if getattr(a, 'total_time', None) is not None else None,
"total_cycles": getattr(a, 'total_cycles', None),
"current_status": getattr(a, 'current_status', 'in_service') or "in_service",
"current_status": getattr(a, 'current_status', None) or getattr(a, 'status', 'in_service') or "in_service",
"configuration": getattr(a, 'configuration', None),
"drawing_numbers": getattr(a, 'drawing_numbers', None),
"work_completion_date": getattr(a, 'work_completion_date', None),
@ -81,7 +81,7 @@ def list_aircraft(
"""List aircraft with pagination. Returns {items, total, page, per_page, pages}."""
query = _base_query(db, user)
if q:
query = query.filter(or_(Aircraft.registration_number.ilike(f"%{q}%"), Aircraft.drawing_numbers.ilike(f"%{q}%")))
query = query.filter(Aircraft.registration_number.ilike(f"%{q}%"))
query = query.order_by(Aircraft.registration_number)
total = query.count()
items_raw = query.offset((page - 1) * per_page).limit(per_page).all()

View File

@ -0,0 +1,261 @@
"""
Полное наполнение демо-данными для доклада: пользователи, аудиты, дефекты, заявки, риски, персонал ПЛГ.
Запускается из main.py lifespan после seed_checklists и seed_organizations.
"""
import logging
import uuid
from datetime import datetime, timezone, timedelta
from app.db.session import SessionLocal
from app.models import (
User,
Organization,
Audit,
ChecklistTemplate,
CertApplication,
CertApplicationStatus,
RiskAlert,
)
from app.models.aircraft_db import Aircraft
from app.models.personnel_plg import PLGSpecialist, PLGQualification
logger = logging.getLogger(__name__)
def _get_org_id(db, name_substr: str):
o = db.query(Organization).filter(Organization.name.ilike(f"%{name_substr}%")).first()
return str(o.id) if o else None
def _get_aircraft_id_by_reg(db, reg: str):
a = db.query(Aircraft).filter(Aircraft.registration_number == reg).first()
return str(a.id) if a else None
def _get_first_aircraft_id(db):
a = db.query(Aircraft).first()
return str(a.id) if a else None
def _get_template_id(db):
t = db.query(ChecklistTemplate).first()
return str(t.id) if t else None
def seed_full_demo():
db = SessionLocal()
try:
# ─── 1. Пользователи ─────────────────────────────────────────────
demo_users = [
("Иванов Сергей А.", "ivanov@refly.ru", "admin", None),
("Петрова Елена В.", "petrova@refly.ru", "authority_inspector", None),
("Козлов Дмитрий И.", "kozlov@refly.ru", "operator_manager", "S7"),
("Сидорова Анна М.", "sidorova@refly.ru", "operator_user", "S7"),
("Волков Алексей Н.", "volkov@refly.ru", "mro_manager", "Домодедово"),
("Морозова Ольга С.", "morozova@refly.ru", "mro_user", "Домодедово"),
("Николаев Павел Р.", "nikolaev@refly.ru", "authority_inspector", None),
("Федорова Мария К.", "fedorova@refly.ru", "operator_user", "Smartavia"),
]
created_users = {}
for display_name, email, role, org_key in demo_users:
if db.query(User).filter(User.email == email).first():
continue
org_id = _get_org_id(db, org_key) if org_key else None
uid = str(uuid.uuid4())
db.add(
User(
id=uid,
external_subject=f"demo-{email}",
display_name=display_name,
email=email,
role=role,
organization_id=org_id,
)
)
created_users[email] = uid
db.commit()
logger.info("seed_full_demo: users checked/created")
# ─── 2. Аудиты (нужен template_id и aircraft_id) ─────────────────
template_id = _get_template_id(db)
first_aircraft_id = _get_first_aircraft_id(db)
inspector_id = db.query(User).filter(User.role == "authority_inspector").first()
inspector_id = str(inspector_id.id) if inspector_id else None
if template_id and first_aircraft_id and inspector_id:
audits_data = [
("Плановая проверка ТОиР S7 Airlines", "completed", "scheduled"),
("Внеплановая проверка после инцидента", "in_progress", "unscheduled"),
("Сертификационный аудит АТЦ Домодедово", "draft", "certification"),
("Проверка СМК авиакомпании Smartavia", "completed", "scheduled"),
("Инспекция рампы Шереметьево", "in_progress", "ramp"),
("Аудит системы управления безопасностью", "draft", "sms"),
]
base_date = datetime(2025, 6, 1, tzinfo=timezone.utc)
existing_count = db.query(Audit).count()
if existing_count >= len(audits_data):
pass # already enough
else:
for i, (title, status, audit_type) in enumerate(audits_data):
if existing_count + i >= len(audits_data):
break
planned = base_date + timedelta(days=30 * i)
completed = planned + timedelta(days=2) if status == "completed" else None
a = Audit(
template_id=template_id,
aircraft_id=first_aircraft_id,
status=status,
planned_at=planned,
completed_at=completed,
inspector_user_id=inspector_id,
notes=f"{audit_type}: {title}",
)
db.add(a)
db.commit()
logger.info("seed_full_demo: audits checked/created")
# ─── 3. Дефекты (in-memory _defects в роуте) ───────────────────
try:
from app.api.routes.defects import _defects
aircraft_regs = [r[0] for r in db.query(Aircraft.registration_number).limit(5).all()]
reg = aircraft_regs[0] if aircraft_regs else "RA-00000"
defects_demo = [
("Трещина обшивки фюзеляжа секция 41", "critical", "open"),
("Течь гидросистемы левая стойка шасси", "high", "deferred"),
("Коррозия лонжерона крыла зона 2", "high", "open"),
("Неисправность датчика угла атаки", "critical", "rectified"),
("Износ тормозных дисков выше допуска", "medium", "open"),
("Повреждение обтекателя антенны", "low", "closed"),
("Утечка топлива бак №2", "critical", "open"),
("Вибрация двигателя №1 выше нормы", "high", "deferred"),
]
for desc, severity, status in defects_demo:
if any(d.get("description") == desc for d in _defects.values()):
continue
did = str(uuid.uuid4())
_defects[did] = {
"id": did,
"aircraft_reg": reg,
"description": desc,
"severity": severity,
"status": status,
"ata_chapter": "32" if "шасси" in desc else "53" if "топлив" in desc else "21",
"created_at": datetime.now(timezone.utc).isoformat(),
}
logger.info("seed_full_demo: defects (in-memory) populated")
except Exception as e:
logger.warning("seed_full_demo: defects skip %s", e)
# ─── 4. Заявки на сертификацию ─────────────────────────────────
app_org = db.query(Organization).filter(Organization.kind == "operator").first()
app_user = db.query(User).filter(User.role == "operator_manager").first()
if app_org and app_user:
apps_data = [
("Заявка на СЛГ RA-89060", CertApplicationStatus.APPROVED, "airworthiness_certificate"),
("Заявка на одобрение ТОиР АТЦ Кольцово", CertApplicationStatus.UNDER_REVIEW, "mro_approval"),
("Продление СЛГ RA-73801", CertApplicationStatus.SUBMITTED, "renewal"),
("Заявка на допуск ETOPS", CertApplicationStatus.DRAFT, "special_approval"),
("Сертификация нового типа Ту-214", CertApplicationStatus.UNDER_REVIEW, "type_certificate"),
]
for i, (subject, status, app_type) in enumerate(apps_data):
num = f"KLG-DEMO-{datetime.now().strftime('%Y%m%d')}-{1001 + i}"
if db.query(CertApplication).filter(CertApplication.number == num).first():
continue
db.add(
CertApplication(
applicant_org_id=str(app_org.id),
created_by_user_id=str(app_user.id),
number=num,
status=status,
subject=f"[{app_type}] {subject}",
description=subject,
submitted_at=datetime.now(timezone.utc) - timedelta(days=5 - i)
if status != CertApplicationStatus.DRAFT
else None,
)
)
db.commit()
logger.info("seed_full_demo: cert applications checked/created")
# ─── 5. Риски (RiskAlert) ──────────────────────────────────────
risk_titles = [
("Просрочка директивы лётной годности AD-2025-0142", "critical", False),
("Недостаточная квалификация персонала ТОиР", "high", True),
("Устаревшее оборудование контроля", "medium", False),
("Нарушение сроков периодического ТО", "high", False),
("Отсутствие резервных компонентов", "medium", True),
("Риск усталостного разрушения фюзеляжа", "critical", True),
]
ac_id = _get_first_aircraft_id(db)
for title, severity, resolved in risk_titles:
if db.query(RiskAlert).filter(RiskAlert.title == title).first():
continue
db.add(
RiskAlert(
entity_type="directive",
entity_id=str(uuid.uuid4()),
aircraft_id=ac_id,
severity=severity,
title=title,
message=title,
due_at=datetime.now(timezone.utc) + timedelta(days=30),
is_resolved=resolved,
resolved_at=datetime.now(timezone.utc) if resolved else None,
)
)
db.commit()
logger.info("seed_full_demo: risk_alerts checked/created")
# ─── 6. Персонал ПЛГ (PLGSpecialist + PLGQualification) ──────────
plg_org = db.query(Organization).filter(Organization.kind == "operator").first()
if plg_org:
specialists_data = [
("Петрова Елена В.", "ПЕТРОВА-001", "Инспектор ЛГ", "I", "LIC-2024-001", "2026-12-01"),
("Волков Алексей Н.", "ВОЛКОВ-001", "Инженер ТОиР", "B2", "LIC-2024-002", "2026-08-15"),
("Морозова Ольга С.", "МОРОЗОВА-001", "Авиатехник B1", "B1", "LIC-2024-003", "2026-03-20"),
("Козлов Дмитрий И.", "КОЗЛОВ-001", "Пилот CAT.A", "CAT-A", "LIC-2024-004", "2027-01-10"),
]
for full_name, personnel_number, position, category, license_no, expires in specialists_data:
if db.query(PLGSpecialist).filter(PLGSpecialist.personnel_number == personnel_number).first():
continue
exp_dt = datetime.strptime(expires, "%Y-%m-%d").date()
spec = PLGSpecialist(
organization_id=str(plg_org.id),
full_name=full_name,
personnel_number=personnel_number,
position=position,
category=category,
license_number=license_no,
license_expires=exp_dt,
status="active",
)
db.add(spec)
db.flush()
# одна квалификация на специалиста
q = PLGQualification(
specialist_id=spec.id,
program_id="PQ-001",
program_name=f"ПК по {position}",
program_type="periodic",
date_start=exp_dt - timedelta(days=365),
date_end=exp_dt,
hours_total=40,
result="passed",
next_due=exp_dt,
)
db.add(q)
db.commit()
logger.info("seed_full_demo: personnel PLG checked/created")
except Exception as e:
db.rollback()
logger.exception("seed_full_demo failed: %s", e)
raise
finally:
db.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
seed_full_demo()

View File

@ -72,6 +72,12 @@ async def lifespan(app: FastAPI):
except Exception as e:
import logging
logging.getLogger(__name__).warning("Aircraft demo seed skipped: %s", e)
try:
from app.demo.seed_full_demo import seed_full_demo
seed_full_demo()
except Exception as e:
import logging
logging.getLogger(__name__).warning("Full demo seed skipped: %s", e)
# Планировщик рисков (передаём app для shutdown hook)
setup_scheduler(app)
yield

144
components/AIAssistant.tsx Normal file
View File

@ -0,0 +1,144 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { getAuthToken } from '@/lib/api/api-client';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export default function AIAssistant() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([
{
role: 'assistant',
content:
'Здравствуйте! Я AI-помощник REFLY. Спросите меня о лётной годности, ТО, директивах или рисках.',
},
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = async () => {
if (!input.trim() || loading) return;
const userMsg = input.trim();
setInput('');
setMessages((prev) => [...prev, { role: 'user', content: userMsg }]);
setLoading(true);
try {
const token =
typeof window !== 'undefined'
? (getAuthToken() || document.cookie.match(/auth-token=([^;]+)/)?.[1] || 'dev')
: 'dev';
const res = await fetch('/api/v1/ai/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ message: userMsg }),
});
if (!res.ok) throw new Error('AI service unavailable');
const data = await res.json();
setMessages((prev) => [...prev, { role: 'assistant', content: data.reply }]);
} catch {
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: 'Извините, не удалось получить ответ. Попробуйте позже.',
},
]);
} finally {
setLoading(false);
}
};
return (
<>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-blue-600 text-white shadow-lg hover:bg-blue-700 transition-all flex items-center justify-center text-2xl"
title="AI Помощник"
aria-label={isOpen ? 'Закрыть чат' : 'Открыть AI Помощник'}
>
{isOpen ? '✕' : '🤖'}
</button>
{isOpen && (
<div
className="fixed bottom-24 right-6 z-50 w-96 max-w-[calc(100vw-3rem)] h-[500px] bg-white rounded-2xl shadow-2xl border border-gray-200 flex flex-col overflow-hidden"
role="dialog"
aria-label="Чат с AI-помощником"
>
<div className="bg-blue-600 text-white px-4 py-3 flex items-center gap-2">
<span className="text-xl" aria-hidden>🤖</span>
<div>
<div className="font-semibold text-sm">AI Помощник REFLY</div>
<div className="text-xs opacity-80">Контроль лётной годности</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] px-3 py-2 rounded-xl text-sm whitespace-pre-wrap ${
msg.role === 'user'
? 'bg-blue-600 text-white rounded-br-sm'
: 'bg-gray-100 text-gray-800 rounded-bl-sm'
}`}
>
{msg.content}
</div>
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="bg-gray-100 px-3 py-2 rounded-xl text-sm text-gray-500">
Думаю...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-3 border-t flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Задайте вопрос..."
className="flex-1 px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={loading}
aria-label="Сообщение"
/>
<button
type="button"
onClick={sendMessage}
disabled={loading || !input.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50"
aria-label="Отправить"
>
</button>
</div>
</div>
)}
</>
);
}