feat: protocol generation — LLM integration, prompt templates, Celery task
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user