2026-03-18 17:18:23 +08:00
|
|
|
|
"""
|
|
|
|
|
|
FastAPI 应用入口(app 内主入口,符合架构计划)
|
|
|
|
|
|
"""
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-05-22 13:44:50 +08:00
|
|
|
|
from contextlib import asynccontextmanager
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-03-26 12:13:36 +08:00
|
|
|
|
from app.core.logging import get_logger, setup_logging
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
setup_logging()
|
|
|
|
|
|
|
feat: OpenTelemetry LGTM observability, dev tooling, and memoir UX fixes (#31) (#32)
* add staging ios app build script
* feat(api): add OpenTelemetry LGTM stack for local observability
Wire OTel traces, metrics, and logs through a collector to Tempo,
Prometheus, and Loki, with custom LLM instrumentation, dev compose overlay,
Grafana provisioning, env templates, and development.sh auto-start.
* feat: expand observability, harden dev tooling, and fix expo staging UX
Add business and LLM Prometheus metrics with Grafana dashboards, alerting,
and a metrics verification script. Wire telemetry through adapters and core
LLM paths, and document the local LGTM workflow.
Fix development.sh for macOS bash 3.2, open Grafana and eval-web in Chrome,
and repair eval-web auto-open (unbound EVAL_WEB_BROWSER_SCHEDULED). Merge
internal-eval into the main dev script with improved compose handling.
Require EXPO_PUBLIC_* at build time, improve iOS HTTP ATS for staging IPs,
show memoir empty state instead of load errors when no chapters exist, and
add jest env setup plus chapter list response normalization.
* chore: enable Grafana Assistant Cursor plugin
* fix: memoir empty state and repair withdrawn 0020_chapters_book_id stamp
Show empty memoir UI when the chapter list succeeds with no items; treat auth/404 as non-fatal. Extend alembic revision repair so local dev DBs stamped with the removed 0020_chapters_book_id migration can roll back and upgrade to 0019.
---------
Co-authored-by: Kevin <kevin@brighteng.org>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:14:13 +08:00
|
|
|
|
from app.core.config import settings
|
2026-05-22 13:44:50 +08:00
|
|
|
|
from app.core.runtime_constants import asr_defaults, otel_defaults
|
feat: OpenTelemetry LGTM observability, dev tooling, and memoir UX fixes (#31) (#32)
* add staging ios app build script
* feat(api): add OpenTelemetry LGTM stack for local observability
Wire OTel traces, metrics, and logs through a collector to Tempo,
Prometheus, and Loki, with custom LLM instrumentation, dev compose overlay,
Grafana provisioning, env templates, and development.sh auto-start.
* feat: expand observability, harden dev tooling, and fix expo staging UX
Add business and LLM Prometheus metrics with Grafana dashboards, alerting,
and a metrics verification script. Wire telemetry through adapters and core
LLM paths, and document the local LGTM workflow.
Fix development.sh for macOS bash 3.2, open Grafana and eval-web in Chrome,
and repair eval-web auto-open (unbound EVAL_WEB_BROWSER_SCHEDULED). Merge
internal-eval into the main dev script with improved compose handling.
Require EXPO_PUBLIC_* at build time, improve iOS HTTP ATS for staging IPs,
show memoir empty state instead of load errors when no chapters exist, and
add jest env setup plus chapter list response normalization.
* chore: enable Grafana Assistant Cursor plugin
* fix: memoir empty state and repair withdrawn 0020_chapters_book_id stamp
Show empty memoir UI when the chapter list succeeds with no items; treat auth/404 as non-fatal. Extend alembic revision repair so local dev DBs stamped with the removed 0020_chapters_book_id migration can roll back and upgrade to 0019.
---------
Co-authored-by: Kevin <kevin@brighteng.org>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:14:13 +08:00
|
|
|
|
from app.core.telemetry import instrument_fastapi_app, setup_telemetry
|
|
|
|
|
|
|
|
|
|
|
|
setup_telemetry(
|
2026-05-22 13:44:50 +08:00
|
|
|
|
service_name=otel_defaults.service_name or "life-echo-api",
|
feat: OpenTelemetry LGTM observability, dev tooling, and memoir UX fixes (#31) (#32)
* add staging ios app build script
* feat(api): add OpenTelemetry LGTM stack for local observability
Wire OTel traces, metrics, and logs through a collector to Tempo,
Prometheus, and Loki, with custom LLM instrumentation, dev compose overlay,
Grafana provisioning, env templates, and development.sh auto-start.
* feat: expand observability, harden dev tooling, and fix expo staging UX
Add business and LLM Prometheus metrics with Grafana dashboards, alerting,
and a metrics verification script. Wire telemetry through adapters and core
LLM paths, and document the local LGTM workflow.
Fix development.sh for macOS bash 3.2, open Grafana and eval-web in Chrome,
and repair eval-web auto-open (unbound EVAL_WEB_BROWSER_SCHEDULED). Merge
internal-eval into the main dev script with improved compose handling.
Require EXPO_PUBLIC_* at build time, improve iOS HTTP ATS for staging IPs,
show memoir empty state instead of load errors when no chapters exist, and
add jest env setup plus chapter list response normalization.
* chore: enable Grafana Assistant Cursor plugin
* fix: memoir empty state and repair withdrawn 0020_chapters_book_id stamp
Show empty memoir UI when the chapter list succeeds with no items; treat auth/404 as non-fatal. Extend alembic revision repair so local dev DBs stamped with the removed 0020_chapters_book_id migration can roll back and upgrade to 0019.
---------
Co-authored-by: Kevin <kevin@brighteng.org>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:14:13 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from fastapi import FastAPI
|
|
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.errors import register_exception_handlers
|
|
|
|
|
|
from app.core.middleware import RequestIdMiddleware
|
|
|
|
|
|
from app.core.openapi import custom_openapi
|
|
|
|
|
|
from app.features.auth.router import router as auth_router
|
|
|
|
|
|
from app.features.content.router import router as content_router
|
|
|
|
|
|
from app.features.conversation.router import router as conversation_router
|
|
|
|
|
|
from app.features.conversation.ws.router import websocket_endpoint
|
|
|
|
|
|
from app.features.memoir.router import router as memoir_router
|
2026-03-27 16:01:28 +08:00
|
|
|
|
from app.features.memory.router import router as memory_router
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.features.payment.router import router as payment_router
|
|
|
|
|
|
from app.features.plan.router import router as plan_router
|
|
|
|
|
|
from app.features.quota.router import router as quota_router
|
|
|
|
|
|
from app.features.tasks.router import router as tasks_router
|
|
|
|
|
|
from app.features.user.router import feedback_router as user_feedback_router
|
|
|
|
|
|
from app.features.user.router import router as user_router
|
|
|
|
|
|
|
|
|
|
|
|
# 聚合注册所有 feature 的 model 到 Base.metadata(供 Alembic 等使用)
|
|
|
|
|
|
from app.features.auth import models as _auth_models # noqa: F401
|
|
|
|
|
|
from app.features.conversation import models as _conv_models # noqa: F401
|
|
|
|
|
|
from app.features.memory import models as _memory_models # noqa: F401
|
|
|
|
|
|
from app.features.memoir import models as _memoir_models # noqa: F401
|
|
|
|
|
|
from app.features.payment import models as _payment_models # noqa: F401
|
2026-03-20 10:30:07 +08:00
|
|
|
|
from app.features.story import models as _story_models # noqa: F401
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.features.user import models as _user_models # noqa: F401
|
|
|
|
|
|
|
2026-03-26 12:13:36 +08:00
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
2026-05-22 13:44:50 +08:00
|
|
|
|
@asynccontextmanager
|
|
|
|
|
|
async def lifespan(app: FastAPI):
|
|
|
|
|
|
"""应用生命周期:启动迁移/Redis/ASR/支付预初始化,关闭时释放连接。"""
|
2026-03-18 17:18:23 +08:00
|
|
|
|
import asyncio
|
|
|
|
|
|
|
2026-03-26 12:13:36 +08:00
|
|
|
|
from app.core.alembic_startup import run_alembic_upgrade_at_startup
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger.info("Life Echo API 正在启动...")
|
2026-05-22 13:44:50 +08:00
|
|
|
|
if settings.app_environment == "production" and not (
|
|
|
|
|
|
settings.api_cors_origins or ""
|
|
|
|
|
|
).strip():
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"生产环境未配置 API_CORS_ORIGINS;浏览器跨域请求将无法携带 credentials"
|
|
|
|
|
|
)
|
2026-03-26 12:13:36 +08:00
|
|
|
|
await asyncio.to_thread(run_alembic_upgrade_at_startup)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
2026-04-08 21:36:12 +08:00
|
|
|
|
from app.core.celery_broker_dev import maybe_purge_celery_broker_on_startup
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.core.redis import redis_service
|
|
|
|
|
|
|
2026-04-08 21:36:12 +08:00
|
|
|
|
_redis = await redis_service.get_client()
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger.info("Redis 连接已建立")
|
2026-04-08 21:36:12 +08:00
|
|
|
|
await maybe_purge_celery_broker_on_startup(_redis)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
except Exception as e:
|
2026-03-20 15:15:35 +08:00
|
|
|
|
logger.warning("Redis 连接失败(会话存储将不可用): {}", e)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.core.dependencies import get_asr_provider
|
|
|
|
|
|
|
|
|
|
|
|
provider = get_asr_provider()
|
|
|
|
|
|
ensure_ready = getattr(provider, "ensure_ready", None)
|
|
|
|
|
|
if callable(ensure_ready):
|
|
|
|
|
|
asr_ready = await asyncio.to_thread(ensure_ready)
|
|
|
|
|
|
else:
|
|
|
|
|
|
asr_ready = True
|
|
|
|
|
|
if asr_ready:
|
2026-05-25 10:21:41 +08:00
|
|
|
|
logger.info(
|
2026-05-25 11:28:22 +08:00
|
|
|
|
"ASR 服务已就绪(腾讯云录音文件识别极速版,引擎 {})",
|
2026-05-25 10:21:41 +08:00
|
|
|
|
asr_defaults.engine_type,
|
2026-03-19 14:36:14 +08:00
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
else:
|
|
|
|
|
|
logger.warning("ASR 服务未就绪,语音转写将不可用")
|
|
|
|
|
|
except Exception as e:
|
2026-03-20 15:15:35 +08:00
|
|
|
|
logger.warning("ASR 初始化检查失败: {}", e)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
|
|
|
|
def _init_wechat_pay_client():
|
|
|
|
|
|
from app.features.payment.deps import get_payment_service
|
|
|
|
|
|
|
|
|
|
|
|
svc = get_payment_service()
|
|
|
|
|
|
if svc.is_method_available("wechat"):
|
|
|
|
|
|
_ = svc.wechat_client
|
|
|
|
|
|
|
|
|
|
|
|
await asyncio.to_thread(_init_wechat_pay_client)
|
|
|
|
|
|
logger.info("微信支付客户端已预初始化")
|
|
|
|
|
|
except Exception as e:
|
feat(eval): memoir A/B chapter judging and eval-web parity with dialogue
- Judge baseline excerpt and library chapter separately; build_memoir_compare_summary for gate, nine-dim and leaf deltas.
- Memoir SSE chapter payload: baseline_judge, compare_summary, baseline_judge_error.
- MemoirJudgeOutput: loose score coercion and post-validate clamp; memoir judge prompt caps from settings.
- app-eval-web: two-column MemoirScoreCard layout, MemoirCompareSummary, chapter blocks and CSS.
- Add memoir_compare_summary, log_events, celery_log_context, memoir_pipeline_progress; tests and migration 0014.
- Misc: memory/evidence and enrichment paths, task/orchestrator updates, internal-eval docs, env examples.
2026-04-10 10:23:43 +08:00
|
|
|
|
logger.warning("微信支付预初始化失败(首次下单时再初始化): {}", e)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
2026-05-22 13:44:50 +08:00
|
|
|
|
yield
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
logger.info("Life Echo API 正在关闭...")
|
2026-05-22 13:44:50 +08:00
|
|
|
|
try:
|
|
|
|
|
|
from app.core.telemetry import shutdown_telemetry
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
2026-05-22 13:44:50 +08:00
|
|
|
|
shutdown_telemetry()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("关闭 OpenTelemetry 失败: {}", e)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
try:
|
|
|
|
|
|
from app.core.redis import redis_service
|
|
|
|
|
|
|
|
|
|
|
|
await redis_service.close()
|
|
|
|
|
|
logger.info("Redis 连接已关闭")
|
|
|
|
|
|
except Exception as e:
|
2026-03-20 15:15:35 +08:00
|
|
|
|
logger.warning("关闭 Redis 连接失败: {}", e)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 13:44:50 +08:00
|
|
|
|
app = FastAPI(
|
|
|
|
|
|
title="Life Echo API",
|
|
|
|
|
|
version="1.0.0",
|
|
|
|
|
|
docs_url="/docs" if settings.enable_docs else None,
|
|
|
|
|
|
redoc_url="/redoc" if settings.enable_docs else None,
|
|
|
|
|
|
openapi_url="/openapi.json" if settings.enable_docs else None,
|
|
|
|
|
|
lifespan=lifespan,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
instrument_fastapi_app(app)
|
|
|
|
|
|
|
|
|
|
|
|
# OpenAPI 全局增强
|
|
|
|
|
|
app.openapi = lambda: custom_openapi(app) # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
# Middleware(注册顺序:LIFO,先注册的后执行)
|
|
|
|
|
|
app.add_middleware(RequestIdMiddleware)
|
|
|
|
|
|
_origins = [
|
|
|
|
|
|
o.strip() for o in (settings.api_cors_origins or "").split(",") if o.strip()
|
|
|
|
|
|
]
|
|
|
|
|
|
_allow_creds = bool(_origins)
|
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
|
allow_origins=_origins if _origins else ["*"],
|
|
|
|
|
|
allow_credentials=_allow_creds,
|
|
|
|
|
|
allow_methods=["*"],
|
|
|
|
|
|
allow_headers=["*"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 全局异常处理
|
|
|
|
|
|
register_exception_handlers(app)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
# ── Feature routers ──────────────────────────────────────────
|
|
|
|
|
|
app.include_router(auth_router)
|
|
|
|
|
|
app.websocket("/ws/conversation/{conversation_id}")(websocket_endpoint)
|
|
|
|
|
|
app.include_router(conversation_router)
|
|
|
|
|
|
app.include_router(memoir_router)
|
2026-03-27 16:01:28 +08:00
|
|
|
|
app.include_router(memory_router)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
app.include_router(user_router)
|
|
|
|
|
|
app.include_router(user_feedback_router)
|
|
|
|
|
|
app.include_router(plan_router)
|
|
|
|
|
|
app.include_router(payment_router)
|
|
|
|
|
|
app.include_router(quota_router)
|
|
|
|
|
|
app.include_router(tasks_router)
|
|
|
|
|
|
app.include_router(content_router)
|
|
|
|
|
|
|
|
|
|
|
|
# static 在 api/ 下,app/main.py 在 api/app/ 下
|
|
|
|
|
|
_static_dir = Path(__file__).resolve().parent.parent / "static"
|
|
|
|
|
|
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/health", include_in_schema=False)
|
|
|
|
|
|
async def health():
|
|
|
|
|
|
return {"status": "ok"}
|