Files
dyak/dyak/api/v1/ai.py
T
2026-05-19 09:59:42 +00:00

319 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}"