"""Scheduled jobs for the security-awareness domain.""" from __future__ import annotations import logging import uuid from typing import Any from sqlalchemy.ext.asyncio import AsyncSession # Imported for its side effect: ``install_ladder()`` runs at import # time and registers this domain's LONGER cadence override. A process # that has the handler registered but not the ladder would chase on # the platform default (daily for the first three days) — which is # exactly the cadence this domain exists to avoid. from app.domains.security_awareness import reminders as _reminders # noqa: F401 from app.scheduled_jobs.registry import register_scheduled_job logger = logging.getLogger(__name__) @register_scheduled_job( key="security_awareness.campaign_dispatch", display_name="安全宣贯 · 发送到点的活动", description=( "把到点的安全宣贯活动发出去。每 10 分钟看一次 `secaw_campaigns` 里 " "`status='scheduled'` 且 `scheduled_at <= now()` 的行,展开受众、" "逐人建台账、经通知 outbox 发飞书卡片 / 邮件 / 站内铃铛。\n" "**只发一次**:活动状态在发送前先由一条**带状态条件的 UPDATE** 置成 " "`sending`,所以两个 tick 撞上同一行时第二个更新到 0 行、直接返回;" "逐人台账的 `(campaign_id, person_id)` 唯一约束让重入变成 UPDATE 而" "不是第二行。\n" "受众在**发送那一刻**展开成快照 —— 活动发出后的人员变动不改写已发" "历史。一个活动失败不拦住其它活动,失败原因写回 " "`last_dispatch_error` 并把状态放回 `scheduled` 等下一个 tick 重试。" ), ) async def run_campaign_dispatch( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.security_awareness.campaign_service import CampaignService trace_id = f"security_awareness.campaign_dispatch:{uuid.uuid4()}" summary = await CampaignService(db).dispatch_due(trace_id=trace_id) await db.commit() logger.info( "security_awareness.campaign_dispatch: considered=%s dispatched=%s " "failed=%s", summary.get("considered"), summary.get("dispatched"), summary.get("failed"), ) # Per ``feedback_handler_status_must_raise``: a handler that returns a # soft-failure dict lands ``runs.status='ok'``, so a dispatcher that # has not been able to send for a week would look healthy. Every # campaign failing is a real outage of this job and must raise; # SOME failing is per-campaign noise already recorded on the row. considered = int(summary.get("considered") or 0) failed = int(summary.get("failed") or 0) if considered and failed == considered: raise RuntimeError( f"every due awareness campaign failed to dispatch ({failed}/" f"{considered}) — see last_dispatch_error on each row" ) return summary @register_scheduled_job( key="security_awareness.campaign_reminders", display_name="安全宣贯 · 催办必修宣贯", description=( "每天看一次哪些人还欠着必修宣贯的确认,并按**递减的**阶梯催办:" "发出后 **7 天**才第一次催(材料几分钟就能读完,大多数人第一周就做了;" "更早催是把渠道花在本来就会照做的人身上),之后 7 / 7 / 14 天,再之后" "每 14 天一次,只要这件事还开着就不停。\n" "**三周后给主管发一次摘要** —— 一张卡列全部下属,不是一人一张:把" "九十个别人的待办丢给五个管理员『produced a chase queue and moved no " "decisions』,这个判断本仓已经下过一次。\n" "每 tick 有条数上限、最久未处理优先,**被压下的条数写进摘要** —— 一次" "悄悄丢掉半个队列的运行,和一次健康的运行长得一模一样。员工一确认就" "立刻销账(`cadence.clear`),否则他下一次因为同类事情被催时会继承这次" "的计数,第一次就落进两周档。\n" "已结束(`closed`)的活动不催 —— 结束这个动作本身就是停止追问。" ), ) async def run_campaign_reminders( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.security_awareness.reminders import ReminderService trace_id = f"security_awareness.campaign_reminders:{uuid.uuid4()}" summary = await ReminderService(db).run(trace_id=trace_id) await db.commit() logger.info( "security_awareness.campaign_reminders: outstanding=%s pushed=%s " "held=%s managers=%s", summary.get("outstanding"), summary.get("employee_pushes"), summary.get("employee_held_back"), summary.get("manager_pushes"), ) return summary #: How many materials one tick may translate. Each costs up to four model #: calls (title+summary and body, per target language), so an unbounded #: tick on a freshly imported library would be a several-minute job #: holding a DB session. Hourly × 10 clears any realistic backlog in a #: day, and ``outstanding`` in the summary says what is left. _DEFAULT_LIMIT = 10 @register_scheduled_job( key="security_awareness.translation_backfill", display_name="安全宣贯 · 自动补齐缺失语言", description=( "每小时把**已发布**素材缺的英文 / 日文补上。起因是一条员工反馈:" "英文界面右下角的安全学习气泡是中文的 —— 语言链路没坏(前端传 " "i18next 当前语言,`pick_block` 按 `LANG_FALLBACK` 解析),缺的是" "内容:2026-09-05 线上 46 条已发布素材里 30 条漫画只有 `zh`。\n" "用平台共享的 AI/LLM 连接器翻译中文 block:标题 + 摘要走 " "`chat_json`,正文走 `chat_text`(让模型把 markdown 塞进 JSON " "字符串是 `BAILIAN_LLM_OUTPUT_TRUNCATED` 的标准配方)。\n" "**不改中文原文、不改发布状态、不译图片** —— 漫画的 image_keys " "原样复制,所以英文读者拿到英文标题 + 中文画面;这只有在告诉读者" "的前提下才诚实,于是生成的语言记进 `secaw_materials.mt_langs`," "每个读取面都带 `machine_translated`,详情页渲染成提示。\n" "一种语言失败不拖累其它语言;每 tick 上限 `config.limit`(默认 " "10),**剩余条数写进 `outstanding`**。没配 LLM key 而确实有缺口" "时直接 raise —— 这个任务此刻做不了它唯一的工作,记成 ok 就是往 " "runs 表里写一句谎话。" ), ) async def run_translation_backfill( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.security_awareness.translation import MaterialTranslator try: limit = int(config.get("limit", _DEFAULT_LIMIT)) except (TypeError, ValueError): limit = _DEFAULT_LIMIT limit = max(1, min(limit, 50)) trace_id = f"security_awareness.translation_backfill:{uuid.uuid4()}" translator = MaterialTranslator(db) gaps = await translator.gap_summary() if not gaps["materials_missing"]: return {**gaps, "attempted": 0, "langs_filled": 0, "outstanding": 0} if not gaps["llm_configured"]: # There IS work and we cannot do any of it. Returning a tidy # "skipped" dict here would land ``runs.status='ok'`` and the job # would look healthy for as long as the connector stayed empty — # the exact failure mode ``feedback_handler_status_must_raise`` # exists to prevent. raise RuntimeError( f"{gaps['materials_missing']} published awareness material(s) are " "missing a language and no AI/LLM key is configured " "(ai_spending.llm.api_key) — employees on the English or " "Japanese UI are being shown Chinese" ) summary = await translator.fill_missing(limit=limit, trace_id=trace_id) await db.commit() logger.info( "security_awareness.translation_backfill: attempted=%s filled=%s " "langs=%s failed=%s outstanding=%s", summary.get("attempted"), summary.get("materials_filled"), summary.get("langs_filled"), summary.get("materials_failed"), summary.get("outstanding"), ) # Some failing is per-material noise (one oversize article body, one # flaky call) and is already recorded in the summary. EVERY attempt # failing is an outage of this job — the vendor is down or the key is # rejected — and must not land as a healthy run. attempted = int(summary.get("attempted") or 0) failed = int(summary.get("materials_failed") or 0) if attempted and failed == attempted: raise RuntimeError( f"every awareness material attempted failed to translate " f"({failed}/{attempted}) — see details in the run summary" ) return summary