"""Monthly compliance-posture summary job. Separate from the pre-compile and drift handlers because it is the only one of the three that may spend tokens. Keeping "which job can cost money" answerable by reading the module list rather than the code is worth one extra file. """ from __future__ import annotations from typing import Any from zoneinfo import ZoneInfo from sqlalchemy.ext.asyncio import AsyncSession from app.scheduled_jobs.registry import register_scheduled_job from app.scheduled_jobs.slo import SLO_DAILY_DIGEST # The month a period NAMES is a calendar month in the business's own # timezone, not in UTC. The cron fires at Asia/Shanghai so a scheduled # run agrees either way, but a MANUAL trigger in the CST small hours is # still the previous day in UTC — and on the 1st that silently picks the # wrong month. Same constant, same reason, as ai_spending_handlers. _PERIOD_TZ = ZoneInfo("Asia/Shanghai") @register_scheduled_job( key="auditor.compliance_posture_summary", display_name="合规态势月报", description=( "每月 3 号 09:00(Asia/Shanghai)把上一个完整自然月的合规态势汇总成一封" "给 auditor / global_admin 的月报:控制项通过率、证据预编译覆盖、" "未决访问漂移、待决策事项。\n" "**这是本主题里唯一会花 Token 的任务。** 事实全部确定性计算并冻结成快照," "模型只拿到计数、比例和匿名编号 —— 它不能改数字、不能点名、不能引入事实," "既有的接地/匿名引用/低可信度三道闸照常生效。\n" "同一个月事实未变则复用已有结论(30 天缓存),重开月报不额外花钱也不会换一套说法。\n" "排在预编译(2 号 03:20)之后:先有证据,再谈覆盖率。\n" 'config={"month":"YYYY-MM"} 补发历史月份;{"notify": false} 只生成不发信。' ), slo=SLO_DAILY_DIGEST, weight="heavy", ) async def run_compliance_posture_summary( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: """Freeze the month's posture facts and narrate them once.""" from app.domains.auditor.posture_summary import ( POSTURE_ANALYSIS_KIND, build_posture_snapshot, notify_posture_summary, ) from app.intelligence.generation import generate_analysis from app.scheduled_jobs.evidence_precompile_handler import ( closed_month_window, ) month = config.get("month") if month: import calendar from datetime import date year, mon = (int(part) for part in str(month).split("-")) period_start = date(year, mon, 1).isoformat() period_end = date( year, mon, calendar.monthrange(year, mon)[1], ).isoformat() else: from datetime import datetime # noqa: PLC0415 # Local date, not UTC — a manual trigger in the CST small hours # is still the previous day in UTC, and on the 1st that # summarises the wrong month. period_start, period_end = closed_month_window( datetime.now(_PERIOD_TZ).date(), ) built = await build_posture_snapshot( db, period_start=period_start, period_end=period_end, ) result = await generate_analysis( db, snapshot=built.snapshot, analysis_kind=POSTURE_ANALYSIS_KIND, actor_id=None, # A scheduled run has no user whose daily quota this should # consume; the cost is the platform's, and it is bounded by the # once-a-month cadence plus the facts-hash cache. enforce_user_quota=False, ) notified = 0 if config.get("notify", True) is not False and not result.cache_reused: # Cache reuse means nothing changed since the last run — sending # the same month's letter twice would train recipients to ignore # it, which is the cheapest way to make a monthly report useless. notified = await notify_posture_summary( db, analysis_id=result.analysis.id, period_label=period_start[:7], ) return { "period_start": period_start, "period_end": period_end, "analysis_id": str(result.analysis.id), "cache_reused": result.cache_reused, "notified": notified, } @register_scheduled_job( key="auditor.answer_library_freshness", display_name="安全问卷答案库·过时标记", description=( "每天 05:10(Asia/Shanghai)重算答案库每条启用答案的过时状态:\n" "① backing_control_failing —— 这条答案依据的控制项不再通过," "它的说法可能已经不成立(紧急);\n" "② review_interval_elapsed —— 没有已知错误,但超过了这条答案自己" "声明的复核周期没人看过。\n" "**只标记,不自动重写。** 自动重起草会把没人看过的 AI 文本推进一份要发给" "客户的合规材料里;答案库的既有纪律是「起草可以自动,发送永远留人」。" "过时标记的作用是把「该复核了」变成可见信号,而不是替人做决定。\n" "控制项恢复通过、或有人复核过之后,标记会在下一次运行时自动清除。\n" "只读外部:不打厂商接口、不调用模型。" ), slo=SLO_DAILY_DIGEST, ) async def run_answer_library_freshness( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: """Recompute answer staleness. Never rewrites an answer.""" from app.domains.auditor.answer_freshness import evaluate_answer_freshness return await evaluate_answer_freshness(db)