From 4f506a4351281173880ecde54fd0df7cde75cb16 Mon Sep 17 00:00:00 2001 From: "V.Bolshakov" Date: Tue, 19 May 2026 09:59:42 +0000 Subject: [PATCH] build: first commit --- .github/workflows/ci.yml | 105 -- .github/workflows/linter.yml | 60 - README.md | 124 +- dyak/api/v1/ai.py | 319 ++++ dyak/api/v1/chat.py | 786 ++++++++++ dyak/api/v1/chat_global.py | 1329 +++++++++++++++++ dyak/api/v1/profiles.py | 556 +++++++ dyak/api/v1/sandbox.py | 9 + dyak/api/v1/transcribe.py | 416 ++++++ dyak/dyak/doctype/__init__.py | 0 .../analysis_profile/analysis_profile.js | 74 + .../analysis_profile/analysis_profile.json | 163 ++ .../analysis_profile/analysis_profile.py | 13 + .../doctype/dyak_chat_message/__init__.py | 0 .../dyak_chat_message/dyak_chat_message.json | 118 ++ .../dyak_chat_message/dyak_chat_message.py | 15 + .../doctype/dyak_chat_session/__init__.py | 0 .../dyak_chat_session/dyak_chat_session.json | 98 ++ .../dyak_chat_session/dyak_chat_session.py | 8 + dyak/dyak/doctype/dyak_settings/__init__.py | 0 .../doctype/dyak_settings/dyak_settings.js | 22 + .../doctype/dyak_settings/dyak_settings.json | 155 ++ .../doctype/dyak_settings/dyak_settings.py | 8 + .../dyak_settings/test_dyak_settings.py | 22 + .../meeting_analysis_result.js | 75 + .../meeting_analysis_result.json | 182 +++ .../meeting_analysis_result.py | 6 + .../doctype/meeting_participant/__init__.py | 0 .../meeting_participant.json | 49 + .../meeting_participant.py | 8 + dyak/dyak/doctype/meeting_record/__init__.py | 0 .../meeting_record/meeting_record copy 2.js | 548 +++++++ .../meeting_record/meeting_record copy.js | 520 +++++++ .../doctype/meeting_record/meeting_record.js | 1163 +++++++++++++++ .../meeting_record/meeting_record.json | 351 +++++ .../doctype/meeting_record/meeting_record.py | 17 + .../meeting_record/test_meeting_record.py | 22 + dyak/dyak/page/__init__.py | 0 dyak/dyak/page/dyak_chat/__init__.py | 0 dyak/dyak/page/dyak_chat/dyak_chat.css | 478 ++++++ dyak/dyak/page/dyak_chat/dyak_chat.js | 807 ++++++++++ dyak/dyak/page/dyak_chat/dyak_chat.json | 19 + dyak/dyak/workspace/dyak/dyak.json | 84 ++ dyak/hooks.py | 21 + dyak/patches.txt | 6 +- dyak/patches/v01/create_builtin_profiles.py | 222 +++ dyak/patches/v02/create_default_profiles.py | 833 +++++++++++ dyak/patches/v03/add_meeting_record_fts.py | 76 + dyak/utils/api.py | 55 + 49 files changed, 9753 insertions(+), 189 deletions(-) delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/linter.yml create mode 100644 dyak/api/v1/ai.py create mode 100644 dyak/api/v1/chat.py create mode 100644 dyak/api/v1/chat_global.py create mode 100644 dyak/api/v1/profiles.py create mode 100644 dyak/api/v1/sandbox.py create mode 100644 dyak/api/v1/transcribe.py create mode 100644 dyak/dyak/doctype/__init__.py create mode 100644 dyak/dyak/doctype/analysis_profile/analysis_profile.js create mode 100644 dyak/dyak/doctype/analysis_profile/analysis_profile.json create mode 100644 dyak/dyak/doctype/analysis_profile/analysis_profile.py create mode 100644 dyak/dyak/doctype/dyak_chat_message/__init__.py create mode 100644 dyak/dyak/doctype/dyak_chat_message/dyak_chat_message.json create mode 100644 dyak/dyak/doctype/dyak_chat_message/dyak_chat_message.py create mode 100644 dyak/dyak/doctype/dyak_chat_session/__init__.py create mode 100644 dyak/dyak/doctype/dyak_chat_session/dyak_chat_session.json create mode 100644 dyak/dyak/doctype/dyak_chat_session/dyak_chat_session.py create mode 100644 dyak/dyak/doctype/dyak_settings/__init__.py create mode 100644 dyak/dyak/doctype/dyak_settings/dyak_settings.js create mode 100644 dyak/dyak/doctype/dyak_settings/dyak_settings.json create mode 100644 dyak/dyak/doctype/dyak_settings/dyak_settings.py create mode 100644 dyak/dyak/doctype/dyak_settings/test_dyak_settings.py create mode 100644 dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.js create mode 100644 dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.json create mode 100644 dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.py create mode 100644 dyak/dyak/doctype/meeting_participant/__init__.py create mode 100644 dyak/dyak/doctype/meeting_participant/meeting_participant.json create mode 100644 dyak/dyak/doctype/meeting_participant/meeting_participant.py create mode 100644 dyak/dyak/doctype/meeting_record/__init__.py create mode 100644 dyak/dyak/doctype/meeting_record/meeting_record copy 2.js create mode 100644 dyak/dyak/doctype/meeting_record/meeting_record copy.js create mode 100644 dyak/dyak/doctype/meeting_record/meeting_record.js create mode 100644 dyak/dyak/doctype/meeting_record/meeting_record.json create mode 100644 dyak/dyak/doctype/meeting_record/meeting_record.py create mode 100644 dyak/dyak/doctype/meeting_record/test_meeting_record.py create mode 100644 dyak/dyak/page/__init__.py create mode 100644 dyak/dyak/page/dyak_chat/__init__.py create mode 100644 dyak/dyak/page/dyak_chat/dyak_chat.css create mode 100644 dyak/dyak/page/dyak_chat/dyak_chat.js create mode 100644 dyak/dyak/page/dyak_chat/dyak_chat.json create mode 100644 dyak/dyak/workspace/dyak/dyak.json create mode 100644 dyak/patches/v01/create_builtin_profiles.py create mode 100644 dyak/patches/v02/create_default_profiles.py create mode 100644 dyak/patches/v03/add_meeting_record_fts.py create mode 100644 dyak/utils/api.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 3be5ced..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: CI - -on: - push: - branches: - - HEAD - pull_request: - -concurrency: - group: HEAD-dyak-${{ github.event.number }} - cancel-in-progress: true - -jobs: - tests: - runs-on: ubuntu-latest - strategy: - fail-fast: false - name: Server - - services: - redis-cache: - image: redis:alpine - ports: - - 13000:6379 - redis-queue: - image: redis:alpine - ports: - - 11000:6379 - mariadb: - image: mariadb:11.8 - env: - MYSQL_ROOT_PASSWORD: root - ports: - - 3306:3306 - options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 - - steps: - - name: Clone - uses: actions/checkout@v6 - - - name: Find tests - run: | - echo "Finding tests" - grep -rn "def test" > /dev/null - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - check-latest: true - - - name: Cache pip - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }} - restore-keys: | - ${{ runner.os }}-pip- - ${{ runner.os }}- - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: 'echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT' - - - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install MariaDB Client - run: | - sudo apt update - sudo apt-get install mariadb-client - - - name: Setup - run: | - pip install frappe-bench - bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench - - - name: Install - working-directory: /home/runner/frappe-bench - run: | - bench get-app dyak $GITHUB_WORKSPACE - bench setup requirements --dev - bench new-site --db-root-password root --admin-password admin test_site - bench --site test_site install-app dyak - bench build - env: - CI: 'Yes' - - - name: Run Tests - working-directory: /home/runner/frappe-bench - run: | - bench --site test_site set-config allow_tests true - bench --site test_site run-tests --app dyak - env: - TYPE: server diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml deleted file mode 100644 index 3c810e5..0000000 --- a/.github/workflows/linter.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Linters - -on: - pull_request: - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - linter: - name: 'Frappe Linter' - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 - with: - python-version: '3.14' - cache: pip - - uses: pre-commit/action@v3.0.0 - - - name: Download Semgrep rules - run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules - - - name: Run Semgrep rules - run: | - pip install semgrep - semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness - - deps-vulnerable-check: - name: 'Vulnerable Dependency Check' - runs-on: ubuntu-latest - - steps: - - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - - uses: actions/checkout@v6 - - - name: Cache pip - uses: actions/cache@v5 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }} - restore-keys: | - ${{ runner.os }}-pip- - ${{ runner.os }}- - - - name: Install and run pip-audit - run: | - pip install pip-audit - cd ${GITHUB_WORKSPACE} - pip-audit --desc on . diff --git a/README.md b/README.md index b623761..d2f8107 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,118 @@ -### Dyak +# Дьяк — система управления протоколами встреч -Управление встречами +Frappe 16 приложение для загрузки аудиозаписей встреч, автоматической +транскрибации и диаризации (через внешний микросервис) и структурированного +ведения протокола. -### Installation +## Установка -You can install this app using the [bench](https://github.com/frappe/bench) CLI: +> Предполагается, что Frappe 16 + bench уже установлен. ```bash -cd $PATH_TO_YOUR_BENCH -bench get-app $URL_OF_THIS_REPO --branch HEAD -bench install-app dyak +# 1. Положить эту папку (`dyak/`) в `frappe-bench/apps/` +cd ~/frappe-bench/apps +# (скопируйте/распакуйте сюда содержимое архива — должен получиться +# каталог frappe-bench/apps/dyak/ с setup.py внутри) + +# 2. Зарегистрировать приложение в bench +cd ~/frappe-bench +bench --site <ваш_сайт> install-app dyak + +# 3. Применить фикстуры (создаёт роль Dyak User) +bench --site <ваш_сайт> migrate + +# 4. (опционально) собрать assets +bench build --app dyak + +# 5. Перезапустить +bench restart ``` -### Contributing +После установки в Desk появится модуль **Дьяк** с doctype'ами: -This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository: +* **Meeting Record** (`MR-YYYY-#####`) — основной документ встречи. +* **Dyak Settings** (Singleton) — настройки сервиса транскрибации и LLM. +* 8 child-tables: Meeting Participant, Action Item, Decision, Problem, + Open Question, Schedule Change, Help Request, External Reference. -```bash -cd apps/dyak -pre-commit install +## Конфигурация + +В **Dyak Settings** заполните: + +| Поле | Значение по умолчанию | +| --- | --- | +| URL сервиса | `http://192.168.1.112:8000` | +| Модель Whisper | `large-v3` | +| Язык | `ru` | +| Подсказка | (термины, разделённые запятыми) | +| Количество спикеров | `0` (автоопределение) | + +## Поток работы + +``` +Черновик + └─[кнопка «Транскрибировать»]──▶ В обработке + └─(background job)──────▶ Расшифровано + ├─[кнопка «Назначить спикеров»] + ├─[AI: задачи / резюме / анализ — заглушки] + └─[кнопка «На проверку»]──────▶ Проверено + └─[«Утвердить»]──▶ Утверждено ``` -Pre-commit is configured to use the following tools for checking and formatting your code: +При ошибке транскрибации статус возвращается в **Черновик**, трейсбек +пишется в Error Log. -- ruff -- eslint -- prettier -- pyupgrade -### CI +## Архитектура -This app can use GitHub Actions for CI. The following workflows are configured: +``` +dyak/ +├── api/ +│ ├── transcribe.py # whitelisted transcribe() + фоновый _run_transcription() +│ └── ai.py # заглушки extract_action_items, generate_summary, analyze_meeting +├── dyak/doctype/ +│ ├── meeting_record/ # JSON + .py + .js (формовая логика) +│ ├── dyak_settings/ # Singleton с настройками +│ └── meeting_<...>/ # 8 child-tables +└── hooks.py +``` -- CI: Installs this app and runs unit tests on every push to `develop` branch. -- Linters: Runs [Frappe Semgrep Rules](https://github.com/frappe/semgrep-rules) and [pip-audit](https://pypi.org/project/pip-audit/) on every pull request. +### Микросервис транскрибации +`POST {service_url}/process` — multipart/form-data: -### License +``` +file аудиофайл +language ru код языка +initial_prompt str подсказка (опц.) +num_speakers int 0 = auto +model str tiny|base|...|large-v3|turbo (опц.) +``` -mit +Ответ: + +```json +{ + "language": "ru", + "duration": 197.6, + "processing_time": 18.2, + "speakers": {"SPEAKER_01": 113.5, "SPEAKER_02": 41.2}, + "num_speakers": 3, + "utterances": [ + {"speaker": "SPEAKER_01", "start": 0.0, "end": 94.6, "text": "..."} + ] +} +``` + +Полный ответ кладётся в `Meeting Record.utterances_json` и используется +для рендеринга чат-диалога с цветами по спикерам. + +## Точки расширения + +* `dyak/api/ai.py` — заменить `msgprint` на реальные LLM-вызовы + (Anthropic/OpenAI/Ollama по `Dyak Settings.llm_provider`), + заполняющие `summary`, `action_items`, `decisions`, `problems`, + `open_questions`, `help_requests`, `external_references`, + `meeting_topics`, `meeting_mood`. +* Расписание автоматической транскрибации сразу после загрузки — + можно добавить через `doc_events` в `hooks.py` + (например, `Meeting Record: after_insert`). diff --git a/dyak/api/v1/ai.py b/dyak/api/v1/ai.py new file mode 100644 index 0000000..01db913 --- /dev/null +++ b/dyak/api/v1/ai.py @@ -0,0 +1,319 @@ +""" +dyak.api.v1.ai +────────────── +После перехода на систему профилей (см. dyak.api.v1.profiles) здесь +остался только один автономный AI-метод — `generate_summary`. Он пишет +текстовое резюме напрямую в поле `summary` и не использует профили, +потому что: + + • Это специальное действие — не «структурированный анализ». + • Поле `summary` (Text Editor) живёт прямо на форме встречи. + • Большинство пользователей хотят резюме одним кликом, без выбора + профилей. + +Старые методы `extract_action_items` и `analyze_meeting` удалены — +их функциональность теперь обеспечивает встроенный профиль +«Стандартный анализ» через dyak.api.v1.profiles.apply_profiles. + +Контракт workflow `/ask-summary` не изменился: + POST {N8N_BASE_URL}/ask-summary + {"prompt": "...", "model": "qwen2.5:32b"} + → [{"content": "<резюме>"}] +""" +from __future__ import annotations + +import time +from typing import Any + +import frappe +import requests + +from dyak.api.v1.transcribe import _publish_progress + + +N8N_BASE_URL = "http://192.168.1.112:5678/webhook" +ENDPOINT_SUMMARY = f"{N8N_BASE_URL}/ask-summary" +REQUEST_TIMEOUT = (30, 600) + + +_PROMPT_SUMMARY = """\ +Ты анализируешь рабочую встречу и возвращаешь СТРОГО валидный JSON +со следующими полями: + +{ + "summary": "связный текст 4–7 предложений на русском", + "topics": ["тема 1", "тема 2", "тема 3"], + "mood": "Конструктивный | Нейтральный | Напряжённый | Конфликтный" +} + +ТРЕБОВАНИЯ К ПОЛЯМ: + +summary — связный текст из 4–7 предложений, который отражает: +- цель встречи (если ясна) +- основные обсуждаемые темы +- ключевые решения (если есть) +- дальнейшие шаги или планы (если есть) + +topics — массив из 3–7 коротких тем (1–4 слова каждая). Только +содержательные темы, обсуждавшиеся на встрече. Например: +["добавление endpoint", "JSON Logic", "перенос демо"] + +mood — одно из четырёх значений строго: +- "Конструктивный" — все согласны, решения принимаются легко +- "Нейтральный" — обычная рабочая дискуссия, без эмоций +- "Напряжённый" — есть разногласия, но без личных конфликтов +- "Конфликтный" — открытые столкновения, переход на личности + +ПРАВИЛА: +- используй только информацию из расшифровки +- не выдумывай факты, темы, имена, решения +- не добавляй markdown, заголовки, преамбулу или пояснения +- summary не должен начинаться с "Резюме:", "Эта встреча..." и т.п. +- верни ТОЛЬКО JSON, без обёртки ```json или текста до/после + +РАСШИФРОВКА: +--- +{transcript} +--- +""" + + +# ────────────────────────────────────────────────────────────────────────── +# Whitelisted endpoint +# ────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +def generate_summary(docname: str) -> dict: + """Сгенерировать резюме встречи и записать в поле summary.""" + doc = frappe.get_doc("Meeting Record", docname) + if not (doc.full_text or "").strip(): + frappe.throw("Нет текста транскрибации — сначала запустите расшифровку") + + user = frappe.session.user + frappe.enqueue( + "dyak.api.v1.ai._run_summary", + queue="long", + timeout=900, + docname=docname, + user=user, + enqueue_after_commit=True, + ) + return {"queued": True, "docname": docname} + + +# ────────────────────────────────────────────────────────────────────────── +# Фоновая задача +# ────────────────────────────────────────────────────────────────────────── + +def _run_summary(docname: str, user: str | None = None) -> None: + """Фоновое выполнение запроса резюме. + + Промпт просит модель вернуть JSON с тремя полями: summary, topics, + mood. При успешном парсе все три поля записываются в Meeting Record. + Если модель вернула не-JSON (старое поведение / маленькая модель) — + fallback: содержимое целиком идёт в summary, topics/mood остаются + как были (не затираем). + """ + started_at = time.time() + try: + doc = frappe.get_doc("Meeting Record", docname) + settings = frappe.get_single("Dyak Settings") + model = (settings.llm_model or "qwen2.5:32b").strip() + + transcript = _format_transcript(doc) + # ВАЖНО: .replace, не .format — в промпте есть JSON-пример + # с фигурными скобками, .format упадёт с KeyError. + prompt = _PROMPT_SUMMARY.replace("{transcript}", transcript) + + _publish_progress( + docname, "AI · Резюме", 10, + f"Запуск задачи, модель: {model}", user=user, + ) + + _publish_progress( + docname, "AI · Отправка", 25, + f"POST {ENDPOINT_SUMMARY} (prompt {len(prompt)} симв.)", user=user, + ) + + send_started = time.time() + response = requests.post( + ENDPOINT_SUMMARY, + json={"prompt": prompt, "model": model}, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + net_secs = time.time() - send_started + + body = response.json() + content = _extract_content(body) + if not content: + raise ValueError("Пустой ответ модели") + + _publish_progress( + docname, "AI · Получен ответ", 70, + f"HTTP {response.status_code}, {len(content)} симв. за {net_secs:.1f} сек", + user=user, + ) + + # Парсим JSON; при неудаче — fallback на plain-text как summary. + parsed = _try_parse_summary_json(content) + + updates: dict[str, Any] = {} + if parsed and isinstance(parsed.get("summary"), str) \ + and parsed["summary"].strip(): + updates["summary"] = parsed["summary"].strip() + else: + # Fallback: модель не вернула JSON. Кладём весь content в summary. + updates["summary"] = content.strip() + + if parsed: + topics = parsed.get("topics") + if isinstance(topics, list) and topics: + # Сшиваем массив в строку через запятую — для поля + # meeting_topics (Small Text). + topics_str = ", ".join( + str(t).strip() for t in topics + if t and isinstance(t, (str, int, float)) + ) + if topics_str: + updates["meeting_topics"] = topics_str + elif isinstance(topics, str) and topics.strip(): + # Иногда модель возвращает строкой через запятую. + updates["meeting_topics"] = topics.strip() + + mood = parsed.get("mood") + allowed = {"Конструктивный", "Нейтральный", + "Напряжённый", "Конфликтный"} + if isinstance(mood, str) and mood.strip() in allowed: + updates["meeting_mood"] = mood.strip() + + frappe.db.set_value( + "Meeting Record", docname, + updates, + update_modified=True, + ) + frappe.db.commit() + + # Расскажем пользователю, что заполнилось. + filled = [k for k in ("summary", "meeting_topics", "meeting_mood") + if k in updates] + total = time.time() - started_at + _publish_progress( + docname, "AI · Готово", 100, + f"Заполнены поля: {', '.join(filled)} за {total:.1f} сек", + user=user, + ) + + except requests.exceptions.ConnectionError as exc: + _summary_failure(docname, user, "Ошибка сети", + f"Не удалось подключиться к n8n: {exc}") + except requests.exceptions.Timeout as exc: + _summary_failure(docname, user, "Таймаут", + f"n8n не ответил вовремя: {exc}") + except requests.exceptions.HTTPError as exc: + body_preview = "" + try: + body_preview = exc.response.text[:300] if exc.response is not None else "" + except Exception: + pass + status_code = exc.response.status_code if exc.response is not None else "?" + _summary_failure(docname, user, "Ошибка n8n", + f"HTTP {status_code}: {body_preview}") + except Exception as exc: + _summary_failure(docname, user, "Ошибка", + f"{type(exc).__name__}: {exc}") + + +def _try_parse_summary_json(text: str) -> dict | None: + """Возвращает dict с summary/topics/mood, либо None если не JSON. + + Допускает обёртку ```json ... ``` (модель иногда добавляет). Если + парс не удался — возвращаем None, вызывающий применит fallback. + """ + import json + import re + if not text: + return None + cleaned = text.strip() + fence = re.search(r"```(?:json)?\s*(.+?)\s*```", cleaned, re.DOTALL | re.IGNORECASE) + if fence: + cleaned = fence.group(1).strip() + # Прямой парс. + try: + obj = json.loads(cleaned) + return obj if isinstance(obj, dict) else None + except json.JSONDecodeError: + pass + # Поиск внешнего {...}. + start = cleaned.find("{") + end = cleaned.rfind("}") + if start != -1 and end != -1 and end > start: + try: + obj = json.loads(cleaned[start:end + 1]) + return obj if isinstance(obj, dict) else None + except json.JSONDecodeError: + return None + return None + + +def _summary_failure(docname: str, user: str | None, + stage: str, message: str) -> None: + frappe.log_error( + title=f"Dyak Summary: {stage} для {docname}", + message=frappe.get_traceback(), + ) + _publish_progress( + docname, f"AI · {stage}", 0, message, + user=user, is_error=True, + ) + + +# ────────────────────────────────────────────────────────────────────────── +# Утилиты (минимально — то, что нужно только для summary) +# ────────────────────────────────────────────────────────────────────────── + +def _extract_content(body: Any) -> str: + if isinstance(body, list) and body: + first = body[0] + if isinstance(first, dict): + return (first.get("content") or "").strip() + if isinstance(body, dict): + return (body.get("content") or "").strip() + return "" + + +def _format_transcript(doc) -> str: + """Тот же формат расшифровки, что и в profiles.py.""" + import json + raw = doc.utterances_json + if raw: + try: + parsed = json.loads(raw) if isinstance(raw, str) else raw + utterances = parsed if isinstance(parsed, list) \ + else parsed.get("utterances") or [] + except Exception: + utterances = [] + if utterances: + name_by_speaker = { + p.speaker_id: p.participant_name + for p in (doc.participants or []) + if p.speaker_id and p.participant_name + } + lines = [] + for u in utterances: + spk = u.get("speaker") or "SPEAKER_??" + name = name_by_speaker.get(spk, spk) + start = _fmt_secs(u.get("start")) + end = _fmt_secs(u.get("end")) + text = (u.get("text") or "").strip() + lines.append(f"[{name}] ({start}–{end}): {text}") + return "\n".join(lines) + return (doc.full_text or "").strip() or "(расшифровка отсутствует)" + + +def _fmt_secs(s) -> str: + try: + s = int(float(s or 0)) + except Exception: + return "00:00" + return f"{s // 60:02d}:{s % 60:02d}" \ No newline at end of file diff --git a/dyak/api/v1/chat.py b/dyak/api/v1/chat.py new file mode 100644 index 0000000..bcf9817 --- /dev/null +++ b/dyak/api/v1/chat.py @@ -0,0 +1,786 @@ +""" +dyak.api.v1.chat +──────────────── +Чат с моделью прямо в Activity Meeting Record через стандартные +Frappe-комментарии. Триггер — `#Имя` или `@Имя` в начале комментария +(имя берётся из `Dyak Settings.assistant_name`, по умолчанию «Дьяк»). + +Поток: + 1. Пользователь добавляет комментарий: «#Дьяк помоги с задачами». + 2. Хук `Comment.after_insert` (см. hooks.py) → `on_comment_insert`. + 3. Если триггер сработал и автор не сам бот — создаётся + комментарий-плейсхолдер от бота: «⏳ Думаю…» + и ставится фоновая задача `_run_chat`. + 4. Фон собирает контекст (transcript + form state + chat history), + POST на `/webhook/ask-chat` (n8n), получает ответ, + обновляет плейсхолдер реальным ответом или сообщением об ошибке. + +Контракт workflow: + REQUEST POST {N8N_BASE_URL}/ask-chat + {"system": "...", "messages": [{"role": "...", "content": "..."}, ...], + "model": "qwen2.5:32b"} + RESPONSE 200 OK + [{"content": "<ответ модели>"}] +""" +from __future__ import annotations + +import json +import re +import time +from typing import Any + +import frappe +import requests +from frappe.utils import markdown as md_to_html + +from dyak.api.v1.transcribe import _publish_progress + + +# ────────────────────────────────────────────────────────────────────────── +# Конфигурация +# ────────────────────────────────────────────────────────────────────────── + +N8N_BASE_URL = "http://192.168.1.112:5678/webhook" +ENDPOINT_CHAT = f"{N8N_BASE_URL}/ask-chat" +REQUEST_TIMEOUT = (30, 600) + +# Email-логин бот-пользователя. Зашит, потому что должен быть стабильным — +# по нему мы фильтруем «свои» комментарии, чтобы не зациклиться. +BOT_USER_EMAIL = "dyak@bot.local" + +# CSS-обёртка вокруг ответа бота. +BOT_REPLY_WRAPPER = ( + '
' + '{content}' + '
' +) + + +# ────────────────────────────────────────────────────────────────────────── +# Bootstrap бота (вызывается из Dyak Settings → кнопка) +# ────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +def setup_assistant() -> dict: + """Создаёт или обновляет бот-пользователя. + + Дёргается из Dyak Settings кнопкой «Создать/обновить помощника». + Берёт `assistant_name` из настроек, создаёт User dyak@bot.local + (или обновляет full_name, если уже есть), добавляет роль `Dyak User`. + Сохраняет ссылку на User в `Dyak Settings.assistant_user`. + + Идемпотентна: можно дёргать сколько угодно раз. + """ + settings = frappe.get_single("Dyak Settings") + name = (settings.assistant_name or "Дьяк").strip() + + # User: создаём или обновляем full_name. + if frappe.db.exists("User", BOT_USER_EMAIL): + user = frappe.get_doc("User", BOT_USER_EMAIL) + user.full_name = name + user.first_name = name + user.enabled = 1 + # Включаем системный доступ — иначе комментарии от него выглядят + # странно (без аватарки, без имени). + user.user_type = "System User" + # Гарантируем роль Dyak User. + existing_roles = {r.role for r in user.roles} + if "Dyak User" not in existing_roles: + user.append("roles", {"role": "Dyak User"}) + user.save(ignore_permissions=True) + action = "updated" + else: + user = frappe.get_doc({ + "doctype": "User", + "email": BOT_USER_EMAIL, + "first_name": name, + "full_name": name, + "user_type": "System User", + "send_welcome_email": 0, + "enabled": 1, + "roles": [{"role": "Dyak User"}], + }) + user.insert(ignore_permissions=True) + action = "created" + + # Ссылка в Dyak Settings. + settings.assistant_user = BOT_USER_EMAIL + settings.save(ignore_permissions=True) + frappe.db.commit() + + frappe.msgprint( + f"Помощник «{name}» {action} (пользователь: {BOT_USER_EMAIL})", + title="Готово", indicator="green", + ) + return {"status": action, "user": BOT_USER_EMAIL, "name": name} + + +# ────────────────────────────────────────────────────────────────────────── +# Хук Comment.after_insert +# ────────────────────────────────────────────────────────────────────────── + +def on_comment_insert(doc, method=None) -> None: + """Хук, прописанный в hooks.py: + doc_events = {"Comment": {"after_insert": "dyak.api.v1.chat.on_comment_insert"}} + + Срабатывает на ЛЮБОЕ добавление Comment во всей системе — поэтому + жёстко фильтруем: + - reference_doctype == "Meeting Record" + - comment_type == "Comment" (не "Like", не "Edit", и т.д.) + - автор не бот + - текст начинается с #Имя или @Имя + """ + try: + if doc.reference_doctype != "Meeting Record": + return + if doc.comment_type != "Comment": + return + if (doc.comment_email or doc.owner) == BOT_USER_EMAIL: + return + + plain = _html_to_plain(doc.content or "") + assistant_name = _get_assistant_name() + if not _matches_trigger(plain, assistant_name): + return + + # ВАЖНО: ничего не пишем в БД от лица бота прямо здесь. + # Если в синхронном HTTP-запросе пользователя сделать + # `frappe.set_user(bot)` — Frappe подменит cookies и пользователя + # «выкинет» из интерфейса. Поэтому создание плейсхолдера и весь + # обмен с моделью идут в фоновой задаче. + frappe.enqueue( + "dyak.api.v1.chat._run_chat", + queue="long", + timeout=900, + meeting_docname=doc.reference_name, + user_message=plain, + placeholder_comment=None, # будет создан в _run_chat + trigger_comment=doc.name, # для realtime-уведомления родителя + user=frappe.session.user, + enqueue_after_commit=True, + ) + except Exception: + # Хук НИКОГДА не должен валить вставку комментария. + frappe.log_error( + title="Dyak Chat: ошибка в on_comment_insert", + message=frappe.get_traceback(), + ) + + +def _matches_trigger(text: str, assistant_name: str) -> bool: + """Проверяет, начинается ли plain-text комментария с триггера. + + Триггер — `#Имя` или `@Имя`, должен идти В НАЧАЛЕ строки и заканчиваться + границей слова (чтобы `#Дьяконов` не срабатывал). + """ + text = (text or "").lstrip() + if not text: + return False + # Экранируем имя для регэкспа (на случай дефисов и пр.). + name_re = re.escape(assistant_name) + pattern = rf"^[#@]{name_re}\b" + return bool(re.match(pattern, text, re.IGNORECASE)) + + +def _strip_trigger(text: str, assistant_name: str) -> str: + """Убирает `#Имя` / `@Имя` из начала строки, оставляя сам вопрос.""" + name_re = re.escape(assistant_name) + return re.sub( + rf"^\s*[#@]{name_re}\s*[:,—\-]?\s*", + "", text, count=1, flags=re.IGNORECASE, + ).strip() + + +# ────────────────────────────────────────────────────────────────────────── +# Комментарии: создание / обновление +# ────────────────────────────────────────────────────────────────────────── + +def _create_placeholder_comment(reference_name: str) -> str: + """Создаёт комментарий «⏳ Думаю…» от бот-пользователя. Возвращает имя + Comment-документа (для последующего обновления). + + Чтобы в Activity timeline комментарий отображался от имени бота + («Дьяк commented»), а не от текущего пользователя, перед `insert` + переключаем сессию на бот-юзера. Это единственный способ повлиять + на поле `owner` у нового документа — Frappe берёт его из текущей + сессии и не позволяет переопределить через словарь `get_doc({...})`. + + ВАЖНО: вызывается ТОЛЬКО из фоновой задачи (`_run_chat`), а не из + синхронного хука. В синхронном HTTP-запросе `frappe.set_user` + подменяет cookies живого пользователя, и его «выкидывает» из + интерфейса. В фоновом воркере сессии нет — менять её безопасно. + """ + bot_email = _get_bot_email() + original_user = frappe.session.user + try: + frappe.set_user(bot_email) + comment = frappe.get_doc({ + "doctype": "Comment", + "comment_type": "Comment", + "reference_doctype": "Meeting Record", + "reference_name": reference_name, + "comment_email": bot_email, + "comment_by": _get_assistant_name(), + "content": _wrap_bot_reply("⏳ Думаю…"), + }) + comment.insert(ignore_permissions=True) + frappe.db.commit() + return comment.name + finally: + # Возвращаем прежнего пользователя — даже если что-то упало. + frappe.set_user(original_user) + + +def _update_comment(comment_name: str, html: str) -> None: + """Заменяет content существующего Comment. Используется для подмены + плейсхолдера на реальный ответ или сообщение об ошибке. + + Перерисовка timeline на стороне клиента сейчас не делается — + пользователь обновляет страницу руками. Если потом захотим + автообновление, верним publish_realtime + клиентский watcher. + """ + frappe.db.set_value( + "Comment", comment_name, + "content", html, + update_modified=True, + ) + frappe.db.commit() + + +def _wrap_bot_reply(content: str) -> str: + """Оборачивает текст в стилизованный HTML-блок.""" + # Если content уже HTML (после markdown→HTML), не экранируем повторно. + return BOT_REPLY_WRAPPER.format(content=content) + + +# ────────────────────────────────────────────────────────────────────────── +# Фоновая задача +# ────────────────────────────────────────────────────────────────────────── + +def _run_chat( + meeting_docname: str, + user_message: str, + placeholder_comment: str | None = None, + trigger_comment: str | None = None, + user: str | None = None, +) -> None: + """Фоновая задача: создать плейсхолдер (если ещё нет), собрать контекст, + POST в n8n, обновить плейсхолдер реальным ответом. + + Плейсхолдер создаётся именно здесь (а не в синхронном хуке), чтобы + `frappe.set_user(bot)` не подменял cookies живого пользователя. + """ + started_at = time.time() + + # Создаём плейсхолдер, если он ещё не создан в синхронном пути. + if not placeholder_comment: + try: + placeholder_comment = _create_placeholder_comment( + reference_name=meeting_docname, + ) + except Exception: + frappe.log_error( + title=f"Dyak Chat: не удалось создать плейсхолдер для {meeting_docname}", + message=frappe.get_traceback(), + ) + return + + try: + doc = frappe.get_doc("Meeting Record", meeting_docname) + settings = frappe.get_single("Dyak Settings") + model = (settings.llm_model or "qwen2.5:32b").strip() + assistant_name = (settings.assistant_name or "Дьяк").strip() + ctx_limit = int(settings.chat_context_limit or 30000) + + _publish_progress( + meeting_docname, "Чат · Подготовка", 10, + f"Сборка контекста, модель: {model}", user=user, + ) + + # Очищаем триггер из последнего сообщения пользователя. + clean_user_message = _strip_trigger(user_message, assistant_name) + + # Собираем контекст (system + messages). + system_prompt, messages, ctx_warning = _build_chat_context( + doc=doc, + assistant_name=assistant_name, + current_user_message=clean_user_message, + ctx_limit=ctx_limit, + ) + + if ctx_warning: + _publish_progress( + meeting_docname, "Чат · Контекст обрезан", 15, + ctx_warning, user=user, + ) + + _publish_progress( + meeting_docname, "Чат · Отправка", 30, + f"POST {ENDPOINT_CHAT} (system {len(system_prompt)} симв., " + f"сообщений: {len(messages)})", user=user, + ) + + send_started = time.time() + response = requests.post( + ENDPOINT_CHAT, + json={ + "system": system_prompt, + "messages": messages, + "model": model, + }, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + net_secs = time.time() - send_started + + body = response.json() + content = _extract_content(body) + if not content: + raise ValueError("Пустой ответ модели") + + _publish_progress( + meeting_docname, "Чат · Получен ответ", 80, + f"HTTP {response.status_code}, {len(content)} симв. за {net_secs:.1f} сек", + user=user, + ) + + # Markdown → HTML для красивого отображения в комментарии. + try: + html_content = md_to_html(content) + except Exception: + # Fallback: показать как plain-text с переносами. + html_content = _plain_to_html(content) + + _update_comment(placeholder_comment, _wrap_bot_reply(html_content)) + + total = time.time() - started_at + _publish_progress( + meeting_docname, "Чат · Готово", 100, + f"Ответ за {total:.1f} сек", user=user, + ) + + except requests.exceptions.ConnectionError as exc: + _chat_failure(meeting_docname, user, placeholder_comment, + "Ошибка сети", f"Не удалось подключиться к n8n: {exc}") + except requests.exceptions.Timeout as exc: + _chat_failure(meeting_docname, user, placeholder_comment, + "Таймаут", f"n8n не ответил вовремя: {exc}") + except requests.exceptions.HTTPError as exc: + body_preview = "" + try: + body_preview = exc.response.text[:300] if exc.response is not None else "" + except Exception: + pass + status_code = exc.response.status_code if exc.response is not None else "?" + _chat_failure(meeting_docname, user, placeholder_comment, + "Ошибка n8n", f"HTTP {status_code}: {body_preview}") + except Exception as exc: + _chat_failure(meeting_docname, user, placeholder_comment, + "Ошибка", f"{type(exc).__name__}: {exc}") + + +def _chat_failure(meeting_docname: str, user: str | None, + placeholder_comment: str, stage: str, message: str) -> None: + """Лог + плейсхолдер заменяется на сообщение об ошибке. + + Перед любыми операциями делаем rollback — на случай, если исходная + ошибка случилась в открытой транзакции PostgreSQL и оставила её в + состоянии `aborted` (тогда без rollback все следующие SQL отклоняются). + """ + try: + frappe.db.rollback() + except Exception: + pass + + try: + frappe.log_error( + title=f"Dyak Chat: {stage} для {meeting_docname}", + message=frappe.get_traceback(), + ) + except Exception: + pass + + _publish_progress( + meeting_docname, f"Чат · {stage}", 0, message, + user=user, is_error=True, + ) + error_html = ( + f'❌ {frappe.utils.escape_html(stage)}: ' + f'{frappe.utils.escape_html(message)}' + f'

