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