319 lines
14 KiB
Python
319 lines
14 KiB
Python
"""
|
||
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}" |