Do PDF report

This commit is contained in:
Слонова Анна 2026-03-26 00:56:48 +03:00
parent 471e9bcc3b
commit 7ba74f3b51
6 changed files with 717 additions and 566 deletions

18
base_prompt.txt Normal file
View file

@ -0,0 +1,18 @@
Твоя роль: сотрудник IT-компании.
Составь краткий отчёт о встрече на основе следующего текста.
Выдели ключевые темы, решения и действия, а также тезисно изложи суть и ход совещания.
Текст - расшифровка аудиозаписи встречи, и возможно, текстовые сообщения пользователя,
которому нужен отчёт.
Твой отчёт должен по стилю и содержанию вписываться в формат корпоративного отчёта
по прошедшей встрече, быть информативным, фиксировать все необходимые сведения, особенно те,
которые могут быть важны для дальнейших действий сотрудников.
НЕ ИСПОЛЬЗУЙ эмодзи. Твой отчёт должен содержать достаточный объём информации, отображая
все важные моменты. Вне зависимости от содержания полученных на вход данных оформи
ответ в формате html-страницы. Твой ответ ОБЯЗАТЕЛЬНО должен представлять собой html-страницу.
НЕ НУЖНО слишком сильно уходить в визуальное оформление отчёта: он должен быть удобен для
чтения и печати (в том числе) на ч/б принтере, должен быть оформлен аккуратно и легко для восприятия,
но не перегружено, в формате документа, основную смысловую часть которого составляет
текстовая информация. Размер шрифт должен быть не крупным, но удобно читаемым (около 12 пунктов).
Фон ВСЕГДА должен быть исключительно белым.
Если это требуется, ответ может быть достаточно большим по размеру. Не ограничивай себя в длине
ответа, но и ненужную информацию оставлять не нужно.

203
main.py
View file

