Initial SmartMeeting deployment

This commit is contained in:
Xulilong 2026-07-15 17:02:32 +08:00
commit 63b1913d9e
307 changed files with 129339 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Runtime data
deploy/data/
*.db
*.sqlite
*.sqlite3
# Logs and build output
*.log
logs/
deploy/build*.log
# Local env files
.env
*.env.local
# Backups and generated platform files
.flatten-backup-*/
.flatten-backup-local-*/
.DS_Store
.claude/
__pycache__/
*.py[cod]
*.bak
*.bak-*
# Audio/media uploads and test captures
*.wav
*.mp3
*.m4a
*.webm
*.ogg
*.flac
# Node/Python caches
node_modules/
.venv/
venv/
.pytest_cache/
.mypy_cache/
# Large local models
models/

611
asr/main.py Normal file
View File

@ -0,0 +1,611 @@
"""
FunASR Paraformer 语音识别 FastAPI 服务
ASR 模型: speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch
VAD 模型: speech_fsmn_vad_zh-cn-16k-common-pytorch
PUNC 模型: punc_ct-transformer_zh-cn-common-vocab272727-pytorch
端口: 59805
"""
import os
import re
import json
import tempfile
import logging
import subprocess
import wave
from typing import Optional
import requests
import uvicorn
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, PlainTextResponse
from funasr import AutoModel
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
_CN_NUM = {
"": 0,
"": 1,
"": 1,
"": 2,
"": 2,
"": 3,
"": 4,
"": 5,
"": 6,
"": 7,
"": 8,
"": 9,
}
_CN_UNIT = {
"": 10,
"": 100,
"": 1000,
"": 10000,
"亿": 100000000,
}
_CN_PATTERN = re.compile(r"[零一幺二两三四五六七八九十百千万亿]+")
def _cn2num(s: str) -> int:
total, section, num = 0, 0, 0
for ch in s:
if ch in _CN_NUM:
num = _CN_NUM[ch]
elif ch in _CN_UNIT:
unit = _CN_UNIT[ch]
if unit >= 10000:
section = (section + (num or 1)) * unit
total += section
section = 0
else:
section += (num or 1) * unit
num = 0
return total + section + num
def cn_to_arabic(text: str) -> str:
return _CN_PATTERN.sub(lambda m: str(_cn2num(m.group())), text)
def read_txt_file(file_path: str) -> str:
try:
with open(file_path, "r", encoding="utf-8") as file:
return file.read()
except FileNotFoundError:
logger.warning(f"找不到热词文件: {file_path}")
return ""
except Exception as e:
logger.warning(f"读取热词文件失败: {e}")
return ""
app = FastAPI(
title="Paraformer ASR API",
description="基于 FunASR Paraformer 的中文语音识别服务",
version="1.0.0",
)
model = None
VOICE_DETECTED_URL = os.environ.get("VOICE_DETECTED_URL", "http://127.0.0.1:18000")
VOICEPRINT_STORE = os.environ.get("VOICEPRINT_STORE", "/tmp/asr_voiceprints.json")
VOICEPRINT_MATCH_THRESHOLD = float(os.environ.get("VOICEPRINT_MATCH_THRESHOLD", "0.45"))
def _voiceprint_key(database_name: str, collection_name: str) -> str:
return f"{database_name or 'NB'}::{collection_name or 'voice'}"
def _load_voiceprints() -> dict:
if not os.path.exists(VOICEPRINT_STORE):
return {}
try:
with open(VOICEPRINT_STORE, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception as e:
logger.warning(f"读取声纹库失败,将使用空库: {e}")
return {}
def _save_voiceprints(data: dict):
os.makedirs(os.path.dirname(VOICEPRINT_STORE) or ".", exist_ok=True)
tmp_path = f"{VOICEPRINT_STORE}.tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp_path, VOICEPRINT_STORE)
def _extract_embedding(audio_path: str, filename: str) -> list:
with open(audio_path, "rb") as f:
response = requests.post(
f"{VOICE_DETECTED_URL.rstrip('/')}/extract_embedding",
files={"wav_file": (filename or "voice.wav", f, "audio/wav")},
timeout=120,
)
response.raise_for_status()
payload = response.json()
embedding = payload.get("embedding")
if not isinstance(embedding, list):
raise ValueError("voice-dected 返回中没有有效 embedding")
return embedding
def _convert_to_embedding_wav(audio_path: str) -> str:
output = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
output_path = output.name
output.close()
try:
subprocess.run(
[
"ffmpeg",
"-y",
"-i",
audio_path,
"-acodec",
"pcm_s16le",
"-ar",
"16000",
"-ac",
"1",
output_path,
],
check=True,
capture_output=True,
text=True,
timeout=60,
)
return output_path
except Exception:
if os.path.exists(output_path):
os.unlink(output_path)
raise
def _cosine_similarity(a: list, b: list) -> float:
if not a or not b or len(a) != len(b):
return -1.0
dot = sum(float(x) * float(y) for x, y in zip(a, b))
norm_a = sum(float(x) * float(x) for x in a) ** 0.5
norm_b = sum(float(y) * float(y) for y in b) ** 0.5
if norm_a == 0 or norm_b == 0:
return -1.0
return dot / (norm_a * norm_b)
def _match_voiceprint(audio_path: str, filename: str, database_name: str = "NB", collection_name: str = "voice"):
store = _load_voiceprints()
records = store.get(_voiceprint_key(database_name, collection_name), {})
if not records:
return None, None
embedding_path = _convert_to_embedding_wav(audio_path)
try:
embedding = _extract_embedding(embedding_path, "match.wav")
finally:
if os.path.exists(embedding_path):
os.unlink(embedding_path)
best_name = None
best_score = -1.0
for name, record in records.items():
score = _cosine_similarity(embedding, record.get("embedding", []))
if score > best_score:
best_name = name
best_score = score
logger.info(f"声纹匹配结果: name={best_name}, score={best_score:.4f}, threshold={VOICEPRINT_MATCH_THRESHOLD}")
if best_name and best_score >= VOICEPRINT_MATCH_THRESHOLD:
return best_name, best_score
return None, best_score
def load_model():
global model
if model is None:
logger.info("正在加载 Paraformer 模型...")
model = AutoModel(
model="/app/models/seacomodel",
vad_model="/app/models/vadmodel",
punc_model="/app/models/ctpuncmodel",
)
logger.info("模型加载完成")
return model
@app.on_event("startup")
async def startup_event():
load_model()
@app.get("/")
async def root():
return {
"status": "ok",
"message": "Paraformer ASR 服务运行中",
}
@app.get("/health")
async def health():
return {"status": "healthy"}
async def run_asr_websocket(websocket: WebSocket):
await websocket.accept(subprotocol="binary")
audio_chunks = []
try:
while True:
message = await websocket.receive()
if "bytes" in message and message["bytes"]:
audio_chunks.append(message["bytes"])
continue
if "text" in message and message["text"]:
try:
payload = json.loads(message["text"])
except Exception:
payload = {}
if payload.get("type") == "stop" or payload.get("is_speaking") is False:
break
continue
if message.get("type") == "websocket.disconnect":
break
except WebSocketDisconnect:
pass
text = ""
tmp_path = None
try:
if audio_chunks:
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
tmp_path = tmp.name
with wave.open(tmp_path, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16000)
wav_file.writeframes(b"".join(audio_chunks))
result = await run_asr(tmp_path, language="zh", response_format="json")
if isinstance(result, dict):
text = result.get("text", "")
await websocket.send_json({
"mode": "2pass-offline",
"text": text,
"is_final": True,
})
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
try:
await websocket.close()
except Exception:
pass
@app.websocket("/ws/asr")
async def websocket_asr(websocket: WebSocket):
await run_asr_websocket(websocket)
@app.websocket("/")
async def websocket_asr_root(websocket: WebSocket):
await run_asr_websocket(websocket)
def make_speakr_response(text: str, language: str = "zh", speaker: str = "SPEAKER_00"):
text = (text or "").strip()
return {
"text": text,
"language": language,
"segments": [
{
"start": None,
"end": None,
"text": text,
"speaker": speaker or "SPEAKER_00",
}
] if text else [],
}
async def run_asr(
audio_path: str,
language: str = "zh",
response_format: str = "json",
):
try:
hotword = read_txt_file("/app/hotword.txt")
result = model.generate(input=audio_path, hotword=hotword)
if result and len(result) > 0:
text = result[0].get("text", "")
else:
text = ""
text = cn_to_arabic(text)
logger.info(f"识别结果: {text}")
if response_format == "text":
return PlainTextResponse(content=text)
return {
"text": text,
"language": language,
}
except Exception as e:
logger.error(f"识别出错: {e}")
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
@app.post("/api/v1/audio/transcriptions")
async def transcribe(
request: Request,
file: Optional[UploadFile] = File(None),
language: Optional[str] = Form("zh"),
response_format: Optional[str] = Form("json"),
):
"""
语音转文字接口兼容两种方式
1. multipart/form-data 上传文件
- file: 音频文件
- language: 默认 zh
- response_format: json/text
2. application/json 传服务器本地文件路径
{
"file_path": "/app/audio/test.wav",
"language": "zh",
"response_format": "json"
}
"""
tmp_path = None
should_delete = False
try:
content_type = request.headers.get("content-type", "").lower()
if "application/json" in content_type:
body = await request.json()
file_path = body.get("file_path")
language = body.get("language", language)
response_format = body.get("response_format", response_format)
if not file_path:
raise HTTPException(status_code=400, detail="缺少 file_path")
if not os.path.exists(file_path):
raise HTTPException(status_code=400, detail=f"文件不存在: {file_path}")
if not os.path.isfile(file_path):
raise HTTPException(status_code=400, detail=f"不是有效文件: {file_path}")
audio_path = file_path
logger.info(f"处理服务器本地文件: {audio_path}")
else:
if file is None:
raise HTTPException(status_code=400, detail="缺少上传文件 file")
suffix = os.path.splitext(file.filename or "")[1] or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="上传文件为空")
tmp.write(content)
tmp_path = tmp.name
should_delete = True
audio_path = tmp_path
logger.info(
f"处理上传文件: {file.filename}, 大小: {len(content)} bytes, 临时路径: {audio_path}"
)
return await run_asr(
audio_path=audio_path,
language=language or "zh",
response_format=response_format or "json",
)
finally:
if should_delete and tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
@app.post("/v1/audio/transcriptions")
async def transcribe_v1(
request: Request,
file: Optional[UploadFile] = File(None),
language: Optional[str] = Form("zh"),
response_format: Optional[str] = Form("json"),
):
return await transcribe(
request=request,
file=file,
language=language,
response_format=response_format,
)
@app.post("/asr")
async def transcribe_speakr_compatible(
audio_file: UploadFile = File(...),
language: Optional[str] = Form("zh"),
database_name: Optional[str] = Form("NB"),
collection_name: Optional[str] = Form("voice"),
):
"""
Speakr 兼容接口
- 上传字段audio_file
- 返回字段segments其中每个 segment text/speaker/start/end
"""
tmp_path = None
try:
suffix = os.path.splitext(audio_file.filename or "")[1] or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await audio_file.read()
if not content:
raise HTTPException(status_code=400, detail="上传文件为空")
tmp.write(content)
tmp_path = tmp.name
logger.info(
f"处理 Speakr /asr 上传文件: {audio_file.filename}, 大小: {len(content)} bytes, 临时路径: {tmp_path}"
)
result = await run_asr(
audio_path=tmp_path,
language=language or "zh",
response_format="json",
)
speaker_name = "SPEAKER_00"
try:
matched_name, score = _match_voiceprint(
tmp_path,
audio_file.filename or "audio.wav",
database_name or "NB",
collection_name or "voice",
)
if matched_name:
speaker_name = matched_name
except Exception as e:
logger.warning(f"声纹匹配失败,使用默认说话人: {e}")
return make_speakr_response(
text=result.get("text", "") if isinstance(result, dict) else "",
language=result.get("language", language or "zh") if isinstance(result, dict) else language or "zh",
speaker=speaker_name,
)
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
@app.get("/get_voice_name")
async def get_voice_name(
database_name: str = "NB",
collection_name: str = "voice",
):
store = _load_voiceprints()
records = store.get(_voiceprint_key(database_name, collection_name), {})
return {
"code": 0,
"success": True,
"message": "ok",
"data": [
{"name": name, "voice_name": name}
for name in sorted(records.keys())
],
}
@app.post("/voice_insert")
async def voice_insert(
voice_name: str = Form(...),
database_name: str = Form("NB"),
collection_name: str = Form("voice"),
voice_file: UploadFile = File(...),
):
tmp_path = None
try:
suffix = os.path.splitext(voice_file.filename or "")[1] or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await voice_file.read()
if not content:
raise HTTPException(status_code=400, detail="上传声纹文件为空")
tmp.write(content)
tmp_path = tmp.name
embedding = _extract_embedding(tmp_path, voice_file.filename or f"{voice_name}.wav")
store = _load_voiceprints()
key = _voiceprint_key(database_name, collection_name)
records = store.setdefault(key, {})
records[voice_name] = {
"name": voice_name,
"database_name": database_name,
"collection_name": collection_name,
"embedding": embedding,
}
_save_voiceprints(store)
return {
"code": 0,
"success": True,
"message": "voice inserted",
"data": {"name": voice_name, "embedding_size": len(embedding)},
}
except requests.RequestException as e:
logger.error(f"调用 voice-dected 失败: {e}")
raise HTTPException(status_code=502, detail=f"voice-dected 调用失败: {e}")
except Exception as e:
logger.error(f"写入声纹失败: {e}")
raise HTTPException(status_code=500, detail=f"写入声纹失败: {e}")
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
@app.delete("/delete_voice")
async def delete_voice(
name: str,
database_name: str = "NB",
collection_name: str = "voice",
):
store = _load_voiceprints()
key = _voiceprint_key(database_name, collection_name)
records = store.setdefault(key, {})
existed = name in records
records.pop(name, None)
_save_voiceprints(store)
return {
"code": 0,
"success": True,
"message": "voice deleted" if existed else "voice not found",
"data": {"name": name, "deleted": existed},
}
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=59805,
reload=False,
workers=1,
log_level="info",
)

40
config.env Normal file
View File

@ -0,0 +1,40 @@
# Shared runtime config for /storage01/home/xll/202607/voice/docker-compose.yml
# Speakr / ASR
USE_ASR_ENDPOINT=true
ASR_BASE_URL=http://asr-test:59805
ASR_DIARIZE=true
ASR_MIN_SPEAKERS=1
ASR_MAX_SPEAKERS=10
# LLM used by Speakr summaries and chat
TEXT_MODEL_BASE_URL=http://192.168.0.46:59800/v1
TEXT_MODEL_NAME=46-qwen3.5-35B
TEXT_MODEL_API_KEY=none
# Realtime WebSocket bridge
TARGET_WS_URL=ws://asr-test:59805/ws/asr
REDIS_URL=redis://redis:6379/0
LLM_API_KEY=none
LLM_BASE_URL=http://192.168.0.46:59800/v1
LLM_MODEL=46-qwen3.5-35B
USE_LLM_ROLE_IDENTIFICATION=false
LLM_ROLE_CANDIDATES=主持人,正方辩手,反方辩手,计时员,评委,嘉宾,未知
LLM_ROLE_MIN_TEXT_LEN=8
LLM_ROLE_CACHE_CONFIDENCE=0.82
LLM_ROLE_HISTORY_MAX_CHARS=1200
# ASR voiceprint adapter
VOICE_DETECTED_URL=http://voice-dected:8000
VOICEPRINT_STORE=/data/asr_voiceprints.json
VOICEPRINT_MATCH_THRESHOLD=0.45
# fastapi_wss legacy/local ASR-with-speaker helper
ASR_LOCAL_URL=http://asr-test:59805/asr
SPEAKER_DET_URL=http://voice-dected:8000/extract_embedding
# Runtime
DEVICE=cuda
CUDA_VISIBLE_DEVICES=0
NVIDIA_VISIBLE_DEVICES=7
PYTHONUNBUFFERED=1

41
deploy/docker-compose.yml Normal file
View File

@ -0,0 +1,41 @@
services:
speakr:
build:
context: ..
dockerfile: deploy/speakr.runtime.Dockerfile
image: voice-speakr:runtime
container_name: voice-speakr
restart: unless-stopped
working_dir: /app
environment:
PYTHONPATH: /app
SQLALCHEMY_DATABASE_URI: sqlite:////data/instance/transcriptions.db
UPLOAD_FOLDER: /data/uploads
USE_ASR_ENDPOINT: "true"
ASR_BASE_URL: http://192.168.0.46:59805
TEXT_MODEL_BASE_URL: http://192.168.0.46:59800/v1
TEXT_MODEL_NAME: 46-qwen3.5-35B
TEXT_MODEL_API_KEY: none
volumes:
- ../speakr:/app
- ./data/speakr/uploads:/data/uploads
- ./data/speakr/instance:/data/instance
ports:
- "8899:8899"
depends_on:
- voice-dected
voice-dected:
build:
context: ..
dockerfile: deploy/voice-dected.runtime.Dockerfile
image: voice-dected:runtime
container_name: voice-dected
restart: unless-stopped
working_dir: /app
environment:
PYTHONPATH: /app
volumes:
- ../voice-dected/src:/app
ports:
- "18000:8000"

View File

@ -0,0 +1,29 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/app \
FLASK_APP=src/app.py \
SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db \
UPLOAD_FOLDER=/data/uploads
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libgomp1 \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY speakr/requirements.txt /tmp/requirements.txt
RUN pip install --upgrade pip setuptools wheel \
&& pip install --index-url https://download.pytorch.org/whl/cpu torch \
&& pip install -r /tmp/requirements.txt \
&& rm -rf /root/.cache/pip
RUN mkdir -p /data/uploads /data/instance
EXPOSE 8899
CMD ["sh", "-c", "mkdir -p /data/uploads /data/instance && python -c \"from src.app import app; from src.clients import db; app.app_context().push(); db.create_all()\" && exec gunicorn --workers ${GUNICORN_WORKERS:-3} --threads ${GUNICORN_THREADS:-8} --bind 0.0.0.0:8899 --timeout ${GUNICORN_TIMEOUT:-600} src.app:app"]

7
deploy/start.sh Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p data/speakr/uploads data/speakr/instance
docker compose up -d --build
docker compose ps

View File

@ -0,0 +1,27 @@
FROM python:3.8-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/app
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libsndfile1 \
sox \
libsox-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --upgrade pip setuptools wheel \
&& pip install --index-url https://download.pytorch.org/whl/cpu torch torchaudio
COPY voice-dected/requirements.txt /tmp/requirements.txt
RUN grep -v -E '^[[:space:]]*(torch|torchaudio)[[:space:]]*$' /tmp/requirements.txt > /tmp/requirements-no-torch.txt \
&& pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r /tmp/requirements-no-torch.txt \
&& rm -rf /root/.cache/pip
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

128
docker-compose.yml Normal file
View File

@ -0,0 +1,128 @@
services:
speakr:
image: voice-speakr:runtime
container_name: voice-speakr
restart: unless-stopped
working_dir: /app
env_file:
- ./config.env
environment:
PYTHONPATH: /app
SQLALCHEMY_DATABASE_URI: sqlite:////data/instance/transcriptions.db
UPLOAD_FOLDER: /data/uploads
USE_ASR_ENDPOINT: "true"
ASR_BASE_URL: http://asr-test:59805
volumes:
- ./speakr:/app
- ./deploy/data/speakr/uploads:/data/uploads
- ./deploy/data/speakr/instance:/data/instance
ports:
- "8899:8899"
depends_on:
- asr-test
- voice-dected
command:
- sh
- -c
- >
mkdir -p /data/uploads /data/instance &&
python -c "from src.app import app; from src.clients import db; app.app_context().push(); db.create_all()" &&
exec gunicorn --workers $${GUNICORN_WORKERS:-3} --threads $${GUNICORN_THREADS:-8}
--bind 0.0.0.0:8899 --timeout $${GUNICORN_TIMEOUT:-600} src.app:app
voice-dected:
image: voice-dected:runtime
container_name: voice-dected
restart: unless-stopped
working_dir: /app
env_file:
- ./config.env
environment:
PYTHONPATH: /app
volumes:
- ./voice-dected/src:/app
ports:
- "18000:8000"
command:
- uvicorn
- app:app
- --host
- 0.0.0.0
- --port
- "8000"
asr-test:
image: paraformer-asr:latest
container_name: asr-test
restart: unless-stopped
runtime: nvidia
gpus:
- driver: nvidia
device_ids:
- "7"
capabilities:
- gpu
working_dir: /app
env_file:
- ./config.env
environment:
DEVICE: cuda
CUDA_VISIBLE_DEVICES: "0"
NVIDIA_VISIBLE_DEVICES: "7"
VOICE_DETECTED_URL: http://voice-dected:8000
VOICEPRINT_STORE: /data/asr_voiceprints.json
volumes:
- ./asr/main.py:/app/main.py:ro
- /storage01/home/xll/audio:/app/audio:ro
- /storage01/home/xll/weixiu-hj/seacomodel:/app/models/seacomodel:ro
- /storage01/home/xll/weixiu-hj/vadmodel:/app/models/vadmodel:ro
- /storage01/home/xll/weixiu-hj/ctpuncmodel:/app/models/ctpuncmodel:ro
- /storage01/home/xll/weixiu-hj/speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch/example/hotword.txt:/app/hotword.txt:ro
- ./deploy/data/asr:/data
ports:
- "59805:59805"
depends_on:
- voice-dected
command:
- uvicorn
- main:app
- --host
- 0.0.0.0
- --port
- "59805"
- --workers
- "1"
fastapi-wss:
image: fastapi-wss:runtime
container_name: fastapi-wss
restart: unless-stopped
working_dir: /app/src
env_file:
- ./config.env
environment:
TARGET_WS_URL: ws://asr-test:59805/ws/asr
REDIS_URL: redis://redis:6379/0
volumes:
- ./fastapi_wss:/app
ports:
- "10095:10095"
depends_on:
- asr-test
- redis
command:
- python
- a2a_wss.py
redis:
image: redis:7-alpine
container_name: voice-redis
restart: unless-stopped
volumes:
- ./deploy/data/redis:/data
ports:
- "16380:6379"
command:
- redis-server
- --appendonly
- "yes"

5
fastapi_wss/.env.example Normal file
View File

@ -0,0 +1,5 @@
TARGET_WS_URL= ws://10.100.3.22:10095
REDIS_URL=redis://localhost:6379/0
LLM_API_KEY=http://192.168.0.46:59800/v1
LLM_BASE_URL=http://192.168.0.46:59800/v1
LLM_MODEL=

29
fastapi_wss/Dockerfile Normal file
View File

