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(`
+ `);
+
+ // Кнопка показать лог — открывает модалку с 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 = `
+
`;
+ } 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 = `
`;
+ 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 = `
+