@ -2,12 +2,17 @@
import asyncio
import os
import tempfile
import aiofiles
import time
import shutil
import subprocess
from weasyprint import HTML
import io
import json
from typing import Dict, Optional, Tuple
from dotenv import load_dotenv
import aiohttp
from nio import (
AsyncClient,
RoomMessageText,
@ -16,6 +21,8 @@ from nio import (
LoginResponse,
AsyncClientConfig,
ErrorResponse,
UploadResponse,
UploadError,
)
from faster_whisper import WhisperModel
@ -29,6 +36,14 @@ ALLOWED_ROOMS = set(room.strip() for room in os.getenv("ALLOWED_ROOMS", "").spli
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ru")
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
# Qwen API
QWEN_API_KEY = os.getenv("QWEN_API_KEY")
QWEN_ENDPOINT = os.getenv("QWEN_ENDPOINT", "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions")
QWEN_MODEL = os.getenv("QWEN_MODEL", "qwen3.5-122b")
QWEN_PROMPT_TEMPLATE = ""
with open("base_prompt.txt", "r") as f:
QWEN_PROMPT_TEMPLATE += f.read()
TEMP_DIR = tempfile.gettempdir()
GROUPING_TIMEOUT = 15.0
@ -126,6 +141,95 @@ async def transcribe_audio(audio_bytes: bytes, mimetype: str) -> Optional[str]:
os.unlink(wav_path)
async def call_qwen_api(prompt: str) -> str:
"""
Асинхронный вызов Qwen API для генерации отчёта.
Возвращает текст ответа или сообщение об ошибке.
"""
if not QWEN_API_KEY:
print("[QWEN] API ключ не задан, возвращаем заглушку.")
return "API ключ Qwen не настроен. Отчёт не может быть сгенерирован."
headers = {
"Authorization": f"Bearer {QWEN_API_KEY}",
"Content-Type": "application/json"
}
payload = {
"model": QWEN_MODEL,
"messages": [
{"role": "user", "content": prompt}
]
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(QWEN_ENDPOINT, headers=headers, json=payload) as resp:
if resp.status == 200:
data = await resp.json()
if "choices" in data and len(data["choices"]) > 0:
content = data["choices"][0]["message"]["content"]
return content.strip()
else:
print(f"[QWEN] Неожиданный формат ответа: {data}")
return "Ошибка: не удалось извлечь ответ из API."
else:
text = await resp.text()
print(f"[QWEN] Ошибка API: {resp.status} - {text}")
return f"Ошибка при обращении к Qwen API (HTTP {resp.status})."
except Exception as e:
print(f"[QWEN] Исключение: {e}")
return "Не удалось соединиться с Qwen API."
async def generate_report(text: str, images_data: list, audios_data: list) -> Optional[bytes]:
# Собираем транскрипции аудио
audio_texts = []
for audio in audios_data:
audio_text = await transcribe_audio(audio["bytes"], audio.get("mimetype", "audio/ogg"))
if audio_text:
audio_texts.append(audio_text)
# Формируем полный текст для отчёта
parts = []
if text:
parts.append(f"Текстовые сообщения:\n{text}")
if audio_texts:
parts.append("Расшифровка аудио:\n" + "\n\n".join(audio_texts))
if images_data:
parts.append(f"Количество изображений: {len(images_data)} (анализ не выполнен)")
full_text = "\n\n".join(parts)
if not full_text.strip():
return None
prompt = f"{QWEN_PROMPT_TEMPLATE}\n Текст: {full_text}"
print("[QWEN] Отправка запроса...")
report = await call_qwen_api(prompt)
print(f"[QWEN] Получен ответ: {report[:200]}...")
# Если API вернул ошибку, не генерируем PDF
if report.startswith("Ошибка:"):
print(f"[QWEN] Ошибка API: {report}")
return None
report = report.replace('```html', '')
report = report.replace('```', '')
try:
pdf_bytes = HTML(string=report).write_pdf()
return pdf_bytes
except Exception as e:
print(f"[PDF] Ошибка генерации: {e}")
return None
async def send_error_message(room_id: str, error_text: str):
await client.room_send(
room_id,
"m.room.message",
{"msgtype": "m.text", "body": f"{error_text}"}
)
async def process_audio(audio_data: Dict) -> str:
audio_bytes = audio_data["bytes"]
mimetype = audio_data.get("mimetype", "audio/ogg")
@ -142,58 +246,72 @@ async def process_image(image_data: Dict) -> str:
return "[Описание изображения будет добавлено позже]"
async def generate_report(text: str, images_data: list, audios_data: list) -> str:
audio_texts = []
for audio in audios_data:
audio_text = await process_audio(audio)
if audio_text:
audio_texts.append(audio_text)
image_descriptions = []
for img in images_data:
desc = await process_image(img)
if desc:
image_descriptions.append(desc)
parts = []
if text:
parts.append(f"**Текст сообщения:**\n{text}")
if audio_texts:
parts.append("**Распознанный текст из аудио:**\n" + "\n\n".join(audio_texts))
if image_descriptions:
parts.append("**Описания изображений:**\n" + "\n".join(image_descriptions))
if not parts:
return "Не удалось обработать сообщение (нет текста, не распознано аудио или ошибка)."
print(f"[REPORT] text: {text}, images: {len(image_descriptions)}, audio: {len(audio_texts)}")
return "\n\n".join(parts)
async def send_error_message(room_id: str, error_text: str):
await client.room_send(
room_id,
"m.room.message",
{"msgtype": "m.text", "body": f"{error_text}"}
)
async def process_complete_message(data: Dict):
room_id = data["room_id"]
# Объединяем все текстовые сообщения, которые были в этой группе
text_parts = data.get("text", [])
text = "\n".join(text_parts) if text_parts else ""
images_data = data.get("images", [])
audios_data = data.get("audio", [])
report = await generate_report(text, images_data, audios_data)
pdf_bytes = await generate_report(text, images_data, audios_data)
if pdf_bytes is None:
await client.room_send(
room_id,
"m.room.message",
{"msgtype": "m.text", "body": report}
{"msgtype": "m.text",
"body": "Не удалось обработать сообщение (нет текста, не распознано аудио или ошибка)."}
)
else:
print("[FILE] Загрузка файла на сервер...")
# Создаём файловый объект из байтов
file_like = io.BytesIO(pdf_bytes)
upload_result = await client.upload(
file_like,
content_type="application/pdf",
filename="report.pdf",
filesize=len(pdf_bytes) # обязательно указываем размер
)
# Результат может быть кортежем (UploadError, None) или объектом UploadResponse
if isinstance(upload_result, tuple) and len(upload_result) > 0:
result_obj = upload_result[0]
else:
result_obj = upload_result
if isinstance(result_obj, UploadError):
print(f"[FILE] Ошибка загрузки: {result_obj.status_code} - {result_obj.message}")
await client.room_send(
room_id,
"m.room.message",
{"msgtype": "m.text", "body": "Не удалось загрузить отчёт на сервер."}
)
elif isinstance(result_obj, UploadResponse):
mxc_url = result_obj.content_uri
await client.room_send(
room_id,
"m.room.message",
{
"msgtype": "m.file",
"body": "report.pdf",
"url": mxc_url,
"filename": "report.pdf",
"info": {
"mimetype": "application/pdf",
"size": len(pdf_bytes)
}
}
)
print("[FILE] PDF отправлен")
else:
print(f"[FILE] Неизвестный тип ответа: {result_obj}")
await client.room_send(
room_id,
"m.room.message",
{"msgtype": "m.text", "body": "❌ Ошибка при загрузке отчёта (неизвестный ответ сервера)."}
)
# Очистка данных
if "event_id" in data:
pending_by_event_id.pop(data["event_id"], None)
pending_by_conversation.pop((room_id, data["sender"]), None)
@ -217,7 +335,7 @@ def get_or_create_pending(room_id: str, sender: str, event_id: Optional[str] = N
data = {
"room_id": room_id,
"sender": sender,
"text": [], # список строк, а не одна строка
"text": [],
"images": [],
"audio": [],
"timestamp": time.time(),
@ -246,7 +364,6 @@ async def on_text_message(room, event: RoomMessageText):
event_id = event.event_id
data = get_or_create_pending(room.room_id, event.sender, event_id)
# Добавляем текст в список, а не заменяем
data["text"].append(event.body)
reset_timer(data)
print(f"[TEXT] Добавлен текст в сообщение от {event.sender}: {event.body}")
@ -343,7 +460,6 @@ async def main():
if isinstance(response, LoginResponse):
print(f"Бот {USERNAME} успешно авторизован на {HOMESERVER}")
print(f"Access token: {client.access_token}")
else:
print(f"Ошибка авторизации: {response}")
return
@ -360,6 +476,9 @@ async def main():
if WHISPER_LANGUAGE:
print(f"Язык распознавания: {WHISPER_LANGUAGE}")
if not QWEN_API_KEY:
print("ВНИМАНИЕ: QWEN_API_KEY не задан. Генерация отчётов будет недоступна.")
client.add_event_callback(on_text_message, RoomMessageText)
client.add_event_callback(on_image_message, RoomMessageImage)
client.add_event_callback(on_audio_message, RoomMessageAudio)

View file

@ -7,13 +7,17 @@ annotated-doc==0.0.4
anyio==4.12.1
attrs==26.1.0
av==17.0.0
brotli==1.2.0
certifi==2026.2.25
cffi==2.0.0
click==8.3.1
cssselect2==0.9.0
ctranslate2==4.7.1
dotenv==0.9.9
faster-whisper==1.2.1
filelock==3.25.2
flatbuffers==25.12.19
fonttools==4.62.1
frozenlist==1.8.0
fsspec==2026.2.0
h11==0.16.0
@ -27,6 +31,7 @@ hyperframe==6.1.0
idna==3.11
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
Markdown==3.10.2
markdown-it-py==4.0.0
matrix-nio==0.25.2
mdurl==0.1.2
@ -35,10 +40,14 @@ multidict==6.7.1
numpy==2.4.3
onnxruntime==1.24.4
packaging==26.0
pillow==12.1.1
propcache==0.4.1
protobuf==7.34.1
pycparser==3.0
pycryptodome==3.23.0
pydyf==0.12.1
Pygments==2.19.2
pyphen==0.17.2
python-dotenv==1.2.2
python-socks==2.8.1
PyYAML==6.0.3
@ -48,9 +57,14 @@ rpds-py==0.30.0
setuptools==82.0.1
shellingham==1.5.4
sympy==1.14.0
tinycss2==1.5.1
tinyhtml5==2.1.0
tokenizers==0.22.2
tqdm==4.67.3
typer==0.24.1
typing_extensions==4.15.0
unpaddedbase64==2.1.0
weasyprint==68.1
webencodings==0.5.1
yarl==1.23.0
zopfli==0.4.1