915 lines
37 KiB
Python
915 lines
37 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks, File, UploadFile, Form, WebSocket, WebSocketDisconnect, status
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import or_, and_, func, desc
|
|
from app.core.db import get_db, SessionLocal
|
|
from app.core.deps import get_current_user
|
|
from app.core.security import decode_token
|
|
from app.models import Meeting, MeetingAttendee, User, TranscriptSegment, SummarizeTask, TranscriptTask, MeetingAudio, AIModel, Hotword
|
|
from app.schemas.meeting import MeetingOut, MeetingDetailOut, MeetingUpdate, MeetingListOut, MeetingCreate, AttendeeOut, MeetingAudioOut
|
|
from app.services.meeting_service import MeetingService
|
|
from typing import List, Optional
|
|
import shutil
|
|
from pathlib import Path
|
|
import uuid
|
|
from datetime import datetime
|
|
import asyncio
|
|
import websockets
|
|
import json
|
|
import logging
|
|
from redis import Redis
|
|
from app.core.redis import get_redis
|
|
from app.core.config import get_settings
|
|
from app.models import PromptTemplate
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/meetings", tags=["meetings"])
|
|
|
|
async def run_transcript_worker(task_id: str):
|
|
db = SessionLocal()
|
|
try:
|
|
await MeetingService.process_transcript_task(db, task_id)
|
|
finally:
|
|
db.close()
|
|
|
|
@router.post("/upload_temp")
|
|
async def upload_temp_file(
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
# Dynamically resolve storage path relative to project root (backend)
|
|
base_dir = Path(__file__).resolve().parents[4]
|
|
storage_dir = base_dir / "storage" / "uploads" / "temp"
|
|
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
file_ext = Path(file.filename).suffix
|
|
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
|
file_path = storage_dir / unique_filename
|
|
|
|
with file_path.open("wb") as buffer:
|
|
shutil.copyfileobj(file.file, buffer)
|
|
|
|
return {"file_path": str(file_path), "file_name": file.filename}
|
|
|
|
@router.post("", response_model=MeetingOut)
|
|
async def create_meeting(
|
|
meeting_in: MeetingCreate,
|
|
background_tasks: BackgroundTasks,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
meeting = Meeting(
|
|
title=meeting_in.title,
|
|
tags=meeting_in.tags,
|
|
meeting_time=meeting_in.meeting_time,
|
|
status="uploaded" if meeting_in.file_path else meeting_in.status,
|
|
type=meeting_in.type,
|
|
user_id=current_user.user_id,
|
|
asr_model_id=meeting_in.asr_model_id,
|
|
summary_model_id=meeting_in.summary_model_id,
|
|
summary_prompt_id=meeting_in.prompt_id,
|
|
)
|
|
db.add(meeting)
|
|
db.flush()
|
|
|
|
if meeting_in.participants:
|
|
# Deduplicate participants to avoid UniqueViolation
|
|
unique_participants = list(set(meeting_in.participants))
|
|
for user_id in unique_participants:
|
|
attendee = MeetingAttendee(meeting_id=meeting.meeting_id, user_id=user_id)
|
|
db.add(attendee)
|
|
|
|
if meeting_in.file_path:
|
|
p = Path(meeting_in.file_path)
|
|
if p.exists():
|
|
audio = MeetingAudio(
|
|
meeting_id=meeting.meeting_id,
|
|
file_path=str(p),
|
|
file_name=p.name,
|
|
file_size=p.stat().st_size,
|
|
processing_status="uploaded"
|
|
)
|
|
db.add(audio)
|
|
db.flush() # Ensure audio ID is generated and accessible
|
|
|
|
final_model_id = meeting.asr_model_id
|
|
if not final_model_id:
|
|
default_model = db.query(AIModel).filter(AIModel.model_type == 'asr').first()
|
|
if default_model:
|
|
final_model_id = default_model.model_id
|
|
meeting.asr_model_id = final_model_id
|
|
|
|
try:
|
|
print(f"[DEBUG] Creating transcript task for meeting {meeting.meeting_id} with model {final_model_id}")
|
|
task = await MeetingService.create_transcript_task(
|
|
db,
|
|
meeting_id=meeting.meeting_id,
|
|
model_id=final_model_id
|
|
)
|
|
print(f"[DEBUG] Task created: {task.task_id}, scheduling background worker")
|
|
background_tasks.add_task(run_transcript_worker, task.task_id)
|
|
except Exception as e:
|
|
print(f"[ERROR] Failed to start transcription task: {e}")
|
|
|
|
db.commit()
|
|
db.refresh(meeting)
|
|
return meeting
|
|
|
|
@router.post("/{meeting_id}/upload_audio")
|
|
async def upload_meeting_audio(
|
|
meeting_id: int,
|
|
background_tasks: BackgroundTasks,
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
|
|
if not meeting:
|
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
|
|
|
# Save File
|
|
base_dir = Path(__file__).resolve().parents[4]
|
|
storage_dir = base_dir / "storage" / "uploads" / str(meeting_id)
|
|
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
file_ext = Path(file.filename).suffix
|
|
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
|
file_path = storage_dir / unique_filename
|
|
|
|
with file_path.open("wb") as buffer:
|
|
shutil.copyfileobj(file.file, buffer)
|
|
|
|
# Create MeetingAudio record
|
|
audio = MeetingAudio(
|
|
meeting_id=meeting.meeting_id,
|
|
file_path=str(file_path),
|
|
file_name=file.filename,
|
|
file_size=file_path.stat().st_size,
|
|
processing_status="uploaded"
|
|
)
|
|
db.add(audio)
|
|
db.commit()
|
|
db.refresh(audio)
|
|
|
|
# Trigger Transcription Task
|
|
# For live meetings, the audio upload is just a backup/archive.
|
|
# Transcription is handled in real-time via WebSocket, so we don't trigger it here
|
|
# to avoid overwriting the status or duplicating work.
|
|
if meeting.type != 'live':
|
|
final_model_id = meeting.asr_model_id
|
|
if not final_model_id:
|
|
default_model = db.query(AIModel).filter(AIModel.model_type == 'asr').first()
|
|
if default_model:
|
|
final_model_id = default_model.model_id
|
|
meeting.asr_model_id = final_model_id
|
|
db.commit()
|
|
|
|
try:
|
|
print(f"[DEBUG] Triggering transcript task for uploaded audio: meeting {meeting_id}")
|
|
task = await MeetingService.create_transcript_task(
|
|
db,
|
|
meeting_id=meeting.meeting_id,
|
|
model_id=final_model_id
|
|
)
|
|
background_tasks.add_task(run_transcript_worker, task.task_id)
|
|
except Exception as e:
|
|
print(f"[ERROR] Failed to start transcription task for upload: {e}")
|
|
else:
|
|
print(f"[DEBUG] Skipping transcript task for live meeting audio upload: meeting {meeting_id}")
|
|
|
|
return {"status": "success", "file_path": str(file_path)}
|
|
|
|
|
|
@router.post("/upload", response_model=MeetingOut)
|
|
async def create_meeting_with_upload(
|
|
background_tasks: BackgroundTasks,
|
|
file: UploadFile = File(...),
|
|
title: str = Form(...),
|
|
participants: Optional[List[int]] = Form(None),
|
|
prompt_id: Optional[int] = Form(None),
|
|
asr_model_id: Optional[int] = Form(None),
|
|
summary_model_id: Optional[int] = Form(None),
|
|
tags: Optional[List[str]] = Form(None),
|
|
meeting_time: Optional[str] = Form(None),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
# Process meeting time
|
|
mt = datetime.utcnow()
|
|
if meeting_time:
|
|
try:
|
|
# Attempt to parse timestamp (ms)
|
|
mt = datetime.fromtimestamp(int(meeting_time) / 1000.0)
|
|
except:
|
|
pass
|
|
|
|
# Create Meeting
|
|
meeting = Meeting(
|
|
title=title,
|
|
tags=",".join(tags) if tags else None, # Store as comma separated string if that's the convention, or modify model
|
|
meeting_time=mt,
|
|
status="uploaded", # Initial status for uploaded meeting
|
|
user_id=current_user.user_id,
|
|
asr_model_id=asr_model_id,
|
|
summary_model_id=summary_model_id,
|
|
)
|
|
db.add(meeting)
|
|
db.flush() # Get meeting_id
|
|
|
|
# Handle Participants
|
|
if participants:
|
|
for user_id in participants:
|
|
attendee = MeetingAttendee(meeting_id=meeting.meeting_id, user_id=user_id)
|
|
db.add(attendee)
|
|
|
|
# Save File
|
|
base_dir = Path(__file__).resolve().parents[4]
|
|
storage_dir = base_dir / "storage" / "uploads"
|
|
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
file_ext = Path(file.filename).suffix
|
|
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
|
file_path = storage_dir / unique_filename
|
|
|
|
with file_path.open("wb") as buffer:
|
|
shutil.copyfileobj(file.file, buffer)
|
|
|
|
# Create MeetingAudio record
|
|
audio = MeetingAudio(
|
|
meeting_id=meeting.meeting_id,
|
|
file_path=str(file_path),
|
|
file_name=file.filename,
|
|
file_size=file_path.stat().st_size,
|
|
processing_status="uploaded"
|
|
)
|
|
db.add(audio)
|
|
|
|
db.commit()
|
|
db.refresh(meeting)
|
|
|
|
# Trigger Transcription Task
|
|
# If no model provided, try to find default
|
|
final_model_id = asr_model_id
|
|
if not final_model_id:
|
|
default_model = db.query(AIModel).filter(AIModel.model_type == 'asr').first()
|
|
if default_model:
|
|
final_model_id = default_model.model_id
|
|
# Update meeting with default model
|
|
meeting.asr_model_id = final_model_id
|
|
db.commit()
|
|
|
|
# Only create task if we have a model (or let service handle it if we want to support auto-detect later)
|
|
# But for now, let's just pass what we have.
|
|
# Service create_transcript_task expects model_id.
|
|
# If we pass None, it will be stored as None in TranscriptTask.
|
|
|
|
try:
|
|
task = await MeetingService.create_transcript_task(
|
|
db,
|
|
meeting_id=meeting.meeting_id,
|
|
model_id=final_model_id
|
|
)
|
|
background_tasks.add_task(run_transcript_worker, task.task_id)
|
|
except Exception as e:
|
|
# Log error but don't fail the upload response
|
|
print(f"Failed to start transcription task: {e}")
|
|
|
|
return meeting
|
|
|
|
async def run_summarize_worker(task_id: str):
|
|
db = SessionLocal()
|
|
try:
|
|
await MeetingService.process_summarize_task(db, task_id)
|
|
finally:
|
|
db.close()
|
|
|
|
@router.post("/{meeting_id}/transcript")
|
|
async def create_transcript_task(
|
|
meeting_id: int,
|
|
background_tasks: BackgroundTasks,
|
|
model_id: int = Query(..., description="ASR Model ID"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Manually trigger a transcript task for a meeting.
|
|
Useful for retrying failed tasks or re-transcribing with a different model.
|
|
"""
|
|
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
|
|
if not meeting:
|
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
|
|
|
try:
|
|
task = await MeetingService.create_transcript_task(
|
|
db,
|
|
meeting_id=meeting_id,
|
|
model_id=model_id
|
|
)
|
|
background_tasks.add_task(run_transcript_worker, task.task_id)
|
|
return task
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.post("/{meeting_id}/summarize")
|
|
async def start_summarize(
|
|
meeting_id: int,
|
|
payload: dict,
|
|
background_tasks: BackgroundTasks,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
try:
|
|
task = await MeetingService.create_summarize_task(
|
|
db,
|
|
meeting_id,
|
|
payload.get("prompt_id"),
|
|
payload.get("model_id"),
|
|
payload.get("extra_prompt", "")
|
|
)
|
|
background_tasks.add_task(run_summarize_worker, task.task_id)
|
|
return {"task_id": task.task_id, "status": task.status}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
@router.get("/tasks", response_model=dict)
|
|
def list_all_tasks(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
task_type: Optional[str] = Query(None), # summarize, transcript
|
|
status: Optional[str] = Query(None),
|
|
):
|
|
"""
|
|
统一任务监控接口,合并转译和总结任务
|
|
"""
|
|
tasks = []
|
|
|
|
# 1. 获取总结任务
|
|
sum_query = db.query(SummarizeTask, Meeting.title, User.username).join(
|
|
Meeting, SummarizeTask.meeting_id == Meeting.meeting_id
|
|
).join(
|
|
User, Meeting.user_id == User.user_id
|
|
)
|
|
|
|
if status:
|
|
sum_query = sum_query.filter(SummarizeTask.status == status)
|
|
|
|
for t, m_title, username in sum_query.order_by(desc(SummarizeTask.created_at)).limit(50).all():
|
|
tasks.append({
|
|
"task_id": t.task_id,
|
|
"type": "总结",
|
|
"meeting_title": m_title,
|
|
"creator": username,
|
|
"status": t.status,
|
|
"progress": t.progress,
|
|
"created_at": t.created_at,
|
|
"meeting_id": t.meeting_id
|
|
})
|
|
|
|
# 2. 获取转译任务
|
|
trans_query = db.query(TranscriptTask, Meeting.title, User.username).join(
|
|
Meeting, TranscriptTask.meeting_id == Meeting.meeting_id
|
|
).join(
|
|
User, Meeting.user_id == User.user_id
|
|
)
|
|
|
|
if status:
|
|
trans_query = trans_query.filter(TranscriptTask.status == status)
|
|
|
|
for t, m_title, username in trans_query.order_by(desc(TranscriptTask.created_at)).limit(50).all():
|
|
tasks.append({
|
|
"task_id": t.task_id,
|
|
"type": "转译",
|
|
"meeting_title": m_title,
|
|
"creator": username,
|
|
"status": t.status,
|
|
"progress": t.progress,
|
|
"created_at": t.created_at,
|
|
"meeting_id": t.meeting_id
|
|
})
|
|
|
|
# 按时间全局排序
|
|
tasks.sort(key=lambda x: x["created_at"], reverse=True)
|
|
|
|
return {"items": tasks}
|
|
|
|
@router.websocket("/{meeting_id}/ws")
|
|
async def websocket_meeting(
|
|
websocket: WebSocket,
|
|
meeting_id: int,
|
|
token: str = Query(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Real-time meeting WebSocket endpoint.
|
|
Proxies audio to local ASR service and saves transcripts to DB.
|
|
"""
|
|
# 1. Authenticate
|
|
try:
|
|
payload = decode_token(token)
|
|
user_id = payload.get("sub")
|
|
if not user_id:
|
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
return
|
|
user = db.query(User).filter(User.user_id == user_id).first()
|
|
if not user:
|
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
return
|
|
|
|
# Check meeting access
|
|
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
|
|
if not meeting:
|
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
return
|
|
|
|
# Check if user is creator or attendee
|
|
is_attendee = any(a.user_id == user.user_id for a in meeting.attendees)
|
|
if meeting.user_id != user.user_id and not is_attendee:
|
|
# Check admin
|
|
role_codes = [ur.role.role_code for ur in user.roles]
|
|
if "admin" not in role_codes and "superuser" not in role_codes:
|
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
return
|
|
|
|
except Exception as e:
|
|
logger.error(f"WebSocket auth failed: {e}")
|
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
return
|
|
|
|
await websocket.accept()
|
|
if meeting and meeting.type == "live" and meeting.status != "transcribing":
|
|
meeting.status = "transcribing"
|
|
db.commit()
|
|
|
|
# 2. Connect to ASR Service
|
|
asr_ws_url = "ws://127.0.0.1:3050"
|
|
|
|
# Determine ASR Model URL based on meeting configuration
|
|
model_id = meeting.asr_model_id
|
|
if not model_id:
|
|
# Fallback to default model
|
|
default_model = db.query(AIModel).filter(AIModel.model_type == 'asr', AIModel.is_default == 1).first()
|
|
if not default_model:
|
|
default_model = db.query(AIModel).filter(AIModel.model_type == 'asr').first()
|
|
if default_model:
|
|
model_id = default_model.model_id
|
|
|
|
if model_id:
|
|
model = db.query(AIModel).filter(AIModel.model_id == model_id).first()
|
|
if model and model.base_url:
|
|
base = model.base_url.rstrip('/')
|
|
if base.startswith("https://"):
|
|
asr_ws_url = base.replace("https://", "wss://")
|
|
elif base.startswith("http://"):
|
|
asr_ws_url = base.replace("http://", "ws://")
|
|
else:
|
|
# If no protocol specified, assume ws://
|
|
if "://" not in base:
|
|
asr_ws_url = f"ws://{base}"
|
|
else:
|
|
asr_ws_url = base
|
|
|
|
logger.info(f"Connecting to ASR at {asr_ws_url} for meeting {meeting_id}")
|
|
|
|
try:
|
|
async with websockets.connect(asr_ws_url) as asr_ws:
|
|
# Send initial configuration to ASR if needed
|
|
# Model-Kf expects configuration first usually, but defaults might work.
|
|
# Let's send a default config based on schema
|
|
hotword_filters = [Hotword.scope.in_(["public", "global"])]
|
|
if meeting.user_id:
|
|
hotword_filters.append(
|
|
(Hotword.scope == "personal") & (Hotword.user_id == meeting.user_id)
|
|
)
|
|
hotwords = db.query(Hotword).filter(
|
|
(hotword_filters[0]) if len(hotword_filters) == 1 else (hotword_filters[0] | hotword_filters[1])
|
|
).all()
|
|
hotword_list = []
|
|
hotword_string_parts = []
|
|
for hw in hotwords:
|
|
token = hw.word
|
|
if token:
|
|
hotword_list.append(token)
|
|
hotword_string_parts.append(token)
|
|
hotword_string = " ".join([p for p in hotword_string_parts if p])
|
|
|
|
init_msg = {
|
|
"mode": "2pass",
|
|
"wav_name": "microphone",
|
|
"is_speaking": True,
|
|
"chunk_size": [5, 10, 5],
|
|
"chunk_interval": 10,
|
|
"itn": False,
|
|
"hotwords": hotword_list,
|
|
"hotword": hotword_string
|
|
}
|
|
await asr_ws.send(json.dumps(init_msg))
|
|
|
|
# Create tasks for bidirectional forwarding
|
|
async def forward_client_to_asr():
|
|
try:
|
|
while True:
|
|
data = await websocket.receive()
|
|
if "bytes" in data:
|
|
await asr_ws.send(data["bytes"])
|
|
elif "text" in data:
|
|
# Forward control messages (JSON)
|
|
await asr_ws.send(data["text"])
|
|
except WebSocketDisconnect:
|
|
logger.info("Client disconnected")
|
|
except Exception as e:
|
|
logger.error(f"Client->ASR error: {e}")
|
|
|
|
async def forward_asr_to_client():
|
|
try:
|
|
last_saved_end_ms = 0
|
|
|
|
def parse_number(v):
|
|
if isinstance(v, (int, float)):
|
|
return float(v)
|
|
if isinstance(v, str):
|
|
try:
|
|
return float(v.strip())
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
def parse_bool(v):
|
|
if isinstance(v, bool):
|
|
return v
|
|
if isinstance(v, str):
|
|
return v.strip().lower() in ("true", "1", "yes", "y", "t")
|
|
if isinstance(v, (int, float)):
|
|
return int(v) == 1
|
|
return False
|
|
|
|
def extract_text(d: dict) -> str:
|
|
t = d.get("text")
|
|
if isinstance(t, str):
|
|
return t
|
|
result = d.get("result")
|
|
if isinstance(result, dict):
|
|
rt = result.get("text")
|
|
if isinstance(rt, str):
|
|
return rt
|
|
return ""
|
|
|
|
async for message in asr_ws:
|
|
# Parse message
|
|
try:
|
|
msg_data = json.loads(message)
|
|
|
|
# Forward to client
|
|
await websocket.send_text(message)
|
|
|
|
raw_is_final = msg_data.get("is_final")
|
|
raw_final = msg_data.get("final")
|
|
raw_status = msg_data.get("status")
|
|
is_final = parse_bool(raw_is_final) or parse_bool(raw_final)
|
|
if not is_final:
|
|
if isinstance(raw_status, str):
|
|
is_final = raw_status.strip().lower() in ("final", "finished", "done", "completed", "complete")
|
|
elif isinstance(raw_status, (int, float)):
|
|
is_final = int(raw_status) in (1, 2)
|
|
|
|
text = extract_text(msg_data).strip()
|
|
if is_final is True and text:
|
|
speaker_tag = msg_data.get("speaker", "Unknown")
|
|
|
|
start_ms = None
|
|
end_ms = None
|
|
|
|
timestamp = msg_data.get("timestamp")
|
|
if isinstance(timestamp, list) and len(timestamp) > 0:
|
|
try:
|
|
starts = []
|
|
ends = []
|
|
for pair in timestamp:
|
|
if not isinstance(pair, (list, tuple)) or len(pair) < 2:
|
|
continue
|
|
s = parse_number(pair[0])
|
|
e = parse_number(pair[1])
|
|
if s is not None and e is not None:
|
|
starts.append(s)
|
|
ends.append(e)
|
|
if starts and ends:
|
|
raw_start = min(starts)
|
|
raw_end = max(ends)
|
|
if raw_end < raw_start:
|
|
raise ValueError("timestamp end < start")
|
|
# Heuristic: if ASR returns seconds, convert to ms
|
|
if raw_end < 1000:
|
|
raw_start *= 1000.0
|
|
raw_end *= 1000.0
|
|
start_ms = int(raw_start)
|
|
end_ms = int(raw_end)
|
|
except Exception:
|
|
start_ms = None
|
|
end_ms = None
|
|
|
|
if start_ms is None or end_ms is None:
|
|
bt = msg_data.get("begin_time")
|
|
et = msg_data.get("end_time")
|
|
bt_num = parse_number(bt)
|
|
et_num = parse_number(et)
|
|
if bt_num is not None and et_num is not None:
|
|
if et_num < 1000 and bt_num < 1000:
|
|
bt_num *= 1000.0
|
|
et_num *= 1000.0
|
|
start_ms = int(bt_num)
|
|
end_ms = int(et_num)
|
|
|
|
if start_ms is None or end_ms is None or end_ms < start_ms:
|
|
start_ms = last_saved_end_ms + 1
|
|
end_ms = start_ms + 1000
|
|
last_saved_end_ms = max(last_saved_end_ms, end_ms)
|
|
|
|
with SessionLocal() as local_db:
|
|
existing = local_db.query(TranscriptSegment).filter(
|
|
TranscriptSegment.meeting_id == meeting_id,
|
|
TranscriptSegment.speaker_tag == speaker_tag,
|
|
TranscriptSegment.start_time_ms == start_ms,
|
|
TranscriptSegment.end_time_ms == end_ms,
|
|
).order_by(TranscriptSegment.segment_id.desc()).first()
|
|
|
|
if existing:
|
|
existing.text_content = text
|
|
local_db.commit()
|
|
else:
|
|
segment = TranscriptSegment(
|
|
meeting_id=meeting_id,
|
|
speaker_id=0,
|
|
speaker_tag=speaker_tag,
|
|
start_time_ms=start_ms,
|
|
end_time_ms=end_ms,
|
|
text_content=text,
|
|
)
|
|
local_db.add(segment)
|
|
local_db.commit()
|
|
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
except Exception as e:
|
|
logger.error(f"ASR->Client error: {e}")
|
|
|
|
# Run both tasks
|
|
done, pending = await asyncio.wait(
|
|
[asyncio.create_task(forward_client_to_asr()),
|
|
asyncio.create_task(forward_asr_to_client())],
|
|
return_when=asyncio.FIRST_COMPLETED
|
|
)
|
|
|
|
for task in pending:
|
|
task.cancel()
|
|
|
|
# 3. Post-Meeting Processing (Auto-Summary)
|
|
logger.info(f"WebSocket session ended for meeting {meeting_id}. Checking for auto-summary...")
|
|
|
|
# Use a new session for post-processing to ensure clean state
|
|
with SessionLocal() as local_db:
|
|
# Update meeting status to 'transcribed'
|
|
m = local_db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
|
|
if m and m.status == 'transcribing':
|
|
m.status = 'transcribed'
|
|
local_db.commit()
|
|
logger.info(f"Meeting {meeting_id} status updated to 'transcribed'")
|
|
|
|
# Check Auto-Summary Setting
|
|
settings = get_settings()
|
|
if getattr(settings, "AUTO_SUMMARIZE", True): # Default to True if not set
|
|
try:
|
|
# Find default LLM Model
|
|
llm_model = None
|
|
if m.summary_model_id:
|
|
llm_model = local_db.query(AIModel).filter(
|
|
AIModel.model_id == m.summary_model_id,
|
|
AIModel.status == 1
|
|
).first()
|
|
if not llm_model:
|
|
llm_model = local_db.query(AIModel).filter(
|
|
AIModel.model_type == 'llm',
|
|
AIModel.is_default == 1,
|
|
AIModel.status == 1
|
|
).first()
|
|
|
|
# Find Prompt (Use meeting-specific or default)
|
|
prompt_tmpl = None
|
|
if m.summary_prompt_id:
|
|
prompt_tmpl = local_db.query(PromptTemplate).filter(
|
|
PromptTemplate.id == m.summary_prompt_id
|
|
).first()
|
|
|
|
if not prompt_tmpl:
|
|
prompt_tmpl = local_db.query(PromptTemplate).filter(
|
|
PromptTemplate.status == 1
|
|
).order_by(PromptTemplate.is_system.desc(), PromptTemplate.id.asc()).first()
|
|
|
|
if llm_model and prompt_tmpl:
|
|
logger.info(f"Auto-triggering summary for meeting {meeting_id}")
|
|
|
|
# Create summarize task
|
|
# Note: create_summarize_task is async and commits to DB
|
|
# We need to await it
|
|
new_sum_task = await MeetingService.create_summarize_task(
|
|
local_db,
|
|
meeting_id=meeting_id,
|
|
prompt_id=prompt_tmpl.id,
|
|
model_id=llm_model.model_id
|
|
)
|
|
|
|
# Launch worker in background
|
|
asyncio.create_task(MeetingService.run_summarize_worker(new_sum_task.task_id))
|
|
|
|
# Update meeting status to 'summarizing'
|
|
m.status = 'summarizing'
|
|
local_db.commit()
|
|
logger.info(f"Meeting {meeting_id} status updated to 'summarizing'")
|
|
else:
|
|
logger.warning(f"Skipping auto-summary: No default LLM or Prompt found for meeting {meeting_id}")
|
|
except Exception as sum_e:
|
|
logger.error(f"Failed to auto-trigger summary for meeting {meeting_id}: {sum_e}")
|
|
else:
|
|
logger.info(f"Auto-summary disabled for meeting {meeting_id}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to connect to ASR service: {e}")
|
|
await websocket.close(code=status.WS_1011_INTERNAL_ERROR)
|
|
|
|
@router.get("/tasks/{task_id}")
|
|
async def get_task_progress(
|
|
task_id: str,
|
|
db: Session = Depends(get_db),
|
|
redis: Redis = Depends(get_redis),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
task = MeetingService.get_task_status(db, task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
# Fetch real-time status message from Redis
|
|
message = None
|
|
try:
|
|
msg_bytes = await redis.get(f"task:status:{task_id}")
|
|
if msg_bytes:
|
|
# redis.get in async mode (aioredis) returns bytes or str depending on decode_responses
|
|
# If decode_responses=True (which is common in FastAPI setups), it's already str.
|
|
# If it's bytes, we decode. If it's str, we use it directly.
|
|
if isinstance(msg_bytes, bytes):
|
|
message = msg_bytes.decode("utf-8")
|
|
else:
|
|
message = str(msg_bytes)
|
|
except Exception as e:
|
|
# Use logging.warning instead of logger.warning if logger is not defined globally
|
|
logging.warning(f"Failed to get task status message from Redis: {e}")
|
|
|
|
return {
|
|
"task_id": task.task_id,
|
|
"status": task.status,
|
|
"progress": task.progress,
|
|
"result": getattr(task, "result", None),
|
|
"error": task.error_message,
|
|
"message": message
|
|
}
|
|
|
|
# ... 其余接口保持不变
|
|
@router.get("", response_model=MeetingListOut)
|
|
def list_meetings(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
scope: str = Query("all", description="all, created, joined"),
|
|
keyword: Optional[str] = Query(None),
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(10, ge=1, le=100),
|
|
):
|
|
id_query = db.query(Meeting.meeting_id, Meeting.meeting_time)
|
|
if scope == "created":
|
|
id_query = id_query.filter(Meeting.user_id == current_user.user_id)
|
|
elif scope == "joined":
|
|
id_query = id_query.join(MeetingAttendee).filter(MeetingAttendee.user_id == current_user.user_id)
|
|
else:
|
|
id_query = id_query.outerjoin(MeetingAttendee).filter(
|
|
or_(Meeting.user_id == current_user.user_id, MeetingAttendee.user_id == current_user.user_id)
|
|
)
|
|
if keyword:
|
|
id_query = id_query.filter(Meeting.title.contains(keyword))
|
|
id_query = id_query.distinct()
|
|
total = id_query.count()
|
|
paged_ids = id_query.order_by(
|
|
Meeting.meeting_time.desc(),
|
|
Meeting.meeting_id.desc()
|
|
).offset((page - 1) * page_size).limit(page_size).subquery()
|
|
results = db.query(Meeting).join(
|
|
paged_ids, Meeting.meeting_id == paged_ids.c.meeting_id
|
|
).order_by(
|
|
paged_ids.c.meeting_time.desc(),
|
|
paged_ids.c.meeting_id.desc()
|
|
).all()
|
|
items = []
|
|
for m in results:
|
|
item = MeetingOut.model_validate(m)
|
|
item.creator_name = m.creator.display_name if m.creator else "Unknown"
|
|
item.creator_avatar = m.creator.avatar if m.creator else None
|
|
items.append(item)
|
|
return {"items": items, "total": total}
|
|
|
|
@router.get("/{meeting_id}/audio/{audio_id}")
|
|
def get_meeting_audio(
|
|
meeting_id: int,
|
|
audio_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
|
|
if not meeting:
|
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
|
|
|
is_attendee = any(a.user_id == current_user.user_id for a in meeting.attendees)
|
|
if meeting.user_id != current_user.user_id and not is_attendee:
|
|
role_codes = [ur.role.role_code for ur in current_user.roles]
|
|
if "admin" not in role_codes and "superuser" not in role_codes:
|
|
raise HTTPException(status_code=403, detail="Not authorized")
|
|
|
|
audio = db.query(MeetingAudio).filter(
|
|
MeetingAudio.audio_id == audio_id,
|
|
MeetingAudio.meeting_id == meeting_id
|
|
).first()
|
|
if not audio:
|
|
raise HTTPException(status_code=404, detail="Audio not found")
|
|
|
|
file_path = Path(audio.file_path)
|
|
if not file_path.exists():
|
|
raise HTTPException(status_code=404, detail="Audio file missing")
|
|
|
|
return FileResponse(path=str(file_path), filename=audio.file_name or file_path.name)
|
|
|
|
@router.get("/{meeting_id}", response_model=MeetingDetailOut)
|
|
def get_meeting_detail(
|
|
meeting_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
|
|
if not meeting:
|
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
|
|
|
is_attendee = any(a.user_id == current_user.user_id for a in meeting.attendees)
|
|
if meeting.user_id != current_user.user_id and not is_attendee:
|
|
# Check if admin
|
|
role_codes = [ur.role.role_code for ur in current_user.roles]
|
|
if "admin" not in role_codes and "superuser" not in role_codes:
|
|
raise HTTPException(status_code=403, detail="Not authorized")
|
|
|
|
item = MeetingDetailOut.model_validate(meeting)
|
|
item.creator_name = meeting.creator.display_name if meeting.creator else "Unknown"
|
|
item.creator_avatar = meeting.creator.avatar if meeting.creator else None
|
|
|
|
# Keep ASR arrival order: segment_id reflects insertion order
|
|
if item.segments:
|
|
item.segments = sorted(item.segments, key=lambda x: x.segment_id)
|
|
|
|
# Fetch latest transcript task
|
|
latest_trans = db.query(TranscriptTask).filter(
|
|
TranscriptTask.meeting_id == meeting_id
|
|
).order_by(desc(TranscriptTask.created_at)).first()
|
|
|
|
if latest_trans:
|
|
item.latest_transcript_task = latest_trans
|
|
|
|
# Fetch latest summarize task
|
|
latest_sum = db.query(SummarizeTask).filter(
|
|
SummarizeTask.meeting_id == meeting_id
|
|
).order_by(desc(SummarizeTask.created_at)).first()
|
|
|
|
if latest_sum:
|
|
item.latest_summarize_task = latest_sum
|
|
|
|
settings = get_settings()
|
|
audios = db.query(MeetingAudio).filter(
|
|
MeetingAudio.meeting_id == meeting_id
|
|
).order_by(desc(MeetingAudio.upload_time)).all()
|
|
item.audio_files = []
|
|
for audio in audios:
|
|
audio_item = MeetingAudioOut.model_validate(audio)
|
|
audio_item.audio_url = f"{settings.api_v1_prefix}/meetings/{meeting_id}/audio/{audio.audio_id}"
|
|
item.audio_files.append(audio_item)
|
|
|
|
return item
|
|
|
|
@router.delete("/{meeting_id}")
|
|
def delete_meeting(
|
|
meeting_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
|
|
if not meeting:
|
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
|
if meeting.user_id != current_user.user_id:
|
|
role_codes = [ur.role.role_code for ur in current_user.roles]
|
|
if "admin" not in role_codes and "superuser" not in role_codes:
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
db.delete(meeting)
|
|
db.commit()
|
|
return {"status": "ok"}
|