MAX / app.py
1
Add duplicate file detection; show skip message in UI
f4e7319
"""
Веб-интерфейс RAG-системы — Gradio
Запуск: python3 app.py
Публичная ссылка создаётся автоматически (--share).
"""
import os
import shutil
import threading
from pathlib import Path
import gradio as gr
from rag_system import RAGSystem, CHROMA_PERSIST_DIR
# ─── Глобальный экземпляр системы (thread-safe) ───────────────────────────
_rag: RAGSystem | None = None
_rag_lock = threading.Lock() # инициализация RAGSystem
_index_lock = threading.Lock() # индексация PDF
def _get_rag(provider: str, openai_key: str) -> RAGSystem:
"""
Возвращает единственный экземпляр RAGSystem (singleton).
Пересоздаёт только при смене провайдера.
Lock предотвращает двойную инициализацию при параллельных запросах Gradio.
"""
global _rag
key = openai_key.strip() if openai_key else None
if key:
if provider == "groq":
os.environ["GROQ_API_KEY"] = key
elif provider == "gemini":
os.environ["GOOGLE_API_KEY"] = key
else:
os.environ["OPENAI_API_KEY"] = key
with _rag_lock:
if _rag is None:
_rag = RAGSystem(llm_provider=provider)
elif _rag.llm_provider != provider:
# Меняем только LLM — vectorstore и индексированные данные сохраняются
_rag.switch_llm(provider)
return _rag
# ─── Обработчики ──────────────────────────────────────────────────────────
def index_pdf(files, provider: str, openai_key: str) -> str:
"""Принимает список загруженных PDF, индексирует их."""
if not files:
return "⚠️ Файлы не выбраны."
with _index_lock:
try:
rag = _get_rag(provider, openai_key)
log_lines = []
for file in files:
# Gradio 5 возвращает строку-путь, Gradio 4 — объект с .name
tmp_path = Path(file if isinstance(file, str) else file.name)
added = rag.add_documents(str(tmp_path))
if added == 0:
log_lines.append(f"⏭️ {tmp_path.name} — уже в базе, пропущен")
else:
log_lines.append(f"✅ {tmp_path.name} — добавлено {added} чанков")
stats = rag.get_stats()
log_lines.append(f"\nВсего в базе: {stats['total_chunks']} чанков")
return "\n".join(log_lines)
except Exception as e:
return f"❌ Ошибка индексации: {e}"
def ask(question: str, provider: str, openai_key: str):
"""Обрабатывает вопрос, возвращает (ответ, источники)."""
if not question.strip():
return "Введите вопрос.", ""
try:
rag = _get_rag(provider, openai_key)
result = rag.ask_question(question)
# Форматируем источники отдельно
sources_lines = []
seen: set = set()
for src in result.sources:
key = (src["file"], src["page"])
if key not in seen:
seen.add(key)
score = f"{src['score']:.0%}" if src.get("score") is not None else "—"
sources_lines.append(f"• **{src['file']}**, стр. {src['page']} `[{score}]`")
if result.conflicts:
sources_lines.append("")
sources_lines.append("⚠️ **Обнаружены противоречия между источниками:**")
for c in result.conflicts:
sources_lines.append(f" — {c}")
sources_md = "\n".join(sources_lines) if sources_lines else "_Источники не найдены_"
return result.answer, sources_md
except Exception as e:
return f"❌ Ошибка: {e}", ""
def clear_db(provider: str, openai_key: str) -> str:
"""Очищает векторную базу данных."""
global _rag
db_path = Path(CHROMA_PERSIST_DIR)
if db_path.exists():
shutil.rmtree(db_path)
_rag = None
return "🗑️ База данных очищена. Загрузите PDF заново."
def toggle_key_visibility(provider: str):
"""Показывает/скрывает поле API-ключа в зависимости от провайдера."""
return gr.update(visible=(provider in ("openai", "groq", "gemini")))
# ─── Интерфейс ────────────────────────────────────────────────────────────
CSS = """
#title { text-align: center; }
#answer-box textarea { font-size: 14px; min-height: 260px; }
#sources-box { font-size: 13px; }
.gradio-container { max-width: 900px !important; margin: auto; }
"""
with gr.Blocks(title="RAG-чатбот для документов") as demo:
gr.Markdown("# 📄 RAG-чатбот для документов", elem_id="title")
gr.Markdown(
"Загрузите PDF или DOCX документ → задайте вопрос → получите ответ со ссылкой на источник.",
elem_id="title",
)
with gr.Row():
provider = gr.Radio(
choices=["gemini", "groq", "openai", "ollama"],
value="gemini",
label="LLM-провайдер",
scale=1,
)
openai_key = gr.Textbox(
label="API Key (Groq: gsk_... / OpenAI: sk-...)",
placeholder="gsk_... или sk-...",
type="password",
visible=True,
scale=2,
)
provider.change(toggle_key_visibility, inputs=provider, outputs=openai_key)
with gr.Tab("📄 Загрузка документов"):
pdf_input = gr.File(
label="PDF / DOCX файлы",
file_types=["pdf", ".pdf", ".docx", ".doc"],
file_count="multiple",
)
with gr.Row():
index_btn = gr.Button("📥 Индексировать", variant="primary")
clear_btn = gr.Button("🗑️ Очистить базу", variant="stop")
index_log = gr.Textbox(label="Лог индексации", lines=6, interactive=False)
index_btn.click(index_pdf, inputs=[pdf_input, provider, openai_key], outputs=index_log)
clear_btn.click(clear_db, inputs=[provider, openai_key], outputs=index_log)
with gr.Tab("💬 Вопрос-ответ"):
question = gr.Textbox(
label="Вопрос",
placeholder="Например: Какие услуги в разделе 2.1 предоставляются бесплатно?",
lines=2,
)
ask_btn = gr.Button("🔍 Спросить", variant="primary")
answer_box = gr.Textbox(
label="Ответ",
lines=12,
interactive=False,
elem_id="answer-box",
)
sources_box = gr.Markdown(
label="Источники",
elem_id="sources-box",
)
ask_btn.click(
ask,
inputs=[question, provider, openai_key],
outputs=[answer_box, sources_box],
)
question.submit(
ask,
inputs=[question, provider, openai_key],
outputs=[answer_box, sources_box],
)
with gr.Tab("ℹ️ Инструкция"):
gr.Markdown("""
### Как пользоваться
1. **Выберите провайдер** — `gemini` (по умолчанию) или `groq`, `openai`, `ollama`.
2. **Введите API-ключ** — Gemini / Groq: `gsk_...` / OpenAI: `sk-...`.
3. **Вкладка «Загрузка»** — загрузите PDF или DOCX и нажмите **Индексировать**.
4. **Вкладка «Вопрос-ответ»** — введите вопрос и нажмите **Спросить** (или Enter).
### Возможности системы
| Функция | Описание |
|---|---|
| 📄 PDF + DOCX | Загружайте документы любого объёма |
| 🔍 Гибридный поиск | BM25 + векторный — находит точные коды и семантику |
| 🧠 Chain of Thought | Рассуждает пошагово, анализирует данные из документов |
| ⚠️ Конфликты | Обнаруживает противоречия между документами |
| 🛡️ Защита | Не выдумывает — если нет в документе, так и скажет |
### Провайдеры LLM
- **Gemini** (по умолчанию): ключ на [aistudio.google.com](https://aistudio.google.com)
- **Groq**: бесплатный ключ на [console.groq.com](https://console.groq.com)
- **OpenAI**: ключ вида `sk-...`
- **Ollama**: локальный запуск (ключ не нужен)
""")
if __name__ == "__main__":
demo.queue()
demo.launch(css=CSS)