Чтобы повторить — отправьте новый комментарий с ' + f'#{_get_assistant_name()}.' + ) + try: + _update_comment(placeholder_comment, _wrap_bot_reply(error_html)) + except Exception: + pass + + +# ────────────────────────────────────────────────────────────────────────── +# Сборка контекста +# ────────────────────────────────────────────────────────────────────────── + +def _build_chat_context( + doc, + assistant_name: str, + current_user_message: str, + ctx_limit: int, +) -> tuple[str, list[dict], str | None]: + """Готовит system + messages для отправки в n8n. + + Стратегия: + - system всегда содержит описание роли + полное состояние формы + + полный transcript (это база, без неё бот бесполезен). + - messages — история чата (комментарии с триггером + ответы бота), + строго чередующиеся user/assistant. Если суммарный объём messages + превышает `ctx_limit`, отрезаем самые старые сообщения. + Пользователю возвращается warning, который тоже добавится в system. + - Последнее user-сообщение — current_user_message (только что введённое). + """ + system = _build_system_prompt(doc, assistant_name) + history = _load_chat_history(doc, assistant_name) + + # Текущее сообщение пользователя добавляем в самый конец. + history.append({"role": "user", "content": current_user_message}) + + # Soft-лимит по символам. + warning = None + total = sum(len(m["content"]) for m in history) + if total > ctx_limit: + # Режем с начала, но сохраняем последнее (текущее) сообщение. + kept = [] + running = len(current_user_message) + for m in reversed(history[:-1]): + running += len(m["content"]) + if running > ctx_limit: + break + kept.append(m) + kept.reverse() + kept.append(history[-1]) + dropped = len(history) - len(kept) + warning = ( + f"Внимание: история чата обрезана. " + f"Показаны последние {len(kept)} сообщений из {len(history)} " + f"(пропущено {dropped} ранних)." + ) + system = warning + "\n\n" + system + history = kept + + return system, history, warning + + +def _build_system_prompt(doc, assistant_name: str) -> str: + """Большой system promp: личность + расшифровка + полное состояние формы.""" + transcript = _format_transcript(doc) + form_state = _format_form_state(doc) + + return f"""\ +Ты — {assistant_name}, AI-помощник в системе протоколов рабочих встреч. +Отвечай на русском языке, кратко и по делу. +Если вопрос относится к встрече — опирайся на расшифровку и текущее +состояние формы ниже. Если данных не хватает — честно скажи об этом, не +выдумывай. + +Можно использовать markdown: списки, **жирный текст**, `код`. + +═══════════════════════════════════════════════════════════ +ТЕКУЩЕЕ СОСТОЯНИЕ ФОРМЫ ВСТРЕЧИ +═══════════════════════════════════════════════════════════ +{form_state} + +═══════════════════════════════════════════════════════════ +РАСШИФРОВКА ВСТРЕЧИ +═══════════════════════════════════════════════════════════ +{transcript} +""" + + +def _format_form_state(doc) -> str: + """Текстовое представление формы: статус, summary, темы, child-tables.""" + lines = [] + lines.append(f"Название: {doc.title or '—'}") + lines.append(f"Статус: {doc.status or '—'}") + if doc.meeting_date: + lines.append(f"Дата: {doc.meeting_date}") + if doc.project: + lines.append(f"Проект: {doc.project}") + if doc.category: + lines.append(f"Категория: {doc.category}") + if doc.detected_language: + lines.append(f"Язык: {doc.detected_language}") + if doc.num_speakers: + lines.append(f"Спикеров: {doc.num_speakers}") + if doc.audio_duration: + lines.append(f"Длительность: {int(doc.audio_duration)} сек") + + if doc.summary: + lines.append(f"\nРезюме:\n{_strip_html(doc.summary)}") + if doc.meeting_topics: + lines.append(f"\nТемы: {doc.meeting_topics}") + if doc.meeting_mood and doc.meeting_mood != "—": + lines.append(f"Тон встречи: {doc.meeting_mood}") + + # Участники. + if doc.participants: + lines.append("\nУчастники:") + for p in doc.participants: + speaker_part = f" ({p.speaker_id})" if p.speaker_id else "" + role_part = f" — {p.role}" if p.role else "" + lines.append(f" • {p.participant_name}{speaker_part}{role_part}") + + # Применённые профили анализа. + _append_analysis_results(lines, doc.name) + + return "\n".join(lines) + + +def _append_analysis_results(lines: list, meeting_name: str) -> None: + """Добавляет блок с результатами применённых профилей анализа. + + Для каждого `Meeting Analysis Result` в статусе «Готово» компактно + сериализуем `result_json` в плоский текст — это даёт боту понять, + что уже было проанализировано. Тяжёлые поля (full result_json в + «сыром виде») не подмешиваем, чтобы не раздувать system prompt. + """ + results = frappe.get_all( + "Meeting Analysis Result", + filters={"meeting_record": meeting_name}, + fields=["name", "profile_name_snapshot", "status", "result_json"], + order_by="creation asc", + ) + if not results: + return + + lines.append("\nПрименённые профили анализа:") + for r in results: + if r.status != "Готово": + lines.append( + f" • {r.profile_name_snapshot or r.name} — {r.status}" + ) + continue + + summary = _summarise_result_json(r.result_json) + if summary: + lines.append(f" • {r.profile_name_snapshot or r.name}:") + for s in summary: + lines.append(f" – {s}") + else: + lines.append( + f" • {r.profile_name_snapshot or r.name} — пустой результат" + ) + + +def _summarise_result_json(raw: str | None) -> list[str]: + """Сворачивает result_json в короткие текстовые строки. + + Поведение по типу значения ключа: + - строка: «ключ: значение» + - список строк: «ключ: a, b, c» (до 5 элементов, остальные — ...) + - список объектов: «ключ: N запис(ей)» + первые 2 объекта одной + строкой через `;` + - другие типы пропускаются (вложенные dict без схемы редки). + Все строки усекаются до 200 символов. + """ + if not raw: + return [] + try: + parsed = json.loads(raw) + except Exception: + return [] + if not isinstance(parsed, dict): + return [] + + out = [] + for key, value in parsed.items(): + if value is None: + continue + if isinstance(value, str): + v = value.strip() + if v: + out.append(_clip(f"{key}: {v}", 200)) + elif isinstance(value, list): + if not value: + continue + if all(isinstance(x, str) for x in value): + shown = ", ".join(value[:5]) + tail = "…" if len(value) > 5 else "" + out.append(_clip(f"{key}: {shown}{tail}", 200)) + elif all(isinstance(x, dict) for x in value): + # Берём первые 2 объекта, склеиваем их непустые поля. + previews = [] + for obj in value[:2]: + parts = [ + f"{k}={v}" for k, v in obj.items() + if isinstance(v, (str, int, float)) and str(v).strip() + ] + if parts: + previews.append("; ".join(parts)) + tail = f" (+ ещё {len(value) - 2})" if len(value) > 2 else "" + if previews: + out.append(_clip( + f"{key} ({len(value)}): " + " | ".join(previews) + tail, + 300, + )) + else: + out.append(f"{key}: {len(value)} запис(ей)") + elif isinstance(value, (int, float, bool)): + out.append(f"{key}: {value}") + return out + + +def _clip(text: str, limit: int) -> str: + text = (text or "").strip() + if len(text) <= limit: + return text + return text[: limit - 1].rstrip() + "…" + + +def _format_transcript(doc) -> str: + """То же, что в ai.py — но переиспользуем напрямую, чтобы не дублировать.""" + raw = doc.utterances_json + if raw: + try: + parsed = json.loads(raw) if isinstance(raw, str) else raw + utterances = parsed if isinstance(parsed, list) else parsed.get("utterances") or [] + except Exception: + utterances = [] + if utterances: + name_by_speaker = { + p.speaker_id: p.participant_name + for p in (doc.participants or []) + if p.speaker_id and p.participant_name + } + lines = [] + for u in utterances: + spk = u.get("speaker") or "SPEAKER_??" + name = name_by_speaker.get(spk, spk) + start = _fmt_secs(u.get("start")) + end = _fmt_secs(u.get("end")) + text = (u.get("text") or "").strip() + lines.append(f"[{name}] ({start}–{end}): {text}") + return "\n".join(lines) + return (doc.full_text or "").strip() or "(расшифровка отсутствует)" + + +def _fmt_secs(s) -> str: + try: + s = int(float(s or 0)) + except Exception: + return "00:00" + return f"{s // 60:02d}:{s % 60:02d}" + + +# ────────────────────────────────────────────────────────────────────────── +# История чата +# ────────────────────────────────────────────────────────────────────────── + +def _load_chat_history(doc, assistant_name: str) -> list[dict]: + """Загружает все комментарии этого Meeting Record, отбирает чат-сообщения + (триггер от пользователя + ответы бота) и возвращает их в виде + `[{role: "user"|"assistant", "content": "..."}, ...]` в хронологическом + порядке. Текущее (только что добавленное) сообщение исключается — + оно добавится отдельно вызывающим кодом. + """ + bot_email = _get_bot_email() + comments = frappe.get_all( + "Comment", + filters={ + "reference_doctype": "Meeting Record", + "reference_name": doc.name, + "comment_type": "Comment", + }, + fields=["name", "content", "comment_email", "owner", "creation"], + order_by="creation asc", + ) + + history = [] + # Идём по парам: user-trigger → bot-reply (плейсхолдер или финальный). + # Если пара неполная (например, бот ещё не ответил) — пропускаем + # «висячий» элемент, чтобы у модели не было кривого чередования. + pending_user = None + for c in comments: + plain = _html_to_plain(c.content or "").strip() + is_bot = (c.comment_email or c.owner) == bot_email + + if is_bot: + # Это ответ бота. Берём только содержательные ответы — + # плейсхолдеры «⏳ Думаю…» и текущая активная обработка + # пропускаются (они не часть истории). + if plain.startswith("⏳"): + # Текущий висящий плейсхолдер — это и есть наш свежий + # запрос, его в историю не кладём. + pending_user = None + continue + if pending_user is not None: + history.append({"role": "user", "content": pending_user}) + history.append({"role": "assistant", "content": plain}) + pending_user = None + else: + # Сообщение пользователя — учитываем только если есть триггер. + if _matches_trigger(plain, assistant_name): + # Если предыдущий user остался без ответа (например, бот + # упал), мы его пропустим — иначе чередование сломается. + pending_user = _strip_trigger(plain, assistant_name) + + return history + + +# ────────────────────────────────────────────────────────────────────────── +# Утилиты +# ────────────────────────────────────────────────────────────────────────── + +def _extract_content(body: Any) -> str: + if isinstance(body, list) and body: + first = body[0] + if isinstance(first, dict): + return (first.get("content") or "").strip() + if isinstance(body, dict): + return (body.get("content") or "").strip() + return "" + + +def _html_to_plain(html: str) -> str: + """Грубо снимает HTML-теги — для триггера и истории чата.""" + if not html: + return "" + # frappe умеет это нормально, но без зависимости — вот регэкспный fallback. + text = re.sub(r"", "\n", html, flags=re.IGNORECASE) + text = re.sub(r"

", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", "", text) + # HTML entities — самые ходовые. + text = (text + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", '"') + .replace("'", "'")) + return text.strip() + + +def _plain_to_html(text: str) -> str: + """Простое экранирование с сохранением переносов.""" + return frappe.utils.escape_html(text).replace("\n", "
") + + +def _strip_html(html: str) -> str: + """Тот же _html_to_plain, отдельная семантика для form-state.""" + return _html_to_plain(html) + + +def _get_assistant_name() -> str: + return (frappe.db.get_single_value("Dyak Settings", "assistant_name") or "Дьяк").strip() + + +def _get_bot_email() -> str: + """Возвращает email бот-пользователя. Если в Settings есть + `assistant_user` — берём его, иначе используем константу + `BOT_USER_EMAIL`. На случай, если кто-то решил иначе именовать + юзера. + """ + saved = frappe.db.get_single_value("Dyak Settings", "assistant_user") + return (saved or BOT_USER_EMAIL).strip() \ No newline at end of file diff --git a/dyak/api/v1/chat_global.py b/dyak/api/v1/chat_global.py new file mode 100644 index 0000000..0551342 --- /dev/null +++ b/dyak/api/v1/chat_global.py @@ -0,0 +1,1329 @@ +""" +dyak.api.v1.chat_global +─────────────────────── +Глобальный чат с Дьяком — экспериментальная фича. + +В отличие от meeting-режима (chat.py), этот модуль не привязан к +конкретной встрече: бот сам ищет релевантные встречи в корпусе через +PostgreSQL FTS (`_search_tsv` + GIN-индекс, создаётся патчем v03) и +подтягивает их в контекст. + +Архитектура + + client (/dyak/chat page) + ↓ post_message(session, content) + • create Dyak Chat Message role=user, status=Готово + • create Dyak Chat Message role=assistant, status=В обработке ← плейсхолдер + • enqueue _run_global_chat(assistant_message) + ↓ + ┌── planner ──────────────────────────┐ + │ LLM call #1: вопрос + 4 последних │ + │ пар → JSON с фильтрами поиска │ + └─────────────────────────────────────┘ + ↓ + ┌── retrieval ────────────────────────┐ + │ PostgreSQL FTS по tabMeeting │ + │ Record._search_tsv, ts_rank, │ + │ ts_headline. Топ-20 кандидатов. │ + └─────────────────────────────────────┘ + ↓ + ┌── context assembly ─────────────────┐ + │ top-1: полный транскрипт + │ + │ Meeting Analysis Results │ + │ top-2..3: summary + сниппеты │ + │ top-4..5: только summary │ + └─────────────────────────────────────┘ + ↓ + ┌── answerer ─────────────────────────┐ + │ LLM call #2: system (роль + ваш │ + │ контекст встреч) + messages │ + │ (вся история до chat_context_limit │ + │ + текущий вопрос) │ + └─────────────────────────────────────┘ + ↓ + • update assistant message: content, meta_json (sources), status=Готово + +Permissions: пока НЕ проверяем — все видят все встречи. TODO: добавить +post-filter через frappe.has_permission, когда дойдут руки до ролей. + +Контракты n8n: + • /ask-analyze (planner): {prompt, model} → [{content}] + • /ask-chat (answerer): {system, messages, model} → [{content}] +""" +from __future__ import annotations + +import json +import re +import time +from typing import Any + +import frappe +import requests +from frappe.utils import escape_html, now_datetime + + +N8N_BASE_URL = "http://192.168.1.112:5678/webhook" +ENDPOINT_PLANNER = f"{N8N_BASE_URL}/ask-analyze" +ENDPOINT_ANSWERER = f"{N8N_BASE_URL}/ask-chat" +REQUEST_TIMEOUT = (30, 600) + +# Сколько встреч максимум возвращает retrieval. Берётся из FTS-топа, +# потом распределяется по «уровням детальности». +MAX_CANDIDATES = 20 +TOP_FULL = 1 # сколько встреч с ПОЛНЫМ транскриптом + аналитикой +TOP_SNIPPETS = 3 # сколько встреч с summary + сниппетами +TOP_PREVIEW = 5 # всего показываем (для top_full=1, top_snippets=3 → ещё 1 preview) + +# Сколько последних пар user/assistant даём planner-у для уточнения вопроса. +PLANNER_HISTORY_PAIRS = 4 + + +# ────────────────────────────────────────────────────────────────────────── +# Whitelisted endpoints (вызываются с фронта страницы /dyak/chat) +# ────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +def list_sessions(include_archived: int = 0) -> list[dict]: + """Список чат-сессий текущего пользователя. + + Сортировка: pinned первыми, затем по last_message_at desc. + Архивные скрыты по умолчанию. + """ + filters = {"owner_user": frappe.session.user} + if not int(include_archived or 0): + filters["archived"] = 0 + return frappe.get_all( + "Dyak Chat Session", + filters=filters, + fields=["name", "title", "last_message_at", "pinned", + "archived", "message_count"], + order_by="pinned desc, last_message_at desc", + limit_page_length=200, + ) + + +@frappe.whitelist() +def create_session(title: str | None = None) -> dict: + """Создаёт новую чат-сессию. Если title пустой — поставится placeholder, + позже автоматически заменится из первого вопроса пользователя. + """ + session = frappe.get_doc({ + "doctype": "Dyak Chat Session", + "title": (title or "Новый диалог").strip(), + "owner_user": frappe.session.user, + "last_message_at": now_datetime(), + "pinned": 0, + "archived": 0, + "message_count": 0, + }) + session.insert(ignore_permissions=True) + frappe.db.commit() + return {"name": session.name, "title": session.title} + + +@frappe.whitelist() +def get_messages(session: str, limit: int = 50, + before: str | None = None) -> list[dict]: + """История сообщений в чат-сессии. Сортировка ASC по creation. + + Пагинация: `before` — creation предыдущего самого старого сообщения, + клиент догружает в обе стороны (для длинных диалогов). + """ + _ensure_session_access(session) + filters: dict[str, Any] = {"session": session} + if before: + filters["creation"] = ("<", before) + + messages = frappe.get_all( + "Dyak Chat Message", + filters=filters, + fields=["name", "role", "status", "content", "meta_json", + "model_used", "error_message", "creation"], + order_by="creation asc", + limit_page_length=int(limit), + ) + return messages + + +@frappe.whitelist() +def post_message(session: str, content: str) -> dict: + """Принимает новое сообщение пользователя, создаёт плейсхолдер ответа + бота и ставит фоновую задачу. + + Возвращает имена обоих созданных сообщений, чтобы фронт мог + мониторить статус ассистент-сообщения. + """ + _ensure_session_access(session) + content = (content or "").strip() + if not content: + frappe.throw("Сообщение пустое") + + user = frappe.session.user + now = now_datetime() + + # 1. Сохраняем user-сообщение. + user_msg = frappe.get_doc({ + "doctype": "Dyak Chat Message", + "session": session, + "role": "user", + "status": "Готово", + "content": content, + "created_at": now, + }) + user_msg.insert(ignore_permissions=True) + + # 2. Плейсхолдер ассистент-сообщения. + assistant_msg = frappe.get_doc({ + "doctype": "Dyak Chat Message", + "session": session, + "role": "assistant", + "status": "В обработке", + "content": "", + "created_at": now, + }) + assistant_msg.insert(ignore_permissions=True) + + # 3. Обновляем мета сессии. Если title всё ещё «Новый диалог» — + # подставляем первый вопрос (обрезанный). + session_doc = frappe.get_doc("Dyak Chat Session", session) + session_doc.last_message_at = now + session_doc.message_count = (session_doc.message_count or 0) + 2 + if (session_doc.title or "").strip() in ("", "Новый диалог"): + session_doc.title = _make_title_from_message(content) + session_doc.save(ignore_permissions=True) + + frappe.db.commit() + + # 4. Фоновая задача. + frappe.enqueue( + "dyak.api.v1.chat_global._run_global_chat", + queue="long", + timeout=900, + assistant_message=assistant_msg.name, + user=user, + enqueue_after_commit=True, + ) + + return { + "user_message": user_msg.name, + "assistant_message": assistant_msg.name, + } + + +@frappe.whitelist() +def rename_session(session: str, title: str) -> dict: + _ensure_session_access(session) + title = (title or "").strip() + if not title: + frappe.throw("Название пустое") + frappe.db.set_value("Dyak Chat Session", session, "title", title) + frappe.db.commit() + return {"ok": True, "title": title} + + +@frappe.whitelist() +def set_session_flag(session: str, flag: str, value: int) -> dict: + """Универсально: pinned / archived.""" + if flag not in ("pinned", "archived"): + frappe.throw(f"Неизвестный флаг: {flag}") + _ensure_session_access(session) + frappe.db.set_value("Dyak Chat Session", session, flag, int(value)) + frappe.db.commit() + return {"ok": True} + + +@frappe.whitelist() +def delete_session(session: str) -> dict: + """Удаляет сессию вместе с сообщениями (каскада нет, чистим явно).""" + _ensure_session_access(session) + frappe.db.delete("Dyak Chat Message", {"session": session}) + frappe.delete_doc("Dyak Chat Session", session, ignore_permissions=True) + frappe.db.commit() + return {"ok": True} + + +# ────────────────────────────────────────────────────────────────────────── +# Permissions (заглушка) +# ────────────────────────────────────────────────────────────────────────── + +def _ensure_session_access(session: str) -> None: + """Проверяет, что пользователь имеет доступ к сессии. + + Сейчас — простейшая проверка: владелец = текущий пользователь. + System Manager видит всё. Шаринга между пользователями пока нет. + """ + if "System Manager" in frappe.get_roles(): + return + owner = frappe.db.get_value("Dyak Chat Session", session, "owner_user") + if owner and owner != frappe.session.user: + frappe.throw("Нет доступа к этой сессии", frappe.PermissionError) + + +# ────────────────────────────────────────────────────────────────────────── +# Фоновая задача +# ────────────────────────────────────────────────────────────────────────── +# Фоновая задача — с подробной отладкой (debug_log в meta_json) +# ────────────────────────────────────────────────────────────────────────── + +def _debug(meta: dict, level: str, stage: str, message: str, + **extra) -> None: + """Добавляет одну запись в meta['debug_log']. + + level: info | warn | error + stage: planner_request / planner_response / retrieval_sql / + retrieval_result / answerer_request / answerer_response / + postprocess / save / fail / total + """ + if "debug_log" not in meta: + meta["debug_log"] = [] + entry = { + "ts": now_datetime().strftime("%H:%M:%S.%f")[:-3], + "level": level, + "stage": stage, + "message": message, + } + if extra: + entry["extra"] = extra + meta["debug_log"].append(entry) + + +def _save_progress(assistant_message: str, meta: dict, + status: str | None = None, + content: str | None = None) -> None: + """Пишет промежуточный snapshot meta_json (и опционально status/content). + + Это нужно, чтобы пользователь видел отладку даже если задача упала + где-то в середине. Каждый этап вызывает _save_progress, и meta_json + обновляется в БД. + + ВАЖНО: перед записью делаем rollback на случай aborted-транзакции. + """ + try: + frappe.db.rollback() + except Exception: + pass + + updates = {"meta_json": json.dumps(meta, ensure_ascii=False, default=str)} + if status: + updates["status"] = status + if content is not None: + updates["content"] = content + try: + frappe.db.set_value( + "Dyak Chat Message", assistant_message, + updates, update_modified=True, + ) + frappe.db.commit() + except Exception: + try: + frappe.db.rollback() + except Exception: + pass + + +def _run_global_chat(assistant_message: str, user: str | None = None) -> None: + """Основной поток: planner → retrieval → answerer → запись ответа. + + На каждом этапе: + • добавляется запись в meta['debug_log']; + • при значимых событиях вызывается _save_progress — это сохраняет + текущий debug_log в БД сразу, не дожидаясь конца задачи. Так + пользователь может в любой момент открыть отладку и увидеть, + где именно «застрял» бот. + """ + started_at = time.time() + meta: dict[str, Any] = {"stages": [], "debug_log": []} + + _debug(meta, "info", "start", f"Задача запущена, message={assistant_message}") + _save_progress(assistant_message, meta) + + try: + # ── Подготовка ──────────────────────────────────────────────── + msg = frappe.get_doc("Dyak Chat Message", assistant_message) + session = msg.session + settings = frappe.get_single("Dyak Settings") + model = (settings.llm_model or "qwen2.5:32b").strip() + assistant_name = (settings.assistant_name or "Дьяк").strip() + ctx_limit = int(settings.chat_context_limit or 30000) + + _debug(meta, "info", "config", + f"model={model}, assistant={assistant_name}, ctx_limit={ctx_limit}") + + history = _load_history(session) + history = [m for m in history if m["_name"] != assistant_message] + if not history or history[-1]["role"] != "user": + raise ValueError("Не найдено user-сообщение для ответа") + user_message = history[-1]["content"] + + _debug(meta, "info", "history", + f"Загружено {len(history)} сообщений, последний вопрос: " + f"{_clip(user_message, 120)!r}") + _save_progress(assistant_message, meta) + + # ── Planner ─────────────────────────────────────────────────── + t0 = time.time() + _debug(meta, "info", "planner_request", + f"Отправка вопроса в planner ({ENDPOINT_PLANNER})") + _save_progress(assistant_message, meta) + + plan = _run_planner( + user_message=user_message, + recent_history=history[:-1][-(PLANNER_HISTORY_PAIRS * 2):], + model=model, + meta=meta, # planner сам добавит свои записи в debug_log + ) + planner_took = round(time.time() - t0, 2) + meta["stages"].append({ + "stage": "planner", "took": planner_took, "plan": plan, + }) + _debug(meta, "info", "planner_response", + f"Plan получен за {planner_took} сек: " + f"needs_search={plan.get('needs_search')}, " + f"terms={plan.get('search_terms')}, " + f"project={plan.get('project_filter')}, " + f"category={plan.get('category_filter')}, " + f"date_from={plan.get('date_from')}, " + f"date_to={plan.get('date_to')}", + reasoning=plan.get("reasoning")) + _save_progress(assistant_message, meta) + + # ── Retrieval ───────────────────────────────────────────────── + candidates: list[dict] = [] + if plan.get("needs_search"): + t0 = time.time() + _debug(meta, "info", "retrieval_sql", + f"Запуск FTS-поиска: '{' '.join(plan.get('search_terms') or [])}'") + _save_progress(assistant_message, meta) + + try: + candidates = _retrieve_with_fallbacks(plan, meta) + retrieval_took = round(time.time() - t0, 2) + _debug(meta, "info", "retrieval_result", + f"Найдено {len(candidates)} встреч за {retrieval_took} сек", + names=[c["name"] for c in candidates[:10]], + ranks=[round(c.get("rank") or 0, 4) + for c in candidates[:10]]) + meta["stages"].append({ + "stage": "retrieval", "took": retrieval_took, + "count": len(candidates), + "names": [c["name"] for c in candidates], + }) + except Exception as exc: + retrieval_took = round(time.time() - t0, 2) + _debug(meta, "error", "retrieval_failed", + f"FTS-поиск упал за {retrieval_took} сек: " + f"{type(exc).__name__}: {exc}", + hint="Возможно, индекс _search_tsv ещё не создан — " + "запустите bench migrate. " + "См. patches/v03/add_meeting_record_fts.py") + meta["stages"].append({ + "stage": "retrieval", "took": retrieval_took, + "count": 0, "error": f"{type(exc).__name__}: {exc}", + }) + # Продолжаем без встреч — бот ответит общим знанием. + candidates = [] + _save_progress(assistant_message, meta) + else: + _debug(meta, "info", "retrieval_skipped", + "planner: needs_search=false, поиск не выполнялся") + + # ── Context assembly ────────────────────────────────────────── + t0 = time.time() + meetings_context = _build_meetings_context(candidates) + meta["sources"] = [c["name"] for c in candidates[:TOP_PREVIEW]] + _debug(meta, "info", "context_built", + f"System-контекст {len(meetings_context)} симв., " + f"sources={meta['sources']}, " + f"за {round(time.time() - t0, 2)} сек") + _save_progress(assistant_message, meta) + + # ── Answerer ────────────────────────────────────────────────── + t0 = time.time() + system_prompt = _build_system_prompt( + assistant_name=assistant_name, + meetings_context=meetings_context, + had_search=plan.get("needs_search", False), + candidates_count=len(candidates), + ) + messages = [ + {"role": m["role"], "content": m["content"]} + for m in history if m["content"] + ] + messages_before = len(messages) + messages = _trim_messages(messages, ctx_limit) + if len(messages) != messages_before: + _debug(meta, "warn", "history_trimmed", + f"История обрезана: {messages_before} → {len(messages)} " + f"сообщений (лимит {ctx_limit} симв.)") + + _debug(meta, "info", "answerer_request", + f"Отправка в {ENDPOINT_ANSWERER}: " + f"system {len(system_prompt)} симв., " + f"messages {len(messages)} шт., " + f"всего ~{sum(len(m['content']) for m in messages)} симв.") + _save_progress(assistant_message, meta) + + try: + response_text = _call_answerer( + system=system_prompt, messages=messages, model=model, + ) + except Exception as exc: + _debug(meta, "error", "answerer_failed", + f"{type(exc).__name__}: {exc}") + raise + + answerer_took = round(time.time() - t0, 2) + meta["stages"].append({ + "stage": "answerer", "took": answerer_took, + "input_chars": len(system_prompt) + + sum(len(m["content"]) for m in messages), + "output_chars": len(response_text), + }) + _debug(meta, "info", "answerer_response", + f"Ответ получен за {answerer_took} сек, " + f"{len(response_text)} симв.", + preview=_clip(response_text, 200)) + _save_progress(assistant_message, meta) + + # ── Постобработка ───────────────────────────────────────────── + rendered = _postprocess_response(response_text, candidates) + _debug(meta, "info", "postprocess", + f"После замены ссылок: {len(rendered)} симв.") + + # ── Сохраняем результат ─────────────────────────────────────── + total = round(time.time() - started_at, 2) + meta["total_time"] = total + + # Проверяем, были ли НЕкритичные ошибки по пути (retrieval упал, + # planner-парсинг сорвался и т.п.). Если были — итоговый статус + # = «Ошибка», к ответу модели сверху приписываем красный блок, + # чтобы пользователь понял: ответ может быть неполным. + soft_errors = [ + e for e in meta.get("debug_log", []) + if e.get("level") == "error" + ] + if soft_errors: + final_status = "Ошибка" + err_summary = "\n".join( + f"- **{e.get('stage', '?')}**: {e.get('message', '')}" + for e in soft_errors + ) + final_content = ( + f"> ⚠️ В процессе обработки случились ошибки — ответ может быть " + f"неполным или неточным:\n>\n" + + "\n".join(f"> {line}" for line in err_summary.splitlines()) + + f"\n\n---\n\n{rendered}" + ) + error_field = "; ".join( + f"{e.get('stage', '?')}: {e.get('message', '')}" + for e in soft_errors + )[:500] + _debug(meta, "warn", "total", + f"Готово с ошибками за {total} сек " + f"({len(soft_errors)} soft-error)") + else: + final_status = "Готово" + final_content = rendered + error_field = "" + _debug(meta, "info", "total", f"Готово за {total} сек") + + update = { + "status": final_status, + "content": final_content, + "meta_json": json.dumps(meta, ensure_ascii=False, default=str), + "model_used": model, + } + if error_field: + update["error_message"] = error_field + + frappe.db.set_value( + "Dyak Chat Message", assistant_message, + update, + update_modified=True, + ) + frappe.db.commit() + + except requests.exceptions.ConnectionError as exc: + _fail(assistant_message, meta, "Ошибка сети", + f"Не удалось подключиться к n8n: {exc}") + except requests.exceptions.Timeout as exc: + _fail(assistant_message, meta, "Таймаут", + f"n8n не ответил вовремя: {exc}") + except requests.exceptions.HTTPError as exc: + body_preview = "" + try: + body_preview = exc.response.text[:300] if exc.response is not None else "" + except Exception: + pass + status_code = exc.response.status_code if exc.response is not None else "?" + _fail(assistant_message, meta, "Ошибка n8n", + f"HTTP {status_code}: {body_preview}") + except Exception as exc: + _fail(assistant_message, meta, "Ошибка", + f"{type(exc).__name__}: {exc}") + + +def _fail(assistant_message: str, meta: dict, + stage: str, message: str) -> None: + """Откат + запись ошибки в плейсхолдер. + + ВАЖНО: первым делом rollback — иначе следующий set_value / log_error + упадёт с InFailedSqlTransaction. + """ + try: + frappe.db.rollback() + except Exception: + pass + + _debug(meta, "error", "fail", f"{stage}: {message}", + traceback=frappe.get_traceback()[:2000]) + + try: + frappe.log_error( + title=f"Dyak Global Chat: {stage} ({assistant_message})", + message=frappe.get_traceback(), + ) + except Exception: + pass + + meta["error"] = {"stage": stage, "message": message} + try: + frappe.db.set_value( + "Dyak Chat Message", assistant_message, + { + "status": "Ошибка", + "error_message": f"{stage}: {message}", + "content": f"❌ **{stage}**: {message}", + "meta_json": json.dumps(meta, ensure_ascii=False, default=str), + }, + update_modified=True, + ) + frappe.db.commit() + except Exception: + try: + frappe.db.rollback() + except Exception: + pass + + + + +# ────────────────────────────────────────────────────────────────────────── +# Planner — LLM решает, что искать +# ────────────────────────────────────────────────────────────────────────── + +_PLANNER_PROMPT = """\ +Ты — планировщик поиска по корпусу записей встреч. Твоя задача: +прочитать вопрос пользователя и (опционально) недавнюю историю +диалога, и решить, нужно ли искать релевантные встречи. Если да — +сформулировать ключевые слова для поиска и опциональные фильтры. + +Если вопрос НЕ требует поиска по встречам (приветствие, общий вопрос +к ассистенту, уточнение про прошлый твой ответ, генеральные знания +вроде «что такое JSON Logic») — `needs_search: false`. + +Если вопрос требует встреч (типа «что мы обсуждали», «когда говорили +про X», «какие задачи на мне», «у кого выгорание» и т.п.) — +`needs_search: true` и заполни ключевые слова. + +Верни СТРОГО валидный JSON по схеме: + +{ + "needs_search": true, + "search_terms": ["ключевые", "слова", "для", "FTS"], + "date_from": "YYYY-MM-DD или null", + "date_to": "YYYY-MM-DD или null", + "project_filter": "название проекта или null", + "category_filter": "одна из категорий или null", + "reasoning": "одно предложение по-русски: почему именно так" +} + +Допустимые категории: Ежедневный стендап, Еженедельная встреча, +Sprint Review, Ретроспектива, Звонок с клиентом, Интервью, Другое. + +Правила: +- search_terms — не больше 6 слов, без стоп-слов вроде «что», «как», + «было». Только содержательные. +- Если в вопросе явно нет указания на проект — `project_filter: null`. +- ДАТЫ — ставь ОЧЕНЬ ОСТОРОЖНО. Только если в вопросе прямое явное + указание периода («встреча 12 мая», «за март», «за неделю с 5 по 12»). + Расплывчатые формулировки («недавно», «на прошлой неделе», «вчера») + → даты НЕ ставь, пусть `date_from: null, date_to: null`. Лучше показать + лишние встречи, чем пропустить нужную: пользователь увидит дату в + результатах и сам поймёт. Сегодня: {today}. +- Если ставишь дату — расширяй окно: «на прошлой неделе» = последние 14 + дней, не 7. Лучше пересечь, чем недостать. +- Если сомневаешься — лучше `needs_search: true` с широкими словами и + БЕЗ фильтров, чем узкий поиск с фильтрами. + +Недавняя история диалога (для контекста): +--- +{history} +--- + +Текущий вопрос пользователя: +--- +{question} +--- + +Верни ТОЛЬКО JSON, без markdown и пояснений.""" + + +def _run_planner(user_message: str, recent_history: list[dict], + model: str, meta: dict | None = None) -> dict: + """Запускает planner и парсит JSON-ответ. На любую ошибку парсинга — + fallback `needs_search=true` с user_message как единственным + search_term. + + Если передан `meta` — пишет туда подробные записи debug_log: + • promt_chars — длина итогового prompt; + • raw_response — сырой ответ модели (до парсинга); + • parse_error — если JSON не распарсился; + • fallback_used — если применили fallback. + """ + history_text = "\n".join( + f"[{m['role']}]: {m['content']}" for m in recent_history + ) or "(пусто)" + # ВАЖНО: используем .replace, а не .format — в промпте есть JSON-пример + # с фигурными скобками, .format() парсил бы их как placeholder'ы + # и падал с KeyError на `{\n "needs_search"`. + prompt = ( + _PLANNER_PROMPT + .replace("{today}", now_datetime().strftime("%Y-%m-%d")) + .replace("{history}", history_text) + .replace("{question}", user_message) + ) + + if meta is not None: + _debug(meta, "info", "planner_prompt_built", + f"prompt {len(prompt)} симв., history {len(recent_history)} сообщений") + + response = requests.post( + ENDPOINT_PLANNER, + json={"prompt": prompt, "model": model}, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + body = response.json() + content = _extract_content(body) + + if meta is not None: + _debug(meta, "info", "planner_raw_response", + f"HTTP {response.status_code}, ответ {len(content)} симв.", + preview=_clip(content, 500)) + + try: + plan = _extract_json(content) + if not isinstance(plan, dict): + raise ValueError("Не объект") + plan.setdefault("needs_search", True) + plan.setdefault("search_terms", [user_message[:80]]) + if not isinstance(plan["search_terms"], list): + plan["search_terms"] = [str(plan["search_terms"])] + for k in ("date_from", "date_to", "project_filter", + "category_filter"): + v = plan.get(k) + if v in ("", "null", None): + plan[k] = None + return plan + except Exception as exc: + if meta is not None: + _debug(meta, "warn", "planner_parse_error", + f"Не удалось распарсить JSON: {type(exc).__name__}: {exc}. " + f"Используем fallback с поиском по самому вопросу.", + raw=_clip(content, 300)) + return { + "needs_search": True, + "search_terms": [user_message[:80]], + "date_from": None, "date_to": None, + "project_filter": None, "category_filter": None, + "reasoning": "fallback: ответ planner не распарсился", + } + + +# ────────────────────────────────────────────────────────────────────────── +# Retrieval — PostgreSQL FTS +# ────────────────────────────────────────────────────────────────────────── + +def _retrieve_meetings(plan: dict, + include_dates: bool = True, + include_project: bool = True, + include_category: bool = True, + dictionary: str = "russian") -> tuple[list[dict], str, dict]: + """Делает FTS-запрос. Возвращает (rows, sql, params) — последние два + для debug-логирования. + + Параметры-флаги управляют тем, какие фильтры применять (для каскада + fallback-попыток). `dictionary` — pg-словарь: 'russian' (морфология) + или 'simple' (без морфологии, чисто токены). + """ + terms = plan.get("search_terms") or [] + if not terms: + return [], "", {} + query_str = " ".join(t for t in terms if t and isinstance(t, str)).strip() + if not query_str: + return [], "", {} + + where_extra = [] + params: dict[str, Any] = {"q": query_str, "limit": MAX_CANDIDATES} + + if include_dates and plan.get("date_from"): + where_extra.append("meeting_date >= %(date_from)s") + params["date_from"] = plan["date_from"] + if include_dates and plan.get("date_to"): + where_extra.append("meeting_date <= %(date_to)s") + params["date_to"] = plan["date_to"] + if include_project and plan.get("project_filter"): + where_extra.append("project ILIKE %(project)s") + params["project"] = f"%{plan['project_filter']}%" + if include_category and plan.get("category_filter"): + where_extra.append("category = %(category)s") + params["category"] = plan["category_filter"] + + extra_sql = ("AND " + " AND ".join(where_extra)) if where_extra else "" + + headline_opts = "MaxFragments=2, MinWords=4, MaxWords=18, StartSel=<<<, StopSel=>>>" + params["headline_opts"] = headline_opts + params["dict"] = dictionary + + sql_template = """ + SELECT + name, title, meeting_date, project, category, summary, + ts_rank("_search_tsv", plainto_tsquery(%(dict)s, %(q)s)) AS rank, + ts_headline( + %(dict)s, + coalesce(full_text, summary, ''), + plainto_tsquery(%(dict)s, %(q)s), + %(headline_opts)s + ) AS headlines + FROM "tabMeeting Record" + WHERE "_search_tsv" @@ plainto_tsquery(%(dict)s, %(q)s) + {EXTRA} + ORDER BY rank DESC + LIMIT %(limit)s + """ + sql = sql_template.replace("{EXTRA}", extra_sql) + try: + rows = frappe.db.sql(sql, params, as_dict=True) + except Exception as exc: + try: + frappe.db.rollback() + except Exception: + pass + try: + frappe.log_error( + title="Dyak FTS: SQL-ошибка", + message=frappe.get_traceback(), + ) + except Exception: + pass + raise RuntimeError( + f"FTS SQL failed: {type(exc).__name__}: {exc}" + ) from exc + + # Превращаем headlines в массив сниппетов. + for r in rows: + raw = r.get("headlines") or "" + snippets = [s.strip() for s in raw.split(" ... ") if s.strip()] + snippets = [s.replace("<<<", "**").replace(">>>", "**") for s in snippets] + r["headlines"] = snippets + + return rows, sql.strip(), params + + +def _retrieve_with_fallbacks(plan: dict, meta: dict) -> list[dict]: + """Каскад попыток retrieval, чтобы не возвращать 0 встреч там, где + данные есть, но planner перестарался с фильтрами или PG-словарь + не разобрал токены. + + Шаги (каждый пишется в debug_log): + 1. С полным набором фильтров planner'а (russian). + 2. Без date_from/date_to. + 3. Без любых фильтров кроме search_terms. + 4. То же, но словарь 'simple' (без морфологии — для аббревиатур). + 5. ILIKE fallback по title/project/summary — если FTS вообще не + попадает (например, когда искомый текст только в audio_file + или ещё не успел обновиться tsv-индекс). + """ + attempts = [ + {"label": "full filters", + "include_dates": True, "include_project": True, + "include_category": True, "dictionary": "russian"}, + {"label": "without dates", + "include_dates": False, "include_project": True, + "include_category": True, "dictionary": "russian"}, + {"label": "search terms only", + "include_dates": False, "include_project": False, + "include_category": False, "dictionary": "russian"}, + {"label": "simple dictionary", + "include_dates": False, "include_project": False, + "include_category": False, "dictionary": "simple"}, + ] + + for i, opts in enumerate(attempts, 1): + label = opts.pop("label") + try: + rows, sql, params = _retrieve_meetings(plan, **opts) + except Exception as exc: + _debug(meta, "warn", f"retrieval_attempt_{i}", + f"Попытка {i} ({label}) упала: " + f"{type(exc).__name__}: {exc}") + continue + + _debug(meta, "info", f"retrieval_attempt_{i}", + f"Попытка {i} ({label}): найдено {len(rows)}", + sql=_clip(sql, 600), + params={k: v for k, v in params.items() + if k != "headline_opts"}) + + if rows: + if i > 1: + _debug(meta, "warn", "retrieval_fallback_used", + f"Использован fallback-уровень {i} ({label}). " + f"Возможно, planner перестарался с фильтрами или " + f"PG-словарь 'russian' не разобрал часть токенов.") + return rows + + # Шаг 5: ILIKE fallback. + rows = _ilike_fallback(plan, meta) + if rows: + _debug(meta, "warn", "retrieval_ilike_fallback", + f"FTS не дал результатов; найдено {len(rows)} через ILIKE по " + f"title/project/summary. Это резервный путь — релевантность не " + f"гарантирована.") + return rows + + _debug(meta, "warn", "retrieval_empty", + "Все попытки исчерпаны, 0 встреч найдено.") + return [] + + +def _ilike_fallback(plan: dict, meta: dict) -> list[dict]: + """Последний резерв — простой ILIKE по нескольким текстовым полям. + Не использует FTS-индекс, работает медленно при больших таблицах, + но не зависит от качества словаря или существования tsv-колонки. + """ + terms = [t for t in (plan.get("search_terms") or []) + if t and isinstance(t, str)] + if not terms: + return [] + + # Сшиваем условия: каждое слово хотя бы в одном из полей. + conditions = [] + params: dict[str, Any] = {"limit": MAX_CANDIDATES} + for idx, term in enumerate(terms[:5]): # максимум 5 термов + key = f"t{idx}" + params[key] = f"%{term}%" + conditions.append( + f"(title ILIKE %({key})s OR project ILIKE %({key})s " + f"OR summary ILIKE %({key})s OR meeting_topics ILIKE %({key})s " + f"OR full_text ILIKE %({key})s)" + ) + + if not conditions: + return [] + + sql = f""" + SELECT name, title, meeting_date, project, category, summary + FROM "tabMeeting Record" + WHERE {' AND '.join(conditions)} + ORDER BY meeting_date DESC NULLS LAST + LIMIT %(limit)s + """ + try: + rows = frappe.db.sql(sql, params, as_dict=True) + except Exception as exc: + try: + frappe.db.rollback() + except Exception: + pass + _debug(meta, "error", "ilike_failed", + f"ILIKE fallback упал: {type(exc).__name__}: {exc}") + return [] + # Унифицируем форматы: у ILIKE нет rank и headlines. + for r in rows: + r["rank"] = 0.0 + r["headlines"] = [] + return rows + + +# ────────────────────────────────────────────────────────────────────────── +# Context assembly +# ────────────────────────────────────────────────────────────────────────── + +def _build_meetings_context(candidates: list[dict]) -> str: + """Превращает список кандидатов в текстовое описание для system prompt. + + Уровни детализации: + • top-1 (TOP_FULL): превью + полный транскрипт + результаты профилей + • top-2..(1+TOP_SNIPPETS): превью + сниппеты + • top-(1+TOP_SNIPPETS+1)..TOP_PREVIEW: только превью + """ + if not candidates: + return "(встречи не найдены)" + + chunks = [] + for i, c in enumerate(candidates[:TOP_PREVIEW]): + chunks.append(f"\n━━━ Встреча #{i+1} [{c['name']}] ━━━") + chunks.append(_format_preview(c)) + + if i < TOP_FULL: + chunks.append(_format_full_meeting(c["name"])) + elif i < TOP_FULL + TOP_SNIPPETS: + chunks.append(_format_snippets(c)) + # Остальные — только превью. + + return "\n".join(chunks) + + +def _format_preview(c: dict) -> str: + lines = [ + f"Название: {c.get('title') or '—'}", + f"Дата: {c.get('meeting_date') or '—'}", + f"Проект: {c.get('project') or '—'}", + f"Категория: {c.get('category') or '—'}", + ] + summary = (c.get("summary") or "").strip() + if summary: + lines.append(f"Резюме: {_strip_html(summary)[:500]}") + return "\n".join(lines) + + +def _format_snippets(c: dict) -> str: + snippets = c.get("headlines") or [] + if not snippets: + return "" + return "Выдержки:\n" + "\n".join(f" — {s}" for s in snippets) + + +def _format_full_meeting(meeting_name: str) -> str: + """Полный транскрипт + результаты профилей для топ-1 кандидата.""" + try: + doc = frappe.get_doc("Meeting Record", meeting_name) + except Exception: + return "" + + parts = [] + + # Транскрипт с маппингом спикеров. + transcript = _format_transcript(doc) + if transcript: + parts.append("\nПолная расшифровка:\n" + transcript) + + # Применённые профили. + results = frappe.get_all( + "Meeting Analysis Result", + filters={"meeting_record": meeting_name, "status": "Готово"}, + fields=["profile_name_snapshot", "result_json"], + order_by="creation desc", + limit_page_length=10, + ) + if results: + parts.append("\nРезультаты применённых профилей:") + for r in results: + summary_lines = _summarise_result_json(r.get("result_json")) + if summary_lines: + parts.append( + f" • {r.get('profile_name_snapshot') or '—'}:" + ) + for s in summary_lines: + parts.append(f" – {s}") + + return "\n".join(parts) + + +def _format_transcript(doc) -> str: + """То же, что у chat.py — расшифровка [Имя] (mm:ss–mm:ss): текст.""" + raw = doc.utterances_json + if raw: + try: + parsed = json.loads(raw) if isinstance(raw, str) else raw + utterances = parsed if isinstance(parsed, list) \ + else parsed.get("utterances") or [] + except Exception: + utterances = [] + if utterances: + name_by_speaker = { + p.speaker_id: p.participant_name + for p in (doc.participants or []) + if p.speaker_id and p.participant_name + } + lines = [] + for u in utterances: + spk = u.get("speaker") or "SPEAKER_??" + name = name_by_speaker.get(spk, spk) + start = _fmt_secs(u.get("start")) + end = _fmt_secs(u.get("end")) + text = (u.get("text") or "").strip() + lines.append(f"[{name}] ({start}–{end}): {text}") + return "\n".join(lines) + return (doc.full_text or "").strip() + + +def _summarise_result_json(raw: str | None) -> list[str]: + """Сворачивает result_json профиля в плоские строки. + То же, что в chat.py, но локальная копия — чтобы chat_global не + зависел от chat (модули независимы). + """ + if not raw: + return [] + try: + parsed = json.loads(raw) if isinstance(raw, str) else raw + except Exception: + return [] + if not isinstance(parsed, dict): + return [] + + out = [] + for key, value in parsed.items(): + if value is None or value == "" or value == []: + continue + if isinstance(value, str): + v = value.strip() + if v: + out.append(_clip(f"{key}: {v}", 200)) + elif isinstance(value, list): + if not value: + continue + if all(isinstance(x, str) for x in value): + shown = ", ".join(value[:5]) + tail = "…" if len(value) > 5 else "" + out.append(_clip(f"{key}: {shown}{tail}", 200)) + elif all(isinstance(x, dict) for x in value): + previews = [] + for obj in value[:2]: + fields = [ + f"{k}={v}" for k, v in obj.items() + if isinstance(v, (str, int, float)) + and str(v).strip() + ] + if fields: + previews.append("; ".join(fields)) + tail = f" (+ ещё {len(value) - 2})" if len(value) > 2 else "" + if previews: + out.append(_clip( + f"{key} ({len(value)}): " + + " | ".join(previews) + tail, 300, + )) + else: + out.append(f"{key}: {len(value)} запис(ей)") + elif isinstance(value, (int, float, bool)): + out.append(f"{key}: {value}") + return out + + +# ────────────────────────────────────────────────────────────────────────── +# Answerer — финальный LLM-запрос +# ────────────────────────────────────────────────────────────────────────── + +def _build_system_prompt(assistant_name: str, meetings_context: str, + had_search: bool, candidates_count: int) -> str: + if had_search: + intro_search = ( + "Ниже — список встреч, найденных в базе по твоему запросу " + "(отсортированы по релевантности). Используй ИХ как источник " + f"истины. Всего найдено: {candidates_count}, показано до " + f"{TOP_PREVIEW}. Для самой релевантной приведена полная " + "расшифровка и применённые профили." + ) + else: + intro_search = ( + "По текущему вопросу поиск по встречам не выполнялся. " + "Отвечай на основе истории диалога и общих знаний." + ) + + return f"""\ +Ты — {assistant_name}, AI-помощник в системе протоколов рабочих встреч. +Отвечай на русском языке, чётко и по делу. + +ПРАВИЛА: +- Опирайся ТОЛЬКО на приведённые ниже данные о встречах. Не выдумывай + факты, имена, проекты, даты, решения. +- Если данных недостаточно — честно скажи: «В найденных встречах об + этом нет» или «Уточни вопрос». +- Когда ссылаешься на конкретную встречу, обязательно используй её + идентификатор в квадратных скобках, например: [MR-2026-00042]. +- Если в нескольких встречах есть похожая информация — назови все + упомянутые встречи через их идентификаторы. +- Можешь использовать markdown: списки, **жирный текст**, `код`. + +{intro_search} + +═══════════════════════════════════════════════════════════ +НАЙДЕННЫЕ ВСТРЕЧИ +═══════════════════════════════════════════════════════════ +{meetings_context} +""" + + +def _call_answerer(system: str, messages: list[dict], model: str) -> str: + response = requests.post( + ENDPOINT_ANSWERER, + json={"system": system, "messages": messages, "model": model}, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + body = response.json() + content = _extract_content(body) + if not content: + raise ValueError("Пустой ответ модели (answerer)") + return content + + +def _trim_messages(messages: list[dict], limit: int) -> list[dict]: + """Soft-лимит на messages: отрезает старые, сохраняя последнее (свежий + вопрос пользователя).""" + total = sum(len(m["content"]) for m in messages) + if total <= limit: + return messages + if not messages: + return messages + + kept = [messages[-1]] + running = len(messages[-1]["content"]) + for m in reversed(messages[:-1]): + if running + len(m["content"]) > limit: + break + kept.append(m) + running += len(m["content"]) + kept.reverse() + return kept + + +# ────────────────────────────────────────────────────────────────────────── +# Postprocess — [MR-...] → ссылки +# ────────────────────────────────────────────────────────────────────────── + +_MR_PATTERN = re.compile(r"\[(MR-[A-Z0-9\-]+)\]") + + +def _postprocess_response(text: str, candidates: list[dict]) -> str: + """Заменяет упоминания [MR-2026-00042] на markdown-ссылки. + + Имя встречи в подписи берётся из кандидатов, если есть; иначе + fallback — просто [MR-...]. + """ + if not text: + return text + + by_name = {c["name"]: c for c in candidates} + + def replace(match: re.Match) -> str: + name = match.group(1) + c = by_name.get(name) + label = name + if c: + title = str(c.get("title") or "").strip() + # meeting_date в Frappe — Datetime. При SQL-возврате это + # Python datetime.datetime, а у него нет .strip(). Приводим + # к строке. + raw_date = c.get("meeting_date") + date_str = str(raw_date) if raw_date else "" + date_short = date_str.split(" ")[0] if date_str else "" + if title and date_short: + label = f"{title} · {date_short}" + elif title: + label = title + return f"[{label}](/app/meeting-record/{name})" + + return _MR_PATTERN.sub(replace, text) + + +# ────────────────────────────────────────────────────────────────────────── +# История чата +# ────────────────────────────────────────────────────────────────────────── + +def _load_history(session: str) -> list[dict]: + """Возвращает все сообщения сессии в хронологическом порядке. + + `_name` — служебное поле, чтобы можно было отфильтровать плейсхолдер + ассистент-сообщения по docname. + """ + rows = frappe.get_all( + "Dyak Chat Message", + filters={"session": session}, + fields=["name", "role", "content", "creation"], + order_by="creation asc", + limit_page_length=1000, + ) + return [ + {"_name": r.name, "role": r.role, "content": r.content or "", + "creation": r.creation} + for r in rows + ] + + +# ────────────────────────────────────────────────────────────────────────── +# Утилиты +# ────────────────────────────────────────────────────────────────────────── + +def _extract_content(body: Any) -> str: + if isinstance(body, list) and body: + first = body[0] + if isinstance(first, dict): + return (first.get("content") or "").strip() + if isinstance(body, dict): + return (body.get("content") or "").strip() + return "" + + +def _extract_json(text: str) -> Any: + """Снимаем code fences, ищем внешний JSON-блок.""" + text = (text or "").strip() + fence = re.search(r"```(?:json)?\s*(.+?)\s*```", text, re.DOTALL | re.IGNORECASE) + if fence: + text = fence.group(1).strip() + try: + return json.loads(text) + except json.JSONDecodeError: + pass + for opener, closer in [("{", "}"), ("[", "]")]: + start = text.find(opener) + end = text.rfind(closer) + if start != -1 and end != -1 and end > start: + try: + return json.loads(text[start:end + 1]) + except json.JSONDecodeError: + continue + raise ValueError(f"Не удалось распарсить JSON: {text[:300]}") + + +def _strip_html(html: str) -> str: + if not html: + return "" + text = re.sub(r"", "\n", html, flags=re.IGNORECASE) + text = re.sub(r"

", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", "", text) + return (text + .replace(" ", " ").replace("&", "&") + .replace("<", "<").replace(">", ">") + .replace(""", '"').replace("'", "'") + ).strip() + + +def _fmt_secs(s) -> str: + try: + s = int(float(s or 0)) + except Exception: + return "00:00" + return f"{s // 60:02d}:{s % 60:02d}" + + +def _clip(text: str, limit: int) -> str: + text = (text or "").strip() + if len(text) <= limit: + return text + return text[: limit - 1].rstrip() + "…" + + +def _make_title_from_message(content: str) -> str: + """Делает заголовок сессии из первого вопроса пользователя. + Берёт первые ~60 символов, обрезает по слову.""" + text = re.sub(r"\s+", " ", content).strip() + if len(text) <= 60: + return text + cut = text[:60] + last_space = cut.rfind(" ") + if last_space > 30: + cut = cut[:last_space] + return cut + "…" \ No newline at end of file diff --git a/dyak/api/v1/profiles.py b/dyak/api/v1/profiles.py new file mode 100644 index 0000000..3cd3d78 --- /dev/null +++ b/dyak/api/v1/profiles.py @@ -0,0 +1,556 @@ +""" +dyak.api.v1.profiles +──────────────────── +Применение Analysis Profile к Meeting Record. Создаёт Meeting Analysis +Result, отправляет промпт через тот же эндпоинт `/ask-analyze` (контракт +`{prompt, model}` → `[{content}]`), парсит ответ как JSON, рендерит +result_html по схеме профиля, при необходимости прокидывает значения +обратно в Meeting Record (`parent_field_mapping`). + +Поток: + apply_profiles(docname, profile_names) + → для каждого profile создаём Meeting Analysis Result (status=В очереди) + → enqueue _run_profile(result_name) + _run_profile(result_name) + → собираем prompt (analysis_prompt с {transcript} или авто из output_schema) + → POST на /ask-analyze + → парсим JSON (с защитой от markdown-обёртки) + → рендерим result_html + → прокидываем parent_field_mapping → Meeting Record + → status=Готово, completed_at=now +""" +from __future__ import annotations + +import json +import re +import time +from typing import Any + +import frappe +import requests +from frappe.utils import now_datetime + +from dyak.api.v1.transcribe import _publish_progress + + +# Используем тот же endpoint, что и старый «Анализ встречи». +N8N_BASE_URL = "http://192.168.1.112:5678/webhook" +ENDPOINT_ANALYZE = f"{N8N_BASE_URL}/ask-analyze" +REQUEST_TIMEOUT = (30, 600) + + +# ────────────────────────────────────────────────────────────────────────── +# Whitelisted endpoints +# ────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +def list_active_profiles() -> list[dict]: + """Список активных профилей для диалога выбора на форме.""" + return frappe.get_all( + "Analysis Profile", + filters={"enabled": 1}, + fields=["name", "profile_name", "description", "is_builtin"], + order_by="is_builtin desc, profile_name asc", + ) + + +@frappe.whitelist() +def apply_profiles(docname: str, profile_names) -> dict: + """Запустить применение N профилей к Meeting Record. + + `profile_names` — JSON-строка со списком (так Frappe передаёт массивы + в whitelist) или сам список. Каждое имя — `Analysis Profile.name`. + """ + if isinstance(profile_names, str): + try: + profile_names = json.loads(profile_names) + except json.JSONDecodeError: + profile_names = [profile_names] + + if not isinstance(profile_names, list) or not profile_names: + frappe.throw("Не выбран ни один профиль") + + doc = frappe.get_doc("Meeting Record", docname) + if not (doc.full_text or "").strip(): + frappe.throw("Нет текста транскрибации — сначала запустите расшифровку") + + user = frappe.session.user + created = [] + for profile_name in profile_names: + if not frappe.db.exists("Analysis Profile", profile_name): + continue + + # Создаём Meeting Analysis Result со снэпшотом имени. + result = frappe.get_doc({ + "doctype": "Meeting Analysis Result", + "meeting_record": docname, + "profile": profile_name, + "profile_name_snapshot": profile_name, + "status": "В очереди", + }) + result.insert(ignore_permissions=True) + created.append(result.name) + + frappe.enqueue( + "dyak.api.v1.profiles._run_profile", + queue="long", + timeout=900, + result_name=result.name, + user=user, + enqueue_after_commit=True, + ) + + frappe.db.commit() + return {"queued": len(created), "result_names": created} + + +# ────────────────────────────────────────────────────────────────────────── +# Фоновая задача +# ────────────────────────────────────────────────────────────────────────── + +def _run_profile(result_name: str, user: str | None = None) -> None: + """Применяет один профиль и заполняет Meeting Analysis Result.""" + started_at = time.time() + log_lines = [] + + def log(stage, percent, message): + ts = now_datetime().strftime("%H:%M:%S") + log_lines.append(f"[{ts}] {stage}: {message}") + # Пишем в processing_log результата. + frappe.db.set_value( + "Meeting Analysis Result", result_name, + "processing_log", "\n".join(log_lines), + update_modified=False, + ) + # А также репортим на родительскую встречу через старый канал. + meeting = frappe.db.get_value( + "Meeting Analysis Result", result_name, "meeting_record", + ) + if meeting: + _publish_progress( + meeting, f"Профиль · {stage}", percent, message, + user=user, + ) + + try: + result = frappe.get_doc("Meeting Analysis Result", result_name) + meeting_record = result.meeting_record + profile_name = result.profile + + profile = frappe.get_doc("Analysis Profile", profile_name) + meeting = frappe.get_doc("Meeting Record", meeting_record) + settings = frappe.get_single("Dyak Settings") + + model = (profile.model_override or settings.llm_model or "qwen2.5:32b").strip() + temperature = float(profile.temperature or 0.2) + + # Старт. + frappe.db.set_value( + "Meeting Analysis Result", result_name, + { + "status": "В обработке", + "started_at": now_datetime(), + "model_used": model, + "temperature_used": temperature, + }, + update_modified=True, + ) + frappe.db.commit() + + log("Подготовка", 10, f"Профиль «{profile_name}», модель: {model}") + + # Сборка промпта. + prompt = _build_profile_prompt(profile, meeting) + log("Подготовка", 20, f"Промпт {len(prompt)} симв.") + + # Отправка. + log("Отправка", 30, f"POST {ENDPOINT_ANALYZE}") + send_started = time.time() + response = requests.post( + ENDPOINT_ANALYZE, + json={"prompt": prompt, "model": model}, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + net_secs = time.time() - send_started + + body = response.json() + content = _extract_content(body) + if not content: + raise ValueError("Пустой ответ модели") + + log("Получен ответ", 60, + f"HTTP {response.status_code}, {len(content)} симв. за {net_secs:.1f} сек") + + # Парсинг. + parsed = _extract_json(content) + log("Парсинг", 75, + f"Получен {'объект' if isinstance(parsed, dict) else 'массив'}") + + # Запись результата. result_html не сохраняем — он рендерится + # клиентским скриптом из result_json и output_schema профиля. + frappe.db.set_value( + "Meeting Analysis Result", result_name, + { + "result_json": json.dumps(parsed, ensure_ascii=False), + "status": "Готово", + "completed_at": now_datetime(), + }, + update_modified=True, + ) + frappe.db.commit() + + # Прокидывание parent_field_mapping. + propagated = _propagate_to_parent(profile, meeting_record, parsed) + if propagated: + log("Прокидывание", 95, + f"Записано полей в Meeting Record: {', '.join(propagated)}") + + total = time.time() - started_at + log("Готово", 100, f"Профиль применён за {total:.1f} сек") + + except requests.exceptions.ConnectionError as exc: + _profile_failure(result_name, user, log_lines, + "Ошибка сети", f"Не удалось подключиться к n8n: {exc}") + except requests.exceptions.Timeout as exc: + _profile_failure(result_name, user, log_lines, + "Таймаут", f"n8n не ответил вовремя: {exc}") + except requests.exceptions.HTTPError as exc: + body_preview = "" + try: + body_preview = exc.response.text[:300] if exc.response is not None else "" + except Exception: + pass + status_code = exc.response.status_code if exc.response is not None else "?" + _profile_failure(result_name, user, log_lines, + "Ошибка n8n", f"HTTP {status_code}: {body_preview}") + except ValueError as exc: + _profile_failure(result_name, user, log_lines, + "Ошибка парсинга", str(exc)) + except Exception as exc: + _profile_failure(result_name, user, log_lines, + "Ошибка", f"{type(exc).__name__}: {exc}") + + +def _profile_failure(result_name: str, user: str | None, + log_lines: list[str], stage: str, message: str) -> None: + """Фиксирует неуспех. + + ВАЖНО: до любых дальнейших операций делаем `frappe.db.rollback()`. + Если исходная ошибка случилась в рамках открытой транзакции + (типичный случай для PostgreSQL — `InFailedSqlTransaction`), все + последующие SQL будут отвергаться сервером, пока транзакция не + закрыта. Откат гарантирует, что log_error и запись статуса смогут + выполниться. + """ + try: + frappe.db.rollback() + except Exception: + pass + + try: + frappe.log_error( + title=f"Dyak Profile: {stage} ({result_name})", + message=frappe.get_traceback(), + ) + except Exception: + # Даже log_error может упасть на сломанной БД — не валим всё. + pass + + ts = now_datetime().strftime("%H:%M:%S") + log_lines.append(f"[{ts}] {stage} ❌: {message}") + + try: + frappe.db.set_value( + "Meeting Analysis Result", result_name, + { + "status": "Ошибка", + "error_message": f"{stage}: {message}", + "completed_at": now_datetime(), + "processing_log": "\n".join(log_lines), + }, + update_modified=True, + ) + frappe.db.commit() + except Exception: + # Если и это не вышло — последний рубеж: rollback и просто прекращаем. + try: + frappe.db.rollback() + except Exception: + pass + + # Сообщаем форме встречи. + try: + meeting = frappe.db.get_value( + "Meeting Analysis Result", result_name, "meeting_record", + ) + if meeting: + _publish_progress( + meeting, f"Профиль · {stage}", 0, message, + user=user, is_error=True, + ) + except Exception: + pass + + +# ────────────────────────────────────────────────────────────────────────── +# Сборка промпта +# ────────────────────────────────────────────────────────────────────────── + +def _build_profile_prompt(profile, meeting) -> str: + """Готовит промпт для модели. + + Структура итогового промпта: + + + + Схема JSON-ответа: + ```json + { ...пример из output_schema... } + ``` + + <строгое требование вернуть JSON> + + Если `analysis_prompt` пустой — берём авто-промпт из схемы (он уже + включает пример). Если задан — добавляем пример схемы отдельным + блоком перед суффиксом, чтобы модель всегда видела формальный + шаблон ответа, а не только текстовое описание целей. + + Транскрипт ВСЕГДА подставляется в текст пользовательского промпта, + не отправляется как `system` — это безопаснее (пользовательский + промпт не должен подменять системные инструкции). + """ + transcript = _format_transcript(meeting) + user_prompt = (profile.analysis_prompt or "").strip() + schema = _safe_json_load(profile.output_schema) + + if not user_prompt: + # Авто-генерация из схемы (она уже включает JSON-пример). + user_prompt = _auto_prompt_from_schema(schema) + schema_block = "" # уже в user_prompt + else: + # Кастомный промпт — добавим JSON-пример отдельно, если есть схема. + if isinstance(schema, dict) and schema.get("sections"): + example = json.dumps( + _schema_to_example(schema), ensure_ascii=False, indent=2, + ) + schema_block = ( + "\n\nСхема JSON-ответа:\n```json\n" + + example + + "\n```" + ) + else: + schema_block = "" + + # Подставляем расшифровку. + if "{transcript}" in user_prompt: + body = user_prompt.replace("{transcript}", transcript) + else: + body = ( + user_prompt + + "\n\nРасшифровка встречи:\n---\n" + + transcript + + "\n---" + ) + + # Жёсткое требование вернуть JSON — всегда в конце. + suffix = ( + "\n\nВажно: верни СТРОГО валидный JSON по схеме выше, без " + "markdown-обёртки, без пояснений до или после. Не выдумывай факты — " + "если данных в расшифровке нет, используй \"\" или []." + ) + return body + schema_block + suffix + + +def _auto_prompt_from_schema(schema: dict | None) -> str: + """Автогенерация промпта, если пользователь не задал свой.""" + if not isinstance(schema, dict) or not schema.get("sections"): + return ( + "Проанализируй расшифровку встречи и верни JSON-объект " + "со структурированным анализом." + ) + + lines = [ + "Проанализируй расшифровку встречи и верни JSON по схеме ниже.", + "", + "Схема ответа:", + "```", + json.dumps(_schema_to_example(schema), ensure_ascii=False, indent=2), + "```", + ] + return "\n".join(lines) + + +def _schema_to_example(schema: dict) -> dict: + """Конвертирует пользовательскую output_schema в пример JSON-объекта, + который покажем модели как образец структуры. + """ + out = {} + for section in schema.get("sections") or []: + for f in section.get("fields") or []: + key = f.get("key") + if not key: + continue + out[key] = _example_for_type(f) + return out + + +def _example_for_type(field: dict): + t = (field.get("type") or "text").lower() + if t == "text": + return "" + if t == "long_text": + return "" + if t == "select": + opts = field.get("options") or [] + return "|".join(str(o) for o in opts) if opts else "" + if t == "list_of_strings": + return [] + if t == "list_of_objects": + item = {} + for sub in field.get("item_schema") or []: + item[sub.get("key", "")] = "" + return [item] if item else [] + if t == "number": + return 0 + if t == "date": + return "" + return "" + + +def _format_transcript(meeting) -> str: + """Расшифровка в формате `[Имя] (mm:ss–mm:ss): текст`.""" + raw = meeting.utterances_json + if raw: + try: + parsed = json.loads(raw) if isinstance(raw, str) else raw + utterances = parsed if isinstance(parsed, list) \ + else parsed.get("utterances") or [] + except Exception: + utterances = [] + if utterances: + name_by_speaker = { + p.speaker_id: p.participant_name + for p in (meeting.participants or []) + if p.speaker_id and p.participant_name + } + lines = [] + for u in utterances: + spk = u.get("speaker") or "SPEAKER_??" + name = name_by_speaker.get(spk, spk) + start = _fmt_secs(u.get("start")) + end = _fmt_secs(u.get("end")) + text = (u.get("text") or "").strip() + lines.append(f"[{name}] ({start}–{end}): {text}") + return "\n".join(lines) + return (meeting.full_text or "").strip() or "(расшифровка отсутствует)" + + +def _fmt_secs(s) -> str: + try: + s = int(float(s or 0)) + except Exception: + return "00:00" + return f"{s // 60:02d}:{s % 60:02d}" + + +# ────────────────────────────────────────────────────────────────────────── +# Парсинг ответа +# ────────────────────────────────────────────────────────────────────────── + +def _extract_content(body: Any) -> str: + if isinstance(body, list) and body: + first = body[0] + if isinstance(first, dict): + return (first.get("content") or "").strip() + if isinstance(body, dict): + return (body.get("content") or "").strip() + return "" + + +def _extract_json(text: str) -> Any: + """Тот же парсер, что в ai.py — снимаем code fences, ищем внешний блок.""" + text = (text or "").strip() + fence = re.search(r"```(?:json)?\s*(.+?)\s*```", text, re.DOTALL | re.IGNORECASE) + if fence: + text = fence.group(1).strip() + try: + return json.loads(text) + except json.JSONDecodeError: + pass + for opener, closer in [("{", "}"), ("[", "]")]: + start = text.find(opener) + end = text.rfind(closer) + if start != -1 and end != -1 and end > start: + try: + return json.loads(text[start:end + 1]) + except json.JSONDecodeError: + continue + raise ValueError(f"Не удалось распарсить JSON: {text[:300]}") + + +def _safe_json_load(value) -> dict | None: + if not value: + return None + if isinstance(value, dict): + return value + try: + return json.loads(value) if isinstance(value, str) else None + except Exception: + return None + + + +def _is_empty(value) -> bool: + if value is None: + return True + if isinstance(value, str) and not value.strip(): + return True + if isinstance(value, (list, dict)) and not value: + return True + return False + + +# ────────────────────────────────────────────────────────────────────────── +# parent_field_mapping +# ────────────────────────────────────────────────────────────────────────── + +def _propagate_to_parent(profile, meeting_record: str, parsed: Any) -> list[str]: + """Если у профиля задан `parent_field_mapping`, копирует значения из + parsed в указанные поля Meeting Record. + + Возвращает список имён полей, которые были обновлены. + """ + mapping = _safe_json_load(profile.parent_field_mapping) + if not isinstance(mapping, dict) or not mapping: + return [] + if not isinstance(parsed, dict): + return [] + + # Известные fieldnames Meeting Record, в которые разрешено писать. + # Whitelist на случай, если в mapping окажется что-то вредное. + allowed = {"summary", "meeting_topics", "meeting_mood"} + + updates = {} + for parent_field, json_key in mapping.items(): + if parent_field not in allowed: + continue + if not json_key or json_key not in parsed: + continue + value = parsed[json_key] + if _is_empty(value): + continue + # Списки → строка через запятую. + if isinstance(value, list): + value = ", ".join(str(v) for v in value if v) + updates[parent_field] = str(value) + + if not updates: + return [] + + frappe.db.set_value( + "Meeting Record", meeting_record, updates, + update_modified=True, + ) + frappe.db.commit() + return list(updates.keys()) \ No newline at end of file diff --git a/dyak/api/v1/sandbox.py b/dyak/api/v1/sandbox.py new file mode 100644 index 0000000..cca0a14 --- /dev/null +++ b/dyak/api/v1/sandbox.py @@ -0,0 +1,9 @@ +import frappe +from dyak.utils.api import jsonify_kwargs + +@frappe.whitelist() +def button(**kwargs): + """Песочница""" + kwargs = jsonify_kwargs(kwargs) + + return kwargs \ No newline at end of file diff --git a/dyak/api/v1/transcribe.py b/dyak/api/v1/transcribe.py new file mode 100644 index 0000000..8d27246 --- /dev/null +++ b/dyak/api/v1/transcribe.py @@ -0,0 +1,416 @@ +""" +dyak.api.v1.transcribe +────────────────────── +Транскрибация Meeting Record через внешний микросервис с прогресс-репортингом +и автоматическим заполнением списка участников по обнаруженным спикерам. + +Поток: + 1. `transcribe(docname)` — whitelisted endpoint, помечает запись + как «В обработке», очищает прежний лог, ставит фоновую задачу. + 2. `_run_transcription(docname, user)` — фон (queue=long), идёт по этапам + и на каждом шлёт realtime-событие `dyak_progress` (через + `frappe.publish_realtime`) и дописывает в `processing_log` строку с + меткой времени. После получения payload — автозаполняет таблицу + `participants` по уникальным спикерам диаризации. + +Этапы: + 5/10% Подготовка — чтение настроек, проверка файла + 25% Отправка — POST на микросервис (тело уже в сети) + 60% Ожидание — модель работает, ждём ответа + 80% Получен ответ — сервис вернул payload + 90/92% Сохранение — пишем в БД, добавляем участников + 100% Готово — финальное событие + +При ошибке: статус → «Черновик», публикуется событие с этапом «Ошибка». +""" +from __future__ import annotations + +import json +import os +import time +from typing import Any + +import frappe +import requests +from frappe.utils import cstr, now_datetime + + +# ────────────────────────────────────────────────────────────────────────── +# Прогресс-репортинг +# ────────────────────────────────────────────────────────────────────────── + +def _publish_progress( + docname: str, + stage: str, + percent: int, + message: str, + user: str | None = None, + is_error: bool = False, +) -> None: + """Шлёт realtime-событие и дописывает строку в processing_log. + + Realtime-событие имени `dyak_progress` приходит на frontend с payload + `{docname, stage, percent, message, is_error, ts}`. Frontend + отфильтровывает по docname. + + Параллельно делаем `frappe.db.set_value` на поля `processing_stage` и + `processing_log` — чтобы при перезагрузке формы пользователь видел + последний известный этап и историю. + """ + ts = now_datetime().strftime("%H:%M:%S") + log_line = f"[{ts}] {stage}{' ❌' if is_error else ''}: {message}" + + # Дописываем в processing_log (read из БД → append → write). + try: + prev_log = frappe.db.get_value("Meeting Record", docname, "processing_log") or "" + new_log = (prev_log + "\n" + log_line).strip() + frappe.db.set_value( + "Meeting Record", + docname, + {"processing_stage": stage, "processing_log": new_log}, + update_modified=False, + ) + frappe.db.commit() + except Exception: + # Лог-репортинг не должен валить основной процесс. + frappe.log_error( + title=f"Dyak: не удалось записать processing_log для {docname}", + message=frappe.get_traceback(), + ) + + # Realtime-событие. + payload = { + "docname": docname, + "stage": stage, + "percent": percent, + "message": message, + "is_error": is_error, + "ts": ts, + } + try: + if user: + frappe.publish_realtime( + event="dyak_progress", + message=payload, + user=user, + ) + else: + frappe.publish_realtime(event="dyak_progress", message=payload) + except Exception: + # Realtime может быть недоступен (нет socketio); не валимся. + pass + + +# ────────────────────────────────────────────────────────────────────────── +# Whitelisted endpoint +# ────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +def transcribe(docname: str) -> dict: + """Поставить запись в очередь на транскрибацию. + + Возвращает: {"queued": True, "docname": ...} + """ + doc = frappe.get_doc("Meeting Record", docname) + + if not doc.audio_file: + frappe.throw("Не загружен аудиофайл") + + if doc.status == "В обработке": + frappe.throw("Запись уже находится в обработке") + + # Очищаем прежний лог и ставим стартовый этап. + frappe.db.set_value( + "Meeting Record", + docname, + { + "status": "В обработке", + "processing_stage": "В очереди", + "processing_log": "", + }, + update_modified=True, + ) + frappe.db.commit() + + user = frappe.session.user + + frappe.enqueue( + "dyak.api.v1.transcribe._run_transcription", + queue="long", + timeout=3600, + docname=docname, + user=user, + enqueue_after_commit=True, + ) + + return {"queued": True, "docname": docname} + + +@frappe.whitelist() +def get_progress(docname: str) -> dict: + """Fallback для случаев, когда realtime недоступен — фронт может тянуть + текущий этап и лог обычным polling'ом. + """ + row = frappe.db.get_value( + "Meeting Record", docname, + ["status", "processing_stage", "processing_log"], + as_dict=True, + ) or {} + return row + + +# ────────────────────────────────────────────────────────────────────────── +# Фоновая задача +# ────────────────────────────────────────────────────────────────────────── + +def _run_transcription(docname: str, user: str | None = None) -> None: + """Фоновая задача: выполняет HTTP-запрос к микросервису и обновляет документ. + + Все ключевые этапы публикуются через `_publish_progress`. + """ + started_at = time.time() + try: + _publish_progress( + docname, "Подготовка", 5, + "Запуск задачи, чтение настроек", user=user, + ) + + doc = frappe.get_doc("Meeting Record", docname) + settings = frappe.get_single("Dyak Settings") + + service_url = (settings.transcription_service_url or "").rstrip("/") + if not service_url: + raise frappe.ValidationError("Не задан URL сервиса в Dyak Settings") + + endpoint = f"{service_url}/process" + file_path = _resolve_audio_file_path(doc.audio_file) + if not os.path.exists(file_path): + raise FileNotFoundError(f"Аудиофайл не найден на диске: {file_path}") + + file_size_mb = os.path.getsize(file_path) / (1024 * 1024) + _publish_progress( + docname, "Подготовка", 10, + f"Файл найден: {os.path.basename(file_path)} ({file_size_mb:.1f} МБ)", + user=user, + ) + + # Параметры формы. + data: dict[str, Any] = {} + if settings.default_language: + data["language"] = settings.default_language + if settings.default_initial_prompt: + data["initial_prompt"] = settings.default_initial_prompt + if settings.default_num_speakers and int(settings.default_num_speakers) > 0: + data["num_speakers"] = int(settings.default_num_speakers) + if settings.default_model: + data["model"] = settings.default_model + + params_summary = ", ".join(f"{k}={v}" for k, v in data.items()) or "по умолчанию" + _publish_progress( + docname, "Отправка", 25, + f"Отправка на {service_url} ({params_summary})", user=user, + ) + + send_started = time.time() + with open(file_path, "rb") as fh: + files = {"file": (os.path.basename(file_path), fh)} + _publish_progress( + docname, "Ожидание", 60, + "Сервис обрабатывает аудио (whisper + pyannote)…", user=user, + ) + response = requests.post( + endpoint, data=data, files=files, + timeout=(30, 3600), + ) + + response.raise_for_status() + network_secs = time.time() - send_started + _publish_progress( + docname, "Получен ответ", 80, + f"HTTP {response.status_code}, {len(response.content) / 1024:.1f} КБ " + f"за {network_secs:.1f} сек", + user=user, + ) + + payload = response.json() + utterances = payload.get("utterances") or [] + full_text = "\n".join(u.get("text", "") for u in utterances).strip() + + _publish_progress( + docname, "Сохранение", 90, + f"Получено {len(utterances)} реплик, " + f"{payload.get('num_speakers') or '?'} спикер(ов), " + f"длительность {payload.get('duration') or 0:.1f} сек", + user=user, + ) + + # Автозаполнение участников по уникальным спикерам диаризации. + # Не перезаписывает уже привязанных вручную: если строка с таким + # `speaker_id` уже есть, новая не добавляется. Имя-плейсхолдер + # совпадает со speaker_id — пользователю удобно переименовать ФИО, + # маппинг при этом сохраняется автоматически. + added = _autofill_participants(docname, utterances) + if added: + _publish_progress( + docname, "Сохранение", 92, + f"Добавлено участников: {added} (по числу спикеров)", + user=user, + ) + + # Финальная атомарная запись результата + смена статуса. + frappe.db.set_value( + "Meeting Record", + docname, + { + "utterances_json": json.dumps(payload, ensure_ascii=False), + "full_text": full_text, + "audio_duration": payload.get("duration") or 0, + "detected_language": payload.get("language") or "", + "num_speakers": payload.get("num_speakers") or 0, + "processing_time": payload.get("processing_time") or 0, + "status": "Расшифровано", + "processing_stage": "Готово", + }, + update_modified=True, + ) + frappe.db.commit() + + total_secs = time.time() - started_at + _publish_progress( + docname, "Готово", 100, + f"Транскрибация завершена за {total_secs:.1f} сек", + user=user, + ) + + except requests.exceptions.ConnectionError as exc: + _handle_failure( + docname, user, + stage="Ошибка сети", + message=f"Не удалось подключиться к сервису: {exc}", + ) + except requests.exceptions.Timeout as exc: + _handle_failure( + docname, user, + stage="Таймаут", + message=f"Сервис не ответил вовремя: {exc}", + ) + except requests.exceptions.HTTPError as exc: + body_preview = "" + try: + body_preview = exc.response.text[:500] if exc.response is not None else "" + except Exception: + pass + status_code = exc.response.status_code if exc.response is not None else "?" + _handle_failure( + docname, user, + stage="Ошибка сервиса", + message=f"HTTP {status_code}: {body_preview}", + ) + except FileNotFoundError as exc: + _handle_failure(docname, user, stage="Файл не найден", message=str(exc)) + except Exception as exc: + _handle_failure( + docname, user, + stage="Ошибка", + message=f"{type(exc).__name__}: {exc}", + ) + + +def _handle_failure(docname: str, user: str | None, stage: str, message: str) -> None: + """Единая точка обработки неудач: лог, realtime, сброс статуса.""" + frappe.log_error( + title=f"Dyak: {stage} для {docname}", + message=frappe.get_traceback(), + ) + _publish_progress(docname, stage, 0, message, user=user, is_error=True) + try: + frappe.db.set_value( + "Meeting Record", docname, + {"status": "Черновик", "processing_stage": f"Ошибка: {stage}"}, + update_modified=True, + ) + frappe.db.commit() + except Exception: + frappe.log_error( + title=f"Dyak: не удалось сбросить статус {docname}", + message=frappe.get_traceback(), + ) + + +# ────────────────────────────────────────────────────────────────────────── +# Утилиты +# ────────────────────────────────────────────────────────────────────────── + +def _autofill_participants(docname: str, utterances: list[dict]) -> int: + """Добавляет в Meeting Record.participants строки по уникальным спикерам. + + Правила: + * Берутся реальные `speaker` из `utterances` (не `num_speakers`), + т.к. их количество может расходиться. + * Сортируем стабильно — `SPEAKER_00, SPEAKER_01, ...`. + * Не перезаписываем уже привязанных: если строка с таким `speaker_id` + существует — пропускаем. + * `participant_name` инициализируем тем же значением, что и + `speaker_id`, чтобы пользователю было очевидно, какую роль он + переименовывает; маппинг при этом не теряется. + + Возвращает число добавленных строк. + """ + unique_speakers = sorted({ + u.get("speaker") for u in utterances + if u.get("speaker") + }) + if not unique_speakers: + return 0 + + doc = frappe.get_doc("Meeting Record", docname) + existing_speaker_ids = { + (p.speaker_id or "").strip() + for p in (doc.participants or []) + if (p.speaker_id or "").strip() + } + + added = 0 + for spk in unique_speakers: + if spk in existing_speaker_ids: + continue + doc.append("participants", { + "participant_name": spk, + "speaker_id": spk, + "role": "Участник", + }) + added += 1 + + if added: + doc.save(ignore_permissions=True) + frappe.db.commit() + + return added + + +def _resolve_audio_file_path(file_url: str) -> str: + """Преобразует frappe-URL вложения в абсолютный путь на диске. + + Стратегия: + 1. Сначала пытаемся найти запись в doctype File — она знает, + private или public, и умеет отдать полный путь. + 2. Иначе разбираем сам URL (`/files/...` → public, `/private/files/...` + → private). + """ + file_url = cstr(file_url).strip() + + # 1. Через File doctype (надёжный путь). + file_doc_name = frappe.db.get_value("File", {"file_url": file_url}, "name") + if file_doc_name: + file_doc = frappe.get_doc("File", file_doc_name) + return file_doc.get_full_path() + + # 2. Fallback по префиксу URL. + site_path = frappe.get_site_path() + if file_url.startswith("/files/"): + return os.path.join(site_path, "public", file_url.lstrip("/")) + if file_url.startswith("/private/files/"): + return os.path.join(site_path, file_url.lstrip("/")) + + # 3. Последняя попытка — public-файл по относительному имени. + return os.path.join(site_path, "public", "files", os.path.basename(file_url)) \ No newline at end of file diff --git a/dyak/dyak/doctype/__init__.py b/dyak/dyak/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dyak/dyak/doctype/analysis_profile/analysis_profile.js b/dyak/dyak/doctype/analysis_profile/analysis_profile.js new file mode 100644 index 0000000..52e3b0d --- /dev/null +++ b/dyak/dyak/doctype/analysis_profile/analysis_profile.js @@ -0,0 +1,74 @@ +/** + * Analysis Profile — клиентские мелочи: + * • Кнопка «Вставить пример схемы» (если поле пустое). + * • Запрет удалять встроенные профили (контролируется и на сервере). + */ +frappe.ui.form.on("Analysis Profile", { + refresh(frm) { + if (frm.doc.is_builtin) { + frm.dashboard.add_indicator( + "Встроенный профиль — удаление запрещено", "blue", + ); + } + + if (!frm.is_new()) { + frm.add_custom_button("Вставить пример схемы", () => { + if (frm.doc.output_schema) { + frappe.confirm( + "Поле схемы уже заполнено. Перезаписать примером?", + () => set_example_schema(frm), + ); + } else { + set_example_schema(frm); + } + }); + } + }, +}); + +function set_example_schema(frm) { + const example = { + sections: [ + { + title: "Настроение", + fields: [ + { + key: "mood", + label: "Настроение участника", + type: "select", + options: ["Позитивное", "Нейтральное", "Тревожное", "Выгорание"], + }, + { + key: "mood_evidence", + label: "Признаки", + type: "long_text", + }, + ], + }, + { + title: "Цели и блокеры", + fields: [ + { + key: "career_goals", + label: "Карьерные цели", + type: "list_of_strings", + }, + { + key: "blockers", + label: "Блокеры роста", + type: "list_of_objects", + item_schema: [ + { key: "description", label: "Что мешает" }, + { key: "owner", label: "Кто решает" }, + ], + }, + ], + }, + ], + }; + frm.set_value("output_schema", JSON.stringify(example, null, 2)); + frappe.show_alert({ + message: "Пример схемы вставлен. Отредактируйте под свой профиль.", + indicator: "blue", + }); +} diff --git a/dyak/dyak/doctype/analysis_profile/analysis_profile.json b/dyak/dyak/doctype/analysis_profile/analysis_profile.json new file mode 100644 index 0000000..fa6f062 --- /dev/null +++ b/dyak/dyak/doctype/analysis_profile/analysis_profile.json @@ -0,0 +1,163 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:profile_name", + "creation": "2026-01-01 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "main_section", + "profile_name", + "description", + "column_break_main", + "enabled", + "is_builtin", + "schema_section", + "output_schema", + "parent_field_mapping", + "prompt_section", + "analysis_prompt", + "model_section", + "model_override", + "column_break_model", + "temperature" + ], + "fields": [ + { + "fieldname": "main_section", + "fieldtype": "Section Break", + "label": "Основное" + }, + { + "fieldname": "profile_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Название профиля", + "description": "Уникальное имя. Пример: «HR 1:1», «Sprint Review», «Постановка задач»", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Описание", + "description": "Когда применять этот профиль. Видно пользователям при выборе" + }, + { + "fieldname": "column_break_main", + "fieldtype": "Column Break" + }, + { + "fieldname": "enabled", + "fieldtype": "Check", + "default": "1", + "in_list_view": 1, + "label": "Активен", + "description": "Если выключен — профиль не показывается в списке для применения" + }, + { + "fieldname": "is_builtin", + "fieldtype": "Check", + "default": "0", + "read_only": 1, + "in_list_view": 1, + "label": "Встроенный", + "description": "Встроенные профили создаются миграцией приложения и не могут быть удалены" + }, + { + "fieldname": "schema_section", + "fieldtype": "Section Break", + "label": "Схема результата" + }, + { + "fieldname": "output_schema", + "fieldtype": "JSON", + "label": "Схема ответа модели", + "description": "JSON-схема секций и полей. Используется для рендера результата на форме встречи. См. документацию приложения для формата (sections → fields с типами text/long_text/select/list_of_strings/list_of_objects)" + }, + { + "fieldname": "parent_field_mapping", + "fieldtype": "JSON", + "label": "Прокидывание в Meeting Record", + "description": "Опционально. Какие ключи результата записывать в поля Meeting Record. Формат: {\"meeting_topics\": \"topics\", \"meeting_mood\": \"mood\"}. Пусто — ничего не прокидывается" + }, + { + "fieldname": "prompt_section", + "fieldtype": "Section Break", + "label": "Промпт" + }, + { + "fieldname": "analysis_prompt", + "fieldtype": "Long Text", + "label": "Промпт для модели", + "description": "Текст промпта. Если оставить пустым — промпт сгенерируется автоматически из схемы. Можно использовать плейсхолдер {transcript} — на его место подставится расшифровка встречи. Если плейсхолдера нет, расшифровка добавляется в конце" + }, + { + "fieldname": "model_section", + "fieldtype": "Section Break", + "label": "Параметры модели" + }, + { + "fieldname": "model_override", + "fieldtype": "Data", + "label": "Модель (переопределение)", + "description": "Имя модели Ollama. Если пусто — используется значение из Dyak Settings. Полезно, если для этого профиля нужна более точная или быстрая модель" + }, + { + "fieldname": "column_break_model", + "fieldtype": "Column Break" + }, + { + "fieldname": "temperature", + "fieldtype": "Float", + "default": "0.2", + "label": "Температура", + "precision": "2", + "description": "Креативность модели. 0.0–0.3 — для извлечения структурированных данных, 0.5–0.8 — для пересказов и аналитики" + } + ], + "links": [ + { + "group": "Применения", + "link_doctype": "Meeting Analysis Result", + "link_fieldname": "profile" + } + ], + "modified": "2026-01-01 00:00:00", + "modified_by": "Administrator", + "module": "Dyak", + "name": "Analysis Profile", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 0, + "delete": 0, + "email": 0, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Dyak User", + "share": 0, + "write": 0 + } + ], + "search_fields": "profile_name,description", + "sort_field": "profile_name", + "sort_order": "ASC", + "title_field": "profile_name", + "track_changes": 1 +} diff --git a/dyak/dyak/doctype/analysis_profile/analysis_profile.py b/dyak/dyak/doctype/analysis_profile/analysis_profile.py new file mode 100644 index 0000000..d387dfa --- /dev/null +++ b/dyak/dyak/doctype/analysis_profile/analysis_profile.py @@ -0,0 +1,13 @@ +import frappe +from frappe.model.document import Document + + +class AnalysisProfile(Document): + """Профиль анализа: шаблон для применения LLM-анализа к встречам.""" + + def on_trash(self): + if self.is_builtin: + frappe.throw( + "Встроенный профиль нельзя удалить. " + "Чтобы скрыть его — снимите галочку «Активен»." + ) diff --git a/dyak/dyak/doctype/dyak_chat_message/__init__.py b/dyak/dyak/doctype/dyak_chat_message/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dyak/dyak/doctype/dyak_chat_message/dyak_chat_message.json b/dyak/dyak/doctype/dyak_chat_message/dyak_chat_message.json new file mode 100644 index 0000000..4dedbe5 --- /dev/null +++ b/dyak/dyak/doctype/dyak_chat_message/dyak_chat_message.json @@ -0,0 +1,118 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "DCM-.YYYY.-.######", + "creation": "2026-01-01 00:00:00", + "doctype": "DocType", + "editable_grid": 0, + "engine": "InnoDB", + "field_order": [ + "session", + "role", + "status", + "column_break_1", + "model_used", + "created_at", + "content_section", + "content", + "meta_section", + "meta_json", + "error_message" + ], + "fields": [ + { + "fieldname": "session", + "fieldtype": "Link", + "options": "Dyak Chat Session", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Диалог", + "reqd": 1 + }, + { + "fieldname": "role", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Роль", + "options": "user\nassistant\nsystem", + "reqd": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "default": "Готово", + "label": "Статус", + "options": "В обработке\nГотово\nОшибка" + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "model_used", + "fieldtype": "Data", + "read_only": 1, + "label": "Модель" + }, + { + "fieldname": "created_at", + "fieldtype": "Datetime", + "read_only": 1, + "label": "Создано" + }, + { + "fieldname": "content_section", + "fieldtype": "Section Break", + "label": "Содержимое" + }, + { + "fieldname": "content", + "fieldtype": "Long Text", + "label": "Текст", + "description": "Текст сообщения (markdown для assistant, plain для user)" + }, + { + "fieldname": "meta_section", + "fieldtype": "Section Break", + "label": "Метаданные", + "collapsible": 1 + }, + { + "fieldname": "meta_json", + "fieldtype": "Long Text", + "read_only": 1, + "label": "Метаданные (JSON)", + "description": "Источники, диагностика: какие встречи подтянул retrieval, время поиска, релевантность" + }, + { + "fieldname": "error_message", + "fieldtype": "Small Text", + "read_only": 1, + "label": "Сообщение об ошибке" + } + ], + "links": [], + "modified": "2026-01-01 00:00:00", + "modified_by": "Administrator", + "module": "Dyak", + "name": "Dyak Chat Message", + "owner": "Administrator", + "permissions": [ + { + "create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, + "read": 1, "report": 1, "role": "System Manager", + "share": 1, "write": 1 + }, + { + "create": 1, "delete": 0, "email": 0, "export": 1, "print": 0, + "read": 1, "report": 1, "role": "Dyak User", + "share": 0, "write": 0 + } + ], + "search_fields": "session,role", + "sort_field": "creation", + "sort_order": "ASC", + "track_changes": 0 +} \ No newline at end of file diff --git a/dyak/dyak/doctype/dyak_chat_message/dyak_chat_message.py b/dyak/dyak/doctype/dyak_chat_message/dyak_chat_message.py new file mode 100644 index 0000000..92e3392 --- /dev/null +++ b/dyak/dyak/doctype/dyak_chat_message/dyak_chat_message.py @@ -0,0 +1,15 @@ +import frappe +from frappe.model.document import Document +from frappe.utils import now_datetime + + +class DyakChatMessage(Document): + """Отдельное сообщение чата с Дьяком. + + НЕ child-table — отдельный doctype, чтобы можно было пагинировать + историю и не грузить тысячи сообщений сразу. + """ + + def before_insert(self): + if not self.created_at: + self.created_at = now_datetime() \ No newline at end of file diff --git a/dyak/dyak/doctype/dyak_chat_session/__init__.py b/dyak/dyak/doctype/dyak_chat_session/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dyak/dyak/doctype/dyak_chat_session/dyak_chat_session.json b/dyak/dyak/doctype/dyak_chat_session/dyak_chat_session.json new file mode 100644 index 0000000..b3535d2 --- /dev/null +++ b/dyak/dyak/doctype/dyak_chat_session/dyak_chat_session.json @@ -0,0 +1,98 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "DCS-.YYYY.-.#####", + "creation": "2026-01-01 00:00:00", + "doctype": "DocType", + "editable_grid": 0, + "engine": "InnoDB", + "field_order": [ + "title", + "owner_user", + "last_message_at", + "column_break_1", + "pinned", + "archived", + "message_count" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "in_global_search": 1, + "label": "Название диалога", + "description": "Авто-генерируется из первого вопроса, можно переименовать", + "reqd": 1 + }, + { + "fieldname": "owner_user", + "fieldtype": "Link", + "options": "User", + "read_only": 1, + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Владелец" + }, + { + "fieldname": "last_message_at", + "fieldtype": "Datetime", + "read_only": 1, + "in_list_view": 1, + "label": "Последнее сообщение" + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "pinned", + "fieldtype": "Check", + "default": "0", + "label": "Закреплён" + }, + { + "fieldname": "archived", + "fieldtype": "Check", + "default": "0", + "in_standard_filter": 1, + "label": "В архиве" + }, + { + "fieldname": "message_count", + "fieldtype": "Int", + "read_only": 1, + "default": "0", + "label": "Сообщений" + } + ], + "links": [ + { + "group": "Сообщения", + "link_doctype": "Dyak Chat Message", + "link_fieldname": "session" + } + ], + "modified": "2026-01-01 00:00:00", + "modified_by": "Administrator", + "module": "Dyak", + "name": "Dyak Chat Session", + "owner": "Administrator", + "permissions": [ + { + "create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, + "read": 1, "report": 1, "role": "System Manager", + "share": 1, "write": 1 + }, + { + "create": 1, "delete": 1, "email": 0, "export": 1, "print": 0, + "read": 1, "report": 1, "role": "Dyak User", + "share": 0, "write": 1 + } + ], + "search_fields": "title", + "sort_field": "last_message_at", + "sort_order": "DESC", + "title_field": "title", + "track_changes": 0 +} \ No newline at end of file diff --git a/dyak/dyak/doctype/dyak_chat_session/dyak_chat_session.py b/dyak/dyak/doctype/dyak_chat_session/dyak_chat_session.py new file mode 100644 index 0000000..0c46b39 --- /dev/null +++ b/dyak/dyak/doctype/dyak_chat_session/dyak_chat_session.py @@ -0,0 +1,8 @@ +import frappe +from frappe.model.document import Document + + +class DyakChatSession(Document): + """Глобальная чат-сессия с Дьяком. Контейнер для Dyak Chat Message.""" + + pass \ No newline at end of file diff --git a/dyak/dyak/doctype/dyak_settings/__init__.py b/dyak/dyak/doctype/dyak_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dyak/dyak/doctype/dyak_settings/dyak_settings.js b/dyak/dyak/doctype/dyak_settings/dyak_settings.js new file mode 100644 index 0000000..ffcbe65 --- /dev/null +++ b/dyak/dyak/doctype/dyak_settings/dyak_settings.js @@ -0,0 +1,22 @@ +/** + * Dyak Settings — клиентский скрипт. Привязывает кнопку + * «Создать/обновить помощника» к whitelist-методу setup_assistant. + */ +frappe.ui.form.on("Dyak Settings", { + setup_assistant_btn(frm) { + if (!frm.doc.assistant_name) { + frappe.msgprint({ + message: "Сначала задайте «Имя помощника» и сохраните настройки.", + title: "Не заполнено", + indicator: "orange", + }); + return; + } + frappe.call({ + method: "dyak.api.v1.chat.setup_assistant", + freeze: true, + freeze_message: "Создаём помощника…", + callback() { frm.reload_doc(); }, + }); + }, +}); diff --git a/dyak/dyak/doctype/dyak_settings/dyak_settings.json b/dyak/dyak/doctype/dyak_settings/dyak_settings.json new file mode 100644 index 0000000..bef0939 --- /dev/null +++ b/dyak/dyak/doctype/dyak_settings/dyak_settings.json @@ -0,0 +1,155 @@ +{ + "actions": [], + "creation": "2026-01-01 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "service_section", + "transcription_service_url", + "default_model", + "default_language", + "default_initial_prompt", + "default_num_speakers", + "ai_section", + "llm_provider", + "llm_model", + "llm_api_key", + "llm_url", + "assistant_section", + "assistant_name", + "assistant_user", + "chat_context_limit", + "setup_assistant_btn" + ], + "fields": [ + { + "fieldname": "service_section", + "fieldtype": "Section Break", + "label": "\u0421\u0435\u0440\u0432\u0438\u0441 \u0442\u0440\u0430\u043d\u0441\u043a\u0440\u0438\u0431\u0430\u0446\u0438\u0438" + }, + { + "default": "http://192.168.1.112:8000", + "description": "\u0410\u0434\u0440\u0435\u0441 API Gateway. \u041f\u0440\u0438\u043c\u0435\u0440: http://192.168.1.112:8000", + "fieldname": "transcription_service_url", + "fieldtype": "Data", + "label": "URL \u0441\u0435\u0440\u0432\u0438\u0441\u0430" + }, + { + "default": "large-v3", + "description": "tiny / base / small / medium / large-v3 / turbo", + "fieldname": "default_model", + "fieldtype": "Data", + "label": "\u041c\u043e\u0434\u0435\u043b\u044c Whisper" + }, + { + "default": "ru", + "description": "\u041a\u043e\u0434 \u044f\u0437\u044b\u043a\u0430 (ru, en, de...). \u041f\u0443\u0441\u0442\u043e = \u0430\u0432\u0442\u043e\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435", + "fieldname": "default_language", + "fieldtype": "Data", + "label": "\u042f\u0437\u044b\u043a" + }, + { + "description": "\u0422\u0435\u0440\u043c\u0438\u043d\u044b \u0438 \u0438\u043c\u0435\u043d\u0430 \u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e. \u041f\u0440\u0438\u043c\u0435\u0440: Unisab, DevOps, JSON Logic", + "fieldname": "default_initial_prompt", + "fieldtype": "Small Text", + "label": "\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430 \u0434\u043b\u044f \u043c\u043e\u0434\u0435\u043b\u0438" + }, + { + "default": "0", + "description": "0 = \u0430\u0432\u0442\u043e\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435. \u0422\u043e\u0447\u043d\u043e\u0435 \u0447\u0438\u0441\u043b\u043e \u043f\u043e\u0432\u044b\u0448\u0430\u0435\u0442 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u043e", + "fieldname": "default_num_speakers", + "fieldtype": "Int", + "label": "\u041a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u0441\u043f\u0438\u043a\u0435\u0440\u043e\u0432" + }, + { + "fieldname": "ai_section", + "fieldtype": "Section Break", + "label": "AI-\u0444\u0443\u043d\u043a\u0446\u0438\u0438" + }, + { + "default": "Ollama", + "description": "\u0421\u0435\u0439\u0447\u0430\u0441 \u0430\u043a\u0442\u0438\u0432\u0435\u043d \u0442\u043e\u043b\u044c\u043a\u043e Ollama (\u0447\u0435\u0440\u0435\u0437 n8n-workflow)", + "fieldname": "llm_provider", + "fieldtype": "Select", + "label": "LLM-\u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440", + "options": "\u041d\u0435\u0442\nAnthropic\nOpenAI\nOllama" + }, + { + "default": "qwen2.5:32b", + "description": "\u0418\u043c\u044f \u043c\u043e\u0434\u0435\u043b\u0438 \u0432 Ollama. \u041f\u0435\u0440\u0435\u0434\u0430\u0451\u0442\u0441\u044f \u0432 n8n-workflow \u043a\u0430\u043a \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440 model", + "fieldname": "llm_model", + "fieldtype": "Select", + "label": "\u041c\u043e\u0434\u0435\u043b\u044c Ollama", + "options": "qwen2.5:7b\nqwen2.5:14b\nqwen2.5:32b\nqwen2.5:72b\nllama3.1:8b\nllama3.1:70b\nllama3.3:70b\nmistral:7b\nmixtral:8x7b\ngemma2:9b\ngemma2:27b\ndeepseek-r1:7b\ndeepseek-r1:14b\ndeepseek-r1:32b\ndeepseek-r1:70b" + }, + { + "description": "\u0414\u043b\u044f \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0445 \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u043e\u0432 (Anthropic/OpenAI). \u0414\u043b\u044f Ollama \u043d\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f", + "fieldname": "llm_api_key", + "fieldtype": "Password", + "label": "API-\u043a\u043b\u044e\u0447" + }, + { + "description": "\u0414\u043b\u044f Ollama: http://localhost:11434 (\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u0441\u0430\u043c\u0438\u043c n8n)", + "fieldname": "llm_url", + "fieldtype": "Data", + "label": "URL LLM" + }, + { + "fieldname": "assistant_section", + "fieldtype": "Section Break", + "label": "\u041f\u043e\u043c\u043e\u0449\u043d\u0438\u043a (\u0447\u0430\u0442 \u0432 Activity)" + }, + { + "default": "\u0414\u044c\u044f\u043a", + "description": "\u0422\u0440\u0438\u0433\u0433\u0435\u0440 \u0447\u0430\u0442\u0430: \u043a\u043e\u043c\u043c\u0435\u043d\u0442\u0430\u0440\u0438\u0439, \u043d\u0430\u0447\u0438\u043d\u0430\u044e\u0449\u0438\u0439\u0441\u044f \u0441 #\u0418\u043c\u044f \u0438\u043b\u0438 @\u0418\u043c\u044f. \u041f\u0440\u0438\u043c\u0435\u0440: #\u0414\u044c\u044f\u043a \u043f\u043e\u043c\u043e\u0433\u0438 \u0441 \u0437\u0430\u0434\u0430\u0447\u0430\u043c\u0438", + "fieldname": "assistant_name", + "fieldtype": "Data", + "label": "\u0418\u043c\u044f \u043f\u043e\u043c\u043e\u0449\u043d\u0438\u043a\u0430" + }, + { + "description": "Frappe-\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c, \u043e\u0442 \u0438\u043c\u0435\u043d\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u043f\u043e\u043c\u043e\u0449\u043d\u0438\u043a \u043f\u0438\u0448\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u044b. \u0417\u0430\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043f\u0440\u0438 \u043d\u0430\u0436\u0430\u0442\u0438\u0438 \u043a\u043d\u043e\u043f\u043a\u0438 \u043d\u0438\u0436\u0435", + "fieldname": "assistant_user", + "fieldtype": "Link", + "label": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c-\u043f\u043e\u043c\u043e\u0449\u043d\u0438\u043a", + "options": "User", + "read_only": 1 + }, + { + "default": "30000", + "description": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u044b\u0439 \u043e\u0431\u044a\u0451\u043c \u0438\u0441\u0442\u043e\u0440\u0438\u0438 \u0447\u0430\u0442\u0430 \u0432 system-\u043f\u0440\u043e\u043c\u043f\u0442\u0435. \u041f\u0440\u0438 \u043f\u0440\u0435\u0432\u044b\u0448\u0435\u043d\u0438\u0438 \u0441\u0442\u0430\u0440\u044b\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442\u0440\u0435\u0437\u0430\u044e\u0442\u0441\u044f. \u0420\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0430 \u0438 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435 \u0444\u043e\u0440\u043c\u044b \u043f\u0435\u0440\u0435\u0434\u0430\u044e\u0442\u0441\u044f \u0432\u0441\u0435\u0433\u0434\u0430 \u043d\u0435\u0437\u0430\u0432\u0438\u0441\u0438\u043c\u043e \u043e\u0442 \u043b\u0438\u043c\u0438\u0442\u0430", + "fieldname": "chat_context_limit", + "fieldtype": "Int", + "label": "\u041b\u0438\u043c\u0438\u0442 \u043a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u0430 \u0447\u0430\u0442\u0430 (\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)" + }, + { + "description": "\u0421\u043e\u0437\u0434\u0430\u0451\u0442 Frappe-\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f dyak@bot.local \u0441 full_name = \u00ab\u0418\u043c\u044f \u043f\u043e\u043c\u043e\u0449\u043d\u0438\u043a\u0430\u00bb \u0438 \u0440\u043e\u043b\u044c\u044e Dyak User. \u0411\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e \u0432\u044b\u0437\u044b\u0432\u0430\u0442\u044c \u043c\u043d\u043e\u0433\u043e\u043a\u0440\u0430\u0442\u043d\u043e", + "fieldname": "setup_assistant_btn", + "fieldtype": "Button", + "label": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c/\u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043c\u043e\u0449\u043d\u0438\u043a\u0430" + } + ], + "issingle": 1, + "links": [], + "modified": "2026-05-10 16:29:50.545503", + "modified_by": "Administrator", + "module": "Dyak", + "name": "Dyak Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/dyak/dyak/doctype/dyak_settings/dyak_settings.py b/dyak/dyak/doctype/dyak_settings/dyak_settings.py new file mode 100644 index 0000000..d674bbe --- /dev/null +++ b/dyak/dyak/doctype/dyak_settings/dyak_settings.py @@ -0,0 +1,8 @@ +import frappe +from frappe.model.document import Document + + +class DyakSettings(Document): + """Singleton с настройками приложения Дьяк.""" + + pass diff --git a/dyak/dyak/doctype/dyak_settings/test_dyak_settings.py b/dyak/dyak/doctype/dyak_settings/test_dyak_settings.py new file mode 100644 index 0000000..290554e --- /dev/null +++ b/dyak/dyak/doctype/dyak_settings/test_dyak_settings.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026, V.Bolshakovsky and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + + +class IntegrationTestDyakSettings(IntegrationTestCase): + """ + Integration tests for DyakSettings. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.js b/dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.js new file mode 100644 index 0000000..4eab116 --- /dev/null +++ b/dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.js @@ -0,0 +1,75 @@ +/** + * Meeting Analysis Result — клиентский скрипт. + * + * Рендер result_html выполняется НА КЛИЕНТЕ через общую функцию + * `dyak_render_analysis`, определённую в meeting_record.js (она + * выставлена в window). Мы тянем result_json результата + output_schema + * связанного профиля и собираем HTML. + * + * Если глобальная функция ещё не загружена (например, форма открыта + * напрямую без перехода через Meeting Record) — используем fallback: + * pretty-printed JSON. + */ +frappe.ui.form.on("Meeting Analysis Result", { + refresh(frm) { + const wrapper = frm.get_field("result_html").$wrapper; + if (wrapper) { + render_result_into(wrapper, frm.doc); + } + + if (frm.doc.status === "В очереди" || frm.doc.status === "В обработке") { + // Polling — реалтайм у нас на канале родительской встречи, + // не на самом результате. + if (!frm._dyak_mar_poll) { + frm._dyak_mar_poll = setInterval(() => { + if (!cur_frm || cur_frm.doc.name !== frm.doc.name) { + clearInterval(frm._dyak_mar_poll); + frm._dyak_mar_poll = null; + return; + } + frappe.db.get_value( + "Meeting Analysis Result", frm.doc.name, "status" + ).then(r => { + const status = r.message && r.message.status; + if (status && status !== frm.doc.status) { + clearInterval(frm._dyak_mar_poll); + frm._dyak_mar_poll = null; + frm.reload_doc(); + } + }); + }, 4000); + } + } + }, +}); + +function render_result_into(wrapper, doc) { + if (!doc.result_json) { + wrapper.html(`
+ Результат пока не сформирован. +
`); + return; + } + + // Если функция-рендерер загружена — используем её, подтянув схему + // профиля. Иначе — fallback с сырым JSON. + if (typeof window.dyak_render_analysis === "function" && doc.profile) { + wrapper.html(`
Загрузка…
`); + frappe.db.get_value( + "Analysis Profile", doc.profile, "output_schema" + ).then(r => { + const schema = (r.message && r.message.output_schema) || ""; + wrapper.html( + window.dyak_render_analysis(doc.result_json, schema) + ); + }); + } else { + let pretty = doc.result_json; + try { + pretty = JSON.stringify(JSON.parse(doc.result_json), null, 2); + } catch (_) { /* keep as is */ } + wrapper.html(`
${frappe.utils.escape_html(pretty)}
`); + } +} \ No newline at end of file diff --git a/dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.json b/dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.json new file mode 100644 index 0000000..c47d6db --- /dev/null +++ b/dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.json @@ -0,0 +1,182 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "MAR-.YYYY.-.#####", + "creation": "2026-01-01 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "main_section", + "meeting_record", + "profile", + "column_break_main", + "profile_name_snapshot", + "status", + "meta_section", + "model_used", + "temperature_used", + "column_break_meta", + "started_at", + "completed_at", + "result_section", + "result_html", + "result_json", + "log_section", + "processing_log", + "error_message" + ], + "fields": [ + { + "fieldname": "main_section", + "fieldtype": "Section Break", + "label": "Основное" + }, + { + "fieldname": "meeting_record", + "fieldtype": "Link", + "options": "Meeting Record", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Встреча", + "reqd": 1 + }, + { + "fieldname": "profile", + "fieldtype": "Link", + "options": "Analysis Profile", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Профиль анализа", + "reqd": 1 + }, + { + "fieldname": "column_break_main", + "fieldtype": "Column Break" + }, + { + "fieldname": "profile_name_snapshot", + "fieldtype": "Data", + "read_only": 1, + "in_list_view": 1, + "label": "Профиль (на момент применения)", + "description": "Имя профиля сохраняется в момент запуска. Если профиль будет переименован, здесь останется старое имя — это упрощает поиск по истории" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "default": "В очереди", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Статус", + "options": "В очереди\nВ обработке\nГотово\nОшибка" + }, + { + "fieldname": "meta_section", + "fieldtype": "Section Break", + "label": "Параметры запуска" + }, + { + "fieldname": "model_used", + "fieldtype": "Data", + "read_only": 1, + "label": "Использованная модель" + }, + { + "fieldname": "temperature_used", + "fieldtype": "Float", + "read_only": 1, + "precision": "2", + "label": "Температура" + }, + { + "fieldname": "column_break_meta", + "fieldtype": "Column Break" + }, + { + "fieldname": "started_at", + "fieldtype": "Datetime", + "read_only": 1, + "label": "Начало" + }, + { + "fieldname": "completed_at", + "fieldtype": "Datetime", + "read_only": 1, + "label": "Завершение" + }, + { + "fieldname": "result_section", + "fieldtype": "Section Break", + "label": "Результат" + }, + { + "fieldname": "result_html", + "fieldtype": "HTML", + "label": "Отображение", + "description": "Структурированное представление результата по схеме профиля. Рендерится клиентским скриптом из result_json" + }, + { + "fieldname": "result_json", + "fieldtype": "Long Text", + "read_only": 1, + "label": "Сырой ответ модели (JSON)", + "description": "JSON-строка с ответом модели. Используется как источник для рендера и для Report Builder" + }, + { + "fieldname": "log_section", + "fieldtype": "Section Break", + "label": "Лог обработки", + "collapsible": 1 + }, + { + "fieldname": "processing_log", + "fieldtype": "Long Text", + "read_only": 1, + "label": "Этапы" + }, + { + "fieldname": "error_message", + "fieldtype": "Small Text", + "read_only": 1, + "label": "Сообщение об ошибке", + "description": "Заполняется при статусе «Ошибка»" + } + ], + "links": [], + "modified": "2026-01-01 00:00:00", + "modified_by": "Administrator", + "module": "Dyak", + "name": "Meeting Analysis Result", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 0, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Dyak User", + "share": 1, + "write": 0 + } + ], + "search_fields": "meeting_record,profile_name_snapshot,status", + "sort_field": "creation", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.py b/dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.py new file mode 100644 index 0000000..21b24f8 --- /dev/null +++ b/dyak/dyak/doctype/meeting_analysis_result/meeting_analysis_result.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class MeetingAnalysisResult(Document): + pass diff --git a/dyak/dyak/doctype/meeting_participant/__init__.py b/dyak/dyak/doctype/meeting_participant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dyak/dyak/doctype/meeting_participant/meeting_participant.json b/dyak/dyak/doctype/meeting_participant/meeting_participant.json new file mode 100644 index 0000000..b0ba649 --- /dev/null +++ b/dyak/dyak/doctype/meeting_participant/meeting_participant.json @@ -0,0 +1,49 @@ +{ + "actions": [], + "allow_rename": 0, + "creation": "2026-01-01 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "participant_name", + "role", + "speaker_id" + ], + "fields": [ + { + "fieldname": "participant_name", + "fieldtype": "Data", + "label": "ФИО", + "in_list_view": 1, + "reqd": 1, + "description": "Имя участника встречи" + }, + { + "fieldname": "role", + "fieldtype": "Select", + "label": "Роль", + "options": "Ведущий\nУчастник\nНаблюдатель", + "in_list_view": 1, + "default": "Участник", + "description": "Роль на встрече" + }, + { + "fieldname": "speaker_id", + "fieldtype": "Data", + "label": "Спикер", + "in_list_view": 1, + "description": "ID из диаризации (SPEAKER_00, SPEAKER_01...). Назначается после обработки" + } + ], + "istable": 1, + "links": [], + "modified": "2026-01-01 00:00:00", + "modified_by": "Administrator", + "module": "Dyak", + "name": "Meeting Participant", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/dyak/dyak/doctype/meeting_participant/meeting_participant.py b/dyak/dyak/doctype/meeting_participant/meeting_participant.py new file mode 100644 index 0000000..dbd1e25 --- /dev/null +++ b/dyak/dyak/doctype/meeting_participant/meeting_participant.py @@ -0,0 +1,8 @@ +import frappe +from frappe.model.document import Document + + +class MeetingParticipant(Document): + """Child-table doctype `Meeting Participant` для приложения Дьяк.""" + + pass diff --git a/dyak/dyak/doctype/meeting_record/__init__.py b/dyak/dyak/doctype/meeting_record/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dyak/dyak/doctype/meeting_record/meeting_record copy 2.js b/dyak/dyak/doctype/meeting_record/meeting_record copy 2.js new file mode 100644 index 0000000..b293ac2 --- /dev/null +++ b/dyak/dyak/doctype/meeting_record/meeting_record copy 2.js @@ -0,0 +1,548 @@ +/** + * Дьяк — Meeting Record client script. + * + * Реализует: + * • Кнопку «Транскрибировать» (черновик + есть аудио). + * • Realtime-прогресс через `frappe.realtime.on("dyak_progress")`: + * отрисовка прогресс-бара с процентом, этапом и сообщением. + * • Polling-fallback на случай, если websocket не работает. + * • Кнопку «Назначить спикеров» (после расшифровки). + * • Рендер диалога по utterances_json в HTML-поле dialog_html. + * • Кнопки-заглушки AI-функций. + * • Workflow-кнопки «На проверку» / «Утвердить». + * • Dashboard со сводными метриками после обработки. + */ + +frappe.provide("dyak.meeting_record"); + +// Палитра цветов для спикеров (8 контрастных оттенков). +const SPEAKER_COLORS = [ + "#5e64ff", "#ff5858", "#28a745", "#ff8c00", + "#9b59b6", "#17a2b8", "#e83e8c", "#6c757d", +]; + +// ────────────────────────────────────────────────────────────────────────── +// Хелперы +// ────────────────────────────────────────────────────────────────────────── + +function format_time(seconds) { + if (seconds === null || seconds === undefined || isNaN(seconds)) return ""; + const total = Math.floor(Number(seconds)); + const m = Math.floor(total / 60); + const s = total % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +function get_color_for_speaker(speaker_id, speaker_index_map) { + if (!(speaker_id in speaker_index_map)) { + speaker_index_map[speaker_id] = Object.keys(speaker_index_map).length; + } + const idx = speaker_index_map[speaker_id]; + return SPEAKER_COLORS[idx % SPEAKER_COLORS.length]; +} + +function build_speaker_name_map(frm) { + const map = {}; + (frm.doc.participants || []).forEach(row => { + if (row.speaker_id && row.participant_name) { + map[row.speaker_id] = row.participant_name; + } + }); + return map; +} + +function escape_html(text) { + if (text === null || text === undefined) return ""; + const div = document.createElement("div"); + div.textContent = String(text); + return div.innerHTML; +} + +// ────────────────────────────────────────────────────────────────────────── +// Прогресс-индикатор +// ────────────────────────────────────────────────────────────────────────── + +/** + * Рисует «живую» плашку с прогресс-баром, текущим этапом, процентом и + * последним сообщением. Если is_error=true — окрашивается в красное. + */ +function render_progress(frm, { stage, percent, message, is_error, ts } = {}) { + if (!frm) return; + + // Если запись не в обработке — убрать плашку. + const in_progress = frm.doc.status === "В обработке" + || (stage && stage !== "Готово" && !is_error && percent !== 100); + + if (!in_progress && !is_error) { + frm.dashboard.clear_headline(); + return; + } + + const safe_stage = escape_html(stage || frm.doc.processing_stage || "Ожидание…"); + const safe_msg = escape_html(message || ""); + const pct = Math.max(0, Math.min(100, Number(percent) || 0)); + const color = is_error ? "#d9534f" : "#5e64ff"; + const bar_bg = is_error ? "#f8d7da" : "#e8e9ff"; + const ts_label = ts ? `${escape_html(ts)}` : ""; + + const html = ` +
+
+ ${safe_stage} + ${pct}% + ${ts_label} +
+
+
+
+ ${safe_msg ? `
${safe_msg}
` : ""} +
+ `; + + frm.dashboard.clear_headline(); + frm.dashboard.set_headline_alert(html, is_error ? "red" : "blue"); +} + +/** + * Рендерит processing_log как блок с моноширинным шрифтом. + * Вызывается из refresh; обновляется в realtime через перерисовку при + * получении события (мы пишем в БД, потом фронт может её перечитать). + */ +function render_processing_log(frm) { + if (!frm.doc.processing_log) return; + // Помещаем после dashboard в form sidebar / wrapper. Используем + // встроенный механизм headlines — но т.к. там уже прогресс, лог + // показываем как отдельный sticky-блок над секциями. + // Простой путь: показать в виде frappe.msgprint? Нет — лучше + // отдельным блоком. Используем dashboard comments. + // Здесь оставляем поле read-only Long Text как часть формы — оно само + // отрисовано, ничего дополнительного не нужно. Функция сохранена как + // точка расширения, если потом понадобится кастомный виджет. +} + +// ────────────────────────────────────────────────────────────────────────── +// Подписка на realtime +// ────────────────────────────────────────────────────────────────────────── + +function subscribe_to_progress(frm) { + // Чистим прежний обработчик, если есть. + if (dyak.meeting_record._unsubscribe) { + try { dyak.meeting_record._unsubscribe(); } catch (e) { /* noop */ } + dyak.meeting_record._unsubscribe = null; + } + + const handler = (data) => { + if (!data || data.docname !== frm.doc.name) return; + + render_progress(frm, data); + + // При finalных состояниях — обновляем форму, чтобы подтянулись + // новые поля (utterances_json, full_text, audio_duration и т.д.). + if (data.percent === 100 || data.is_error) { + // Небольшая задержка, чтобы коммит точно успел докатиться. + setTimeout(() => { + if (cur_frm && cur_frm.doc.name === frm.doc.name) { + cur_frm.reload_doc(); + } + }, 800); + } else { + // На промежуточных этапах подгрузим только processing_log, + // чтобы пользователь видел историю. + frappe.db.get_value( + "Meeting Record", frm.doc.name, + ["processing_log", "processing_stage"] + ).then(r => { + if (r.message && cur_frm && cur_frm.doc.name === frm.doc.name) { + cur_frm.doc.processing_log = r.message.processing_log; + cur_frm.doc.processing_stage = r.message.processing_stage; + cur_frm.refresh_field("processing_log"); + cur_frm.refresh_field("processing_stage"); + } + }); + } + }; + + frappe.realtime.on("dyak_progress", handler); + dyak.meeting_record._unsubscribe = () => frappe.realtime.off("dyak_progress", handler); +} + +// ────────────────────────────────────────────────────────────────────────── +// Подписка на обновления чата (placeholder → ответ бота) +// ────────────────────────────────────────────────────────────────────────── + +function subscribe_to_chat_updates(frm) { + if (dyak.meeting_record._chat_unsubscribe) { + try { dyak.meeting_record._chat_unsubscribe(); } catch (e) { /* noop */ } + dyak.meeting_record._chat_unsubscribe = null; + } + + const handler = (data) => { + if (!data || data.doctype !== "Meeting Record") return; + if (data.docname !== frm.doc.name) return; + // Перерисовываем activity feed — там лежат комментарии бота. + if (frm.timeline && typeof frm.timeline.refresh === "function") { + frm.timeline.refresh(); + } else { + // Fallback: перезагружаем форму целиком. + frm.reload_doc(); + } + }; + + frappe.realtime.on("dyak_chat_update", handler); + dyak.meeting_record._chat_unsubscribe = () => + frappe.realtime.off("dyak_chat_update", handler); +} + +// ────────────────────────────────────────────────────────────────────────── +// Polling-fallback (на случай отсутствия websocket) +// ────────────────────────────────────────────────────────────────────────── + +function poll_status(frm) { + if (dyak.meeting_record._polling_interval) return; + + dyak.meeting_record._polling_interval = setInterval(() => { + if (!cur_frm || cur_frm.doc.name !== frm.doc.name) { + clearInterval(dyak.meeting_record._polling_interval); + dyak.meeting_record._polling_interval = null; + return; + } + + frappe.call({ + method: "dyak.api.v1.transcribe.get_progress", + args: { docname: frm.doc.name }, + callback(r) { + const m = r.message; + if (!m) return; + // Обновляем поля локально, без reload, чтобы прогресс-плашка + // и лог жили синхронно с состоянием БД. + if (cur_frm && cur_frm.doc.name === frm.doc.name) { + let dirty = false; + if (cur_frm.doc.processing_stage !== m.processing_stage) { + cur_frm.doc.processing_stage = m.processing_stage; + cur_frm.refresh_field("processing_stage"); + dirty = true; + } + if (cur_frm.doc.processing_log !== m.processing_log) { + cur_frm.doc.processing_log = m.processing_log; + cur_frm.refresh_field("processing_log"); + } + if (dirty) { + // Аппроксимация процента по этапу (для случая + // отсутствия realtime — даём хоть какую-то динамику). + const pct_map = { + "В очереди": 2, + "Подготовка": 10, + "Отправка": 25, + "Ожидание": 60, + "Получен ответ": 80, + "Сохранение": 90, + "Готово": 100, + }; + const pct = pct_map[m.processing_stage] || 0; + const is_error = (m.processing_stage || "").startsWith("Ошибка"); + render_progress(frm, { + stage: m.processing_stage, + percent: is_error ? 0 : pct, + message: "", + is_error, + }); + } + + // Финал: вышли из «В обработке» — перезагружаем. + if (m.status !== "В обработке") { + clearInterval(dyak.meeting_record._polling_interval); + dyak.meeting_record._polling_interval = null; + cur_frm.reload_doc(); + } + } + }, + }); + }, 4000); +} + +// ────────────────────────────────────────────────────────────────────────── +// Рендеринг диалога +// ────────────────────────────────────────────────────────────────────────── + +function render_dialog(frm) { + const wrapper = frm.get_field("dialog_html").$wrapper; + if (!wrapper) return; + + let utterances = []; + if (frm.doc.utterances_json) { + try { + const parsed = typeof frm.doc.utterances_json === "string" + ? JSON.parse(frm.doc.utterances_json) + : frm.doc.utterances_json; + utterances = Array.isArray(parsed) ? parsed : (parsed.utterances || []); + } catch (e) { + console.warn("dyak: не удалось распарсить utterances_json", e); + } + } + + if (!utterances.length) { + wrapper.html(`
+ Расшифровка появится здесь после обработки записи. +
`); + return; + } + + const speaker_names = build_speaker_name_map(frm); + const speaker_index_map = {}; + let html = `
`; + + utterances.forEach(u => { + const speaker_id = u.speaker || "SPEAKER_??"; + const display_name = speaker_names[speaker_id] || speaker_id; + const color = get_color_for_speaker(speaker_id, speaker_index_map); + const start = format_time(u.start); + const end = format_time(u.end); + const text = escape_html(u.text || ""); + + html += ` +
+
+ [${escape_html(display_name)}] + + (${start}\u00a0–\u00a0${end}) + +
+
${text}
+
`; + }); + html += `
`; + wrapper.html(html); +} + +// ────────────────────────────────────────────────────────────────────────── +// Dashboard (метрики после обработки) +// ────────────────────────────────────────────────────────────────────────── + +function render_dashboard(frm) { + if (!frm.doc.audio_duration || frm.doc.status === "В обработке") return; + const headline = ` +
+
Длительность: ${format_time(frm.doc.audio_duration)}
+
Спикеров: ${frm.doc.num_speakers || "—"}
+
Язык: ${escape_html(frm.doc.detected_language || "—")}
+
Обработка: ${(frm.doc.processing_time || 0).toFixed(1)} сек
+
`; + frm.dashboard.set_headline_alert(headline, "blue"); +} + +// ────────────────────────────────────────────────────────────────────────── +// Назначение спикеров +// ────────────────────────────────────────────────────────────────────────── + +function open_speaker_assignment_dialog(frm) { + let utterances = []; + try { + const parsed = typeof frm.doc.utterances_json === "string" + ? JSON.parse(frm.doc.utterances_json) + : frm.doc.utterances_json; + utterances = Array.isArray(parsed) ? parsed : (parsed.utterances || []); + } catch (e) { + frappe.msgprint({ message: "Нет данных диаризации", indicator: "orange" }); + return; + } + + if (!utterances.length) { + frappe.msgprint({ message: "Нет данных диаризации", indicator: "orange" }); + return; + } + + const speakers = {}; + utterances.forEach(u => { + if (!u.speaker) return; + if (!(u.speaker in speakers)) { + speakers[u.speaker] = (u.text || "").slice(0, 100); + } + }); + + const participants = (frm.doc.participants || []) + .filter(p => p.participant_name) + .map(p => p.participant_name); + + if (!participants.length) { + frappe.msgprint({ + message: "Сначала добавьте участников в таблицу «Участники встречи»", + indicator: "orange", + }); + return; + } + + const fields = []; + Object.keys(speakers).sort().forEach(spk => { + fields.push({ + fieldname: spk, + label: `${spk} — «${speakers[spk]}${speakers[spk].length >= 100 ? "…" : ""}»`, + fieldtype: "Select", + options: ["", ...participants].join("\n"), + default: (frm.doc.participants || []).find(p => p.speaker_id === spk)?.participant_name || "", + }); + }); + + const d = new frappe.ui.Dialog({ + title: "Назначение спикеров", + size: "large", + fields: fields, + primary_action_label: "Сохранить", + primary_action(values) { + Object.keys(values).forEach(spk => { + const chosen_name = values[spk]; + (frm.doc.participants || []).forEach(row => { + if (row.speaker_id === spk && row.participant_name !== chosen_name) { + frappe.model.set_value(row.doctype, row.name, "speaker_id", ""); + } + }); + if (chosen_name) { + const target = (frm.doc.participants || []).find( + p => p.participant_name === chosen_name + ); + if (target) { + frappe.model.set_value(target.doctype, target.name, "speaker_id", spk); + } + } + }); + + frm.refresh_field("participants"); + frm.save().then(() => { + render_dialog(frm); + frappe.show_alert({ message: "Спикеры назначены", indicator: "green" }); + }); + d.hide(); + }, + }); + d.show(); +} + +// ────────────────────────────────────────────────────────────────────────── +// Главный обработчик формы +// ────────────────────────────────────────────────────────────────────────── + +frappe.ui.form.on("Meeting Record", { + onload(frm) { + subscribe_to_progress(frm); + subscribe_to_chat_updates(frm); + }, + + refresh(frm) { + render_dialog(frm); + + // Если идёт обработка — показываем прогресс-бар; иначе — dashboard метрик. + if (frm.doc.status === "В обработке") { + const pct_map = { + "В очереди": 2, + "Подготовка": 10, + "Отправка": 25, + "Ожидание": 60, + "Получен ответ": 80, + "Сохранение": 90, + "Готово": 100, + }; + const pct = pct_map[frm.doc.processing_stage] || 5; + render_progress(frm, { + stage: frm.doc.processing_stage || "В очереди", + percent: pct, + message: "", + is_error: false, + }); + poll_status(frm); + subscribe_to_progress(frm); + } else { + render_dashboard(frm); + } + + // ─── Кнопка «Транскрибировать» ───────────────────────────────── + if (frm.doc.status === "Черновик" && frm.doc.audio_file && !frm.is_new()) { + frm.add_custom_button("Транскрибировать", () => { + frappe.call({ + method: "dyak.api.v1.transcribe.transcribe", + args: { docname: frm.doc.name }, + freeze: true, + freeze_message: "Постановка в очередь…", + callback() { + frappe.show_alert({ + message: "Запись отправлена на обработку", + indicator: "blue", + }); + // Сразу подхватываем смену статуса на «В обработке» + // и подключаем подписку на прогресс. + setTimeout(() => frm.reload_doc(), 800); + }, + }); + }).addClass("btn-primary"); + } + + // ─── Кнопка повтора при ошибке ───────────────────────────────── + if (frm.doc.status === "Черновик" && frm.doc.audio_file + && frm.doc.processing_stage && frm.doc.processing_stage.startsWith("Ошибка")) { + frm.add_custom_button("Очистить лог обработки", () => { + frappe.call({ + method: "frappe.client.set_value", + args: { + doctype: "Meeting Record", + name: frm.doc.name, + fieldname: { processing_stage: "", processing_log: "" }, + }, + callback() { frm.reload_doc(); }, + }); + }); + } + + // ─── Кнопка «Назначить спикеров» ─────────────────────────────── + if (frm.doc.status === "Расшифровано" || frm.doc.status === "Проверено") { + frm.add_custom_button("Назначить спикеров", () => { + open_speaker_assignment_dialog(frm); + }, "Действия"); + } + + // ─── AI-кнопки (заглушки) ────────────────────────────────────── + if (frm.doc.status === "Расшифровано" || frm.doc.status === "Проверено") { + frm.add_custom_button("Извлечь задачи", () => { + frappe.call({ method: "dyak.api.ai.extract_action_items", args: { docname: frm.doc.name } }); + }, "AI"); + frm.add_custom_button("Сгенерировать резюме", () => { + frappe.call({ method: "dyak.api.ai.generate_summary", args: { docname: frm.doc.name } }); + }, "AI"); + frm.add_custom_button("Анализ встречи", () => { + frappe.call({ method: "dyak.api.ai.analyze_meeting", args: { docname: frm.doc.name } }); + }, "AI"); + } + + // ─── Workflow-кнопки ─────────────────────────────────────────── + if (frm.doc.status === "Расшифровано") { + frm.add_custom_button("На проверку", () => { + frm.set_value("status", "Проверено"); + frm.save(); + }).addClass("btn-secondary"); + } + if (frm.doc.status === "Проверено") { + frm.add_custom_button("Утвердить", () => { + frm.set_value("status", "Утверждено"); + frm.save(); + }).addClass("btn-success"); + } + }, + + utterances_json(frm) { + render_dialog(frm); + }, +}); + +// Перерисовываем диалог при изменении маппинга спикеров. +frappe.ui.form.on("Meeting Participant", { + speaker_id(frm) { render_dialog(frm); }, + participant_name(frm) { render_dialog(frm); }, +}); diff --git a/dyak/dyak/doctype/meeting_record/meeting_record copy.js b/dyak/dyak/doctype/meeting_record/meeting_record copy.js new file mode 100644 index 0000000..5de7354 --- /dev/null +++ b/dyak/dyak/doctype/meeting_record/meeting_record copy.js @@ -0,0 +1,520 @@ +/** + * Дьяк — Meeting Record client script. + * + * Реализует: + * • Кнопку «Транскрибировать» (черновик + есть аудио). + * • Realtime-прогресс через `frappe.realtime.on("dyak_progress")`: + * отрисовка прогресс-бара с процентом, этапом и сообщением. + * • Polling-fallback на случай, если websocket не работает. + * • Кнопку «Назначить спикеров» (после расшифровки). + * • Рендер диалога по utterances_json в HTML-поле dialog_html. + * • Кнопки-заглушки AI-функций. + * • Workflow-кнопки «На проверку» / «Утвердить». + * • Dashboard со сводными метриками после обработки. + */ + +frappe.provide("dyak.meeting_record"); + +// Палитра цветов для спикеров (8 контрастных оттенков). +const SPEAKER_COLORS = [ + "#5e64ff", "#ff5858", "#28a745", "#ff8c00", + "#9b59b6", "#17a2b8", "#e83e8c", "#6c757d", +]; + +// ────────────────────────────────────────────────────────────────────────── +// Хелперы +// ────────────────────────────────────────────────────────────────────────── + +function format_time(seconds) { + if (seconds === null || seconds === undefined || isNaN(seconds)) return ""; + const total = Math.floor(Number(seconds)); + const m = Math.floor(total / 60); + const s = total % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +function get_color_for_speaker(speaker_id, speaker_index_map) { + if (!(speaker_id in speaker_index_map)) { + speaker_index_map[speaker_id] = Object.keys(speaker_index_map).length; + } + const idx = speaker_index_map[speaker_id]; + return SPEAKER_COLORS[idx % SPEAKER_COLORS.length]; +} + +function build_speaker_name_map(frm) { + const map = {}; + (frm.doc.participants || []).forEach(row => { + if (row.speaker_id && row.participant_name) { + map[row.speaker_id] = row.participant_name; + } + }); + return map; +} + +function escape_html(text) { + if (text === null || text === undefined) return ""; + const div = document.createElement("div"); + div.textContent = String(text); + return div.innerHTML; +} + +// ────────────────────────────────────────────────────────────────────────── +// Прогресс-индикатор +// ────────────────────────────────────────────────────────────────────────── + +/** + * Рисует «живую» плашку с прогресс-баром, текущим этапом, процентом и + * последним сообщением. Если is_error=true — окрашивается в красное. + */ +function render_progress(frm, { stage, percent, message, is_error, ts } = {}) { + if (!frm) return; + + // Если запись не в обработке — убрать плашку. + const in_progress = frm.doc.status === "В обработке" + || (stage && stage !== "Готово" && !is_error && percent !== 100); + + if (!in_progress && !is_error) { + frm.dashboard.clear_headline(); + return; + } + + const safe_stage = escape_html(stage || frm.doc.processing_stage || "Ожидание…"); + const safe_msg = escape_html(message || ""); + const pct = Math.max(0, Math.min(100, Number(percent) || 0)); + const color = is_error ? "#d9534f" : "#5e64ff"; + const bar_bg = is_error ? "#f8d7da" : "#e8e9ff"; + const ts_label = ts ? `${escape_html(ts)}` : ""; + + const html = ` +
+
+ ${safe_stage} + ${pct}% + ${ts_label} +
+
+
+
+ ${safe_msg ? `
${safe_msg}
` : ""} +
+ `; + + frm.dashboard.clear_headline(); + frm.dashboard.set_headline_alert(html, is_error ? "red" : "blue"); +} + +/** + * Рендерит processing_log как блок с моноширинным шрифтом. + * Вызывается из refresh; обновляется в realtime через перерисовку при + * получении события (мы пишем в БД, потом фронт может её перечитать). + */ +function render_processing_log(frm) { + if (!frm.doc.processing_log) return; + // Помещаем после dashboard в form sidebar / wrapper. Используем + // встроенный механизм headlines — но т.к. там уже прогресс, лог + // показываем как отдельный sticky-блок над секциями. + // Простой путь: показать в виде frappe.msgprint? Нет — лучше + // отдельным блоком. Используем dashboard comments. + // Здесь оставляем поле read-only Long Text как часть формы — оно само + // отрисовано, ничего дополнительного не нужно. Функция сохранена как + // точка расширения, если потом понадобится кастомный виджет. +} + +// ────────────────────────────────────────────────────────────────────────── +// Подписка на realtime +// ────────────────────────────────────────────────────────────────────────── + +function subscribe_to_progress(frm) { + // Чистим прежний обработчик, если есть. + if (dyak.meeting_record._unsubscribe) { + try { dyak.meeting_record._unsubscribe(); } catch (e) { /* noop */ } + dyak.meeting_record._unsubscribe = null; + } + + const handler = (data) => { + if (!data || data.docname !== frm.doc.name) return; + + render_progress(frm, data); + + // При finalных состояниях — обновляем форму, чтобы подтянулись + // новые поля (utterances_json, full_text, audio_duration и т.д.). + if (data.percent === 100 || data.is_error) { + // Небольшая задержка, чтобы коммит точно успел докатиться. + setTimeout(() => { + if (cur_frm && cur_frm.doc.name === frm.doc.name) { + cur_frm.reload_doc(); + } + }, 800); + } else { + // На промежуточных этапах подгрузим только processing_log, + // чтобы пользователь видел историю. + frappe.db.get_value( + "Meeting Record", frm.doc.name, + ["processing_log", "processing_stage"] + ).then(r => { + if (r.message && cur_frm && cur_frm.doc.name === frm.doc.name) { + cur_frm.doc.processing_log = r.message.processing_log; + cur_frm.doc.processing_stage = r.message.processing_stage; + cur_frm.refresh_field("processing_log"); + cur_frm.refresh_field("processing_stage"); + } + }); + } + }; + + frappe.realtime.on("dyak_progress", handler); + dyak.meeting_record._unsubscribe = () => frappe.realtime.off("dyak_progress", handler); +} + +// ────────────────────────────────────────────────────────────────────────── +// Polling-fallback (на случай отсутствия websocket) +// ────────────────────────────────────────────────────────────────────────── + +function poll_status(frm) { + if (dyak.meeting_record._polling_interval) return; + + dyak.meeting_record._polling_interval = setInterval(() => { + if (!cur_frm || cur_frm.doc.name !== frm.doc.name) { + clearInterval(dyak.meeting_record._polling_interval); + dyak.meeting_record._polling_interval = null; + return; + } + + frappe.call({ + method: "dyak.api.v1.transcribe.get_progress", + args: { docname: frm.doc.name }, + callback(r) { + const m = r.message; + if (!m) return; + // Обновляем поля локально, без reload, чтобы прогресс-плашка + // и лог жили синхронно с состоянием БД. + if (cur_frm && cur_frm.doc.name === frm.doc.name) { + let dirty = false; + if (cur_frm.doc.processing_stage !== m.processing_stage) { + cur_frm.doc.processing_stage = m.processing_stage; + cur_frm.refresh_field("processing_stage"); + dirty = true; + } + if (cur_frm.doc.processing_log !== m.processing_log) { + cur_frm.doc.processing_log = m.processing_log; + cur_frm.refresh_field("processing_log"); + } + if (dirty) { + // Аппроксимация процента по этапу (для случая + // отсутствия realtime — даём хоть какую-то динамику). + const pct_map = { + "В очереди": 2, + "Подготовка": 10, + "Отправка": 25, + "Ожидание": 60, + "Получен ответ": 80, + "Сохранение": 90, + "Готово": 100, + }; + const pct = pct_map[m.processing_stage] || 0; + const is_error = (m.processing_stage || "").startsWith("Ошибка"); + render_progress(frm, { + stage: m.processing_stage, + percent: is_error ? 0 : pct, + message: "", + is_error, + }); + } + + // Финал: вышли из «В обработке» — перезагружаем. + if (m.status !== "В обработке") { + clearInterval(dyak.meeting_record._polling_interval); + dyak.meeting_record._polling_interval = null; + cur_frm.reload_doc(); + } + } + }, + }); + }, 4000); +} + +// ────────────────────────────────────────────────────────────────────────── +// Рендеринг диалога +// ────────────────────────────────────────────────────────────────────────── + +function render_dialog(frm) { + const wrapper = frm.get_field("dialog_html").$wrapper; + if (!wrapper) return; + + let utterances = []; + if (frm.doc.utterances_json) { + try { + const parsed = typeof frm.doc.utterances_json === "string" + ? JSON.parse(frm.doc.utterances_json) + : frm.doc.utterances_json; + utterances = Array.isArray(parsed) ? parsed : (parsed.utterances || []); + } catch (e) { + console.warn("dyak: не удалось распарсить utterances_json", e); + } + } + + if (!utterances.length) { + wrapper.html(`
+ Расшифровка появится здесь после обработки записи. +
`); + return; + } + + const speaker_names = build_speaker_name_map(frm); + const speaker_index_map = {}; + let html = `
`; + + utterances.forEach(u => { + const speaker_id = u.speaker || "SPEAKER_??"; + const display_name = speaker_names[speaker_id] || speaker_id; + const color = get_color_for_speaker(speaker_id, speaker_index_map); + const start = format_time(u.start); + const end = format_time(u.end); + const text = escape_html(u.text || ""); + + html += ` +
+
+ [${escape_html(display_name)}] + + (${start}\u00a0–\u00a0${end}) + +
+
${text}
+
`; + }); + html += `
`; + wrapper.html(html); +} + +// ────────────────────────────────────────────────────────────────────────── +// Dashboard (метрики после обработки) +// ────────────────────────────────────────────────────────────────────────── + +function render_dashboard(frm) { + if (!frm.doc.audio_duration || frm.doc.status === "В обработке") return; + const headline = ` +
+
Длительность: ${format_time(frm.doc.audio_duration)}
+
Спикеров: ${frm.doc.num_speakers || "—"}
+
Язык: ${escape_html(frm.doc.detected_language || "—")}
+
Обработка: ${(frm.doc.processing_time || 0).toFixed(1)} сек
+
`; + frm.dashboard.set_headline_alert(headline, "blue"); +} + +// ────────────────────────────────────────────────────────────────────────── +// Назначение спикеров +// ────────────────────────────────────────────────────────────────────────── + +function open_speaker_assignment_dialog(frm) { + let utterances = []; + try { + const parsed = typeof frm.doc.utterances_json === "string" + ? JSON.parse(frm.doc.utterances_json) + : frm.doc.utterances_json; + utterances = Array.isArray(parsed) ? parsed : (parsed.utterances || []); + } catch (e) { + frappe.msgprint({ message: "Нет данных диаризации", indicator: "orange" }); + return; + } + + if (!utterances.length) { + frappe.msgprint({ message: "Нет данных диаризации", indicator: "orange" }); + return; + } + + const speakers = {}; + utterances.forEach(u => { + if (!u.speaker) return; + if (!(u.speaker in speakers)) { + speakers[u.speaker] = (u.text || "").slice(0, 100); + } + }); + + const participants = (frm.doc.participants || []) + .filter(p => p.participant_name) + .map(p => p.participant_name); + + if (!participants.length) { + frappe.msgprint({ + message: "Сначала добавьте участников в таблицу «Участники встречи»", + indicator: "orange", + }); + return; + } + + const fields = []; + Object.keys(speakers).sort().forEach(spk => { + fields.push({ + fieldname: spk, + label: `${spk} — «${speakers[spk]}${speakers[spk].length >= 100 ? "…" : ""}»`, + fieldtype: "Select", + options: ["", ...participants].join("\n"), + default: (frm.doc.participants || []).find(p => p.speaker_id === spk)?.participant_name || "", + }); + }); + + const d = new frappe.ui.Dialog({ + title: "Назначение спикеров", + size: "large", + fields: fields, + primary_action_label: "Сохранить", + primary_action(values) { + Object.keys(values).forEach(spk => { + const chosen_name = values[spk]; + (frm.doc.participants || []).forEach(row => { + if (row.speaker_id === spk && row.participant_name !== chosen_name) { + frappe.model.set_value(row.doctype, row.name, "speaker_id", ""); + } + }); + if (chosen_name) { + const target = (frm.doc.participants || []).find( + p => p.participant_name === chosen_name + ); + if (target) { + frappe.model.set_value(target.doctype, target.name, "speaker_id", spk); + } + } + }); + + frm.refresh_field("participants"); + frm.save().then(() => { + render_dialog(frm); + frappe.show_alert({ message: "Спикеры назначены", indicator: "green" }); + }); + d.hide(); + }, + }); + d.show(); +} + +// ────────────────────────────────────────────────────────────────────────── +// Главный обработчик формы +// ────────────────────────────────────────────────────────────────────────── + +frappe.ui.form.on("Meeting Record", { + onload(frm) { + subscribe_to_progress(frm); + }, + + refresh(frm) { + render_dialog(frm); + + // Если идёт обработка — показываем прогресс-бар; иначе — dashboard метрик. + if (frm.doc.status === "В обработке") { + const pct_map = { + "В очереди": 2, + "Подготовка": 10, + "Отправка": 25, + "Ожидание": 60, + "Получен ответ": 80, + "Сохранение": 90, + "Готово": 100, + }; + const pct = pct_map[frm.doc.processing_stage] || 5; + render_progress(frm, { + stage: frm.doc.processing_stage || "В очереди", + percent: pct, + message: "", + is_error: false, + }); + poll_status(frm); + subscribe_to_progress(frm); + } else { + render_dashboard(frm); + } + + // ─── Кнопка «Транскрибировать» ───────────────────────────────── + if (frm.doc.status === "Черновик" && frm.doc.audio_file && !frm.is_new()) { + frm.add_custom_button("Транскрибировать", () => { + frappe.call({ + method: "dyak.api.v1.transcribe.transcribe", + args: { docname: frm.doc.name }, + freeze: true, + freeze_message: "Постановка в очередь…", + callback() { + frappe.show_alert({ + message: "Запись отправлена на обработку", + indicator: "blue", + }); + // Сразу подхватываем смену статуса на «В обработке» + // и подключаем подписку на прогресс. + setTimeout(() => frm.reload_doc(), 800); + }, + }); + }).addClass("btn-primary"); + } + + // ─── Кнопка повтора при ошибке ───────────────────────────────── + if (frm.doc.status === "Черновик" && frm.doc.audio_file + && frm.doc.processing_stage && frm.doc.processing_stage.startsWith("Ошибка")) { + frm.add_custom_button("Очистить лог обработки", () => { + frappe.call({ + method: "frappe.client.set_value", + args: { + doctype: "Meeting Record", + name: frm.doc.name, + fieldname: { processing_stage: "", processing_log: "" }, + }, + callback() { frm.reload_doc(); }, + }); + }); + } + + // ─── Кнопка «Назначить спикеров» ─────────────────────────────── + if (frm.doc.status === "Расшифровано" || frm.doc.status === "Проверено") { + frm.add_custom_button("Назначить спикеров", () => { + open_speaker_assignment_dialog(frm); + }, "Действия"); + } + + // ─── AI-кнопки (заглушки) ────────────────────────────────────── + if (frm.doc.status === "Расшифровано" || frm.doc.status === "Проверено") { + frm.add_custom_button("Извлечь задачи", () => { + frappe.call({ method: "dyak.api.v1.ai.extract_action_items", args: { docname: frm.doc.name } }); + }, "AI"); + frm.add_custom_button("Сгенерировать резюме", () => { + frappe.call({ method: "dyak.api.v1.ai.generate_summary", args: { docname: frm.doc.name } }); + }, "AI"); + frm.add_custom_button("Анализ встречи", () => { + frappe.call({ method: "dyak.api.v1.ai.analyze_meeting", args: { docname: frm.doc.name } }); + }, "AI"); + } + + // ─── Workflow-кнопки ─────────────────────────────────────────── + if (frm.doc.status === "Расшифровано") { + frm.add_custom_button("На проверку", () => { + frm.set_value("status", "Проверено"); + frm.save(); + }).addClass("btn-secondary"); + } + if (frm.doc.status === "Проверено") { + frm.add_custom_button("Утвердить", () => { + frm.set_value("status", "Утверждено"); + frm.save(); + }).addClass("btn-success"); + } + }, + + utterances_json(frm) { + render_dialog(frm); + }, +}); + +// Перерисовываем диалог при изменении маппинга спикеров. +frappe.ui.form.on("Meeting Participant", { + speaker_id(frm) { render_dialog(frm); }, + participant_name(frm) { render_dialog(frm); }, +}); \ No newline at end of file diff --git a/dyak/dyak/doctype/meeting_record/meeting_record.js b/dyak/dyak/doctype/meeting_record/meeting_record.js new file mode 100644 index 0000000..22d3c42 --- /dev/null +++ b/dyak/dyak/doctype/meeting_record/meeting_record.js @@ -0,0 +1,1163 @@ +/** + * Дьяк — Meeting Record client script. + * + * Реализует: + * • Кнопку «Транскрибировать» (черновик + есть аудио). + * • Realtime-прогресс через `frappe.realtime.on("dyak_progress")`: + * отрисовка прогресс-бара с процентом, этапом и сообщением. + * • Polling-fallback на случай, если websocket не работает. + * • Кнопку «Назначить спикеров» (после расшифровки). + * • Рендер диалога по utterances_json в HTML-поле dialog_html. + * • Кнопки-заглушки AI-функций. + * • Workflow-кнопки «На проверку» / «Утвердить». + * • Dashboard со сводными метриками после обработки. + */ + +frappe.provide("dyak.meeting_record"); + +// Палитра цветов для спикеров (8 контрастных оттенков). +const SPEAKER_COLORS = [ + "#5e64ff", "#ff5858", "#28a745", "#ff8c00", + "#9b59b6", "#17a2b8", "#e83e8c", "#6c757d", +]; + +// ────────────────────────────────────────────────────────────────────────── +// Хелперы +// ────────────────────────────────────────────────────────────────────────── + +function format_time(seconds) { + if (seconds === null || seconds === undefined || isNaN(seconds)) return ""; + const total = Math.floor(Number(seconds)); + const m = Math.floor(total / 60); + const s = total % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +function get_color_for_speaker(speaker_id, speaker_index_map) { + if (!(speaker_id in speaker_index_map)) { + speaker_index_map[speaker_id] = Object.keys(speaker_index_map).length; + } + const idx = speaker_index_map[speaker_id]; + return SPEAKER_COLORS[idx % SPEAKER_COLORS.length]; +} + +function build_speaker_name_map(frm) { + const map = {}; + (frm.doc.participants || []).forEach(row => { + if (row.speaker_id && row.participant_name) { + map[row.speaker_id] = row.participant_name; + } + }); + return map; +} + +function escape_html(text) { + if (text === null || text === undefined) return ""; + const div = document.createElement("div"); + div.textContent = String(text); + return div.innerHTML; +} + +// ────────────────────────────────────────────────────────────────────────── +// Прогресс-индикатор +// ────────────────────────────────────────────────────────────────────────── + +/** + * Рисует «живую» плашку с прогресс-баром, текущим этапом, процентом и + * последним сообщением. Если is_error=true — окрашивается в красное. + */ +function render_progress(frm, { stage, percent, message, is_error, ts } = {}) { + if (!frm) return; + + // Если запись не в обработке — убрать плашку. + const in_progress = frm.doc.status === "В обработке" + || (stage && stage !== "Готово" && !is_error && percent !== 100); + + if (!in_progress && !is_error) { + frm.dashboard.clear_headline(); + return; + } + + const safe_stage = escape_html(stage || frm.doc.processing_stage || "Ожидание…"); + const safe_msg = escape_html(message || ""); + const pct = Math.max(0, Math.min(100, Number(percent) || 0)); + const color = is_error ? "#d9534f" : "#5e64ff"; + const bar_bg = is_error ? "#f8d7da" : "#e8e9ff"; + const ts_label = ts ? `${escape_html(ts)}` : ""; + + const html = ` +
+
+ ${safe_stage} + ${pct}% + ${ts_label} +
+
+
+
+ ${safe_msg ? `
${safe_msg}
` : ""} +
+ `; + + frm.dashboard.clear_headline(); + frm.dashboard.set_headline_alert(html, is_error ? "red" : "blue"); +} + +/** + * Рендерит компактный индикатор обработки в HTML-поле + * `processing_compact_html`. Заменил длинный read-only лог в форме — + * пользователь видит только текущий этап, по кнопке «История этапов» + * открывается модалка с полным логом. + */ +function render_processing_compact(frm) { + const wrapper = frm.get_field("processing_compact_html") && + frm.get_field("processing_compact_html").$wrapper; + if (!wrapper) return; + + const stage = (frm.doc.processing_stage || "").trim(); + const log = (frm.doc.processing_log || "").trim(); + + // Если ничего нет — показываем placeholder. + if (!stage && !log) { + wrapper.html( + `
+ Записей о фоновой обработке пока нет. +
` + ); + return; + } + + // Цвет по типу этапа. + const is_error = stage.toLowerCase().startsWith("ошибка"); + const is_done = stage === "Готово"; + const color = is_error + ? "var(--red-500, #d9534f)" + : (is_done ? "var(--green-500, #28a745)" : "var(--blue-500, #5e64ff)"); + const icon = is_error ? "❌" : (is_done ? "✓" : "⟳"); + + const log_button = log + ? `` + : ""; + + wrapper.html(` +
+ + ${icon} ${escape_html(stage || "—")} + + ${log_button} +
+ `); + + // Кнопка показать лог — открывает модалку с pre-блоком. + wrapper.find(".dyak-show-log").off("click.dyak").on("click.dyak", () => { + show_processing_log_dialog(log); + }); +} + +function show_processing_log_dialog(log_text) { + const html = render_processing_log_html(log_text); + const d = new frappe.ui.Dialog({ + title: "История этапов обработки", + size: "large", + fields: [{ fieldtype: "HTML", options: html }], + }); + d.show(); +} + +/** + * Рендерит processing_log как структурированную хронологию. + * + * Формат строк лога (см. transcribe._publish_progress): + * [HH:MM:SS] этап: сообщение + * [HH:MM:SS] этап ❌: сообщение ← ошибка + * + * Этап может содержать префикс — «Чат · …», «Профиль · …», «AI · …» — + * это уже подкатегория для группировки в UI. + */ +function render_processing_log_html(log_text) { + if (!log_text || !log_text.trim()) { + return `
+ Лог пуст. Запустите транскрибацию или применение профиля. +
`; + } + + const entries = parse_processing_log(log_text); + if (!entries.length) { + // Не смогли распарсить ни одной строки — fallback на raw. + return `
${escape_html(log_text)}
`; + } + + // Подсчёт по уровням — для шапки. + const counts = { info: 0, warn: 0, error: 0 }; + for (const e of entries) counts[e.level] = (counts[e.level] || 0) + 1; + + const header = ` +
+
+ Всего: ${entries.length} + ${counts.info ? ` + ℹ ${counts.info}` : ""} + ${counts.warn ? ` + ⚠ ${counts.warn}` : ""} + ${counts.error ? ` + ✖ ${counts.error}` : ""} +
+
+ `; + + const level_colors = { + info: "#5e64ff", + warn: "#f0b400", + error: "#d9534f", + }; + const level_icons = { info: "ℹ", warn: "⚠", error: "✖" }; + + const items = entries.map(e => { + const color = level_colors[e.level] || "#888"; + const icon = level_icons[e.level] || "·"; + // Подкатегория («Чат», «Профиль», «AI») — отделяем визуально. + let stage_html = ""; + if (e.category) { + stage_html = ` + ${escape_html(e.category)} + ${escape_html(e.stage)} + `; + } else { + stage_html = `${escape_html(e.stage)}`; + } + return ` +
+
+ ${icon} + ${escape_html(e.ts)} + ${stage_html} +
+
+ ${escape_html(e.message)} +
+
+ `; + }).join(""); + + return _LOG_DIALOG_STYLES + header + ` +
+
+ 📜 Хронология +
+
${items}
+
+ `; +} + +// Стили inline, потому что dyak_chat.css грузится только на странице +// /app/dyak-chat, а лог обработки показывается из формы Meeting Record. +const _LOG_DIALOG_STYLES = ` + +`; + +/** + * Парсит строку лога формата `[HH:MM:SS] этап ❌: сообщение`. + * + * Возвращает массив объектов: + * {ts: "14:23:01", category: "Чат" | null, stage: "Подготовка", + * message: "...", level: "info" | "warn" | "error"} + * + * Уровень определяется так: + * • в строке есть «❌» или «ошибка» (case-insensitive) → error + * • в строке есть «обрезан» / «таймаут» / «warn» → warn + * • иначе → info + */ +function parse_processing_log(log_text) { + const lines = log_text.split("\n"); + const entries = []; + // Регэксп: timestamp, потом до первого ':', потом сообщение. + // ❌ в имени этапа учитываем отдельно. + const re = /^\[(\d{1,2}:\d{2}:\d{2}(?:\.\d+)?)\]\s+(.+?):\s*(.*)$/; + for (const raw of lines) { + const line = raw.trim(); + if (!line) continue; + const m = line.match(re); + if (!m) { + // Если строка не подошла под шаблон — добавим её как + // «свободный» текст без timestamp. + entries.push({ + ts: "", category: null, stage: "—", + message: line, level: "info", + }); + continue; + } + const ts = m[1]; + let stage_part = m[2].trim(); + const message = m[3] || ""; + + // Уровень. + let level = "info"; + if (stage_part.includes("❌") || /ошибк/i.test(stage_part) + || /ошибк/i.test(message)) { + level = "error"; + } else if (/обрезан|таймаут|warn/i.test(stage_part + " " + message)) { + level = "warn"; + } + stage_part = stage_part.replace(/\s*❌\s*$/, "").trim(); + + // Категория («Чат · Подготовка» → cat=Чат, stage=Подготовка). + let category = null; + let stage = stage_part; + const cat_match = stage_part.match(/^([^·]+?)\s*·\s*(.+)$/); + if (cat_match) { + category = cat_match[1].trim(); + stage = cat_match[2].trim(); + } + entries.push({ ts, category, stage, message, level }); + } + return entries; +} + +// ────────────────────────────────────────────────────────────────────────── +// Подписка на realtime +// ────────────────────────────────────────────────────────────────────────── + +function subscribe_to_progress(frm) { + // Чистим прежний обработчик, если есть. + if (dyak.meeting_record._unsubscribe) { + try { dyak.meeting_record._unsubscribe(); } catch (e) { /* noop */ } + dyak.meeting_record._unsubscribe = null; + } + + const handler = (data) => { + if (!data || data.docname !== frm.doc.name) return; + + render_progress(frm, data); + + // При finalных состояниях — обновляем форму, чтобы подтянулись + // новые поля (utterances_json, full_text, audio_duration и т.д.). + if (data.percent === 100 || data.is_error) { + // Небольшая задержка, чтобы коммит точно успел докатиться. + setTimeout(() => { + if (cur_frm && cur_frm.doc.name === frm.doc.name) { + cur_frm.reload_doc(); + } + }, 800); + } else { + // На промежуточных этапах подгрузим только processing_log, + // чтобы пользователь видел историю. + frappe.db.get_value( + "Meeting Record", frm.doc.name, + ["processing_log", "processing_stage"] + ).then(r => { + if (r.message && cur_frm && cur_frm.doc.name === frm.doc.name) { + cur_frm.doc.processing_log = r.message.processing_log; + cur_frm.doc.processing_stage = r.message.processing_stage; + cur_frm.refresh_field("processing_log"); + cur_frm.refresh_field("processing_stage"); + render_processing_compact(cur_frm); + } + }); + } + }; + + frappe.realtime.on("dyak_progress", handler); + dyak.meeting_record._unsubscribe = () => frappe.realtime.off("dyak_progress", handler); +} + +// ────────────────────────────────────────────────────────────────────────── +// Polling-fallback (на случай отсутствия websocket) +// ────────────────────────────────────────────────────────────────────────── + +function poll_status(frm) { + if (dyak.meeting_record._polling_interval) return; + + dyak.meeting_record._polling_interval = setInterval(() => { + if (!cur_frm || cur_frm.doc.name !== frm.doc.name) { + clearInterval(dyak.meeting_record._polling_interval); + dyak.meeting_record._polling_interval = null; + return; + } + + frappe.call({ + method: "dyak.api.v1.transcribe.get_progress", + args: { docname: frm.doc.name }, + callback(r) { + const m = r.message; + if (!m) return; + // Обновляем поля локально, без reload, чтобы прогресс-плашка + // и лог жили синхронно с состоянием БД. + if (cur_frm && cur_frm.doc.name === frm.doc.name) { + let dirty = false; + if (cur_frm.doc.processing_stage !== m.processing_stage) { + cur_frm.doc.processing_stage = m.processing_stage; + cur_frm.refresh_field("processing_stage"); + dirty = true; + } + if (cur_frm.doc.processing_log !== m.processing_log) { + cur_frm.doc.processing_log = m.processing_log; + cur_frm.refresh_field("processing_log"); + } + // Перерисовываем компактный индикатор всегда, когда + // поменялся стейдж или лог. + if (dirty || cur_frm.doc.processing_log !== m.processing_log) { + render_processing_compact(cur_frm); + } + if (dirty) { + // Аппроксимация процента по этапу (для случая + // отсутствия realtime — даём хоть какую-то динамику). + const pct_map = { + "В очереди": 2, + "Подготовка": 10, + "Отправка": 25, + "Ожидание": 60, + "Получен ответ": 80, + "Сохранение": 90, + "Готово": 100, + }; + const pct = pct_map[m.processing_stage] || 0; + const is_error = (m.processing_stage || "").startsWith("Ошибка"); + render_progress(frm, { + stage: m.processing_stage, + percent: is_error ? 0 : pct, + message: "", + is_error, + }); + } + + // Финал: вышли из «В обработке» — перезагружаем. + if (m.status !== "В обработке") { + clearInterval(dyak.meeting_record._polling_interval); + dyak.meeting_record._polling_interval = null; + cur_frm.reload_doc(); + } + } + }, + }); + }, 4000); +} + +// ────────────────────────────────────────────────────────────────────────── +// Рендеринг диалога +// ────────────────────────────────────────────────────────────────────────── + +function render_dialog(frm) { + const wrapper = frm.get_field("dialog_html").$wrapper; + if (!wrapper) return; + + let utterances = []; + if (frm.doc.utterances_json) { + try { + const parsed = typeof frm.doc.utterances_json === "string" + ? JSON.parse(frm.doc.utterances_json) + : frm.doc.utterances_json; + utterances = Array.isArray(parsed) ? parsed : (parsed.utterances || []); + } catch (e) { + console.warn("dyak: не удалось распарсить utterances_json", e); + } + } + + if (!utterances.length) { + wrapper.html(`
+ Расшифровка появится здесь после обработки записи. +
`); + return; + } + + const speaker_names = build_speaker_name_map(frm); + const speaker_index_map = {}; + let html = `
`; + + utterances.forEach(u => { + const speaker_id = u.speaker || "SPEAKER_??"; + const display_name = speaker_names[speaker_id] || speaker_id; + const color = get_color_for_speaker(speaker_id, speaker_index_map); + const start = format_time(u.start); + const end = format_time(u.end); + const text = escape_html(u.text || ""); + + html += ` +
+
+ [${escape_html(display_name)}] + + (${start}\u00a0–\u00a0${end}) + +
+
${text}
+
`; + }); + html += `
`; + wrapper.html(html); +} + +// ────────────────────────────────────────────────────────────────────────── +// Dashboard (метрики после обработки) +// ────────────────────────────────────────────────────────────────────────── + +function render_dashboard(frm) { + if (!frm.doc.audio_duration || frm.doc.status === "В обработке") return; + const headline = ` +
+
Длительность: ${format_time(frm.doc.audio_duration)}
+
Спикеров: ${frm.doc.num_speakers || "—"}
+
Язык: ${escape_html(frm.doc.detected_language || "—")}
+
Обработка: ${(frm.doc.processing_time || 0).toFixed(1)} сек
+
`; + frm.dashboard.set_headline_alert(headline, "blue"); +} + +// ────────────────────────────────────────────────────────────────────────── +// Назначение спикеров +// ────────────────────────────────────────────────────────────────────────── + +function open_speaker_assignment_dialog(frm) { + let utterances = []; + try { + const parsed = typeof frm.doc.utterances_json === "string" + ? JSON.parse(frm.doc.utterances_json) + : frm.doc.utterances_json; + utterances = Array.isArray(parsed) ? parsed : (parsed.utterances || []); + } catch (e) { + frappe.msgprint({ message: "Нет данных диаризации", indicator: "orange" }); + return; + } + + if (!utterances.length) { + frappe.msgprint({ message: "Нет данных диаризации", indicator: "orange" }); + return; + } + + const speakers = {}; + utterances.forEach(u => { + if (!u.speaker) return; + if (!(u.speaker in speakers)) { + speakers[u.speaker] = (u.text || "").slice(0, 100); + } + }); + + const participants = (frm.doc.participants || []) + .filter(p => p.participant_name) + .map(p => p.participant_name); + + if (!participants.length) { + frappe.msgprint({ + message: "Сначала добавьте участников в таблицу «Участники встречи»", + indicator: "orange", + }); + return; + } + + const fields = []; + Object.keys(speakers).sort().forEach(spk => { + fields.push({ + fieldname: spk, + label: `${spk} — «${speakers[spk]}${speakers[spk].length >= 100 ? "…" : ""}»`, + fieldtype: "Select", + options: ["", ...participants].join("\n"), + default: (frm.doc.participants || []).find(p => p.speaker_id === spk)?.participant_name || "", + }); + }); + + const d = new frappe.ui.Dialog({ + title: "Назначение спикеров", + size: "large", + fields: fields, + primary_action_label: "Сохранить", + primary_action(values) { + Object.keys(values).forEach(spk => { + const chosen_name = values[spk]; + (frm.doc.participants || []).forEach(row => { + if (row.speaker_id === spk && row.participant_name !== chosen_name) { + frappe.model.set_value(row.doctype, row.name, "speaker_id", ""); + } + }); + if (chosen_name) { + const target = (frm.doc.participants || []).find( + p => p.participant_name === chosen_name + ); + if (target) { + frappe.model.set_value(target.doctype, target.name, "speaker_id", spk); + } + } + }); + + frm.refresh_field("participants"); + frm.save().then(() => { + render_dialog(frm); + frappe.show_alert({ message: "Спикеры назначены", indicator: "green" }); + }); + d.hide(); + }, + }); + d.show(); +} + +// ────────────────────────────────────────────────────────────────────────── +// Аналитика по профилям +// ────────────────────────────────────────────────────────────────────────── + +/** + * Диалог «Применить профиль»: загружает активные профили, даёт + * multi-select, при подтверждении ставит на каждый профиль фоновую задачу. + */ +function open_apply_profile_dialog(frm) { + frappe.call({ + method: "dyak.api.v1.profiles.list_active_profiles", + callback(r) { + const profiles = (r.message || []); + if (!profiles.length) { + frappe.msgprint({ + message: "Нет активных профилей. Создайте их в разделе " + + "«Analysis Profile» или включите встроенный профиль " + + "«Стандартный анализ».", + title: "Нет профилей", + indicator: "orange", + }); + return; + } + + // Diff-friendly список флагов: по одному Check на профиль. + const fields = [ + { + fieldname: "info_html", + fieldtype: "HTML", + options: `
Можно выбрать несколько профилей — + каждый запустится отдельной фоновой задачей.
`, + }, + ]; + profiles.forEach(p => { + fields.push({ + fieldname: `_p_${p.name}`, + fieldtype: "Check", + label: p.profile_name + (p.is_builtin ? " (встроенный)" : ""), + description: p.description || "", + }); + }); + + const d = new frappe.ui.Dialog({ + title: "Применить профиль анализа", + size: "large", + fields, + primary_action_label: "Запустить", + primary_action(values) { + const selected = profiles + .map(p => p.name) + .filter(name => values[`_p_${name}`]); + if (!selected.length) { + frappe.show_alert({ + message: "Не выбран ни один профиль", + indicator: "orange", + }); + return; + } + frappe.call({ + method: "dyak.api.v1.profiles.apply_profiles", + args: { + docname: frm.doc.name, + profile_names: JSON.stringify(selected), + }, + freeze: true, + freeze_message: "Постановка в очередь…", + callback(r) { + const n = (r.message || {}).queued || 0; + frappe.show_alert({ + message: `Запущено профилей: ${n}`, + indicator: "blue", + }); + d.hide(); + // Сразу обновим секцию аналитики, чтобы появились + // строки «В очереди». + setTimeout(() => render_analysis_results(frm), 600); + }, + }); + }, + }); + d.show(); + }, + }); +} + +/** + * Рендерит секцию `analysis_results_html`: список Meeting Analysis Result + * для текущей встречи. Каждая строка — карточка с именем профиля, + * статусом, временем; клик «раскрыть» выводит result_html. + */ +function render_analysis_results(frm) { + const wrapper = frm.get_field("analysis_results_html").$wrapper; + if (!wrapper) return; + + if (frm.is_new()) { + wrapper.html(`
+ Сохраните встречу, чтобы запустить профили анализа. +
`); + return; + } + + frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "Meeting Analysis Result", + filters: { meeting_record: frm.doc.name }, + fields: [ + "name", "profile", "profile_name_snapshot", "status", + "model_used", "started_at", "completed_at", + "error_message", + ], + order_by: "creation desc", + limit_page_length: 50, + }, + callback(r) { + const rows = r.message || []; + wrapper.html(_build_analysis_results_html(rows, frm)); + _wire_analysis_result_buttons(wrapper, frm); + }, + }); +} + +function _build_analysis_results_html(rows, frm) { + if (!rows.length) { + return `
+ Профили ещё не применялись. + Нажмите AI → Применить профиль, чтобы запустить анализ. +
`; + } + + const status_colors = { + "В очереди": "#888", + "В обработке": "#5e64ff", + "Готово": "#28a745", + "Ошибка": "#d9534f", + }; + + const cards = rows.map(row => { + const color = status_colors[row.status] || "#888"; + const time_label = row.completed_at + ? frappe.datetime.str_to_user(row.completed_at) + : (row.started_at ? frappe.datetime.str_to_user(row.started_at) : ""); + + const status_badge = ` + ${frappe.utils.escape_html(row.status)} + `; + + const model_part = row.model_used + ? `${frappe.utils.escape_html(row.model_used)}` + : ""; + + const error_part = (row.status === "Ошибка" && row.error_message) + ? `
${frappe.utils.escape_html(row.error_message)}
` + : ""; + + const expand_button = (row.status === "Готово") + ? `` + : ""; + + const open_link = `↗ Открыть`; + + return ` +
+
+
+ ${frappe.utils.escape_html(row.profile_name_snapshot + || row.name)} +
+ ${status_badge} + ${model_part} + + ${frappe.utils.escape_html(time_label)} + + ${expand_button} + ${open_link} +
+ ${error_part} + +
+ `; + }).join(""); + + return `
${cards}
`; +} + +function _wire_analysis_result_buttons(wrapper, frm) { + wrapper.find(".dyak-mar-expand").off("click.dyak").on("click.dyak", function (e) { + e.preventDefault(); + const $btn = $(this); + const name = $btn.data("name"); + const $card = $btn.closest(".dyak-mar-card"); + const $body = $card.find(".dyak-mar-body"); + const profileName = $btn.data("profile"); + + if ($body.is(":visible")) { + $body.hide(); + $btn.text("Раскрыть"); + return; + } + + // Lazy-load: тянем result_json + output_schema профиля, рендерим + // на клиенте. + if (!$body.data("loaded")) { + $body.html(`
Загрузка…
`); + $body.show(); + $btn.text("Свернуть"); + + Promise.all([ + frappe.db.get_value( + "Meeting Analysis Result", name, "result_json" + ), + profileName + ? frappe.db.get_value( + "Analysis Profile", profileName, "output_schema" + ) + : Promise.resolve({ message: {} }), + ]).then(([resR, schemaR]) => { + const jsonStr = (resR.message && resR.message.result_json) || ""; + const schemaStr = (schemaR.message && schemaR.message.output_schema) || ""; + const html = dyak_render_analysis(jsonStr, schemaStr); + $body.html(html); + $body.data("loaded", true); + }); + } else { + $body.show(); + $btn.text("Свернуть"); + } + }); +} + +/** + * Рендерит отчёт по результату профиля. + * + * @param {string|object} resultJson — result_json (строка с JSON или объект) + * @param {string|object} outputSchema — output_schema профиля + * @returns {string} HTML + * + * Если схема не задана или невалидна — возвращает
с pretty JSON. + * Поддерживаемые типы: text, long_text, select, date, number, + * list_of_strings, list_of_objects. + */ +function dyak_render_analysis(resultJson, outputSchema) { + const esc = frappe.utils.escape_html; + let parsed = null; + let schema = null; + try { + parsed = typeof resultJson === "string" + ? JSON.parse(resultJson) : resultJson; + } catch (_) { parsed = null; } + try { + schema = typeof outputSchema === "string" && outputSchema + ? JSON.parse(outputSchema) : outputSchema; + } catch (_) { schema = null; } + + function isEmpty(v) { + if (v === null || v === undefined) return true; + if (typeof v === "string" && !v.trim()) return true; + if (Array.isArray(v) && v.length === 0) return true; + if (typeof v === "object" && !Array.isArray(v) + && Object.keys(v).length === 0) return true; + return false; + } + + function renderField(field, value) { + const label = field.label || field.key; + const type = (field.type || "text").toLowerCase(); + const labelHtml = `
${esc(label)}
`; + let body; + + if (["text", "select", "date", "number"].includes(type)) { + body = `
${esc(String(value))}
`; + } else if (type === "long_text") { + body = `
${esc(String(value))}
`; + } else if (type === "list_of_strings") { + const items = (value || []) + .filter(v => !isEmpty(v)) + .map(v => `
  • ${esc(String(v))}
  • `).join(""); + body = `
      ${items}
    `; + } else if (type === "list_of_objects") { + const sub = field.item_schema || []; + const cards = (value || []).map(obj => { + if (!obj || typeof obj !== "object") return ""; + const lines = sub.map(s => { + const sk = s.key; + if (!sk) return ""; + const sv = obj[sk]; + if (isEmpty(sv)) return ""; + return `
    ${esc(s.label || sk)}: ${esc(String(sv))}
    `; + }).filter(Boolean).join(""); + return lines + ? `
    ${lines}
    ` + : ""; + }).filter(Boolean).join(""); + body = `
    ${cards}
    `; + } else { + body = `
    ${esc(JSON.stringify(value))}
    `; + } + + return `
    + ${labelHtml}${body}
    `; + } + + function renderFallback() { + const pretty = parsed + ? JSON.stringify(parsed, null, 2) + : (resultJson || "(пусто)"); + return `
    + + Сырой JSON-результат + +
    ${esc(pretty)}
    +
    `; + } + + if (!schema || !schema.sections || !Array.isArray(schema.sections) + || !parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return renderFallback(); + } + + const parts = []; + for (const section of schema.sections) { + const fields = section.fields || []; + const rows = []; + for (const f of fields) { + const key = f.key; + if (!key) continue; + const value = parsed[key]; + if (isEmpty(value)) continue; + rows.push(renderField(f, value)); + } + if (!rows.length) continue; + parts.push(`
    +

    ${esc(section.title || "")}

    +
    ${rows.join("")}
    +
    `); + } + + if (!parts.length) return renderFallback(); + return `
    ${parts.join("")}
    `; +} + +// Делаем функцию глобально доступной, чтобы её мог использовать +// клиентский скрипт Meeting Analysis Result. +window.dyak_render_analysis = dyak_render_analysis; + +/** + * Подписка на realtime-обновления результатов профилей. Когда фоновый + * процесс меняет статус Meeting Analysis Result (через старый канал + * `dyak_progress` со stage, начинающимся на `Профиль · `), мы + * перерисовываем секцию аналитики. + */ +function subscribe_to_analysis_updates(frm) { + if (dyak.meeting_record._analysis_unsubscribe) { + try { dyak.meeting_record._analysis_unsubscribe(); } catch (e) { /* */ } + dyak.meeting_record._analysis_unsubscribe = null; + } + const handler = (data) => { + if (!data || !data.stage) return; + if (data.docname !== frm.doc.name) return; + if (!data.stage.startsWith("Профиль")) return; + // Перерисовываем секцию — статусы там обновятся. + render_analysis_results(frm); + }; + frappe.realtime.on("dyak_progress", handler); + dyak.meeting_record._analysis_unsubscribe = + () => frappe.realtime.off("dyak_progress", handler); +} + +frappe.ui.form.on("Meeting Record", { + onload(frm) { + subscribe_to_progress(frm); + subscribe_to_analysis_updates(frm); + }, + + refresh(frm) { + render_dialog(frm); + render_analysis_results(frm); + render_processing_compact(frm); + + // Если идёт обработка — показываем прогресс-бар; иначе — dashboard метрик. + if (frm.doc.status === "В обработке") { + const pct_map = { + "В очереди": 2, + "Подготовка": 10, + "Отправка": 25, + "Ожидание": 60, + "Получен ответ": 80, + "Сохранение": 90, + "Готово": 100, + }; + const pct = pct_map[frm.doc.processing_stage] || 5; + render_progress(frm, { + stage: frm.doc.processing_stage || "В очереди", + percent: pct, + message: "", + is_error: false, + }); + poll_status(frm); + subscribe_to_progress(frm); + } else { + render_dashboard(frm); + } + + // ─── Кнопка «Транскрибировать» ───────────────────────────────── + if (frm.doc.status === "Черновик" && frm.doc.audio_file && !frm.is_new()) { + frm.add_custom_button("Транскрибировать", () => { + frappe.call({ + method: "dyak.api.v1.transcribe.transcribe", + args: { docname: frm.doc.name }, + freeze: true, + freeze_message: "Постановка в очередь…", + callback() { + frappe.show_alert({ + message: "Запись отправлена на обработку", + indicator: "blue", + }); + // Сразу подхватываем смену статуса на «В обработке» + // и подключаем подписку на прогресс. + setTimeout(() => frm.reload_doc(), 800); + }, + }); + }).addClass("btn-primary"); + } + + // ─── Кнопка повтора при ошибке ───────────────────────────────── + if (frm.doc.status === "Черновик" && frm.doc.audio_file + && frm.doc.processing_stage && frm.doc.processing_stage.startsWith("Ошибка")) { + frm.add_custom_button("Очистить лог обработки", () => { + frappe.call({ + method: "frappe.client.set_value", + args: { + doctype: "Meeting Record", + name: frm.doc.name, + fieldname: { processing_stage: "", processing_log: "" }, + }, + callback() { frm.reload_doc(); }, + }); + }); + } + + // ─── Кнопка «Назначить спикеров» ─────────────────────────────── + if (frm.doc.status === "Расшифровано" || frm.doc.status === "Проверено") { + frm.add_custom_button("Назначить спикеров", () => { + open_speaker_assignment_dialog(frm); + }, "Действия"); + } + + // ─── AI-кнопки ────────────────────────────────────────────────── + if (frm.doc.status === "Расшифровано" || frm.doc.status === "Проверено") { + frm.add_custom_button("Сгенерировать резюме", () => { + frappe.call({ + method: "dyak.api.v1.ai.generate_summary", + args: { docname: frm.doc.name }, + callback() { + frappe.show_alert({ + message: "Резюме отправлено на генерацию", + indicator: "blue", + }); + }, + }); + }, "AI"); + + frm.add_custom_button("Применить профиль…", () => { + open_apply_profile_dialog(frm); + }, "AI"); + } + + // ─── Workflow-кнопки ─────────────────────────────────────────── + if (frm.doc.status === "Расшифровано") { + frm.add_custom_button("На проверку", () => { + frm.set_value("status", "Проверено"); + frm.save(); + }).addClass("btn-secondary"); + } + if (frm.doc.status === "Проверено") { + frm.add_custom_button("Утвердить", () => { + frm.set_value("status", "Утверждено"); + frm.save(); + }).addClass("btn-success"); + } + }, + + utterances_json(frm) { + render_dialog(frm); + }, +}); + +// Перерисовываем диалог при изменении маппинга спикеров. +frappe.ui.form.on("Meeting Participant", { + speaker_id(frm) { render_dialog(frm); }, + participant_name(frm) { render_dialog(frm); }, +}); \ No newline at end of file diff --git a/dyak/dyak/doctype/meeting_record/meeting_record.json b/dyak/dyak/doctype/meeting_record/meeting_record.json new file mode 100644 index 0000000..da627ba --- /dev/null +++ b/dyak/dyak/doctype/meeting_record/meeting_record.json @@ -0,0 +1,351 @@ +{ + "actions": [], + "autoname": "MR-.YYYY.-.#####", + "creation": "2026-01-01 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "main_section", + "title", + "meeting_date", + "category", + "audio_file", + "column_break_main", + "project", + "description", + "processing_subsection", + "status", + "processing_compact_html", + "audio_subsection", + "audio_duration", + "tab_2_tab", + "\u0434\u0438\u0430\u0440\u0438\u0437\u0430\u0446\u0438\u044f_section", + "full_text", + "participants_subsection", + "participants", + "dialog_html", + "\u0440\u0435\u0437\u044e\u043c\u0435_\u0432\u0441\u0442\u0440\u0435\u0447\u0438_tab", + "summary_subsection", + "summary", + "column_break_summary", + "meeting_mood", + "meeting_topics", + "\u0430\u043d\u0430\u043b\u0438\u0442\u0438\u043a\u0430_tab", + "analysis_subsection", + "analysis_results_html", + "tab_3_tab", + "tech_subsection", + "detected_language", + "num_speakers", + "processing_time", + "column_break_tech", + "utterances_json", + "processing_stage", + "\u043b\u043e\u0433_\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438_section", + "processing_log" + ], + "fields": [ + { + "fieldname": "main_section", + "fieldtype": "Section Break", + "label": "\u041e\u0441\u043d\u043e\u0432\u043d\u0430\u044f \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f" + }, + { + "description": "\u0422\u0435\u043c\u0430 \u0438\u043b\u0438 \u043f\u043e\u0432\u0435\u0441\u0442\u043a\u0430 \u0434\u043d\u044f \u0432\u0441\u0442\u0440\u0435\u0447\u0438", + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0432\u0441\u0442\u0440\u0435\u0447\u0438", + "reqd": 1 + }, + { + "description": "\u041a\u043e\u0433\u0434\u0430 \u043f\u0440\u043e\u0445\u043e\u0434\u0438\u043b\u0430 \u0432\u0441\u0442\u0440\u0435\u0447\u0430", + "fieldname": "meeting_date", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "\u0414\u0430\u0442\u0430 \u0438 \u0432\u0440\u0435\u043c\u044f \u0437\u0430\u043f\u0438\u0441\u0438", + "reqd": 1 + }, + { + "description": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0435\u043a\u0442\u0430, \u043a \u043a\u043e\u0442\u043e\u0440\u043e\u043c\u0443 \u043e\u0442\u043d\u043e\u0441\u0438\u0442\u0441\u044f \u0432\u0441\u0442\u0440\u0435\u0447\u0430", + "fieldname": "project", + "fieldtype": "Data", + "in_list_view": 1, + "label": "\u041f\u0440\u043e\u0435\u043a\u0442" + }, + { + "fieldname": "column_break_main", + "fieldtype": "Column Break" + }, + { + "description": "\u0422\u0438\u043f \u0432\u0441\u0442\u0440\u0435\u0447\u0438 \u0434\u043b\u044f \u043a\u043b\u0430\u0441\u0441\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438", + "fieldname": "category", + "fieldtype": "Select", + "label": "\u041a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f", + "options": "\n\u0415\u0436\u0435\u0434\u043d\u0435\u0432\u043d\u044b\u0439 \u0441\u0442\u0435\u043d\u0434\u0430\u043f\n\u0415\u0436\u0435\u043d\u0435\u0434\u0435\u043b\u044c\u043d\u0430\u044f \u0432\u0441\u0442\u0440\u0435\u0447\u0430\nSprint Review\n\u0420\u0435\u0442\u0440\u043e\u0441\u043f\u0435\u043a\u0442\u0438\u0432\u0430\n\u0417\u0432\u043e\u043d\u043e\u043a \u0441 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u043c\n\u0418\u043d\u0442\u0435\u0440\u0432\u044c\u044e\n\u0414\u0440\u0443\u0433\u043e\u0435", + "reqd": 1 + }, + { + "description": "\u0427\u0442\u043e \u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043b\u043e\u0441\u044c \u043e\u0431\u0441\u0443\u0434\u0438\u0442\u044c \u043d\u0430 \u0432\u0441\u0442\u0440\u0435\u0447\u0435", + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "\u041f\u043e\u0432\u0435\u0441\u0442\u043a\u0430 / \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435", + "max_height": "100px" + }, + { + "collapsible": 1, + "fieldname": "audio_subsection", + "fieldtype": "Section Break", + "label": "\u0410\u0443\u0434\u0438\u043e\u0437\u0430\u043f\u0438\u0441\u044c" + }, + { + "description": "\u0417\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u0435 \u0437\u0430\u043f\u0438\u0441\u044c \u0432\u0441\u0442\u0440\u0435\u0447\u0438 (.aac, .mp3, .wav, .m4a, .ogg)", + "fieldname": "audio_file", + "fieldtype": "Attach", + "label": "\u0410\u0443\u0434\u0438\u043e\u0444\u0430\u0439\u043b", + "reqd": 1 + }, + { + "description": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u044f\u0435\u0442\u0441\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043f\u043e\u0441\u043b\u0435 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438", + "fieldname": "audio_duration", + "fieldtype": "Float", + "label": "\u0414\u043b\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u044c (\u0441\u0435\u043a)", + "read_only": 1 + }, + { + "fieldname": "participants_subsection", + "fieldtype": "Section Break", + "label": "\u0423\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438" + }, + { + "description": "\u0414\u043e\u0431\u0430\u0432\u044c\u0442\u0435 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432. \u041f\u043e\u0441\u043b\u0435 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u043d\u0430\u0437\u043d\u0430\u0447\u044c\u0442\u0435 \u0438\u043c \u0441\u043f\u0438\u043a\u0435\u0440\u043e\u0432 \u0438\u0437 \u0434\u0438\u0430\u0440\u0438\u0437\u0430\u0446\u0438\u0438", + "fieldname": "participants", + "fieldtype": "Table", + "label": "\u0423\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438 \u0432\u0441\u0442\u0440\u0435\u0447\u0438", + "options": "Meeting Participant" + }, + { + "fieldname": "summary_subsection", + "fieldtype": "Section Break", + "label": "\u0420\u0435\u0437\u044e\u043c\u0435" + }, + { + "description": "\u041a\u0440\u0430\u0442\u043a\u043e\u0435 \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435. \u0417\u0430\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u0438\u043b\u0438 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043f\u0440\u043e\u0444\u0438\u043b\u0435\u043c \u00ab\u0421\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439 \u0430\u043d\u0430\u043b\u0438\u0437\u00bb", + "fieldname": "summary", + "fieldtype": "Text Editor", + "label": "\u0420\u0435\u0437\u044e\u043c\u0435 \u0432\u0441\u0442\u0440\u0435\u0447\u0438" + }, + { + "description": "\u041a\u043b\u044e\u0447\u0435\u0432\u044b\u0435 \u0442\u0435\u043c\u044b \u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e", + "fieldname": "meeting_topics", + "fieldtype": "Small Text", + "label": "\u0422\u0435\u043c\u044b \u043e\u0431\u0441\u0443\u0436\u0434\u0435\u043d\u0438\u044f" + }, + { + "fieldname": "column_break_summary", + "fieldtype": "Column Break" + }, + { + "default": "\u2014", + "description": "\u041e\u0431\u0449\u0435\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0438\u0435 \u0432\u0441\u0442\u0440\u0435\u0447\u0438", + "fieldname": "meeting_mood", + "fieldtype": "Select", + "label": "\u0422\u043e\u043d \u0432\u0441\u0442\u0440\u0435\u0447\u0438", + "options": "\u2014\n\u041a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u0438\u0432\u043d\u044b\u0439\n\u041d\u0435\u0439\u0442\u0440\u0430\u043b\u044c\u043d\u044b\u0439\n\u041d\u0430\u043f\u0440\u044f\u0436\u0451\u043d\u043d\u044b\u0439\n\u041a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u043d\u044b\u0439" + }, + { + "collapsible_depends_on": "eval:doc.status==\"\u0420\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u043e\" || doc.status==\"\u041f\u0440\u043e\u0432\u0435\u0440\u0435\u043d\u043e\" || doc.status==\"\u0423\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043e\"", + "fieldname": "tech_subsection", + "fieldtype": "Section Break", + "label": "\u0422\u0435\u0445\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0435 \u0434\u0430\u043d\u043d\u044b\u0435" + }, + { + "description": "\u042f\u0437\u044b\u043a, \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0451\u043d\u043d\u044b\u0439 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438", + "fieldname": "detected_language", + "fieldtype": "Data", + "label": "\u042f\u0437\u044b\u043a", + "read_only": 1 + }, + { + "description": "\u0421\u043a\u043e\u043b\u044c\u043a\u043e \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043e \u0432 \u0437\u0430\u043f\u0438\u0441\u0438", + "fieldname": "num_speakers", + "fieldtype": "Int", + "label": "\u0421\u043f\u0438\u043a\u0435\u0440\u043e\u0432", + "read_only": 1 + }, + { + "description": "\u0421\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043d\u044f\u043b\u0430 \u0442\u0440\u0430\u043d\u0441\u043a\u0440\u0438\u0431\u0430\u0446\u0438\u044f \u0438 \u0434\u0438\u0430\u0440\u0438\u0437\u0430\u0446\u0438\u044f", + "fieldname": "processing_time", + "fieldtype": "Float", + "label": "\u0412\u0440\u0435\u043c\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 (\u0441\u0435\u043a)", + "read_only": 1 + }, + { + "fieldname": "column_break_tech", + "fieldtype": "Column Break" + }, + { + "description": "\u0421\u044b\u0440\u0430\u044f \u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0430 \u0431\u0435\u0437 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u043f\u043e \u0441\u043f\u0438\u043a\u0435\u0440\u0430\u043c. \u041e\u0431\u044b\u0447\u043d\u043e \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e \u0431\u043b\u043e\u043a\u0430 \u00ab\u0414\u0438\u0430\u043b\u043e\u0433\u00bb", + "fieldname": "full_text", + "fieldtype": "Long Text", + "label": "\u041f\u043e\u043b\u043d\u044b\u0439 \u0442\u0435\u043a\u0441\u0442", + "read_only": 1 + }, + { + "description": "JSON-\u043e\u0442\u0432\u0435\u0442 \u043e\u0442 \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \u0442\u0440\u0430\u043d\u0441\u043a\u0440\u0438\u0431\u0430\u0446\u0438\u0438", + "fieldname": "utterances_json", + "fieldtype": "JSON", + "label": "\u0421\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442" + }, + { + "description": "\u042d\u0442\u0430\u043f \u0444\u043e\u043d\u043e\u0432\u043e\u0439 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438. \u0421\u043a\u0440\u044b\u0442 \u2014 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u0442\u0441\u044f \u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u0438\u043c \u0441\u043a\u0440\u0438\u043f\u0442\u043e\u043c", + "fieldname": "processing_stage", + "fieldtype": "Data", + "hidden": 1, + "label": "\u0422\u0435\u043a\u0443\u0449\u0438\u0439 \u044d\u0442\u0430\u043f", + "read_only": 1 + }, + { + "description": "\u0418\u0441\u0442\u043e\u0440\u0438\u044f \u044d\u0442\u0430\u043f\u043e\u0432 \u0441 \u043c\u0435\u0442\u043a\u0430\u043c\u0438 \u0432\u0440\u0435\u043c\u0435\u043d\u0438. \u0421\u043a\u0440\u044b\u0442 \u2014 \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f \u043a\u043d\u043e\u043f\u043a\u043e\u0439 \u0432 \u0431\u043b\u043e\u043a\u0435 \u00ab\u041e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u00bb", + "fieldname": "processing_log", + "fieldtype": "Long Text", + "label": "\u041b\u043e\u0433 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438", + "read_only": 1 + }, + { + "fieldname": "processing_subsection", + "fieldtype": "Section Break", + "label": "\u041e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430" + }, + { + "default": "\u0427\u0435\u0440\u043d\u043e\u0432\u0438\u043a", + "description": "\u0422\u0435\u043a\u0443\u0449\u0438\u0439 \u044d\u0442\u0430\u043f \u0440\u0430\u0431\u043e\u0442\u044b \u0441 \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u043e\u043c", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "\u0421\u0442\u0430\u0442\u0443\u0441", + "options": "\u0427\u0435\u0440\u043d\u043e\u0432\u0438\u043a\n\u0412 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0435\n\u0420\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u043e\n\u041f\u0440\u043e\u0432\u0435\u0440\u0435\u043d\u043e\n\u0423\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043e" + }, + { + "description": "\u041a\u043e\u043c\u043f\u0430\u043a\u0442\u043d\u044b\u0439 \u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440 \u0442\u0435\u043a\u0443\u0449\u0435\u0433\u043e \u044d\u0442\u0430\u043f\u0430. \u041a\u043d\u043e\u043f\u043a\u0430 \u00ab\u0418\u0441\u0442\u043e\u0440\u0438\u044f \u044d\u0442\u0430\u043f\u043e\u0432\u00bb \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442 \u043f\u043e\u043b\u043d\u044b\u0439 \u043b\u043e\u0433", + "fieldname": "processing_compact_html", + "fieldtype": "HTML", + "label": "\u0421\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438" + }, + { + "description": "\u0414\u0438\u0430\u043b\u043e\u0433 \u0432\u0441\u0442\u0440\u0435\u0447\u0438 \u2014 \u043a\u0442\u043e \u0447\u0442\u043e \u0441\u043a\u0430\u0437\u0430\u043b", + "fieldname": "dialog_html", + "fieldtype": "HTML", + "label": "\u0420\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0430 \u043f\u043e \u0441\u043f\u0438\u043a\u0435\u0440\u0430\u043c" + }, + { + "fieldname": "analysis_subsection", + "fieldtype": "Section Break", + "label": "\u0410\u043d\u0430\u043b\u0438\u0442\u0438\u043a\u0430 \u043f\u043e \u043f\u0440\u043e\u0444\u0438\u043b\u044f\u043c" + }, + { + "description": "\u0421\u043f\u0438\u0441\u043e\u043a \u043f\u0440\u0438\u043c\u0435\u043d\u0451\u043d\u043d\u044b\u0445 \u043f\u0440\u043e\u0444\u0438\u043b\u0435\u0439 \u0430\u043d\u0430\u043b\u0438\u0437\u0430. \u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u00abAI \u2192 \u041f\u0440\u0438\u043c\u0435\u043d\u0438\u0442\u044c \u043f\u0440\u043e\u0444\u0438\u043b\u044c\u00bb, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043f\u0440\u043e\u0444\u0438\u043b\u044c", + "fieldname": "analysis_results_html", + "fieldtype": "HTML", + "label": "\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u044b \u043f\u0440\u043e\u0444\u0438\u043b\u0435\u0439" + }, + { + "fieldname": "tab_2_tab", + "fieldtype": "Tab Break", + "label": "\u0414\u0438\u0430\u043b\u043e\u0433 \u0432\u0441\u0442\u0440\u0435\u0447\u0438" + }, + { + "fieldname": "tab_3_tab", + "fieldtype": "Tab Break", + "label": "\u0422\u0435\u0445\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0435 \u0434\u0430\u043d\u043d\u044b\u0435" + }, + { + "fieldname": "\u043b\u043e\u0433_\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438_section", + "fieldtype": "Section Break", + "label": "\u041b\u043e\u0433 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438" + }, + { + "fieldname": "\u0440\u0435\u0437\u044e\u043c\u0435_\u0432\u0441\u0442\u0440\u0435\u0447\u0438_tab", + "fieldtype": "Tab Break", + "label": "\u0420\u0435\u0437\u044e\u043c\u0435" + }, + { + "fieldname": "\u0430\u043d\u0430\u043b\u0438\u0442\u0438\u043a\u0430_tab", + "fieldtype": "Tab Break", + "label": "\u0410\u043d\u0430\u043b\u0438\u0442\u0438\u043a\u0430" + }, + { + "collapsible": 1, + "fieldname": "\u0434\u0438\u0430\u0440\u0438\u0437\u0430\u0446\u0438\u044f_section", + "fieldtype": "Section Break", + "label": "\u0414\u0438\u0430\u0440\u0438\u0437\u0430\u0446\u0438\u044f" + } + ], + "links": [ + { + "group": "\u0410\u043d\u0430\u043b\u0438\u0442\u0438\u043a\u0430", + "link_doctype": "Meeting Analysis Result", + "link_fieldname": "meeting_record" + } + ], + "modified": "2026-05-11 08:15:36.494870", + "modified_by": "Administrator", + "module": "Dyak", + "name": "Meeting Record", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Dyak User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "search_fields": "title,project,status", + "sort_field": "modified", + "sort_order": "DESC", + "states": [ + { + "color": "Gray", + "title": "\u0427\u0435\u0440\u043d\u043e\u0432\u0438\u043a" + }, + { + "color": "Light Blue", + "title": "\u0412 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0435" + }, + { + "color": "Blue", + "title": "\u0420\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u043e" + }, + { + "color": "Yellow", + "title": "\u041f\u0440\u043e\u0432\u0435\u0440\u0435\u043d\u043e" + }, + { + "color": "Green", + "title": "\u0423\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043e" + } + ], + "title_field": "title", + "track_changes": 1 +} diff --git a/dyak/dyak/doctype/meeting_record/meeting_record.py b/dyak/dyak/doctype/meeting_record/meeting_record.py new file mode 100644 index 0000000..62433b9 --- /dev/null +++ b/dyak/dyak/doctype/meeting_record/meeting_record.py @@ -0,0 +1,17 @@ +import frappe +from frappe.model.document import Document + + +class MeetingRecord(Document): + """Контроллер документа `Meeting Record`. + + Серверные хуки минимальны: вся тяжёлая работа выполняется в + `dyak.api.v1.transcribe` (через background job) и в client-side скрипте. + """ + + def validate(self): + # Если есть аудиофайл, но не указано название — оставим как есть, + # обязательность name на уровне поля. + # Проставляем дефолтный статус, если пустой. + if not self.status: + self.status = "Черновик" diff --git a/dyak/dyak/doctype/meeting_record/test_meeting_record.py b/dyak/dyak/doctype/meeting_record/test_meeting_record.py new file mode 100644 index 0000000..7117c98 --- /dev/null +++ b/dyak/dyak/doctype/meeting_record/test_meeting_record.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026, V.Bolshakovsky and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + + +class IntegrationTestMeetingRecord(IntegrationTestCase): + """ + Integration tests for MeetingRecord. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/dyak/dyak/page/__init__.py b/dyak/dyak/page/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dyak/dyak/page/dyak_chat/__init__.py b/dyak/dyak/page/dyak_chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dyak/dyak/page/dyak_chat/dyak_chat.css b/dyak/dyak/page/dyak_chat/dyak_chat.css new file mode 100644 index 0000000..ceab83e --- /dev/null +++ b/dyak/dyak/page/dyak_chat/dyak_chat.css @@ -0,0 +1,478 @@ +/* dyak_chat.css — стили страницы /app/dyak-chat */ + +.dyak-chat-root { + display: flex; + height: calc(100vh - 130px); /* минус Frappe header + page header */ + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; +} + +/* ── Сайдбар ─────────────────────────────────────────────────────── */ + +.dyak-sidebar { + width: 240px; + min-width: 240px; + background: var(--bg-light-gray, #fafafa); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; +} +.dyak-sidebar-actions { + padding: 12px; + border-bottom: 1px solid var(--border-color); +} +.dyak-sessions-list { + overflow-y: auto; + flex: 1; + padding: 6px 0; +} +.dyak-section-label { + padding: 8px 12px 4px; + font-size: 11px; + text-transform: uppercase; + color: var(--text-muted); + font-weight: 600; + letter-spacing: 0.02em; +} +.dyak-session-item { + padding: 8px 12px; + cursor: pointer; + border-left: 3px solid transparent; + transition: background 0.1s; +} +.dyak-session-item:hover { + background: var(--bg-color); +} +.dyak-session-item.active { + background: var(--bg-color); + border-left-color: var(--primary, #5e64ff); +} +.dyak-session-title { + font-size: 13px; + font-weight: 500; + color: var(--text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dyak-session-meta { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} +.dyak-archived-block summary { + list-style: none; +} +.dyak-archived-block summary::-webkit-details-marker { + display: none; +} + +/* ── Основная область ────────────────────────────────────────────── */ + +.dyak-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} +.dyak-empty-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.dyak-main-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 12px; +} +.dyak-main-title { + flex: 1; + font-weight: 600; + font-size: 15px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dyak-main-title:hover { + color: var(--primary, #5e64ff); +} +.dyak-main-menu { + display: flex; + gap: 4px; +} + +/* ── Сообщения ───────────────────────────────────────────────────── */ + +.dyak-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} +.dyak-msg { + display: flex; + max-width: 80%; +} +.dyak-msg-user { + align-self: flex-end; + justify-content: flex-end; +} +.dyak-msg-user .dyak-msg-bubble { + background: var(--bg-light-gray, #f0f0f5); + color: var(--text-color); + padding: 8px 12px; + border-radius: 12px 12px 4px 12px; + font-size: 13px; + line-height: 1.5; +} +.dyak-msg-assistant { + align-self: flex-start; + flex-direction: column; + border-left: 3px solid #5e64ff; + background: rgba(94,100,255,0.05); + border-radius: 4px 12px 12px 4px; + padding: 10px 14px; +} +.dyak-msg-content { + font-size: 13px; + line-height: 1.55; + color: var(--text-color); +} +.dyak-msg-content p { margin: 0 0 8px; } +.dyak-msg-content p:last-child { margin-bottom: 0; } +.dyak-msg-content ul, .dyak-msg-content ol { + margin: 4px 0 8px 20px; + padding: 0; +} +.dyak-msg-content code { + background: rgba(0,0,0,0.05); + padding: 1px 4px; + border-radius: 3px; + font-size: 12px; +} +.dyak-msg-content pre { + background: rgba(0,0,0,0.05); + padding: 8px; + border-radius: 4px; + overflow-x: auto; + font-size: 12px; +} +.dyak-msg-content a { + color: var(--primary, #5e64ff); + text-decoration: none; + border-bottom: 1px dashed rgba(94,100,255,0.4); +} +.dyak-msg-content a:hover { border-bottom-style: solid; } + +.dyak-msg-pending { + font-size: 13px; + color: var(--text-muted); +} +.dyak-pulse { + display: inline-block; + animation: dyak-pulse 1.5s ease-in-out infinite; +} +@keyframes dyak-pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} +.dyak-msg-error { + color: #d9534f; + font-size: 13px; +} + +.dyak-debug-btn { + align-self: flex-end; + margin-top: 8px; + font-size: 10px; + line-height: 1; + padding: 3px 8px; + background: transparent; + color: var(--text-muted); + border: 1px solid rgba(0,0,0,0.08); + border-radius: 10px; + cursor: pointer; + transition: all 0.1s; +} +.dyak-debug-btn:hover { + color: var(--text-color); + background: rgba(0,0,0,0.04); + border-color: rgba(0,0,0,0.15); +} + +.dyak-msg-sources { + margin-top: 10px; + padding-top: 8px; + border-top: 1px dashed rgba(94,100,255,0.2); + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.dyak-msg-sources-label { + font-size: 11px; + color: var(--text-muted); + margin-right: 4px; +} +.dyak-source-pill { + display: inline-block; + font-size: 11px; + padding: 2px 8px; + background: rgba(94,100,255,0.1); + color: var(--primary, #5e64ff); + border-radius: 10px; + border: 1px solid rgba(94,100,255,0.2); + text-decoration: none; +} +.dyak-source-pill:hover { + background: rgba(94,100,255,0.2); + text-decoration: none; +} + +/* ── Composer ────────────────────────────────────────────────────── */ + +.dyak-composer { + border-top: 1px solid var(--border-color); + padding: 12px 16px; + display: flex; + gap: 8px; + align-items: flex-end; +} +.dyak-input { + flex: 1; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px 12px; + font-family: inherit; + font-size: 13px; + resize: vertical; + min-height: 40px; + max-height: 200px; +} +.dyak-input:focus { + outline: none; + border-color: var(--primary, #5e64ff); + box-shadow: 0 0 0 2px rgba(94,100,255,0.15); +} +.dyak-send { + align-self: stretch; + min-width: 100px; +} + +/* ── Адаптив ─────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .dyak-chat-root { + flex-direction: column; + height: calc(100vh - 100px); + } + .dyak-sidebar { + width: 100%; + min-width: 0; + max-height: 200px; + border-right: none; + border-bottom: 1px solid var(--border-color); + } + .dyak-msg { max-width: 95%; } +} + +/* ── Отладочная модалка ─────────────────────────────────────────── */ + +.dyak-dbg-header { + margin-bottom: 12px; +} +.dyak-dbg-header-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} +.dyak-dbg-badge { + display: inline-block; + font-size: 11px; + padding: 3px 10px; + border-radius: 10px; + border: 1px solid; + font-weight: 600; +} +.dyak-dbg-chip { + display: inline-block; + font-size: 11px; + padding: 3px 10px; + background: rgba(0,0,0,0.04); + border-radius: 10px; + color: var(--text-muted); +} +.dyak-dbg-section { + margin-top: 16px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 6px; +} +.dyak-dbg-section-title { + font-weight: 600; + font-size: 13px; + margin-bottom: 8px; +} +.dyak-dbg-stages-summary { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 8px; +} +.dyak-dbg-stage-chip { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + min-width: 80px; + text-align: center; +} +.dyak-dbg-stage-name { + font-weight: 600; + color: var(--text-color); +} +.dyak-dbg-stage-took { + color: var(--text-muted); + margin-top: 2px; +} +.dyak-dbg-plan > div { + font-size: 12px; + padding: 3px 0; + line-height: 1.55; +} +.dyak-dbg-tag { + display: inline-block; + font-size: 11px; + padding: 1px 6px; + background: rgba(94,100,255,0.1); + color: var(--primary, #5e64ff); + border-radius: 8px; + margin: 0 2px; +} +.dyak-dbg-mr-list { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.dyak-dbg-mr-pill { + display: inline-block; + font-size: 11px; + padding: 2px 8px; + background: rgba(40, 167, 69, 0.1); + color: #28a745; + border-radius: 10px; + border: 1px solid rgba(40, 167, 69, 0.3); + text-decoration: none; +} +.dyak-dbg-mr-pill:hover { + background: rgba(40, 167, 69, 0.2); + text-decoration: none; +} +.dyak-dbg-error-box { + background: rgba(217, 83, 79, 0.08); + border-left: 3px solid #d9534f; + padding: 8px 12px; + font-size: 12px; + color: #a94442; + border-radius: 4px; + font-family: var(--font-monospace, monospace); + white-space: pre-wrap; + word-break: break-word; +} +.dyak-dbg-log { + max-height: 50vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; +} +.dyak-dbg-log-entry { + padding: 6px 10px; + border-left: 2px solid var(--border-color); + background: rgba(0,0,0,0.02); + border-radius: 0 4px 4px 0; +} +.dyak-dbg-log-meta { + display: flex; + gap: 8px; + align-items: center; + font-size: 11px; +} +.dyak-dbg-log-icon { + font-weight: 700; + font-size: 12px; +} +.dyak-dbg-log-ts { + color: var(--text-muted); + font-family: var(--font-monospace, monospace); +} +.dyak-dbg-log-stage { + color: var(--text-color); + font-weight: 600; + font-size: 11px; +} +.dyak-dbg-log-message { + font-size: 12px; + line-height: 1.5; + margin-top: 3px; + color: var(--text-color); +} +.dyak-dbg-extra { + margin-top: 6px; +} +.dyak-dbg-extra summary { + font-size: 11px; + color: var(--text-muted); + cursor: pointer; + list-style: none; +} +.dyak-dbg-extra summary::-webkit-details-marker { display: none; } +.dyak-dbg-extra summary:before { + content: "▸ "; + display: inline-block; + transition: transform 0.1s; +} +.dyak-dbg-extra[open] summary:before { + transform: rotate(90deg); +} +.dyak-dbg-extra pre { + margin-top: 4px; + background: rgba(0,0,0,0.05); + padding: 6px 8px; + border-radius: 4px; + font-size: 10px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; +} + +/* ── Soft-error внутри assistant-сообщения ──────────────────────── */ + +.dyak-msg-content blockquote { + border-left: 3px solid #f0b400; + background: rgba(240, 180, 0, 0.08); + padding: 8px 12px; + margin: 0 0 10px; + font-size: 12px; + color: var(--text-color); + border-radius: 0 4px 4px 0; +} +.dyak-msg-content blockquote p { margin: 0 0 4px; } +.dyak-msg-content blockquote p:last-child { margin-bottom: 0; } +.dyak-msg-content blockquote ul, +.dyak-msg-content blockquote ol { + margin: 4px 0 4px 16px; + font-size: 12px; +} \ No newline at end of file diff --git a/dyak/dyak/page/dyak_chat/dyak_chat.js b/dyak/dyak/page/dyak_chat/dyak_chat.js new file mode 100644 index 0000000..beadee3 --- /dev/null +++ b/dyak/dyak/page/dyak_chat/dyak_chat.js @@ -0,0 +1,807 @@ +/** + * dyak/page/dyak_chat — глобальный чат с Дьяком. + * + * URL: /app/dyak-chat + * + * Структура DOM: + * .dyak-chat-root + * .dyak-sidebar — левая колонка со списком сессий + * .dyak-sidebar-actions — «+ Новый диалог» + * .dyak-sessions-list — pinned + active + archived + * .dyak-main — правая колонка + * .dyak-main-header — название сессии + меню + * .dyak-messages — скролл с сообщениями + * .dyak-composer — textarea + кнопка отправки + * + * Состояние: + * state.sessions: [{name, title, last_message_at, pinned, archived, ...}] + * state.activeSession: name или null + * state.messages: [{name, role, content, status, meta_json, ...}] + * state.pollingFor: name ассистент-сообщения, который опрашиваем + * state.pollingInterval: handle интервала + * state.meetingTitleCache: {MR-...: "Название · дата"} — для рендера sources + */ + +frappe.provide("dyak.chat"); + +frappe.pages["dyak-chat"].on_page_load = function (wrapper) { + const page = frappe.ui.make_app_page({ + parent: wrapper, + title: "Чат с Дьяком", + single_column: true, + }); + + // Полное DOM-дерево страницы создаётся одним innerHTML — это + // компактнее, чем сборка по кусочкам через jQuery. Дальнейшая + // работа уже через querySelector/closest. + page.main.html(` +
    + +
    +
    +
    +
    💬
    +
    Выберите диалог слева или создайте новый.
    +
    +
    +
    +
    + `); + + const root = page.main.find(".dyak-chat-root")[0]; + const state = { + sessions: [], + activeSession: null, + messages: [], + pollingFor: null, + pollingInterval: null, + pollingStartedAt: 0, + meetingTitleCache: {}, + }; + dyak.chat._state = state; // на отладку из консоли + + // ── Список сессий ───────────────────────────────────────────────── + + async function load_sessions() { + const r = await frappe.call({ + method: "dyak.api.v1.chat_global.list_sessions", + args: { include_archived: 1 }, + }); + state.sessions = r.message || []; + render_sessions(); + } + + function render_sessions() { + const list_el = root.querySelector(".dyak-sessions-list"); + const pinned = state.sessions.filter(s => s.pinned && !s.archived); + const active = state.sessions.filter(s => !s.pinned && !s.archived); + const archived = state.sessions.filter(s => s.archived); + + const html_for = (sessions, label) => { + if (!sessions.length) return ""; + const items = sessions.map(s => session_item_html(s)).join(""); + const hdr = label + ? `` + : ""; + return hdr + items; + }; + + let inner = ""; + if (pinned.length) inner += html_for(pinned, "Закреплённые"); + inner += html_for(active, pinned.length ? "Диалоги" : null); + + if (archived.length) { + inner += ` +
    + + ${archived.map(s => session_item_html(s)).join("")} +
    + `; + } + if (!inner.trim()) { + inner = `
    + Пока нет диалогов. Создайте первый. +
    `; + } + list_el.innerHTML = inner; + } + + function session_item_html(s) { + const active = s.name === state.activeSession ? " active" : ""; + const pin = s.pinned ? "📌 " : ""; + // comment_when возвращает безопасный HTML , экранировать НЕЛЬЗЯ. Если функции нет + // (в каких-то версиях её прячут) — fallback на pretty_date или + // просто на короткую дату. + const date = format_pretty_date(s.last_message_at); + return ` +
    +
    ${pin}${escape_html(s.title || "—")}
    +
    + ${date} · ${s.message_count || 0} +
    +
    + `; + } + + function format_pretty_date(value) { + if (!value) return ""; + try { + if (frappe.datetime && typeof frappe.datetime.comment_when === "function") { + return frappe.datetime.comment_when(value); + } + if (frappe.datetime && typeof frappe.datetime.prettyDate === "function") { + return escape_html(frappe.datetime.prettyDate(value)); + } + } catch (e) { /* fall through */ } + // Fallback — короткая дата YYYY-MM-DD HH:MM. + return escape_html(String(value).slice(0, 16)); + } + + // Делегированные клики по списку сессий. + $(root).on("click", ".dyak-session-item", function () { + const name = this.dataset.name; + open_session(name); + }); + + $(root).on("click", ".dyak-new-session", async function () { + const r = await frappe.call({ + method: "dyak.api.v1.chat_global.create_session", + }); + const name = r.message && r.message.name; + if (!name) return; + await load_sessions(); + open_session(name); + }); + + // ── Открыть сессию ──────────────────────────────────────────────── + + async function open_session(name) { + stop_polling(); + state.activeSession = name; + localStorage.setItem("dyak_chat_last_session", name); + render_sessions(); + render_main_skeleton(); + await load_messages(); + // Если есть незавершённое сообщение (status=В обработке) — + // подцепиться к polling'у, чтобы автоматически дождаться. + const last_pending = [...state.messages].reverse() + .find(m => m.role === "assistant" && m.status === "В обработке"); + if (last_pending) { + start_polling(last_pending.name); + } + } + + function render_main_skeleton() { + const main = root.querySelector(".dyak-main"); + const session = state.sessions.find(s => s.name === state.activeSession); + if (!session) { + main.innerHTML = `
    `; + return; + } + main.innerHTML = ` +
    +
    + ${escape_html(session.title || "—")} +
    +
    + + + +
    +
    +
    +
    Загрузка сообщений…
    +
    +
    + + +
    + `; + } + + // Делегированные обработчики для шапки и composer'а. + $(root).on("click", ".dyak-main-title", function () { + const session = state.sessions.find(s => s.name === state.activeSession); + if (!session) return; + frappe.prompt({ + label: "Новое название", fieldname: "title", fieldtype: "Data", + default: session.title, + }, async ({ title }) => { + await frappe.call({ + method: "dyak.api.v1.chat_global.rename_session", + args: { session: state.activeSession, title }, + }); + await load_sessions(); + render_main_skeleton(); + await load_messages(); + }, "Переименовать диалог", "Сохранить"); + }); + + $(root).on("click", ".dyak-pin", async function () { + const session = state.sessions.find(s => s.name === state.activeSession); + if (!session) return; + await frappe.call({ + method: "dyak.api.v1.chat_global.set_session_flag", + args: { session: state.activeSession, flag: "pinned", + value: session.pinned ? 0 : 1 }, + }); + await load_sessions(); + render_main_skeleton(); + }); + + $(root).on("click", ".dyak-archive", async function () { + const session = state.sessions.find(s => s.name === state.activeSession); + if (!session) return; + await frappe.call({ + method: "dyak.api.v1.chat_global.set_session_flag", + args: { session: state.activeSession, flag: "archived", + value: session.archived ? 0 : 1 }, + }); + await load_sessions(); + render_main_skeleton(); + }); + + $(root).on("click", ".dyak-delete", function () { + frappe.confirm( + "Удалить диалог со всеми сообщениями? Действие необратимо.", + async () => { + await frappe.call({ + method: "dyak.api.v1.chat_global.delete_session", + args: { session: state.activeSession }, + }); + state.activeSession = null; + localStorage.removeItem("dyak_chat_last_session"); + await load_sessions(); + root.querySelector(".dyak-main").innerHTML = + `
    `; + }, + ); + }); + + // ── Сообщения ──────────────────────────────────────────────────── + + async function load_messages() { + const r = await frappe.call({ + method: "dyak.api.v1.chat_global.get_messages", + args: { session: state.activeSession, limit: 200 }, + }); + state.messages = r.message || []; + await prefetch_meeting_titles(); + render_messages(); + } + + function render_messages() { + const msgs_el = root.querySelector(".dyak-messages"); + if (!msgs_el) return; + if (!state.messages.length) { + msgs_el.innerHTML = ` +
    + Задайте свой вопрос — Дьяк найдёт встречи и ответит. +
    Например: «Найди созвоны, где обсуждали JSON Logic» +
    `; + return; + } + msgs_el.innerHTML = state.messages.map(message_html).join(""); + msgs_el.scrollTop = msgs_el.scrollHeight; + } + + function message_html(m) { + if (m.role === "user") { + return ` +
    +
    + ${escape_html(m.content || "").replace(/\n/g, "
    ")} +
    +
    + `; + } + + // assistant + let body; + if (m.status === "В обработке") { + const last_stage = extract_last_debug_stage(m.meta_json); + const stage_label = last_stage + ? ` + · ${escape_html(last_stage)}` + : ""; + body = `
    +  Думаю…${stage_label} +
    `; + } else if (m.status === "Ошибка") { + // Различаем «жёсткую» ошибку (нет ответа модели — content + // просто `❌ ...` либо пуст) и «soft» (есть ответ модели + + // предупреждение в начале content). При soft рендерим контент + // как markdown — там сверху сам бот уже добавил блок про + // ошибку через '>' цитату. + const content = (m.content || "").trim(); + const looks_like_hard_error = !content || content.startsWith("❌"); + if (looks_like_hard_error) { + body = `
    + ${escape_html(content || m.error_message || "Ошибка")} +
    `; + } else { + const md_html = (typeof frappe.markdown === "function") + ? frappe.markdown(content) + : escape_html(content).replace(/\n/g, "
    "); + body = `
    ${md_html}
    `; + body += render_sources(m); + } + } else { + const md_html = (typeof frappe.markdown === "function") + ? frappe.markdown(m.content || "") + : escape_html(m.content || "").replace(/\n/g, "
    "); + body = `
    ${md_html}
    `; + body += render_sources(m); + } + + // Кнопка отладки — для ЛЮБОГО assistant-сообщения, если есть meta_json. + // Полезна и при «В обработке» (показывает текущий этап), и при + // «Готово», и при «Ошибка». + const debug_button = m.meta_json + ? `` + : ""; + + return ` +
    + ${body} + ${debug_button} +
    + `; + } + + /** + * Достаёт из meta_json текст последнего этапа debug_log — это + * показывается рядом со «⏳ Думаю…», чтобы было видно, что + * именно сейчас делает бот (planner_request / retrieval_sql / + * answerer_request / …). + */ + function extract_last_debug_stage(meta_json) { + if (!meta_json) return ""; + try { + const meta = JSON.parse(meta_json); + const log = meta.debug_log || []; + const last = log[log.length - 1]; + if (!last) return ""; + // Делаем человеческие лейблы для самых частых этапов. + const labels = { + start: "Старт", + config: "Подготовка", + history: "Загрузка истории", + planner_request: "Планирование поиска", + planner_prompt_built: "Планирование поиска", + planner_raw_response: "Планирование поиска", + planner_parse_error: "Планирование поиска", + planner_response: "Поиск встреч", + retrieval_sql: "Поиск встреч", + retrieval_result: "Сборка контекста", + retrieval_failed: "Поиск упал", + retrieval_skipped: "Без поиска", + context_built: "Подготовка вопроса", + answerer_request: "Генерация ответа", + answerer_response: "Постобработка", + answerer_failed: "Ответ упал", + postprocess: "Постобработка", + }; + return labels[last.stage] || last.stage; + } catch (e) { return ""; } + } + + function open_debug_dialog(message_name) { + const m = state.messages.find(x => x.name === message_name); + if (!m || !m.meta_json) return; + + let meta; + try { meta = JSON.parse(m.meta_json); } + catch (e) { meta = null; } + + const d = new frappe.ui.Dialog({ + title: `Отладка ${message_name}`, + size: "large", + fields: [{ + fieldtype: "HTML", + options: render_debug_html(m, meta), + }], + }); + d.show(); + } + + function render_debug_html(m, meta) { + const status_color = { + "Готово": "#28a745", + "В обработке": "#5e64ff", + "Ошибка": "#d9534f", + }[m.status] || "#888"; + + // Шапка с ключевой инфо. + const header = ` +
    +
    + + ${escape_html(m.status || "?")} + + ${m.model_used + ? `модель: ${escape_html(m.model_used)}` + : ""} + ${meta && meta.total_time + ? `всего: ${meta.total_time} сек` + : ""} +
    +
    + `; + + if (!meta) { + return header + `
    Не удалось распарсить meta_json.
    `; + } + + // Сводка по этапам — горизонтальная плашка со временем. + let stages_html = ""; + if ((meta.stages || []).length) { + stages_html = ` +
    + ${meta.stages.map(s => { + const took = s.took != null ? `${s.took}s` : "—"; + const err = s.error ? ` style="color:#d9534f;"` : ""; + return `
    +
    ${escape_html(s.stage)}
    +
    ${took}
    +
    `; + }).join("")} +
    + `; + } + + // Plan от planner-а — отдельной карточкой, если есть. + let plan_html = ""; + const planner_stage = (meta.stages || []).find(s => s.stage === "planner"); + if (planner_stage && planner_stage.plan) { + const p = planner_stage.plan; + plan_html = ` +
    +
    📋 Plan от planner
    +
    +
    Нужен поиск: ${p.needs_search ? "да" : "нет"}
    + ${(p.search_terms || []).length + ? `
    Ключевые слова: + ${(p.search_terms || []).map(t => + `${escape_html(t)}` + ).join("")}
    ` + : ""} + ${p.project_filter + ? `
    Проект: ${escape_html(p.project_filter)}
    ` : ""} + ${p.category_filter + ? `
    Категория: ${escape_html(p.category_filter)}
    ` : ""} + ${p.date_from || p.date_to + ? `
    Период: ${escape_html(p.date_from || "—")} + → ${escape_html(p.date_to || "—")}
    ` + : ""} + ${p.reasoning + ? `
    Обоснование: + ${escape_html(p.reasoning)}
    ` : ""} +
    +
    + `; + } + + // Retrieval — что нашли. + const retrieval_stage = (meta.stages || []).find(s => s.stage === "retrieval"); + let retrieval_html = ""; + if (retrieval_stage) { + const r = retrieval_stage; + if (r.error) { + retrieval_html = ` +
    +
    🔎 Retrieval
    +
    ${escape_html(r.error)}
    +
    + `; + } else { + retrieval_html = ` +
    +
    🔎 Найдено встреч: ${r.count || 0}
    + ${(r.names || []).length + ? `
    ${ + (r.names || []).slice(0, 10).map(n => + `${escape_html(n)}` + ).join(" ") + }
    ` + : `
    `} +
    + `; + } + } + + // Хронологический лог. + const log_html = render_debug_log(meta.debug_log || []); + + return header + stages_html + plan_html + retrieval_html + log_html; + } + + function render_debug_log(log) { + if (!log.length) return ""; + + const level_colors = { + info: "#5e64ff", + warn: "#f0b400", + error: "#d9534f", + }; + const level_icons = { + info: "ℹ", + warn: "⚠", + error: "✖", + }; + + const items = log.map((entry, idx) => { + const color = level_colors[entry.level] || "#888"; + const icon = level_icons[entry.level] || "·"; + const has_extra = entry.extra + && Object.keys(entry.extra).length > 0; + let extra_block = ""; + if (has_extra) { + const extra_str = JSON.stringify(entry.extra, null, 2); + extra_block = ` +
    + детали +
    ${escape_html(extra_str)}
    +
    + `; + } + return ` +
    +
    + ${icon} + ${escape_html(entry.ts || "")} + ${escape_html(entry.stage || "")} +
    +
    + ${escape_html(entry.message || "")} +
    + ${extra_block} +
    + `; + }).join(""); + + return ` +
    +
    + 📜 Хронология (${log.length} событий) +
    +
    ${items}
    +
    + `; + } + + $(root).on("click", ".dyak-debug-btn", function (e) { + e.stopPropagation(); + const name = this.dataset.name; + if (name) open_debug_dialog(name); + }); + + function render_sources(m) { + if (!m.meta_json) return ""; + let meta; + try { meta = JSON.parse(m.meta_json); } catch (e) { return ""; } + const sources = (meta.sources || []).filter(Boolean); + if (!sources.length) return ""; + const items = sources.map(name => { + const label = state.meetingTitleCache[name] || name; + return ` + 📎 ${escape_html(label)}`; + }).join(" "); + return `
    +
    Источники:
    + ${items} +
    `; + } + + // Подтягиваем названия встреч для всех уникальных MR-... из meta_json, + // которые ещё не в кэше. Это один запрос на загрузку сессии. + async function prefetch_meeting_titles() { + const need = new Set(); + for (const m of state.messages) { + if (m.role !== "assistant" || !m.meta_json) continue; + try { + const meta = JSON.parse(m.meta_json); + (meta.sources || []).forEach(n => { + if (n && !state.meetingTitleCache[n]) need.add(n); + }); + } catch (e) { /* */ } + } + if (!need.size) return; + const r = await frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "Meeting Record", + filters: { name: ["in", Array.from(need)] }, + fields: ["name", "title", "meeting_date"], + limit_page_length: 500, + }, + }); + for (const row of (r.message || [])) { + const date = row.meeting_date ? row.meeting_date.split(" ")[0] : ""; + state.meetingTitleCache[row.name] = date + ? `${row.title} · ${date}` + : row.title; + } + } + + // ── Отправка сообщения ─────────────────────────────────────────── + + $(root).on("click", ".dyak-send", send_message); + $(root).on("keydown", ".dyak-input", function (e) { + // Ctrl+Enter или Cmd+Enter → отправить. + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + send_message(); + } + }); + + async function send_message() { + const ta = root.querySelector(".dyak-input"); + if (!ta) return; + const content = ta.value.trim(); + if (!content) return; + if (!state.activeSession) { + frappe.show_alert({ + message: "Выберите или создайте диалог", + indicator: "orange", + }); + return; + } + + // Дизейблим composer на время отправки. + const btn = root.querySelector(".dyak-send"); + btn.disabled = true; + ta.disabled = true; + + try { + const r = await frappe.call({ + method: "dyak.api.v1.chat_global.post_message", + args: { session: state.activeSession, content }, + }); + ta.value = ""; + const asst = r.message && r.message.assistant_message; + await load_sessions(); // обновит last_message_at и сортировку + render_sessions(); + await load_messages(); + if (asst) start_polling(asst); + } catch (e) { + frappe.show_alert({ + message: "Не удалось отправить: " + (e.message || e), + indicator: "red", + }); + } finally { + btn.disabled = false; + ta.disabled = false; + ta.focus(); + } + } + + // ── Polling статуса ассистент-сообщения ────────────────────────── + + function start_polling(message_name) { + stop_polling(); + state.pollingFor = message_name; + state.pollingStartedAt = Date.now(); + + const TIMEOUT_MS = 3 * 60 * 1000; + state.pollingInterval = setInterval(async () => { + if (Date.now() - state.pollingStartedAt > TIMEOUT_MS) { + stop_polling(); + return; + } + try { + const r = await frappe.db.get_value( + "Dyak Chat Message", message_name, + ["status", "content", "meta_json", "error_message"], + ); + if (!r || !r.message) return; + const data = r.message; + + // Локальный апдейт сообщения. Если что-то изменилось + // (статус, контент или meta_json) — перерисуем. Это + // нужно, чтобы рядом со «⏳ Думаю…» обновлялся текущий + // этап (planner_request → retrieval_sql → ...). + const idx = state.messages.findIndex( + m => m.name === message_name); + if (idx === -1) return; + const old = state.messages[idx]; + const changed = ( + old.status !== data.status || + old.content !== data.content || + old.meta_json !== data.meta_json + ); + if (changed) { + Object.assign(state.messages[idx], data); + if (data.status && data.status !== "В обработке") { + // Финальный апдейт — может быть новый sources. + await prefetch_meeting_titles(); + } + render_messages(); + } + + if (data.status && data.status !== "В обработке") { + stop_polling(); + } + } catch (e) { /* транзиентная ошибка — попробуем снова */ } + }, 2000); + } + + function stop_polling() { + if (state.pollingInterval) { + clearInterval(state.pollingInterval); + state.pollingInterval = null; + } + state.pollingFor = null; + } + + // ── Утилиты ────────────────────────────────────────────────────── + + function escape_html(s) { + return (s || "").replace(/[&<>"']/g, c => + ({ "&": "&", "<": "<", ">": ">", + '"': """, "'": "'" }[c])); + } + + // Экспорт в namespace для отладки. + dyak.chat.load_sessions = load_sessions; + dyak.chat.open_session = open_session; + + // ── Старт ──────────────────────────────────────────────────────── + + (async () => { + await load_sessions(); + // Если есть сохранённая сессия — открываем её, иначе первую, + // иначе создаём новую. + const saved = localStorage.getItem("dyak_chat_last_session"); + const target = (saved && state.sessions.find(s => s.name === saved)) + ? saved + : (state.sessions[0] && state.sessions[0].name); + if (target) { + open_session(target); + } else { + // Пусто — создаём первую сессию автоматически. + const r = await frappe.call({ + method: "dyak.api.v1.chat_global.create_session", + }); + await load_sessions(); + if (r.message && r.message.name) open_session(r.message.name); + } + })(); +}; \ No newline at end of file diff --git a/dyak/dyak/page/dyak_chat/dyak_chat.json b/dyak/dyak/page/dyak_chat/dyak_chat.json new file mode 100644 index 0000000..031fa0a --- /dev/null +++ b/dyak/dyak/page/dyak_chat/dyak_chat.json @@ -0,0 +1,19 @@ +{ + "creation": "2026-01-01 00:00:00", + "doctype": "Page", + "icon": "comment", + "idx": 0, + "modified": "2026-01-01 00:00:00", + "modified_by": "Administrator", + "module": "Dyak", + "name": "dyak-chat", + "owner": "Administrator", + "page_name": "dyak-chat", + "roles": [ + {"role": "System Manager"}, + {"role": "Dyak User"} + ], + "standard": "Yes", + "system_page": 0, + "title": "Чат с Дьяком" +} \ No newline at end of file diff --git a/dyak/dyak/workspace/dyak/dyak.json b/dyak/dyak/workspace/dyak/dyak.json new file mode 100644 index 0000000..f3fb507 --- /dev/null +++ b/dyak/dyak/workspace/dyak/dyak.json @@ -0,0 +1,84 @@ +{ + "charts": [], + "content": "[{\"id\":\"header_intro\",\"type\":\"header\",\"data\":{\"text\":\"Протоколы встреч\",\"col\":12}},{\"id\":\"shortcut_records\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Записи встреч\",\"col\":3}},{\"id\":\"shortcut_settings\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Настройки\",\"col\":3}}]", + "creation": "2026-01-01 00:00:00", + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "for_user": "", + "hide_custom": 0, + "icon": "file-media", + "idx": 0, + "is_hidden": 0, + "label": "Дьяк", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Протоколы", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Записи встреч", + "link_count": 0, + "link_to": "Meeting Record", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Настройки", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Dyak Settings", + "link_count": 0, + "link_to": "Dyak Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2026-01-01 00:00:00", + "modified_by": "Administrator", + "module": "Dyak", + "name": "Дьяк", + "number_cards": [], + "owner": "Administrator", + "parent_page": "", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 100.0, + "shortcuts": [ + { + "color": "Blue", + "doc_view": "List", + "format": "{} Active", + "label": "Записи встреч", + "link_to": "Meeting Record", + "stats_filter": "{\"status\":[\"!=\",\"Утверждено\"]}", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "", + "label": "Настройки", + "link_to": "Dyak Settings", + "type": "DocType" + } + ], + "title": "Дьяк" +} diff --git a/dyak/hooks.py b/dyak/hooks.py index e2cdb21..395e432 100644 --- a/dyak/hooks.py +++ b/dyak/hooks.py @@ -5,6 +5,27 @@ app_description = "Управление встречами" app_email = "Vladimir@boshakovsky.ru" app_license = "mit" +# (cd ~/frappe-bench && bench --site dyak.bbr.ru export-fixtures --app dyak) +fixtures = [ + { + "doctype": "Custom HTML Block", + "filters": { + "name": ( + "in", + [ + "btn_dyak_sandbox", + ], + ) + }, + }, +] + +doc_events = { + "Comment": { + "after_insert": "dyak.api.v1.chat.on_comment_insert", + }, +} + # Apps # ------------------ diff --git a/dyak/patches.txt b/dyak/patches.txt index f15c3a9..99662ab 100644 --- a/dyak/patches.txt +++ b/dyak/patches.txt @@ -3,4 +3,8 @@ # Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations [post_model_sync] -# Patches added in this section will be executed after doctypes are migrated \ No newline at end of file +# Patches added in this section will be executed after doctypes are migrated + +dyak.patches.v01.create_builtin_profiles +dyak.patches.v02.create_default_profiles +dyak.patches.v03.add_meeting_record_fts \ No newline at end of file diff --git a/dyak/patches/v01/create_builtin_profiles.py b/dyak/patches/v01/create_builtin_profiles.py new file mode 100644 index 0000000..15c4fde --- /dev/null +++ b/dyak/patches/v01/create_builtin_profiles.py @@ -0,0 +1,222 @@ +""" +Создаёт встроенный профиль «Стандартный анализ» — точное соответствие +бывшей кнопке «Анализ встречи» из ai.py. Идемпотентен: если профиль уже +существует, обновляются только поля, которые могут измениться при +обновлении приложения (analysis_prompt, output_schema). Имя и флаг +is_builtin не трогаем. + +Запускается автоматически через bench migrate (см. patches.txt). +""" +import json + +import frappe + + +PROFILE_NAME = "Стандартный анализ" + +OUTPUT_SCHEMA = { + "sections": [ + { + "title": "Решения", + "fields": [ + { + "key": "decisions", + "label": "Принятые решения", + "type": "list_of_objects", + "item_schema": [ + {"key": "decision_description", "label": "Решение"}, + {"key": "decided_by", "label": "Кто принял"}, + {"key": "source_quote", "label": "Цитата"}, + ], + } + ], + }, + { + "title": "Проблемы и риски", + "fields": [ + { + "key": "problems", + "label": "Проблемы", + "type": "list_of_objects", + "item_schema": [ + {"key": "problem_description", "label": "Проблема"}, + {"key": "severity", "label": "Серьёзность"}, + {"key": "owner_name", "label": "Ответственный"}, + {"key": "source_quote", "label": "Цитата"}, + ], + } + ], + }, + { + "title": "Открытые вопросы", + "fields": [ + { + "key": "open_questions", + "label": "Вопросы без ответа", + "type": "list_of_objects", + "item_schema": [ + {"key": "question", "label": "Вопрос"}, + {"key": "addressed_to", "label": "Кому адресован"}, + {"key": "source_quote", "label": "Цитата"}, + ], + } + ], + }, + { + "title": "Изменения расписания", + "fields": [ + { + "key": "schedule_changes", + "label": "Переносы и отмены", + "type": "list_of_objects", + "item_schema": [ + {"key": "change_description", "label": "Что изменилось"}, + {"key": "new_datetime", "label": "Новая дата/время"}, + {"key": "source_quote", "label": "Цитата"}, + ], + } + ], + }, + { + "title": "Запросы помощи", + "fields": [ + { + "key": "help_requests", + "label": "Запросы", + "type": "list_of_objects", + "item_schema": [ + {"key": "request_description", "label": "Запрос"}, + {"key": "requested_from", "label": "От кого требуется"}, + {"key": "source_quote", "label": "Цитата"}, + ], + } + ], + }, + { + "title": "Внешние ссылки", + "fields": [ + { + "key": "external_references", + "label": "Тикеты, ветки, документы", + "type": "list_of_objects", + "item_schema": [ + {"key": "ref_type", "label": "Тип"}, + {"key": "ref_title", "label": "Название"}, + {"key": "ref_url", "label": "URL"}, + {"key": "source_quote", "label": "Цитата"}, + ], + } + ], + }, + { + "title": "Темы и тон", + "fields": [ + { + "key": "topics", + "label": "Ключевые темы", + "type": "list_of_strings", + }, + { + "key": "mood", + "label": "Тон встречи", + "type": "select", + "options": [ + "Конструктивный", + "Нейтральный", + "Напряжённый", + "Конфликтный", + ], + }, + ], + }, + ] +} + +# Прокидываем темы и настроение в Meeting Record. +PARENT_FIELD_MAPPING = { + "meeting_topics": "topics", + "meeting_mood": "mood", +} + +ANALYSIS_PROMPT = """\ +Ты анализируешь расшифровку рабочей встречи и извлекаешь структурированные +данные. Верни СТРОГО валидный JSON-объект. + +ВАЖНО: +- извлекай только информацию, явно присутствующую в тексте +- не выдумывай факты, имена, решения или даты +- если данных нет — используй "" (пустую строку) или [] для списков +- если категория пустая — верни [] + +Схема ответа: +{ + "decisions": [ + {"decision_description": "", "decided_by": "", "source_quote": ""} + ], + "problems": [ + {"problem_description": "", "severity": "Низкая|Средняя|Высокая|Критичная", + "owner_name": "", "source_quote": ""} + ], + "open_questions": [ + {"question": "", "addressed_to": "", "source_quote": ""} + ], + "schedule_changes": [ + {"change_description": "", "new_datetime": "YYYY-MM-DD HH:MM:SS или пусто", + "source_quote": ""} + ], + "help_requests": [ + {"request_description": "", "requested_from": "", "source_quote": ""} + ], + "external_references": [ + {"ref_type": "Тикет|Ветка кода|Wiki/Confluence|Документ|Другое", + "ref_title": "", "ref_url": "", "source_quote": ""} + ], + "topics": [], + "mood": "Конструктивный|Нейтральный|Напряжённый|Конфликтный" +} + +Расшифровка встречи: +--- +{transcript} +--- +""" + + +def execute(): + """Создаёт или обновляет встроенный профиль.""" + if frappe.db.exists("Analysis Profile", PROFILE_NAME): + # Обновляем только содержательные поля; настройки пользователя + # (enabled, model_override, temperature) не трогаем. + profile = frappe.get_doc("Analysis Profile", PROFILE_NAME) + profile.is_builtin = 1 + profile.description = ( + "Стандартный набор: решения, проблемы, открытые вопросы, " + "изменения расписания, запросы помощи, внешние ссылки, " + "темы и тон встречи. Темы и тон записываются в поля Meeting Record." + ) + profile.analysis_prompt = ANALYSIS_PROMPT + profile.output_schema = json.dumps(OUTPUT_SCHEMA, ensure_ascii=False) + profile.parent_field_mapping = json.dumps( + PARENT_FIELD_MAPPING, ensure_ascii=False, + ) + profile.save(ignore_permissions=True) + else: + frappe.get_doc({ + "doctype": "Analysis Profile", + "profile_name": PROFILE_NAME, + "description": ( + "Стандартный набор: решения, проблемы, открытые вопросы, " + "изменения расписания, запросы помощи, внешние ссылки, " + "темы и тон встречи. Темы и тон записываются в поля Meeting Record." + ), + "enabled": 1, + "is_builtin": 1, + "analysis_prompt": ANALYSIS_PROMPT, + "output_schema": json.dumps(OUTPUT_SCHEMA, ensure_ascii=False), + "parent_field_mapping": json.dumps( + PARENT_FIELD_MAPPING, ensure_ascii=False, + ), + "temperature": 0.2, + }).insert(ignore_permissions=True) + + frappe.db.commit() diff --git a/dyak/patches/v02/create_default_profiles.py b/dyak/patches/v02/create_default_profiles.py new file mode 100644 index 0000000..746d6c4 --- /dev/null +++ b/dyak/patches/v02/create_default_profiles.py @@ -0,0 +1,833 @@ +""" +Создаёт ещё пять встроенных профилей анализа (помимо «Стандартного +анализа» из v01): + + • Еженедельная встреча проекта — состояние, изменения, задачи, + договорённости. + • Анализ как база знаний — концепты/определения/правила/примеры, + превращающие встречу в записи для будущей команды. + • Вопрос-ответ — пары «вопрос/ответ» + открытые вопросы. + • Разговор с клиентом банка — пять видов риска (регуляторный, + репутационный, мисселинг, утечка данных, социальная инженерия). + • HR 1:1 / разбор сотрудника — настроение, выгорание, карьера, + блокеры, обещания менеджера, фидбэк о команде. + +Идемпотентен: уже существующие профили обновляются (analysis_prompt и +output_schema), пользовательские флаги (enabled, model_override, +temperature) не трогаются. Новые — создаются. +""" +import json + +import frappe + + +# ────────────────────────────────────────────────────────────────────────── +# Профиль 1: Еженедельная встреча проекта +# ────────────────────────────────────────────────────────────────────────── + +WEEKLY_SCHEMA = { + "sections": [ + { + "title": "Прогресс с прошлой встречи", + "fields": [ + { + "key": "completed", + "label": "Что сделано", + "type": "list_of_objects", + "item_schema": [ + {"key": "description", "label": "Что сделано"}, + {"key": "owner", "label": "Кто делал"}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + { + "key": "in_progress", + "label": "Что в работе", + "type": "list_of_objects", + "item_schema": [ + {"key": "description", "label": "Что"}, + {"key": "owner", "label": "Кто"}, + {"key": "expected_completion", + "label": "Ожидаемое завершение"}, + ], + }, + ], + }, + { + "title": "Изменения и новый скоуп", + "fields": [ + { + "key": "scope_changes", + "label": "Изменения в требованиях / приоритетах", + "type": "list_of_objects", + "item_schema": [ + {"key": "description", "label": "Что изменилось"}, + {"key": "reason", "label": "Причина"}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + { + "key": "blockers", + "label": "Блокеры и зависимости", + "type": "list_of_objects", + "item_schema": [ + {"key": "description", "label": "Блокер"}, + {"key": "owner", "label": "Кто разблокирует"}, + {"key": "severity", + "label": "Серьёзность", + "options": ["Низкая", "Средняя", "Высокая", + "Критичная"]}, + ], + }, + ], + }, + { + "title": "Задачи на следующий период", + "fields": [ + { + "key": "tasks", + "label": "Поручения с ответственным и сроком", + "type": "list_of_objects", + "item_schema": [ + {"key": "description", "label": "Что сделать"}, + {"key": "assigned_to", "label": "Ответственный"}, + {"key": "due_date", "label": "Срок (YYYY-MM-DD)"}, + {"key": "priority", + "label": "Приоритет", + "options": ["Низкий", "Средний", "Высокий", + "Критичный"]}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + ], + }, + { + "title": "Договорённости", + "fields": [ + { + "key": "agreements", + "label": "Двусторонние договорённости", + "type": "list_of_objects", + "item_schema": [ + {"key": "description", + "label": "Суть договорённости"}, + {"key": "side_a", + "label": "Что обязуется сторона А"}, + {"key": "side_b", + "label": "Что обязуется сторона Б"}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + ], + }, + ] +} + +WEEKLY_PROMPT = """\ +Ты анализируешь расшифровку еженедельной встречи команды по проекту. +Цель — извлечь динамику работы и договорённости, чтобы ничто не +потерялось до следующей встречи. + +Особенно важно: +- ОТЛИЧАТЬ «что сделано» (в прошлом времени, факт) от «что в работе» + (продолжается) и «что планируется» (новые задачи). +- Любая ДВУСТОРОННЯЯ договорённость («ты пришлёшь данные, я подключу + Антона») должна попадать в `agreements` с обеими обязующимися + сторонами, а не разваливаться на две односторонние задачи. +- Не выдумывай ответственных и сроки. Если не названы — оставляй + пусто. + +Верни СТРОГО валидный JSON. Без markdown, без пояснений до или после. + +Расшифровка встречи: +--- +{transcript} +--- +""" + + +# ────────────────────────────────────────────────────────────────────────── +# Профиль 2: База знаний +# ────────────────────────────────────────────────────────────────────────── + +KB_SCHEMA = { + "sections": [ + { + "title": "Темы и концепты", + "fields": [ + { + "key": "concepts", + "label": "Объяснённые понятия", + "type": "list_of_objects", + "item_schema": [ + {"key": "name", "label": "Концепт"}, + {"key": "definition", + "label": "Краткое определение по записи"}, + {"key": "context", + "label": "В каком контексте упоминался"}, + ], + }, + ], + }, + { + "title": "Правила и ограничения", + "fields": [ + { + "key": "rules", + "label": "Утверждения «как должно быть» / «нельзя»", + "type": "list_of_objects", + "item_schema": [ + {"key": "rule", + "label": "Правило или ограничение"}, + {"key": "rationale", + "label": "Обоснование (если приведено)"}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + ], + }, + { + "title": "Примеры и кейсы", + "fields": [ + { + "key": "examples", + "label": "Конкретные примеры из обсуждения", + "type": "list_of_objects", + "item_schema": [ + {"key": "example", "label": "Что произошло / пример"}, + {"key": "lesson", + "label": "Какой вывод можно сделать"}, + ], + }, + ], + }, + { + "title": "Ссылки и источники", + "fields": [ + { + "key": "references", + "label": "Внешние материалы для углубления", + "type": "list_of_objects", + "item_schema": [ + {"key": "title", + "label": "Документ / страница / ресурс"}, + {"key": "url", "label": "URL (если есть)"}, + {"key": "why_relevant", + "label": "Зачем стоит посмотреть"}, + ], + }, + ], + }, + { + "title": "Эксперты по теме", + "fields": [ + { + "key": "experts", + "label": "Кто разбирается, к кому обращаться", + "type": "list_of_objects", + "item_schema": [ + {"key": "person", "label": "Имя"}, + {"key": "expertise", + "label": "В чём именно эксперт"}, + ], + }, + ], + }, + ] +} + +KB_PROMPT = """\ +Ты превращаешь рабочую встречу в запись для базы знаний компании. +Через полгода новый сотрудник должен прочитать твой результат и +получить полезную информацию. + +Извлекай ТОЛЬКО то, что переживёт время: +- понятия, термины, аббревиатуры (ЮНИСАП, JSON Logic и т.п.) — с + определениями; +- правила «как должно быть», ограничения «так делать нельзя», + принципы работы; +- конкретные кейсы и примеры с уроками; +- ссылки на документы, тикеты, страницы — для дальнейшего изучения; +- людей-экспертов в обсуждавшихся областях. + +НЕ извлекай: +- сиюминутные задачи и поручения, +- эмоции, мнения, обсуждение людей, +- организационные мелочи (перенос встречи, согласование времени). + +Если в расшифровке нет содержательной знаниевой составляющей — верни +пустые массивы, не выдумывай. + +Верни СТРОГО валидный JSON. + +Расшифровка встречи: +--- +{transcript} +--- +""" + + +# ────────────────────────────────────────────────────────────────────────── +# Профиль 3: Вопрос-ответ +# ────────────────────────────────────────────────────────────────────────── + +QA_SCHEMA = { + "sections": [ + { + "title": "Заданные вопросы и ответы", + "fields": [ + { + "key": "qa_pairs", + "label": "Пары «вопрос → ответ»", + "type": "list_of_objects", + "item_schema": [ + {"key": "question", "label": "Вопрос"}, + {"key": "asked_by", "label": "Кто спросил"}, + {"key": "answer", + "label": "Полученный ответ (как был дан)"}, + {"key": "answered_by", "label": "Кто ответил"}, + {"key": "completeness", + "label": "Полнота ответа", + "options": ["Полный", "Частичный", + "Уклончивый", "Не ответили"]}, + ], + }, + ], + }, + { + "title": "Открытые вопросы", + "fields": [ + { + "key": "open_questions", + "label": "Заданные, но без ответа", + "type": "list_of_objects", + "item_schema": [ + {"key": "question", "label": "Вопрос"}, + {"key": "asked_by", "label": "Кто задал"}, + {"key": "addressed_to", + "label": "Кому адресован (если ясно)"}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + ], + }, + { + "title": "Follow-up", + "fields": [ + { + "key": "follow_ups", + "label": "Что нужно уточнить / выяснить позже", + "type": "list_of_objects", + "item_schema": [ + {"key": "topic", + "label": "Что выяснить / обсудить"}, + {"key": "owner", "label": "Кто выясняет"}, + {"key": "trigger", + "label": "Из какого вопроса возникло"}, + ], + }, + ], + }, + ] +} + +QA_PROMPT = """\ +Ты разбираешь встречу с точки зрения вопросов и ответов. + +Извлеки: +1. ПАРЫ — реально заданные вопросы, на которые был дан ответ. Оцени + полноту ответа честно: если человек ушёл от темы или ответил «потом + обсудим» — это «уклончивый» или «не ответили». +2. ОТКРЫТЫЕ ВОПРОСЫ — заданные, но оставшиеся без ответа. +3. FOLLOW-UP — что планируют выяснить или обсудить отдельно. + +Не путай риторические вопросы («Понимаешь?») с настоящими. Если +человек спросил «А может…» и сам же ответил — это не вопрос, а мысль +вслух. + +Верни СТРОГО валидный JSON. Если категория пустая — верни []. + +Расшифровка встречи: +--- +{transcript} +--- +""" + + +# ────────────────────────────────────────────────────────────────────────── +# Профиль 4: Разговор с клиентом банка — оценка рисков +# ────────────────────────────────────────────────────────────────────────── + +BANK_RISK_SCHEMA = { + "sections": [ + { + "title": "Регуляторный риск", + "fields": [ + { + "key": "regulatory_risks", + "label": "Нарушения процедур и законодательства", + "type": "list_of_objects", + "item_schema": [ + {"key": "description", + "label": "Что произошло"}, + {"key": "regulation", + "label": "Какая норма / закон / процедура"}, + {"key": "severity", + "label": "Серьёзность", + "options": ["Низкая", "Средняя", "Высокая", + "Критичная"]}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + ], + }, + { + "title": "Мисселинг", + "fields": [ + { + "key": "misselling", + "label": "Некорректная продажа продуктов", + "type": "list_of_objects", + "item_schema": [ + {"key": "description", + "label": "Что было сделано не так"}, + {"key": "kind", + "label": "Тип нарушения", + "options": [ + "Гарантия дохода без оснований", + "Замалчивание комиссий/рисков", + "Продукт не подходит клиенту", + "Искажение сравнения с конкурентами", + "Давление при продаже", + "Другое", + ]}, + {"key": "product", + "label": "О каком продукте речь"}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + ], + }, + { + "title": "Репутационный риск", + "fields": [ + { + "key": "reputational_risks", + "label": "Эпизоды, грозящие репутации банка", + "type": "list_of_objects", + "item_schema": [ + {"key": "description", + "label": "Что произошло"}, + {"key": "kind", + "label": "Тип", + "options": [ + "Грубость / непрофессионализм сотрудника", + "Раздражение клиента / угроза жалобы", + "Негативные высказывания о конкурентах", + "Обсуждение других клиентов", + "Обещания, которые банк не сможет сдержать", + "Другое", + ]}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + ], + }, + { + "title": "Утечка данных / банковская тайна", + "fields": [ + { + "key": "data_leakage", + "label": "Передача чувствительной информации", + "type": "list_of_objects", + "item_schema": [ + {"key": "description", + "label": "Что было разглашено / запрошено"}, + {"key": "data_kind", + "label": "Тип данных", + "options": [ + "Персональные данные третьих лиц", + "Реквизиты карт / счетов", + "Паспортные данные", + "Сведения о других клиентах", + "Внутренняя информация банка", + "Другое", + ]}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + ], + }, + { + "title": "Социальная инженерия / признаки мошенничества", + "fields": [ + { + "key": "fraud_signals", + "label": "Подозрительные признаки", + "type": "list_of_objects", + "item_schema": [ + {"key": "description", + "label": "Что насторожило"}, + {"key": "kind", + "label": "Тип сигнала", + "options": [ + "Просьба обойти процедуру", + "Признаки давления третьих лиц на клиента", + "Нетипичная операция под надуманным предлогом", + "Срочность без объективных причин", + "Несоответствие истории клиента запросу", + "Другое", + ]}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + ], + }, + { + "title": "Общая оценка", + "fields": [ + { + "key": "overall_risk", + "label": "Совокупный уровень риска разговора", + "type": "select", + "options": ["Нет риска", "Низкий", "Средний", + "Высокий", "Критичный"], + }, + { + "key": "summary", + "label": "Резюме разговора с точки зрения комплаенса", + "type": "long_text", + }, + { + "key": "recommended_action", + "label": "Рекомендуемые действия", + "type": "select", + "options": [ + "Эскалация в комплаенс", + "Эскалация в безопасность", + "Обратная связь сотруднику", + "Доп. обучение сотрудника", + "Связаться с клиентом повторно", + "Без действий", + ], + }, + ], + }, + ] +} + +BANK_RISK_PROMPT = """\ +Ты — комплаенс-аналитик банка. Анализируешь расшифровку разговора +сотрудника банка с клиентом и оцениваешь пять видов рисков. + +Категории риска и что в них искать: + +1. РЕГУЛЯТОРНЫЙ — нарушение 115-ФЗ и процедур ПОД/ФТ, некорректная + классификация клиента (квалинвестор, статус резидентства), + некорректная фиксация согласий, упоминание санкционных операций, + обещание условий вне регламента. + +2. МИССЕЛИНГ — продажа продукта, не подходящего клиенту по риск- + профилю; гарантирование дохода по непредусмотренному продукту; + замалчивание комиссий, штрафов, ограничений; искажение фактов + при сравнении продуктов; давление, дедлайны. + +3. РЕПУТАЦИОННЫЙ — грубость или непрофессионализм сотрудника, + раздражение клиента, угрозы жалобы, негативные высказывания + о конкурентах, обсуждение других клиентов или коллег, обещания + за пределами полномочий. + +4. УТЕЧКА ДАННЫХ — разглашение или запрос данных третьих лиц, + реквизитов карт/счетов чужих людей, паспортных данных без + процедуры, внутренней информации банка. + +5. СОЦИАЛЬНАЯ ИНЖЕНЕРИЯ / МОШЕННИЧЕСТВО — клиент просит обойти + процедуру, явное давление третьих лиц на клиента, нетипичная + операция под надуманным предлогом, неуместная срочность, операция + расходится с историей клиента. + +Принципы: +- Лучше отметить серое поле, чем пропустить — но не выдумывай рисков + там, где их нет. Если разговор штатный — все массивы пусты, общий + риск «Нет риска». +- Каждый эпизод подтверждай цитатой. +- Один и тот же эпизод может попасть в две категории — это нормально. + +Верни СТРОГО валидный JSON. + +Расшифровка разговора: +--- +{transcript} +--- +""" + + +# ────────────────────────────────────────────────────────────────────────── +# Профиль 5: HR 1:1 / разбор сотрудника +# ────────────────────────────────────────────────────────────────────────── + +HR_SCHEMA = { + "sections": [ + { + "title": "Состояние сотрудника", + "fields": [ + { + "key": "mood", + "label": "Общее настроение", + "type": "select", + "options": ["Позитивное", "Нейтральное", + "Тревожное", "Негативное"], + }, + { + "key": "burnout_signals", + "label": "Признаки выгорания", + "type": "list_of_objects", + "item_schema": [ + {"key": "signal", + "label": "Признак (усталость, цинизм, апатия и т.п.)"}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + { + "key": "engagement", + "label": "Уровень вовлечённости", + "type": "select", + "options": ["Высокая", "Средняя", "Низкая", + "Признаки увольнения"], + }, + ], + }, + { + "title": "Карьера и развитие", + "fields": [ + { + "key": "career_goals", + "label": "Озвученные карьерные ожидания", + "type": "list_of_strings", + }, + { + "key": "growth_blockers", + "label": "Что мешает росту", + "type": "list_of_objects", + "item_schema": [ + {"key": "blocker", "label": "Что мешает"}, + {"key": "owner", + "label": "Кто может помочь устранить"}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + { + "key": "skill_requests", + "label": "Запросы на обучение и навыки", + "type": "list_of_strings", + }, + ], + }, + { + "title": "Обратная связь", + "fields": [ + { + "key": "feedback_about_team", + "label": "Обратная связь о команде / процессах", + "type": "list_of_objects", + "item_schema": [ + {"key": "feedback", + "label": "Что сказал сотрудник"}, + {"key": "tone", + "label": "Тон", + "options": ["Позитивный", "Нейтральный", + "Критический"]}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + { + "key": "feedback_about_manager", + "label": "Обратная связь о руководителе", + "type": "list_of_objects", + "item_schema": [ + {"key": "feedback", + "label": "Что сказал сотрудник"}, + {"key": "tone", + "label": "Тон", + "options": ["Позитивный", "Нейтральный", + "Критический"]}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + ], + }, + { + "title": "Договорённости с менеджером", + "fields": [ + { + "key": "manager_promises", + "label": "Что обещал руководитель", + "type": "list_of_objects", + "item_schema": [ + {"key": "promise", + "label": "Что именно обещано"}, + {"key": "deadline", + "label": "Срок (если назван)"}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + { + "key": "employee_commitments", + "label": "Что обещал сотрудник", + "type": "list_of_objects", + "item_schema": [ + {"key": "commitment", + "label": "Что именно обещано"}, + {"key": "deadline", + "label": "Срок (если назван)"}, + {"key": "source_quote", "label": "Цитата"}, + ], + }, + ], + }, + { + "title": "Сигналы для HR", + "fields": [ + { + "key": "alerts", + "label": "На что обратить внимание HR", + "type": "list_of_objects", + "item_schema": [ + {"key": "alert", + "label": "Что насторожило"}, + {"key": "severity", + "label": "Серьёзность", + "options": ["Информация", "Внимание", "Срочно"]}, + ], + }, + ], + }, + ] +} + +HR_PROMPT = """\ +Ты — HR-аналитик. Анализируешь встречу 1:1 (сотрудник + руководитель) +или другую встречу, в которой обсуждается сотрудник. + +Цель — извлечь то, что важно для удержания, развития и благополучия +человека. Особенно следи за: +- ПРИЗНАКАМИ ВЫГОРАНИЯ — усталость, цинизм, апатия, фразы вроде + «всё бесполезно», «опять то же самое», «нет сил», «не хочу видеть + никого». Если есть такие сигналы — обязательно отметь и приведи + цитату, не сглаживай. +- ОБЕЩАНИЯМИ С ОБЕИХ СТОРОН — что обещал руководитель и что + обязался сотрудник. Это самое лёгкое для пропуска и самое + болезненное при потере. +- ОТКРЫТОЙ КРИТИКОЙ В АДРЕС РУКОВОДИТЕЛЯ или команды — даже + если осторожно сформулирована. +- ЗАПРОСАМИ НА РОСТ — повышение, новый проект, обучение. + +ПРАВИЛА: +- Не выдумывай эмоции, которых нет в тексте. Если разговор + деловой и нейтральный — так и пиши. +- Не интерпретируй за сотрудника. Цитата + краткое описание факта. +- Если есть СИЛЬНЫЕ сигналы (увольнение, выгорание, конфликт с + руководителем) — пометь как «Срочно» в alerts. +- Если разговор не про сотрудника, а технический — все массивы + пустые, mood = «Нейтральное». + +Верни СТРОГО валидный JSON. + +Расшифровка встречи: +--- +{transcript} +--- +""" + + +# ────────────────────────────────────────────────────────────────────────── +# Реестр профилей и применение +# ────────────────────────────────────────────────────────────────────────── + +PROFILES = [ + { + "name": "Еженедельная встреча проекта", + "description": ( + "Динамика проекта: что сделано, что в работе, изменения в " + "скоупе, блокеры, новые задачи и двусторонние договорённости." + ), + "schema": WEEKLY_SCHEMA, + "prompt": WEEKLY_PROMPT, + "temperature": 0.2, + }, + { + "name": "База знаний", + "description": ( + "Превращает встречу в запись для базы знаний: концепты, " + "правила, примеры, ссылки на ресурсы, эксперты по теме. " + "Игнорирует сиюминутные задачи." + ), + "schema": KB_SCHEMA, + "prompt": KB_PROMPT, + "temperature": 0.3, + }, + { + "name": "Вопрос-ответ", + "description": ( + "Извлекает заданные вопросы и ответы парами, отдельно — " + "открытые вопросы без ответа и follow-up для дальнейшего " + "разбора." + ), + "schema": QA_SCHEMA, + "prompt": QA_PROMPT, + "temperature": 0.2, + }, + { + "name": "Риски разговора с клиентом банка", + "description": ( + "Комплаенс-анализ разговора с клиентом по пяти зонам риска: " + "регуляторный, мисселинг, репутационный, утечка данных, " + "социальная инженерия. Плюс общая оценка и рекомендация." + ), + "schema": BANK_RISK_SCHEMA, + "prompt": BANK_RISK_PROMPT, + "temperature": 0.1, + }, + { + "name": "HR 1:1", + "description": ( + "Разбор встречи руководителя с сотрудником: настроение, " + "признаки выгорания, карьерные ожидания, блокеры роста, " + "обратная связь, обещания обеих сторон, сигналы для HR." + ), + "schema": HR_SCHEMA, + "prompt": HR_PROMPT, + "temperature": 0.2, + }, +] + + +def execute(): + """Создаёт или обновляет 5 встроенных профилей. Идемпотентно.""" + for spec in PROFILES: + _upsert_profile(spec) + frappe.db.commit() + + +def _upsert_profile(spec: dict) -> None: + name = spec["name"] + schema_json = json.dumps(spec["schema"], ensure_ascii=False) + if frappe.db.exists("Analysis Profile", name): + # Обновляем содержательные поля; пользовательские флаги не трогаем. + profile = frappe.get_doc("Analysis Profile", name) + profile.is_builtin = 1 + profile.description = spec["description"] + profile.analysis_prompt = spec["prompt"] + profile.output_schema = schema_json + profile.save(ignore_permissions=True) + else: + frappe.get_doc({ + "doctype": "Analysis Profile", + "profile_name": name, + "description": spec["description"], + "enabled": 1, + "is_builtin": 1, + "analysis_prompt": spec["prompt"], + "output_schema": schema_json, + "temperature": spec.get("temperature", 0.2), + }).insert(ignore_permissions=True) \ No newline at end of file diff --git a/dyak/patches/v03/add_meeting_record_fts.py b/dyak/patches/v03/add_meeting_record_fts.py new file mode 100644 index 0000000..7419c51 --- /dev/null +++ b/dyak/patches/v03/add_meeting_record_fts.py @@ -0,0 +1,76 @@ +""" +Добавляет PostgreSQL FTS на Meeting Record: + • generated column `_search_tsv` (STORED) с весами: + A — title + B — project / summary / meeting_topics / description + D — full_text + • GIN-индекс на этой колонке. + +Поле создаётся прямо в БД, минуя DocType JSON. Frappe о нём не знает — +это служебная колонка для retrieval, не должна светиться в форме или +Report Builder. + +Идемпотентно: оба DDL — IF NOT EXISTS. +Выполняется только на PostgreSQL (на MariaDB — тихо пропускается). +""" +import frappe + + +DDL_COLUMN = """ +ALTER TABLE "tabMeeting Record" + ADD COLUMN IF NOT EXISTS "_search_tsv" tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('russian', coalesce(title, '')), 'A') || + setweight(to_tsvector('russian', coalesce(project, '')), 'B') || + setweight(to_tsvector('russian', coalesce(summary, '')), 'B') || + setweight(to_tsvector('russian', coalesce(meeting_topics, '')), 'B') || + setweight(to_tsvector('russian', coalesce(description, '')), 'B') || + setweight(to_tsvector('russian', coalesce(full_text, '')), 'D') + ) STORED; +""" + +DDL_INDEX = """ +CREATE INDEX IF NOT EXISTS "idx_meeting_record_search_tsv" + ON "tabMeeting Record" + USING GIN ("_search_tsv"); +""" + + +def execute(): + if frappe.db.db_type != "postgres": + return + + # Защита: убеждаемся, что все колонки, на которые ссылается generated + # column, существуют. Если, например, кто-то ещё не накатил migrate + # на новую версию meeting_record.json — DDL упадёт. + needed = {"title", "project", "summary", "meeting_topics", + "description", "full_text"} + cols = frappe.db.sql( + """ + SELECT column_name FROM information_schema.columns + WHERE table_name = 'tabMeeting Record' + """, + as_dict=True, + ) + col_names = {c.column_name for c in cols} + missing = needed - col_names + if missing: + frappe.log_error( + title="Dyak FTS: пропуск (нет колонок)", + message=f"Отсутствуют колонки: {missing}. " + "Сначала bench migrate обновит схему Meeting Record, " + "затем при следующем migrate патч будет переприменён.", + ) + return + + try: + frappe.db.sql(DDL_COLUMN) + frappe.db.sql(DDL_INDEX) + frappe.db.commit() + except Exception: + frappe.db.rollback() + frappe.log_error( + title="Dyak FTS: ошибка создания индекса", + message=frappe.get_traceback(), + ) + raise \ No newline at end of file diff --git a/dyak/utils/api.py b/dyak/utils/api.py new file mode 100644 index 0000000..820ad5a --- /dev/null +++ b/dyak/utils/api.py @@ -0,0 +1,55 @@ +import json +import frappe + +def jsonify_kwargs(kwargs) -> dict[str, str]: + """Преобразует каждое значение в переданном словаре kwargs в JSON-строку""" + + jsonified_kwargs = {} + for key, value in kwargs.items(): + try: + if isinstance(value, str): + if value == "": + jsonified_kwargs[key] = None + else: + jsonified_kwargs[key] = json.loads(value) + else: + jsonified_kwargs[key] = value + except json.decoder.JSONDecodeError: + # Если значение не может быть преобразовано в JSON, + # оставляем его без изменений. + jsonified_kwargs[key] = value + return jsonified_kwargs + +def parse_json_param(value, param_name: str, expected_type: type = dict): + """Разбирает параметр, который может быть строкой JSON или уже с нужным типом""" + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError as e: + frappe.throw( + f"Forge.ОшибкаВалидации: Параметр '{param_name}' содержит некорректный JSON: {e}", + title="Forge. Ошибка входных данных" + ) + + if not isinstance(parsed, expected_type): + frappe.throw( + f"Forge.ОшибкаВалидации: Параметр '{param_name}' должен быть {expected_type.__name__}", + title="Forge. Ошибка входных данных" + ) + return parsed + + return value + +def get_request_meta() -> dict: + """Собирает метаданные HTTP-запроса""" + meta = {} + try: + if hasattr(frappe, "request") and frappe.request: + req = frappe.request + meta["ip"] = req.remote_addr + meta["method"] = req.method + meta["url"] = req.url + meta["user_agent"] = req.headers.get("User-Agent", "") + except Exception: + pass + return meta \ No newline at end of file