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