Do PDF report
This commit is contained in:
parent
471e9bcc3b
commit
7ba74f3b51
6 changed files with 717 additions and 566 deletions
28
.env.example
28
.env.example
|
|
@ -1,15 +1,15 @@
|
||||||
HOMESERVER=https://matrix.org
|
HOMESERVER=https://matrix.org
|
||||||
|
|
||||||
# Bot's Matrix username (full MXID)
|
# Bot's Matrix username (full MXID)
|
||||||
MATRIX_USERNAME=@your_bot:matrix.org
|
MATRIX_USERNAME=@your_bot:matrix.org
|
||||||
|
|
||||||
# Either use password OR access token
|
# Either use password OR access token
|
||||||
PASSWORD=
|
PASSWORD=
|
||||||
ACCESS_TOKEN=syt_...
|
ACCESS_TOKEN=syt_...
|
||||||
|
|
||||||
# Allowed rooms (comma-separated, no spaces)
|
# Allowed rooms (comma-separated, no spaces)
|
||||||
ALLOWED_ROOMS=!roomid1:matrix.org,!roomid2:matrix.org
|
ALLOWED_ROOMS=!roomid1:matrix.org,!roomid2:matrix.org
|
||||||
|
|
||||||
# Whisper settings
|
# Whisper settings
|
||||||
WHISPER_LANGUAGE=ru
|
WHISPER_LANGUAGE=ru
|
||||||
WHISPER_MODEL=small
|
WHISPER_MODEL=small
|
||||||
344
.gitignore
vendored
344
.gitignore
vendored
|
|
@ -1,172 +1,172 @@
|
||||||
# ---> Python
|
# ---> Python
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
.idea/*
|
.idea/*
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
venv1
|
venv1
|
||||||
venv1/
|
venv1/
|
||||||
venv1/*
|
venv1/*
|
||||||
venv1/**
|
venv1/**
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
lib/
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
wheels/
|
wheels/
|
||||||
share/python-wheels/
|
share/python-wheels/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
MANIFEST
|
MANIFEST
|
||||||
|
|
||||||
# PyInstaller
|
# PyInstaller
|
||||||
# Usually these files are written by a python script from a template
|
# Usually these files are written by a python script from a template
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
*.manifest
|
*.manifest
|
||||||
*.spec
|
*.spec
|
||||||
|
|
||||||
# Installer logs
|
# Installer logs
|
||||||
pip-log.txt
|
pip-log.txt
|
||||||
pip-delete-this-directory.txt
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
# Unit test / coverage reports
|
# Unit test / coverage reports
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
.nox/
|
.nox/
|
||||||
.coverage
|
.coverage
|
||||||
.coverage.*
|
.coverage.*
|
||||||
.cache
|
.cache
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
coverage.xml
|
coverage.xml
|
||||||
*.cover
|
*.cover
|
||||||
*.py,cover
|
*.py,cover
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
cover/
|
cover/
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
*.pot
|
*.pot
|
||||||
|
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
|
|
||||||
# Flask stuff:
|
# Flask stuff:
|
||||||
instance/
|
instance/
|
||||||
.webassets-cache
|
.webassets-cache
|
||||||
|
|
||||||
# Scrapy stuff:
|
# Scrapy stuff:
|
||||||
.scrapy
|
.scrapy
|
||||||
|
|
||||||
# Sphinx documentation
|
# Sphinx documentation
|
||||||
docs/_build/
|
docs/_build/
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
.pybuilder/
|
.pybuilder/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Jupyter Notebook
|
# Jupyter Notebook
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
|
|
||||||
# IPython
|
# IPython
|
||||||
profile_default/
|
profile_default/
|
||||||
ipython_config.py
|
ipython_config.py
|
||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
# .python-version
|
# .python-version
|
||||||
|
|
||||||
# pipenv
|
# pipenv
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
# install all needed dependencies.
|
# install all needed dependencies.
|
||||||
#Pipfile.lock
|
#Pipfile.lock
|
||||||
|
|
||||||
# poetry
|
# poetry
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
# commonly ignored for libraries.
|
# commonly ignored for libraries.
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
#poetry.lock
|
#poetry.lock
|
||||||
|
|
||||||
# pdm
|
# pdm
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
#pdm.lock
|
#pdm.lock
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
# in version control.
|
# in version control.
|
||||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
.pdm.toml
|
.pdm.toml
|
||||||
.pdm-python
|
.pdm-python
|
||||||
.pdm-build/
|
.pdm-build/
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
__pypackages__/
|
__pypackages__/
|
||||||
|
|
||||||
# Celery stuff
|
# Celery stuff
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
celerybeat.pid
|
celerybeat.pid
|
||||||
|
|
||||||
# SageMath parsed files
|
# SageMath parsed files
|
||||||
*.sage.py
|
*.sage.py
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
.spyderproject
|
.spyderproject
|
||||||
.spyproject
|
.spyproject
|
||||||
|
|
||||||
# Rope project settings
|
# Rope project settings
|
||||||
.ropeproject
|
.ropeproject
|
||||||
|
|
||||||
# mkdocs documentation
|
# mkdocs documentation
|
||||||
/site
|
/site
|
||||||
|
|
||||||
# mypy
|
# mypy
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.dmypy.json
|
.dmypy.json
|
||||||
dmypy.json
|
dmypy.json
|
||||||
|
|
||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
# pytype static type analyzer
|
# pytype static type analyzer
|
||||||
.pytype/
|
.pytype/
|
||||||
|
|
||||||
# Cython debug symbols
|
# Cython debug symbols
|
||||||
cython_debug/
|
cython_debug/
|
||||||
|
|
||||||
# PyCharm
|
# PyCharm
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Бот для автоматической генерации отчётов
|
# Бот для автоматической генерации отчётов
|
||||||
|
|
||||||
Принимает фото/аудио файлы и текстовые сообщения, когда в течение 15 секунд нет новых сообщений,
|
Принимает фото/аудио файлы и текстовые сообщения, когда в течение 15 секунд нет новых сообщений,
|
||||||
формирует отчёт по отправленному.
|
формирует отчёт по отправленному.
|
||||||
18
base_prompt.txt
Normal file
18
base_prompt.txt
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
Твоя роль: сотрудник IT-компании.
|
||||||
|
Составь краткий отчёт о встрече на основе следующего текста.
|
||||||
|
Выдели ключевые темы, решения и действия, а также тезисно изложи суть и ход совещания.
|
||||||
|
Текст - расшифровка аудиозаписи встречи, и возможно, текстовые сообщения пользователя,
|
||||||
|
которому нужен отчёт.
|
||||||
|
Твой отчёт должен по стилю и содержанию вписываться в формат корпоративного отчёта
|
||||||
|
по прошедшей встрече, быть информативным, фиксировать все необходимые сведения, особенно те,
|
||||||
|
которые могут быть важны для дальнейших действий сотрудников.
|
||||||
|
НЕ ИСПОЛЬЗУЙ эмодзи. Твой отчёт должен содержать достаточный объём информации, отображая
|
||||||
|
все важные моменты. Вне зависимости от содержания полученных на вход данных оформи
|
||||||
|
ответ в формате html-страницы. Твой ответ ОБЯЗАТЕЛЬНО должен представлять собой html-страницу.
|
||||||
|
НЕ НУЖНО слишком сильно уходить в визуальное оформление отчёта: он должен быть удобен для
|
||||||
|
чтения и печати (в том числе) на ч/б принтере, должен быть оформлен аккуратно и легко для восприятия,
|
||||||
|
но не перегружено, в формате документа, основную смысловую часть которого составляет
|
||||||
|
текстовая информация. Размер шрифт должен быть не крупным, но удобно читаемым (около 12 пунктов).
|
||||||
|
Фон ВСЕГДА должен быть исключительно белым.
|
||||||
|
Если это требуется, ответ может быть достаточно большим по размеру. Не ограничивай себя в длине
|
||||||
|
ответа, но и ненужную информацию оставлять не нужно.
|
||||||
873
main.py
873
main.py
|
|
@ -1,377 +1,496 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import aiofiles
|
||||||
import shutil
|
import time
|
||||||
import subprocess
|
import shutil
|
||||||
from typing import Dict, Optional, Tuple
|
import subprocess
|
||||||
from dotenv import load_dotenv
|
from weasyprint import HTML
|
||||||
|
import io
|
||||||
from nio import (
|
import json
|
||||||
AsyncClient,
|
from typing import Dict, Optional, Tuple
|
||||||
RoomMessageText,
|
from dotenv import load_dotenv
|
||||||
RoomMessageImage,
|
|
||||||
RoomMessageAudio,
|
import aiohttp
|
||||||
LoginResponse,
|
from nio import (
|
||||||
AsyncClientConfig,
|
AsyncClient,
|
||||||
ErrorResponse,
|
RoomMessageText,
|
||||||
)
|
RoomMessageImage,
|
||||||
|
RoomMessageAudio,
|
||||||
from faster_whisper import WhisperModel
|
LoginResponse,
|
||||||
|
AsyncClientConfig,
|
||||||
load_dotenv()
|
ErrorResponse,
|
||||||
|
UploadResponse,
|
||||||
HOMESERVER = os.getenv("HOMESERVER", "https://matrix.org")
|
UploadError,
|
||||||
USERNAME = os.getenv("MATRIX_USERNAME")
|
)
|
||||||
PASSWORD = os.getenv("PASSWORD")
|
|
||||||
ALLOWED_ROOMS = set(room.strip() for room in os.getenv("ALLOWED_ROOMS", "").split(",") if room.strip())
|
from faster_whisper import WhisperModel
|
||||||
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ru")
|
|
||||||
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
|
load_dotenv()
|
||||||
|
|
||||||
TEMP_DIR = tempfile.gettempdir()
|
HOMESERVER = os.getenv("HOMESERVER", "https://matrix.org")
|
||||||
GROUPING_TIMEOUT = 15.0
|
USERNAME = os.getenv("MATRIX_USERNAME")
|
||||||
|
PASSWORD = os.getenv("PASSWORD")
|
||||||
client: AsyncClient = None
|
ALLOWED_ROOMS = set(room.strip() for room in os.getenv("ALLOWED_ROOMS", "").split(",") if room.strip())
|
||||||
pending_by_conversation: Dict[Tuple[str, str], Dict] = {}
|
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ru")
|
||||||
pending_by_event_id: Dict[str, Dict] = {}
|
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
|
||||||
|
|
||||||
whisper_model = None
|
# 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")
|
||||||
def get_whisper_model():
|
QWEN_MODEL = os.getenv("QWEN_MODEL", "qwen3.5-122b")
|
||||||
global whisper_model
|
QWEN_PROMPT_TEMPLATE = ""
|
||||||
if whisper_model is None:
|
with open("base_prompt.txt", "r") as f:
|
||||||
whisper_model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
|
QWEN_PROMPT_TEMPLATE += f.read()
|
||||||
print(f"Whisper модель {WHISPER_MODEL} загружена (faster-whisper).")
|
|
||||||
return whisper_model
|
TEMP_DIR = tempfile.gettempdir()
|
||||||
|
GROUPING_TIMEOUT = 15.0
|
||||||
|
|
||||||
def ffmpeg_available() -> bool:
|
client: AsyncClient = None
|
||||||
found = shutil.which("ffmpeg") is not None
|
pending_by_conversation: Dict[Tuple[str, str], Dict] = {}
|
||||||
if not found:
|
pending_by_event_id: Dict[str, Dict] = {}
|
||||||
print("[ERROR] ffmpeg не найден в системе. Установите ffmpeg и добавьте в PATH.")
|
|
||||||
return found
|
whisper_model = None
|
||||||
|
|
||||||
|
|
||||||
def get_file_extension(mimetype: str) -> str:
|
def get_whisper_model():
|
||||||
ext_map = {
|
global whisper_model
|
||||||
"audio/ogg": ".ogg",
|
if whisper_model is None:
|
||||||
"audio/mpeg": ".mp3",
|
whisper_model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
|
||||||
"audio/mp4": ".m4a",
|
print(f"Whisper модель {WHISPER_MODEL} загружена (faster-whisper).")
|
||||||
"audio/x-m4a": ".m4a",
|
return whisper_model
|
||||||
"audio/wav": ".wav",
|
|
||||||
"audio/webm": ".webm",
|
|
||||||
}
|
def ffmpeg_available() -> bool:
|
||||||
return ext_map.get(mimetype, ".tmp")
|
found = shutil.which("ffmpeg") is not None
|
||||||
|
if not found:
|
||||||
|
print("[ERROR] ffmpeg не найден в системе. Установите ffmpeg и добавьте в PATH.")
|
||||||
async def convert_to_wav(input_path: str) -> Optional[str]:
|
return found
|
||||||
output_fd, output_path = tempfile.mkstemp(suffix=".wav")
|
|
||||||
os.close(output_fd)
|
|
||||||
cmd = [
|
def get_file_extension(mimetype: str) -> str:
|
||||||
"ffmpeg", "-i", input_path,
|
ext_map = {
|
||||||
"-map", "0:a:0",
|
"audio/ogg": ".ogg",
|
||||||
"-map_metadata", "-1",
|
"audio/mpeg": ".mp3",
|
||||||
"-vn",
|
"audio/mp4": ".m4a",
|
||||||
"-acodec", "pcm_s16le",
|
"audio/x-m4a": ".m4a",
|
||||||
"-ar", "16000",
|
"audio/wav": ".wav",
|
||||||
"-ac", "1",
|
"audio/webm": ".webm",
|
||||||
"-y",
|
}
|
||||||
output_path
|
return ext_map.get(mimetype, ".tmp")
|
||||||
]
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_running_loop()
|
async def convert_to_wav(input_path: str) -> Optional[str]:
|
||||||
await loop.run_in_executor(None, lambda: subprocess.run(cmd, capture_output=True, check=True))
|
output_fd, output_path = tempfile.mkstemp(suffix=".wav")
|
||||||
return output_path
|
os.close(output_fd)
|
||||||
except subprocess.CalledProcessError as e:
|
cmd = [
|
||||||
print(f"[AUDIO] Ошибка конвертации ffmpeg: {e.stderr.decode()}")
|
"ffmpeg", "-i", input_path,
|
||||||
if os.path.exists(output_path):
|
"-map", "0:a:0",
|
||||||
os.unlink(output_path)
|
"-map_metadata", "-1",
|
||||||
return None
|
"-vn",
|
||||||
|
"-acodec", "pcm_s16le",
|
||||||
|
"-ar", "16000",
|
||||||
async def transcribe_audio(audio_bytes: bytes, mimetype: str) -> Optional[str]:
|
"-ac", "1",
|
||||||
if not ffmpeg_available():
|
"-y",
|
||||||
print("[AUDIO] Ошибка: ffmpeg не установлен.")
|
output_path
|
||||||
return None
|
]
|
||||||
|
try:
|
||||||
ext = get_file_extension(mimetype)
|
loop = asyncio.get_running_loop()
|
||||||
loop = asyncio.get_running_loop()
|
await loop.run_in_executor(None, lambda: subprocess.run(cmd, capture_output=True, check=True))
|
||||||
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
|
return output_path
|
||||||
tmp.write(audio_bytes)
|
except subprocess.CalledProcessError as e:
|
||||||
input_path = tmp.name
|
print(f"[AUDIO] Ошибка конвертации ffmpeg: {e.stderr.decode()}")
|
||||||
|
if os.path.exists(output_path):
|
||||||
wav_path = None
|
os.unlink(output_path)
|
||||||
try:
|
return None
|
||||||
wav_path = await convert_to_wav(input_path)
|
|
||||||
if not wav_path:
|
|
||||||
print("[AUDIO] Конвертация в WAV не удалась.")
|
async def transcribe_audio(audio_bytes: bytes, mimetype: str) -> Optional[str]:
|
||||||
return None
|
if not ffmpeg_available():
|
||||||
|
print("[AUDIO] Ошибка: ffmpeg не установлен.")
|
||||||
model = get_whisper_model()
|
return None
|
||||||
segments, info = await loop.run_in_executor(
|
|
||||||
None,
|
ext = get_file_extension(mimetype)
|
||||||
lambda: model.transcribe(wav_path, beam_size=5, language=WHISPER_LANGUAGE)
|
loop = asyncio.get_running_loop()
|
||||||
)
|
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
|
||||||
text = " ".join([segment.text for segment in segments])
|
tmp.write(audio_bytes)
|
||||||
return text.strip()
|
input_path = tmp.name
|
||||||
except Exception as e:
|
|
||||||
print(f"[AUDIO] Ошибка при распознавании: {e}")
|
wav_path = None
|
||||||
return None
|
try:
|
||||||
finally:
|
wav_path = await convert_to_wav(input_path)
|
||||||
if os.path.exists(input_path):
|
if not wav_path:
|
||||||
os.unlink(input_path)
|
print("[AUDIO] Конвертация в WAV не удалась.")
|
||||||
if wav_path and os.path.exists(wav_path):
|
return None
|
||||||
os.unlink(wav_path)
|
|
||||||
|
model = get_whisper_model()
|
||||||
|
segments, info = await loop.run_in_executor(
|
||||||
async def process_audio(audio_data: Dict) -> str:
|
None,
|
||||||
audio_bytes = audio_data["bytes"]
|
lambda: model.transcribe(wav_path, beam_size=5, language=WHISPER_LANGUAGE)
|
||||||
mimetype = audio_data.get("mimetype", "audio/ogg")
|
)
|
||||||
print(f"[AUDIO] Получено {len(audio_bytes)} байт аудио, тип: {mimetype}")
|
text = " ".join([segment.text for segment in segments])
|
||||||
text = await transcribe_audio(audio_bytes, mimetype)
|
return text.strip()
|
||||||
if text is None:
|
except Exception as e:
|
||||||
print("[AUDIO] Распознавание не удалось.")
|
print(f"[AUDIO] Ошибка при распознавании: {e}")
|
||||||
return ""
|
return None
|
||||||
return text
|
finally:
|
||||||
|
if os.path.exists(input_path):
|
||||||
|
os.unlink(input_path)
|
||||||
async def process_image(image_data: Dict) -> str:
|
if wav_path and os.path.exists(wav_path):
|
||||||
print(f"[IMAGE] Получено {len(image_data['bytes'])} байт изображения")
|
os.unlink(wav_path)
|
||||||
return "[Описание изображения будет добавлено позже]"
|
|
||||||
|
|
||||||
|
async def call_qwen_api(prompt: str) -> str:
|
||||||
async def generate_report(text: str, images_data: list, audios_data: list) -> str:
|
"""
|
||||||
audio_texts = []
|
Асинхронный вызов Qwen API для генерации отчёта.
|
||||||
for audio in audios_data:
|
Возвращает текст ответа или сообщение об ошибке.
|
||||||
audio_text = await process_audio(audio)
|
"""
|
||||||
if audio_text:
|
if not QWEN_API_KEY:
|
||||||
audio_texts.append(audio_text)
|
print("[QWEN] API ключ не задан, возвращаем заглушку.")
|
||||||
|
return "API ключ Qwen не настроен. Отчёт не может быть сгенерирован."
|
||||||
image_descriptions = []
|
|
||||||
for img in images_data:
|
headers = {
|
||||||
desc = await process_image(img)
|
"Authorization": f"Bearer {QWEN_API_KEY}",
|
||||||
if desc:
|
"Content-Type": "application/json"
|
||||||
image_descriptions.append(desc)
|
}
|
||||||
|
payload = {
|
||||||
parts = []
|
"model": QWEN_MODEL,
|
||||||
if text:
|
"messages": [
|
||||||
parts.append(f"**Текст сообщения:**\n{text}")
|
{"role": "user", "content": prompt}
|
||||||
if audio_texts:
|
]
|
||||||
parts.append("**Распознанный текст из аудио:**\n" + "\n\n".join(audio_texts))
|
}
|
||||||
if image_descriptions:
|
|
||||||
parts.append("**Описания изображений:**\n" + "\n".join(image_descriptions))
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
if not parts:
|
async with session.post(QWEN_ENDPOINT, headers=headers, json=payload) as resp:
|
||||||
return "Не удалось обработать сообщение (нет текста, не распознано аудио или ошибка)."
|
if resp.status == 200:
|
||||||
|
data = await resp.json()
|
||||||
print(f"[REPORT] text: {text}, images: {len(image_descriptions)}, audio: {len(audio_texts)}")
|
if "choices" in data and len(data["choices"]) > 0:
|
||||||
return "\n\n".join(parts)
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
return content.strip()
|
||||||
|
else:
|
||||||
async def send_error_message(room_id: str, error_text: str):
|
print(f"[QWEN] Неожиданный формат ответа: {data}")
|
||||||
await client.room_send(
|
return "Ошибка: не удалось извлечь ответ из API."
|
||||||
room_id,
|
else:
|
||||||
"m.room.message",
|
text = await resp.text()
|
||||||
{"msgtype": "m.text", "body": f"❌ {error_text}"}
|
print(f"[QWEN] Ошибка API: {resp.status} - {text}")
|
||||||
)
|
return f"Ошибка при обращении к Qwen API (HTTP {resp.status})."
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[QWEN] Исключение: {e}")
|
||||||
async def process_complete_message(data: Dict):
|
return "Не удалось соединиться с Qwen API."
|
||||||
room_id = data["room_id"]
|
|
||||||
# Объединяем все текстовые сообщения, которые были в этой группе
|
|
||||||
text_parts = data.get("text", [])
|
async def generate_report(text: str, images_data: list, audios_data: list) -> Optional[bytes]:
|
||||||
text = "\n".join(text_parts) if text_parts else ""
|
# Собираем транскрипции аудио
|
||||||
images_data = data.get("images", [])
|
audio_texts = []
|
||||||
audios_data = data.get("audio", [])
|
for audio in audios_data:
|
||||||
|
audio_text = await transcribe_audio(audio["bytes"], audio.get("mimetype", "audio/ogg"))
|
||||||
report = await generate_report(text, images_data, audios_data)
|
if audio_text:
|
||||||
|
audio_texts.append(audio_text)
|
||||||
await client.room_send(
|
|
||||||
room_id,
|
# Формируем полный текст для отчёта
|
||||||
"m.room.message",
|
parts = []
|
||||||
{"msgtype": "m.text", "body": report}
|
if text:
|
||||||
)
|
parts.append(f"Текстовые сообщения:\n{text}")
|
||||||
|
if audio_texts:
|
||||||
if "event_id" in data:
|
parts.append("Расшифровка аудио:\n" + "\n\n".join(audio_texts))
|
||||||
pending_by_event_id.pop(data["event_id"], None)
|
if images_data:
|
||||||
pending_by_conversation.pop((room_id, data["sender"]), None)
|
parts.append(f"Количество изображений: {len(images_data)} (анализ не выполнен)")
|
||||||
|
|
||||||
|
full_text = "\n\n".join(parts)
|
||||||
async def delayed_processing(data: Dict):
|
if not full_text.strip():
|
||||||
await asyncio.sleep(GROUPING_TIMEOUT)
|
return None
|
||||||
key = (data["room_id"], data["sender"])
|
|
||||||
if pending_by_conversation.get(key) is data:
|
prompt = f"{QWEN_PROMPT_TEMPLATE}\n Текст: {full_text}"
|
||||||
await process_complete_message(data)
|
print("[QWEN] Отправка запроса...")
|
||||||
|
report = await call_qwen_api(prompt)
|
||||||
|
print(f"[QWEN] Получен ответ: {report[:200]}...")
|
||||||
def get_or_create_pending(room_id: str, sender: str, event_id: Optional[str] = None) -> Dict:
|
|
||||||
if event_id and event_id in pending_by_event_id:
|
# Если API вернул ошибку, не генерируем PDF
|
||||||
return pending_by_event_id[event_id]
|
if report.startswith("Ошибка:"):
|
||||||
|
print(f"[QWEN] Ошибка API: {report}")
|
||||||
key = (room_id, sender)
|
return None
|
||||||
if key in pending_by_conversation:
|
report = report.replace('```html', '')
|
||||||
return pending_by_conversation[key]
|
report = report.replace('```', '')
|
||||||
|
|
||||||
data = {
|
try:
|
||||||
"room_id": room_id,
|
pdf_bytes = HTML(string=report).write_pdf()
|
||||||
"sender": sender,
|
return pdf_bytes
|
||||||
"text": [], # список строк, а не одна строка
|
except Exception as e:
|
||||||
"images": [],
|
print(f"[PDF] Ошибка генерации: {e}")
|
||||||
"audio": [],
|
return None
|
||||||
"timestamp": time.time(),
|
|
||||||
"task": None,
|
|
||||||
}
|
async def send_error_message(room_id: str, error_text: str):
|
||||||
if event_id:
|
await client.room_send(
|
||||||
data["event_id"] = event_id
|
room_id,
|
||||||
pending_by_conversation[key] = data
|
"m.room.message",
|
||||||
if event_id:
|
{"msgtype": "m.text", "body": f"❌ {error_text}"}
|
||||||
pending_by_event_id[event_id] = data
|
)
|
||||||
return data
|
|
||||||
|
|
||||||
|
async def process_audio(audio_data: Dict) -> str:
|
||||||
def reset_timer(data: Dict):
|
audio_bytes = audio_data["bytes"]
|
||||||
if data["task"] and not data["task"].done():
|
mimetype = audio_data.get("mimetype", "audio/ogg")
|
||||||
data["task"].cancel()
|
print(f"[AUDIO] Получено {len(audio_bytes)} байт аудио, тип: {mimetype}")
|
||||||
data["timestamp"] = time.time()
|
text = await transcribe_audio(audio_bytes, mimetype)
|
||||||
data["task"] = asyncio.create_task(delayed_processing(data))
|
if text is None:
|
||||||
|
print("[AUDIO] Распознавание не удалось.")
|
||||||
|
return ""
|
||||||
async def on_text_message(room, event: RoomMessageText):
|
return text
|
||||||
if event.sender == client.user_id:
|
|
||||||
return
|
|
||||||
if room.room_id not in ALLOWED_ROOMS:
|
async def process_image(image_data: Dict) -> str:
|
||||||
return
|
print(f"[IMAGE] Получено {len(image_data['bytes'])} байт изображения")
|
||||||
|
return "[Описание изображения будет добавлено позже]"
|
||||||
event_id = event.event_id
|
|
||||||
data = get_or_create_pending(room.room_id, event.sender, event_id)
|
|
||||||
# Добавляем текст в список, а не заменяем
|
async def process_complete_message(data: Dict):
|
||||||
data["text"].append(event.body)
|
room_id = data["room_id"]
|
||||||
reset_timer(data)
|
text_parts = data.get("text", [])
|
||||||
print(f"[TEXT] Добавлен текст в сообщение от {event.sender}: {event.body}")
|
text = "\n".join(text_parts) if text_parts else ""
|
||||||
|
images_data = data.get("images", [])
|
||||||
|
audios_data = data.get("audio", [])
|
||||||
async def on_image_message(room, event: RoomMessageImage):
|
|
||||||
if event.sender == client.user_id:
|
pdf_bytes = await generate_report(text, images_data, audios_data)
|
||||||
return
|
|
||||||
if room.room_id not in ALLOWED_ROOMS:
|
if pdf_bytes is None:
|
||||||
return
|
await client.room_send(
|
||||||
|
room_id,
|
||||||
related_event_id = None
|
"m.room.message",
|
||||||
if hasattr(event, "source") and "content" in event.source:
|
{"msgtype": "m.text",
|
||||||
content = event.source["content"]
|
"body": "Не удалось обработать сообщение (нет текста, не распознано аудио или ошибка)."}
|
||||||
if "m.relates_to" in content and "event_id" in content["m.relates_to"]:
|
)
|
||||||
related_event_id = content["m.relates_to"]["event_id"]
|
else:
|
||||||
|
print("[FILE] Загрузка файла на сервер...")
|
||||||
data = get_or_create_pending(room.room_id, event.sender, related_event_id)
|
# Создаём файловый объект из байтов
|
||||||
|
file_like = io.BytesIO(pdf_bytes)
|
||||||
download_result = await client.download(event.url)
|
upload_result = await client.upload(
|
||||||
if isinstance(download_result, ErrorResponse):
|
file_like,
|
||||||
print(f"[IMAGE] Ошибка скачивания: {download_result.status_code} - {download_result.message}")
|
content_type="application/pdf",
|
||||||
await send_error_message(room.room_id, "Не удалось загрузить изображение.")
|
filename="report.pdf",
|
||||||
return
|
filesize=len(pdf_bytes) # обязательно указываем размер
|
||||||
|
)
|
||||||
mimetype = getattr(event, "mimetype", None)
|
|
||||||
if not mimetype and hasattr(event, "info") and isinstance(event.info, dict):
|
# Результат может быть кортежем (UploadError, None) или объектом UploadResponse
|
||||||
mimetype = event.info.get("mimetype")
|
if isinstance(upload_result, tuple) and len(upload_result) > 0:
|
||||||
if not mimetype:
|
result_obj = upload_result[0]
|
||||||
mimetype = "image/jpeg"
|
else:
|
||||||
|
result_obj = upload_result
|
||||||
data["images"].append({
|
|
||||||
"bytes": download_result.body,
|
if isinstance(result_obj, UploadError):
|
||||||
"mimetype": mimetype,
|
print(f"[FILE] Ошибка загрузки: {result_obj.status_code} - {result_obj.message}")
|
||||||
})
|
await client.room_send(
|
||||||
reset_timer(data)
|
room_id,
|
||||||
print(f"[IMAGE] Добавлено изображение в сообщение от {event.sender}")
|
"m.room.message",
|
||||||
|
{"msgtype": "m.text", "body": "❌ Не удалось загрузить отчёт на сервер."}
|
||||||
|
)
|
||||||
async def on_audio_message(room, event: RoomMessageAudio):
|
elif isinstance(result_obj, UploadResponse):
|
||||||
if event.sender == client.user_id:
|
mxc_url = result_obj.content_uri
|
||||||
return
|
await client.room_send(
|
||||||
if room.room_id not in ALLOWED_ROOMS:
|
room_id,
|
||||||
return
|
"m.room.message",
|
||||||
|
{
|
||||||
related_event_id = None
|
"msgtype": "m.file",
|
||||||
if hasattr(event, "source") and "content" in event.source:
|
"body": "report.pdf",
|
||||||
content = event.source["content"]
|
"url": mxc_url,
|
||||||
if "m.relates_to" in content and "event_id" in content["m.relates_to"]:
|
"filename": "report.pdf",
|
||||||
related_event_id = content["m.relates_to"]["event_id"]
|
"info": {
|
||||||
|
"mimetype": "application/pdf",
|
||||||
data = get_or_create_pending(room.room_id, event.sender, related_event_id)
|
"size": len(pdf_bytes)
|
||||||
|
}
|
||||||
download_result = await client.download(event.url)
|
}
|
||||||
if isinstance(download_result, ErrorResponse):
|
)
|
||||||
print(f"[AUDIO] Ошибка скачивания: {download_result.status_code} - {download_result.message}")
|
print("[FILE] PDF отправлен")
|
||||||
await send_error_message(room.room_id, "Не удалось загрузить аудио.")
|
else:
|
||||||
return
|
print(f"[FILE] Неизвестный тип ответа: {result_obj}")
|
||||||
|
await client.room_send(
|
||||||
mimetype = None
|
room_id,
|
||||||
if hasattr(event, "info") and isinstance(event.info, dict):
|
"m.room.message",
|
||||||
mimetype = event.info.get("mimetype")
|
{"msgtype": "m.text", "body": "❌ Ошибка при загрузке отчёта (неизвестный ответ сервера)."}
|
||||||
if not mimetype:
|
)
|
||||||
mimetype = "audio/ogg"
|
|
||||||
|
# Очистка данных
|
||||||
data["audio"].append({
|
if "event_id" in data:
|
||||||
"bytes": download_result.body,
|
pending_by_event_id.pop(data["event_id"], None)
|
||||||
"mimetype": mimetype,
|
pending_by_conversation.pop((room_id, data["sender"]), None)
|
||||||
})
|
|
||||||
reset_timer(data)
|
|
||||||
print(f"[AUDIO] Добавлено аудио в сообщение от {event.sender}")
|
async def delayed_processing(data: Dict):
|
||||||
|
await asyncio.sleep(GROUPING_TIMEOUT)
|
||||||
|
key = (data["room_id"], data["sender"])
|
||||||
async def main():
|
if pending_by_conversation.get(key) is data:
|
||||||
global client
|
await process_complete_message(data)
|
||||||
|
|
||||||
config = AsyncClientConfig(
|
|
||||||
max_timeouts=10,
|
def get_or_create_pending(room_id: str, sender: str, event_id: Optional[str] = None) -> Dict:
|
||||||
store_sync_tokens=True,
|
if event_id and event_id in pending_by_event_id:
|
||||||
encryption_enabled=False,
|
return pending_by_event_id[event_id]
|
||||||
)
|
|
||||||
client = AsyncClient(
|
key = (room_id, sender)
|
||||||
homeserver=HOMESERVER,
|
if key in pending_by_conversation:
|
||||||
user=USERNAME,
|
return pending_by_conversation[key]
|
||||||
device_id=None,
|
|
||||||
config=config,
|
data = {
|
||||||
)
|
"room_id": room_id,
|
||||||
|
"sender": sender,
|
||||||
try:
|
"text": [],
|
||||||
if PASSWORD:
|
"images": [],
|
||||||
response = await client.login(PASSWORD)
|
"audio": [],
|
||||||
else:
|
"timestamp": time.time(),
|
||||||
response = await client.login(token=os.environ.get("ACCESS_TOKEN", ""))
|
"task": None,
|
||||||
|
}
|
||||||
if isinstance(response, LoginResponse):
|
if event_id:
|
||||||
print(f"Бот {USERNAME} успешно авторизован на {HOMESERVER}")
|
data["event_id"] = event_id
|
||||||
print(f"Access token: {client.access_token}")
|
pending_by_conversation[key] = data
|
||||||
else:
|
if event_id:
|
||||||
print(f"Ошибка авторизации: {response}")
|
pending_by_event_id[event_id] = data
|
||||||
return
|
return data
|
||||||
except Exception as e:
|
|
||||||
print(f"Исключение при авторизации: {e}")
|
|
||||||
return
|
def reset_timer(data: Dict):
|
||||||
|
if data["task"] and not data["task"].done():
|
||||||
if not ffmpeg_available():
|
data["task"].cancel()
|
||||||
print("ВНИМАНИЕ: ffmpeg не найден. Бот не сможет распознавать аудио.")
|
data["timestamp"] = time.time()
|
||||||
print("Установите ffmpeg (https://ffmpeg.org/download.html) и добавьте в PATH.")
|
data["task"] = asyncio.create_task(delayed_processing(data))
|
||||||
else:
|
|
||||||
print("ffmpeg найден, аудио будет обрабатываться.")
|
|
||||||
|
async def on_text_message(room, event: RoomMessageText):
|
||||||
if WHISPER_LANGUAGE:
|
if event.sender == client.user_id:
|
||||||
print(f"Язык распознавания: {WHISPER_LANGUAGE}")
|
return
|
||||||
|
if room.room_id not in ALLOWED_ROOMS:
|
||||||
client.add_event_callback(on_text_message, RoomMessageText)
|
return
|
||||||
client.add_event_callback(on_image_message, RoomMessageImage)
|
|
||||||
client.add_event_callback(on_audio_message, RoomMessageAudio)
|
event_id = event.event_id
|
||||||
|
data = get_or_create_pending(room.room_id, event.sender, event_id)
|
||||||
print("Бот запущен, ожидание событий...")
|
data["text"].append(event.body)
|
||||||
try:
|
reset_timer(data)
|
||||||
await client.sync_forever(timeout=30000)
|
print(f"[TEXT] Добавлен текст в сообщение от {event.sender}: {event.body}")
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("Бот остановлен пользователем")
|
|
||||||
finally:
|
async def on_image_message(room, event: RoomMessageImage):
|
||||||
await client.close()
|
if event.sender == client.user_id:
|
||||||
|
return
|
||||||
|
if room.room_id not in ALLOWED_ROOMS:
|
||||||
if __name__ == "__main__":
|
return
|
||||||
asyncio.run(main())
|
|
||||||
|
related_event_id = None
|
||||||
|
if hasattr(event, "source") and "content" in event.source:
|
||||||
|
content = event.source["content"]
|
||||||
|
if "m.relates_to" in content and "event_id" in content["m.relates_to"]:
|
||||||
|
related_event_id = content["m.relates_to"]["event_id"]
|
||||||
|
|
||||||
|
data = get_or_create_pending(room.room_id, event.sender, related_event_id)
|
||||||
|
|
||||||
|
download_result = await client.download(event.url)
|
||||||
|
if isinstance(download_result, ErrorResponse):
|
||||||
|
print(f"[IMAGE] Ошибка скачивания: {download_result.status_code} - {download_result.message}")
|
||||||
|
await send_error_message(room.room_id, "Не удалось загрузить изображение.")
|
||||||
|
return
|
||||||
|
|
||||||
|
mimetype = getattr(event, "mimetype", None)
|
||||||
|
if not mimetype and hasattr(event, "info") and isinstance(event.info, dict):
|
||||||
|
mimetype = event.info.get("mimetype")
|
||||||
|
if not mimetype:
|
||||||
|
mimetype = "image/jpeg"
|
||||||
|
|
||||||
|
data["images"].append({
|
||||||
|
"bytes": download_result.body,
|
||||||
|
"mimetype": mimetype,
|
||||||
|
})
|
||||||
|
reset_timer(data)
|
||||||
|
print(f"[IMAGE] Добавлено изображение в сообщение от {event.sender}")
|
||||||
|
|
||||||
|
|
||||||
|
async def on_audio_message(room, event: RoomMessageAudio):
|
||||||
|
if event.sender == client.user_id:
|
||||||
|
return
|
||||||
|
if room.room_id not in ALLOWED_ROOMS:
|
||||||
|
return
|
||||||
|
|
||||||
|
related_event_id = None
|
||||||
|
if hasattr(event, "source") and "content" in event.source:
|
||||||
|
content = event.source["content"]
|
||||||
|
if "m.relates_to" in content and "event_id" in content["m.relates_to"]:
|
||||||
|
related_event_id = content["m.relates_to"]["event_id"]
|
||||||
|
|
||||||
|
data = get_or_create_pending(room.room_id, event.sender, related_event_id)
|
||||||
|
|
||||||
|
download_result = await client.download(event.url)
|
||||||
|
if isinstance(download_result, ErrorResponse):
|
||||||
|
print(f"[AUDIO] Ошибка скачивания: {download_result.status_code} - {download_result.message}")
|
||||||
|
await send_error_message(room.room_id, "Не удалось загрузить аудио.")
|
||||||
|
return
|
||||||
|
|
||||||
|
mimetype = None
|
||||||
|
if hasattr(event, "info") and isinstance(event.info, dict):
|
||||||
|
mimetype = event.info.get("mimetype")
|
||||||
|
if not mimetype:
|
||||||
|
mimetype = "audio/ogg"
|
||||||
|
|
||||||
|
data["audio"].append({
|
||||||
|
"bytes": download_result.body,
|
||||||
|
"mimetype": mimetype,
|
||||||
|
})
|
||||||
|
reset_timer(data)
|
||||||
|
print(f"[AUDIO] Добавлено аудио в сообщение от {event.sender}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
global client
|
||||||
|
|
||||||
|
config = AsyncClientConfig(
|
||||||
|
max_timeouts=10,
|
||||||
|
store_sync_tokens=True,
|
||||||
|
encryption_enabled=False,
|
||||||
|
)
|
||||||
|
client = AsyncClient(
|
||||||
|
homeserver=HOMESERVER,
|
||||||
|
user=USERNAME,
|
||||||
|
device_id=None,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if PASSWORD:
|
||||||
|
response = await client.login(PASSWORD)
|
||||||
|
else:
|
||||||
|
response = await client.login(token=os.environ.get("ACCESS_TOKEN", ""))
|
||||||
|
|
||||||
|
if isinstance(response, LoginResponse):
|
||||||
|
print(f"Бот {USERNAME} успешно авторизован на {HOMESERVER}")
|
||||||
|
else:
|
||||||
|
print(f"Ошибка авторизации: {response}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Исключение при авторизации: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not ffmpeg_available():
|
||||||
|
print("ВНИМАНИЕ: ffmpeg не найден. Бот не сможет распознавать аудио.")
|
||||||
|
print("Установите ffmpeg (https://ffmpeg.org/download.html) и добавьте в PATH.")
|
||||||
|
else:
|
||||||
|
print("ffmpeg найден, аудио будет обрабатываться.")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
print("Бот запущен, ожидание событий...")
|
||||||
|
try:
|
||||||
|
await client.sync_forever(timeout=30000)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Бот остановлен пользователем")
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
|
||||||
|
|
@ -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