| """ |
| Веб-интерфейс 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 |
|
|
| |
| _rag: RAGSystem | None = None |
| _rag_lock = threading.Lock() |
| _index_lock = threading.Lock() |
|
|
|
|
| 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: |
| |
| _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: |
| |
| 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) |