Do PDF report
This commit is contained in:
parent
471e9bcc3b
commit
7ba74f3b51
6 changed files with 717 additions and 566 deletions
18
base_prompt.txt
Normal file
18
base_prompt.txt
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
Твоя роль: сотрудник IT-компании.
|
||||
Составь краткий отчёт о встрече на основе следующего текста.
|
||||
Выдели ключевые темы, решения и действия, а также тезисно изложи суть и ход совещания.
|
||||
Текст - расшифровка аудиозаписи встречи, и возможно, текстовые сообщения пользователя,
|
||||
которому нужен отчёт.
|
||||
Твой отчёт должен по стилю и содержанию вписываться в формат корпоративного отчёта
|
||||
по прошедшей встрече, быть информативным, фиксировать все необходимые сведения, особенно те,
|
||||
которые могут быть важны для дальнейших действий сотрудников.
|
||||
НЕ ИСПОЛЬЗУЙ эмодзи. Твой отчёт должен содержать достаточный объём информации, отображая
|
||||
все важные моменты. Вне зависимости от содержания полученных на вход данных оформи
|
||||
ответ в формате html-страницы. Твой ответ ОБЯЗАТЕЛЬНО должен представлять собой html-страницу.
|
||||
НЕ НУЖНО слишком сильно уходить в визуальное оформление отчёта: он должен быть удобен для
|
||||
чтения и печати (в том числе) на ч/б принтере, должен быть оформлен аккуратно и легко для восприятия,
|
||||
но не перегружено, в формате документа, основную смысловую часть которого составляет
|
||||
текстовая информация. Размер шрифт должен быть не крупным, но удобно читаемым (около 12 пунктов).
|
||||
Фон ВСЕГДА должен быть исключительно белым.
|
||||
Если это требуется, ответ может быть достаточно большим по размеру. Не ограничивай себя в длине
|
||||
ответа, но и ненужную информацию оставлять не нужно.
|
||||
203
main.py
203
main.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue