build: first commit

This commit is contained in:
V.Bolshakov
2026-05-19 09:59:42 +00:00
parent d8e03f1aad
commit 4f506a4351
49 changed files with 9753 additions and 189 deletions
-105
View File
@@ -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
-60
View File
@@ -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 .
+101 -23
View File
@@ -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`).
+319
View File
@@ -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}"
+786
View File
@@ -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("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
.replace("&#39;", "'"))
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
+556
View File
@@ -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:ssmm: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())
+9
View File
@@ -0,0 +1,9 @@
import frappe
from dyak.utils.api import jsonify_kwargs
@frappe.whitelist()
def button(**kwargs):
"""Песочница"""
kwargs = jsonify_kwargs(kwargs)
return kwargs
+416
View File
@@ -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))
View File
@@ -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
View File
+478
View File
@@ -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;
}
+807
View File
@@ -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>&nbsp;Новый диалог
</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>&nbsp;Думаю…${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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;",
'"': "&quot;", "'": "&#39;" }[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);
}
})();
};
+19
View File
@@ -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": "Чат с Дьяком"
}
+84
View File
@@ -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": "Дьяк"
}
+21
View File
@@ -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
View File
@@ -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
+222
View File
@@ -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()
+833
View File
@@ -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
+55
View File
@@ -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