From 5c009bc368a5da7301561de143903a036830480c Mon Sep 17 00:00:00 2001 From: nekenny Date: Fri, 3 Apr 2026 22:24:53 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20protocol=20generation=20=E2=80=94=20LLM?= =?UTF-8?q?=20integration,=20prompt=20templates,=20Celery=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/prompts/__init__.py | 0 backend/app/prompts/defaults.py | 101 +++++++++++++++++++++ backend/app/schemas/prompt_template.py | 40 ++++++++ backend/app/schemas/protocol.py | 30 ++++++ backend/app/services/protocol_generator.py | 51 +++++++++++ backend/app/tasks/protocol.py | 60 ++++++++++++ backend/tests/test_protocol_generator.py | 41 +++++++++ 7 files changed, 323 insertions(+) create mode 100644 backend/app/prompts/__init__.py create mode 100644 backend/app/prompts/defaults.py create mode 100644 backend/app/schemas/prompt_template.py create mode 100644 backend/app/schemas/protocol.py create mode 100644 backend/app/services/protocol_generator.py create mode 100644 backend/app/tasks/protocol.py create mode 100644 backend/tests/test_protocol_generator.py diff --git a/backend/app/prompts/__init__.py b/backend/app/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/prompts/defaults.py b/backend/app/prompts/defaults.py new file mode 100644 index 0000000..ab5f1f2 --- /dev/null +++ b/backend/app/prompts/defaults.py @@ -0,0 +1,101 @@ +DEFAULT_TEMPLATES = [ + { + "name": "Обследование процессов клиента", + "description": "Протокол встречи по обследованию бизнес-процессов клиента", + "type": "client_survey", + "system_prompt": ( + "Ты — опытный бизнес-аналитик. Твоя задача — составить формальный протокол встречи " + "по обследованию бизнес-процессов клиента на основе транскрипции записи встречи. " + "Протокол должен быть структурированным, точным и содержать все ключевые детали. " + "Не придумывай информацию, которой нет в транскрипции. " + "Если какой-то раздел не удаётся заполнить по транскрипции, укажи 'Не обсуждалось'." + ), + "user_prompt": ( + "Составь протокол встречи по следующей транскрипции. " + "Ответ должен быть в формате JSON с ключами:\n" + "- date_participants_topic: дата, участники и тема встречи\n" + "- process_description: описание обследуемого процесса (задачи бизнеса, подпроцесс)\n" + "- current_state: текущее состояние (as-is)\n" + "- problems: выявленные проблемы и узкие места\n" + "- agreements: договорённости и решения\n" + "- open_questions: открытые вопросы (о которых не договорились)\n" + "- tasks: задачи с ответственными и сроками (массив объектов с полями: task, responsible, deadline)\n" + "- next_steps: следующие шаги\n\n" + "Транскрипция:\n{transcription}" + ), + "output_schema": { + "sections": [ + "date_participants_topic", "process_description", "current_state", + "problems", "agreements", "open_questions", "tasks", "next_steps", + ] + }, + "is_default": True, + }, + { + "name": "Вводная встреча-знакомство с клиентом", + "description": "Протокол первой встречи с клиентом — выявление целей, параметров проекта, ЛПР", + "type": "client_intro", + "system_prompt": ( + "Ты — опытный бизнес-аналитик. Твоя задача — составить формальный протокол вводной " + "встречи-знакомства с клиентом на основе транскрипции. " + "Фокус на выявлении целей клиента, параметров проекта, ограничений и ключевых лиц. " + "Не придумывай информацию, которой нет в транскрипции. " + "Если какой-то раздел не удаётся заполнить, укажи 'Не обсуждалось'." + ), + "user_prompt": ( + "Составь протокол вводной встречи-знакомства с клиентом по следующей транскрипции. " + "Ответ должен быть в формате JSON с ключами:\n" + "- date_participants_topic: дата, участники и тема встречи\n" + "- client_goals: верхнеуровневые цели клиента\n" + "- project_parameters: параметры будущего проекта\n" + "- budget_constraints: бюджетные ограничения\n" + "- time_constraints: ограничения по срокам\n" + "- decision_makers: лица, принимающие решения (ЛПР)\n" + "- beneficiaries: бенефициары\n" + "- influencers: лица, влияющие на принятие решения\n" + "- agreements: договорённости и решения\n" + "- open_questions: открытые вопросы\n" + "- next_steps: следующие шаги\n\n" + "Транскрипция:\n{transcription}" + ), + "output_schema": { + "sections": [ + "date_participants_topic", "client_goals", "project_parameters", + "budget_constraints", "time_constraints", "decision_makers", + "beneficiaries", "influencers", "agreements", "open_questions", "next_steps", + ] + }, + "is_default": False, + }, + { + "name": "Внутренняя рабочая встреча", + "description": "Протокол внутренней рабочей встречи команды", + "type": "internal", + "system_prompt": ( + "Ты — опытный менеджер проектов. Твоя задача — составить формальный протокол " + "внутренней рабочей встречи на основе транскрипции. " + "Протокол должен быть кратким, структурированным и фокусироваться на решениях и задачах. " + "Не придумывай информацию, которой нет в транскрипции. " + "Если какой-то раздел не удаётся заполнить, укажи 'Не обсуждалось'." + ), + "user_prompt": ( + "Составь протокол внутренней рабочей встречи по следующей транскрипции. " + "Ответ должен быть в формате JSON с ключами:\n" + "- date_participants_topic: дата, участники и тема\n" + "- discussed_topics: обсуждённые вопросы\n" + "- problems: выявленные проблемы\n" + "- decisions: принятые решения\n" + "- open_questions: открытые вопросы\n" + "- tasks: задачи с ответственными и сроками (массив объектов с полями: task, responsible, deadline)\n" + "- next_steps: следующие шаги\n\n" + "Транскрипция:\n{transcription}" + ), + "output_schema": { + "sections": [ + "date_participants_topic", "discussed_topics", "problems", + "decisions", "open_questions", "tasks", "next_steps", + ] + }, + "is_default": False, + }, +] diff --git a/backend/app/schemas/prompt_template.py b/backend/app/schemas/prompt_template.py new file mode 100644 index 0000000..180daad --- /dev/null +++ b/backend/app/schemas/prompt_template.py @@ -0,0 +1,40 @@ +import uuid +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + +from app.models.prompt_template import TemplateType + + +class TemplateCreate(BaseModel): + name: str + description: str = "" + type: TemplateType = TemplateType.custom + system_prompt: str + user_prompt: str + output_schema: dict[str, Any] = {} + is_default: bool = False + + +class TemplateUpdate(BaseModel): + name: str | None = None + description: str | None = None + system_prompt: str | None = None + user_prompt: str | None = None + output_schema: dict[str, Any] | None = None + is_default: bool | None = None + + +class TemplateResponse(BaseModel): + id: uuid.UUID + name: str + description: str + type: TemplateType + system_prompt: str + user_prompt: str + output_schema: dict[str, Any] + is_default: bool + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/protocol.py b/backend/app/schemas/protocol.py new file mode 100644 index 0000000..720adb4 --- /dev/null +++ b/backend/app/schemas/protocol.py @@ -0,0 +1,30 @@ +import uuid +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + +from app.models.protocol import ProtocolStatus + + +class ProtocolResponse(BaseModel): + id: uuid.UUID + transcription_id: uuid.UUID + template_id: uuid.UUID + content: dict[str, Any] + raw_text: str + llm_model: str + status: ProtocolStatus + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ProtocolList(BaseModel): + items: list[ProtocolResponse] + total: int + + +class ProtocolRegenerate(BaseModel): + template_id: uuid.UUID | None = None diff --git a/backend/app/services/protocol_generator.py b/backend/app/services/protocol_generator.py new file mode 100644 index 0000000..cd31990 --- /dev/null +++ b/backend/app/services/protocol_generator.py @@ -0,0 +1,51 @@ +import json +import uuid + +from litellm import completion +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import settings +from app.models.prompt_template import PromptTemplate + + +def generate_protocol_text(transcription_text: str, template: PromptTemplate) -> dict: + user_message = template.user_prompt.replace("{transcription}", transcription_text) + + response = completion( + model=settings.litellm_model, + api_key=settings.litellm_api_key, + api_base=settings.litellm_api_base or None, + messages=[ + {"role": "system", "content": template.system_prompt}, + {"role": "user", "content": user_message}, + ], + temperature=0.3, + response_format={"type": "json_object"}, + ) + + raw_text = response.choices[0].message.content + try: + content = json.loads(raw_text) + except json.JSONDecodeError: + content = {"raw": raw_text} + + return { + "content": content, + "raw_text": raw_text, + "model": response.model, + } + + +def get_default_template(db: Session) -> PromptTemplate | None: + result = db.execute( + select(PromptTemplate).where(PromptTemplate.is_default == True) + ) + return result.scalar_one_or_none() + + +def get_template_by_id(db: Session, template_id: uuid.UUID) -> PromptTemplate | None: + result = db.execute( + select(PromptTemplate).where(PromptTemplate.id == template_id) + ) + return result.scalar_one_or_none() diff --git a/backend/app/tasks/protocol.py b/backend/app/tasks/protocol.py new file mode 100644 index 0000000..d6961dc --- /dev/null +++ b/backend/app/tasks/protocol.py @@ -0,0 +1,60 @@ +import uuid + +from sqlalchemy import select + +from app.tasks.celery_app import celery_app +from app.tasks.transcription import SyncSession +from app.models.protocol import Protocol, ProtocolStatus +from app.models.transcription import Transcription +from app.services.protocol_generator import ( + generate_protocol_text, + get_default_template, + get_template_by_id, +) + + +@celery_app.task(bind=True, name="app.tasks.protocol.generate_protocol") +def generate_protocol(self, transcription_id: str, template_id: str | None = None): + trans_uuid = uuid.UUID(transcription_id) + + with SyncSession() as db: + transcription = db.execute( + select(Transcription).where(Transcription.id == trans_uuid) + ).scalar_one_or_none() + + if not transcription: + return {"error": "Transcription not found"} + + if template_id: + template = get_template_by_id(db, uuid.UUID(template_id)) + else: + template = get_default_template(db) + + if not template: + return {"error": "No template found"} + + protocol = Protocol( + transcription_id=trans_uuid, + template_id=template.id, + llm_model="", + status=ProtocolStatus.generating, + ) + db.add(protocol) + db.commit() + db.refresh(protocol) + + try: + result = generate_protocol_text(transcription.text, template) + + protocol.content = result["content"] + protocol.raw_text = result["raw_text"] + protocol.llm_model = result["model"] + protocol.status = ProtocolStatus.done + db.commit() + + return {"protocol_id": str(protocol.id), "status": "done"} + + except Exception as e: + protocol.status = ProtocolStatus.error + db.commit() + raise self.retry(exc=e, max_retries=2, countdown=60) diff --git a/backend/tests/test_protocol_generator.py b/backend/tests/test_protocol_generator.py new file mode 100644 index 0000000..88b6633 --- /dev/null +++ b/backend/tests/test_protocol_generator.py @@ -0,0 +1,41 @@ +import pytest +from unittest.mock import patch, MagicMock + +from app.services.protocol_generator import generate_protocol_text + + +@pytest.mark.asyncio +async def test_generate_protocol_text_returns_structured_result(): + mock_template = MagicMock() + mock_template.system_prompt = "You are an analyst." + mock_template.user_prompt = "Create protocol from: {transcription}" + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = '{"topic": "Test meeting", "decisions": ["Decision 1"]}' + mock_response.model = "test-model" + + with patch("app.services.protocol_generator.completion", return_value=mock_response): + result = generate_protocol_text("Hello this is a meeting", mock_template) + + assert result["content"]["topic"] == "Test meeting" + assert result["raw_text"] == '{"topic": "Test meeting", "decisions": ["Decision 1"]}' + assert result["model"] == "test-model" + + +@pytest.mark.asyncio +async def test_generate_protocol_text_handles_non_json_response(): + mock_template = MagicMock() + mock_template.system_prompt = "You are an analyst." + mock_template.user_prompt = "Create protocol from: {transcription}" + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Not valid JSON response" + mock_response.model = "test-model" + + with patch("app.services.protocol_generator.completion", return_value=mock_response): + result = generate_protocol_text("Hello", mock_template) + + assert "raw" in result["content"] + assert result["content"]["raw"] == "Not valid JSON response"