Files
fquiz/api/app/services/fl_analysis_service.py
T
2026-06-07 16:35:08 +08:00

738 lines
28 KiB
Python

from __future__ import annotations
import asyncio
import time
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from ..core.database import SessionLocal
from ..models.base import utcnow
from ..models.fl_analysis import FlAnalysisJob, FlAnalysisRun, FlAnalysisTowerResult, FlAnalysisTowerSnapshot
from ..models.line import Line
from ..models.line_tower import LineTower
from ..models.tower_profile import TowerProfile
from ..models.user import User
from ..schemas.fl_analysis import (
FlAnalysisJobCreateRequest,
FlAnalysisJobCreateResponse,
FlAnalysisJobDetail,
FlAnalysisJobListResponse,
FlAnalysisJobStartResponse,
FlAnalysisJobSummary,
FlAnalysisRunSummary,
FlAnalysisTowerResultListResponse,
FlAnalysisTowerResultSummary,
)
from .fl_analysis_rules import grade_mitigation_snapshot_payload, grade_snapshot_payload
from .push_service import publish_topic
FL_ANALYSIS_TOPIC = "admin.fl-analysis"
def serialize_run(item: FlAnalysisRun) -> FlAnalysisRunSummary:
return FlAnalysisRunSummary(
id=item.id,
job_id=item.job_id,
status=item.status,
runner_kind=item.runner_kind,
engine_command=item.engine_command,
working_dir=item.working_dir,
error_message=item.error_message,
snapshot_tower_count=item.snapshot_tower_count,
result_tower_count=item.result_tower_count,
duration_ms=item.duration_ms,
started_at=item.started_at,
finished_at=item.finished_at,
create_date=item.create_date,
create_user=item.create_user,
update_date=item.update_date,
update_user=item.update_user,
)
def serialize_job(item: FlAnalysisJob, *, include_runs: bool = False) -> FlAnalysisJobDetail | FlAnalysisJobSummary:
line = item.line
payload: dict[str, Any] = {
"id": item.id,
"line_id": item.line_id,
"line_code": line.code if line else None,
"line_name": line.name if line else None,
"job_name": item.job_name,
"job_type": item.job_type,
"source_kind": item.source_kind,
"status": item.status,
"task_id": item.task_id,
"latest_run_id": item.latest_run_id,
"total_tower_count": item.total_tower_count,
"snapshotted_tower_count": item.snapshotted_tower_count,
"result_tower_count": item.result_tower_count,
"external_adapter": item.external_adapter,
"adapter_config_json": item.adapter_config_json or {},
"execution_options_json": item.execution_options_json or {},
"result_summary_json": item.result_summary_json or {},
"error_message": item.error_message,
"started_at": item.started_at,
"finished_at": item.finished_at,
"create_date": item.create_date,
"create_user": item.create_user,
"update_date": item.update_date,
"update_user": item.update_user,
}
if include_runs:
payload["runs"] = [serialize_run(run) for run in item.runs]
return FlAnalysisJobDetail(**payload)
return FlAnalysisJobSummary(**payload)
def serialize_tower_result(item: FlAnalysisTowerResult) -> FlAnalysisTowerResultSummary:
snapshot = item.snapshot
return FlAnalysisTowerResultSummary(
id=item.id,
job_id=item.job_id,
run_id=item.run_id,
snapshot_id=item.snapshot_id,
tower_id=snapshot.tower_id,
seq_no=snapshot.seq_no,
tower_no=snapshot.tower_no,
tower_model=snapshot.tower_model,
tower_type=snapshot.tower_type,
status=item.status,
risk_level=item.risk_level,
summary_text=item.summary_text,
result_json=item.result_json or {},
create_date=item.create_date,
update_date=item.update_date,
)
def get_job_by_id(db: Session, job_id: str) -> FlAnalysisJob | None:
return db.execute(select(FlAnalysisJob).where(FlAnalysisJob.id == job_id)).scalar_one_or_none()
def list_jobs(
db: Session,
*,
line_id: str | None,
status_filter: str | None,
limit: int,
) -> FlAnalysisJobListResponse:
stmt = select(FlAnalysisJob)
total_stmt = select(func.count()).select_from(FlAnalysisJob)
if line_id:
stmt = stmt.where(FlAnalysisJob.line_id == line_id)
total_stmt = total_stmt.where(FlAnalysisJob.line_id == line_id)
if status_filter:
stmt = stmt.where(FlAnalysisJob.status == status_filter)
total_stmt = total_stmt.where(FlAnalysisJob.status == status_filter)
total = int(db.scalar(total_stmt) or 0)
items = db.execute(
stmt.order_by(FlAnalysisJob.update_date.desc(), FlAnalysisJob.id.desc()).limit(limit)
).scalars().all()
return FlAnalysisJobListResponse(items=[serialize_job(item) for item in items], total=total)
def list_tower_results(db: Session, *, job_id: str, run_id: str | None = None) -> FlAnalysisTowerResultListResponse:
stmt = select(FlAnalysisTowerResult).where(FlAnalysisTowerResult.job_id == job_id)
total_stmt = select(func.count()).select_from(FlAnalysisTowerResult).where(FlAnalysisTowerResult.job_id == job_id)
if run_id:
stmt = stmt.where(FlAnalysisTowerResult.run_id == run_id)
total_stmt = total_stmt.where(FlAnalysisTowerResult.run_id == run_id)
total = int(db.scalar(total_stmt) or 0)
items = db.execute(
stmt.order_by(FlAnalysisTowerResult.risk_level.desc(), FlAnalysisTowerResult.create_date.asc())
).scalars().all()
return FlAnalysisTowerResultListResponse(items=[serialize_tower_result(item) for item in items], total=total)
def create_job(
db: Session,
payload: FlAnalysisJobCreateRequest,
*,
actor: User,
) -> FlAnalysisJobCreateResponse:
line = db.execute(select(Line).where(Line.id == payload.line_id)).scalar_one_or_none()
if not line:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="线路不存在")
execution_options = _normalize_execution_options(payload.job_type, payload.execution_options_json or {})
if payload.job_type == "mitigation":
total_tower_count = _validate_mitigation_options(db, line_id=line.id, execution_options=execution_options)
else:
total_tower_count = int(
db.scalar(
select(func.count()).select_from(LineTower).where(LineTower.line_id == line.id)
)
or 0
)
if total_tower_count <= 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前线路没有可分析的杆塔数据")
now = utcnow()
job = FlAnalysisJob(
line_id=line.id,
job_name=(payload.job_name or "").strip() or None,
job_type=payload.job_type,
source_kind="line",
status="pending",
total_tower_count=total_tower_count,
external_adapter=payload.external_adapter,
adapter_config_json=payload.adapter_config_json or {},
execution_options_json=execution_options,
result_summary_json={},
create_date=now,
create_user=actor.id,
update_date=now,
update_user=actor.id,
)
db.add(job)
db.commit()
saved = get_job_by_id(db, job.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="创建防雷分析任务失败")
_publish_change(
"fl_analysis.job.created",
{"action": "job_created", "job_id": saved.id, "line_id": saved.line_id},
)
return FlAnalysisJobCreateResponse(job=serialize_job(saved, include_runs=True))
def start_job(
db: Session,
job_id: str,
*,
actor: User,
) -> FlAnalysisJobStartResponse:
job = get_job_by_id(db, job_id)
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="防雷分析任务不存在")
if job.status in {"queued", "running"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="任务已在排队或执行中")
try:
task = _dispatch_fl_analysis_task(job_id=job.id)
except Exception as exc:
job.error_message = str(exc)
job.update_date = utcnow()
job.update_user = actor.id
db.commit()
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"任务派发失败: {exc}") from exc
job.status = "queued"
job.task_id = getattr(task, "id", None)
job.error_message = None
job.finished_at = None
job.update_date = utcnow()
job.update_user = actor.id
db.commit()
latest = get_job_by_id(db, job.id)
if not latest:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="任务派发后查询失败")
_publish_change(
"fl_analysis.job.queued",
{"action": "job_queued", "job_id": latest.id, "line_id": latest.line_id, "task_id": latest.task_id},
)
return FlAnalysisJobStartResponse(job=serialize_job(latest, include_runs=True), queued=True)
def execute_job(job_id: str) -> None:
db = SessionLocal()
started_perf = time.perf_counter()
run_id: str | None = None
try:
job = get_job_by_id(db, job_id)
if not job:
return
now = utcnow()
job.status = "running"
job.started_at = now
job.finished_at = None
job.error_message = None
job.result_summary_json = {}
job.update_date = now
run = FlAnalysisRun(
job_id=job.id,
status="running",
runner_kind=job.external_adapter,
started_at=now,
create_date=now,
create_user=job.update_user or job.create_user,
update_date=now,
update_user=job.update_user or job.create_user,
)
db.add(run)
db.flush()
run_id = run.id
job.latest_run_id = run.id
db.commit()
_publish_change(
"fl_analysis.job.running",
{"action": "job_running", "job_id": job.id, "line_id": job.line_id, "run_id": run.id},
)
execution_options = _normalize_execution_options(job.job_type, job.execution_options_json or {})
towers = _load_job_towers(db, job=job, execution_options=execution_options)
if not towers:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前线路没有可分析的杆塔数据")
source_result_map = _load_source_result_map(db, execution_options=execution_options) if job.job_type == "mitigation" else {}
if job.job_type == "mitigation" and not source_result_map:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="前驱风险任务结果已失效,请重新生成措施推荐任务")
tower_ids = [tower.id for tower in towers]
profile_rows = db.execute(select(TowerProfile).where(TowerProfile.tower_id.in_(tower_ids))).scalars().all()
profile_map = {item.tower_id: item for item in profile_rows}
db.execute(delete(FlAnalysisTowerResult).where(FlAnalysisTowerResult.run_id == run.id))
db.execute(delete(FlAnalysisTowerSnapshot).where(FlAnalysisTowerSnapshot.run_id == run.id))
db.flush()
snapshot_count = 0
snapshots: list[FlAnalysisTowerSnapshot] = []
for tower in towers:
profile = profile_map.get(tower.id)
snapshot = FlAnalysisTowerSnapshot(
job_id=job.id,
run_id=run.id,
tower_id=tower.id,
seq_no=tower.seq_no,
tower_no=tower.tower_no,
tower_model=tower.tower_model,
tower_type=tower.tower_type,
longitude=tower.longitude,
latitude=tower.latitude,
altitude_m=tower.altitude_m,
terrain=tower.terrain,
base_tower_json=_build_base_tower_json(tower, job.line),
profile_json=_build_profile_json(profile),
create_date=utcnow(),
)
db.add(snapshot)
snapshots.append(snapshot)
snapshot_count += 1
db.flush()
result_count = 0
summary = _new_result_summary()
tower_map = {tower.id: tower for tower in towers}
for snapshot in snapshots:
payload = {
"base_tower_json": snapshot.base_tower_json or {},
"profile_json": snapshot.profile_json or {},
}
source_result = source_result_map.get(snapshot.tower_id)
if source_result:
payload["source_result_json"] = source_result
graded = (
grade_mitigation_snapshot_payload(
payload,
non_construction=bool(execution_options.get("non_construction")),
)
if job.job_type == "mitigation"
else grade_snapshot_payload(payload)
)
db.add(
FlAnalysisTowerResult(
job_id=job.id,
run_id=run.id,
snapshot_id=snapshot.id,
status="success",
risk_level=graded["risk_level"],
summary_text=graded["summary_text"],
result_json=graded,
create_date=utcnow(),
update_date=utcnow(),
)
)
result_count += 1
_accumulate_result_summary(summary, graded)
tower = tower_map.get(snapshot.tower_id)
if tower is not None and job.job_type != "mitigation":
tower.risk_level = graded["risk_level"]
tower.update_date = utcnow()
tower.update_user = job.update_user or job.create_user
run.snapshot_tower_count = snapshot_count
run.result_tower_count = result_count
job.total_tower_count = len(towers)
job.snapshotted_tower_count = snapshot_count
job.result_tower_count = result_count
if job.job_type == "mitigation":
summary["selected_tower_count"] = len(towers)
summary["source_job_id"] = execution_options.get("source_job_id")
summary["source_run_id"] = execution_options.get("source_run_id")
summary["non_construction"] = bool(execution_options.get("non_construction"))
db.commit()
_finish_rule_based_run(
db,
job_id=job.id,
run_id=run.id,
started_perf=started_perf,
summary=summary,
)
except Exception as exc:
db.rollback()
if run_id is not None:
_mark_run_failed(db, job_id=job_id, run_id=run_id, error_message=str(exc), started_perf=started_perf)
else:
_mark_job_failed_without_run(db, job_id=job_id, error_message=str(exc))
finally:
db.close()
def _dispatch_fl_analysis_task(*, job_id: str):
from ..tasks.fl_analysis_tasks import execute_fl_analysis_job
return execute_fl_analysis_job.delay(job_id)
def _finish_rule_based_run(
db: Session,
*,
job_id: str,
run_id: str,
started_perf: float,
summary: dict[str, Any],
) -> None:
job = get_job_by_id(db, job_id)
run = db.execute(select(FlAnalysisRun).where(FlAnalysisRun.id == run_id)).scalar_one_or_none()
if not job or not run:
return
duration_ms = int((time.perf_counter() - started_perf) * 1000)
now = utcnow()
run.status = "success"
run.error_message = None
run.duration_ms = max(duration_ms, 0)
run.finished_at = now
run.update_date = now
job.status = "success"
job.error_message = None
job.result_summary_json = {
**summary,
"adapter_status": "computed",
"external_adapter": job.external_adapter,
}
job.finished_at = now
job.update_date = now
db.commit()
_publish_change(
"fl_analysis.job.success",
{
"action": "job_success",
"job_id": job.id,
"line_id": job.line_id,
"run_id": run.id,
"summary": job.result_summary_json,
},
)
def _mark_run_failed(
db: Session,
*,
job_id: str,
run_id: str,
error_message: str,
started_perf: float,
) -> None:
run = db.execute(select(FlAnalysisRun).where(FlAnalysisRun.id == run_id)).scalar_one_or_none()
job = get_job_by_id(db, job_id)
if not run or not job:
return
duration_ms = int((time.perf_counter() - started_perf) * 1000)
now = utcnow()
run.status = "failed"
run.error_message = error_message
run.duration_ms = max(duration_ms, 0)
run.finished_at = now
run.update_date = now
job.status = "failed"
job.error_message = error_message
job.finished_at = now
job.update_date = now
db.commit()
_publish_change(
"fl_analysis.job.failed",
{"action": "job_failed", "job_id": job.id, "line_id": job.line_id, "run_id": run.id},
)
def _mark_job_failed_without_run(db: Session, *, job_id: str, error_message: str) -> None:
job = get_job_by_id(db, job_id)
if not job:
return
now = utcnow()
job.status = "failed"
job.error_message = error_message
job.finished_at = now
job.update_date = now
db.commit()
_publish_change(
"fl_analysis.job.failed",
{"action": "job_failed", "job_id": job.id, "line_id": job.line_id},
)
def _build_base_tower_json(tower: LineTower, line: Line | None) -> dict[str, Any]:
return {
"tower_id": tower.id,
"line_id": tower.line_id,
"line_name": line.name if line else None,
"line_voltage_kv": line.voltage_kv if line else None,
"line_phase_sequence_json": (line.phase_sequence_json or {}) if line else {},
"line_arrester_install_json": (line.arrester_install_json or {}) if line else {},
"line_lightning_param_json": (line.lightning_param_json or {}) if line else {},
"seq_no": tower.seq_no,
"tower_no": tower.tower_no,
"tower_model": tower.tower_model,
"tower_type": tower.tower_type,
"longitude": tower.longitude,
"latitude": tower.latitude,
"altitude_m": tower.altitude_m,
"terrain": tower.terrain,
"ground_resistance_ohm": tower.ground_resistance_ohm,
"lightning_density": tower.lightning_density,
"span_small_m": tower.span_small_m,
"span_large_m": tower.span_large_m,
"slope_1": tower.slope_1,
"slope_2": tower.slope_2,
"risk_level": tower.risk_level,
"circuit_geometry_json": tower.circuit_geometry_json or {},
"lightning_result_json": tower.lightning_result_json or {},
"raw_extra_json": tower.raw_extra_json or {},
}
def _build_profile_json(profile: TowerProfile | None) -> dict[str, Any]:
if profile is None:
return {}
return {
"phase_sequence_1": profile.phase_sequence_1,
"phase_sequence_2": profile.phase_sequence_2,
"phase_sequence_3": profile.phase_sequence_3,
"phase_sequence_4": profile.phase_sequence_4,
"arrester_a": profile.arrester_a,
"arrester_b": profile.arrester_b,
"arrester_c": profile.arrester_c,
"protection_angle_left_deg": profile.protection_angle_left_deg,
"protection_angle_right_deg": profile.protection_angle_right_deg,
"shield_wire_height_m": profile.shield_wire_height_m,
"insulator_length_m": profile.insulator_length_m,
"call_height_m": profile.call_height_m,
"angle_deg": profile.angle_deg,
"current_a": profile.current_a,
"current_b": profile.current_b,
"structure_kind": profile.structure_kind,
"stroke_mode": profile.stroke_mode,
"current_type": profile.current_type,
"current_head_time_us": profile.current_head_time_us,
"current_tail_time_us": profile.current_tail_time_us,
"geometry_layers_json": profile.geometry_layers_json or {},
"extra_profile_json": profile.extra_profile_json or {},
}
def _grade_snapshot_payload(payload: dict[str, Any]) -> dict[str, Any]:
return grade_snapshot_payload(payload)
def _new_result_summary() -> dict[str, Any]:
return {
"risk_counts": {"high": 0, "medium": 0, "low": 0},
"baseline_risk_counts": {"high": 0, "medium": 0, "low": 0},
"score_total": 0,
"score_average": 0,
"arrester_required_count": 0,
"action_total": 0,
}
def _accumulate_result_summary(summary: dict[str, Any], graded: dict[str, Any]) -> None:
risk_counts = summary.setdefault("risk_counts", {"high": 0, "medium": 0, "low": 0})
risk_level = str(graded.get("risk_level") or "low")
risk_counts[risk_level] = int(risk_counts.get(risk_level, 0)) + 1
current_risk_level = str(graded.get("current_risk_level") or "")
if current_risk_level:
baseline_counts = summary.setdefault("baseline_risk_counts", {"high": 0, "medium": 0, "low": 0})
baseline_counts[current_risk_level] = int(baseline_counts.get(current_risk_level, 0)) + 1
score = int(graded.get("score") or 0)
summary["score_total"] = int(summary.get("score_total", 0)) + score
total_count = sum(int(value) for value in risk_counts.values())
summary["score_average"] = round(summary["score_total"] / total_count, 2) if total_count else 0
summary["action_total"] = int(summary.get("action_total", 0)) + len(graded.get("mitigation_actions") or [])
if graded.get("recommendation_result") == "需要安装避雷器":
summary["arrester_required_count"] = int(summary.get("arrester_required_count", 0)) + 1
def _normalize_execution_options(job_type: str, execution_options: dict[str, Any]) -> dict[str, Any]:
normalized = dict(execution_options or {})
selected_ids = normalized.get("selected_tower_ids") or normalized.get("tower_ids") or []
if isinstance(selected_ids, list):
normalized["selected_tower_ids"] = list(dict.fromkeys(str(item).strip() for item in selected_ids if str(item).strip()))
else:
normalized["selected_tower_ids"] = []
source_job_id = str(normalized.get("source_job_id") or "").strip()
if source_job_id:
normalized["source_job_id"] = source_job_id
else:
normalized.pop("source_job_id", None)
source_run_id = str(normalized.get("source_run_id") or "").strip()
if source_run_id:
normalized["source_run_id"] = source_run_id
else:
normalized.pop("source_run_id", None)
normalized["non_construction"] = bool(
normalized.get("non_construction")
or normalized.get("mitigation_mode") == "non_construction"
)
if job_type != "mitigation":
normalized.pop("selected_tower_ids", None)
normalized.pop("source_job_id", None)
normalized.pop("source_run_id", None)
normalized.pop("non_construction", None)
return normalized
def _validate_mitigation_options(db: Session, *, line_id: str, execution_options: dict[str, Any]) -> int:
source_job_id = str(execution_options.get("source_job_id") or "")
if not source_job_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="措施推荐任务缺少前驱风险任务")
selected_tower_ids = execution_options.get("selected_tower_ids") or []
if not selected_tower_ids:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="措施推荐任务至少需要选择一座高风险杆塔")
source_job = get_job_by_id(db, source_job_id)
if not source_job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="前驱风险任务不存在")
if source_job.line_id != line_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="前驱风险任务与当前线路不匹配")
if source_job.job_type != "risk":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="前驱任务必须为风险评估任务")
if source_job.status != "success":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="前驱风险任务尚未成功完成")
source_run_id = _resolve_source_run_id(source_job)
if not source_run_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="前驱风险任务缺少可复用结果")
allowed_tower_ids = _load_result_tower_ids(db, job_id=source_job.id, run_id=source_run_id)
if not allowed_tower_ids:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="前驱风险任务暂无可复用杆塔结果")
invalid_ids = [tower_id for tower_id in selected_tower_ids if tower_id not in allowed_tower_ids]
if invalid_ids:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="措施推荐任务包含无效的杆塔选择")
execution_options["source_run_id"] = source_run_id
return len(selected_tower_ids)
def _load_job_towers(db: Session, *, job: FlAnalysisJob, execution_options: dict[str, Any]) -> list[LineTower]:
towers = db.execute(
select(LineTower).where(LineTower.line_id == job.line_id).order_by(LineTower.seq_no.asc())
).scalars().all()
if job.job_type != "mitigation":
return towers
selected_ids = set(execution_options.get("selected_tower_ids") or [])
scoped_towers = [tower for tower in towers if tower.id in selected_ids]
if len(scoped_towers) != len(selected_ids):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="措施推荐任务的杆塔范围已失效,请重新生成任务")
return scoped_towers
def _load_source_result_map(db: Session, *, execution_options: dict[str, Any]) -> dict[str, dict[str, Any]]:
source_job_id = str(execution_options.get("source_job_id") or "")
source_run_id = str(execution_options.get("source_run_id") or "")
if not source_job_id or not source_run_id:
return {}
rows = db.execute(
select(FlAnalysisTowerResult)
.where(FlAnalysisTowerResult.job_id == source_job_id, FlAnalysisTowerResult.run_id == source_run_id)
).scalars().all()
result: dict[str, dict[str, Any]] = {}
for item in rows:
if item.snapshot and item.snapshot.tower_id:
result[item.snapshot.tower_id] = item.result_json or {}
return result
def _load_result_tower_ids(db: Session, *, job_id: str, run_id: str) -> set[str]:
rows = db.execute(
select(FlAnalysisTowerSnapshot.tower_id).where(
FlAnalysisTowerSnapshot.job_id == job_id,
FlAnalysisTowerSnapshot.run_id == run_id,
)
).scalars().all()
return {str(item) for item in rows if item}
def _resolve_source_run_id(job: FlAnalysisJob) -> str | None:
if job.latest_run_id:
return job.latest_run_id
if job.runs:
return job.runs[0].id
return None
def _as_int(value: Any) -> int | None:
parsed = _as_float(value)
if parsed is None:
return None
try:
return int(parsed)
except (TypeError, ValueError):
return None
def _placeholder_message_for_adapter(adapter: str) -> str:
if adapter == "wine":
return "Wine 外部程序适配器已预留,真实执行链路尚未接入"
if adapter == "atp":
return "ATP 适配器已预留,真实执行链路尚未接入"
if adapter == "custom":
return "自定义外部程序适配器已预留,真实执行链路尚未接入"
return "外部分析适配器尚未接入,当前仅完成任务骨架与快照链路"
def _publish_change(event_name: str, payload: dict[str, Any]) -> None:
_fire_and_forget(
publish_topic(
FL_ANALYSIS_TOPIC,
name=event_name,
payload=payload,
requires_refetch=[],
dedupe_key=f"fl-analysis:{event_name}:{payload.get('job_id', '-')}:"
f"{payload.get('run_id', payload.get('line_id', '-'))}",
)
)
def _fire_and_forget(coro: object) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(coro)