fix(report): FE 问卷键名双侧兼容 + worker 持久事件循环(live E2E 两处修复)
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
FE↔后端联调实测暴露并修复: 1. 问卷键名三处错位(FE height_cm/weight_kg/psych_style vs 文档 height/weight/psyche_style)——vision._bmi 多键取值、diagnosis 维度 装配与规则上下文双键或运算,两侧谁改键名都不再炸 2. celery worker asyncio.run 每投递起新 loop + 模块级引擎缓存 → asyncpg 连接跨 loop(同进程第 3 个报告任务必现 RuntimeError: attached to a different loop;集成测试单循环掩盖)。新增 apps/worker/loop.py 持久 loop,report/ping 两任务接入 E2E 终态: psyche_style/height_weight 恢复、BMI 体型确定值、ok 26/32、 真实 LLM 文案通过校验 degraded=False。
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
"""Celery worker 的持久事件循环。
|
||||||
|
|
||||||
|
``asyncio.run`` 每次投递都新建并随即关闭事件循环,而
|
||||||
|
``packages.shared.db`` 的引擎是模块级缓存——asyncpg 连接绑定在创建它的
|
||||||
|
loop 上,后续投递从池里拿到旧 loop 的连接就会
|
||||||
|
``RuntimeError: attached to a different loop``(live E2E 实测:同进程第
|
||||||
|
三个报告任务必现,集成测试单循环跑掩盖了它)。
|
||||||
|
|
||||||
|
worker 进程内复用一个 loop,让连接池与 loop 生命周期一致,问题从根上
|
||||||
|
消失。prefork 模型下每个子进程各有一份模块状态,互不影响。集成测试
|
||||||
|
不受影响:它们在 pytest 自己的 loop 里直调任务内芯,不经过本模块。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Coroutine
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def run(coro: Coroutine[Any, Any, Any]) -> Any:
|
||||||
|
"""在 worker 进程唯一的持久 loop 上执行协程并返回结果。"""
|
||||||
|
global _loop
|
||||||
|
if _loop is None or _loop.is_closed():
|
||||||
|
_loop = asyncio.new_event_loop()
|
||||||
|
return _loop.run_until_complete(coro)
|
||||||
@@ -14,15 +14,15 @@ worker 侧 LLM 网关装配:apps.api.deps 属 HTTP 层,worker 不得 import
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
|
from apps.worker import loop as worker_loop
|
||||||
from apps.worker.celery_app import celery_app
|
from apps.worker.celery_app import celery_app
|
||||||
from packages.billing.service import BillingService
|
from packages.billing.service import BillingService
|
||||||
from packages.domain.task.state import TERMINAL_STATUSES, TaskStatus
|
from packages.domain.task.state import TERMINAL_STATUSES, TaskStatus
|
||||||
@@ -279,7 +279,7 @@ def generate_report(self: Any, task_id: int) -> dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
bind_context(task_id=task_id)
|
bind_context(task_id=task_id)
|
||||||
try:
|
try:
|
||||||
return asyncio.run(_generate_report(task_id))
|
return cast("dict[str, Any]", worker_loop.run(_generate_report(task_id)))
|
||||||
except StageFailureError as exc:
|
except StageFailureError as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"report_stage_failed",
|
"report_stage_failed",
|
||||||
@@ -293,5 +293,5 @@ def generate_report(self: Any, task_id: int) -> dict[str, Any]:
|
|||||||
logger.warning("report_generate_retry", task_id=task_id, error=str(exc))
|
logger.warning("report_generate_retry", task_id=task_id, error=str(exc))
|
||||||
raise self.retry(exc=exc, countdown=10) from exc
|
raise self.retry(exc=exc, countdown=10) from exc
|
||||||
logger.exception("report_generate_failed_after_retry", task_id=task_id)
|
logger.exception("report_generate_failed_after_retry", task_id=task_id)
|
||||||
asyncio.run(_fail_report(task_id, f"{type(exc).__name__}: {exc}"))
|
worker_loop.run(_fail_report(task_id, f"{type(exc).__name__}: {exc}"))
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ render) follow exactly this shape and additionally call
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from typing import Any, cast
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
from apps.worker import loop as worker_loop
|
||||||
from apps.worker.celery_app import celery_app
|
from apps.worker.celery_app import celery_app
|
||||||
from packages.domain.task.state import TaskStatus
|
from packages.domain.task.state import TaskStatus
|
||||||
from packages.shared.context import bind_context
|
from packages.shared.context import bind_context
|
||||||
@@ -61,9 +61,9 @@ async def _fail_ping(task_id: int, error: str) -> None:
|
|||||||
def ping_task(self: Any, task_id: int, echo: str | None = None) -> dict[str, Any]:
|
def ping_task(self: Any, task_id: int, echo: str | None = None) -> dict[str, Any]:
|
||||||
bind_context(task_id=task_id)
|
bind_context(task_id=task_id)
|
||||||
try:
|
try:
|
||||||
return asyncio.run(_run_ping(task_id, echo))
|
return cast("dict[str, Any]", worker_loop.run(_run_ping(task_id, echo)))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("ping_task_failed", task_id=task_id)
|
logger.exception("ping_task_failed", task_id=task_id)
|
||||||
# Real tasks additionally refund the quota reservation here (FR-BILL-05).
|
# Real tasks additionally refund the quota reservation here (FR-BILL-05).
|
||||||
asyncio.run(_fail_ping(task_id, f"{type(exc).__name__}: {exc}"))
|
worker_loop.run(_fail_ping(task_id, f"{type(exc).__name__}: {exc}"))
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ def build_rule_context(
|
|||||||
features=tuple(_str_list(derived_features.get("body_features"))),
|
features=tuple(_str_list(derived_features.get("body_features"))),
|
||||||
goals=tuple(_str_list(questionnaire.get("core_goals"))),
|
goals=tuple(_str_list(questionnaire.get("core_goals"))),
|
||||||
occasion=occasion,
|
occasion=occasion,
|
||||||
style=_str(questionnaire.get("psyche_style")),
|
style=_str(questionnaire.get("psyche_style"))
|
||||||
|
or _str(questionnaire.get("psych_style")),
|
||||||
user_taboos=tuple(_str_list(questionnaire.get("taboos"))),
|
user_taboos=tuple(_str_list(questionnaire.get("taboos"))),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -214,7 +215,9 @@ def evaluate_dimensions(
|
|||||||
else:
|
else:
|
||||||
results.append(_skip(_spec("gender"), "问卷未填写性别"))
|
results.append(_skip(_spec("gender"), "问卷未填写性别"))
|
||||||
|
|
||||||
height, weight = _num(q.get("height")), _num(q.get("weight"))
|
# FE 发 height_cm/weight_kg(带单位键名),文档口径 height/weight——都认
|
||||||
|
height = _num(q.get("height_cm")) if q.get("height_cm") is not None else _num(q.get("height"))
|
||||||
|
weight = _num(q.get("weight_kg")) if q.get("weight_kg") is not None else _num(q.get("weight"))
|
||||||
parts: dict[str, float] = {}
|
parts: dict[str, float] = {}
|
||||||
if height is not None:
|
if height is not None:
|
||||||
parts["height"] = height
|
parts["height"] = height
|
||||||
@@ -538,7 +541,8 @@ def evaluate_dimensions(
|
|||||||
all_hits = _rule_keys(verdict)
|
all_hits = _rule_keys(verdict)
|
||||||
rule_text_all = _rule_evidence(verdict)
|
rule_text_all = _rule_evidence(verdict)
|
||||||
|
|
||||||
psyche = _str(q.get("psyche_style"))
|
# FE 发 psych_style,文档口径 psyche_style——都认(联调缝双侧兼容)
|
||||||
|
psyche = _str(q.get("psyche_style")) or _str(q.get("psych_style"))
|
||||||
if psyche:
|
if psyche:
|
||||||
results.append(
|
results.append(
|
||||||
_ok(
|
_ok(
|
||||||
|
|||||||
@@ -118,9 +118,22 @@ def _num(value: Any) -> float | None:
|
|||||||
return float(value)
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _q_num(questionnaire: Mapping[str, Any], *keys: str) -> float | None:
|
||||||
|
"""多键取值:FE 发 height_cm/weight_kg,文档口径 height/weight——都认。
|
||||||
|
|
||||||
|
FE(ReportCreatePage)用带单位的键名,本函数按传入顺序取第一个可解析
|
||||||
|
数值,联调两侧谁改键名都不炸。
|
||||||
|
"""
|
||||||
|
for key in keys:
|
||||||
|
value = _num(questionnaire.get(key))
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _bmi(questionnaire: Mapping[str, Any]) -> float | None:
|
def _bmi(questionnaire: Mapping[str, Any]) -> float | None:
|
||||||
height = _num(questionnaire.get("height"))
|
height = _q_num(questionnaire, "height_cm", "height")
|
||||||
weight = _num(questionnaire.get("weight"))
|
weight = _q_num(questionnaire, "weight_kg", "weight")
|
||||||
if height is None or weight is None or height <= 0:
|
if height is None or weight is None or height <= 0:
|
||||||
return None
|
return None
|
||||||
return weight / (height / 100.0) ** 2
|
return weight / (height / 100.0) ** 2
|
||||||
|
|||||||
Reference in New Issue
Block a user