refactor(api): TOML 配置 SSOT、统一错误契约、Auth/事务加固与可观测性 (#33)
配置 SSOT(TOML + .env) 统一错误契约 Auth 与事务边界 Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client 可观测性(OpenTelemetry + LGTM)
This commit is contained in:
@@ -14,7 +14,7 @@ from app.core.logging import get_logger
|
||||
if TYPE_CHECKING:
|
||||
from app.features.quota.service import QuotaService
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.agents.chat import ChatOrchestrator
|
||||
@@ -27,6 +27,10 @@ from app.core.cos_url_keys import (
|
||||
extract_cos_object_key_if_owned,
|
||||
)
|
||||
from app.core.db import AsyncSessionLocal
|
||||
from app.features.conversation.ws.persist import (
|
||||
persist_message_tts_url_segment,
|
||||
persist_voice_segment_row,
|
||||
)
|
||||
from app.core.dependencies import get_asr_provider, get_object_storage, get_tts_provider
|
||||
from app.features.conversation.chat_turn import (
|
||||
ChatTurnContext,
|
||||
@@ -37,7 +41,6 @@ from app.features.conversation.history_store import (
|
||||
AI_RESPONSE_SEGMENT_JOIN,
|
||||
ConversationHistoryStore,
|
||||
)
|
||||
from app.features.conversation.lineage_schemas import DialogueLineage
|
||||
from app.features.conversation.models import Conversation, ConversationMessage, Segment
|
||||
from app.features.conversation.ws.connection_manager import manager
|
||||
from app.features.conversation.ws.message_types import MessageType
|
||||
@@ -47,11 +50,12 @@ from app.features.conversation.ws.profile_collector import (
|
||||
get_missing_profile_fields,
|
||||
)
|
||||
from app.features.conversation.ws.topic_chips_push import maybe_send_topic_chips_ws
|
||||
from app.features.memoir.state_service import get_or_create_state
|
||||
from app.features.memoir.background_runner import BackgroundTaskRunner
|
||||
from app.features.memoir.ingest_scheduler import MemoirIngestScheduler
|
||||
from app.features.memoir.ingest_scheduler import MemoirIngestScheduler, MemoirTrigger
|
||||
from app.features.memoir.state_service import get_or_create_state
|
||||
from app.features.user.models import User
|
||||
from app.ports.asr import ASRTranscriptionError
|
||||
from app.core.runtime_constants import tts_defaults
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -118,20 +122,20 @@ async def _send_tts_audio(
|
||||
chunk_index,
|
||||
language,
|
||||
(text or "")[:30],
|
||||
settings.tts_provider,
|
||||
tts_defaults.provider,
|
||||
)
|
||||
return None
|
||||
if _tts_epoch_value(conversation_id) != tts_epoch_start:
|
||||
return None
|
||||
ext = _tts_object_ext(settings.tts_codec)
|
||||
content_type = _tts_codec_to_content_type(settings.tts_codec)
|
||||
ext = _tts_object_ext(tts_defaults.codec)
|
||||
content_type = _tts_codec_to_content_type(tts_defaults.codec)
|
||||
storage = get_object_storage()
|
||||
key = f"conversations/{conversation_id}/tts/{uuid.uuid4().hex}.{ext}"
|
||||
public_url = storage.upload(key, audio_bytes, content_type)
|
||||
# 与 `tts_delivery.apply_presigned_tts_urls_to_messages` / 回忆录图片 presign 一致:下发可播 URL
|
||||
playback_url = storage.get_url(key, expires=TTS_PRESIGNED_EXPIRES_SEC)
|
||||
payload_data: Dict[str, Any] = {
|
||||
"format": settings.tts_codec,
|
||||
"format": tts_defaults.codec,
|
||||
"audio_base64": base64.b64encode(audio_bytes).decode("utf-8"),
|
||||
"audio_url": playback_url,
|
||||
"index": chunk_index,
|
||||
@@ -182,7 +186,7 @@ async def handle_tts_request_on_demand(
|
||||
segment_index,
|
||||
len(segment_text or ""),
|
||||
settings.enable_tts,
|
||||
settings.tts_provider,
|
||||
tts_defaults.provider,
|
||||
)
|
||||
|
||||
conv = await db.get(Conversation, conversation_id)
|
||||
@@ -260,7 +264,7 @@ async def handle_tts_request_on_demand(
|
||||
"conversation_id": conversation_id,
|
||||
"data": {
|
||||
"audio_url": playback_url,
|
||||
"format": settings.tts_codec,
|
||||
"format": tts_defaults.codec,
|
||||
"index": segment_index,
|
||||
"total": chunk_total,
|
||||
"assistant_message_id": assistant_message_id,
|
||||
@@ -319,11 +323,7 @@ async def handle_tts_request_on_demand(
|
||||
)
|
||||
return False, "语音合成失败"
|
||||
|
||||
while len(urls) <= segment_index:
|
||||
urls.append("")
|
||||
urls[segment_index] = url_stored
|
||||
msg.tts_audio_urls = urls
|
||||
await db.commit()
|
||||
await persist_message_tts_url_segment(db, msg, segment_index, url_stored)
|
||||
|
||||
store = ConversationHistoryStore(db)
|
||||
await store._sync_redis_best_effort(conversation_id)
|
||||
@@ -344,6 +344,24 @@ _background_runner = BackgroundTaskRunner()
|
||||
memoir_ingest_scheduler = MemoirIngestScheduler(_background_runner)
|
||||
|
||||
|
||||
async def _schedule_memoir_ingest_for_segment(
|
||||
user_id: str,
|
||||
segment: Segment,
|
||||
*,
|
||||
trigger: MemoirTrigger = "turn",
|
||||
) -> None:
|
||||
"""Queue memoir phase1 after segment text (and ideally lineage) is durable."""
|
||||
text = (segment.user_input_text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
await memoir_ingest_scheduler.queue_segment(
|
||||
user_id,
|
||||
str(segment.id),
|
||||
text_char_count=len(text),
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
|
||||
# ── 分段流状态 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -457,14 +475,6 @@ def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _mark_conversation_active(
|
||||
conversation: Conversation, at: Optional[datetime] = None
|
||||
) -> datetime:
|
||||
activity_time = at or _utc_now()
|
||||
conversation.last_message_at = activity_time
|
||||
return activity_time
|
||||
|
||||
|
||||
def _voice_session_id_from_client_segment_id(
|
||||
client_segment_id: Optional[str],
|
||||
) -> Optional[str]:
|
||||
@@ -825,15 +835,9 @@ async def process_audio_segment(
|
||||
else None,
|
||||
processed=False,
|
||||
)
|
||||
db.add(segment)
|
||||
user_message_timestamp = _mark_conversation_active(conversation)
|
||||
await db.commit()
|
||||
await persist_voice_segment_row(db, segment, conversation)
|
||||
user_message_timestamp = conversation.last_message_at
|
||||
await db.refresh(segment)
|
||||
await memoir_ingest_scheduler.queue_segment(
|
||||
conversation.user_id,
|
||||
segment.id,
|
||||
text_char_count=len((transcript_text or "").strip()),
|
||||
)
|
||||
|
||||
ready_segments: List[Tuple[int, str, Segment]] = []
|
||||
tts_flag_this_voice_session = False
|
||||
@@ -943,6 +947,8 @@ async def process_user_message(
|
||||
*,
|
||||
force_skip_tts: bool = False,
|
||||
tts_this_turn: Optional[bool] = None,
|
||||
memoir_trigger: MemoirTrigger = "turn",
|
||||
schedule_memoir: bool = True,
|
||||
) -> None:
|
||||
"""处理用户消息,生成 Agent 回应。由 ChatOrchestrator 路由到 ProfileAgent 或 InterviewAgent。"""
|
||||
with business_span("conversation.ws.process_turn"):
|
||||
@@ -956,6 +962,8 @@ async def process_user_message(
|
||||
user_message_timestamp,
|
||||
force_skip_tts=force_skip_tts,
|
||||
tts_this_turn=tts_this_turn,
|
||||
memoir_trigger=memoir_trigger,
|
||||
schedule_memoir=schedule_memoir,
|
||||
)
|
||||
|
||||
|
||||
@@ -970,6 +978,8 @@ async def _process_user_message_inner(
|
||||
*,
|
||||
force_skip_tts: bool = False,
|
||||
tts_this_turn: Optional[bool] = None,
|
||||
memoir_trigger: MemoirTrigger = "turn",
|
||||
schedule_memoir: bool = True,
|
||||
) -> None:
|
||||
store = ConversationHistoryStore(db)
|
||||
tts_urls: list[str] = []
|
||||
@@ -1022,18 +1032,17 @@ async def _process_user_message_inner(
|
||||
want_tts,
|
||||
)
|
||||
|
||||
segment.agent_response = AI_RESPONSE_SEGMENT_JOIN.join(responses)
|
||||
_mark_conversation_active(conversation)
|
||||
turn_ids = await store.record_human_ai_turn(
|
||||
agent_response = AI_RESPONSE_SEGMENT_JOIN.join(responses)
|
||||
turn_ids = await store.record_human_ai_turn_with_segment(
|
||||
conversation_id=conversation_id,
|
||||
user_message=user_message,
|
||||
responses=responses,
|
||||
segment=segment,
|
||||
user_message_timestamp=user_message_timestamp,
|
||||
is_from_voice=is_from_voice,
|
||||
voice_session_id=voice_session_id,
|
||||
audio_duration_seconds=audio_dur,
|
||||
tts_audio_urls=None,
|
||||
segment_id=segment.id,
|
||||
agent_response=agent_response,
|
||||
memory_retrieval_trace=turn.memory_retrieval_trace,
|
||||
)
|
||||
if not turn_ids:
|
||||
@@ -1053,23 +1062,22 @@ async def _process_user_message_inner(
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
owner_id = (user.id if user is not None else None) or conversation.user_id
|
||||
if schedule_memoir:
|
||||
await _schedule_memoir_ingest_for_segment(
|
||||
owner_id,
|
||||
segment,
|
||||
trigger=memoir_trigger,
|
||||
)
|
||||
return
|
||||
|
||||
lineage = DialogueLineage.for_single_turn(
|
||||
conversation_id=conversation_id,
|
||||
user_message_id=turn_ids.human_message_id,
|
||||
assistant_message_id=turn_ids.assistant_message_id,
|
||||
segment_ids=[str(segment.id)],
|
||||
)
|
||||
await db.execute(
|
||||
update(Segment)
|
||||
.where(Segment.id == segment.id)
|
||||
.values(
|
||||
user_message_id=turn_ids.human_message_id,
|
||||
lineage_json=lineage.model_dump(mode="json"),
|
||||
owner_id = (user.id if user is not None else None) or conversation.user_id
|
||||
if schedule_memoir:
|
||||
await _schedule_memoir_ingest_for_segment(
|
||||
owner_id,
|
||||
segment,
|
||||
trigger=memoir_trigger,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
ai_msg_id = turn_ids.assistant_message_id
|
||||
tts_epoch_start = _tts_epoch_value(conversation_id)
|
||||
@@ -1156,32 +1164,20 @@ async def _process_user_message_inner(
|
||||
logger.warning("after-turn topic chips skipped: {}", chip_err)
|
||||
|
||||
if tts_urls:
|
||||
await store.attach_ai_tts_audio_urls(
|
||||
await store.attach_ai_tts_for_turn(
|
||||
conversation_id,
|
||||
tts_audio_urls=tts_urls,
|
||||
segment_id=segment.id,
|
||||
segment=segment,
|
||||
)
|
||||
await db.execute(
|
||||
update(Segment)
|
||||
.where(Segment.id == segment.id)
|
||||
.values(tts_audio_urls=tts_urls)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
if tts_urls:
|
||||
try:
|
||||
await store.attach_ai_tts_audio_urls(
|
||||
await store.attach_ai_tts_for_turn(
|
||||
conversation_id,
|
||||
tts_audio_urls=tts_urls,
|
||||
segment_id=segment.id,
|
||||
segment=segment,
|
||||
)
|
||||
await db.execute(
|
||||
update(Segment)
|
||||
.where(Segment.id == segment.id)
|
||||
.values(tts_audio_urls=tts_urls)
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as persist_error:
|
||||
logger.warning("补写 TTS 元数据失败: {}", persist_error)
|
||||
logger.exception("处理用户消息失败: {}", e)
|
||||
|
||||
Reference in New Issue
Block a user