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:
Sully
2026-05-22 13:44:50 +08:00
committed by GitHub
parent f09ae248f9
commit 53e0065e3e
298 changed files with 15247 additions and 4344 deletions

View File

@@ -5,11 +5,11 @@ from __future__ import annotations
import json
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_async_db
from app.core.deps_types import DbDep
from app.core.errors import BadRequestError, NotFoundError
from app.core.memoir_pipeline_progress import get_pipeline_run_for_eval
from app.features.evaluation.admin_service import EvaluationAdminService
from app.features.evaluation.deps import (
@@ -17,11 +17,9 @@ from app.features.evaluation.deps import (
get_evaluation_admin_service,
get_memoir_readiness_service,
get_replay_conversation_service,
get_session_catalog_service,
)
from app.features.evaluation.errors import (
EvaluationBadRequestError,
EvaluationNotFoundError,
)
from app.features.evaluation.errors import EvaluationBadRequestError
from app.features.evaluation.importers.user_export_markdown import (
extract_memoir_chapter_sections_from_export_md,
extract_source_user_id_from_export_md,
@@ -60,6 +58,8 @@ from app.features.evaluation.schemas import (
from app.features.evaluation.session_catalog_service import SessionCatalogService
from app.features.evaluation.user_export_fixtures import read_user_export_fixture
SessionCatalogDep = Annotated[SessionCatalogService, Depends(get_session_catalog_service)]
router = APIRouter(tags=["internal-evaluation"])
@@ -69,18 +69,12 @@ async def eval_api_ping() -> dict[str, str | bool]:
return {"ok": True, "service": "life-echo-internal-eval"}
def _eval_http_exc(
e: EvaluationNotFoundError | EvaluationBadRequestError,
) -> HTTPException:
if isinstance(e, EvaluationNotFoundError):
return HTTPException(status_code=404, detail=e.detail)
return HTTPException(status_code=400, detail=e.detail)
@router.get("/sessions", response_model=SessionListResponse)
async def list_sessions(
_auth: InternalEvalAuth,
db: Annotated[AsyncSession, Depends(get_async_db)],
catalog: SessionCatalogDep,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
user_id: str | None = Query(None),
@@ -90,7 +84,6 @@ async def list_sessions(
description="按会话 status 过滤,如 active",
),
):
catalog = SessionCatalogService(db)
rows, total = await catalog.list_sessions(
offset=offset, limit=limit, user_id=user_id, q=q, status=status
)
@@ -119,12 +112,11 @@ async def list_sessions(
async def get_session_dialogue(
conversation_id: str,
_auth: InternalEvalAuth,
db: Annotated[AsyncSession, Depends(get_async_db)],
catalog: SessionCatalogDep,
):
catalog = SessionCatalogService(db)
out = await catalog.get_session_dialogue(conversation_id)
if not out:
raise HTTPException(status_code=404, detail="conversation not found")
raise NotFoundError("conversation not found")
return out
@@ -134,12 +126,11 @@ async def get_session_dialogue(
async def get_session_transcript(
conversation_id: str,
_auth: InternalEvalAuth,
db: Annotated[AsyncSession, Depends(get_async_db)],
catalog: SessionCatalogDep,
):
catalog = SessionCatalogService(db)
tr = await catalog.get_transcript(conversation_id)
if not tr:
raise HTTPException(status_code=404, detail="conversation not found")
raise NotFoundError("conversation not found")
return SessionTranscriptOut(
conversation_id=tr.conversation_id,
user_id=tr.user_id,
@@ -155,12 +146,11 @@ async def get_session_transcript(
async def get_playground_conversation_judge(
conversation_id: str,
_auth: InternalEvalAuth,
db: Annotated[AsyncSession, Depends(get_async_db)],
catalog: SessionCatalogDep,
):
catalog = SessionCatalogService(db)
tr = await catalog.get_transcript(conversation_id)
if not tr:
raise HTTPException(status_code=404, detail="conversation not found")
raise NotFoundError("conversation not found")
judge = await catalog.get_playground_conversation_judge_json(conversation_id)
return PlaygroundConversationJudgeOut(
conversation_id=conversation_id,
@@ -185,22 +175,16 @@ async def get_memoir_pipeline_run(
] = None,
):
if not phase1_task_id and not memoir_correlation_id:
raise HTTPException(
status_code=400,
detail="provide phase1_task_id or memoir_correlation_id",
)
raise BadRequestError("provide phase1_task_id or memoir_correlation_id")
if phase1_task_id and memoir_correlation_id:
raise HTTPException(
status_code=400,
detail="provide only one of phase1_task_id or memoir_correlation_id",
)
raise BadRequestError("provide only one of phase1_task_id or memoir_correlation_id")
snap = get_pipeline_run_for_eval(
user_id.strip(),
memoir_correlation_id=memoir_correlation_id,
phase1_task_id=phase1_task_id,
)
if not snap:
raise HTTPException(status_code=404, detail="pipeline snapshot not found")
raise NotFoundError("pipeline snapshot not found")
return MemoirPipelineRunOut.model_validate(snap)
@@ -220,15 +204,10 @@ async def memoir_phase1_ready(
),
],
):
try:
return await svc.memoir_phase1_ready_for_segments(
conversation_id=conversation_id,
segment_ids=segment_ids,
)
except EvaluationNotFoundError as e:
raise _eval_http_exc(e) from e
except EvaluationBadRequestError as e:
raise _eval_http_exc(e) from e
return await svc.memoir_phase1_ready_for_segments(
conversation_id=conversation_id,
segment_ids=segment_ids,
)
@router.post(
@@ -240,14 +219,9 @@ async def memoir_submit_phase1(
_auth: InternalEvalAuth,
svc: Annotated[MemoirReadinessService, Depends(get_memoir_readiness_service)],
):
try:
return await svc.submit_memoir_phase1_for_conversation(
conversation_id=conversation_id,
)
except EvaluationNotFoundError as e:
raise _eval_http_exc(e) from e
except EvaluationBadRequestError as e:
raise _eval_http_exc(e) from e
return await svc.submit_memoir_phase1_for_conversation(
conversation_id=conversation_id,
)
@router.post("/sessions/replay-bootstrap", response_model=ReplayBootstrapOut)
@@ -258,10 +232,7 @@ async def replay_bootstrap(
ReplayConversationService, Depends(get_replay_conversation_service)
],
):
try:
cid = await replay.bootstrap_conversation(body.user_id)
except EvaluationBadRequestError as e:
raise _eval_http_exc(e) from e
cid = await replay.bootstrap_conversation(body.user_id)
return ReplayBootstrapOut(conversation_id=cid)
@@ -272,10 +243,7 @@ async def create_eval_sandbox(
ReplayConversationService, Depends(get_replay_conversation_service)
],
):
try:
uid, cid, phone, nick = await replay.create_eval_sandbox()
except EvaluationBadRequestError as e:
raise _eval_http_exc(e) from e
uid, cid, phone, nick = await replay.create_eval_sandbox()
return EvalSandboxOut(
user_id=uid,
conversation_id=cid,
@@ -293,42 +261,34 @@ async def replay_conversation(
],
):
if body.fixture_filename and body.user_utterances:
raise HTTPException(
status_code=400,
detail="provide only one of fixture_filename or user_utterances",
raise BadRequestError("provide only one of fixture_filename or user_utterances")
segment_ids: list[str] = []
timing = None
if body.fixture_filename:
fn = body.fixture_filename.strip()
n, echo, segment_ids, timing = await replay.replay_fixture(
conversation_id=body.conversation_id,
fixture_filename=fn,
flush_memoir_after=body.flush_memoir_after,
skip_memoir=body.skip_memoir,
skip_tts=body.skip_tts,
)
elif body.user_utterances is not None:
utt = [str(u) for u in body.user_utterances if str(u).strip()]
if not utt:
raise EvaluationBadRequestError("user_utterances is empty")
n, segment_ids, timing = await replay.replay_utterances(
conversation_id=body.conversation_id,
utterances=utt,
flush_memoir_after=body.flush_memoir_after,
skip_memoir=body.skip_memoir,
skip_tts=body.skip_tts,
)
echo = utt
else:
raise EvaluationBadRequestError(
"fixture_filename or user_utterances required"
)
try:
segment_ids: list[str] = []
timing = None
if body.fixture_filename:
fn = body.fixture_filename.strip()
n, echo, segment_ids, timing = await replay.replay_fixture(
conversation_id=body.conversation_id,
fixture_filename=fn,
flush_memoir_after=body.flush_memoir_after,
skip_memoir=body.skip_memoir,
skip_tts=body.skip_tts,
)
elif body.user_utterances is not None:
utt = [str(u) for u in body.user_utterances if str(u).strip()]
if not utt:
raise EvaluationBadRequestError("user_utterances is empty")
n, segment_ids, timing = await replay.replay_utterances(
conversation_id=body.conversation_id,
utterances=utt,
flush_memoir_after=body.flush_memoir_after,
skip_memoir=body.skip_memoir,
skip_tts=body.skip_tts,
)
echo = utt
else:
raise EvaluationBadRequestError(
"fixture_filename or user_utterances required"
)
except EvaluationNotFoundError as e:
raise _eval_http_exc(e) from e
except EvaluationBadRequestError as e:
raise _eval_http_exc(e) from e
return ReplayConversationOut(
conversation_id=body.conversation_id,
turns_replayed=n,
@@ -348,17 +308,12 @@ async def judge_conversation_manual(
EvalJudgeManualService, Depends(get_eval_judge_manual_service)
],
):
try:
payload = await judge_svc.judge_conversation(
body.conversation_id,
body.fixture_filename,
judge_provider=body.judge_provider,
judge_model=body.judge_model,
)
except EvaluationNotFoundError as e:
raise _eval_http_exc(e) from e
except EvaluationBadRequestError as e:
raise _eval_http_exc(e) from e
payload = await judge_svc.judge_conversation(
body.conversation_id,
body.fixture_filename,
judge_provider=body.judge_provider,
judge_model=body.judge_model,
)
return ManualJudgeConversationOut.model_validate(payload)
@@ -411,18 +366,13 @@ async def retry_baseline_conversation_judge(
EvalJudgeManualService, Depends(get_eval_judge_manual_service)
],
):
try:
payload = await judge_svc.retry_baseline_conversation_judge(
body.conversation_id,
body.fixture_filename,
include_baseline_turn_judges=body.include_baseline_turn_judges,
judge_provider=body.judge_provider,
judge_model=body.judge_model,
)
except EvaluationNotFoundError as e:
raise _eval_http_exc(e) from e
except EvaluationBadRequestError as e:
raise _eval_http_exc(e) from e
payload = await judge_svc.retry_baseline_conversation_judge(
body.conversation_id,
body.fixture_filename,
include_baseline_turn_judges=body.include_baseline_turn_judges,
judge_provider=body.judge_provider,
judge_model=body.judge_model,
)
return RetryBaselineJudgeOut.model_validate(payload)
@@ -434,15 +384,12 @@ async def judge_memoir_chapters_manual(
EvalJudgeManualService, Depends(get_eval_judge_manual_service)
],
):
try:
payload = await judge_svc.judge_memoir_for_user(
body.user_id,
body.baseline_sections,
judge_provider=body.judge_provider,
judge_model=body.judge_model,
)
except EvaluationBadRequestError as e:
raise _eval_http_exc(e) from e
payload = await judge_svc.judge_memoir_for_user(
body.user_id,
body.baseline_sections,
judge_provider=body.judge_provider,
judge_model=body.judge_model,
)
return ManualJudgeMemoirOut.model_validate(payload)
@@ -490,10 +437,7 @@ async def get_user_memoir_snapshot(
EvalJudgeManualService, Depends(get_eval_judge_manual_service)
],
):
try:
payload = await judge_svc.memoir_snapshot(user_id)
except EvaluationBadRequestError as e:
raise _eval_http_exc(e) from e
payload = await judge_svc.memoir_snapshot(user_id)
return UserMemoirSnapshotOut.model_validate(payload)
@@ -519,11 +463,9 @@ async def get_user_export_fixture(
try:
turns, raw_md = read_user_export_fixture(filename)
except ValueError:
raise HTTPException(
status_code=400, detail="invalid fixture filename"
) from None
raise BadRequestError("invalid fixture filename") from None
except FileNotFoundError:
raise HTTPException(status_code=404, detail="fixture not found") from None
raise NotFoundError("fixture not found")
memoir_tuples = extract_memoir_chapter_sections_from_export_md(raw_md)
return UserExportFixtureDetailOut(
filename=filename,