@ -0,0 +1,29 @@
# 使用轻量级的 Python 3.10 镜像
FROM python:3.10-slim
# 设置工作目录
WORKDIR /app
# 设置环境变量
# 1. 确保 Python 输出直接打印到终端,不进行缓冲(方便查看日志)
# 2. 避免生成 pyc 文件
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# 复制依赖文件并安装
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
apt-get update && \
apt-get install -y vim && \
rm -rf /var/lib/apt/lists/*
# 复制当前目录下的所有代码到容器中
COPY . .
WORKDIR /app/src
# 暴露端口 (对应代码中的 LOCAL_PORT)
EXPOSE 10095
# 启动 a2a_wss 服务
CMD ["python", "a2a_wss.py"]

View File

@ -0,0 +1,33 @@
# WebSocket 智能语义分析服务 - 依赖包列表
#
# 安装方法:
# pip install -r requirements.txt
#
# Python 版本要求3.8+
# WebSocket 通信库
websockets>=11.0
# 异步 HTTP 客户端(可选,用于扩展功能)
aiohttp>=3.8.0
# Redis 异步客户端
redis>=5.0.0
# Redis C 扩展(性能优化)
hiredis
# OpenAI API 客户端(用于调用大语言模型)
openai>=1.0.0
# 数值计算库(用于未来的特征处理)
numpy
python-dotenv
# 注意:以下为 Python 标准库,无需安装:
# - asyncio (异步编程)
# - json (JSON 处理)
# - os (操作系统接口)
# - re (正则表达式)
# - logging (日志记录)
# - typing (类型提示)
# - dataclasses (数据类)

425
fastapi_wss/src/README.md Normal file
View File

@ -0,0 +1,425 @@
# WebSocket 智能语义分析服务 - 长期记忆增强版
## 项目概述
这是一个基于 WebSocket 和 Agent-to-Agent (A2A) 架构的智能语音识别服务,具备说话人识别和长期记忆功能。系统能够在同一场会议中,通过学习每个说话人的发言特征,持续提高说话人识别的准确率。
## 核心特性
### 1. 实时语音识别处理
- 接收前端音频流,转发到 ASR 引擎
- 支持双通道模式2pass和 PCM 音频格式
- 实时推送识别结果到前端
### 2. 多 Agent 协作架构
- **AuditorAgent**: 判断说话人是否完成发言
- **ProfilerAgent**: 识别说话人身份(支持长期记忆)
### 3. 长期记忆功能 ⭐
- 为每个说话人建立特征档案
- 持续学习说话人的发言风格和用词习惯
- 利用会议历史上下文提高识别准确率
### 4. 完善的日志系统 📝
- 自动创建日志目录,分级记录运行信息
- 控制台输出INFO 级别(运行状态)
- 完整日志文件DEBUG 级别(详细调试信息)
- 错误日志文件ERROR 级别(错误和异常)
- 所有日志包含时间戳和上下文信息
---
## 日志系统说明
### 日志文件位置
程序运行时会在项目根目录下创建 `logs` 文件夹,包含以下日志文件:
```
logs/
├── a2a_wss.log # 完整日志DEBUG 级别)
└── a2a_wss_error.log # 错误日志ERROR 级别)
```
### 日志级别说明
| 级别 | 说明 | 使用场景 | 输出位置 |
|------|------|----------|----------|
| **DEBUG** | 调试信息 | 详细的程序执行流程、变量值等 | 仅日志文件 |
| **INFO** | 一般信息 | 关键业务流程、状态变更 | 控制台 + 日志文件 |
| **ERROR** | 错误信息 | 异常、错误堆栈 | 控制台 + 日志文件 + 错误日志 |
### 日志内容示例
```
2025-01-07 14:30:15 - root - INFO - 启动 WebSocket 智能语义分析服务: ws://0.0.0.0:10095
2025-01-07 14:30:15 - root - INFO - Agent 模型: Qwen3-32B
2025-01-07 14:30:15 - root - INFO - ASR 服务地址: ws://localhost:59805/ws/asr
2025-01-07 14:30:15 - root - INFO - Redis 地址: redis://localhost:6379/0
2025-01-07 14:30:20 - root - INFO - 客户端已连接
2025-01-07 14:30:20 - root - INFO - 已连接到 ASR 服务: ws://localhost:59805/ws/asr
2025-01-07 14:30:22 - root - DEBUG - 收到 ASR 识别结果: 各位好,今天我们召开月度工作总结会议...
2025-01-07 14:30:23 - root - INFO - [Auditor] 发言完成检测通过 - 会话ID: room_101
2025-01-07 14:30:23 - root - DEBUG - 获取说话人特征库 - 会话ID: room_101, 说话人数量: 0
2025-01-07 14:30:24 - root - INFO - [Profiler] 识别说话人: 主持人 - 会话ID: room_101
```
### 日志配置
日志系统在代码中自动配置(无需手动配置),包含:
1. **日志格式**`时间 - 记录器名称 - 级别 - 消息`
2. **编码**UTF-8支持中文
3. **自动创建目录**:首次运行时自动创建 `logs` 文件夹
4. **错误追踪**ERROR 级别日志包含完整的异常堆栈信息
### 日志查看建议
- **实时监控**`tail -f logs/a2a_wss.log`
- **查看错误**`cat logs/a2a_wss_error.log`
- **搜索关键词**`grep "识别说话人" logs/a2a_wss.log`
- **统计分析**`grep "ERROR" logs/a2a_wss_error.log | wc -l`
---
## 长期记忆功能详解
### 功能说明
长期记忆功能使得系统能够在同一场会议中,记住每个说话人的发言特征,并利用这些历史信息来辅助后续的说话人识别。
### 工作流程
```
┌─────────────┐
│ 开始会议 │
└──────┬──────┘
┌─────────────────────┐
│ 接收语音片段 │
└──────┬──────────────┘
┌─────────────────────┐
│ 累积到缓冲区 │
└──────┬──────────────┘
┌─────────────────────┐
│ Auditor: 判断发言 │
│ 是否完成 │
└──────┬──────────────┘
▼ (发言完成)
┌──────────────────────────────┐
│ 获取长期记忆 │
│ (所有说话人的历史特征) │
└──────┬───────────────────────┘
┌──────────────────────────────┐
│ Profiler: 识别说话人 │
│ - 分析会议历史记录 │
│ - 对比说话人特征库 │
│ - 判断对话流程和上下文 │
└──────┬───────────────────────┘
┌──────────────────────────────┐
│ 更新长期记忆 │
│ (保存当前发言到特征库) │
└──────┬───────────────────────┘
┌─────────────────────┐
│ 推送结果到前端 │
└─────────────────────┘
```
### 核心改进
#### 1. Redis 存储扩展
**新增方法**
- `save_speaker_profile(sid, speaker, text, features)`: 保存说话人特征到长期记忆
- 保留每个说话人最近 10 条发言记录
- 存储发言文本片段(前 200 字符)
- 记录时间戳和可选的特征向量
- `get_speaker_profiles(sid)`: 获取所有说话人的历史特征
- 返回字典格式:`{说话人: [历史记录列表]}`
- 用于 Agent 分析时参考
#### 2. ProfilerAgent 增强
**改进的识别逻辑**
- **历史记录分析**:查看最近 15 条对话,理解会议上下文
- **对话流程分析**:判断当前发言是对之前谁的话题的回应
- **说话风格匹配**:对比每个角色的用词习惯、句式结构、语气
- **角色职责判断**:根据发言内容是否符合角色职责范围
- **话题连贯性**:当前发言与该角色历史话题的连贯性
**提示词优化**
```python
# 增强版系统提示词包含4个分析维度
1. 对话流程分析
2. 说话风格匹配
3. 角色职责判断
4. 话题连贯性
```
#### 3. A2AOrchestrator 集成
`process_asr_fragment()` 方法中集成长期记忆:
```python
# 1. 获取长期记忆(说话人特征库)
speaker_profiles = await self.redis.get_speaker_profiles(session_id)
# 2. 使用长期记忆识别说话人
speaker = await self.profiler.identify(buf, history, speaker_profiles)
# 3. 保存到长期记忆(持续学习)
await self.redis.save_speaker_profile(session_id, speaker, buf)
```
---
## 配置说明
### 环境变量
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `TARGET_WS_URL` | ASR 服务的 WebSocket 地址 | `ws://localhost:59805/ws/asr` |
| `REDIS_URL` | Redis 连接地址 | `redis://localhost:6379/0` |
| `LLM_API_KEY` | 大语言模型 API Key | `sk-xxxx` |
| `LLM_BASE_URL` | 大语言模型服务地址 | `http://172.18.127.124:9997/v1` |
| `LLM_MODEL` | 使用的模型名称 | `Qwen3-32B` |
### 候选说话人列表
在代码中配置(`CANDIDATE_SPEAKERS`
```python
CANDIDATE_SPEAKERS = [
"局长",
"主持人",
"商务专员",
"市场专员",
"控制要素席",
"空中侦察席"
]
```
---
## Redis 数据结构
### 键命名规范
```
meeting:buffer:{session_id} # 会话文本缓冲
meeting:history:{session_id} # 会话历史记录
meeting:speaker_profile:{session_id}:{speaker} # 说话人特征库
```
### 数据类型
- **缓冲区**: String (累积的未完成发言)
- **历史记录**: List (最多 20 条)
- **说话人特征**: List (每个说话人最多 10 条)
---
## 使用示例
### 安装依赖
首先确保已安装 Python 3.8+,然后安装所需依赖:
```bash
# 在项目根目录下执行
pip install -r requirements.txt
```
### 启动服务
```bash
# 在 src 目录下执行
python a2a_wss.py
```
服务将监听 `ws://0.0.0.0:10095`
### 连接和消息格式
**前端发送配置**
```json
{
"mode": "2pass",
"chunk_size": [10, 10, 10],
"wav_format": "pcm"
}
```
**前端发送音频**: 二进制 PCM 数据
**后端返回结果**
```json
{
"mode": "offline-speaker",
"type": "asr_with_speaker",
"segments": [{
"id": 0,
"speaker": "局长",
"embedding": null,
"seek": 0,
"full_text": "今天我们讨论一下市场推广方案"
}],
"speaker": "局长"
}
```
---
## 长期记忆的工作示例
### 会议初期(无记忆)
```
历史记录: [暂无]
说话人特征库: [暂无]
当前发言: "各位好,今天我们召开月度工作总结会议"
识别结果: 主持人 (基于内容特征)
```
### 会议中期(有部分记忆)
```
历史记录:
[1] 主持人: 各位好,今天我们召开月度工作总结会议
[2] 局长: 本月工作总体进展顺利
[3] 商务专员: 我们完成了三个重要合同的签署
[4] 市场专员: 市场推广活动覆盖了五个新城市
说话人特征库:
- 【主持人】(发言1次): 各位好,今天我们召开月度工作总结会议
- 【局长】(发言1次): 本月工作总体进展顺利
- 【商务专员】(发言1次): 我们完成了三个重要合同的签署
- 【市场专员】(发言1次): 市场推广活动覆盖了五个新城市
当前发言: "关于下个月的计划,我们需要重点关注客户反馈"
识别结果: 市场专员
分析:
- 延续了市场专员之前的话题(市场推广)
- 符合市场专员的职责范围(客户反馈)
- 用词风格与历史记录匹配
```
### 会议后期(丰富记忆)
```
历史记录: [15条完整对话记录]
说话人特征库:
- 【局长】(发言4次): 本月工作总体进展顺利; 很好,各部门配合默契;
这次会议很有成效; 最后强调一下安全问题
- 【主持人】(发言3次): 各位好,今天我们召开月度工作总结会议;
下面请商务专员汇报; 时间关系,今天的会议就到这里
- 【商务专员】(发言5次): 我们完成了三个重要合同的签署;
这三个合同总金额达500万; 客户对我们的方案很满意;
下季度重点跟进大客户; 需要技术部门的支持
...
当前发言: "技术部门会全力支持商务工作,确保项目交付质量"
识别结果: (未出现在候选列表中,或需要进一步判断)
分析:
- 提到了"技术部门",可能是技术相关角色
- 对之前商务专员的话题做出回应
- 风式偏向技术人员的特点(强调"质量"、"交付"
```
---
## 性能优化
### 1. 数据限制
- 历史记录:最多保留 20 条
- 每个说话人特征:最多保留 10 条
- LLM 上下文:使用最近 15 条历史记录
### 2. 异步处理
- Agent 处理使用后台任务,不阻塞主循环
- 连接关闭时等待任务完成(最多 5 秒)
### 3. Redis 连接池
- 使用连接池提高并发性能
- 自动编码/解码 JSON 数据
---
## 技术栈
- **Python 3.8+**
- **websockets**: WebSocket 通信
- **redis**: 会话状态和长期记忆存储
- **openai**: 大语言模型调用
- **logging**: 日志记录和系统监控
- **asyncio**: 异步编程
---
## 代码结构
```
a2a_wss.py
├── 基础配置 (1-48行)
├── RedisManager 类 (52-199行)
│ ├── 会话缓冲管理
│ ├── 历史记录管理
│ └── 说话人特征库管理 ⭐新增
├── Agent 基类 (154-208行)
├── AuditorAgent 类 (211-235行)
├── ProfilerAgent 类 (238-369行) ⭐增强
│ ├── 长期记忆提示词
│ ├── 会议历史分析
│ └── 说话人特征匹配
├── A2AOrchestrator 类 (372-343行) ⭐集成
│ └── process_asr_fragment() 方法
└── WebSocket 处理 (352-450行)
```
---
## 注意事项
1. **Redis 依赖**: 确保 Redis 服务正常运行
2. **LLM 可用性**: 确保大语言模型服务可访问
3. **内存管理**: 长时间运行的会议会累积数据,建议定期清理
4. **识别准确率**: 长期记忆随时间累积,会议初期识别准确率可能较低
---
## 未来改进方向
1. **跨会议记忆**: 支持多场会议之间的说话人特征迁移
2. **特征工程**: 提取更多说话人特征(语速、停顿模式等)
3. **置信度评分**: 为每次识别添加置信度分数
4. **自适应学习**: 根据识别准确率动态调整特征权重
5. **多模态融合**: 结合声学特征提高识别准确率
---
## 作者和许可
本项目为智能语音识别系统的核心组件,采用 Agent-to-Agent 编排模式。
最后更新: 2025-01-07

View File

@ -0,0 +1,769 @@
"""
WebSocket 智能语义分析服务
功能说明
1. 作为 WebSocket 代理接收前端音频数据并转发到 ASR 引擎
2. 实时处理 ASR 识别结果应用多 Agent 协作A2A模式
3. Agent 功能
- AuditorAgent: 判断说话人是否完成发言
- ProfilerAgent: 识别当前说话人的身份
4. 使用 Redis 存储会话缓冲和历史记录
5. 将处理后的带说话人身份的结果推送回前端
架构模式Agent-to-Agent (A2A) 编排
"""
import asyncio
import json
import os
import websockets
import re
import logging
from pathlib import Path
from typing import List, Optional, Dict, Set
from dataclasses import dataclass
from dotenv import load_dotenv
try:
import redis.asyncio as redis
except ModuleNotFoundError:
redis = None
try:
from openai import AsyncOpenAI
except ModuleNotFoundError:
AsyncOpenAI = None
# ========================
# 1. 日志配置
# ========================
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
load_dotenv(ENV_PATH)
def get_env(name: str, default: str) -> str:
return os.getenv(name, default).strip()
def get_env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def setup_logging():
"""配置日志系统"""
# 创建日志目录
log_dir = "logs"
os.makedirs(log_dir, exist_ok=True)
# 配置日志格式
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
date_format = "%Y-%m-%d %H:%M:%S"
# 配置根日志记录器
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# 清除现有处理器
logger.handlers.clear()
# 控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_formatter = logging.Formatter(log_format, datefmt=date_format)
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# 文件处理器(所有日志)
file_handler = logging.FileHandler(
f"{log_dir}/a2a_wss.log",
encoding="utf-8"
)
file_handler.setLevel(logging.DEBUG)
file_formatter = logging.Formatter(log_format, datefmt=date_format)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
# 错误日志文件处理器
error_handler = logging.FileHandler(
f"{log_dir}/a2a_wss_error.log",
encoding="utf-8"
)
error_handler.setLevel(logging.ERROR)
error_formatter = logging.Formatter(log_format, datefmt=date_format)
error_handler.setFormatter(error_formatter)
logger.addHandler(error_handler)
return logger
# 初始化日志系统
logger = setup_logging()
# ========================
# 2. 基础配置
# ========================
# 目标 ASR 服务的 WebSocket 地址(负责语音识别)
# 注意:移除末尾的斜杠,避免路径匹配问题
TARGET_WS_URL = get_env("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
# 本地服务监听地址和端口(接收前端连接)
LOCAL_HOST = get_env("LOCAL_HOST", "0.0.0.0")
LOCAL_PORT = int(get_env("LOCAL_PORT", "10095"))
# Redis 连接配置(用于存储会话状态和历史记录)
REDIS_URL = get_env("REDIS_URL", "redis://localhost:6379/0")
# LLM大语言模型配置用于 Agent 推理)
LLM_API_KEY = get_env("LLM_API_KEY", "none")
LLM_BASE_URL = get_env("LLM_BASE_URL", "http://192.168.0.46:59800/v1")
LLM_MODEL = get_env("LLM_MODEL", "46-qwen3.5-35B")
USE_LLM_ROLE_IDENTIFICATION = get_env_bool("USE_LLM_ROLE_IDENTIFICATION", False)
# 候选说话人列表(用于身份识别 Agent
CANDIDATE_SPEAKERS = ["局长", "主持人", "商务专员", "市场专员", "控制要素席", "空中侦察席"]
# ========================
# 3. Redis 管理器
# ========================
class RedisManager:
"""
Redis 会话管理器
职责
- 管理与 Redis 的连接池
- 存储每个会话的文本缓冲累积不完整的识别片段
- 存储历史对话记录用于上下文理解
"""
def __init__(self, url):
"""
初始化 Redis 连接池
Args:
url: Redis 连接 URL
"""
if redis is None:
raise RuntimeError("redis package is not installed")
# 使用连接池提高并发性能decode_responses=True 自动将字节转为字符串
self.pool = redis.ConnectionPool.from_url(url, decode_responses=True)
# 键前缀,用于命名隔离,避免与其他业务冲突
self.prefix = "meeting:"
async def _get_conn(self):
"""从连接池获取一个 Redis 连接"""
return redis.Redis(connection_pool=self.pool)
async def get_buffer(self, sid):
"""
获取指定会话的文本缓冲
Args:
sid: 会话 IDSession ID
Returns:
该会话当前累积的文本片段
"""
r = await self._get_conn()
val = await r.get(f"{self.prefix}buffer:{sid}") or ""
return val
async def append_buffer(self, sid, text):
"""
向会话缓冲追加新的文本片段
Args:
sid: 会话 ID
text: 新识别的文本片段
"""
r = await self._get_conn()
# 追加文本并添加空格分隔
await r.append(f"{self.prefix}buffer:{sid}", text + " ")
async def clear_buffer(self, sid):
"""
清空指定会话的缓冲区
用途当判断发言完成后清空缓冲准备下一次发言
Args:
sid: 会话 ID
"""
r = await self._get_conn()
await r.delete(f"{self.prefix}buffer:{sid}")
async def push_history(self, sid, role, content):
"""
将完成的对话推入历史记录
Args:
sid: 会话 ID
role: 说话人角色
content: 对话内容
"""
r = await self._get_conn()
key = f"{self.prefix}history:{sid}"
# 将对话记录推入列表右端
await r.rpush(key, json.dumps({"role": role, "content": content}))
# 只保留最近 20 条记录,避免历史过长影响性能
await r.ltrim(key, -20, -1)
async def get_history(self, sid):
"""
获取会话的历史对话记录
Args:
sid: 会话 ID
Returns:
历史对话列表每项包含 role content
"""
r = await self._get_conn()
items = await r.lrange(f"{self.prefix}history:{sid}", 0, -1)
return [json.loads(i) for i in items]
async def save_speaker_profile(self, sid, speaker, text, features=None):
"""
保存说话人的特征到长期记忆
Args:
sid: 会话 ID
speaker: 说话人名称
text: 发言文本
features: 可选的特征字典如关键词语速等
"""
r = await self._get_conn()
key = f"{self.prefix}speaker_profile:{sid}:{speaker}"
# 构造特征记录
profile_entry = {
"text": text[:200], # 只保留前200个字符作为示例
"timestamp": asyncio.get_event_loop().time(),
"features": features or {}
}
# 将记录推入该说话人的历史列表
await r.rpush(key, json.dumps(profile_entry))
# 只保留最近10条该说话人的记录
await r.ltrim(key, -10, -1)
async def get_speaker_profiles(self, sid):
"""
获取所有说话人的历史特征记录
Args:
sid: 会话 ID
Returns:
字典key为说话人名称value为该说话人的历史记录列表
"""
r = await self._get_conn()
# 扫描所有说话人的profile键
pattern = f"{self.prefix}speaker_profile:{sid}:*"
profiles = {}
async for key in r.scan_iter(match=pattern):
# 从键名中提取说话人名称
speaker = key.decode() if isinstance(key, bytes) else key
speaker = speaker.split(":")[-1]
# 获取该说话人的所有历史记录
items = await r.lrange(key, 0, -1)
profiles[speaker] = [json.loads(i) for i in items]
return profiles
async def close(self):
"""关闭连接池,释放资源(修复 DeprecationWarning"""
await self.pool.disconnect()
# ========================
# 3. Agent 实现
# ========================
class BaseAgent:
"""
Agent 基类
提供所有 Agent 的通用能力
- 调用 LLM 进行推理
- 提取 LLM 返回的 XML 标签结果
"""
def __init__(self, client: AsyncOpenAI):
"""
初始化 Agent
Args:
client: OpenAI 异步客户端
"""
self.client = client
async def _call_llm(self, system_prompt: str, user_content: str) -> str:
"""
调用大语言模型进行推理
Args:
system_prompt: 系统提示词定义 Agent 的角色和行为
user_content: 用户输入内容
Returns:
LLM 返回的文本结果失败时返回空字符串
"""
try:
response = await self.client.chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content}
],
temperature=0.1, # 低温度使输出更确定、一致
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"LLM调用失败: {e}", exc_info=True)
return ""
def _extract_result(self, text: str) -> str:
"""
LLM 返回结果中提取 <result> 标签内容
Args:
text: LLM 返回的完整文本
Returns:
提取的结果如果没有标签则返回原文
"""
match = re.search(r'<result>(.*?)</result>', text, re.DOTALL | re.IGNORECASE)
return match.group(1).strip() if match else text.strip()
class AuditorAgent(BaseAgent):
"""
审计 Agent判断当前发言是否完成
功能
- 分析累积的文本缓冲
- 判断说话人是否已经完成一次完整的发言
- 判断依据语义完整性停顿暗示等
"""
# 系统提示词:定义 Agent 的角色和输出格式
SYSTEM_PROMPT = "你是一个对话状态监测专家。请判断发言是否结束,放入 <result>true/false</result> 中。"
async def check(self, text: str) -> bool:
"""
判断给定文本是否代表一次完整的发言
Args:
text: 当前累积的文本片段
Returns:
True 表示发言完成False 表示发言未完成继续累积
"""
res = await self._call_llm(self.SYSTEM_PROMPT, f"内容:{text}")
return "true" in self._extract_result(res).lower()
class ProfilerAgent(BaseAgent):
"""
分析 Agent识别说话人身份
功能
- 基于文本内容和说话风格识别说话人
- 利用历史对话上下文提高识别准确率
- 利用长期记忆说话人特征库增强识别
- 从预定义的候选说话人列表中选择
"""
# 系统提示词:定义识别任务和候选说话人列表
SYSTEM_PROMPT = f"你是一个会议身份识别专家。请从 {CANDIDATE_SPEAKERS} 中选择发言人,放入 <result>名称</result> 中。"
# 增强版提示词:结合长期记忆
SYSTEM_PROMPT_WITH_MEMORY = f"""你是一个会议身份识别专家。请根据以下信息识别发言人:
候选说话人列表{CANDIDATE_SPEAKERS}
=== 会议历史记录按时间顺序===
{{history}}
=== 说话人特征库长期记忆===
{{speaker_profiles}}
=== 当前待识别的发言 ===
{{current_text}}
=== 识别指南 ===
请仔细分析并综合判断
1. **对话流程分析**
- 查看历史记录中的对话顺序和上下文
- 判断当前发言是对之前谁的话题的回应延续或补充
- 注意对话的逻辑关系和互动模式
2. **说话风格匹配**
- 每个角色的用词习惯句式结构语气的特点
- 专有名词技术术语的使用习惯
- 提问方式陈述方式指令方式的差异
3. **角色职责判断**
- 根据各角色的职责范围判断发言内容的合理性
- 例如局长通常做总结和决策主持人负责控场专员负责具体业务汇报
4. **话题连贯性**
- 当前发言与该角色之前发言的话题是否连贯
- 是否体现了该角色应有的专业领域关注点
将识别结果放入 <result>说话人名称</result> """
async def identify(self, text: str, history: List[Dict], speaker_profiles: Dict = None) -> str:
"""
识别当前文本的说话人身份
Args:
text: 当前发言文本
history: 历史对话记录用于上下文理解
speaker_profiles: 说话人特征库长期记忆key为说话人名称value为历史记录列表
Returns:
识别出的说话人名称
"""
# 如果有长期记忆,使用增强版提示词
if speaker_profiles:
# 使用更多历史记录最多15条保持完整的会议上下文
h_str = self._format_history(history[-15:])
# 构建说话人特征库摘要
profiles_summary = self._format_profiles(speaker_profiles)
prompt = self.SYSTEM_PROMPT_WITH_MEMORY.format(
history=h_str or "暂无历史记录",
speaker_profiles=profiles_summary,
current_text=text
)
res = await self._call_llm(prompt, "")
return self._extract_result(res)
else:
# 原有的简单识别逻辑
h_str = "\n".join([f"{h['role']}: {h['content']}" for h in history[-5:]])
res = await self._call_llm(self.SYSTEM_PROMPT, f"历史:{h_str}\n当前:{text}")
return self._extract_result(res)
def _format_history(self, history: List[Dict]) -> str:
"""
格式化历史对话记录添加序号和时间顺序
Args:
history: 历史对话列表
Returns:
格式化后的对话文本
"""
if not history:
return "暂无历史记录"
lines = []
for idx, h in enumerate(history, 1):
content_preview = h['content'][:150] # 只显示前150字符
if len(h['content']) > 150:
content_preview += "..."
lines.append(f"[{idx}] {h['role']}: {content_preview}")
return "\n".join(lines)
def _format_profiles(self, speaker_profiles: Dict) -> str:
"""
格式化说话人特征库为可读文本
Args:
speaker_profiles: 说话人特征字典
Returns:
格式化后的文本摘要
"""
if not speaker_profiles:
return "暂无说话人特征记录"
summary_lines = []
for speaker, profiles in speaker_profiles.items():
# 提取该说话人最近5条发言的文本片段
recent_texts = [p['text'] for p in profiles[-5:]]
texts_str = "; ".join(recent_texts)
# 统计该说话人的发言次数
speaking_count = len(profiles)
summary_lines.append(f"- 【{speaker}】(发言{speaking_count}次): {texts_str}")
return "\n".join(summary_lines)
# ========================
# 4. A2A 编排器
# ========================
class A2AOrchestrator:
"""
Agent-to-Agent 编排器
职责
- 协调多个 Agent 的协作流程
- 处理 ASR 识别结果依次调用 Auditor Profiler
- 管理会话状态缓冲和历史记录
- 将最终结果推送给前端
"""
def __init__(self, client: AsyncOpenAI, redis_mgr: RedisManager):
"""
初始化编排器
Args:
client: OpenAI 异步客户端
redis_mgr: Redis 管理器实例
"""
self.redis = redis_mgr
self.auditor = AuditorAgent(client) # 审计 Agent
self.profiler = ProfilerAgent(client) # 分析 Agent
async def process_asr_fragment(self, session_id: str, fragment: str, websocket):
"""
处理 ASR 识别的文本片段核心流程
流程
1. 将新片段追加到缓冲区
2. Auditor 判断发言是否完成
3. 如果完成 Profiler 使用长期记忆识别说话人
4. 将识别结果保存到长期记忆
5. 将结果存入历史清空缓冲
6. 推送结果到前端
Args:
session_id: 会话 ID
fragment: ASR 识别的新文本片段
websocket: 前端 WebSocket 连接用于推送结果
"""
# 1. 追加新片段到缓冲区
await self.redis.append_buffer(session_id, fragment)
buf = await self.redis.get_buffer(session_id)
# 2. 检查发言是否完成
if await self.auditor.check(buf):
logger.info(f"[Auditor] 发言完成检测通过 - 会话ID: {session_id}")
# 3. 获取长期记忆(说话人特征库)
speaker_profiles = await self.redis.get_speaker_profiles(session_id)
logger.debug(f"获取说话人特征库 - 会话ID: {session_id}, 说话人数量: {len(speaker_profiles)}")
# 4. 识别说话人身份(使用长期记忆)
history = await self.redis.get_history(session_id)
speaker = await self.profiler.identify(buf, history, speaker_profiles)
logger.info(f"[Profiler] 识别说话人: {speaker} - 会话ID: {session_id}")
# 5. 保存到长期记忆(更新说话人特征库)
await self.redis.save_speaker_profile(session_id, speaker, buf)
# 6. 保存到历史记录并清空缓冲
await self.redis.push_history(session_id, speaker, buf)
await self.redis.clear_buffer(session_id)
# 7. 构造结果消息
segments = [{
"id": 0,
"speaker": speaker,
"embedding": None, # 占位(预留字段)
"seek": 0,
"full_text": buf.strip()
}]
msg = {
"mode": "offline-speaker",
"type": "asr_with_speaker",
"segments": segments,
"speaker": speaker
}
# 推送结果到前端(失败时静默处理)
try:
await websocket.send(json.dumps(msg, ensure_ascii=False))
except:
pass
def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]:
text = (data.get("text") or "").strip()
speaker = (data.get("spk_name") or "").strip()
if not text or not speaker or speaker.lower() == "unknown":
return None
segment = {
"id": 0,
"speaker": speaker,
"embedding": None,
"seek": 0,
"full_text": text,
"start": None,
"end": None,
}
msg = {
"mode": "offline-speaker",
"type": "asr_with_speaker",
"source": "acoustic",
"speaker": speaker,
"spk_score": data.get("spk_score"),
"text": text,
"wav_name": data.get("wav_name"),
"is_final": data.get("is_final", True),
"segments": [segment],
}
if "timestamp" in data:
msg["timestamp"] = data["timestamp"]
if "sentence_info" in data:
msg["sentence_info"] = data["sentence_info"]
return msg
# ========================
# 5. WebSocket 处理与任务追踪
# ========================
async def forward_audio(websocket):
"""
WebSocket 连接处理函数核心入口
职责
1. 接受前端 WebSocket 连接
2. 建立到 ASR 服务的下游连接
3. 双向转发消息前端 -> ASRASR -> 前端
4. 拦截 ASR 结果并触 Agent 处理
5. 确保所有后台任务完成后才退出防止数据丢失
Args:
websocket: 前端 WebSocket 连接对象
"""
logger.info("客户端已连接")
client = None
redis_mgr = None
orchestrator = None
use_llm_role_identification = USE_LLM_ROLE_IDENTIFICATION
if use_llm_role_identification:
if AsyncOpenAI is None:
logger.error("openai package is not installed; LLM role identification disabled")
use_llm_role_identification = False
elif redis is None:
logger.error("redis package is not installed; LLM role identification disabled")
use_llm_role_identification = False
else:
# 初始化 LLM 客户端、Redis 管理器和编排器
client = AsyncOpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
redis_mgr = RedisManager(REDIS_URL)
orchestrator = A2AOrchestrator(client, redis_mgr)
# 用于追踪后台任务,防止连接关闭时任务被强制销毁
background_tasks: Set[asyncio.Task] = set()
try:
# 连接到目标 ASR 服务
# 添加连接参数以提高鲁棒性
async with websockets.connect(
TARGET_WS_URL,
close_timeout=10, # 关闭超时
ping_timeout=20, # ping 超时
max_queue=1024, # 消息队列大小
subprotocols=["binary"],
) as target_ws:
logger.info(f"已连接到 ASR 服务: {TARGET_WS_URL}")
# 发送 ASR 配置双通道模式PCM 音频格式)
config = {"mode": "2pass", "chunk_size": [10, 10, 10], "wav_format": "pcm"}
await target_ws.send(json.dumps(config))
logger.debug(f"发送 ASR 配置: {config}")
async def frontend_to_target():
"""
上行转发前端音频数据 -> ASR 服务
"""
async for message in websocket:
await target_ws.send(message)
async def target_to_frontend():
"""
下行转发ASR 识别结果 -> 前端
同时拦截结果触发 Agent 处理
"""
async for message in target_ws:
# 1. 透传 ASR 原始结果到前端(字幕显示)
await websocket.send(message)
# 2. 如果是文本消息,尝试触发 Agent 处理
if isinstance(message, str):
try:
data = json.loads(message)
# 只处理最终识别结果2pass-offline 模式)
if data.get("mode") == "2pass-offline":
text = data.get("text", "").strip()
speaker_msg = build_acoustic_speaker_message(data)
if speaker_msg is not None:
await websocket.send(json.dumps(speaker_msg, ensure_ascii=False))
if text and use_llm_role_identification and orchestrator is not None:
logger.debug(f"收到 ASR 识别结果: {text[:50]}...")
# 创建后台任务处理 Agent 逻辑(不阻塞主循环)
t = asyncio.create_task(
orchestrator.process_asr_fragment("room_101", text, websocket)
)
background_tasks.add(t)
# 任务完成后自动从追踪集合中移除
t.add_done_callback(background_tasks.discard)
except Exception as e:
logger.error(f"处理 ASR 结果时出错: {e}", exc_info=True)
# 并发运行两个方向的数据流
await asyncio.gather(frontend_to_target(), target_to_frontend())
except websockets.exceptions.InvalidMessage as e:
logger.error(f"WebSocket 握手失败 - ASR 服务可能未正常运行或 URL 配置错误: {e}")
logger.error(f"请检查: 1) ASR 服务是否在 {TARGET_WS_URL} 正常运行")
logger.error(f" 2) ASR 服务的 URL 路径是否正确")
logger.error(f" 3) ASR 服务是否需要特定的认证或配置")
except ConnectionRefusedError:
logger.error(f"连接被拒绝 - 无法连接到 ASR 服务 {TARGET_WS_URL}")
logger.error(f"请检查 ASR 服务是否已启动")
except Exception as e:
logger.error(f"连接错误: {type(e).__name__}: {e}", exc_info=True)
finally:
# --- 优雅退出处理 ---
if background_tasks:
logger.info(f"等待 {len(background_tasks)} 个后台 Agent 任务完成...")
# 等待所有剩余任务跑完,最多等 5 秒(防止无限等待)
await asyncio.wait(background_tasks, timeout=5)
logger.debug("所有后台任务已完成或超时")
# 释放资源
if client is not None:
await client.close()
if redis_mgr is not None:
await redis_mgr.close()
logger.info("客户端已断开连接")
async def main():
"""
主函数启动 WebSocket 服务器
"""
logger.info(f"启动 WebSocket 智能语义分析服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
logger.info(f"Agent 模型: {LLM_MODEL}")
logger.info(f"ASR 服务地址: {TARGET_WS_URL}")
logger.info(f"Redis 地址: {REDIS_URL}")
# 启动服务器并持续运行asyncio.Future() 永不完成)
async with websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT):
await asyncio.Future()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

958
fastapi_wss/src/a2a_wss.py Normal file
View File

@ -0,0 +1,958 @@
"""
WebSocket 智能语义分析服务
功能说明
1. 作为 WebSocket 代理接收前端音频数据并转发到 ASR 引擎
2. 实时处理 ASR 识别结果应用多 Agent 协作A2A模式
3. Agent 功能
- AuditorAgent: 判断说话人是否完成发言
- ProfilerAgent: 识别当前说话人的身份
4. 使用 Redis 存储会话缓冲和历史记录
5. 将处理后的带说话人身份的结果推送回前端
架构模式Agent-to-Agent (A2A) 编排
"""
import asyncio
import json
import os
import websockets
import re
import logging
from pathlib import Path
from typing import List, Optional, Dict, Set
from dataclasses import dataclass
from dotenv import load_dotenv
try:
import redis.asyncio as redis
except ModuleNotFoundError:
redis = None
try:
from openai import AsyncOpenAI
except ModuleNotFoundError:
AsyncOpenAI = None
# ========================
# 1. 日志配置
# ========================
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
load_dotenv(ENV_PATH)
def get_env(name: str, default: str) -> str:
return os.getenv(name, default).strip()
def get_env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def get_env_list(name: str, default: str) -> List[str]:
raw = get_env(name, default)
return [item.strip() for item in raw.split(",") if item.strip()]
def setup_logging():
"""配置日志系统"""
# 创建日志目录
log_dir = "logs"
os.makedirs(log_dir, exist_ok=True)
# 配置日志格式
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
date_format = "%Y-%m-%d %H:%M:%S"
# 配置根日志记录器
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# 清除现有处理器
logger.handlers.clear()
# 控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_formatter = logging.Formatter(log_format, datefmt=date_format)
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# 文件处理器(所有日志)
file_handler = logging.FileHandler(
f"{log_dir}/a2a_wss.log",
encoding="utf-8"
)
file_handler.setLevel(logging.DEBUG)
file_formatter = logging.Formatter(log_format, datefmt=date_format)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
# 错误日志文件处理器
error_handler = logging.FileHandler(
f"{log_dir}/a2a_wss_error.log",
encoding="utf-8"
)
error_handler.setLevel(logging.ERROR)
error_formatter = logging.Formatter(log_format, datefmt=date_format)
error_handler.setFormatter(error_formatter)
logger.addHandler(error_handler)
return logger
# 初始化日志系统
logger = setup_logging()
# ========================
# 2. 基础配置
# ========================
# 目标 ASR 服务的 WebSocket 地址(负责语音识别)
# 注意:移除末尾的斜杠,避免路径匹配问题
TARGET_WS_URL = get_env("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
# 本地服务监听地址和端口(接收前端连接)
LOCAL_HOST = get_env("LOCAL_HOST", "0.0.0.0")
LOCAL_PORT = int(get_env("LOCAL_PORT", "10095"))
# Redis 连接配置(用于存储会话状态和历史记录)
REDIS_URL = get_env("REDIS_URL", "redis://localhost:6379/0")
# LLM大语言模型配置用于 Agent 推理)
LLM_API_KEY = get_env("LLM_API_KEY", "none")
LLM_BASE_URL = get_env("LLM_BASE_URL", "http://192.168.0.46:59800/v1")
LLM_MODEL = get_env("LLM_MODEL", "46-qwen3.5-35B")
USE_LLM_ROLE_IDENTIFICATION = get_env_bool("USE_LLM_ROLE_IDENTIFICATION", False)
LLM_ROLE_CANDIDATES = get_env_list(
"LLM_ROLE_CANDIDATES",
"主持人,正方辩手,反方辩手,计时员,评委,嘉宾,未知",
)
LLM_ROLE_MIN_TEXT_LEN = int(get_env("LLM_ROLE_MIN_TEXT_LEN", "8"))
LLM_ROLE_CACHE_CONFIDENCE = float(get_env("LLM_ROLE_CACHE_CONFIDENCE", "0.82"))
LLM_ROLE_HISTORY_MAX_CHARS = int(get_env("LLM_ROLE_HISTORY_MAX_CHARS", "1200"))
# 候选说话人列表(用于身份识别 Agent
CANDIDATE_SPEAKERS = ["局长", "主持人", "商务专员", "市场专员", "控制要素席", "空中侦察席"]
# ========================
# 3. Redis 管理器
# ========================
class RedisManager:
"""
Redis 会话管理器
职责
- 管理与 Redis 的连接池
- 存储每个会话的文本缓冲累积不完整的识别片段
- 存储历史对话记录用于上下文理解
"""
def __init__(self, url):
"""
初始化 Redis 连接池
Args:
url: Redis 连接 URL
"""
if redis is None:
raise RuntimeError("redis package is not installed")
# 使用连接池提高并发性能decode_responses=True 自动将字节转为字符串
self.pool = redis.ConnectionPool.from_url(url, decode_responses=True)
# 键前缀,用于命名隔离,避免与其他业务冲突
self.prefix = "meeting:"
async def _get_conn(self):
"""从连接池获取一个 Redis 连接"""
return redis.Redis(connection_pool=self.pool)
async def get_buffer(self, sid):
"""
获取指定会话的文本缓冲
Args:
sid: 会话 IDSession ID
Returns:
该会话当前累积的文本片段
"""
r = await self._get_conn()
val = await r.get(f"{self.prefix}buffer:{sid}") or ""
return val
async def append_buffer(self, sid, text):
"""
向会话缓冲追加新的文本片段
Args:
sid: 会话 ID
text: 新识别的文本片段
"""
r = await self._get_conn()
# 追加文本并添加空格分隔
await r.append(f"{self.prefix}buffer:{sid}", text + " ")
async def clear_buffer(self, sid):
"""
清空指定会话的缓冲区
用途当判断发言完成后清空缓冲准备下一次发言
Args:
sid: 会话 ID
"""
r = await self._get_conn()
await r.delete(f"{self.prefix}buffer:{sid}")
async def push_history(self, sid, role, content):
"""
将完成的对话推入历史记录
Args:
sid: 会话 ID
role: 说话人角色
content: 对话内容
"""
r = await self._get_conn()
key = f"{self.prefix}history:{sid}"
# 将对话记录推入列表右端
await r.rpush(key, json.dumps({"role": role, "content": content}))
# 只保留最近 20 条记录,避免历史过长影响性能
await r.ltrim(key, -20, -1)
async def get_history(self, sid):
"""
获取会话的历史对话记录
Args:
sid: 会话 ID
Returns:
历史对话列表每项包含 role content
"""
r = await self._get_conn()
items = await r.lrange(f"{self.prefix}history:{sid}", 0, -1)
return [json.loads(i) for i in items]
async def save_speaker_profile(self, sid, speaker, text, features=None):
"""
保存说话人的特征到长期记忆
Args:
sid: 会话 ID
speaker: 说话人名称
text: 发言文本
features: 可选的特征字典如关键词语速等
"""
r = await self._get_conn()
key = f"{self.prefix}speaker_profile:{sid}:{speaker}"
# 构造特征记录
profile_entry = {
"text": text[:200], # 只保留前200个字符作为示例
"timestamp": asyncio.get_event_loop().time(),
"features": features or {}
}
# 将记录推入该说话人的历史列表
await r.rpush(key, json.dumps(profile_entry))
# 只保留最近10条该说话人的记录
await r.ltrim(key, -10, -1)
async def get_speaker_profiles(self, sid):
"""
获取所有说话人的历史特征记录
Args:
sid: 会话 ID
Returns:
字典key为说话人名称value为该说话人的历史记录列表
"""
r = await self._get_conn()
# 扫描所有说话人的profile键
pattern = f"{self.prefix}speaker_profile:{sid}:*"
profiles = {}
async for key in r.scan_iter(match=pattern):
# 从键名中提取说话人名称
speaker = key.decode() if isinstance(key, bytes) else key
speaker = speaker.split(":")[-1]
# 获取该说话人的所有历史记录
items = await r.lrange(key, 0, -1)
profiles[speaker] = [json.loads(i) for i in items]
return profiles
async def close(self):
"""关闭连接池,释放资源(修复 DeprecationWarning"""
await self.pool.disconnect()
# ========================
# 3. Agent 实现
# ========================
class BaseAgent:
"""
Agent 基类
提供所有 Agent 的通用能力
- 调用 LLM 进行推理
- 提取 LLM 返回的 XML 标签结果
"""
def __init__(self, client: AsyncOpenAI):
"""
初始化 Agent
Args:
client: OpenAI 异步客户端
"""
self.client = client
async def _call_llm(self, system_prompt: str, user_content: str) -> str:
"""
调用大语言模型进行推理
Args:
system_prompt: 系统提示词定义 Agent 的角色和行为
user_content: 用户输入内容
Returns:
LLM 返回的文本结果失败时返回空字符串
"""
try:
response = await self.client.chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content}
],
temperature=0.1, # 低温度使输出更确定、一致
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"LLM调用失败: {e}", exc_info=True)
return ""
def _extract_result(self, text: str) -> str:
"""
LLM 返回结果中提取 <result> 标签内容
Args:
text: LLM 返回的完整文本
Returns:
提取的结果如果没有标签则返回原文
"""
match = re.search(r'<result>(.*?)</result>', text, re.DOTALL | re.IGNORECASE)
return match.group(1).strip() if match else text.strip()
class AuditorAgent(BaseAgent):
"""
审计 Agent判断当前发言是否完成
功能
- 分析累积的文本缓冲
- 判断说话人是否已经完成一次完整的发言
- 判断依据语义完整性停顿暗示等
"""
# 系统提示词:定义 Agent 的角色和输出格式
SYSTEM_PROMPT = "你是一个对话状态监测专家。请判断发言是否结束,放入 <result>true/false</result> 中。"
async def check(self, text: str) -> bool:
"""
判断给定文本是否代表一次完整的发言
Args:
text: 当前累积的文本片段
Returns:
True 表示发言完成False 表示发言未完成继续累积
"""
res = await self._call_llm(self.SYSTEM_PROMPT, f"内容:{text}")
return "true" in self._extract_result(res).lower()
class ProfilerAgent(BaseAgent):
"""
分析 Agent识别说话人身份
功能
- 基于文本内容和说话风格识别说话人
- 利用历史对话上下文提高识别准确率
- 利用长期记忆说话人特征库增强识别
- 从预定义的候选说话人列表中选择
"""
# 系统提示词:定义识别任务和候选说话人列表
SYSTEM_PROMPT = f"你是一个会议身份识别专家。请从 {CANDIDATE_SPEAKERS} 中选择发言人,放入 <result>名称</result> 中。"
# 增强版提示词:结合长期记忆
SYSTEM_PROMPT_WITH_MEMORY = f"""你是一个会议身份识别专家。请根据以下信息识别发言人:
候选说话人列表{CANDIDATE_SPEAKERS}
=== 会议历史记录按时间顺序===
{{history}}
=== 说话人特征库长期记忆===
{{speaker_profiles}}
=== 当前待识别的发言 ===
{{current_text}}
=== 识别指南 ===
请仔细分析并综合判断
1. **对话流程分析**
- 查看历史记录中的对话顺序和上下文
- 判断当前发言是对之前谁的话题的回应延续或补充
- 注意对话的逻辑关系和互动模式
2. **说话风格匹配**
- 每个角色的用词习惯句式结构语气的特点
- 专有名词技术术语的使用习惯
- 提问方式陈述方式指令方式的差异
3. **角色职责判断**
- 根据各角色的职责范围判断发言内容的合理性
- 例如局长通常做总结和决策主持人负责控场专员负责具体业务汇报
4. **话题连贯性**
- 当前发言与该角色之前发言的话题是否连贯
- 是否体现了该角色应有的专业领域关注点
将识别结果放入 <result>说话人名称</result> """
async def identify(self, text: str, history: List[Dict], speaker_profiles: Dict = None) -> str:
"""
识别当前文本的说话人身份
Args:
text: 当前发言文本
history: 历史对话记录用于上下文理解
speaker_profiles: 说话人特征库长期记忆key为说话人名称value为历史记录列表
Returns:
识别出的说话人名称
"""
# 如果有长期记忆,使用增强版提示词
if speaker_profiles:
# 使用更多历史记录最多15条保持完整的会议上下文
h_str = self._format_history(history[-15:])
# 构建说话人特征库摘要
profiles_summary = self._format_profiles(speaker_profiles)
prompt = self.SYSTEM_PROMPT_WITH_MEMORY.format(
history=h_str or "暂无历史记录",
speaker_profiles=profiles_summary,
current_text=text
)
res = await self._call_llm(prompt, "")
return self._extract_result(res)
else:
# 原有的简单识别逻辑
h_str = "\n".join([f"{h['role']}: {h['content']}" for h in history[-5:]])
res = await self._call_llm(self.SYSTEM_PROMPT, f"历史:{h_str}\n当前:{text}")
return self._extract_result(res)
def _format_history(self, history: List[Dict]) -> str:
"""
格式化历史对话记录添加序号和时间顺序
Args:
history: 历史对话列表
Returns:
格式化后的对话文本
"""
if not history:
return "暂无历史记录"
lines = []
for idx, h in enumerate(history, 1):
content_preview = h['content'][:150] # 只显示前150字符
if len(h['content']) > 150:
content_preview += "..."
lines.append(f"[{idx}] {h['role']}: {content_preview}")
return "\n".join(lines)
def _format_profiles(self, speaker_profiles: Dict) -> str:
"""
格式化说话人特征库为可读文本
Args:
speaker_profiles: 说话人特征字典
Returns:
格式化后的文本摘要
"""
if not speaker_profiles:
return "暂无说话人特征记录"
summary_lines = []
for speaker, profiles in speaker_profiles.items():
# 提取该说话人最近5条发言的文本片段
recent_texts = [p['text'] for p in profiles[-5:]]
texts_str = "; ".join(recent_texts)
# 统计该说话人的发言次数
speaking_count = len(profiles)
summary_lines.append(f"- 【{speaker}】(发言{speaking_count}次): {texts_str}")
return "\n".join(summary_lines)
# ========================
# 4. A2A 编排器
# ========================
class A2AOrchestrator:
"""
Agent-to-Agent 编排器
职责
- 协调多个 Agent 的协作流程
- 处理 ASR 识别结果依次调用 Auditor Profiler
- 管理会话状态缓冲和历史记录
- 将最终结果推送给前端
"""
def __init__(self, client: AsyncOpenAI, redis_mgr: RedisManager):
"""
初始化编排器
Args:
client: OpenAI 异步客户端
redis_mgr: Redis 管理器实例
"""
self.redis = redis_mgr
self.auditor = AuditorAgent(client) # 审计 Agent
self.profiler = ProfilerAgent(client) # 分析 Agent
async def process_asr_fragment(self, session_id: str, fragment: str, websocket):
"""
处理 ASR 识别的文本片段核心流程
流程
1. 将新片段追加到缓冲区
2. Auditor 判断发言是否完成
3. 如果完成 Profiler 使用长期记忆识别说话人
4. 将识别结果保存到长期记忆
5. 将结果存入历史清空缓冲
6. 推送结果到前端
Args:
session_id: 会话 ID
fragment: ASR 识别的新文本片段
websocket: 前端 WebSocket 连接用于推送结果
"""
# 1. 追加新片段到缓冲区
await self.redis.append_buffer(session_id, fragment)
buf = await self.redis.get_buffer(session_id)
# 2. 检查发言是否完成
if await self.auditor.check(buf):
logger.info(f"[Auditor] 发言完成检测通过 - 会话ID: {session_id}")
# 3. 获取长期记忆(说话人特征库)
speaker_profiles = await self.redis.get_speaker_profiles(session_id)
logger.debug(f"获取说话人特征库 - 会话ID: {session_id}, 说话人数量: {len(speaker_profiles)}")
# 4. 识别说话人身份(使用长期记忆)
history = await self.redis.get_history(session_id)
speaker = await self.profiler.identify(buf, history, speaker_profiles)
logger.info(f"[Profiler] 识别说话人: {speaker} - 会话ID: {session_id}")
# 5. 保存到长期记忆(更新说话人特征库)
await self.redis.save_speaker_profile(session_id, speaker, buf)
# 6. 保存到历史记录并清空缓冲
await self.redis.push_history(session_id, speaker, buf)
await self.redis.clear_buffer(session_id)
# 7. 构造结果消息
segments = [{
"id": 0,
"speaker": speaker,
"embedding": None, # 占位(预留字段)
"seek": 0,
"full_text": buf.strip()
}]
msg = {
"mode": "offline-speaker",
"type": "asr_with_speaker",
"segments": segments,
"speaker": speaker
}
# 推送结果到前端(失败时静默处理)
try:
await websocket.send(json.dumps(msg, ensure_ascii=False))
except:
pass
def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]:
text = (data.get("text") or "").strip()
speaker = (data.get("spk_name") or "").strip()
if not text or not speaker or speaker.lower() == "unknown":
return None
segment = {
"id": 0,
"speaker": speaker,
"embedding": None,
"seek": 0,
"full_text": text,
"start": None,
"end": None,
}
msg = {
"mode": "offline-speaker",
"type": "asr_with_speaker",
"source": "acoustic",
"speaker": speaker,
"spk_score": data.get("spk_score"),
"text": text,
"wav_name": data.get("wav_name"),
"is_final": data.get("is_final", True),
"segments": [segment],
}
if "timestamp" in data:
msg["timestamp"] = data["timestamp"]
if "sentence_info" in data:
msg["sentence_info"] = data["sentence_info"]
return msg
MOJIBAKE_MARKERS = ("", "", "", "", "", "", "", "", "", "", "")
def maybe_repair_mojibake(text: str) -> str:
if not text:
return text
marker_count = sum(text.count(marker) for marker in MOJIBAKE_MARKERS)
if marker_count < 3:
return text
try:
repaired = text.encode("gbk").decode("utf-8")
except UnicodeError:
return text
repaired_marker_count = sum(repaired.count(marker) for marker in MOJIBAKE_MARKERS)
return repaired if repaired_marker_count < marker_count else text
def _extract_json_object(text: str) -> Dict:
if not text:
return {}
try:
return json.loads(text)
except json.JSONDecodeError:
pass
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
return {}
try:
return json.loads(text[start:end + 1])
except json.JSONDecodeError:
return {}
def _safe_confidence(value, default: float = 0.0) -> float:
try:
confidence = float(value)
except (TypeError, ValueError):
confidence = default
return max(0.0, min(1.0, confidence))
class RoleIdentifier:
def __init__(self, client):
self.client = client
self.profiles: Dict[str, Dict] = {}
def _get_profile(self, speaker: str) -> Dict:
return self.profiles.setdefault(
speaker,
{"texts": [], "role": None, "confidence": 0.0, "reason": ""},
)
def _append_text(self, profile: Dict, text: str):
if not text:
return
profile["texts"].append(text)
joined = "\n".join(profile["texts"])
while len(joined) > LLM_ROLE_HISTORY_MAX_CHARS and len(profile["texts"]) > 1:
profile["texts"].pop(0)
joined = "\n".join(profile["texts"])
async def identify(self, speaker: str, text: str) -> Optional[Dict]:
clean_text = maybe_repair_mojibake(text.strip())
profile = self._get_profile(speaker)
self._append_text(profile, clean_text)
cached_role = profile.get("role")
cached_confidence = _safe_confidence(profile.get("confidence"))
if cached_role and cached_confidence >= LLM_ROLE_CACHE_CONFIDENCE:
return {
"role": cached_role,
"role_confidence": cached_confidence,
"role_reason": profile.get("reason", ""),
"role_source": "llm_text_cached",
"llm_text": clean_text,
}
if len(clean_text) < LLM_ROLE_MIN_TEXT_LEN:
return None
system_prompt = (
"你是会议/辩论转写的角色识别器。"
"只能根据文本内容、说话顺序和同一声纹说话人的历史文本判断角色。"
"不要修改 speaker 编号speaker_1、speaker_2 是声纹身份,不是角色。"
"候选角色只能从给定列表选择。"
"只返回 JSON不要输出解释文本。"
'JSON 格式:{"role":"候选角色之一","confidence":0.0,"reason":"一句话原因"}'
)
user_payload = {
"acoustic_speaker": speaker,
"candidate_roles": LLM_ROLE_CANDIDATES,
"speaker_history": profile["texts"][-6:],
"current_text": clean_text,
}
try:
response = await self.client.chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)},
],
temperature=0.0,
)
content = response.choices[0].message.content or ""
parsed = _extract_json_object(content)
role = str(parsed.get("role") or "").strip()
if role not in LLM_ROLE_CANDIDATES:
role = "未知" if "未知" in LLM_ROLE_CANDIDATES else LLM_ROLE_CANDIDATES[-1]
confidence = _safe_confidence(parsed.get("confidence"))
reason = str(parsed.get("reason") or "").strip()[:160]
profile["role"] = role
profile["confidence"] = confidence
profile["reason"] = reason
return {
"role": role,
"role_confidence": confidence,
"role_reason": reason,
"role_source": "llm_text",
"llm_text": clean_text,
}
except Exception as e:
logger.error(f"LLM角色判断失败: {e}", exc_info=True)
return None
def build_role_enriched_message(speaker_msg: Dict, role_result: Dict) -> Dict:
msg = dict(speaker_msg)
msg["type"] = "asr_with_speaker_role"
msg["source"] = "acoustic+llm"
msg["role"] = role_result.get("role")
msg["role_confidence"] = role_result.get("role_confidence")
msg["role_reason"] = role_result.get("role_reason")
msg["role_source"] = role_result.get("role_source")
segments = []
for segment in speaker_msg.get("segments", []):
seg = dict(segment)
seg["role"] = msg["role"]
seg["role_confidence"] = msg["role_confidence"]
seg["role_reason"] = msg["role_reason"]
segments.append(seg)
msg["segments"] = segments
return msg
# ========================
# 5. WebSocket 处理与任务追踪
# ========================
async def forward_audio(websocket):
"""
WebSocket 连接处理函数核心入口
职责
1. 接受前端 WebSocket 连接
2. 建立到 ASR 服务的下游连接
3. 双向转发消息前端 -> ASRASR -> 前端
4. 拦截 ASR 结果并触 Agent 处理
5. 确保所有后台任务完成后才退出防止数据丢失
Args:
websocket: 前端 WebSocket 连接对象
"""
logger.info("客户端已连接")
client = None
redis_mgr = None
role_identifier = None
use_llm_role_identification = USE_LLM_ROLE_IDENTIFICATION
if use_llm_role_identification:
if AsyncOpenAI is None:
logger.error("openai package is not installed; LLM role identification disabled")
use_llm_role_identification = False
else:
client = AsyncOpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
role_identifier = RoleIdentifier(client)
# 用于追踪后台任务,防止连接关闭时任务被强制销毁
background_tasks: Set[asyncio.Task] = set()
try:
# 连接到目标 ASR 服务
# 添加连接参数以提高鲁棒性
async with websockets.connect(
TARGET_WS_URL,
close_timeout=10, # 关闭超时
ping_interval=None,
ping_timeout=None,
max_queue=1024, # 消息队列大小
subprotocols=["binary"],
) as target_ws:
logger.info(f"已连接到 ASR 服务: {TARGET_WS_URL}")
# 发送 ASR 配置双通道模式PCM 音频格式)
config = {"mode": "2pass", "chunk_size": [10, 10, 10], "wav_format": "pcm"}
await target_ws.send(json.dumps(config))
logger.debug(f"发送 ASR 配置: {config}")
async def frontend_to_target():
"""
上行转发前端音频数据 -> ASR 服务
"""
try:
async for message in websocket:
await target_ws.send(message)
except websockets.exceptions.ConnectionClosed:
logger.info("客户端连接已关闭,停止上行转发")
finally:
try:
await target_ws.close()
except Exception:
pass
async def send_role_update(speaker_msg: Dict):
if role_identifier is None:
return
try:
role_result = await role_identifier.identify(
speaker_msg.get("speaker", ""),
speaker_msg.get("text", ""),
)
if not role_result:
return
role_msg = build_role_enriched_message(speaker_msg, role_result)
await websocket.send(json.dumps(role_msg, ensure_ascii=False))
except Exception as e:
logger.error(f"发送LLM角色结果失败: {e}", exc_info=True)
async def target_to_frontend():
"""
下行转发ASR 识别结果 -> 前端
同时拦截结果触发 Agent 处理
"""
try:
async for message in target_ws:
# 1. 透传 ASR 原始结果到前端(字幕显示)
try:
await websocket.send(message)
except websockets.exceptions.ConnectionClosed:
logger.info("客户端连接已关闭,停止下行转发")
break
# 2. 如果是文本消息,尝试触发 Agent 处理
if isinstance(message, str):
try:
data = json.loads(message)
# 只处理最终识别结果2pass-offline 模式)
if data.get("mode") == "2pass-offline":
speaker_msg = build_acoustic_speaker_message(data)
if speaker_msg is not None:
await websocket.send(json.dumps(speaker_msg, ensure_ascii=False))
if use_llm_role_identification and role_identifier is not None:
t = asyncio.create_task(send_role_update(speaker_msg))
background_tasks.add(t)
t.add_done_callback(background_tasks.discard)
except websockets.exceptions.ConnectionClosed:
logger.info("客户端连接已关闭,停止发送增强结果")
break
except Exception as e:
logger.error(f"处理 ASR 结果时出错: {e}", exc_info=True)
except websockets.exceptions.ConnectionClosed as e:
logger.info(f"ASR 服务连接已关闭: {type(e).__name__}: {e}")
finally:
try:
await websocket.close()
except Exception:
pass
# 并发运行两个方向的数据流
await asyncio.gather(frontend_to_target(), target_to_frontend())
except websockets.exceptions.InvalidMessage as e:
logger.error(f"WebSocket 握手失败 - ASR 服务可能未正常运行或 URL 配置错误: {e}")
logger.error(f"请检查: 1) ASR 服务是否在 {TARGET_WS_URL} 正常运行")
logger.error(f" 2) ASR 服务的 URL 路径是否正确")
logger.error(f" 3) ASR 服务是否需要特定的认证或配置")
except ConnectionRefusedError:
logger.error(f"连接被拒绝 - 无法连接到 ASR 服务 {TARGET_WS_URL}")
logger.error(f"请检查 ASR 服务是否已启动")
except Exception as e:
logger.error(f"连接错误: {type(e).__name__}: {e}", exc_info=True)
finally:
# --- 优雅退出处理 ---
if background_tasks:
logger.info(f"等待 {len(background_tasks)} 个后台 Agent 任务完成...")
# 等待所有剩余任务跑完,最多等 5 秒(防止无限等待)
await asyncio.wait(background_tasks, timeout=5)
logger.debug("所有后台任务已完成或超时")
# 释放资源
if client is not None:
await client.close()
if redis_mgr is not None:
await redis_mgr.close()
logger.info("客户端已断开连接")
async def main():
"""
主函数启动 WebSocket 服务器
"""
logger.info(f"启动 WebSocket 智能语义分析服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
logger.info(f"Agent 模型: {LLM_MODEL}")
logger.info(f"ASR 服务地址: {TARGET_WS_URL}")
logger.info(f"Redis 地址: {REDIS_URL}")
# 启动服务器并持续运行asyncio.Future() 永不完成)
async with websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT, ping_interval=None):
await asyncio.Future()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

View File

@ -0,0 +1,271 @@
import asyncio
import json
import os
import websockets
import redis.asyncio as redis
import re
from openai import AsyncOpenAI # ⬅️ 引入 OpenAI 异步客户端
# ========================
# 基础配置
# ========================
TARGET_WS_URL = os.getenv("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
LOCAL_HOST = "0.0.0.0"
LOCAL_PORT = 10095
# Redis
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
REDIS_KEY_PREFIX = "meeting:"
# LLM 配置 (适配 OpenAI SDK)
# 如果是 OpenAI 官方Base URL 默认即可;如果是 DeepSeek/阿里等,需修改 BASE_URL
LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-xxxx")
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "http://172.18.127.124:9997/v1")
LLM_MODEL = os.getenv("LLM_MODEL", "Qwen3-32B")
# 候选人
CANDIDATE_SPEAKERS = ["局长", "主持人", "商务专员", "市场专员"]
DEFAULT_SESSION_ID = "room_101"
# ASR 初始化配置
config = {
"chunk_size": [10, 10, 10],
"wav_name": "h5",
"is_speaking": True,
"wav_format": "pcm",
"chunk_interval": 10,
"itn": True,
"mode": "2pass",
"hotwords": "",
}
# ========================
# Redis 操作封装 (保持不变)
# ========================
async def append_to_buffer(session_id, text):
"""将新的片段追加到暂存区"""
r = redis.from_url(REDIS_URL, decode_responses=True)
key = f"{REDIS_KEY_PREFIX}buffer:{session_id}"
await r.append(key, text + " ")
current_buffer = await r.get(key)
await r.close()
return current_buffer
async def clear_buffer(session_id):
"""清空暂存区"""
r = redis.from_url(REDIS_URL, decode_responses=True)
key = f"{REDIS_KEY_PREFIX}buffer:{session_id}"
await r.delete(key)
await r.close()
async def save_history(session_id, role, content):
"""归档历史记录"""
r = redis.from_url(REDIS_URL, decode_responses=True)
key = f"{REDIS_KEY_PREFIX}history:{session_id}"
record = json.dumps({"role": role, "content": content})
await r.rpush(key, record)
await r.ltrim(key, -20, -1)
await r.close()
async def get_history(session_id):
"""获取上下文"""
r = redis.from_url(REDIS_URL, decode_responses=True)
key = f"{REDIS_KEY_PREFIX}history:{session_id}"
items = await r.lrange(key, 0, -1)
await r.close()
return [json.loads(i) for i in items]
# ========================
# 通用 LLM 调用函数 (改为 OpenAI SDK)
# ========================
async def call_llm(client: AsyncOpenAI, system_prompt: str, user_text: str):
"""
使用 OpenAI SDK 发起调用
"""
try:
response = await client.chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text}
],
temperature=0.1,
)
content = response.choices[0].message.content
print(f"🤖 LLM 返回: {content}")
return content
except Exception as e:
print(f"❌ OpenAI API 调用异常: {e}")
return None
# ========================
# Agent A: 完结判断 (The Auditor)
# ========================
async def agent_check_completion(text_buffer, client: AsyncOpenAI):
"""
判断当前的文本累积是否已经构成了一个完整的发言回合
"""
system_prompt = """
#ROLE
你是一个对话状态判断助手你的任务是判断当前的发言内容是否已经结束
#RULE
1. **强制标准**如果内容中包含 "发言完毕""讲完了""汇报完毕" 等明确结束语必须返回 true
2. **语义标准**如果内容完整且看似一段独立的话结束返回 true如果看似还在列举或句子未完返回 false
3. 如果内容仅仅是很短的一句 """好的"视作未结束或无需独立归档
4. 请将结果用 <result> </result>包裹着
#EXAMPLES
示例1:空中侦察席报告今日预计飞行287架航班空气湿度适宜安全风险较小 <result>true</result>
示例2:控制要素席报告我这边已经完成了所有的准备工作.<result>false<result>
示例3:下面有请局长发言 <result>false</result>
示例4:地面侦察席报告目前没有发现敌对情况.<result>false</result>
"""
result = await call_llm(client, system_prompt, f"当前累积内容:{text_buffer}")
if result:
# 更健壮的版本(容忍某些格式错误)
match = re.search(r'<result>([^<]*)(?:</result>|<result>|$)', result, re.IGNORECASE)
if match:
content = match.group(1).strip()
else:
content = False # 或者根据你的逻辑处理未匹配的情况
return content
return False
# ========================
# Agent B: 身份识别 (The Profiler)
# ========================
async def agent_identify_speaker(text_full, session_id, client: AsyncOpenAI):
"""
仅在 Agent A 判定结束时调用根据全文和历史判断身份
"""
history = await get_history(session_id)
history_str = "\n".join([f"{h['role']}: {h['content']}" for h in history])
system_prompt = f"""
#ROLE
你是一个会议记录专家请根据上下文和完整的段落内容推断当前的发言人
#RULE
1. **强制标准**如果内容中包含 "控制要素席""空中侦察席""海上观察席""联合指挥中心"等明确发言人必须直接返回名称
2. **语义标准**如果内容完整且没有明确发言人则根据语义进行判断潜在的候选人有:{', '.join(CANDIDATE_SPEAKERS)}
3. 提供历史上下文{history_str}
4. 请将结果用 <result> </result>包裹着
#EXAMPLES
示例1:空中侦察席报告今日预计飞行287架航班空气湿度适宜安全风险较小 <result>空中侦察席</result>
示例2:控制要素席报告我这边已经完成了所有的准备工作可以开始会议了 <result>控制要素席<result>
示例3:下面有请局长发言 <result>主持人</result>
"""
result = await call_llm(client, system_prompt, f"完整发言内容:{text_full}")
if result:
match = re.search(r'<result>([^<]*)(?:</result>|<result>|$)', result, re.IGNORECASE)
if match:
content = match.group(1).strip()
return content
return "未知说话人"
# ========================
# 核心编排逻辑 (多 Agent 协作)
# ========================
async def orchestrate_agents(new_fragment, websocket, client: AsyncOpenAI):
session_id = DEFAULT_SESSION_ID
# 1. 存入 Buffer (累积)
current_buffer = await append_to_buffer(session_id, new_fragment)
print(f"📥 [Buffer] 长度: {len(current_buffer)} | 内容片段: {current_buffer[-20:]}")
# 2. 调用 Agent A 判断是否结束
is_finished = await agent_check_completion(current_buffer, client)
if is_finished:
print(f"🛑 [Agent A] 判定发言结束。触发身份识别...")
# 3. 调用 Agent B 识别身份
speaker = await agent_identify_speaker(current_buffer, session_id, client)
print(f"👤 [Agent B] 识别为: {speaker}")
# 4. 存入历史并清空 Buffer
await save_history(session_id, speaker, current_buffer)
await clear_buffer(session_id)
# 5. 推送最终结果给前端
msg = {
"type": "final_speech_record",
"speaker": speaker,
"content": current_buffer.strip(),
"timestamp": "now"
}
await websocket.send(json.dumps(msg, ensure_ascii=False))
else:
# 未结束,什么都不做,继续等待下一段文本
print(f"⏳ [Agent A] 判定未结束...")
# ========================
# WebSocket 主逻辑
# ========================
async def forward_audio(websocket):
print("🟢 Client Connected.")
# 初始化 OpenAI 客户端
# 注意AsyncOpenAI 是线程/协程安全的,也可以放在全局初始化
client = AsyncOpenAI(
api_key=LLM_API_KEY,
base_url=LLM_BASE_URL
)
try:
async with websockets.connect(TARGET_WS_URL) as target_ws:
await target_ws.send(json.dumps(config))
async def frontend_to_target():
async for message in websocket:
await target_ws.send(message)
async def target_to_frontend():
async for message in target_ws:
# 1. 实时转发 (用于字幕上屏)
await websocket.send(message)
if isinstance(message, str):
try:
data = json.loads(message)
# 2. 触发 Agent 协作
if data.get("mode") == "2pass-offline":
asr_text = data.get("text", "").strip()
if asr_text:
# 异步触发,传入 client
asyncio.create_task(
orchestrate_agents(asr_text, websocket, client)
)
except json.JSONDecodeError:
pass
await asyncio.gather(frontend_to_target(), target_to_frontend())
except Exception as e:
print(f"❌ Error: {e}")
finally:
# 关闭客户端(虽然 AsyncOpenAI 通常会自动管理,但显式关闭是好习惯)
await client.close()
print("🔴 Client Disconnected.")
# ========================
# 启动
# ========================
async def main():
print(f"🚀 启动 WebSocket 智能语义分析服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
print(f"🧠 Agent 模型: {LLM_MODEL}")
# 这里的 websockets.serve 会在 main 协程内部运行,此时已有 Loop
async with websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT):
# 创建一个永远 pending 的 Future让服务一直运行不退出
await asyncio.Future()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n🛑 服务已停止")

176
fastapi_wss/src/app.py Normal file
View File

@ -0,0 +1,176 @@
import asyncio
import json
import os
import tempfile
import wave
import websockets
import aiohttp
# ========================
# 配置
# ========================
TARGET_WS_URL = os.getenv("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
LOCAL_HOST = "0.0.0.0"
LOCAL_PORT = 10095
# ⚠️ 修改为你自己的 FastAPI ASR 服务地址
ASR_LOCAL_URL = os.getenv("ASR_LOCAL_URL", "http://172.18.127.124:6688/asr")
SAMPLE_RATE = 16000 # ⬅️ 关键!
SAMPLE_WIDTH = 2 # 16-bit
CHANNELS = 1
config = {
"chunk_size": [10, 10, 10],
"wav_name": "h5",
"is_speaking": True,
"wav_format": "pcm",
"chunk_interval": 10,
"itn": True,
"mode": "2pass",
"hotwords": "",
}
# ========================
# 调用本地 ASR 接口(带说话人识别)
# ========================
async def call_local_asr_with_audio(wav_path: str, session: aiohttp.ClientSession):
"""调用你自己的 /asr 接口,返回结构化结果"""
try:
with open(wav_path, "rb") as f:
data = aiohttp.FormData()
data.add_field(
"audio_file",
f,
filename=os.path.basename(wav_path),
content_type="audio/wav"
)
# 可选:传递参数(如 spk_diarization=True 已默认开启)
params = {
"spk_diarization": "true",
"spk_threshold": "0.6",
"output": "json"
}
async with session.post(ASR_LOCAL_URL, data=data, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
if resp.status == 200:
return await resp.json()
else:
text = await resp.text()
print(f"❌ 本地 ASR 接口错误: {resp.status} - {text}")
return None
except Exception as e:
print(f"❌ 调用本地 ASR 异常: {e}")
return None
# ========================
# 后台处理:音频 → 本地 ASR → 推送结果
# ========================
async def process_audio_with_local_asr(audio_chunks, asr_text, websocket, session):
if not audio_chunks:
return
wav_path = None
try:
# 1. 保存为 WAV
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
wav_path = tmp.name
with wave.open(wav_path, 'wb') as wf:
wf.setnchannels(CHANNELS)
wf.setsampwidth(SAMPLE_WIDTH)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(b''.join(audio_chunks))
# 2. 调用本地 ASR 接口
asr_result = await call_local_asr_with_audio(wav_path, session)
if not asr_result or not asr_result.get("success"):
print("❌ 本地 ASR 处理失败")
return
# 3. 构建前端消息
segments = asr_result.get("segments", [])
full_text = asr_result.get("text", "")
# 推送完整结果(含说话人)
msg = {
"type": "asr_with_speaker",
"full_text": full_text,
"segments": segments, # 每段含 speaker, text, start, end 等
"original_asr_text": asr_text # 来自远程 ASR 的原始文本(可选)
}
await websocket.send(json.dumps(msg, ensure_ascii=False))
print(f"✅ 本地 ASR + 说话人识别完成,共 {len(segments)}")
finally:
if wav_path and os.path.exists(wav_path):
os.unlink(wav_path)
# ========================
# 主 WebSocket 转发逻辑
# ========================
async def forward_audio(websocket):
print("🟢 前端已连接。")
current_audio_chunks = []
lock = asyncio.Lock()
async with aiohttp.ClientSession() as session:
try:
async with websockets.connect(TARGET_WS_URL) as target_ws:
print(f"🔗 连接到远程 ASR: {TARGET_WS_URL}")
await target_ws.send(json.dumps(config))
async def frontend_to_target():
async for message in websocket:
if isinstance(message, bytes):
async with lock:
current_audio_chunks.append(message)
await target_ws.send(message)
else:
await target_ws.send(message)
async def target_to_frontend():
async for message in target_ws:
# 立即转发原始 ASR 消息(保持低延迟)
await websocket.send(message)
if isinstance(message, str):
try:
data = json.loads(message)
if data.get("mode") == "2pass-offline":
asr_text = data.get("text", "").strip()
print(f"🔊 触发本地 ASR 处理: '{asr_text}'")
async with lock:
audio_copy = current_audio_chunks.copy()
current_audio_chunks.clear()
# 启动后台任务:调用你自己的 /asr 接口
asyncio.create_task(
process_audio_with_local_asr(audio_copy, asr_text, websocket, session)
)
except json.JSONDecodeError:
pass
await asyncio.gather(frontend_to_target(), target_to_frontend())
except Exception as e:
print(f"❌ 连接异常: {e}")
finally:
print("🔴 前端断开连接。")
# ========================
# 启动服务
# ========================
async def main():
print(f"🚀 启动 WebSocket 中转服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
print(f"📝 本地 ASR 接口: {ASR_LOCAL_URL}")
server = await websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT)
await server.wait_closed()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,317 @@
import asyncio
import json
import os
import tempfile
import wave
import websockets
import aiohttp
import logging
from dotenv import load_dotenv
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
force=True
)
logger = logging.getLogger("app")
# ========================
# 配置
# ========================
load_dotenv()
logger.info("正在加载环境变量 ")
TARGET_WS_URL = os.getenv("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
LOCAL_HOST = "0.0.0.0"
LOCAL_PORT = 10095
ASR_LOCAL_URL = os.getenv("ASR_LOCAL_URL", "http://172.18.127.124:6688/asr")
SAMPLE_RATE = 16000
SAMPLE_WIDTH = 2 # 16-bit = 2 bytes
CHANNELS = 1
# 计算每秒的字节数
BYTES_PER_SECOND = SAMPLE_RATE * SAMPLE_WIDTH * CHANNELS
# 设定最小触发时长(秒)
MIN_DURATION_THRESHOLD = 3.0
config = {
"chunk_size": [10, 10, 10],
"wav_name": "h5",
"is_speaking": True,
"wav_format": "pcm",
"chunk_interval": 10,
"itn": True,
"mode": "2pass",
"hotwords": "",
}
# ========================
# 新增:声纹会话缓存管理类 (带超时机制 + 日志)
# ========================
class SpeakerSession:
def __init__(self):
self.last_speaker = None
self.buffer = []
self.accumulated_duration = 0.0
self.lock = asyncio.Lock()
self.MAX_BUFFER_DURATION = 3.0
async def process_and_send(self, new_msg, websocket, duration=0.0, force_flush=False):
"""
duration: 当前这段消息对应的音频时长()
"""
async with self.lock:
# === 1. 强制清空 (连接断开时) ===
if force_flush:
if self.buffer:
logger.info(f"🧹 [最终清空] 准备推送 {len(self.buffer)} 条缓存...")
for msg in self.buffer:
text_content = msg.get("full_text", "")
logger.info(f" └── 🚀 发送: {text_content}") # 打印发送内容
await websocket.send(json.dumps(msg, ensure_ascii=False))
self.buffer = []
self.accumulated_duration = 0.0
return
# 获取当前消息的说话人
segments = new_msg.get("segments", [])
current_speaker = segments[0].get("spk") if segments else "unknown"
# === 2. 如果是同一个人(或者刚开始) ===
if self.last_speaker is None or current_speaker == self.last_speaker:
self.buffer.append(new_msg)
self.accumulated_duration += duration
self.last_speaker = current_speaker
# 🌟 检查是否超时 20s
if self.accumulated_duration >= self.MAX_BUFFER_DURATION:
logger.info(f"⏰ [超时推送] 同人 ({current_speaker}) 积攒 {self.accumulated_duration:.2f}s >= 8s")
for msg in self.buffer:
text_content = msg.get("full_text", "")
logger.info(f" └── 🚀 发送: {text_content}") # 打印发送内容
await websocket.send(json.dumps(msg, ensure_ascii=False))
# 清空缓存,重置时长
self.buffer = []
self.accumulated_duration = 0.0
else:
# 仅缓存,不发送
text_preview = new_msg.get("full_text", "")
logger.info(f"📥 [缓存中] Spk:{current_speaker} | 文本:{text_preview} | 累计:{self.accumulated_duration:.2f}s")
# === 3. 如果换人了 ===
else:
logger.info(f"🔄 [换人推送] {self.last_speaker} -> {current_speaker}")
# A. 推送上一位说话人的所有积攒消息
for msg in self.buffer:
text_content = msg.get("full_text", "")
logger.info(f" └── 🚀 发送: {text_content}") # 打印发送内容
await websocket.send(json.dumps(msg, ensure_ascii=False))
# B. 清空并开始缓存新人的消息
self.buffer = []
self.buffer.append(new_msg)
self.accumulated_duration = duration
self.last_speaker = current_speaker
# 打印新人入队日志
new_text = new_msg.get("full_text", "")
logger.info(f"📥 [新缓存] Spk:{current_speaker} | 文本:{new_text}")
# ========================
# 调用本地 ASR 接口(带说话人识别)
# ========================
async def call_local_asr_with_audio(wav_path: str, session: aiohttp.ClientSession):
try:
with open(wav_path, "rb") as f:
data = aiohttp.FormData()
data.add_field(
"audio_file",
f,
filename=os.path.basename(wav_path),
content_type="audio/wav"
)
params = {
"spk_diarization": "true",
"spk_threshold": "0.6",
"output": "json"
}
async with session.post(ASR_LOCAL_URL, data=data, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
if resp.status == 200:
return await resp.json()
else:
text = await resp.text()
logger.error(f"❌ 本地 ASR 接口错误: {resp.status} - {text}")
return None
except Exception as e:
logger.error(f"❌ 调用本地 ASR 异常: {e}")
return None
# ========================
# 后台处理:音频 → 本地 ASR → Session 缓存判断
# ========================
async def process_audio_with_local_asr(audio_bytes, asr_text, websocket, session, speaker_session):
if not audio_bytes:
return
wav_path = None
try:
# 1. 计算这段音频的时长,用于 Session 判断超时
audio_duration = len(audio_bytes) / BYTES_PER_SECOND
# 2. 保存为 WAV
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
wav_path = tmp.name
with wave.open(wav_path, 'wb') as wf:
wf.setnchannels(CHANNELS)
wf.setsampwidth(SAMPLE_WIDTH)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(audio_bytes)
# 3. 调用本地 ASR 接口
asr_result = await call_local_asr_with_audio(wav_path, session)
if not asr_result or not asr_result.get("success"):
logger.error("❌ 本地 ASR (带声纹) 处理失败")
return
# 4. 构建消息
segments = asr_result.get("segments", [])
full_text = asr_result.get("text", "")
msg = {
"mode": "offline-speaker",
"full_text": full_text,
"segments": segments,
"original_asr_text": asr_text,
"type": "asr_with_speaker",
}
# 5. 🌟 传入 duration 参数
await speaker_session.process_and_send(msg, websocket, duration=audio_duration)
except websockets.exceptions.ConnectionClosed:
logger.warning("⚠️ WebSocket 连接已关闭")
except Exception as e:
logger.error(f"❌ 后台处理异常: {e}")
finally:
if wav_path and os.path.exists(wav_path):
os.unlink(wav_path)
# ========================
# 主 WebSocket 转发逻辑
# ========================
async def forward_audio(websocket):
logger.info("🟢 前端已连接。")
current_audio_chunks = []
local_accumulated_audio = bytearray()
speaker_session = SpeakerSession()
lock = asyncio.Lock()
async with aiohttp.ClientSession() as session:
try:
async with websockets.connect(TARGET_WS_URL) as target_ws:
logger.info(f"🔗 连接到远程 ASR: {TARGET_WS_URL}")
await target_ws.send(json.dumps(config))
# ---------------------------
# 任务1: 前端 -> 远程 ASR
# ---------------------------
async def frontend_to_target():
async for message in websocket:
if isinstance(message, bytes):
async with lock:
current_audio_chunks.append(message)
await target_ws.send(message)
else:
await target_ws.send(message)
# ---------------------------
# 任务2: 远程 ASR -> 前端
# ---------------------------
async def target_to_frontend():
async for message in target_ws:
# 实时推送
await websocket.send(message)
if isinstance(message, str):
try:
data = json.loads(message)
mode = data.get("mode")
if mode == "2pass-offline":
asr_text = data.get("text", "").strip()
async with lock:
chunk_bytes = b"".join(current_audio_chunks)
current_audio_chunks.clear()
local_accumulated_audio.extend(chunk_bytes)
current_duration = len(local_accumulated_audio) / BYTES_PER_SECOND
if current_duration < MIN_DURATION_THRESHOLD:
logger.info(f"⏳ 音频拼接中: {current_duration:.2f}s")
else:
logger.info(f"⚡ 触发本地声纹识别: {current_duration:.2f}s")
audio_to_send = bytes(local_accumulated_audio)
local_accumulated_audio.clear()
asyncio.create_task(
process_audio_with_local_asr(audio_to_send, asr_text, websocket, session, speaker_session)
)
elif mode == "2pass-online":
pass
except json.JSONDecodeError:
pass
await asyncio.gather(frontend_to_target(), target_to_frontend())
except websockets.exceptions.ConnectionClosedOK:
logger.info("🔗 远程 ASR 连接正常关闭。")
except Exception as e:
logger.error(f"❌ 连接异常: {e}")
finally:
logger.info("🔴 前端断开连接,清理资源...")
if len(local_accumulated_audio) > 0:
final_duration = len(local_accumulated_audio) / BYTES_PER_SECOND
logger.info(f"⚠️ 处理断开前的剩余音频: {final_duration:.2f}s")
try:
audio_to_send = bytes(local_accumulated_audio)
local_accumulated_audio.clear()
await process_audio_with_local_asr(audio_to_send, "Final-Flush", websocket, session, speaker_session)
except Exception as e:
logger.error(f"❌ 剩余音频处理异常: {e}")
# 强制推送最后残留缓存
try:
await speaker_session.process_and_send(None, websocket, force_flush=True)
except Exception as e:
logger.error(f"❌ 最终缓存推送失败: {e}")
# ========================
# 启动服务
# ========================
async def main():
logger.info(f"🚀 启动 WebSocket 中转服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
logger.info("📝 逻辑: 声纹识别缓存同人累积满8s或换人时推送")
server = await websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT)
await server.wait_closed()
if __name__ == "__main__":
asyncio.run(main())

199
fastapi_wss/src/test.py Normal file
View File

@ -0,0 +1,199 @@
import asyncio
import audioop
from array import array
import json
import sys
import time
import wave
import websockets
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
WS_URL = "ws://localhost:10095"
TARGET_SAMPLE_RATE = 16000
TARGET_SAMPLE_WIDTH = 2
CHUNK_SECONDS = 0.06
FINAL_RESULT_TIMEOUT = 60
WAV_PATH = r"E:\ZKYNLP\gonwgendoc\fastapi_wss\fastapi_wss\src\多人会议90分钟.wav"
def downmix_to_mono_pcm16(chunk: bytes, channels: int) -> bytes:
if channels == 1:
return chunk
samples = array("h")
samples.frombytes(chunk)
if sys.byteorder != "little":
samples.byteswap()
mono = array("h")
for index in range(0, len(samples), channels):
frame = samples[index:index + channels]
if len(frame) == channels:
mono.append(int(sum(frame) / channels))
if sys.byteorder != "little":
mono.byteswap()
return mono.tobytes()
def validate_wav_for_streaming(wf: wave.Wave_read):
sample_rate = wf.getframerate()
sample_width = wf.getsampwidth()
channels = wf.getnchannels()
if sample_width != TARGET_SAMPLE_WIDTH:
raise ValueError(f"WAV sample width must be {TARGET_SAMPLE_WIDTH} bytes, got {sample_width} bytes")
if channels < 1:
raise ValueError(f"WAV channels must be >= 1, got {channels}")
if sample_rate != TARGET_SAMPLE_RATE:
print(f"Input WAV is {sample_rate}Hz; resampling to {TARGET_SAMPLE_RATE}Hz before sending.")
if channels > 1:
print(f"Input WAV has {channels} channels; downmixing to mono before sending.")
source_frames_per_chunk = max(1, int(sample_rate * CHUNK_SECONDS))
return sample_rate, channels, source_frames_per_chunk
class Pcm16Mono16kConverter:
def __init__(self, source_rate: int, channels: int):
self.source_rate = source_rate
self.channels = channels
self.rate_state = None
def convert(self, chunk: bytes) -> bytes:
chunk = downmix_to_mono_pcm16(chunk, self.channels)
if self.source_rate != TARGET_SAMPLE_RATE:
chunk, self.rate_state = audioop.ratecv(
chunk,
TARGET_SAMPLE_WIDTH,
1,
self.source_rate,
TARGET_SAMPLE_RATE,
self.rate_state,
)
return chunk
def is_final_asr_message(data: dict) -> bool:
mode = data.get("mode")
if mode not in {"2pass-offline", "offline-speaker"}:
return False
return bool(data.get("is_final", True))
async def recv_loop(ws, audio_sent_event: asyncio.Event, final_result_event: asyncio.Event, stats: dict):
async for msg in ws:
print("RECV:", msg)
if not isinstance(msg, str):
continue
try:
data = json.loads(msg)
except json.JSONDecodeError:
continue
if not is_final_asr_message(data):
continue
now = time.perf_counter()
stats["last_final_time"] = now
if audio_sent_event.is_set() and "first_final_after_send_time" not in stats:
stats["first_final_after_send_time"] = now
final_result_event.set()
def print_timing_stats(stats: dict):
end_time = stats.get("first_final_after_send_time") or stats.get("end_time") or time.perf_counter()
total_elapsed = end_time - stats["start_time"]
send_elapsed = stats.get("send_end_time", end_time) - stats.get("send_start_time", stats["start_time"])
tail_elapsed = None
if "send_end_time" in stats and "first_final_after_send_time" in stats:
tail_elapsed = stats["first_final_after_send_time"] - stats["send_end_time"]
print("\n===== ASR Timing =====")
print(f"Audio duration: {stats.get('audio_duration_sec', 0.0):.2f}s")
print(f"Audio send time: {send_elapsed:.2f}s")
if tail_elapsed is not None:
print(f"Post-send ASR wait: {tail_elapsed:.2f}s")
else:
print("Post-send ASR wait: timeout/no final result")
print(f"Total elapsed: {total_elapsed:.2f}s")
if stats.get("audio_duration_sec"):
rtf = total_elapsed / stats["audio_duration_sec"]
print(f"RTF: {rtf:.3f}x")
print("======================\n")
async def main():
stats = {"start_time": time.perf_counter()}
audio_sent_event = asyncio.Event()
final_result_event = asyncio.Event()
async with websockets.connect(
WS_URL,
max_size=None,
ping_interval=None,
ping_timeout=None,
) as ws:
recv_task = asyncio.create_task(recv_loop(ws, audio_sent_event, final_result_event, stats))
init = {
"mode": "2pass",
"chunk_size": [10, 10, 10],
"chunk_interval": 10,
"wav_name": "test",
"is_speaking": True,
"wav_format": "pcm",
"itn": True
}
await ws.send(json.dumps(init, ensure_ascii=False))
connection_closed = False
with wave.open(WAV_PATH, "rb") as wf:
source_rate, channels, source_frames_per_chunk = validate_wav_for_streaming(wf)
stats["audio_duration_sec"] = wf.getnframes() / source_rate
converter = Pcm16Mono16kConverter(source_rate, channels)
stats["send_start_time"] = time.perf_counter()
while True:
chunk = wf.readframes(source_frames_per_chunk)
if not chunk:
break
chunk = converter.convert(chunk)
try:
await ws.send(chunk)
except websockets.exceptions.ConnectionClosed as e:
connection_closed = True
print(f"WebSocket closed while sending audio: {type(e).__name__}: {e}")
break
await asyncio.sleep(CHUNK_SECONDS)
if not connection_closed:
try:
await ws.send(json.dumps({"is_speaking": False}, ensure_ascii=False))
except websockets.exceptions.ConnectionClosed as e:
connection_closed = True
print(f"WebSocket closed before sending end marker: {type(e).__name__}: {e}")
stats["send_end_time"] = time.perf_counter()
audio_sent_event.set()
if connection_closed and not final_result_event.is_set():
print("Connection closed before final ASR result was received.")
stats["end_time"] = time.perf_counter()
print_timing_stats(stats)
else:
try:
await asyncio.wait_for(final_result_event.wait(), timeout=FINAL_RESULT_TIMEOUT)
except asyncio.TimeoutError:
print(f"Timed out waiting for final ASR result after {FINAL_RESULT_TIMEOUT}s.")
finally:
stats["end_time"] = time.perf_counter()
print_timing_stats(stats)
recv_task.cancel()
await asyncio.gather(recv_task, return_exceptions=True)
asyncio.run(main())

1
fastapi_wss/test.json Normal file

File diff suppressed because one or more lines are too long

24
speakr/.dockerignore Normal file
View File

@ -0,0 +1,24 @@
.git
.codex/
.agents/
.env
.env.*
__pycache__/
*.py[cod]
*.pyo
*.swp
*.swo
.pytest_cache/
.coverage
htmlcov/
logs/
node_modules/
templates/index/node_modules/
static/vite/
instance/
uploads/
*.db
*.log
.DS_Store
.idea/
.vscode/

3
speakr/.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
# 保证 shell 脚本在任何平台检出时都是 LF否则 Docker 镜像内脚本无法执行
*.sh text eol=lf
docker-entrypoint.sh text eol=lf

65
speakr/.gitignore vendored Normal file
View File

@ -0,0 +1,65 @@
# Python 字节码缓存
__pycache__/
*.py[cod]
*$py.class
*.pyo
# 虚拟环境
.venv/
venv/
env/
ENV/
# 环境变量(含敏感信息,禁止提交)
.env
.env.local
.env.*.local
# 日志文件
logs/
*.log
# 编辑器 / IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store
.trae/
.codex/
# 测试 / 覆盖率
.pytest_cache/
.coverage
htmlcov/
templates/index/node_modules/
# 构建产物
dist/
build/
*.egg-info/
static/vite/
skills/
## mypy
.mypy_cache/
# data
data/
static/vite/
instance/transcriptions.db
ai-guide/
音频web转文字前端示例/
# 本地测试脚本
tests/
#本地调试文件
*.backup
# 本地数据库文件
*.db
instance/transcriptions.db
node_modules/
instance/transcriptions.db
instance/transcriptions.db

137
speakr/.gitlab-ci.yml Normal file
View File

@ -0,0 +1,137 @@
# GitLab CI/CD 配置 for speakr 项目
# 自动构建并部署到 Kubernetes
stages:
- build
- deploy
variables:
# 使用阿里云镜像仓库
IMAGE_NAME: crpi-99azmmphmxwdoi76.cn-guangzhou.personal.cr.aliyuncs.com/sea_lee/speakr
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
# ============================================
# 阶段 1: 构建 Docker 镜像并推送
# ============================================
build:
stage: build
image: docker:latest
services:
- docker:dind
before_script:
# 登录阿里云镜像仓库
- echo "$ALIYUN_REGISTRY_PASSWORD" | docker login crpi-99azmmphmxwdoi76.cn-guangzhou.personal.cr.aliyuncs.com -u "$ALIYUN_REGISTRY_USER" --password-stdin
script:
- echo "=========================================="
- echo "🚀 开始构建 speakr 镜像"
- echo "=========================================="
- echo "Git 提交: $CI_COMMIT_SHORT_SHA"
- echo "分支: $CI_COMMIT_BRANCH"
- echo "提交信息: $CI_COMMIT_TITLE"
- echo "提交者: $GITLAB_USER_NAME"
- echo ""
# 显示 Dockerfile 内容
- echo "Dockerfile:"
- cat Dockerfile
- echo ""
# 构建 Docker 镜像 (使用现有的 Dockerfile)
- echo "📦 构建 Docker 镜像..."
- docker build -t $IMAGE_NAME:$IMAGE_TAG .
- echo "✓ 镜像构建完成"
- docker images | grep speakr
# 打标签为 latest
- docker tag $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:latest
# 推送到阿里云镜像仓库
- echo ""
- echo "📤 推送镜像到阿里云..."
- docker push $IMAGE_NAME:$IMAGE_TAG
- docker push $IMAGE_NAME:latest
- echo "✓ 镜像推送完成"
- echo ""
- echo "=========================================="
- echo "✅ 构建阶段完成!"
- echo "镜像: $IMAGE_NAME:$IMAGE_TAG"
- echo "=========================================="
only:
- main
- master
- develop
# ============================================
# 阶段 2: 部署到 Kubernetes
# ============================================
deploy:
stage: deploy
image: bitnami/kubectl:latest
environment:
name: production
url: http://172.18.127.124
before_script:
# 配置 kubectl
- echo "🔧 配置 kubectl..."
- echo "$KUBECONFIG_CONTENT" | base64 -d > /tmp/kubeconfig
- export KUBECONFIG=/tmp/kubeconfig
- chmod 600 /tmp/kubeconfig
# 验证 Kubernetes 连接
- echo ""
- echo "验证 Kubernetes 连接..."
- kubectl config current-context
- kubectl version --client
- kubectl get nodes
script:
- echo "=========================================="
- echo "🚀 部署 speakr 到 Kubernetes"
- echo "=========================================="
- echo "命名空间: default"
- echo "Deployment: speakr"
- echo "镜像: $IMAGE_NAME:$IMAGE_TAG"
- echo ""
# 检查 deployment 是否存在
- |
if kubectl get deployment speakr &>/dev/null; then
echo "✓ Deployment 已存在,更新镜像..."
kubectl set image deployment/speakr speakr=$IMAGE_NAME:$IMAGE_TAG
else
echo "✗ Deployment 不存在,创建新的..."
kubectl create deployment speakr --image=$IMAGE_NAME:$IMAGE_TAG --replicas=2
fi
# 等待 Deployment rollout 完成
- echo ""
- echo "⏳ 等待部署完成..."
- kubectl rollout status deployment/speakr --timeout=10m
# 确保服务存在
- |
if ! kubectl get service speakr &>/dev/null; then
echo "创建 Service..."
kubectl expose deployment speakr --port=8899 --target-port=8899 --type=NodePort
fi
# 显示最终状态
- echo ""
- echo "=========================================="
- echo "✅ 部署完成!"
- echo "=========================================="
- kubectl get pods -l app=speakr
- echo ""
- kubectl get service speakr
- echo ""
# 获取访问地址
- |
NODEPORT=$(kubectl get service speakr -o jsonpath='{.spec.ports[0].nodePort}')
echo "🌐 访问地址:"
echo " NodePort: http://172.18.127.124:$NODEPORT"
echo " 直接访问: http://172.18.127.124:8899"
echo ""
only:
- main
- master

62
speakr/Dockerfile Normal file
View File

@ -0,0 +1,62 @@
FROM node:20-slim AS frontend-builder
WORKDIR /build/templates/index
# 安装前端依赖,并在镜像构建阶段生成 Vite 产物。
COPY templates/index/package.json templates/index/pnpm-lock.yaml ./
RUN corepack enable && \
corepack prepare pnpm@9.15.9 --activate && \
pnpm install --frozen-lockfile
COPY templates/index/ ./
RUN rm -f pnpm-workspace.yaml && pnpm build
FROM python:3.11-slim
WORKDIR /app
# 安装运行时系统依赖。
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# 先安装 Python 依赖,提高 Docker 层缓存命中率。
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 先准备 vendor 目录,便于离线依赖下载脚本写入文件。
RUN mkdir -p /app/static/vendor
# 只复制下载脚本,避免业务代码变化导致 vendor 依赖重复下载。
COPY scripts/download_offline_deps.py scripts/
RUN pip install --no-cache-dir requests && \
python scripts/download_offline_deps.py
# 复制应用代码。
COPY . .
# 使用前端构建阶段生成的产物覆盖源码中的旧产物。
COPY --from=frontend-builder /build/static/vite/index /app/static/vite/index
# 创建容器运行时数据目录。
RUN mkdir -p /data/uploads /data/instance && \
chmod 755 /data/uploads /data/instance
# 设置默认运行环境变量。
ENV FLASK_APP=src/app.py
ENV SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
ENV UPLOAD_FOLDER=/data/uploads
ENV PYTHONPATH=/app
# 安装容器入口脚本。
COPY scripts/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# 暴露 Flask/Gunicorn 服务端口。
EXPOSE 8899
# 设置入口脚本和默认启动命令。
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["gunicorn", "--workers", "3", "--threads", "8", "--bind", "0.0.0.0:8899", "--timeout", "600", "src.app:app"]

61
speakr/Dockerfile20260710 Normal file
View File

@ -0,0 +1,61 @@
FROM node:20-slim AS frontend-builder
WORKDIR /build/templates/index
# 安装前端依赖,并在镜像构建阶段生成 Vite 产物。
COPY templates/index/package.json templates/index/pnpm-lock.yaml templates/index/pnpm-workspace.yaml ./
RUN corepack enable && \
corepack prepare pnpm@9.15.9 --activate && \
pnpm install --frozen-lockfile
COPY templates/index/ ./
RUN pnpm build
FROM python:3.11-slim
WORKDIR /app
# 安装运行时系统依赖。
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# 先安装 Python 依赖,提高 Docker 层缓存命中率。
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 先准备 vendor 目录,便于离线依赖下载脚本写入文件。
RUN mkdir -p /app/static/vendor
# 只复制下载脚本,避免业务代码变化导致 vendor 依赖重复下载。
COPY scripts/download_offline_deps.py scripts/
RUN pip install --no-cache-dir requests && \
python scripts/download_offline_deps.py
# 复制应用代码。
COPY . .
# 使用前端构建阶段生成的产物覆盖源码中的旧产物。
COPY --from=frontend-builder /build/static/vite/index /app/static/vite/index
# 创建容器运行时数据目录。
RUN mkdir -p /data/uploads /data/instance && \
chmod 755 /data/uploads /data/instance
# 设置默认运行环境变量。
ENV FLASK_APP=src/app.py
ENV SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
ENV UPLOAD_FOLDER=/data/uploads
ENV PYTHONPATH=/app
# 安装容器入口脚本。
COPY scripts/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# 暴露 Flask/Gunicorn 服务端口。
EXPOSE 8899
# 设置入口脚本和默认启动命令。
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["gunicorn", "--workers", "3", "--threads", "8", "--bind", "0.0.0.0:8899", "--timeout", "600", "src.app:app"]

661
speakr/LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

513
speakr/README.md Normal file
View File

@ -0,0 +1,513 @@
# Speakr - 定制版
> 基于 [murtaza-nasir/speakr](https://github.com/murtaza-nasir/speakr) 二次开发的语音转录与智能笔记平台。
## 项目简介
本项目是一个自托管的 AI 语音转录和智能笔记平台,支持:
- **语音录制与上传** - 浏览器直接录音或上传已有音频文件
- **AI 转录** - 高准确率语音转文字,支持说话人识别
- **交互式对话** - 对录音提问,获取 AI 回答
- **智能标签** - 支持带自定义 AI 提示词的标签
- **安全分享** - 生成安全链接分享录音
- **草稿功能** - 本地草稿保存与上传
- **声纹管理** - 自定义说话人声纹识别
- **标准词映射** - 自动纠正专业术语和人名
---
## 项目结构
```
speakr/
├── app.py # 入口文件(启动脚本)
├── src/
│ ├── app.py # Flask 应用主逻辑create_app 工厂模式)
│ ├── config.py # 统一配置文件(所有环境变量集中管理)
│ ├── logging_config.py # 日志配置模块(格式化、输出目标、日志级别)
│ ├── flask_ext.py # Flask 扩展实例集合LoginManager、Bcrypt、Limiter、CSRFProtect
│ ├── clients/ # 外部服务客户端层
│ │ ├── __init__.py # 统一导出所有客户端和配置
│ │ ├── database.py # 数据库客户端db 实例 + 配置 + 初始化
│ │ ├── llm_client.py # LLM 客户端OpenAI 客户端初始化
│ │ └── voiceprint_client.py # 声纹客户端VoiceprintClient
│ ├── api/ # API 路由层Flask 蓝图)
│ │ ├── __init__.py # 统一蓝图注册函数 register_blueprints()
│ │ ├── template_filters.py # 模板过滤器注册now、localdatetime
│ │ ├── auth.py # 认证路由(登录、注册、账户管理)
│ │ ├── recording.py # 录音管理CRUD、上传、转录、下载
│ │ ├── share.py # 分享功能
│ │ ├── tag.py # 标签管理
│ │ ├── speaker.py # 说话人管理
│ │ ├── admin.py # 管理后台
│ │ ├── search.py # 搜索和 Inquire
│ │ ├── voiceprint.py # 声纹管理
│ │ ├── config.py # 系统配置管理
│ │ ├── hotword.py # 热词管理
│ │ ├── template.py # 转录模板
│ │ ├── event.py # 事件管理
│ │ └── main.py # 主页和杂项
│ ├── models/ # 数据模型层(每个模型独立文件)
│ │ ├── __init__.py # 统一导出所有模型
│ │ ├── user.py # 用户模型
│ │ ├── speaker.py # 说话人模型
│ │ ├── recording.py # 录音模型
│ │ ├── tag.py # 标签模型
│ │ ├── recording_tag.py # 录音-标签关联模型
│ │ ├── event.py # 事件模型
│ │ ├── share.py # 分享模型
│ │ ├── transcript_chunk.py # 转录块模型
│ │ ├── transcript_template.py # 转录模板模型
│ │ ├── inquire_session.py # 询问会话模型
│ │ ├── voiceprint.py # 声纹模型
│ │ ├── system_setting.py # 系统设置模型
│ │ ├── system_config.py # 系统配置模型
│ │ ├── hotword.py # 热词模型
│ │ ├── draft_recording.py # 草稿录音模型
│ │ ├── draft_segment.py # 草稿片段模型
│ │ └── forms.py # 表单类(注册、登录)
│ ├── services/ # 业务逻辑层
│ │ ├── __init__.py # 统一导出所有服务函数
│ │ ├── llm_service.py # LLM 服务
│ │ ├── transcription_service.py # 转录服务
│ │ ├── summary_service.py # 摘要服务
│ │ ├── embedding_service.py # 嵌入向量服务
│ │ ├── event_service.py # 事件服务
│ │ ├── document_service.py # 文档服务
│ │ ├── speaker_service.py # 说话人服务
│ │ ├── voiceprint_service.py # 声纹服务(业务逻辑)
│ │ ├── auto_process_service.py # 自动处理服务
│ │ ├── auth_service.py # 认证服务
│ │ ├── sync_service.py # 远程数据同步服务
│ │ ├── audio_chunking.py # 音频分块服务
│ │ └── file_monitor.py # 文件监控服务
│ ├── utils/ # 工具函数层
│ │ ├── __init__.py
│ │ ├── markdown.py # Markdown 转 HTML 工具
│ │ ├── datetime.py # 时区转换工具
│ │ ├── text_utils.py # 文本处理工具
│ │ ├── json_utils.py # JSON 处理工具
│ │ └── standard_word_mappings.py # 标准词映射工具
├── templates/ # Jinja2 模板
├── static/ # 静态资源CSS/JS/图片)
├── instance/ # SQLite 数据库文件
├── uploads/ # 上传的音频文件
└── .env # 环境变量配置
```
---
## 启动方式
### 前置条件
- Python 环境(已安装 conda 环境 `speakr`
- Nginx 反向代理(必须启动)
### 启动步骤
```bash
# 1. 进入项目根目录speakr 2 目录)
cd "c:\Work\zdht\python-projects\speakr 2"
# 2. 启动 Nginx 反向代理(必须先启动)
# Nginx 负责将 https://localhost:8083/tool/speakr/ 请求转发到 Flask 后端
# ⚠️ 注意:不启动 Nginx 将无法访问应用
nginx # 或根据你的安装方式启动 nginx
# 3. 激活 conda 环境并启动应用
conda activate speakr
python .\speakr\app.py
```
Flask 后端默认监听 **5000 端口**Nginx 负责将 `https://localhost:8083/tool/speakr/` 请求转发到 `http://localhost:5000`
### 访问地址
- 主页面:`https://localhost:8083/tool/speakr/`
- 管理后台:`https://localhost:8083/tool/speakr/admin`
- 账户设置:`https://localhost:8083/tool/speakr/account`
### 常见错误
| 问题 | 原因 | 解决 |
|------|------|------|
| 页面无法访问 | Nginx 未启动 | 启动 Nginx 反向代理 |
| `ModuleNotFoundError: No module named 'xxx'` | conda 环境未激活 | 执行 `conda activate speakr` |
| 数据库文件位置错误 | 从错误目录启动 | 确保从 `speakr 2/` 目录执行 `python .\speakr\app.py` |
---
## 环境配置
编辑 `.env` 文件,主要配置项:
```ini
# ASR 服务配置
USE_ASR_ENDPOINT=true
ASR_BASE_URL=http://10.100.3.22:6688
ASR_DIARIZE=true
# 文本生成模型配置
TEXT_MODEL_BASE_URL=http://10.100.3.22:9800/v1
TEXT_MODEL_API_KEY=EMPTY
TEXT_MODEL_NAME=minimax-m2.5
# 数据库配置
SQLALCHEMY_DATABASE_URI=sqlite:///transcriptions.db
# PostgreSQL 配置示例(切换时只需修改 URI 即可)
# SQLALCHEMY_DATABASE_URI=postgresql://user:password@localhost/speakr
# DB_POOL_SIZE=10
# DB_MAX_OVERFLOW=20
# DB_POOL_TIMEOUT=30
# DB_POOL_RECYCLE=3600
# 外部访问端口Nginx 监听端口)
EXTERNAL_PORT=8083
# 时区配置
TIMEZONE=Asia/Shanghai
```
---
## 重构计划与当前进度
### 拆分原则
1. ⭐ 标记的模块是业务特色,拆分时只改代码组织结构,不改业务逻辑
2. 拆分顺序:
- **第一步**:抽出 Models最安全纯数据定义
- **第二步**:抽出 Services把 app.py 中的业务逻辑函数提取到独立文件)
- **第三步**:抽出 API 蓝图(把路由按功能域分组注册)
- **第四步**:抽出 Utils工具函数独立化
- **第五步**:模板组件化(可选,优先级较低)
3. `app.py` 最终只保留Flask 初始化、数据库初始化、蓝图注册、全局错误处理
4. 保持向后兼容:拆分过程中所有 API 路径不变,前端无需改动
### 需要特别注意的业务逻辑
| 功能 | 说明 | 拆分注意事项 |
|------|------|-------------|
| ⭐ 声纹管理 | VoiceprintClient 类、声纹注册/比对/同步 | 保持 VoiceprintClient 与外部平台的交互逻辑不变 |
| ⭐ 系统配置 | SystemConfig 动态配置读写 | 保持配置缓存和热加载机制 |
| ⭐ 草稿 API | 已有独立蓝图 draft_bp | 直接迁移,几乎不需改动 |
| ⭐ 标准词映射 | 转写后的文本纠正 | 保持映射规则和应用逻辑 |
| ⭐ 自动处理 | 定时/触发式自动转写 | 保持调度逻辑和状态管理 |
| ⭐ 收件箱 | 录音的收件箱/高亮状态 | 保持查询和状态切换逻辑 |
| ⭐ 热词 | 热词管理和应用 | 保持与转写服务的集成 |
### 当前进度
#### ✅ 已完成Phase 1 - Models 层抽取
已将 `src/app.py` 中的 **16 个数据模型****2 个表单类** 从主文件中抽取到独立模块:
| 状态 | 任务 | 说明 |
|------|------|------|
| ✅ | `src/database.py`(已移至 clients | 创建独立数据库实例文件,集中管理 SQLAlchemy db 对象Phase 2.5 移至 clients/database.py |
| ✅ | `src/models/` | 每个模型独立文件user.py, speaker.py, recording.py 等 16 个模型) |
| ✅ | `src/models/forms.py` | 表单类独立RegistrationForm, LoginForm, password_check |
| ✅ | `src/utils/markdown.py` | Markdown 转 HTML 工具函数 |
| ✅ | `src/utils/datetime.py` | 时区转换工具函数 |
| ✅ | `src/app.py` | 删除模型定义,改为从 `src.models` 导入 |
**验证结果**:应用可正常启动,数据库文件路径正确(`speakr/instance/transcriptions.db`
#### ✅ 已完成Phase 2 - Services 层抽取
**服务文件已全部创建**`src/services/` 目录下 11 个服务文件app.py 中的旧函数定义已全部删除。
| 状态 | 服务文件 | 包含函数 |
|------|---------|---------|
| ✅ | `llm_service.py` | `call_llm_completion`, `clean_llm_response`, `format_transcription_for_llm`, `process_streaming_with_thinking`, `extract_thinking_content`, `preprocess_long_transcription`, `extract_corrected_text`, `format_api_error_message` |
| ✅ | `transcription_service.py` | `transcribe_audio_asr`, `transcribe_audio_task`, `transcribe_single_file`, `transcribe_with_chunking`, `extract_audio_from_video`, `convert_to_wav` |
| ✅ | `summary_service.py` | `generate_title_task`, `generate_summary_only_task` |
| ✅ | `embedding_service.py` | `get_embedding_model`, `chunk_transcription`, `generate_embeddings`, `serialize_embedding`, `deserialize_embedding`, `process_recording_chunks`, `basic_text_search_chunks`, `semantic_search_chunks` |
| ✅ | `event_service.py` | `extract_events_from_transcript`, `generate_ics_content`, `escape_ical_text` |
| ✅ | `document_service.py` | `process_markdown_to_docx`, `get_recording_display_title`, `sanitize_download_filename_component`, `build_docx_download_filename`, `set_download_filename_header` |
| ✅ | `speaker_service.py` | `update_speaker_usage`, `identify_speakers_from_text`, `identify_unidentified_speakers_from_text` |
| ✅ | `voiceprint_service.py` | `sync_voiceprint_to_platform`VoiceprintClient 已移至 clients 层) |
| ✅ | `auto_process_service.py` | `initialize_file_monitor`, `get_file_monitor_functions` |
| ✅ | `auth_service.py` | `is_safe_url`, `auto_login` |
| ✅ | `__init__.py` | 统一导出所有服务函数 |
**已完成的工作:**
1. ✅ 删除了 app.py 中的 22 个重复函数定义,改为从服务层导入
2. ✅ 创建了 `src/utils/text_utils.py``src/utils/json_utils.py`,将工具函数移出 app.py
3. ✅ 添加了服务层和工具层的导入
4. ✅ 核心功能测试通过,应用运行正常
#### ✅ 已完成Phase 2.5 - Clients 层创建
按照行业规范创建了 `src/clients/` 层,统一管理与外部服务的连接客户端:
| 状态 | 客户端 | 说明 |
|------|--------|------|
| ✅ | `database.py` | 数据库客户端:`db` 实例 + 配置 + `init_database()` 初始化逻辑 |
| ✅ | `llm_client.py` | LLM 客户端OpenAI 客户端初始化client, TEXT_MODEL_NAME, TEXT_MODEL_API_KEY 等) |
| ✅ | `voiceprint_client.py` | 声纹客户端VoiceprintClient 类 + voiceprint_client() 延迟初始化 |
| ✅ | `__init__.py` | 统一导出所有客户端 |
**重构成果:**
- 删除了旧 `src/database.py`(空实例文件),合并到 `clients/database.py`
- 删除了 `services/voiceprint_service.py` 中的 `VoiceprintClient` 类,移到 `clients/voiceprint_client.py`
- 17 个模型文件 + 6 个服务文件的 `db` 导入统一改为 `from src.clients import db`
- 消除了层层传递 `client` 参数的问题
- 外部服务客户端与业务逻辑分离,架构更清晰
#### ✅ 已完成Phase 2.6 - 服务层注释中文化
将所有服务层文件中的英文注释和日志消息翻译为中文,提升代码可读性:
| 文件 | 翻译内容 |
|------|---------|
| `clients/llm_client.py` | URL 注释清理逻辑、客户端初始化逻辑 |
| `clients/voiceprint_client.py` | 声纹客户端注释和日志(新建文件,全中文) |
| `clients/database.py` | 数据库配置和初始化注释(新建文件,全中文) |
| `services/llm_service.py` | 思考内容提取、流式处理、API 错误格式化、分段总结等函数的注释和日志 |
| `services/summary_service.py` | 摘要生成全流程的注释和日志(标签提示词、语言要求、系统/用户消息构建等) |
| `services/transcription_service.py` | 音频提取、ASR 转录、分块转录、Whisper 调用等全流程注释和日志 |
| `services/embedding_service.py` | 文本分块、嵌入向量生成、语义搜索等全流程注释和日志 |
| `services/voiceprint_service.py` | 声纹同步业务逻辑注释 |
#### ✅ 已完成Phase 3 - API 蓝图抽取
`src/app.py` 中的约 100 个路由定义按功能域分组抽取到独立的 Flask 蓝图中app.py 从约 10000 行精简到约 700 行:
| 状态 | 蓝图文件 | 路由数量 | 说明 |
|------|---------|---------|------|
| ✅ | `api/auth.py` | 5 | 注册、登录、登出、账户管理、修改密码 |
| ✅ | `api/recording.py` | 24 | 录音 CRUD、上传、转录、下载、状态管理、ASR 校正 |
| ✅ | `api/share.py` | 7 | 分享创建、查看、访问 |
| ✅ | `api/tag.py` | 6 | 标签 CRUD、应用到录音 |
| ✅ | `api/speaker.py` | 5 | 说话人管理、搜索 |
| ✅ | `api/admin.py` | 18 | 管理后台、统计分析、录音管理、配置管理 |
| ✅ | `api/search.py` | 7 | 搜索、Inquire 对话、收件箱 |
| ✅ | `api/voiceprint.py` | 6 | 声纹注册、管理、同步 |
| ✅ | `api/config.py` | 6 | 系统配置读写、分段服务管理 |
| ✅ | `api/hotword.py` | 2 | 热词管理 |
| ✅ | `api/template.py` | 5 | 转录模板 CRUD |
| ✅ | `api/event.py` | 3 | 事件提取、ICS 导出 |
| ✅ | `api/main.py` | 5 | 主页、摘要生成、对话 |
**架构改进:**
- 创建 `api/__init__.py` 中的 `register_blueprints()` 函数,统一管理所有蓝图注册和初始化
- 使用 `init_xxx_bp()` 延迟注入模式解决循环导入问题limiter、csrf、bcrypt 等全局对象)
- 所有 API 路径保持不变,前端无需任何修改
- `app.py` 仅保留Flask 初始化、配置加载、蓝图注册、全局错误处理、启动逻辑
**重构后修复的问题:**
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 重定向循环 | `main.py` 中的 `index()` 路由误加了 `@login_required` 装饰器,丢失了自动登录逻辑 | 恢复备份中的 token 验证和自动登录逻辑,移除装饰器 |
| WebSocket 连接失败 | `getUserConfig` 路由缺少 `FUNASR_WEBSOCKET_IP``SCREEN_PUSH_IP` 配置 | 在 `main.py``get_user_config()` 中补充 WebSocket 配置字段 |
| 循环导入 bcrypt | `main.py` 在模块级别导入 `bcrypt` 导致循环依赖 | 改为在 `init_main_bp()` 中延迟注入 `bcrypt_instance` |
#### ✅ 已完成:统一配置管理
创建 `src/config.py` 统一配置文件,将所有散落的环境变量读取集中管理:
| 配置域 | 包含变量 |
|--------|---------|
| 基础配置 | `SECRET_KEY`, `UPLOAD_FOLDER`, `LOG_LEVEL`, `MAX_FILE_SIZE_MB` |
| ASR 配置 | `USE_ASR_ENDPOINT`, `ASR_BASE_URL`, `ASR_DIARIZE`, `ASR_MIN_SPEAKERS`, `ASR_MAX_SPEAKERS` |
| LLM 配置 | `TEXT_MODEL_API_KEY`, `TEXT_MODEL_BASE_URL`, `TEXT_MODEL_NAME` |
| 分段配置 | `ENABLE_CHUNKING` |
| Inquire 配置 | `ENABLE_INQUIRE_MODE` |
| 数据库配置 | `SQLALCHEMY_DATABASE_URI`, `DB_POOL_SIZE`, `DB_MAX_OVERFLOW`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE` |
| 外部 API | `TRANSCRIPTION_BASE_URL`, `VALID_TOKEN`, `EMBEDDINGS_AVAILABLE` |
| 代理配置 | `HTTP_PROXY`, `HTTPS_PROXY` |
**重构成果:**
- `clients/llm_client.py``clients/voiceprint_client.py``src.config` 导入配置
- `clients/__init__.py` 统一导出所有配置项
- `app.py` 通过 `from src.clients import ...` 统一导入配置,消除散落的环境变量读取
- 提供 `validate_config()` 函数在启动时验证关键配置
#### ✅ 已完成Phase 3.5 - app.py 清理与同步服务抽取
`src/app.py` 中残留的约 400 行业务逻辑彻底清理app.py 从约 700 行精简到约 234 行:
| 状态 | 任务 | 说明 |
|------|------|------|
| ✅ | 删除重复的 preprocess 函数 | 删除了 `preprocess_long_transcription` 等 6 个重复定义的函数(已在 llm_service.py 中) |
| ✅ | 抽取远程同步服务 | 创建了 `services/sync_service.py`,将约 180 行同步逻辑从 app.py 移出 |
| ✅ | 清理无用 import | 删除了 30+ 个未使用的导入render_template、OpenAI、httpx、subprocess、markdown 等) |
| ✅ | 注释中文化 | 将所有英文注释翻译为中文,删除无意义注释 |
| ✅ | 修复函数签名 | `start_sync_thread()` 增加 `app` 参数,调用处同步更新 |
| ✅ | 优化配置读取 | `preprocess_long_transcription` 等函数直接从 `src.config` 读取配置,不再通过参数传递 |
#### ✅ 已完成Phase 4 - app.py 工厂模式重构
采用 Flask `create_app()` 工厂模式,将 `src/app.py` 从 234 行进一步精简到 60 行,各层职责清晰分离:
**新建模块:**
| 文件 | 职责 |
|------|------|
| `src/logging_config.py` | 日志配置模块 |
| `src/flask_ext.py` | Flask 扩展实例集合login_manager、bcrypt、limiter、csrf |
| `src/services/register.py` | 服务层统一初始化(音频分段、同步线程、文件监控、表结构迁移) |
| `src/api/template_filters.py` | 模板过滤器注册 |
**修改模块:**
| 文件 | 修改内容 |
|------|---------|
| `src/config.py` | 新增 `get_app_config()` 函数,一次性返回所有需写入 `app.config` 的配置项。新增配置只需改此函数,无需修改 `app.py` |
| `src/clients/__init__.py` | 集中管理 `chunking_service``EMBEDDINGS_AVAILABLE` 的初始化与导出 |
| `src/api/__init__.py` | 简化 `register_blueprints()` 函数,直接从 `src.flask_ext` 导入扩展实例 |
| `src/app.py` | 采用 `create_app()` 工厂模式,仅负责编排各层初始化顺序 |
**`app.py` 最终结构60 行):**
```
create_app()
├── 1. configure_logging() ← src/logging_config.py
├── 2. 创建 Flask 应用实例
├── 3. 加载应用配置 ← src/config.py 的 get_app_config()
├── 4. 配置反向代理支持
├── 5. 初始化 Flask 扩展 ← src/flask_ext.py
├── 6. 初始化数据库 ← src/clients/database.py
├── 7. 注册蓝图 ← src/api/__init__.py
├── 8. 注册模板过滤器 ← src/api/template_filters.py
└── 9. 初始化服务层 ← src/services/register.py
```
**关键设计决策:**
| 决策 | 理由 |
|------|------|
| `flask_ext.py` 独立 | Flask 扩展是全局单例,不应放在 clients 或 services 中 |
| `get_app_config()` 集中管理 | 新增配置只需改 config.pyapp.py 永远不需要改动 |
| `register_blueprints` 自行读扩展 | 蓝图注册最清楚自己需要什么扩展,无需 app.py 中转 |
| `init_services` 放在最后 | 服务依赖数据库和蓝图,必须在它们之后初始化 |
---
## 开发规范
### API 接口返回格式规范
> **重要:所有新建/修改的 API 接口必须使用 `ApiResponse` 工具类统一返回格式。旧接口暂不强制迁移。**
#### 工具位置
`src/api/api_response.py`
#### 使用方式
```python
from src.api.api_response import ApiResponse
# 成功响应(返回数据)
@blueprint.route('/xxx', methods=['POST'])
def create_xxx():
result = do_something()
return ApiResponse.success({'xxx': result.to_dict()})
# 成功响应(带提示消息)
return ApiResponse.success({'xxx': result.to_dict()}, '创建成功')
# 成功响应(自定义状态码,如 201 Created
return ApiResponse.success({'xxx': result.to_dict()}, '创建成功', status_code=201)
# 错误响应
if not xxx:
return ApiResponse.error('资源未找到', 404)
# 错误响应(带详情)
if not valid:
return ApiResponse.error('参数校验失败', 400, detail='email 格式不正确')
# 分页响应
return ApiResponse.paginated(
items=[item.to_dict() for item in items],
pagination={
'page': page,
'per_page': per_page,
'total': total,
'total_pages': total_pages,
'has_next': has_next,
'has_prev': has_prev
}
)
```
#### 响应格式约定
**成功响应结构:**
```json
{
"success": true,
"message": "操作成功(可选)",
"xxx": {...} // 业务数据直接合并到顶层
}
```
**分页响应结构:**
```json
{
"success": true,
"items": [...],
"pagination": {
"page": 1,
"total": 100,
...
}
}
```
**错误响应结构:**
```json
{
"error": "用户友好的错误提示"
}
```
#### 注意事项
1. `data` 参数必须是字典类型,列表数据请使用 `paginated()` 或直接合并到字典中
2. 前端通过 `response.ok`HTTP 状态码)判断成功/失败,通过 `data.error` 获取错误信息
3. 旧接口暂不迁移,新接口和修改的接口必须使用 `ApiResponse`
4. 不要在 `ApiResponse` 中直接传递原始异常信息给用户(`str(e)`),应转为友好提示
---
## 技术栈
- **后端**: Python/Flask + SQLAlchemy
- **前端**: Vue.js 3 + Tailwind CSS
- **AI**: OpenAI Whisper (ASR) + Ollama/OpenRouter (LLM)
- **数据库**: SQLite默认/ PostgreSQL支持环境变量切换
- **部署**: Nginx 反向代理 + Flask 开发服务器
---
## 依赖安装
```bash
conda activate speakr
pip install -r requirements.txt
```
主要依赖:
- `flask==2.3.3`
- `flask-sqlalchemy==3.1.1`
- `flask-login==0.6.3`
- `flask-wtf==1.2.2`
- `flask-bcrypt==1.0.1`
- `openai==1.3.0`
- `markdown==3.5.1`
- `python-docx==1.1.0`
---
## 许可证
本项目基于 AGPL v3.0 开源协议。

44
speakr/Readme_deploy.md Normal file
View File

@ -0,0 +1,44 @@
# 离线部署
cd /opt/speakr
# 1. 导入镜像(无需网络)
docker load -i speakr.tar
# 2. 一次性:从镜像里取出前端构建产物和完整 vendor 离线依赖,补进宿主机代码目录
docker create --name speakr-tmp speakr:latest
docker cp speakr-tmp:/app/static/vite ./static/
docker cp speakr-tmp:/app/static/vendor ./static/
docker rm speakr-tmp
# 3. 准备 .env按转写方式二选一然后编辑填入密钥等配置
cp config/env.asr.example .env # 自建 ASR 服务用这个
# cp config/env.whisper.example .env # Whisper API 用这个
cd /home/wjs/speakr/speakr
# 1. 把服务器上这份脚本的 CRLF 转成 LF并确保可执行
sed -i 's/\r$//' scripts/docker-entrypoint.sh
chmod +x scripts/docker-entrypoint.sh
# 2. 删掉一直重启的旧容器
docker rm -f speakr
# 3. 重新启动:多加一条单文件挂载,用修好的脚本覆盖镜像里那份坏的
docker run -d \
--name speakr \
--restart unless-stopped \
-p 5000:8899 \
-v /home/wjs/speakr/speakr:/app \
-v /home/wjs/speakr/speakr/scripts/docker-entrypoint.sh:/usr/local/bin/docker-entrypoint.sh \
-v /home/wjs/speakr/uploads:/data/uploads \
-v /home/wjs/speakr/instance:/data/instance \
speakr:v2
# 4. 确认启动正常(应看到"数据库不存在,正在创建..."之类的日志)
docker logs -f speakr
# 合并压缩
cat speakr.tar.part* > speakr.tar

1
speakr/VERSION Normal file
View File

@ -0,0 +1 @@
v0.5.6

4
speakr/app.py Normal file
View File

@ -0,0 +1,4 @@
from src.app import app
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=5000)

View File

@ -0,0 +1,43 @@
services:
app:
image: learnedmachine/speakr:latest
container_name: speakr
restart: unless-stopped
ports:
- "8899:8899"
# --- Configuration ---
# Environment variables are loaded from the .env file.
#
# To get started:
# 1. Choose your desired transcription method.
# 2. Copy the corresponding example file to .env:
#
# For standard Whisper API:
# cp config/env.whisper.example .env
#
# For a custom ASR endpoint:
# cp config/env.asr.example .env
#
# 3. Edit the .env file to add your API keys and settings.
env_file:
- .env
environment:
# Set log level for troubleshooting
# Use ERROR for production (minimal logs)
# Use INFO for debugging issues (recommended when troubleshooting)
# Use DEBUG for detailed development logging
- LOG_LEVEL=ERROR
# --- Volume Configuration ---
# Choose ONE of the following volume configurations.
# Option 1 (Recommended): Bind mounts to local folders.
volumes:
- ./uploads:/data/uploads
- ./instance:/data/instance
# Option 2: Docker-managed volumes.
# volumes:
# - speakr-uploads:/data/uploads
# - speakr-instance:/data/instance

View File

@ -0,0 +1,74 @@
# -----------------------------------------------------------------------------
# Speakr Configuration: ASR Endpoint
#
# Instructions:
# 1. Copy this file to a new file named .env
# cp env.asr.example .env
# 2. Fill in the required URLs, API keys, and settings below.
# -----------------------------------------------------------------------------
# --- Text Generation Model (for summaries, titles, etc.) ---
TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
TEXT_MODEL_API_KEY=your_openrouter_api_key
TEXT_MODEL_NAME=openai/gpt-4o-mini
# --- Transcription Service (ASR Endpoint) ---
# Setting this to true enables all ASR features including speaker diarization
# Note: File chunking is NOT supported with ASR endpoints
USE_ASR_ENDPOINT=true
# ASR Endpoint URL
# For containers in same docker-compose: Use container name and internal port
# Example: http://whisper-asr:9000 (NOT the host port 6002 or external IP)
# For external ASR: Use http://192.168.1.100:9000 or http://asr.example.com:9000
ASR_BASE_URL=http://whisper-asr:9000
# Optional: Override default speaker settings (defaults shown)
# These are automatically set when USE_ASR_ENDPOINT=true
# ASR_DIARIZE=true # Auto-enabled with ASR (set to false to disable)
# ASR_MIN_SPEAKERS=1 # Default minimum speakers
# ASR_MAX_SPEAKERS=5 # Default maximum speakers
# --- Application Settings ---
# Set to "true" to allow user registration, "false" to disable
ALLOW_REGISTRATION=false
SUMMARY_MAX_TOKENS=8000
CHAT_MAX_TOKENS=5000
# Timezone for displaying dates and times in the UI
# Use a valid TZ database name (e.g., "America/New_York", "Europe/London", "UTC")
TIMEZONE="UTC"
# Set the logging level for the application.
# Options: DEBUG, INFO, WARNING, ERROR
LOG_LEVEL="INFO"
# --- Admin User (created on first run) ---
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme
# --- Inquire Mode (AI search across all recordings) ---
# Set to "true" to enable semantic search and chat across all recordings
# Requires additional dependencies (already included in Docker image)
ENABLE_INQUIRE_MODE=false
# --- Automated File Processing (Black Hole Directory) ---
# Set to "true" to enable automated file processing
ENABLE_AUTO_PROCESSING=false
# Processing mode: admin_only, user_directories, or single_user
AUTO_PROCESS_MODE=admin_only
# Directory to watch for new audio files
AUTO_PROCESS_WATCH_DIR=/data/auto-process
# How often to check for new files (seconds)
AUTO_PROCESS_CHECK_INTERVAL=30
# Default username for single_user mode (only used if AUTO_PROCESS_MODE=single_user)
# AUTO_PROCESS_DEFAULT_USERNAME=admin
# --- Docker Settings (rarely need to be changed) ---
SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
UPLOAD_FOLDER=/data/uploads

View File

@ -0,0 +1,77 @@
# -----------------------------------------------------------------------------
# Speakr Configuration: Standard Whisper API
#
# Instructions:
# 1. Copy this file to a new file named .env
# cp env.whisper.example .env
# 2. Fill in the required API keys and settings below.
# -----------------------------------------------------------------------------
# --- Text Generation Model (for summaries, titles, etc.) ---
TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
TEXT_MODEL_API_KEY=your_openrouter_api_key
TEXT_MODEL_NAME=openai/gpt-4o-mini
# --- Transcription Service (OpenAI Whisper API) ---
TRANSCRIPTION_BASE_URL=https://api.openai.com/v1
TRANSCRIPTION_API_KEY=your_openai_api_key
WHISPER_MODEL=whisper-1
# --- Application Settings ---
# Set to "true" to allow user registration, "false" to disable
ALLOW_REGISTRATION=false
SUMMARY_MAX_TOKENS=8000
CHAT_MAX_TOKENS=5000
# Timezone for displaying dates and times in the UI
# Use a valid TZ database name (e.g., "America/New_York", "Europe/London", "UTC")
TIMEZONE="UTC"
# Set the logging level for the application.
# Options: DEBUG, INFO, WARNING, ERROR
LOG_LEVEL="INFO"
# --- Large File Chunking ---
# Automatically splits large files to work with API limits
ENABLE_CHUNKING=true
# Choose chunking method based on your provider's limits:
# For size-based limits (e.g., OpenAI's 25MB limit):
CHUNK_LIMIT=20MB
# For duration-based limits (uncomment and use instead):
# CHUNK_LIMIT=1200s # 20 minutes
# CHUNK_LIMIT=20m # Alternative format
# Overlap between chunks (seconds)
CHUNK_OVERLAP_SECONDS=3
# --- Admin User (created on first run) ---
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme
# --- Inquire Mode (AI search across all recordings) ---
# Set to "true" to enable semantic search and chat across all recordings
# Requires additional dependencies (already included in Docker image)
ENABLE_INQUIRE_MODE=false
# --- Automated File Processing (Black Hole Directory) ---
# Set to "true" to enable automated file processing
ENABLE_AUTO_PROCESSING=false
# Processing mode: admin_only, user_directories, or single_user
AUTO_PROCESS_MODE=admin_only
# Directory to watch for new audio files
AUTO_PROCESS_WATCH_DIR=/data/auto-process
# How often to check for new files (seconds)
AUTO_PROCESS_CHECK_INTERVAL=30
# Default username for single_user mode (only used if AUTO_PROCESS_MODE=single_user)
# AUTO_PROCESS_DEFAULT_USERNAME=admin
# --- Docker Settings (rarely need to be changed) ---
SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
UPLOAD_FOLDER=/data/uploads

View File

@ -0,0 +1,92 @@
#!/bin/bash
# 停止转录服务
sudo systemctl stop transcription
# 创建应用目录并设置所有者
sudo mkdir -p /opt/transcription-app
sudo chown $USER:$USER /opt/transcription-app
# 复制应用文件
cp app.py /opt/transcription-app/
cp -r templates /opt/transcription-app/
cp requirements.txt /opt/transcription-app/
cp scripts/reset_db.py /opt/transcription-app/
cp scripts/create_admin.py /opt/transcription-app/
cp .env /opt/transcription-app/ # 复制包含 API 密钥的 .env 文件
# 如果 .env 文件中不存在 SECRET_KEY则添加
if ! grep -q "SECRET_KEY" /opt/transcription-app/.env; then
echo "SECRET_KEY=$(openssl rand -hex 32)" >> /opt/transcription-app/.env
echo "已添加 SECRET_KEY 到 .env 文件"
fi
# 创建并激活虚拟环境
python3 -m venv /opt/transcription-app/venv
source /opt/transcription-app/venv/bin/activate
# 安装依赖
cd /opt/transcription-app
pip install -r requirements.txt
# 创建上传和数据库目录并设置权限
mkdir -p /opt/transcription-app/uploads
mkdir -p /opt/transcription-app/instance
chmod 755 /opt/transcription-app/uploads
chmod 755 /opt/transcription-app/instance
# 初始化或迁移数据库
if [ ! -f /opt/transcription-app/instance/transcriptions.db ]; then
# 如果数据库不存在,从头创建
echo "数据库不存在,正在创建新数据库..."
python reset_db.py
else
# 如果数据库存在,迁移模式以保留数据
echo "数据库已存在,正在迁移模式以保留数据..."
python migrate_db.py
fi
# 设置所有文件的正确所有者
sudo chown -R $USER:$USER /opt/transcription-app
# 创建 systemd 服务文件
sudo tee /etc/systemd/system/transcription.service << EOF
[Unit]
Description=Transcription Web Application
After=network.target
[Service]
User=$USER
EnvironmentFile=/opt/transcription-app/.env
WorkingDirectory=/opt/transcription-app
Environment="PATH=/opt/transcription-app/venv/bin"
Environment="PYTHONPATH=/opt/transcription-app"
ExecStart=/opt/transcription-app/venv/bin/gunicorn --workers 3 --threads 8 --bind 0.0.0.0:8899 --timeout 600 app:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
# 重新加载 systemd 并启动服务
sudo systemctl daemon-reload
sudo systemctl restart transcription
sudo systemctl disable transcription
sudo systemctl enable transcription
# 检查服务状态
echo "正在检查服务状态..."
sleep 3
sudo systemctl status transcription
echo "安装完成!应用应在 8899 端口上运行。"
# 询问是否创建管理员账户
read -p "是否现在创建管理员账户?(y/n): " create_admin
if [[ $create_admin == "y" || $create_admin == "Y" ]]; then
echo "正在创建管理员账户..."
python create_admin.py
else
echo "你可以稍后运行以下命令来创建管理员账户python create_admin.py"
fi

31
speakr/docs/.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# Jekyll build output
_site/
.sass-cache/
.jekyll-cache/
.jekyll-metadata
# Bundle directory
.bundle/
vendor/
# Local development files
_config_local.yml
local-serve.sh
docker-serve.sh
simple-serve.sh
serve-local.sh
# OS files
.DS_Store
Thumbs.db
# IDE files
.vscode/
.idea/
# Ruby version files
.ruby-version
.ruby-gemset
# Documentation verification (internal use)
DOCUMENTATION_VERIFICATION_CHECKLIST.md

28
speakr/docs/Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM python:3.11-slim
WORKDIR /app
# Install git (required for git-revision-date plugin)
RUN apt-get update && \
apt-get install -y git && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install requirements
COPY requirements-docs.txt .
RUN pip install --no-cache-dir -r requirements-docs.txt
# Create docs directory structure
RUN mkdir -p docs
# Copy mkdocs config to root
COPY mkdocs.yml .
# Copy all documentation files to docs subdirectory
COPY . ./docs/
# Expose MkDocs development server port
EXPOSE 8000
# Run MkDocs server
CMD ["mkdocs", "serve", "--dev-addr=0.0.0.0:8000"]

View File

@ -0,0 +1,8 @@
source "https://rubygems.org"
gem "jekyll", "~> 4.3"
gem "jekyll-seo-tag"
gem "jekyll-sitemap"
gem "jekyll-feed"
gem "webrick", "~> 1.8"
gem "kramdown-parser-gfm"

View File

@ -0,0 +1,33 @@
<nav class="sidebar">
<div class="sidebar-header">
<img src="{{ '/assets/images/logo.png' | relative_url }}" alt="Speakr Logo" class="sidebar-logo">
<h2 class="sidebar-title">Speakr Docs</h2>
</div>
<div class="sidebar-content">
{% for section in site.navigation %}
<div class="sidebar-section">
<h3 class="sidebar-section-title">{{ section.title }}</h3>
<ul class="sidebar-list">
{% for item in section.items %}
<li class="sidebar-item">
<a href="{{ item.url | relative_url }}"
class="sidebar-link {% if page.url == item.url %}active{% endif %}">
{{ item.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
<div class="sidebar-footer">
<a href="https://github.com/murtaza-nasir/speakr" class="github-link">
<svg class="github-icon" viewBox="0 0 16 16" width="20" height="20">
<path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
</svg>
GitHub
</a>
</div>
</nav>

View File

@ -0,0 +1,403 @@
<!DOCTYPE html>
<html lang="{{ site.lang | default: 'en-US' }}">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% seo %}
<link rel="stylesheet" href="{{ '/assets/css/style.css?v=' | append: site.github.build_revision | relative_url }}">
<link rel="icon" type="image/png" href="{{ '/assets/images/logo.png' | relative_url }}">
<!-- Custom fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #1f2937;
font-size: 15px;
}
.docs-wrapper {
display: flex;
min-height: 100vh;
}
/* Sidebar Styles */
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 280px;
height: 100vh;
background: #f9fafb;
border-right: 1px solid #e5e7eb;
overflow-y: auto;
z-index: 100;
}
.sidebar-header {
padding: 1.5rem;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
text-align: center;
}
.sidebar-logo {
width: 60px;
height: 60px;
border-radius: 12px;
margin-bottom: 0.5rem;
}
.sidebar-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.sidebar-content {
padding: 1rem 0;
}
.sidebar-section {
margin-bottom: 1.5rem;
}
.sidebar-section-title {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
padding: 0 1.5rem;
margin-bottom: 0.5rem;
}
.sidebar-list {
list-style: none;
}
.sidebar-item {
margin: 0;
}
.sidebar-link {
display: block;
padding: 0.5rem 1.5rem;
color: #4b5563;
text-decoration: none;
transition: all 0.2s ease;
font-size: 0.95rem;
}
.sidebar-link:hover {
background: #e5e7eb;
color: #1f2937;
}
.sidebar-link.active {
background: #6366f1;
color: white;
font-weight: 500;
}
.sidebar-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
margin-top: auto;
}
.github-link {
display: flex;
align-items: center;
gap: 0.5rem;
color: #6b7280;
text-decoration: none;
font-size: 0.9rem;
transition: color 0.2s;
}
.github-link:hover {
color: #1f2937;
}
.github-icon {
flex-shrink: 0;
}
/* Main Content Styles */
.main-content {
flex: 1;
margin-left: 280px;
min-width: 0;
}
.content-wrapper {
max-width: 900px;
margin: 0 auto;
padding: 2rem 3rem;
}
/* Content Typography */
.content-wrapper h1 {
color: #1f2937;
font-size: 1.875rem;
font-weight: 700;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #6366f1;
}
.content-wrapper h2 {
color: #1f2937;
font-size: 1.5rem;
font-weight: 600;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
.content-wrapper h3 {
color: #374151;
font-size: 1.25rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.content-wrapper h4 {
color: #4b5563;
font-size: 1.125rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.content-wrapper p {
margin-bottom: 1rem;
line-height: 1.6;
font-size: 0.95rem;
}
.content-wrapper img {
max-width: 100%;
height: auto;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
margin: 2rem 0;
}
.content-wrapper code {
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 4px;
color: #dc2626;
font-size: 0.85em;
font-family: 'Monaco', 'Courier New', monospace;
font-weight: 500;
}
.content-wrapper pre {
background: #2d3748;
color: #e2e8f0;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 1rem 0;
border: 1px solid #4a5568;
}
.content-wrapper pre code {
background: none;
color: #e2e8f0;
padding: 0;
font-weight: normal;
}
/* Override syntax highlighting for better contrast */
.content-wrapper .highlight pre {
background: #1f2937;
}
.content-wrapper .highlight code {
color: #f3f4f6;
}
.content-wrapper ul, .content-wrapper ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.content-wrapper li {
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.content-wrapper blockquote {
border-left: 4px solid #6366f1;
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
color: #6b7280;
}
.content-wrapper table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
.content-wrapper th, .content-wrapper td {
padding: 0.75rem;
text-align: left;
border: 1px solid #e5e7eb;
}
.content-wrapper th {
background: #f9fafb;
font-weight: 600;
}
.content-wrapper a {
color: #4f46e5;
text-decoration: none;
font-weight: 500;
}
.content-wrapper a:hover {
text-decoration: underline;
color: #4338ca;
}
/* Ensure inline code in links is visible */
.content-wrapper a code {
color: inherit;
}
/* Override Jekyll/Rouge syntax highlighting for better contrast */
.highlighter-rouge .highlight {
background: #2d3748 !important;
}
.highlight {
background: #2d3748 !important;
color: #e2e8f0 !important;
}
.highlight pre {
background: #2d3748 !important;
color: #e2e8f0 !important;
}
/* Make all text light by default */
.highlight * {
color: #e2e8f0 !important;
}
/* Then override specific elements */
.highlight .c { color: #718096 !important; } /* Comment */
.highlight .s, .highlight .s1, .highlight .s2 { color: #68d391 !important; } /* String */
.highlight .k { color: #b794f4 !important; } /* Keyword */
.highlight .o { color: #f6ad55 !important; } /* Operator */
.highlight .m { color: #f6ad55 !important; } /* Number */
.highlight .nb { color: #63b3ed !important; } /* Builtin */
.highlight .nv { color: #fc8181 !important; } /* Variable */
/* Ensure operators like -O are visible */
.highlight .p { color: #cbd5e0 !important; } /* Punctuation */
.highlight .nt { color: #63b3ed !important; } /* Tag */
.highlight .na { color: #f6ad55 !important; } /* Attribute */
/* Mobile Responsiveness */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.sidebar.active {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.content-wrapper {
padding: 1.5rem;
}
}
/* Home page special styles */
.home-header {
text-align: center;
padding: 3rem 0;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
margin: -2rem -3rem 2rem -3rem;
border-radius: 12px;
}
.project-logo {
width: 100px;
height: 100px;
margin: 0 auto 1rem;
display: block;
border-radius: 20px;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
}
</style>
</head>
<body>
<div class="docs-wrapper">
{% include sidebar.html %}
<main class="main-content">
<div class="content-wrapper">
{{ content }}
</div>
</main>
</div>
<script>
// Mobile sidebar toggle
document.addEventListener('DOMContentLoaded', function() {
const sidebar = document.querySelector('.sidebar');
const toggleBtn = document.createElement('button');
toggleBtn.innerHTML = '☰';
toggleBtn.style.cssText = 'position:fixed;top:1rem;left:1rem;z-index:200;padding:0.5rem 0.75rem;background:white;border:1px solid #e5e7eb;border-radius:8px;cursor:pointer;display:none;';
if (window.innerWidth <= 768) {
toggleBtn.style.display = 'block';
}
toggleBtn.onclick = function() {
sidebar.classList.toggle('active');
};
document.body.appendChild(toggleBtn);
window.addEventListener('resize', function() {
if (window.innerWidth <= 768) {
toggleBtn.style.display = 'block';
} else {
toggleBtn.style.display = 'none';
sidebar.classList.remove('active');
}
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if page.title %}{{ page.title }} - {% endif %}{{ site.title }}</title>
<meta name="description" content="{% if page.description %}{{ page.description }}{% else %}{{ site.description }}{% endif %}">
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
<link rel="icon" type="image/png" href="{{ '/assets/images/logo.png' | relative_url }}">
</head>
<body>
<div class="docs-wrapper">
{% include sidebar.html %}
<main class="main-content">
<div class="content-wrapper">
{{ content }}
</div>
</main>
</div>
</body>
</html>

View File

@ -0,0 +1,99 @@
# 管理员指南
欢迎使用 Speakr 管理员指南!作为管理员,你可以控制 Speakr 实例的核心,管理用户、监控系统健康状况以及配置 AI 行为。
## 管理控制面板
<div class="guide-cards">
<div class="guide-card">
<div class="card-icon">👥</div>
<h3>用户管理</h3>
<p>创建账户、管理权限、监控使用情况以及控制对 Speakr 实例的访问。</p>
<a href="user-management" class="card-link">管理用户 →</a>
</div>
<div class="guide-card">
<div class="card-icon">📊</div>
<h3>系统统计</h3>
<p>监控系统健康状况、跟踪使用模式,并在问题影响用户之前发现潜在问题。</p>
<a href="statistics" class="card-link">查看统计 →</a>
</div>
<div class="guide-card">
<div class="card-icon">🔧</div>
<h3>系统设置</h3>
<p>配置全局限制、超时、文件大小以及影响所有用户的系统级行为。</p>
<a href="system-settings" class="card-link">配置系统 →</a>
</div>
<div class="guide-card">
<div class="card-icon"></div>
<h3>默认提示词</h3>
<p>通过默认摘要提示词自定义 AI 行为,塑造内容处理方式。</p>
<a href="prompts" class="card-link">设置提示词 →</a>
</div>
<div class="guide-card">
<div class="card-icon">🔍</div>
<h3>向量存储</h3>
<p>管理语义搜索功能、监控嵌入状态以及控制 Inquire 模式。</p>
<a href="vector-store" class="card-link">管理搜索 →</a>
</div>
</div>
## 快速操作
<div class="action-cards">
<div class="action-card">
<span class="action-icon"></span>
<div>
<strong>添加新用户</strong>
<p>用户管理 → 添加用户按钮 → 输入详细信息 → 设置权限</p>
</div>
</div>
<div class="action-card">
<span class="action-icon">📈</span>
<div>
<strong>检查系统健康状况</strong>
<p>系统统计 → 查看指标 → 检查处理状态 → 监控存储</p>
</div>
</div>
<div class="action-card">
<span class="action-icon">⚙️</span>
<div>
<strong>更新设置</strong>
<p>系统设置 → 调整限制 → 配置超时 → 保存更改</p>
</div>
</div>
<div class="action-card">
<span class="action-icon">🔄</span>
<div>
<strong>处理嵌入</strong>
<p>向量存储 → 检查状态 → 处理待处理项 → 监控进度</p>
</div>
</div>
</div>
## 需要管理员帮助?
<div class="help-section">
<div class="help-item">
<span class="help-icon">📖</span>
<span>查看详细的<a href="../troubleshooting.md">故障排除指南</a></span>
</div>
<div class="help-item">
<span class="help-icon">🐛</span>
<span>检查 Docker 日志:<code>docker-compose logs -f app</code></span>
</div>
<div class="help-item">
<span class="help-icon">💾</span>
<span>定期备份你的数据目录</span>
</div>
</div>
---
准备好管理你的 Speakr 实例了吗?从[用户管理](user-management.md)开始 →

View File

@ -0,0 +1,91 @@
# 默认提示词
默认提示词选项卡允许你塑造 AI 如何在你的整个 Speakr 实例中[解释和总结录音](../features.md#automatic-summarization)。在这里,你可以建立用户在没有自定义[个人提示词](../user-guide/settings.md#custom-prompts-tab)时体验的基线智能。
![默认提示词](../assets/images/screenshots/Admin default prompts.png)
## 理解提示词层级
Speakr 使用复杂的层级来确定在任何给定录音中使用哪个提示词。该系统在保持控制的同时提供灵活性,确保用户获得适当的摘要,同时在需要时允许自定义。
层级的最顶层是标签特定的提示词。当录音具有[标签](../user-guide/settings.md#tag-management-tab)且关联了提示词时,这些具有绝对优先级。在功能指南中了解[标签管理](../features.md#tagging-system)。多个标签提示词会智能地拼接,允许为专业内容类型进行复杂的提示词堆叠。
接下来是用户的个人自定义提示词,在其[账户设置](../user-guide/settings.md#custom-prompts-tab)中设置。用户还可以为摘要配置[语言偏好](../user-guide/settings.md#language-preferences)。这允许个人在不影响其他人的情况下根据自己的需求定制摘要。许多用户从不设置这个,因此你的管理员默认值更加重要。
你在此页面上配置的管理员默认提示词是大多数摘要的基础。这是新用户体验的内容,也是未自定义设置的长期用户所依赖的内容。它决定了你的 Speakr 实例的整体智能和实用性。
最后,如果所有其他方法都失败,硬编码的系统回退确保始终生成摘要。你在实践中很少看到这个,但它提供了一个安全网,确保系统永远不会无法生成输出。
## 设计有效的默认提示词
你的默认提示词不仅仅是技术指令——它是理解内容的模板。界面中显示的提示词演示了一种平衡的方法,要求关键问题、决策和待办事项。这种结构适用于商务会议,但可能不适合所有场景。
在设计提示词时考虑你的用户群体。研究机构可能会强调方法和发现。律师事务所可能会关注案件细节和判例。对于多语言支持,请参阅[语言配置](../features.md#language-support)和[语言问题故障排除](../troubleshooting.md#summary-language-doesnt-match-preference)。创意机构可能会突出概念和客户反馈。提示词应反映对用户最重要的内容。
AI 对清晰、结构化的请求响应最佳。使用项目符号或编号部分来组织输出。指定你想要的详细程度——"简要概述"与"全面分析"会产生截然不同的结果。如果某些格式至关重要,请包含示例。
请记住,此提示词适用于从五分钟检查到两小时研讨会的所有内容。设计时要考虑多样性。避免对可能不适用于所有内容的过于具体的要求。专注于提取具有普遍价值的信息,同时允许 AI 灵活地适应不同的录音类型。
## 默认提示词编辑器
大型文本区域显示你的当前默认提示词,完全支持 markdown 格式。你可以使用粗体进行强调、列表用于结构化,甚至可以使用代码块(如果需要显示示例格式)。编辑器会扩展以适应更长的提示词,尽管简洁通常会产生更好的结果。
当你点击"保存更改"按钮时,更改会立即保存。没有草稿或暂存——修改会立即影响所有新摘要。用户可以[重新生成摘要](../user-guide/transcripts.md)以将更新的提示词应用于现有录音。"重置为默认"按钮提供了安全网,如果你的自定义效果不如预期,可以恢复到原始提示词。
时间戳显示提示词的最后修改时间,有助于跟踪随时间的更改。如果多个管理员管理你的实例,这有助于协调谁更改了什么以及何时更改的。
## 理解 LLM 提示词结构
可展开的"查看完整 LLM 提示词结构"部分揭示了你的提示词如何融入发送给 AI 的完整指令。这个技术视图显示了系统提示词、你的自定义提示词以及转录文本的集成。
理解这个结构有助于你编写更好的提示词。你会看到某些指令已经由系统提示词处理,因此你不需要重复它们。你会理解你的提示词如何与转录文本交互,以及为什么某些措辞比其他措辞更有效。
这种透明性也有助于故障排除。如果摘要不符合预期,查看完整提示词结构通常会揭示原因。也许你的指令与系统指令冲突,或者你可能在请求转录文本中通常不存在的信息。
## 实用提示词策略
从经过验证的结构开始,并根据结果进行迭代。默认提示词之所以有效,是因为它请求具体、可操作的信息。关键问题提供上下文,决策记录结果,待办事项推动跟进。
在广泛部署之前,使用各种录音类型测试你的提示词。适用于正式演示的提示词可能在随意对话中失效。上传具有不同特征的测试录音并评估生成的摘要。
考虑季节性或基于项目的调整。在规划季节,你可能会强调目标和策略。在执行阶段,专注于进展和阻碍因素。随着组织需求的发展,你可以更新默认提示词。
监控用户关于摘要质量的反馈。如果用户经常编辑摘要或抱怨缺少信息,你的提示词可能需要调整。最好的提示词是用户很少需要修改的提示词。
## 高级提示词技巧
分层指令以获得细致的输出。不要只请求"待办事项",而是指定"带责任方和提及的截止日期的待办事项"。这种精度有助于 AI 在可用时提取更有价值的信息。
使用条件语言增加灵活性。诸如"如果适用"或"当讨论时"之类的短语允许 AI 跳过不适用于每个录音的部分。这可以防止摘要中出现无关的内容。
考虑 AI 模型的优势和局限性。当前模型擅长识别主题、提取具体信息和组织内容。它们在复杂推理、数学计算和未明确陈述的信息方面存在困难。设计发挥这些优势的提示词。
在细节与可读性之间取得平衡。极其详细的提示词可能会生成用户不阅读的综合性摘要。有时简洁、聚焦的摘要比详尽的文档更好地为用户服务。
## 与用户提示词协调
你的默认提示词应补充而非与用户自定义竞争。将其设计为适用于大多数情况的坚实基础,同时鼓励高级用户根据自己的特定需求进行自定义。
向用户传达你的提示词策略。让他们知道默认提示词强调什么,以便他们可以决定自定义是否对他们有利。分享基于你默认值构建的有效用户提示词示例。
考虑为用户记录提示词最佳实践。如果某些部门需要专业摘要,提供他们可以使用的推荐提示词。这赋能了用户,同时在重要的地方保持一致性。
## 衡量提示词有效性
跟踪用户修改 AI 生成摘要的频率。频繁编辑表明你的提示词没有捕获用户需要的内容。最少的编辑表明你的提示词有效地提取了有价值的信息。
定期审查摘要样本。它们是否始终包含请求的部分?信息是否准确且相关?用户是否添加了提示词应该请求的类似信息?
在用户审查或支持交互期间收集反馈。具体询问摘要质量以及默认格式是否满足他们的需求。不自定义提示词的用户完全依赖你的默认值,因此他们的反馈至关重要。
## 常见提示词陷阱
避免过于死板的提示词,将结构强加于不兼容的内容上。并非每个录音都有"决策"或"待办事项"。强制 AI 在不存在时找到这些内容会产生无意义的填充。
不要请求 AI 无法提供的信息。要求"未说出的担忧"或"未讨论的内容"超出了转录分析的范围。AI 只能处理实际说出和记录的内容。
抵制使提示词过长的诱惑。每个指令都会增加复杂性和潜在的混淆。专注于最重要的内容,而不是试图捕获每个可能的细节。
---
下一步:[向量存储](vector-store.md) →

View File

@ -0,0 +1,39 @@
# 系统统计
系统统计选项卡将原始数据转化为关于你的 Speakr 实例的可操作洞察。一目了然,你可以看到你正在服务多少用户、他们创建了多少录音、他们消耗了多少存储,以及一切是否处理顺畅。
![系统统计](../assets/images/screenshots/Admin stats.png)
## 关键指标概览
统计页面顶部的四个醒目卡片让你立即了解系统的规模。总用户数显示你当前的用户基础规模,帮助你了解实例的覆盖范围。总录音数揭示了系统中的累积内容,而总存储显示了实际消耗的磁盘空间。当启用 Inquire 模式时,总查询数表明用户搜索录音的活跃程度。
这些数字讲述了你的实例的健康状况和增长的故事。不断增长的用户数与按比例增长的录音数表明健康的采用率。存储增长速度超过录音数增长可能表明用户正在上传更长的文件。查询数揭示了用户是否从语义搜索功能中获得价值。
## 录音状态分布
状态分布部分将你的录音分解为四种关键状态。已完成的录音已完全处理并准备好使用——这应该是你内容的绝大多数。处理中的录音当前正在进行转录或分析。待处理的录音已排队等待处理。失败的录音遇到错误需要关注。
在健康的系统中你会看到大部分是已完成的录音可能随时只有少数在处理中。大量待处理的录音可能表明你的系统不堪重负或后台处理已停止。失败的录音始终值得调查——它们可能会揭示配置问题、API 问题或用户尝试上传的损坏文件。
## 存储分析
"按存储排名的顶级用户"部分揭示了谁在你的系统中消耗了最多的资源。每个用户都列出了他们的总存储消耗和录音数量,让你了解他们是拥有许多小文件还是较少的大文件。
此信息对于容量规划和用户教育非常有价值。如果一个用户消耗了不成比例的存储,你可能需要更好地了解他们的使用场景。他们是在录制数小时的会议吗?永远保留所有内容?理解数字背后的原因有助于你做出更好的策略决策。
## 理解使用模式
统计不仅仅是数字——它们是等待发现的洞察。录音的突然激增可能与项目启动、学术学期或公司计划相吻合。超过录音增长的存储增长可能表明用户正在上传更长的内容或更高质量的音频文件。
定期监控有助于你在趋势成为问题之前发现它们。如果存储每月增长 10%,你可以预测何时需要扩展容量。如果失败的录音突然激增,你可以调查是否是 API 密钥过期或服务宕机。
## 容量规划
系统统计是你的基础设施需求的水晶球。存储增长趋势告诉你何时需要更多磁盘空间。用户增长模式表明何时可能需要扩展服务器资源。处理队列揭示了当前设置是否能够处理工作负载。
主动使用这些洞察。如果你看到存储以每月 50GB 的速度增长且你有 200GB 可用空间,你知道你在需要干预之前还有大约四个月的时间。这个提前期让你可以预算升级、规划迁移或在达到临界限制之前实施保留策略。
---
下一步:[系统设置](system-settings.md) →

View File

@ -0,0 +1,57 @@
# 系统设置
系统设置是你配置影响 Speakr 实例中每个用户和录音的基本行为的地方。这些全局参数塑造系统的运行方式,从技术限制到面向用户的功能。
![系统设置](../assets/images/screenshots/Admin system settings.png)
## 转录文本长度限制
转录文本长度限制决定了在生成摘要或响应聊天时有多少文本会被发送给 AI。这个看似简单的数字对质量和成本都有很大影响。
当设置为"无限制"时,无论长度如何,整个转录文本都会发送给 AI。这确保了 AI 拥有完整的上下文,但对于长录音来说可能会变得昂贵。两小时的会议可能会生成 20,000 字的转录文本,消耗大量 API 令牌并可能超出 AI 模型的上下文窗口。此限制也将应用于说话人识别模态中的说话人自动检测功能。
设置字符限制(如 50,000 个字符)会为 API 消耗设置上限。系统将截断非常长的转录文本,仅将开头部分发送给 AI。这使成本可预测但可能意味着 AI 会错过录音后面部分的重要内容。
最佳点取决于你的使用场景。对于典型的一小时以内的会议50,000 个字符通常可以捕获所有内容。对于更长的会话,你可以增加此限制或培训用户拆分录音。监控你的 API 成本和用户反馈以找到正确的平衡。
## 最大文件大小
文件大小限制保护你的系统免受大量上传的压倒性影响,同时确保用户可以使用合理的录音。默认的 300MB 可容纳数小时的压缩音频,这涵盖了大多数使用场景。
提高此限制允许更长的录音,但需要仔细考虑。较大的文件需要更长的上传时间、消耗更多存储,并且可能在处理期间超时。你的服务器需要足够的内存来处理这些文件,并且你的存储必须能够容纳它们。网络超时、浏览器限制和用户耐心都影响着什么是实际的。
如果用户经常触及限制,请考虑他们是否真的需要这么长的单个录音。通常,将长会话拆分为逻辑片段会产生更好的结果——更容易查看、处理更快、摘要更聚焦。
## ASR 超时设置
ASR 超时决定了 Speakr 将等待高级转录服务完成工作的时间。默认的 1,800 秒30 分钟)可处理大多数录音,但你可能需要根据你的转录服务和典型文件大小进行调整。
设置过低会导致较长的录音即使转录服务正常工作也会失败。录音似乎卡在处理中,然后最终失败,令必须重试或放弃的用户感到沮丧。设置过高会使系统资源等待可能已经失败的服务。
最佳超时取决于你的转录服务的性能和用户的录音长度。监控成功转录的处理时间,并将超时设置为舒适地高于你最长的正常处理时间。如果你经常处理数小时的录音,你可能需要 3,600 秒或更多。
## 录音免责声明
录音免责声明会在用户开始任何录音会话之前出现,非常适合法律通知、策略提醒或使用指南。这个 markdown 格式的消息确保用户在创建内容之前了解他们的责任。
组织通常将其用于合规要求——提醒用户关于同意要求、数据处理策略或适当使用指南。教育机构可能会指出录音仅用于学术目的。医疗机构可能会参考 HIPAA 合规要求。
保持免责声明简洁且相关。用户会频繁看到此消息,因此冗长的法律文本会变成被忽视的点击-through。专注于最重要的要点并在需要时链接到详细策略。markdown 支持让你可以使用粗体文本进行强调或链接到其他资源来清晰地格式化消息。
## 系统级影响
此页面上的每个设置都会立即影响所有用户。更改在你保存后立即生效,无需系统重启或用户注销。这种即时应用意味着你应该仔细测试更改并将重大修改传达给你的用户。
刷新按钮从数据库重新加载设置,如果多个管理员可能在进行更改或你想确保你看到的是最新值,这将非常有用。界面显示每个设置的最后更新时间,帮助你跟踪随时间的更改。
## 常见问题故障排除
当录音持续失败时,请检查它们是否触及了你配置的限制。错误日志会指示文件是否过大或处理是否超时。用户可能没有意识到他们的录音超出了限制,尤其是如果他们上传现有内容而不是直接录音。
如果 API 成本意外飙升,请检查你的转录文本长度限制。如果未设置限制,单个用户上传许多长录音可能会大幅增加消耗。用户活动与系统设置的组合决定了你的实际成本。
处理积压可能表明你的超时设置过高。如果系统每次失败的转录尝试等待 30 分钟,一系列有问题的文件可能会阻塞队列数小时。在对慢速处理的耐心与服务实际宕机时快速失败之间取得平衡。
---
下一步:[默认提示词](prompts.md) →

View File

@ -0,0 +1,47 @@
# 用户管理
用户管理选项卡是控制谁可以访问你的 Speakr 实例以及他们可以用它做什么的指挥中心。每个用户账户都从这里流转,从初始创建到最终删除,以及沿途所需的所有监控和调整。有关系统级使用模式,请参阅[系统统计](statistics.md)。
![用户管理](../assets/images/screenshots/Admin dashboard.png)
## 理解用户表格
当你打开用户管理时,你会看到一个显示系统中每个用户的综合表格。每一行都讲述了一个故事——电子邮件地址标识用户,管理员徽章显示他们的权限级别,录音数量揭示他们的活跃程度,存储度量展示他们的资源消耗。
顶部的搜索栏在你输入时会立即响应,过滤表格以帮助你快速找到特定用户。随着你的用户基础增长到超出单个屏幕可容纳的范围,这变得非常宝贵。当你在进行更改时,表格会实时更新,因此你始终看到系统的当前状态。
## 添加新用户
创建用户账户从点击右上角的"添加用户"按钮开始。出现的模态框会询问基本信息——用户名、电子邮件地址和密码。你还需要立即决定此人是否需要管理员权限,尽管你以后可以随时更改。
用户名成为他们在 Speakr 中的身份,出现在界面中并组织他们的录音。电子邮件地址具有双重用途——它是他们的登录凭证以及任何系统通信的联系方式。你设置的密码是临时的;用户应在首次登录后立即通过其[账户设置](../user-guide/settings.md)更改它。配置初始[语言偏好](../user-guide/settings.md#language-preferences)和[自定义提示词](../user-guide/settings.md#custom-prompts-tab)。
管理员权限很强大,应谨慎授予。管理员用户可以看到和修改所有[系统设置](system-settings.md)、管理其他用户(包括其他管理员)、配置[默认提示词](prompts.md)以及监控[向量存储](vector-store.md)。大多数用户永远不需要这些功能。
## 管理现有用户
每个用户行都包含操作按钮,让你对该账户拥有完全控制权。编辑按钮会打开一个模态框,你可以在其中更新他们的用户名或电子邮件地址。当人们更改姓名、切换电子邮件提供商或需要纠正初始输入错误时,这非常有用。
管理员切换可能是界面中最强大的单次点击。将用户提升为管理员授予他们可以访问和执行的所有权限。将管理员降级为普通用户会立即撤销他们的所有管理功能。系统会阻止你从自己的账户中删除管理员权限,确保你不会意外将自己锁定。
删除按钮需要仔细考虑。删除用户是永久性的,无法通过界面撤销。他们的所有录音、笔记和设置将与其账户一起删除。系统会请求确认,但一旦确认,删除立即且彻底。
## 监控使用模式
录音数量和存储列揭示了用户如何与你的 Speakr 实例交互。高录音数量可能表明严重依赖系统的重度用户,而低数量可能表明需要培训或可能根本不需要账户的用户。
存储消耗讲述了另一个重要故事。存储消耗过高的用户可能正在上传非常长的录音、无限期保留所有内容,或者可能滥用系统。你可以根据需要调整[文件大小限制](system-settings.md#maximum-file-size)并审查[分块设置](../troubleshooting.md#files-over-25mb-fail-with-openai)。此信息帮助你进行有关资源使用的知情对话并制定适当的策略。
当你定期审查这些数据时,通常会出现模式。你可能会注意到学术环境中的季节性变化、企业环境中基于项目的激增,或表明需要基础设施扩展的逐渐增长。
## 安全影响
每个用户账户都是潜在的安全向量。强密码是你的第一道防线,但仅靠它们是不够的。鼓励用户使用唯一密码、定期更改密码,并且永远不要与他人共享密码。
管理员账户需要额外的警惕。每个管理员都可以执行你可以做的所有操作,包括创建更多管理员或删除所有用户。将管理员访问限制在操作所需的绝对最小值。当某人不再需要管理员权限时,立即撤销它们。
不活动的账户构成特殊风险。它们可能拥有无人监控的弱密码或已泄露的密码。定期审核有助于你在这些漏洞成为问题之前识别并消除它们。
---
下一步:[系统统计](statistics.md) →

View File

@ -0,0 +1,71 @@
# 向量存储管理
向量存储选项卡控制 Inquire 模式背后的智能,这是 Speakr 的语义搜索功能,允许用户使用自然语言问题在其所有录音中查找信息。在这里,你可以监控和管理将转录文本转换为可搜索知识的嵌入系统。
![向量存储管理](../assets/images/screenshots/Admin vector store.png)
## 理解 Inquire 模式
在深入管理之前了解你在管理什么是有价值的。Inquire 模式将每个转录文本分解为重叠的文本块,将这些块转换为称为嵌入的数学表示,并以可搜索的格式存储它们。当用户提问时,他们的查询会被转换为相同的数学格式,并与所有存储的块进行比较,以找到最相关的信息。
这种方法超越了简单的关键字匹配。系统理解"预算担忧"与"财务约束"和"成本超支"相关,即使确切的词语不同。这种语义理解使 Inquire 模式在发现用户可能无法准确记住的信息时非常强大。
## 嵌入模型
你的 Speakr 实例使用 all-MiniLM-L6-v2 模型,在界面中显著显示。该模型生成 384 维向量——想象每个文本块映射到 384 维空间中的一个点,相似含义聚集在一起。
这个特定模型是经过精心选择的。存在具有更好准确性的更大模型,但它们需要 GPU 和大量计算资源。MiniLM 模型在仅 CPU 的系统上高效运行,使高级搜索无需昂贵的基础设施即可实现。它处理文本速度快、上下文理解好,并生成不会压倒你存储的紧凑嵌入。
该模型最适合英文文本,因为这是其训练数据的主要部分。其他语言可能会以不同的成功率运行,但英文内容始终会产生最可靠的结果。这个限制是模型效率和可访问性的权衡。
## 处理状态概览
状态卡片让你立即了解向量存储的健康状况。总录音数显示你的系统中有多少音频文件,而为 Inquire 处理的数量显示有多少已转换为可搜索的嵌入。这些数字最终应该匹配,尽管随着后台处理赶上通常会存在滞后。
需要处理显示等待嵌入生成的录音。当用户上传新内容时这个数字会增加,随着后台处理器处理队列而减少。持续高数字可能表明处理已停滞或你的系统不堪重负。
总块数显示你的录音已被分解为的细粒度片段。根据转录密度,典型的一小时录音可能会生成 50-60 个块。这种分块确保即使在非常长的录音中也能找到相关片段。
嵌入状态指示器提供快速健康检查。绿色的"可用"表示一切正常运行。其他状态可能表明模型正在加载、处理正在运行或需要注意。
## 处理进度
处理进度条显示通过嵌入队列的实时进展。当达到 100% 时,所有录音都已处理并可搜索。较低的百分比表示正在进行的工作,随着录音完成条形图会填充。
这种视觉反馈帮助你一目了然地了解系统状态。卡住的进度条表明处理已停止。缓慢的进度可能表明系统资源受限。快速的进度显示一切高效运行。
## 管理处理队列
刷新状态按钮更新所有统计信息和进度指示器,用于监控活动处理或验证最近上传的内容已排队。界面不会自动刷新,因此手动刷新确保你看到的是当前信息。
当系统显示录音需要处理但进度没有推进时,可能有几个因素在起作用。后台处理器可能已停止、嵌入模型可能加载失败,或者系统资源可能已耗尽。检查你的日志以获取具体的错误消息。
处理系统被设计为具有弹性。如果特定录音的处理失败,系统会标记它并继续而不是卡住。这些失败会出现在你的日志中,可能需要手动干预来解决。
## 优化性能
处理性能在很大程度上取决于你的系统资源。嵌入模型在加载时需要大约 500MB 的 RAM再加上处理文本的额外内存。CPU 速度直接影响嵌入生成的速度——现代多核处理器可以同时处理多个录音。
磁盘 I/O 也很重要。系统读取转录文本、处理它们并将嵌入写回数据库。快速存储,特别是 SSD可以显著提高处理吞吐量。如果你的向量存储与转录文本在不同的磁盘上请确保两者都有足够的性能。
网络延迟不应该影响处理,因为一切都在本地发生,但数据库性能很重要。定期数据库维护,包括索引优化和清理操作,即使你的向量存储增长也能保持查询快速。
## 常见问题故障排除
当 Inquire 模式在处理过的录音下返回较差的结果时,问题可能是查询表达而非向量存储。鼓励用户问完整的问题而不是输入关键字。"John 关于预算说了什么?"比仅仅"John 预算"效果更好。
如果处理似乎冻结了,请检查 sentence-transformers 库是否正确安装。没有它系统会优雅降级,禁用 Inquire 模式而不是崩溃,但处理不会推进。你的日志会显示嵌入模型是否成功加载。
处理期间的内存错误通常表明你的系统试图同时处理太多内容。分块系统防止单个录音压倒内存,但并行处理多个大录音可能会超出可用 RAM。
## 扩展考虑
向量存储随着你的内容可预测地增长。每个块需要大约 2KB 的存储用于其嵌入和元数据。生成 50 个块的典型一小时录音需要大约 100KB 的嵌入存储。一万小时的录音可能需要 100MB 的嵌入存储——即使在适度的系统上也是可管理的。
得益于高效索引,即使使用大型向量存储,搜索性能仍然保持快速。然而,极其大型的实例(数十万条录音)可能受益于专用向量数据库解决方案,而不是内置的 SQLite 存储。
如果你的实例增长到超出舒适的限制,请考虑归档旧录音。向量存储仅包含活动录音,因此删除过时内容可以同时改善存储和搜索性能。
---
返回[管理员指南概览](index.md) →

View File

@ -0,0 +1,347 @@
---
---
@import "{{ site.theme }}";
// Custom colors
:root {
--primary-color: #6366f1;
--secondary-color: #8b5cf6;
--accent-color: #ec4899;
--success-color: #10b981;
--warning-color: #f59e0b;
--danger-color: #ef4444;
--dark-bg: #1f2937;
--light-bg: #f9fafb;
}
// Override theme colors
.page-header {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
padding: 3rem 1rem;
}
.site-footer {
background: var(--dark-bg);
color: #e5e7eb;
padding: 2rem 1rem;
margin-top: 3rem;
}
// Logo in header
.project-logo {
width: 60px;
height: 60px;
margin-bottom: 1rem;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.project-name {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
// Navigation cards
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin: 2rem 0;
}
.card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
border-color: var(--primary-color);
}
.card h3 {
color: var(--primary-color);
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 1.25rem;
}
.card p {
color: #6b7280;
margin-bottom: 1rem;
line-height: 1.6;
}
.card .btn {
display: inline-block;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: white;
padding: 0.5rem 1.25rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: opacity 0.3s ease;
}
.card .btn:hover {
opacity: 0.9;
}
// Feature badges
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
margin: 0 0.25rem;
}
.badge-new {
background: var(--success-color);
color: white;
}
.badge-beta {
background: var(--warning-color);
color: white;
}
// Screenshots
img[src*="screenshots"] {
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin: 1.5rem 0;
max-width: 100%;
height: auto;
}
// Code blocks
pre {
background: #1f2937;
color: #e5e7eb;
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
margin: 1rem 0;
border: 1px solid #374151;
}
code {
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875em;
color: var(--primary-color);
}
pre code {
background: none;
padding: 0;
color: inherit;
}
// Tables
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
margin: 1.5rem 0;
}
th {
background: var(--light-bg);
padding: 0.75rem;
text-align: left;
font-weight: 600;
color: #374151;
border-bottom: 2px solid #e5e7eb;
}
td {
padding: 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
tr:last-child td {
border-bottom: none;
}
tr:hover {
background: #f9fafb;
}
// Alerts and callouts
.note, .warning, .tip {
padding: 1rem 1.25rem;
border-radius: 8px;
margin: 1.5rem 0;
border-left: 4px solid;
}
.note {
background: #eff6ff;
border-color: var(--primary-color);
color: #1e40af;
}
.warning {
background: #fef3c7;
border-color: var(--warning-color);
color: #92400e;
}
.tip {
background: #d1fae5;
border-color: var(--success-color);
color: #065f46;
}
// Sidebar navigation
.sidebar {
background: white;
border-radius: 12px;
padding: 1.5rem;
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.sidebar h3 {
color: var(--primary-color);
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.125rem;
}
.sidebar ul {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar li {
margin-bottom: 0.5rem;
}
.sidebar a {
color: #6b7280;
text-decoration: none;
display: block;
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition: all 0.2s ease;
}
.sidebar a:hover {
background: var(--light-bg);
color: var(--primary-color);
}
.sidebar a.active {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: white;
}
// Feature list
.feature-list {
list-style: none;
padding: 0;
}
.feature-list li {
padding: 0.75rem 0;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: flex-start;
}
.feature-list li:last-child {
border-bottom: none;
}
.feature-list li:before {
content: "";
color: var(--success-color);
font-weight: bold;
margin-right: 0.75rem;
font-size: 1.25rem;
}
// Responsive design
@media (max-width: 768px) {
.grid {
grid-template-columns: 1fr;
}
.page-header {
padding: 2rem 1rem;
}
.project-name {
font-size: 1.75rem;
}
}
// Smooth scrolling
html {
scroll-behavior: smooth;
}
// Better typography
.main-content {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.7;
color: #374151;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.3;
margin-top: 2rem;
margin-bottom: 1rem;
}
h1 {
font-size: 2.5rem;
color: var(--primary-color);
}
h2 {
font-size: 2rem;
color: #1f2937;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 0.5rem;
}
h3 {
font-size: 1.5rem;
color: #374151;
}
// Animations
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.main-content > * {
animation: fadeIn 0.5s ease-out;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

View File

@ -0,0 +1,89 @@
# Speakr CI/CD 设置
本文档解释了 Speakr 项目的持续集成和持续部署CI/CD流水线是如何设置的。
## 自动 Docker 镜像构建与发布
Speakr 项目使用 GitHub Actions 在代码推送到仓库时自动构建 Docker 镜像并发布到 Docker Hub。
### 工作原理
1. 当代码推送到 `master` 分支或创建新标签(格式为 `v*.*.*`GitHub Actions 工作流将被触发。
2. 工作流使用项目的 Dockerfile 构建 Docker 镜像。
3. 如果触发原因是推送到 `master` 分支,镜像将被标记为:
- `latest`
- 提交的短 SHA 值
- 分支名称(`master`
4. 如果触发原因是新标签(例如 `v1.2.3`),镜像将被标记为:
- 完整版本号(`1.2.3`
- 主.次版本号(`1.2`
- 标签名称(`v1.2.3`
5. 然后镜像会被推送到你账户下的 Docker Hub。
### 设置步骤
要设置自动 Docker 镜像发布,请按照以下步骤操作:
1. **创建 Docker Hub 账户**(如果你还没有的话):
- 访问 [Docker Hub](https://hub.docker.com/) 并注册账户。
- 创建一个名为 `speakr` 的仓库。
2. **创建 Docker Hub 访问令牌**
- 登录 Docker Hub。
- 点击右上角的用户名,选择"Account Settings"进入账户设置。
- 导航到"Security"选项卡。
- 点击"New Access Token"。
- 给令牌取个名字(例如"GitHub Actions")并设置适当的权限(至少需要"Read & Write")。
- 复制生成的令牌(你将无法再次看到它)。
3. **将 Docker Hub 凭据添加到 GitHub Secrets**
- 进入你的 GitHub 仓库。
- 点击"Settings" > "Secrets and variables" > "Actions"。
- 点击"New repository secret"。
- 添加以下密钥:
- 名称:`DOCKERHUB_USERNAME`,值:你的 Docker Hub 用户名
- 名称:`DOCKERHUB_TOKEN`,值:你在上一步生成的访问令牌
4. **验证工作流**
- 工作流文件位于 `.github/workflows/docker-publish.yml`
- 它已经配置好可以构建并推送 Docker 镜像到 Docker Hub。
- 如果需要,你可以进一步自定义它。
5. **触发工作流**
- 推送更改到 `master` 分支或创建新标签来触发工作流。
- 进入 GitHub 仓库的"Actions"选项卡来监控工作流进度。
### 使用已发布的 Docker 镜像
镜像发布到 Docker Hub 后,你可以在 docker-compose.yml 文件中使用它们:
```yaml
version: '3.8'
services:
app:
image: learnedmachine/speakr:latest
container_name: speakr
restart: unless-stopped
ports:
- "8899:8899"
volumes:
- ./uploads:/data/uploads
- ./instance:/data/instance
env_file:
- .env
```
### 版本控制策略
要创建新版本的应用程序:
1. 进行更改并提交到仓库。
2. 按照语义化版本控制创建新标签:
```bash
git tag v1.0.0 # 替换为适当的版本号
git push origin v1.0.0
```
3. GitHub Actions 工作流将自动构建并发布带有相应版本标签的新 Docker 镜像。
这样用户可以选择使用最新版本(`learnedmachine/speakr:latest`)或特定版本(`learnedmachine/speakr:1.0.0`)。

View File

@ -0,0 +1,67 @@
import os
from pathlib import Path
def create_markdown_doc(base_dir):
output = []
# Add header
output.append("# Project Files\n")
output.append("Generated documentation of all project files.\n")
# Function to read and format file content
def add_file_content(filepath, relative_path):
output.append(f"\n## {relative_path}\n")
output.append("```" + get_file_extension(filepath) + "\n")
try:
with open(filepath, 'r', encoding='utf-8') as f:
output.append(f.read())
except Exception as e:
output.append(f"Error reading file: {e}")
output.append("```\n")
def get_file_extension(filepath):
ext = os.path.splitext(filepath)[1][1:].lower()
# Map file extensions to markdown code block languages
extension_map = {
'py': 'python',
'html': 'html',
'js': 'javascript',
'css': 'css',
'sh': 'bash',
'md': 'markdown',
'txt': 'text'
}
return extension_map.get(ext, '')
# List of important file patterns to include
patterns = [
'*.py',
'*.html',
'*.js',
'*.css',
'*.sh',
'requirements.txt'
]
# Walk through directory and add files
for root, _, _ in os.walk(base_dir):
for pattern in patterns:
for filepath in Path(root).glob(pattern):
if 'venv' not in str(filepath) and '__pycache__' not in str(filepath):
relative_path = os.path.relpath(filepath, base_dir)
add_file_content(filepath, relative_path)
# Write to output file
output_path = os.path.join(base_dir, 'project_files.md')
with open(output_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(output))
return output_path
if __name__ == "__main__":
# Get the current directory
current_dir = os.getcwd()
# Create the markdown file
output_file = create_markdown_doc(current_dir)
print(f"Created markdown documentation at: {output_file}")

189
speakr/docs/faq.md Normal file
View File

@ -0,0 +1,189 @@
# 常见问题
这些是人们开始使用 Speakr 时最常遇到的问题。这里的答案将为你节省时间,并帮助你了解如何充分利用该平台。
## 一般问题
### Speakr 究竟是什么?
Speakr 是一个自托管的 Web 应用程序,可将你的音频录音转化为有组织、可搜索且智能的笔记。它将[转录](features.md#core-transcription-features)、[AI 摘要](features.md#automatic-summarization)、[说话人识别](features.md#speaker-diarization)和[语义搜索](user-guide/inquire-mode.md)整合到一个你完全掌控的单一平台中。如果你自托管了像 Whisper 端点或推荐的 ASR 服务这样的 ASR 模型,以及用于 LLM 的 OpenAI 兼容 API你的数据永远不会离开你的基础设施从而为你提供完整的隐私和控制权。
### Speakr 与其他转录服务有何不同?
关键区别在于自托管——你在自己的服务器上运行 Speakr完全掌控自己的数据。除了隐私保护之外Speakr 还将转录与 AI 驱动的功能(如[智能摘要](features.md#automatic-summarization)、与录音的[交互式聊天](user-guide/transcripts.md)以及跨所有内容的[语义搜索](user-guide/inquire-mode.md))进行了集成。它不仅仅将语音转换为文本,还让这些文本变得有用且易于访问。
### Speakr 支持哪些音频格式?
Speakr 支持大多数常见音频格式,包括 MP3、WAV、M4A、OGG、FLAC 等。系统在内部使用 FFmpeg 来处理音频,因此基本上任何 FFmpeg 支持的格式都能正常工作。包含音轨的视频文件也受到支持——Speakr 会提取并处理其音频部分。
### 多人可以使用同一个 Speakr 实例吗?
可以Speakr 被设计为一个多用户系统。每个用户都有自己的账户,拥有独立的录音、设置和说话人库。管理员可以[创建和管理用户账户](admin-guide/user-management.md)。请参阅[系统统计](admin-guide/statistics.md)以监控使用情况、监控使用情况以及配置系统范围的设置。除非通过[分享链接](user-guide/sharing.md)明确共享,否则用户无法看到彼此的录音。了解[分享安全性](user-guide/sharing.md#security-and-privacy-considerations)。
## 安装与设置
### 最低系统要求是什么?
Speakr 可以在适度的硬件上舒适运行。你至少需要 2GB RAM但推荐使用 4GB 以获得更好的性能。CPU 需求取决于你的使用情况——双核处理器可以应对单用户实例,而繁忙的多用户安装则受益于更多核心。存储需求取决于你的录音量,但请至少预留 20GB 可用空间用于应用程序和初始录音。
### 安装 Speakr 需要了解 Docker 吗?
基本的 Docker 知识会有帮助,但并非必需。[快速入门指南](getting-started.md)提供了可以复制和运行的确切命令。对于生产部署,请参阅[安装指南](getting-started/installation.md)。你需要在服务器上安装 Docker 和 Docker Compose创建包含 API 密钥的配置文件,然后运行一条命令启动所有内容。通常最难的部分是从 OpenAI 或 OpenRouter 获取 API 密钥。
### 我可以在树莓派上运行 Speakr 吗?
可以Speakr 可以在配备至少 4GB RAM 的树莓派 4 或更新版本上运行。性能无法与完整服务器相比尤其是在转录处理方面但对于个人使用来说完全足够。ARM 兼容的 Docker 镜像开箱即用。只需对大录音的较长处理时间保持耐心即可。
### 我可以在 Mac 上使用 ASR webservice 进行说话人分离吗?
提供[说话人分离](features.md#speaker-diarization)功能的可选 ASR webservice`onerahmet/openai-whisper-asr-webservice`)在 macOS 上有特定要求:
**GPU 限制**GPU 直通在 macOS 上无法工作,因为 Docker 在 Linux 虚拟机内运行容器。这是 Mac 上 Docker 的根本限制。
**解决方案**:使用标准 CPU 镜像而非 GPU 版本:
- 使用 `onerahmet/openai-whisper-asr-webservice:latest`(而非 `:latest-gpu`
- `:latest` 标签同时提供 amd64Intel和 arm64Apple Silicon架构
- 没有 GPU 加速时处理速度会变慢,但功能完全正常
Mac 的配置示例:
```bash
docker run -d -p 9000:9000 \
-e ASR_MODEL=base \
-e ASR_ENGINE=whisperx \
-e HF_TOKEN=your_huggingface_token \
onerahmet/openai-whisper-asr-webservice:latest
```
注意:如果你不需要在转录中进行说话人识别,可以直接使用 Speakr 配合标准 Whisper API这不需要额外的容器。
### 如何备份我的 Speakr 数据?
你的 Speakr 数据由三个基本组件组成:`instance/` 目录中的 SQLite 数据库、`uploads/` 目录中的音频文件和转录内容,以及 `.env` 文件中的配置。要创建完整备份,请先停止容器以确保数据库一致性,然后备份所有三个目录:
```bash
docker compose down
tar czf speakr_backup_$(date +%Y%m%d).tar.gz uploads/ instance/ .env
docker compose up -d
```
强烈建议在生产环境中定期进行自动化备份。
## 转录与 AI 功能
### 转录的准确度如何?
转录准确度取决于多个因素——音频质量、说话人清晰度、背景噪音和专业词汇。请参阅[故障排除指南](troubleshooting.md#poor-transcription-quality)获取建议。为专业词汇配置[自定义提示词](admin-guide/prompts.md)。在音频质量良好的情况下,清晰的英语语音预计可达到 90-95% 的准确度。在口音较重、多人重叠说话或录音质量差的情况下,准确度会下降。配备说话人分离功能的 ASR 端点通常能提供更好的实际可用性,即使原始准确度相近。
### Whisper API 和 ASR 端点有什么区别?
Whisper API 提供基本转录功能——将语音转换为文本,但不包含说话人识别。[推荐的 ASR 容器](getting-started.md#option-b-custom-asr-endpoint-configuration)`onerahmet/openai-whisper-asr-webservice`)提供高级功能,如[说话人分离](features.md#speaker-diarization),可以识别并标记对话中的不同说话人。了解转录后如何[管理说话人](user-guide/transcripts.md#speaker-identification)。对于有多位参与者的会议说话人分离是必不可少的而对于单人录音如口述或播客Whisper API 就足够了。
**关于 ASR 引擎的说明**:为了让说话人分离在 ASR webservice 中正常工作,你必须使用 `ASR_ENGINE=whisperx`,而不是 `faster_whisper`。虽然 faster_whisper 提供转录功能,但它不支持说话人识别。
### Speakr 可以转录英语以外的语言吗?
可以Speakr 通过其转录服务支持多种语言。Whisper 模型可以处理数十种语言,准确度各不相同——西班牙语、法语、德语和中文等主要语言效果良好,而不太常见的语言准确度可能会降低。你可以在[账户设置](user-guide/settings.md#language-preferences)中设置首选语言,或将其留空以自动检测。请参阅[语言支持详情](features.md#language-support)。
**中文转录的重要提示**:使用 ASR 端点进行中文音频转录时,请避免使用 distil 模型(如 distil-large-v3因为它们可能会将中文错误识别为英文。请使用完整 large-v3 模型或类似的非蒸馏模型以获得准确的中文转录。
### 我的录音可以有多长?
录音长度没有硬性限制,但需要考虑实际因素。非常长的录音(超过 2-3 小时)处理时间更长,消耗更多 API 额度,并且可能导致界面响应变慢。文件上传限制默认为 300MB足以容纳数小时的压缩音频。对于全天候研讨会等超长内容建议将其拆分为逻辑段落。
### 什么 AI 模型生成摘要?
摘要生成使用在[环境配置文件](getting-started.md#step-3-configure-your-transcription-service)中配置的语言模型。通过 [AI 提示词](admin-guide/prompts.md)自定义摘要——可以通过本地 LLM 端点或 OpenAI、OpenRouter 等云服务提供商。模型选择会影响[摘要质量](features.md#automatic-summarization)、成本和处理速度。在[系统统计](admin-guide/statistics.md)中监控性能。
## 隐私与安全
### 我的数据真的私密吗?
正确自托管时,你的音频和转录内容永远不会离开你的服务器。但是,转录和摘要 APIOpenAI、OpenRouter确实会在他们的服务器上处理你的内容。要实现完全隐私你需要对转录和摘要都使用本地模型这需要大量的计算资源。
### 我可以将 Speakr 用于机密商务会议吗?
可以,但需要采取适当的预防措施。自托管让数据处于你的控制之下,但请考虑你的 API 提供商的数据政策。OpenAI 和 OpenRouter 有不同的数据保留和使用政策。为了获得最高安全性,请使用本地转录和摘要模型,但这需要强大的硬件和技术专长。
### 分享链接安全吗?
[分享链接](user-guide/sharing.md)使用密码学安全的随机令牌,无法被猜测。你可以从[分享仪表板](user-guide/sharing.md#managing-your-shared-recordings)管理已分享的录音。但是,任何拥有链接的人都可以在未经身份验证的情况下访问共享内容。请将分享链接视为密码——仅通过安全渠道发送它们,并在不再需要时撤销访问权限。对于敏感内容,请考虑需要身份验证的替代分享方式。
### 谁可以看到我的录音?
默认情况下,只有你可以看到自己的录音。[管理员用户](admin-guide/user-management.md)无法通过界面直接查看其他用户的录音,尽管他们可以监控[使用模式](admin-guide/statistics.md),但理论上他们可以访问数据库。已分享的录音对拥有分享链接的任何人可访问。同一 Speakr 实例上的其他用户无法看到你的录音,除非你明确分享。
## 功能与特性
### 什么是 Inquire Mode
[Inquire Mode](user-guide/inquire-mode.md) 是 Speakr 的语义搜索功能,让你可以使用自然语言问题在所有录音中查找信息。必须配置[向量存储](admin-guide/vector-store.md)才能使其正常工作。与其搜索精确的关键词,你可以提出诸如"我们关于营销预算做了什么决定?"这样的问题,并获得讨论过该主题的任何录音的相关摘录。它使用 AI 嵌入来理解含义和上下文。
### 说话人档案如何工作?
当你在转录中[识别说话人](user-guide/transcripts.md#speaker-identification)时通过点击通用标签SPEAKER_01 等并分配名称Speakr 会将这些保存为说话人档案。你可以在[账户设置](user-guide/settings.md#speakers-management-tab)中管理它们。在未来的更新中,我们打算添加功能,允许录音根据声音特征自动建议说话人身份。随着时间的推移,你会建立一个已识别说话人的库,使多人转录变得更加有用。
### 转录生成后可以编辑吗?
可以,转录内容完全可编辑。点击任何转录上方的"编辑"按钮进行修改。请参阅[转录指南](user-guide/transcripts.md#editing-transcriptions)了解编辑选项。这对于修正识别错误的专业术语、专有名词或更正说话人分配特别有用。你的编辑会被保留——如果你重新生成摘要或使用[聊天功能](user-guide/transcripts.md),它们不会丢失。可以使用[各种格式](user-guide/transcripts.md)导出已编辑的转录。
### 有哪些导出格式可用?
Speakr 可以以多种格式导出录音。你可以直接将转录内容复制到剪贴板,以便粘贴到其他应用程序中。了解[导出选项](features.md#export-options)和[分享](user-guide/sharing.md)。可以下载完整的录音为 Word 文档(.docx包括转录、摘要和笔记。[分享链接](user-guide/sharing.md)提供只读的 Web 访问。你可以在[分享设置](user-guide/sharing.md#creating-a-share-link)中配置可见内容。聊天历史记录也可以导出用于文档目的。
## 故障排除
### 为什么转录需要这么长时间?
多个因素会影响转录速度——文件大小、API 服务负载、网络速度和模型选择。大文件自然需要更长时间。API 服务在高峰使用时段可能会变慢。慢速互联网连接会在上传音频时造成瓶颈。使用更大、更准确的模型(如 Whisper Large比使用较小模型耗时更长。
### 我的录音卡在"待处理"状态
这通常意味着后台处理器已停止或遇到错误。请检查 Docker 日志中的错误消息。请参阅[故障排除指南](troubleshooting.md#transcription-never-starts)了解详情。在[向量存储](admin-guide/vector-store.md)中监控处理进度。常见原因包括 API 密钥无效、API 配额超限或网络连接问题。重启容器通常可以解决临时问题。请检查你的 API 提供商仪表板,了解使用限制或账单问题。
### 为什么所有说话人都显示为"UNKNOWN_SPEAKER"
这是说话人分离配置不正确时的常见问题。以下是修复方法:
1. **检查 ASR_ENGINE**:确保你在 ASR 容器中使用的是 `ASR_ENGINE=whisperx`,而不是 `faster_whisper`
2. **验证 ASR_DIARIZE**:虽然在 `USE_ASR_ENDPOINT=true` 时默认设置为 `true`,但请在 .env 文件中显式设置 `ASR_DIARIZE=true`
3. **HuggingFace Token**ASR 容器需要有效的 `HF_TOKEN` 环境变量来下载说话人分离模型
4. **Docker 网络**:如果容器在同一个 docker-compose 中,请使用容器名称(例如 `http://whisper-asr:9000`),而不是 localhost 或外部 IP
5. **检查日志**:在 ASR 容器日志中查找 pyannote/VAD 消息以确认说话人分离已激活
ASR 服务应在转录中返回类似 "SPEAKER_00"、"SPEAKER_01" 的说话人标签。然后你可以[识别这些说话人](user-guide/transcripts.md#speaker-identification)并赋予真实姓名。
### 为什么我无法分享录音?
[分享](user-guide/sharing.md)需要你的 Speakr 实例可以从互联网通过 HTTPS/SSL 加密访问。请检查[分享要求](user-guide/sharing.md#requirements-for-sharing)和[故障排除](troubleshooting.md#sharing-links-dont-work)。本地安装或未配置 HTTPS 的环境无法生成可用的分享链接。当不满足这些要求时,系统会禁用分享功能。要启用分享功能,请在具有域名和 SSL 证书的公共服务器上部署 Speakr。
### 大转录时界面响应缓慢
浏览器在显示大量文本时会遇到困难,尤其是在带有说话人分离的气泡视图中。对于超过 2 小时的录音,请考虑使用简单视图而非气泡视图。如果性能随时间下降,请清除浏览器缓存。将超长录音拆分为多个段落可以提高性能和可用性。
## 最佳实践
### 我应该如何组织我的录音?
尽早开发一个一致的[标签系统](user-guide/settings.md#tag-management-tab)。为不同的项目、会议类型或客户创建标签。标签可以包含用于专门处理的[自定义提示词](admin-guide/prompts.md)。使用描述性标题,帮助你在数月后找到录音。在录音后立即添加笔记,此时上下文仍然清晰。定期维护——归档旧录音和清理测试文件——让你的录音库保持易于管理的状态。
### 我需要告知他人他们正在被录音吗?
法律要求因司法管辖区而异。许多地区要求所有被录制方的明确同意。Speakr 包含可配置的[录音免责声明](admin-guide/system-settings.md#recording-disclaimer)功能。请参阅[合规性考虑](troubleshooting.md#recording-disclaimer-for-legal-compliance)。设置适当的法律文本,在录音开始前显示。请咨询当地法律以确保合规——这在欧盟、加州或澳大利亚等有严格录音法律的地区尤为重要。
### 什么音频质量最适合转录?
尽可能在安静的环境中录音。使用靠近说话人的优质麦克风。对于会议,请将录音设备放置在所有参与者都能清晰听到的中心位置。避免背景音乐或电视噪音。高质量的音频不仅能提高转录准确度,还能减少处理时间和 API 成本。
### 如何最大化转录准确度?
说话清晰,避免互相打断。尽量减少背景噪音和回声。对于技术内容,请考虑在你的[提示词](admin-guide/prompts.md)中添加自定义词汇表或术语表。用户可以为其录音设置[个人提示词](user-guide/settings.md#custom-prompts-tab)。请使用适当的[语言设置](user-guide/settings.md#language-preferences),而不是依赖自动检测。请参阅[语言支持](features.md#language-support)以获得最佳效果。对于多人录音,请使用 [ASR 端点](getting-started.md#option-b-custom-asr-endpoint-configuration)并设置适当的说话人数量。转录后[识别说话人](user-guide/transcripts.md#speaker-identification)以获得最佳效果。
对于中文转录,请使用 large-v3 模型,因为较小的模型可能无法正确输出中文字符。对于其他语言,请测试不同模型,为你的特定语言和口音找到最佳准确度。
### 按大小分块和按持续时间分块有什么区别?
按文件大小分块(如 CHUNK_LIMIT=20MB适用于比特率一致的音频。当转录服务有时间限制时如 Azure 的 1500 秒上限),按持续时间分块(如 CHUNK_LIMIT=1400s更为合适。基于持续时间的分块确保无论文件压缩或质量如何都不会有任何分块超过时间限制。
---
返回 [首页](index.md) →

BIN
speakr/docs/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

109
speakr/docs/features.md Normal file
View File

@ -0,0 +1,109 @@
# 功能特性
Speakr 将强大的转录能力与智能 AI 功能相结合,将您的音频录音转化为有价值的、可操作的知识。每个功能的设计都旨在节省时间并从您的语音内容中提取最大价值。
## 核心转录功能
### 多引擎支持
Speakr 支持多种转录引擎,以满足您的需求和预算。使用 [OpenAI 的 Whisper API](getting-started.md#option-a-openai-whisper-configuration) 进行快速、基于云的转录,并具有出色的准确率。部署 [推荐的 ASR 容器](getting-started.md#option-b-custom-asr-endpoint-configuration) 以实现说话人分离和本地处理等高级功能。有关详细设置说明,请参阅[安装指南](getting-started/installation.md)。系统会自动处理不同的音频格式,并根据需要进行转换以获得最佳转录质量。
### 说话人分离
使用 [ASR 端点](getting-started.md#option-b-custom-asr-endpoint-configuration) 时Speakr 会自动识别录音中的不同说话人。如果遇到问题,请查看[故障排除指南](troubleshooting.md#speaker-identification-not-working)。每个说话人都会获得一个唯一标签,您可以稍后使用真实姓名进行自定义。系统会记住这些说话人档案,构建一个随时间推移不断提高识别准确率的库。在[账户设置](user-guide/settings.md)中管理您的说话人库。此功能将多人会议从大段文本转换为有组织的对话。
### 语言支持
使用自动检测或手动选择,转录数十种语言的内容。英语、西班牙语、法语、德语和中文等主要语言获得出色支持,准确率高。系统能优雅地处理多语言内容,在同一次录音中根据需要切换语言。
## AI 驱动的智能功能
### 自动摘要
每条录音都会收到一个 AI 生成的摘要,捕捉关键点、决策和行动项。通过[自定义提示词](admin-guide/prompts.md)进行配置。用户还可以为他们的录音设置[个人提示词](user-guide/settings.md#custom-prompts-tab)。摘要会根据您的内容类型进行调整——技术会议获得详细的技术摘要,而非正式对话则获得更轻松的概述。自定义提示词让您可以根据特定需求调整摘要。
### 事件提取
在摘要处理过程中Speakr 可以自动从录音中提取值得添加到日历的事件。在账户设置中启用后,系统会识别会议、截止日期、预约和其他时间敏感项目的提及。每个检测到的事件都可以导出为与任何日历应用程序兼容的 ICS 文件。该功能能智能解析相对日期引用,并在未提及具体时间时提供合理的默认值。在工作流程中了解更多关于[使用事件提取](user-guide/transcripts.md#event-extraction)的信息。
### 交互式对话
通过集成的对话功能,将静态转录稿转换为动态对话。了解如何在[转录稿指南](user-guide/transcripts.md)中有效使用 AI 对话。针对您的录音提问并基于实际内容获得智能回答。请求自定义摘要、提取特定信息或生成衍生内容如电子邮件或报告。AI 在整个对话过程中保持上下文,支持复杂的多轮交互。
### 语义搜索Inquire 模式)
通过 [Inquire 模式](user-guide/inquire-mode.md) 使用自然语言问题而非关键词搜索您的所有录音。必须配置 [向量存储](admin-guide/vector-store.md) 此功能才能工作。语义搜索理解含义和上下文,即使确切的词语不匹配也能找到相关内容。提出诸如"我们什么时候讨论过预算增加?"这样的问题,即可从任何涉及该主题的录音中获取结果,无论使用了什么特定术语。
## 组织与管理
### 标签系统
使用灵活的[标签系统](user-guide/settings.md#tag-management-tab)组织录音,超越简单的标签。标签可以包含用于专门处理的[自定义 AI 提示词](admin-guide/prompts.md)。每个标签可以携带[自定义 AI 提示词](admin-guide/prompts.md)和转录设置,根据内容类型启用自动的专业处理。标签使用颜色进行视觉组织,当同一录音应用多个标签时智能堆叠。
### 说话人管理
构建和维护跨录音持久的说话人档案库。每次转录后[识别说话人](user-guide/transcripts.md#speaker-identification)以构建您的库。识别后,系统会在未来的录音中记住并建议这些说话人。系统跟踪使用统计信息,显示每个说话人出现的频率以及最后一次被识别的时间。批量管理工具有助于维护干净、有序的说话人库。
### 自定义提示词
通过多个层级的[自定义提示词](admin-guide/prompts.md)塑造 AI 行为。了解[提示词层级](admin-guide/prompts.md#understanding-prompt-hierarchy)以进行有效配置。个人提示词适用于您的所有录音,而特定标签的提示词会为特定内容类型激活。层级系统确保正确的提示词应用于每条录音,当多个提示词相关时智能堆叠。
## 分享与协作
### 安全分享链接
生成加密安全链接以[分享录音](user-guide/sharing.md)给您 Speakr 实例之外的用户。注意[分享要求](user-guide/sharing.md#requirements-for-sharing)。精确控制接收者看到的内容——根据需要包含或排除摘要和笔记。分享链接可在任何设备上使用,无需账户或身份验证。
### 导出选项
以多种格式导出录音以满足不同用途。生成包含完整转录稿、摘要和笔记的 Word 文档用于正式文档。复制格式化文本到剪贴板以便快速分享。导出单个组件如摘要或笔记用于定向分发。使用[可自定义模板](user-guide/transcript-templates.md)下载转录稿,为您的内容格式化为不同用例,从字幕到采访格式。
### 实时监控
从中央仪表板跟踪所有已分享的录音。查看已分享的内容、时间以及权限。修改分享设置而无需生成新链接,或在分享不再合适时立即撤销访问权限。
## 高级功能
### 音频分块
通过智能分块处理超出 API 限制的大型音频文件。查看[故障排除指南](troubleshooting.md#files-over-25mb-fail-with-openai)获取配置详情。在 FAQ 中了解[分块策略](faq.md#whats-the-difference-between-chunking-by-size-vs-duration)。系统自动将长录音分割为可管理的片段,分别处理,然后无缝组装结果。按持续时间或文件大小配置分块大小,以匹配您的 API 提供商的要求。
### 黑洞处理
设置一个监视目录,放入其中的音频文件会被自动处理。在[系统设置](admin-guide/system-settings.md)中配置此功能以实现自动化工作流。非常适合自动化工作流或批量处理,黑洞目录会监视新文件并将其排队进行转录,无需手动干预。
### 自定义 ASR 配置
使用自定义 ASR 设置针对特定场景微调转录。查看 [ASR 配置](troubleshooting.md#asr-endpoint-returns-405-or-404-errors) 获取常见设置问题。为不同会议类型设置预期说话人数量。为技术词汇或特定口音配置专用模型。通过标签系统自动应用这些设置。
## 用户体验
### 渐进式 Web 应用
将 Speakr 安装为渐进式 Web 应用在任何设备上获得原生般的体验。PWA 在离线状态下可查看现有录音,并在恢复连接时同步。移动优化的界面确保在手机和平板电脑上流畅运行。
### 深色模式
通过影响每个界面元素的完整深色模式实现,减少眼睛疲劳。主题偏好在会话和设备之间持久保存,每次登录时自动应用您的选择。
### 响应式设计
从任何设备访问 Speakr界面会自动适应屏幕尺寸。桌面用户获得完整的三面板布局可同时访问录音、转录稿和对话。移动用户获得为触摸交互和小屏幕优化的简化界面。
## 管理控制
### 多用户支持
为整个团队运行单个 Speakr 实例,具有隔离的用户空间。查看[用户管理](admin-guide/user-management.md)了解详情。[FAQ](faq.md#can-multiple-people-use-the-same-speakr-instance) 解释了多用户架构。每个用户维护自己的录音、设置和说话人库。管理员管理用户、监控使用情况并配置系统级设置,而无需访问个人录音。
### 系统监控
通过全面的统计信息和指标跟踪系统健康状况。监控转录队列、存储使用情况和处理性能。识别瓶颈并根据实际使用模式优化配置。
### 灵活配置
通过[环境变量](getting-started.md#step-3-configure-your-transcription-service)和[管理员设置](admin-guide/index.md)配置 Speakr 的各个方面。检查[系统设置](admin-guide/system-settings.md)获取全局配置选项。设置 API 端点、调整处理限制、启用或禁用功能,以及自定义用户体验。系统会适应您的基础设施和需求。
---
返回 [首页](index.md) →

View File

@ -0,0 +1,188 @@
# 快速入门指南
只需几分钟即可使用预构建的 Docker 镜像运行 Speakr本指南将带你了解部署 Speakr 的最快方法,支持使用 OpenAI Whisper API 或[自定义 ASR 端点](features.md#speaker-diarization)。
> **注意:** 如果你想使用 ASR 端点选项来实现说话人分离功能,你需要运行一个额外的 Docker 容器(`onerahmet/openai-whisper-asr-webservice`)。请参阅[运行 ASR 服务以支持说话人分离](getting-started/installation.md#running-asr-service-for-speaker-diarization)获取详细的设置说明。
## 前置条件
开始之前,请确保你的系统上已安装 Docker 和 Docker Compose。你还需要一个 API 密钥OpenAI 或 OpenRouter或兼容服务至少 2GB 可用内存,以及约 10GB 可用磁盘空间用于存储录音和转录内容。
## 步骤 1创建项目目录
首先,为 Speakr 安装创建一个目录并进入:
```bash
mkdir speakr
cd speakr
```
## 步骤 2下载配置文件
下载 Docker Compose 配置,并根据你选择的转录服务选择合适的环境模板:
```bash
# Download docker compose example
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/docker-compose.example.yml -O docker-compose.yml
```
现在下载环境配置模板。根据你要使用的转录服务,你有两种选择。
使用标准 OpenAI Whisper API推荐大多数用户使用
```bash
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.whisper.example -O .env
```
或使用带说话人分离功能的自定义 ASR 端点(需要额外的 ASR 容器——见下方说明):
```bash
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.asr.example -O .env
```
> **重要:** ASR 端点选项需要与 Speakr 一起运行一个额外的 Docker 容器(`onerahmet/openai-whisper-asr-webservice`)。有关完整的设置说明,包括两个容器的 docker-compose 配置,请参阅[运行 ASR 服务以支持说话人分离](getting-started/installation.md#running-asr-service-for-speaker-diarization)。
## 步骤 3配置你的转录服务
在你喜欢的文本编辑器中打开 `.env` 文件,并根据你选择的服务进行配置。
### 选项 AOpenAI Whisper 配置
如果你使用 OpenAI Whisper你需要同时设置转录服务和文本生成模型。文本生成模型用于创建摘要、生成标题和驱动聊天功能。
编辑你的 `.env` 文件并更新以下关键变量:
```bash
# For text generation (summaries, chat, titles)
TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
TEXT_MODEL_API_KEY=your_openrouter_api_key_here
TEXT_MODEL_NAME=openai/gpt-4o-mini
# For transcription
TRANSCRIPTION_BASE_URL=https://api.openai.com/v1
TRANSCRIPTION_API_KEY=your_openai_api_key_here
WHISPER_MODEL=whisper-1
```
文本模型可以使用 OpenRouter 来访问各种 AI 模型,或者你可以将其直接指向 OpenAI使用与转录服务相同的基础 URL 和 API 密钥。OpenRouter 提供对多种模型的访问,包括 GPT-4、Claude 等,对于文本生成任务来说可能更具成本效益。
### 选项 B自定义 ASR 端点配置
> **前置条件:** 此选项需要运行一个额外的 ASR 服务容器(`onerahmet/openai-whisper-asr-webservice`)。你可以:
>
> - 在同一个 Docker Compose 栈中运行两个容器(推荐)——请参阅[完整设置指南](getting-started/installation.md#running-asr-service-for-speaker-diarization)
> - 在同一台或不同的机器上单独运行 ASR 服务
> - 如果你已经部署了现有的 ASR 服务,可以直接使用
如果你使用的是自定义 ASR 服务(如 WhisperX 或自托管的 Whisper 服务器),请配置以下变量:
```bash
# For text generation (summaries, chat, titles)
TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
TEXT_MODEL_API_KEY=your_openrouter_api_key_here
TEXT_MODEL_NAME=openai/gpt-4o-mini
# Enable ASR endpoint
USE_ASR_ENDPOINT=true
# ASR service URL (use container name if in same docker compose)
ASR_BASE_URL=http://whisper-asr:9000
```
当使用 ASR 端点时,[说话人分离](features.md#speaker-diarization)功能会自动启用,使 Speakr 能够识别录音中的不同说话人。转录完成后,你需要[识别说话人](user-guide/transcripts.md#speaker-identification)来构建你的说话人库。ASR_BASE_URL 应指向你的 ASR 服务。如果你在同一个 Docker Compose 栈中运行 ASR 服务,请使用容器名称和内部端口(如 `http://whisper-asr:9000`)。对于外部服务,请使用带有适当 IP 地址或域名的完整 URL。
## 步骤 4配置管理员账户
Speakr 会在首次启动时自动创建一个管理员用户。在启动之前,请在 `.env` 文件中配置这些凭据:
```bash
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme
```
确保将这些值更改为更安全的值,尤其是密码。当你首次启动 Speakr 时,该管理员账户将自动创建,你将使用这些凭据进行登录。通过此方法创建的第一个用户将成为系统管理员,拥有对所有功能的完全访问权限,包括用户管理和系统设置。
## 步骤 5启动 Speakr
配置完成后,使用 Docker Compose 启动 Speakr
```bash
docker compose up -d
```
首次启动需要几分钟时间,因为 Docker 需要下载预构建镜像(约 3GB并初始化数据库。你可以通过查看日志来监控启动过程
```bash
docker compose logs -f app
```
查找表示 Flask 应用程序正在运行并已准备好接受连接的消息。按 Ctrl+C 退出日志视图(这不会停止容器)。
## 步骤 6访问 Speakr
容器运行后,打开你的 Web 浏览器并访问:
```
http://localhost:8899
```
使用你在步骤 4 中配置的管理员凭据登录。现在你应该能看到 Speakr 仪表盘,已准备好进行你的第一次录音。
## 你的第一次录音
登录后,你可以立即开始使用 Speakr。点击顶部导航栏中的"New Recording"按钮,上传现有音频文件或开始[现场录音](user-guide/recording.md)。有关详细说明,请参阅[录音指南](user-guide/recording.md)。对于上传文件Speakr 支持[常见音频格式](faq.md#what-audio-formats-does-speakr-support),如 MP3、M4A、WAV 等,默认文件大小限制为 500MB。你可以在[系统设置](admin-guide/system-settings.md)中调整此限制。对于现场录音,你可以从麦克风、系统音频或同时进行录制。
## 可选功能
### 启用 Inquire 模式
[Inquire 模式](user-guide/inquire-mode.md)允许你使用自然语言问题在所有录音中进行搜索。在功能指南中了解更多关于[语义搜索功能](features.md#semantic-search-inquire-mode)的信息。要启用它,请在 `.env` 文件中设置:
```bash
ENABLE_INQUIRE_MODE=true
```
然后使用 `docker compose restart` 重启容器以使更改生效。
### 启用用户注册
默认情况下,只有管理员可以创建新用户。在管理员指南中了解更多关于[用户管理](admin-guide/user-management.md)的信息。要允许自行注册,请设置:
```bash
ALLOW_REGISTRATION=true
```
### 配置你的时区
设置本地时区以显示准确的时间戳:
```bash
TIMEZONE="America/New_York"
```
使用 TZ 数据库中的任何有效时区,如"Europe/London"、"Asia/Tokyo"或"UTC"。
## 停止和启动 Speakr
要停止 Speakr 同时保留所有数据:
```bash
docker compose down
```
要再次启动:
```bash
docker compose up -d
```
你的录音、转录内容和设置将保存在主机系统上的 `./uploads``./instance` 目录中。
## 故障排除
如果 Speakr 无法正常启动,请使用 `docker compose logs app` 检查日志中的错误信息。有关更详细的帮助,请参阅[故障排除指南](troubleshooting.md),特别是[安装问题](troubleshooting.md#installation-and-setup-issues)部分。常见问题包括 API 密钥不正确(日志中会显示身份验证错误)或端口冲突(如果其他服务正在使用 8899 端口)。你可以通过编辑 `docker-compose.yml` 文件并修改 ports 部分来更改端口。
如果转录失败,请验证你的 API 密钥是否正确,并且你选择的服务有足够的额度。日志将显示详细的错误信息,有助于识别问题。
---
下一步:[安装指南](getting-started/installation.md)(用于生产部署和高级配置)

View File

@ -0,0 +1,709 @@
# 安装指南
本综合指南涵盖了 Speakr 的生产环境部署,包括详细的配置选项、性能调优和部署最佳实践。虽然快速入门指南能让你快速运行,但本指南提供了稳健生产部署所需的一切。
## 了解 Speakr 的架构
在开始安装之前,了解 Speakr 的工作原理会很有帮助。该应用集成了外部 API主要用于两个目的将音频转换为文本的转录服务以及为摘要、标题和交互式聊天等功能提供支持的文本生成服务。Speakr 设计灵活,既支持 OpenAI 等云端服务,也支持运行在你自有基础设施上的自托管方案。
Speakr 使用特定的 API 端点格式进行这些集成。对于转录,它支持标准的 OpenAI Whisper API 格式,使用 `/audio/transcriptions` 端点,该格式被 OpenAI、OpenRouter 和许多自托管方案所实现。另外对于说话人分离等高级功能Speakr 可以连接提供 `/asr` 端点的 ASR Web 服务。**注意:使用 ASR 端点选项需要与 Speakr 一起运行额外的 Docker 容器**`onerahmet/openai-whisper-asr-webservice`)——完整的设置说明请参考下方的[运行 ASR 服务用于说话人分离](#running-asr-service-for-speaker-diarization)部分。对于文本生成Speakr 使用 OpenAI Chat Completions API 格式的 `/chat/completions` 端点,该格式被不同的 AI 提供商广泛支持。
## 前置要求
对于生产环境部署,请确保你的系统满足以下要求。你需要 Docker Engine 20.10 或更高版本以及 Docker Compose 2.0 或更高版本。系统应至少有 4GB 内存,推荐 8GB 以获得最佳性能,尤其是处理较长录音时。预留至少 20GB 可用磁盘空间来存放录音和转录文件,实际需求将取决于你的使用模式。除非你在本地运行所有服务,否则服务器应有稳定的互联网连接以进行转录和 AI 服务的 API 调用。
## 选择部署方式
你有两种主要的部署方式。第一种(推荐)是使用 Docker Hub 上的预构建 Docker 镜像,无需源代码即可快速运行。第二种是从源码构建,适用于需要修改代码或偏好自行构建镜像的场景。两种方法都使用 Docker Compose 进行编排和管理。
## 使用预构建镜像的标准安装
### 步骤 1创建安装目录
为你的 Speakr 安装选择合适的位置。该目录将包含你的配置文件和数据卷。对于生产环境部署,建议使用专用目录如 `/opt/speakr``/srv/speakr`,这样可以与用户主目录清晰分离,并遵循 Linux 文件系统层次标准。
```bash
mkdir -p /opt/speakr
cd /opt/speakr
```
如果只是测试或个人使用,也可以在主文件夹中创建目录。位置不是关键,但将所有内容组织在一个地方更便于管理。
### 步骤 2创建 Docker Compose 配置
创建 `docker-compose.yml` 文件,内容如下:
```yaml
services:
app:
image: learnedmachine/speakr:latest
container_name: speakr
restart: unless-stopped
ports:
- "8899:8899"
env_file:
- .env
volumes:
- ./uploads:/data/uploads
- ./instance:/data/instance
```
或下载示例配置:
```bash
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/docker-compose.example.yml -O docker-compose.yml
```
重启策略 `unless-stopped` 确保 Speakr 在系统重启后自动启动,除非你显式停止了它。数据卷挂载本地目录用于持久化存储上传文件和数据库文件。
### 步骤 3环境配置
环境配置用于告诉 Speakr 使用哪些 AI 服务以及如何连接它们。根据你的转录服务选择下载相应的环境模板。该模板包含所有配置变量,并附有 helpful 注释解释每个设置。
#### 使用 OpenAI Whisper API
如果你使用 OpenAI 的 Whisper API 或任何兼容服务,下载 Whisper 环境模板:
```bash
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.whisper.example -O .env
```
然后编辑 `.env` 文件添加你的 API 密钥并自定义设置。配置按逻辑部分组织。首先,配置用于摘要、标题和聊天功能的文本生成模型。这里推荐使用 OpenRouter因为它能以有竞争力的价格访问多个 AI 模型,但你也可以使用任何 OpenAI 兼容服务:
```bash
TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
TEXT_MODEL_API_KEY=sk-or-v1-your-key-here
TEXT_MODEL_NAME=openai/gpt-4o-mini
```
如果你更喜欢直接使用 OpenAI 进行文本生成,只需将 base URL 改为 `https://api.openai.com/v1` 并使用你的 OpenAI API 密钥。你也可以通过 Ollama 或 LM Studio 使用本地模型,指向 `http://localhost:11434/v1` 或类似地址。
接下来,配置转录服务。这是将音频文件转换为文本的服务:
```bash
TRANSCRIPTION_BASE_URL=https://api.openai.com/v1
TRANSCRIPTION_API_KEY=sk-your-openai-key-here
WHISPER_MODEL=whisper-1
```
#### 使用自定义 ASR 端点配合说话人分离
如果你想要说话人分离功能来识别录音中的不同说话者,你需要使用 ASR Web 服务端点。**这需要与 Speakr 一起运行额外的 Docker 容器**`onerahmet/openai-whisper-asr-webservice`),但为会议转录和多说话者录音提供了强大的功能。
> **重要提示:** 在进行此配置之前,你需要先设置 ASR 服务容器。请参考[运行 ASR 服务用于说话人分离](#running-asr-service-for-speaker-diarization)获取完整的说明,了解如何一起或分别部署两个容器。
下载 ASR 配置模板:
```bash
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.asr.example -O .env
```
ASR 配置启用自定义端点并告诉 Speakr 在哪里找到它:
```bash
USE_ASR_ENDPOINT=true
ASR_BASE_URL=http://your-asr-service:9000
```
ASR_BASE_URL 取决于你的部署架构。如果你在与 Speakr 相同的 Docker Compose 堆栈中运行 ASR 服务,请使用 docker-compose.yml 中的服务名,如 `http://whisper-asr:9000`。这使用 Docker 的内部网络进行通信。如果 ASR 服务运行在其他地方,请使用其完整 URL 和适当的 IP 地址或域名。
使用 ASR 端点时会自动启用说话人分离。系统将识别录音中的不同说话者并标记为 Speaker 1、Speaker 2 等。你可以选择取消注释并调整环境文件中的 ASR_MIN_SPEAKERS 和 ASR_MAX_SPEAKERS 来覆盖默认的说话者检测设置。
### 步骤 4配置系统设置
Speakr 的一个便利功能是自动创建管理员账户。无需经过注册流程你只需在环境文件中定义管理员凭据Speakr 会在首次启动时自动创建账户。这确保你可以立即登录,无需额外的设置步骤:
```bash
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@your-domain.com
ADMIN_PASSWORD=your-secure-password-here
```
为管理员账户选择强密码,因为它拥有完整的系统访问权限,包括管理用户和查看所有录音的能力。管理员账户是特殊的,无法通过常规注册流程创建,只能通过环境变量创建。
接下来,配置应用程序的行为方式。这些设置控制用户访问和系统操作:
```bash
ALLOW_REGISTRATION=false
TIMEZONE="America/New_York"
LOG_LEVEL="INFO"
```
设置 `ALLOW_REGISTRATION=false` 意味着只有管理员可以创建新用户账户,这对于需要控制访问权限的私有安装是推荐的。如果你为团队或家庭运行 Speakr这可以防止无关人员创建账户。时区设置影响整个界面中日期和时间的显示方式因此请将其设置为你的本地时区以方便使用。日志级别控制 Speakr 写入日志的信息量。在初始设置和测试期间使用 `INFO` 以了解发生了什么,然后在生产环境中切换到 `ERROR` 以减少日志量并提高性能。
### 步骤 5配置高级功能
#### 大文件处理
Speakr 最有用的功能之一是自动处理大音频文件。许多转录 API 有文件大小限制OpenAI 的 25MB 限制就是一个常见的约束。Speakr 通过智能分段自动处理这个问题,而不是强制你手动分割文件:
```bash
ENABLE_CHUNKING=true
CHUNK_LIMIT=20MB
CHUNK_OVERLAP_SECONDS=3
```
启用分段后Speakr 会自动检测文件是否超过配置的限制并将其分割为较小的部分。每个部分单独处理,转录结果无缝合并回一起。重叠设置确保在分段边界处不会丢失单词,这对于连续语音尤为重要。分段限制可以指定为文件大小如 `20MB` 或持续时间如 `20m`20 分钟),具体取决于你的 API 限制。
此功能仅适用于标准 Whisper API 方法。如果你使用 ASR 端点,则不需要分段,因为这些服务通常原生支持大文件。
#### Inquire 模式用于语义搜索
Inquire 模式将 Speakr 从简单的转录工具转变为你所有录音的知识库。启用后,你可以使用自然语言问题在所有转录中进行搜索:
```bash
ENABLE_INQUIRE_MODE=true
```
启用 Inquire 模式后Speakr 会为你的转录创建嵌入向量,从而支持语义搜索。这意味着你可以问"我们什么时候讨论了营销预算?"这样的问题,即使没有使用确切的词语也能找到相关录音。该功能在转录期间需要额外的处理,但随着你的录音库增长,它将提供强大的搜索能力。
#### 自动化文件处理
自动化文件处理功能(有时称为"黑洞"目录)会监控指定文件夹中的新音频文件并自动处理它们,无需手动干预。这非常适合与录音设备、自动化工作流或批处理场景集成:
```bash
ENABLE_AUTO_PROCESSING=true
AUTO_PROCESS_MODE=admin_only
AUTO_PROCESS_WATCH_DIR=/data/auto-process
AUTO_PROCESS_CHECK_INTERVAL=30
```
启用后Speakr 每 30 秒检查一次监控目录是否有新音频文件。发现的任何文件都会自动移动到上传目录并使用你配置的转录设置进行处理。`admin_only` 模式将所有处理后的文件分配给管理员用户,但你也可以为多用户场景配置每个用户的独立目录。
要使用此功能,你需要在 Docker Compose 配置中挂载额外的数据卷,我们将在接下来的步骤中介绍。
### 步骤 6设置数据目录
Speakr 需要本地目录来持久化存储你的数据。这些目录作为 Docker 数据卷挂载,确保你的录音和数据库在容器更新和重启后仍然存在:
```bash
mkdir -p uploads instance
chmod 755 uploads instance
```
`uploads` 目录存储所有音频文件及其转录内容,按用户组织。`instance` 目录包含跟踪所有录音、用户和设置的 SQLite 数据库。将权限设置为 755 确保 Docker 容器可以读写这些目录,同时保持合理的安全性。
如果你使用自动化文件处理功能,也需要创建该目录:
```bash
mkdir -p auto-process
chmod 755 auto-process
```
### 步骤 7启动 Speakr
所有配置完成后,你就可以启动 Speakr 了。`-d` 标志以分离模式运行容器,意味着它将在后台持续运行:
```bash
docker compose up -d
```
首次运行此命令时Docker 将从 Docker Hub 下载 Speakr 镜像。该镜像约 3GB包含运行 Speakr 所需的所有依赖项,包括用于音频处理的 FFmpeg 和各种 Python 库。下载时间取决于你的互联网连接速度。
监控启动过程以确保一切正常运行:
```bash
docker compose logs -f app
```
观察日志是否有任何错误消息。你应该会看到关于数据库初始化、管理员用户创建的消息,最后会显示 Flask 应用程序在 8899 端口运行的消息。按 Ctrl+C 停止查看日志(这不会停止容器,只是停止日志查看)。
### 步骤 8验证安装
打开浏览器并访问 `http://your-server:8899`,将 `your-server` 替换为你的实际服务器地址,如果是本地运行则使用 `localhost`。你应该会看到 Speakr 登录页面,带有其独特的渐变设计。
使用你在环境文件中配置的管理员凭据登录。如果登录失败,请检查 Docker 日志以确保管理员账户创建成功。有时环境文件中的拼写错误会导致问题。
登录成功后,通过创建测试录音或上传示例音频文件来测试安装。录音界面应显示麦克风、系统音频或两者的选项。首先尝试上传一个小型音频文件以验证你的 API 密钥是否正常工作。对于短文件,转录过程应在几秒钟内完成,你应该会看到转录文本出现以及 AI 生成的摘要。
如果转录失败,请检查 Docker 日志中的 API 认证错误或连接问题。常见问题包括 API 密钥不正确、API 额度不足或网络连接问题。
## 高级部署场景
### 运行 ASR 服务用于说话人分离
如果你需要说话人分离功能来识别录音中的不同说话者,你需要与 Speakr 一起运行 ASR 服务。这涉及部署额外的 Docker 容器(`onerahmet/openai-whisper-asr-webservice`)来提供 ASR 端点。推荐的方法是在同一个 Docker Compose 堆栈中运行两个容器,以简化网络和管理。
首先,你需要一个 Hugging Face 令牌来访问说话人分离模型。在 Hugging Face 创建账户,生成访问令牌,并且重要的是,访问 pyannote/segmentation-3.0 和 pyannote/speaker-diarization-3.1 模型页面以接受它们的条款。这些是需要明确授权的受限模型。
以下是包含两个服务的完整 Docker Compose 配置:
```yaml
services:
whisper-asr:
image: onerahmet/openai-whisper-asr-webservice:latest-gpu
container_name: whisper-asr-webservice
ports:
- "9000:9000"
environment:
- ASR_MODEL=distil-large-v3
- ASR_COMPUTE_TYPE=int8
- ASR_ENGINE=whisperx
- HF_TOKEN=your_huggingface_token_here
deploy:
resources:
reservations:
devices:
- driver: nvidia
capabilities: [gpu]
device_ids: ["0"]
restart: unless-stopped
networks:
- speakr-network
speakr:
image: learnedmachine/speakr:latest
container_name: speakr
restart: unless-stopped
ports:
- "8899:8899"
env_file:
- .env
volumes:
- ./uploads:/data/uploads
- ./instance:/data/instance
depends_on:
- whisper-asr
networks:
- speakr-network
networks:
speakr-network:
driver: bridge
```
> **Mac 用户注意:** 由于 Docker 的架构GPU 直通在 macOS 上不起作用。请使用 `onerahmet/openai-whisper-asr-webservice:latest`CPU 版本)而不是 `:latest-gpu`,并删除 `deploy` 部分。ASR 服务将使用 CPU 处理,速度较慢但功能完整。请参考[常见问题](../faq.md#can-i-use-the-asr-webservice-for-speaker-diarization-on-mac)获取 Mac 特定的配置示例。
当在同一个 Docker Compose 文件中运行两个服务时,容器使用服务名进行通信。在 `.env` 文件中,设置 `ASR_BASE_URL=http://whisper-asr:9000`,使用服务名而不是 localhost 或 IP 地址。这是一个常见的混淆点,但这就是 Docker 网络的工作方式。
#### 在独立的 Docker Compose 文件中运行服务
如果你偏好独立管理服务或将 ASR 服务添加到现有 Speakr 安装中,你可以在单独的 Docker Compose 文件中运行它们。这种方法提供了更大的灵活性,无论服务是在同一台机器还是不同的机器上都可以工作。
##### 选项 1同一台机器共享网络
如果两个服务运行在同一台机器上,你可以使用 Docker 的内部网络进行通信:
首先,创建共享的 Docker 网络:
```bash
docker network create speakr-network
```
为 ASR 服务创建 `docker-compose.asr.yml`
```yaml
services:
whisper-asr:
image: onerahmet/openai-whisper-asr-webservice:latest-gpu
container_name: whisper-asr-webservice
ports:
- "9000:9000"
environment:
- ASR_MODEL=distil-large-v3
- ASR_COMPUTE_TYPE=int8
- ASR_ENGINE=whisperx
- HF_TOKEN=your_huggingface_token_here
deploy:
resources:
reservations:
devices:
- driver: nvidia
capabilities: [gpu]
device_ids: ["0"]
restart: unless-stopped
networks:
- speakr-network
networks:
speakr-network:
external: true
```
更新你的 Speakr `docker-compose.yml` 以使用共享网络:
```yaml
services:
app:
image: learnedmachine/speakr:latest
container_name: speakr
restart: unless-stopped
ports:
- "8899:8899"
env_file:
- .env
volumes:
- ./uploads:/data/uploads
- ./instance:/data/instance
networks:
- speakr-network
networks:
speakr-network:
external: true
```
在你的 `.env` 文件中,使用容器名:
```bash
ASR_BASE_URL=http://whisper-asr-webservice:9000
```
##### 选项 2不同机器
当在不同机器上运行时,你不需要共享网络。每个服务独立运行,并使用 IP 地址或主机名通过网络进行通信。
在 ASR 服务器上,创建 `docker-compose.asr.yml`
```yaml
services:
whisper-asr:
image: onerahmet/openai-whisper-asr-webservice:latest-gpu
container_name: whisper-asr-webservice
ports:
- "9000:9000" # 暴露到网络
environment:
- ASR_MODEL=distil-large-v3
- ASR_COMPUTE_TYPE=int8
- ASR_ENGINE=whisperx
- HF_TOKEN=your_huggingface_token_here
deploy:
resources:
reservations:
devices:
- driver: nvidia
capabilities: [gpu]
device_ids: ["0"]
restart: unless-stopped
```
在 Speakr 服务器上,使用标准的 `docker-compose.yml`
```yaml
services:
app:
image: learnedmachine/speakr:latest
container_name: speakr
restart: unless-stopped
ports:
- "8899:8899"
env_file:
- .env
volumes:
- ./uploads:/data/uploads
- ./instance:/data/instance
```
在你的 Speakr `.env` 文件中,使用 ASR 服务器的 IP 地址或主机名:
```bash
# 使用 IP 地址
ASR_BASE_URL=http://192.168.1.100:9000
# 或使用主机名
ASR_BASE_URL=http://asr-server.local:9000
```
在各自的机器上启动两个服务:
```bash
# 在 ASR 服务器上
docker compose -f docker-compose.asr.yml up -d
# 在 Speakr 服务器上
docker compose up -d
```
确保机器之间可以访问端口 9000如有需要请检查防火墙规则
## 生产环境注意事项
### 使用反向代理配置 SSL
对于生产环境部署,将 Speakr 运行在反向代理后面对于安全性和启用所有功能至关重要。浏览器录音功能,特别是系统音频捕获,由于浏览器安全限制需要 HTTPS 才能工作。反向代理处理 SSL 终止,意味着它管理 HTTPS 证书,同时在内部通过 HTTP 与 Speakr 通信。
以下是 nginx 的完整配置示例:
```nginx
server {
listen 443 ssl http2;
server_name speakr.yourdomain.com;
ssl_certificate /path/to/certificate.crt;
ssl_certificate_key /path/to/private.key;
# 安全头部
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
proxy_pass http://localhost:8899;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 实时功能的 WebSocket 支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 实时 ASR WebSocket 代理。如果存在更广泛的 /tool/speakr/ 路由,请将其放在前面。
# location /tool/speakr/ws/ {
# proxy_pass http://speakr:8899/ws/;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# proxy_read_timeout 3600s;
# }
# 大文件上传的超时设置
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
# 将 HTTP 重定向到 HTTPS
server {
listen 80;
server_name speakr.yourdomain.com;
return 301 https://$server_name$request_uri;
}
```
WebSocket 配置对 Speakr 的实时功能很重要。超时设置确保大文件上传不会被中断。你可以使用 Certbot 从 Let's Encrypt 获取免费的 SSL 证书,让每个人都能使用 HTTPS。
### 备份策略
定期备份对于生产环境部署至关重要。你的 Speakr 数据包含三个需要备份的关键组件:`instance` 目录中的 SQLite 数据库、`uploads` 目录中的音频文件和转录内容,以及 `.env` 文件中的配置。
创建一个备份脚本来捕获所有三个组件:
```bash
#!/bin/bash
BACKUP_DIR="/backup/speakr"
DATE=$(date +%Y%m%d_%H%M%S)
# 如果备份目录不存在则创建
mkdir -p "$BACKUP_DIR"
# 创建备份
tar czf "$BACKUP_DIR/speakr_backup_$DATE.tar.gz" \
/opt/speakr/instance \
/opt/speakr/uploads \
/opt/speakr/.env
# 可选:仅保留最近 30 天的备份
find "$BACKUP_DIR" -name "speakr_backup_*.tar.gz" -mtime +30 -delete
echo "备份完成speakr_backup_$DATE.tar.gz"
```
使脚本可执行并使用 cron 安排自动每日备份:
```bash
chmod +x /opt/speakr/backup.sh
crontab -e
# 添加此行以在每天凌晨 2 点进行备份:
0 2 * * * /opt/speakr/backup.sh
```
对于关键部署,考虑将备份复制到远程存储或云服务以获得额外的冗余。压缩后的备份大小通常远小于原始数据,因为音频文件压缩效果很好。
### 监控和维护
主动监控有助于在问题影响用户之前预防问题。音频文件会随着时间的推移占用大量存储空间,尤其是如果你经常录制较长的会议。设置磁盘空间监控,当使用率超过 80% 时发出警报。一种简单的监控方法是使用 cron 和 df
```bash
#!/bin/bash
USAGE=$(df /opt/speakr | tail -1 | awk '{print $5}' | sed 's/%//')
if [ $USAGE -gt 80 ]; then
echo "警告Speakr 磁盘使用率已达 ${USAGE}%" | mail -s "Speakr 磁盘警报" admin@example.com
fi
```
定期监控 Docker 容器的健康和日志。你可以使用 Docker 内置的健康检查功能或外部监控工具。检查是否存在重复的 API 失败、认证错误或处理超时等模式。同时跟踪你的 API 使用情况和成本,因为大量使用会产生可观的费用。
### 安全加固
生产环境部署需要比默认配置更多的安全措施。首先确保所有账户使用强密码,尤其是管理员账户。切勿在生产环境中使用默认或简单密码。
使用防火墙规则限制网络访问。如果 Speakr 仅在内部使用,请将访问限制为你组织的 IP 范围:
```bash
# 使用 ufw 的示例
ufw allow from 192.168.1.0/24 to any port 8899
ufw deny 8899
```
在反向代理级别实施速率限制以防止滥用和 API 耗尽。在 nginx 中,你可以添加:
```nginx
limit_req_zone $binary_remote_addr zone=speakr:10m rate=10r/s;
limit_req zone=speakr burst=20;
```
保持 Docker 镜像更新以获得最新的安全补丁。定期检查更新并规划维护窗口进行更新。更新前务必先备份,如果可能的话先在暂存环境中测试更新。
## 更新 Speakr
保持 Speakr 更新可确保你拥有最新的功能和安全补丁。更新过程很简单,但应谨慎操作以避免数据丢失。
首先,更新前务必创建备份:
```bash
# 创建备份
tar czf speakr_backup_before_update.tar.gz uploads/ instance/ .env
# 拉取最新镜像
docker compose pull
# 停止当前容器
docker compose down
# 使用新镜像启动
docker compose up -d
# 检查日志以确保成功启动
docker compose logs -f app
```
更新过程会保留你所有的数据,因为数据存储在容器外的挂载数据卷中。但是,查看发布说明很重要,因为某些更新可能需要配置更改或有需要注意的破坏性变更。
如果更新导致问题,你可以通过在 docker-compose.yml 中指定先前版本来回滚:
```yaml
image: learnedmachine/speakr:v1.2.3 # 替换为你之前的版本
```
## 常见问题排查
### 容器无法启动
当容器无法启动时,日志通常会准确告诉你问题所在。首先检查日志:
```bash
docker compose logs app
```
常见的启动问题包括缺失或格式不正确的 `.env` 文件。确保你的 `.env` 文件存在且具有正确的语法。每一行应该是 `KEY=value` 格式,等号周围不要有空格。注释以 `#` 开头。
端口冲突是另一个常见问题。检查端口 8899 是否已被占用:
```bash
netstat -tulpn | grep 8899
# 或在 macOS 上:
lsof -i :8899
```
如果端口被占用,请停止冲突的服务或更改 docker-compose.yml 中 Speakr 的端口。
### 转录失败
转录失败通常源于 API 配置问题。检查 Docker 日志中的具体错误消息:
```bash
docker compose logs app | grep -i error
```
常见的转录问题包括不正确的 API 密钥,这在日志中会显示为认证错误。仔细检查 `.env` 文件中的密钥并确保它们是正确的服务。API 额度不足会显示为配额或支付错误。检查你的 API 提供商账户余额。网络连接问题会显示为连接超时或 DNS 解析失败。
对于 ASR 端点,验证服务是否正在运行并可访问:
```bash
# 测试 ASR 端点连接
curl http://your-asr-service:9000/docs
```
如果使用 Docker 网络和服务名,请记住容器必须在同一网络上才能通信。
### 性能问题
性能缓慢可能有多种原因。首先检查系统资源:
```bash
# 检查内存使用情况
free -h
# 检查磁盘 I/O
iotop
# 检查 Docker 资源使用情况
docker stats speakr
```
如果内存紧张,考虑添加 swap 空间或升级你的服务器。对于磁盘 I/O 问题,确保 uploads 和 instance 目录使用 SSD 存储。传统硬盘会显著降低操作速度,尤其是多用户并发使用时。
对于大文件处理,确保正确配置了分段。没有分段的话,大文件可能会超时或完全失败。分段大小应略低于你的 API 限制,以考虑编码开销。
如果你在许多并发用户情况下看到转录缓慢,你可能触及了 API 速率限制。查看你的 API 提供商文档中的速率限制,如果需要请考虑升级你的计划。
### 浏览器录音问题
如果浏览器录音不起作用,尤其是系统音频,最常见的原因是你使用的是 HTTP 而不是 HTTPS。由于隐私问题浏览器需要安全连接才能进行音频捕获。要么使用反向代理设置 SSL要么仅用于本地开发修改浏览器的安全设置将你的本地 URL 视为安全。
在 Chrome 中,导航到 `chrome://flags`,搜索"insecure origins",并将你的 URL 添加到列表中。请记住这会降低安全性,仅应用于开发环境。
## 从源码构建
如果你需要修改 Speakr 的代码或偏好自行构建镜像,你可以从源码构建。这需要克隆仓库并使用 Docker 的构建功能。
首先,克隆仓库并进入目录:
```bash
git clone https://github.com/murtaza-nasir/speakr.git
cd speakr
```
修改 docker-compose.yml 以本地构建而不是使用预构建镜像:
```yaml
services:
app:
build: . # 从当前目录构建
image: speakr:custom # 自定义构建的标签
container_name: speakr
restart: unless-stopped
ports:
- "8899:8899"
env_file:
- .env
volumes:
- ./uploads:/data/uploads
- ./instance:/data/instance
```
构建并启动自定义版本:
```bash
docker compose up -d --build
```
`--build` 标志强制 Docker 即使已有镜像也重新构建。当你进行了代码更改并想测试时很有用。
## 性能优化
对于高负载部署或处理大量大文件时,优化变得很重要。如果使用 ASR首先从模型选择开始。distil-large-v3 模型在速度和准确性之间提供了极佳的平衡。对于纯英语内容,使用 `.en` 变体,它们在英语上更快更准确。
为你的工作负载优化 Docker 资源分配:
```yaml
services:
app:
image: learnedmachine/speakr:latest
deploy:
resources:
limits:
memory: 8G
cpus: '4'
reservations:
memory: 4G
cpus: '2'
```
这确保 Speakr 有足够的资源,同时防止它在共享服务器上消耗所有资源。
对于存储性能,为 Docker 数据卷使用 SSD 驱动器。数据库从快速随机 I/O 中获益良多,大音频文件处理在 SSD 上也会快得多。如果使用网络存储,请确保低延迟连接。
---
下一步:[用户指南](../user-guide/index.md) 了解如何使用 Speakr 的所有功能

156
speakr/docs/index.md Normal file
View File

@ -0,0 +1,156 @@
# 欢迎使用 Speakr
Speakr 是一款功能强大的自托管转录平台帮助您捕获、转录和理解音频内容。无论您是录制会议、采访、讲座还是个人笔记Speakr 都能将语音转化为有价值的、可搜索的知识。
<div style="max-width: 80%; margin: 2em auto;">
<img src="assets/images/screenshots/Filters.png" alt="Main Interface" style="border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);">
</div>
## 快速导航
<div class="grid cards">
<div class="card">
<div class="card-icon">📚</div>
<h3>快速开始</h3>
<p>初次使用 Speakr从这里开始快速了解并查看设置指南。</p>
<a href="getting-started" class="card-link">开始使用 →</a>
</div>
<div class="card">
<div class="card-icon">🚀</div>
<h3>安装指南</h3>
<p>Docker 和手动安装的逐步说明。</p>
<a href="getting-started/installation" class="card-link">立即安装 →</a>
</div>
<div class="card">
<div class="card-icon">👤</div>
<h3>用户指南</h3>
<p>学习如何<a href="user-guide/recording">录制</a><a href="user-guide/transcripts">转录</a>和管理您的音频内容。</p>
<a href="user-guide/" class="card-link">了解更多 →</a>
</div>
<div class="card">
<div class="card-icon">⚙️</div>
<h3>管理员指南</h3>
<p>配置<a href="admin-guide/user-management">用户</a><a href="admin-guide/prompts">系统设置</a>并管理您的实例。</p>
<a href="admin-guide/" class="card-link">进行配置 →</a>
</div>
<div class="card">
<div class="card-icon"></div>
<h3>常见问题</h3>
<p>查找关于 Speakr 的常见问题解答。</p>
<a href="faq" class="card-link">查看常见问题 →</a>
</div>
<div class="card">
<div class="card-icon">🔧</div>
<h3>故障排除</h3>
<p>解决<a href="troubleshooting#transcription-problems">转录问题</a><a href="troubleshooting#performance-issues">性能问题</a>的方案。</p>
<a href="troubleshooting" class="card-link">获取帮助 →</a>
</div>
</div>
## 核心功能
<div class="feature-grid">
<div class="feature-card">
<h4>🎙️ 智能录制</h4>
<ul>
<li>从麦克风或系统捕获音频</li>
<li>录制时做笔记</li>
<li>生成<a href="features#automatic-summarization">智能摘要</a></li>
</ul>
</div>
<div class="feature-card">
<h4>🤖 AI 转录</h4>
<ul>
<li><a href="features#language-support">多语言支持</a></li>
<li><a href="features#speaker-diarization">说话人识别</a></li>
<li>自定义词汇表</li>
</ul>
</div>
<div class="feature-card">
<h4>🔍 智能搜索</h4>
<ul>
<li><a href="user-guide/inquire-mode">语义搜索</a></li>
<li>自然语言查询</li>
<li>跨录音搜索</li>
</ul>
</div>
<div class="feature-card">
<h4>📊 内容组织</h4>
<ul>
<li><a href="features#tagging-system">智能标签系统</a></li>
<li><a href="admin-guide/prompts">自定义 AI 提示词</a></li>
<li><a href="features#speaker-management">参与者追踪</a></li>
</ul>
</div>
<div class="feature-card">
<h4>🌍 国际化</h4>
<ul>
<li>支持 5+ 种语言</li>
<li>自动 UI 翻译</li>
<li>本地化摘要</li>
</ul>
</div>
<div class="feature-card">
<h4>🔒 隐私优先</h4>
<ul>
<li><a href="getting-started/installation">支持自托管</a></li>
<li><a href="troubleshooting#offline-deployment">支持离线使用</a></li>
<li><a href="user-guide/sharing">安全分享</a></li>
</ul>
</div>
</div>
## 最新更新
!!! info "Version 0.5.6 - 最新版本"
- **事件提取** - 自动识别并从录音中导出日历事件
- **转录模板** - 使用灵活的模板系统自定义下载格式
- **增强的导出选项** - 以多种格式下载转录内容,适应不同使用场景
- **改进的用户界面** - 所有页面提供更好的移动端布局和加载遮罩
上一个版本 (v0.5.5):
- 完整的国际化与多语言支持
- 增强的 128kbps 音频处理以获得更高准确率
- 支持日期预设和多标签选择的高级过滤功能
## 获取帮助
需要协助?我们随时为您提供帮助:
<div class="help-grid">
<div class="help-card">
<h4>📖 文档</h4>
<p>您已经在这里!浏览我们的综合指南:</p>
<ul>
<li><a href="faq">常见问题解答</a></li>
<li><a href="troubleshooting">故障排除指南</a></li>
<li><a href="user-guide/">用户文档</a></li>
<li><a href="admin-guide/">管理员文档</a></li>
</ul>
</div>
<div class="help-card">
<h4>💬 社区</h4>
<p>与其他用户交流并获取支持:</p>
<ul>
<li><a href="https://github.com/murtaza-nasir/speakr/issues">报告问题</a></li>
<li><a href="https://github.com/murtaza-nasir/speakr/discussions">参与讨论</a></li>
<li><a href="https://github.com/murtaza-nasir/speakr">在 GitHub 上 Star</a></li>
</ul>
</div>
</div>
---
准备好将您的音频转化为可操作的洞察了吗?[立即开始](getting-started.md) →

View File

@ -0,0 +1,125 @@
// Custom JavaScript for Speakr documentation
document.addEventListener('DOMContentLoaded', function() {
// Add smooth scrolling for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
const href = this.getAttribute('href');
if (href === '#') return;
e.preventDefault();
const target = document.querySelector(href);
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
// Update URL without jumping
history.pushState(null, null, href);
}
});
});
// Add external link indicators
document.querySelectorAll('a[href^="http"]').forEach(link => {
if (!link.hostname.includes(window.location.hostname)) {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
// Add external icon if not already present
if (!link.querySelector('.external-icon')) {
const icon = document.createElement('span');
icon.className = 'external-icon';
icon.innerHTML = ' ↗';
icon.style.fontSize = '0.75em';
icon.style.verticalAlign = 'super';
link.appendChild(icon);
}
}
});
// Enhance search functionality
const searchInput = document.querySelector('.md-search__input');
if (searchInput) {
// Add keyboard shortcut hint
searchInput.setAttribute('placeholder', 'Search (Press "/" to focus)');
// Add "/" keyboard shortcut
document.addEventListener('keydown', function(e) {
if (e.key === '/' && !searchInput.matches(':focus')) {
e.preventDefault();
searchInput.focus();
}
});
}
// Add responsive tables wrapper
document.querySelectorAll('table').forEach(table => {
if (!table.parentElement.classList.contains('table-wrapper')) {
const wrapper = document.createElement('div');
wrapper.className = 'table-wrapper';
wrapper.style.overflowX = 'auto';
table.parentElement.insertBefore(wrapper, table);
wrapper.appendChild(table);
}
});
// Add image zoom functionality
document.querySelectorAll('.md-content img').forEach(img => {
img.style.cursor = 'zoom-in';
img.addEventListener('click', function() {
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.background = 'rgba(0, 0, 0, 0.9)';
overlay.style.zIndex = '9999';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.cursor = 'zoom-out';
const zoomedImg = document.createElement('img');
zoomedImg.src = img.src;
zoomedImg.style.maxWidth = '90%';
zoomedImg.style.maxHeight = '90%';
zoomedImg.style.objectFit = 'contain';
overlay.appendChild(zoomedImg);
document.body.appendChild(overlay);
overlay.addEventListener('click', function() {
document.body.removeChild(overlay);
});
// Close on Escape key
document.addEventListener('keydown', function closeOnEscape(e) {
if (e.key === 'Escape' && document.body.contains(overlay)) {
document.body.removeChild(overlay);
document.removeEventListener('keydown', closeOnEscape);
}
});
});
});
});
// Add loading indicator for slow operations
window.addEventListener('load', function() {
// Remove any loading indicators
const loader = document.querySelector('.page-loader');
if (loader) {
loader.style.display = 'none';
}
});
// Print-friendly styling
window.addEventListener('beforeprint', function() {
document.body.classList.add('print-mode');
});
window.addEventListener('afterprint', function() {
document.body.classList.remove('print-mode');
});

View File

@ -0,0 +1,16 @@
# MkDocs and Material theme
mkdocs>=1.5.3
mkdocs-material>=9.5.0
mkdocs-material-extensions>=1.3
# MkDocs plugins
mkdocs-minify-plugin>=0.7.2
mkdocs-git-revision-date-localized-plugin>=1.2.2
# Markdown extensions
pymdown-extensions>=10.5
pygments>=2.17.0
# Additional utilities
Pillow>=10.1.0 # For image optimization
cairosvg>=2.7.1 # For SVG support

View File

@ -0,0 +1,156 @@
# 后端代码改进工作记录
> 基于 2026-06-23 的代码审查,记录所有发现的问题及处理状态。
> 注:问题编号与分析报告一致。
---
## 一、暂不处理(鉴权相关,后续配合主系统调整)
| 编号 | 问题 | 原因 |
|------|------|------|
| #1 | `auto_login()` 完全绕过认证 | 鉴权在主系统完成,我们是补充子系统 |
| #2 | 未认证接口泄露敏感数据 | 同上 |
| #4 | CSRF 防护全局失效 | 同上 |
---
## 二、已处理
### #8 recording.py 下载接口重复代码抽取
- **状态**: ✅ 已完成
- **文件**: `src/services/document_service.py``src/api/recording.py`
- **修复**: 抽取 `add_unicode_paragraph()` / `create_docx_with_unicode_title()` / `save_and_send_docx()` 到 document_service.py4 个下载接口共减少约 280 行重复代码
### #9 recording.py 大文件拆分
- **状态**: ✅ 已完成
- **文件**: `src/api/recording_download.py`(新建)
- **修复**: 下载路由拆分到独立蓝图 `recording_download_bp`recording.py 从 1610 行降至约 1330 行;在 `api/__init__.py` 统一注册
### #3 字段名不匹配 Bug`error_msg` vs `error_message`
- **状态**: ✅ 已完成
- **文件**: `src/services/transcription_service.py:202, :581`
- **问题**: 模型字段是 `error_message`service 写入 `recording.error_msg`(不存在的属性),错误信息丢失
- **修复**: 两处 `error_msg` 改为 `error_message`
### #10 错误处理不一致且泄露内部异常
- **状态**: 🔴 已还原(撤销全局错误处理器和 `api_response.py`
- **文件**: `src/api/` 下所有蓝图文件
- **问题**: 51 处 `return jsonify({'error': str(e)}), 500` 把内部异常暴露给客户端
- **处理**: 曾创建 `api_response.py` 和全局错误处理器替换,但用户认为全局错误处理器不需要,已还原
- **后续**: 等 #11 统一返回和日志方案重新设计后再处理
### #5 硬编码默认密钥(仅警告)
- **状态**: ✅ 已完成(报警告,不阻止启动)
- **文件**: `src/config.py:17, :73`
- **处理**: `validate_config()` 已有警告逻辑,`get_app_config()` 中保留默认值但启动时打印警告
### #15 LLM/Voiceprint 客户端无超时和重试
- **状态**: ✅ 已完成
- **文件**: `src/clients/llm_client.py``src/clients/voiceprint_client.py`
- **修复**:
- llm_client: 添加 `httpx.Timeout(connect=10, read=300, write=60, pool=30)``print()` 改为 `logging`
- voiceprint_client: 改用持久化 `httpx.Client` 复用连接池添加指数退避重试3次细化异常类型
### #16 双入口文件端口不一致
- **状态**: ✅ 已完成
- **文件**: `src/app.py`
- **修复**: 端口和 debug 模式从环境变量 `FLASK_PORT` / `FLASK_DEBUG` 读取,默认 5000 / false
### #20 config.py 循环依赖风险
- **状态**: ✅ 已完成
- **文件**: `src/config.py``src/app.py``src/services/register.py`
- **修复**: `get_app_config()` 移除对 `src.clients` 的导入;新增 `init_early_services()` 在蓝图注册前初始化 `chunking_service``EMBEDDINGS_AVAILABLE`
### #21 admin.py 绕过 config 直接读 os.environ
- **状态**: ✅ 已完成
- **文件**: `src/api/admin.py:466-470``src/config.py`
- **修复**: 自动处理配置项(`ENABLE_AUTO_PROCESSING` 等 5 项)移入 config.pyadmin.py 从 `current_app.config` 读取
---
## 三、标记待做(高优先级)
### #11 统一错误返回格式
- **状态**: 🔴 已还原(撤销全局错误处理器和 `api_response.py`
- **处理**: 曾创建 `api_response.py` 和全局错误处理器,但已还原;后续需重新设计统一返回和日志方案
- **优先级**: 高(在 #6/#7/#12 之前)
### #19 统一日志格式
- **状态**: ✅ 已完成
- **修复**:
- `src/config.py`: `print()` 改为 `logging.warning()`
- `src/clients/llm_client.py`: `print()` 改为 `logging.info()` / `logging.error()`
- `src/api/draft_api.py`: 约 20 条英文日志全部改为中文
- `src/services/llm_service.py`: 约 15 条英文/混杂日志全部改为中文,语义更清晰
- `src/services/audio_chunking.py`: 约 40 条英文日志全部改为中文,统计和建议部分语义优化
- 所有日志语言统一为中文,语义表达便于调试
---
## 四、标记待做(性能优化,有空再处理)
### #6 N+1 查询优化
- **文件**: `src/api/admin.py:81-93, :645-652``src/models/recording.py:38-64`
- **问题**: admin 接口和 to_dict 列表场景存在 N+1 查询
- **处理**: 标记,当前系统规模无性能问题,有空再优化
- **优先级**: 中
### #7 后台线程管理优化
- **文件**: `src/api/recording.py` 多处 `threading.Thread`
- **问题**: 无线程池、无任务队列、无重试
- **处理**: 标记,当前无并发压力,后续引入 ThreadPoolExecutor 或 Celery
- **优先级**: 中(与 #6 一致)
### #12 缺少输入校验
- **问题**: POST/PUT 接口缺少 email 格式、密码强度、字段长度等校验
- **处理**: 标记,后续引入 marshmallow/pydantic
- **优先级**: 中(与 #6/#7 一致)
### #13 sync_service.py 每轮循环重建 enginePostgreSQL
- **文件**: `src/services/sync_service.py:78`
- **问题**: `create_engine` 在循环内每次重建,连接泄漏
- **优先级**: 高(与 #11 一致,数据库返工)
### #14 数据库迁移仅支持 SQLitePostgreSQL
- **文件**: `src/services/register.py:130-161`
- **问题**: `PRAGMA table_info` 是 SQLite 专有PostgreSQL 上不执行
- **优先级**: 高(与 #11 一致,数据库返工)
### #17 缺失数据库索引PostgreSQL
- **文件**: `src/models/recording.py`
- **问题**: 关键列无 `index=True`PostgreSQL 不会自动建索引
- **优先级**: 高(与 #11 一致,数据库返工)
### #18 datetime 时区不一致PostgreSQL
- **文件**: 多模型和 API 混用 `datetime.now` / `datetime.utcnow`
- **问题**: 跨时区部署时时间错乱
- **优先级**: 高(与 #11 一致,数据库返工)
---
## 进度追踪
| 编号 | 问题 | 状态 | 备注 |
|------|------|------|------|
| #8 | 下载接口重复代码抽取 | ✅ 已完成 | 优先级最高,已处理 |
| #9 | 大文件拆分 | ✅ 已完成 | 优先级最高,已处理 |
| #3 | error_msg 字段名修复 | ✅ 已完成 | 可改,已处理 |
| #10 | 错误处理统一 | 🔴 已还原 | 全局错误处理器不需要,已撤销 |
| #5 | 硬编码密钥警告 | ✅ 已完成 | 仅警告,已处理 |
| #15 | 客户端超时和重试 | ✅ 已完成 | 同 #3/#10 优先级 |
| #16 | 双入口文件端口 | ✅ 已完成 | 同 #3/#10 优先级 |
| #20 | config.py 循环依赖 | ✅ 已完成 | 同 #3/#10 优先级 |
| #21 | admin.py 绕过 config | ✅ 已完成 | 同 #3/#10 优先级 |
| #11 | 统一错误返回格式 | 🟡 部分完成 | 高优先级,需完善日志 |
| #19 | 统一日志格式 | ✅ 已完成 | 高优先级,已处理 |
| #13 | sync_service engine 复用 | 📋 待做 | 中优先级,数据库返工 |
| #14 | 数据库迁移跨库兼容 | 📋 待做 | 中优先级,数据库返工 |
| #17 | 数据库索引 | 📋 待做 | 中优先级,数据库返工 |
| #18 | 时区统一 | 📋 待做 | 中优先级,数据库返工 |
| #6 | N+1 查询优化 | 📋 待做 | 中优先级,优化 |
| #7 | 后台线程管理 | 📋 待做 | 中优先级,优化 |
| #12 | 输入校验 | 📋 待做 | 中优先级,优化 |
| #1 | 认证绕过 | ⏸️ 搁置 | 鉴权相关,暂不处理 |
| #2 | 未认证接口泄露数据 | ⏸️ 搁置 | 鉴权相关,暂不处理 |
| #4 | CSRF 防护失效 | ⏸️ 搁置 | 鉴权相关,暂不处理 |

Some files were not shown because too many files have changed in this diff Show More