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 пунктов).
|
||||||
|
Фон ВСЕГДА должен быть исключительно белым.
|
||||||
|
Если это требуется, ответ может быть достаточно большим по размеру. Не ограничивай себя в длине
|
||||||
|
ответа, но и ненужную информацию оставлять не нужно.
|
||||||
211
main.py
211
main.py
|
|
@ -2,12 +2,17 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import aiofiles
|
||||||
import time
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from weasyprint import HTML
|
||||||
|
import io
|
||||||
|
import json
|
||||||
from typing import Dict, Optional, Tuple
|
from typing import Dict, Optional, Tuple
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
from nio import (
|
from nio import (
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
RoomMessageText,
|
RoomMessageText,
|
||||||
|
|
@ -16,6 +21,8 @@ from nio import (
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
AsyncClientConfig,
|
AsyncClientConfig,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
|
UploadResponse,
|
||||||
|
UploadError,
|
||||||
)
|
)
|
||||||
|
|
||||||
from faster_whisper import WhisperModel
|
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_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ru")
|
||||||
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
|
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()
|
TEMP_DIR = tempfile.gettempdir()
|
||||||
GROUPING_TIMEOUT = 15.0
|
GROUPING_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
@ -126,6 +141,95 @@ async def transcribe_audio(audio_bytes: bytes, mimetype: str) -> Optional[str]:
|
||||||
os.unlink(wav_path)
|
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:
|
async def process_audio(audio_data: Dict) -> str:
|
||||||
audio_bytes = audio_data["bytes"]
|
audio_bytes = audio_data["bytes"]
|
||||||
mimetype = audio_data.get("mimetype", "audio/ogg")
|
mimetype = audio_data.get("mimetype", "audio/ogg")
|
||||||
|
|
@ -142,58 +246,72 @@ async def process_image(image_data: Dict) -> str:
|
||||||
return "[Описание изображения будет добавлено позже]"
|
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):
|
async def process_complete_message(data: Dict):
|
||||||
room_id = data["room_id"]
|
room_id = data["room_id"]
|
||||||
# Объединяем все текстовые сообщения, которые были в этой группе
|
|
||||||
text_parts = data.get("text", [])
|
text_parts = data.get("text", [])
|
||||||
text = "\n".join(text_parts) if text_parts else ""
|
text = "\n".join(text_parts) if text_parts else ""
|
||||||
images_data = data.get("images", [])
|
images_data = data.get("images", [])
|
||||||
audios_data = data.get("audio", [])
|
audios_data = data.get("audio", [])
|
||||||
|
|
||||||
report = await generate_report(text, images_data, audios_data)
|
pdf_bytes = await generate_report(text, images_data, audios_data)
|
||||||
|
|
||||||
await client.room_send(
|
if pdf_bytes is None:
|
||||||
room_id,
|
await client.room_send(
|
||||||
"m.room.message",
|
room_id,
|
||||||
{"msgtype": "m.text", "body": report}
|
"m.room.message",
|
||||||
)
|
{"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:
|
if "event_id" in data:
|
||||||
pending_by_event_id.pop(data["event_id"], None)
|
pending_by_event_id.pop(data["event_id"], None)
|
||||||
pending_by_conversation.pop((room_id, data["sender"]), 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 = {
|
data = {
|
||||||
"room_id": room_id,
|
"room_id": room_id,
|
||||||
"sender": sender,
|
"sender": sender,
|
||||||
"text": [], # список строк, а не одна строка
|
"text": [],
|
||||||
"images": [],
|
"images": [],
|
||||||
"audio": [],
|
"audio": [],
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
|
|
@ -246,7 +364,6 @@ async def on_text_message(room, event: RoomMessageText):
|
||||||
|
|
||||||
event_id = event.event_id
|
event_id = event.event_id
|
||||||
data = get_or_create_pending(room.room_id, event.sender, event_id)
|
data = get_or_create_pending(room.room_id, event.sender, event_id)
|
||||||
# Добавляем текст в список, а не заменяем
|
|
||||||
data["text"].append(event.body)
|
data["text"].append(event.body)
|
||||||
reset_timer(data)
|
reset_timer(data)
|
||||||
print(f"[TEXT] Добавлен текст в сообщение от {event.sender}: {event.body}")
|
print(f"[TEXT] Добавлен текст в сообщение от {event.sender}: {event.body}")
|
||||||
|
|
@ -343,7 +460,6 @@ async def main():
|
||||||
|
|
||||||
if isinstance(response, LoginResponse):
|
if isinstance(response, LoginResponse):
|
||||||
print(f"Бот {USERNAME} успешно авторизован на {HOMESERVER}")
|
print(f"Бот {USERNAME} успешно авторизован на {HOMESERVER}")
|
||||||
print(f"Access token: {client.access_token}")
|
|
||||||
else:
|
else:
|
||||||
print(f"Ошибка авторизации: {response}")
|
print(f"Ошибка авторизации: {response}")
|
||||||
return
|
return
|
||||||
|
|
@ -360,6 +476,9 @@ async def main():
|
||||||
if WHISPER_LANGUAGE:
|
if WHISPER_LANGUAGE:
|
||||||
print(f"Язык распознавания: {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_text_message, RoomMessageText)
|
||||||
client.add_event_callback(on_image_message, RoomMessageImage)
|
client.add_event_callback(on_image_message, RoomMessageImage)
|
||||||
client.add_event_callback(on_audio_message, RoomMessageAudio)
|
client.add_event_callback(on_audio_message, RoomMessageAudio)
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,17 @@ annotated-doc==0.0.4
|
||||||
anyio==4.12.1
|
anyio==4.12.1
|
||||||
attrs==26.1.0
|
attrs==26.1.0
|
||||||
av==17.0.0
|
av==17.0.0
|
||||||
|
brotli==1.2.0
|
||||||
certifi==2026.2.25
|
certifi==2026.2.25
|
||||||
|
cffi==2.0.0
|
||||||
click==8.3.1
|
click==8.3.1
|
||||||
|
cssselect2==0.9.0
|
||||||
ctranslate2==4.7.1
|
ctranslate2==4.7.1
|
||||||
dotenv==0.9.9
|
dotenv==0.9.9
|
||||||
faster-whisper==1.2.1
|
faster-whisper==1.2.1
|
||||||
filelock==3.25.2
|
filelock==3.25.2
|
||||||
flatbuffers==25.12.19
|
flatbuffers==25.12.19
|
||||||
|
fonttools==4.62.1
|
||||||
frozenlist==1.8.0
|
frozenlist==1.8.0
|
||||||
fsspec==2026.2.0
|
fsspec==2026.2.0
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
|
|
@ -27,6 +31,7 @@ hyperframe==6.1.0
|
||||||
idna==3.11
|
idna==3.11
|
||||||
jsonschema==4.26.0
|
jsonschema==4.26.0
|
||||||
jsonschema-specifications==2025.9.1
|
jsonschema-specifications==2025.9.1
|
||||||
|
Markdown==3.10.2
|
||||||
markdown-it-py==4.0.0
|
markdown-it-py==4.0.0
|
||||||
matrix-nio==0.25.2
|
matrix-nio==0.25.2
|
||||||
mdurl==0.1.2
|
mdurl==0.1.2
|
||||||
|
|
@ -35,10 +40,14 @@ multidict==6.7.1
|
||||||
numpy==2.4.3
|
numpy==2.4.3
|
||||||
onnxruntime==1.24.4
|
onnxruntime==1.24.4
|
||||||
packaging==26.0
|
packaging==26.0
|
||||||
|
pillow==12.1.1
|
||||||
propcache==0.4.1
|
propcache==0.4.1
|
||||||
protobuf==7.34.1
|
protobuf==7.34.1
|
||||||
|
pycparser==3.0
|
||||||
pycryptodome==3.23.0
|
pycryptodome==3.23.0
|
||||||
|
pydyf==0.12.1
|
||||||
Pygments==2.19.2
|
Pygments==2.19.2
|
||||||
|
pyphen==0.17.2
|
||||||
python-dotenv==1.2.2
|
python-dotenv==1.2.2
|
||||||
python-socks==2.8.1
|
python-socks==2.8.1
|
||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
|
|
@ -48,9 +57,14 @@ rpds-py==0.30.0
|
||||||
setuptools==82.0.1
|
setuptools==82.0.1
|
||||||
shellingham==1.5.4
|
shellingham==1.5.4
|
||||||
sympy==1.14.0
|
sympy==1.14.0
|
||||||
|
tinycss2==1.5.1
|
||||||
|
tinyhtml5==2.1.0
|
||||||
tokenizers==0.22.2
|
tokenizers==0.22.2
|
||||||
tqdm==4.67.3
|
tqdm==4.67.3
|
||||||
typer==0.24.1
|
typer==0.24.1
|
||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
unpaddedbase64==2.1.0
|
unpaddedbase64==2.1.0
|
||||||
|
weasyprint==68.1
|
||||||
|
webencodings==0.5.1
|
||||||
yarl==1.23.0
|
yarl==1.23.0
|
||||||
|
zopfli==0.4.1
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue