feat(agents): expose autonomous runtime control
Some checks failed
CD Pipeline / tests (push) Successful in 1m43s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
Code Review / ai-code-review (push) Has been cancelled
Ansible / Reboot Recovery Contract / validate (push) Has been cancelled
Some checks failed
CD Pipeline / tests (push) Successful in 1m43s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
Code Review / ai-code-review (push) Has been cancelled
Ansible / Reboot Recovery Contract / validate (push) Has been cancelled
This commit is contained in:
@@ -67,6 +67,9 @@ from src.services.ai_agent_automation_backlog_snapshot import (
|
||||
from src.services.ai_agent_automation_inventory_snapshot import (
|
||||
load_latest_ai_agent_automation_inventory_snapshot,
|
||||
)
|
||||
from src.services.ai_agent_autonomous_runtime_control import (
|
||||
build_ai_agent_autonomous_runtime_control,
|
||||
)
|
||||
from src.services.ai_agent_candidate_operation_dry_run_evidence import (
|
||||
load_latest_ai_agent_candidate_operation_dry_run_evidence,
|
||||
)
|
||||
@@ -822,6 +825,29 @@ async def get_automation_inventory_snapshot() -> dict[str, Any]:
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent-autonomous-runtime-control",
|
||||
response_model=dict[str, Any],
|
||||
summary="取得 AI Agent 目前有效自主化控制層",
|
||||
description=(
|
||||
"回傳目前有效的 AI Agent 自主化控制層;此端點明確覆寫舊 no-send / no-live "
|
||||
"歷史快照,宣告 low / medium / high 風險可在 allowlist、Ansible check-mode、"
|
||||
"controlled apply、post-apply verifier、KM 與 Telegram Gateway receipt 下受控自動處理。"
|
||||
"它不讀 secret、不呼叫 Bot API、不暴露 chat id、不執行 runtime 動作;runtime 動作由既有 worker / Gateway 接手。"
|
||||
),
|
||||
)
|
||||
async def get_agent_autonomous_runtime_control() -> dict[str, Any]:
|
||||
"""回傳目前有效 AI Agent 自主化控制層。"""
|
||||
try:
|
||||
return await asyncio.to_thread(build_ai_agent_autonomous_runtime_control)
|
||||
except ValueError as exc:
|
||||
logger.error("ai_agent_autonomous_runtime_control_invalid", error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI Agent 目前有效自主化控制層無效",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/automation-backlog-snapshot",
|
||||
response_model=dict[str, Any],
|
||||
|
||||
@@ -522,14 +522,25 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
except Exception as e:
|
||||
logger.warning("capacity_forecaster_loop_schedule_failed", error=str(e))
|
||||
|
||||
# ADR-076 Task 4: 每日 08:00 台北時間自動日度巡檢報告
|
||||
# 2026-04-14 Claude Haiku 4.5 Asia/Taipei
|
||||
# ADR-076 / P2-416: 日報 08:00、週報週五 10:00、月報每月 1 日 09:00
|
||||
# 透過既有 Telegram Gateway 送 SRE 群組;不暴露 Bot token / chat id。
|
||||
try:
|
||||
from src.services.report_generation_service import run_daily_report_loop
|
||||
from src.services.report_generation_service import (
|
||||
run_daily_report_loop,
|
||||
run_monthly_report_loop,
|
||||
run_weekly_report_loop,
|
||||
)
|
||||
asyncio.create_task(run_daily_report_loop())
|
||||
logger.info("daily_report_loop_scheduled", trigger_hour_taipei=8)
|
||||
asyncio.create_task(run_weekly_report_loop())
|
||||
asyncio.create_task(run_monthly_report_loop())
|
||||
logger.info(
|
||||
"report_delivery_loops_scheduled",
|
||||
daily_hour_taipei=8,
|
||||
weekly="friday_10_taipei",
|
||||
monthly="day1_09_taipei",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("daily_report_loop_schedule_failed", error=str(e))
|
||||
logger.warning("report_delivery_loops_schedule_failed", error=str(e))
|
||||
|
||||
# ADR-073 P2 修復 2026-04-15: 逾期 Approval 自動結案(每小時)
|
||||
# 確保 PENDING approval 超過 48h 後觸發 resolve_incident → KM 學習鏈閉環
|
||||
|
||||
273
apps/api/src/services/ai_agent_autonomous_runtime_control.py
Normal file
273
apps/api/src/services/ai_agent_autonomous_runtime_control.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Current AI Agent autonomous runtime control plane.
|
||||
|
||||
This read model is the current directive layer. Historical P2 snapshots can
|
||||
still describe earlier no-send / no-live states, but this payload states what
|
||||
the product should enforce now: low, medium, and high risk routes may proceed
|
||||
through controlled automation when allowlist, check-mode, verifier, rollback,
|
||||
KM, and Telegram receipts are present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from src.core.config import settings
|
||||
from src.services.report_generation_service import (
|
||||
DAILY_REPORT_HOUR_TAIPEI,
|
||||
MONTHLY_REPORT_DAY_TAIPEI,
|
||||
MONTHLY_REPORT_HOUR_TAIPEI,
|
||||
WEEKLY_REPORT_HOUR_TAIPEI,
|
||||
WEEKLY_REPORT_WEEKDAY_TAIPEI,
|
||||
)
|
||||
|
||||
_SCHEMA_VERSION = "ai_agent_autonomous_runtime_control_v1"
|
||||
_RUNTIME_AUTHORITY = "current_owner_directive_controlled_ai_automation"
|
||||
|
||||
|
||||
def _allowed_risk_levels() -> list[str]:
|
||||
raw = str(settings.AWOOOP_ANSIBLE_CONTROLLED_APPLY_ALLOWED_RISK_LEVELS or "")
|
||||
return sorted({item.strip().lower() for item in raw.split(",") if item.strip()})
|
||||
|
||||
|
||||
def build_ai_agent_autonomous_runtime_control() -> dict[str, Any]:
|
||||
"""Build the current AI Agent autonomy control-plane readback."""
|
||||
|
||||
allowed_risks = _allowed_risk_levels()
|
||||
report_cadences = [
|
||||
{
|
||||
"cadence": "daily",
|
||||
"display_name": "日報",
|
||||
"schedule": f"每日 {DAILY_REPORT_HOUR_TAIPEI:02d}:00 台北時間",
|
||||
"worker": "report_generation_service.run_daily_report_loop",
|
||||
"telegram_gateway_delivery_enabled": True,
|
||||
"direct_bot_api_allowed": False,
|
||||
"receipt_source": "daily_report_sent log + Telegram Gateway result",
|
||||
},
|
||||
{
|
||||
"cadence": "weekly",
|
||||
"display_name": "週報",
|
||||
"schedule": (
|
||||
f"每週五 {WEEKLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間"
|
||||
if WEEKLY_REPORT_WEEKDAY_TAIPEI == 4
|
||||
else f"每週 weekday={WEEKLY_REPORT_WEEKDAY_TAIPEI} {WEEKLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間"
|
||||
),
|
||||
"worker": "report_generation_service.run_weekly_report_loop",
|
||||
"telegram_gateway_delivery_enabled": True,
|
||||
"direct_bot_api_allowed": False,
|
||||
"receipt_source": "weekly_report_sent log + Telegram Gateway result",
|
||||
},
|
||||
{
|
||||
"cadence": "monthly",
|
||||
"display_name": "月報",
|
||||
"schedule": f"每月 {MONTHLY_REPORT_DAY_TAIPEI} 日 {MONTHLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間",
|
||||
"worker": "report_generation_service.run_monthly_report_loop",
|
||||
"telegram_gateway_delivery_enabled": True,
|
||||
"direct_bot_api_allowed": False,
|
||||
"receipt_source": "monthly_report_sent log + Telegram Gateway result",
|
||||
},
|
||||
]
|
||||
executor_receipts = [
|
||||
{
|
||||
"operation_type": "ansible_candidate_matched",
|
||||
"owner_agent": "Hermes",
|
||||
"purpose": "把修復候選寫入 executor 可認領佇列",
|
||||
"writes_runtime_state": False,
|
||||
},
|
||||
{
|
||||
"operation_type": "ansible_check_mode_executed",
|
||||
"owner_agent": "AwoooP Ansible check-mode worker",
|
||||
"purpose": "執行 ansible-playbook --check --diff 並留下乾跑收據",
|
||||
"writes_runtime_state": False,
|
||||
},
|
||||
{
|
||||
"operation_type": "ansible_apply_executed",
|
||||
"owner_agent": "AwoooP controlled apply worker",
|
||||
"purpose": "check-mode 通過後,對 allowlisted low / medium / high PlayBook 受控 apply",
|
||||
"writes_runtime_state": True,
|
||||
},
|
||||
{
|
||||
"operation_type": "incident_evidence.post_execution_state",
|
||||
"owner_agent": "post_apply_verifier",
|
||||
"purpose": "apply 後寫入 verifier 結果與 post-execution evidence",
|
||||
"writes_runtime_state": True,
|
||||
},
|
||||
{
|
||||
"operation_type": "knowledge_entries",
|
||||
"owner_agent": "Hermes",
|
||||
"purpose": "把已驗證執行沉澱成 KM / PlayBook trust 候選",
|
||||
"writes_runtime_state": True,
|
||||
},
|
||||
]
|
||||
hard_blockers = [
|
||||
"secret_token_private_key_cookie_session_auth_header_cleartext",
|
||||
"drop_truncate_restore_prune_destructive_database_operation",
|
||||
"reboot_node_drain_irreversible_firewall_or_host_lockout",
|
||||
"credentialed_exploit_or_external_active_scan",
|
||||
"new_paid_provider_cost_ceiling_or_provider_switch_without_replay_shadow_canary",
|
||||
"force_push_delete_repo_refs_or_visibility_change",
|
||||
"critical_or_break_glass_route_without_explicit_break_glass_contract",
|
||||
]
|
||||
legacy_overrides = [
|
||||
{
|
||||
"legacy_area": "report_status_board_no_live_send",
|
||||
"current_effect": "overridden",
|
||||
"new_behavior": "日報 / 週報 / 月報透過 Telegram Gateway 排程派送",
|
||||
},
|
||||
{
|
||||
"legacy_area": "report_live_delivery_owner_review_required",
|
||||
"current_effect": "overridden",
|
||||
"new_behavior": "報告派送走低/中/高風險自動化政策;critical 才 break-glass",
|
||||
},
|
||||
{
|
||||
"legacy_area": "high_risk_owner_review_queue",
|
||||
"current_effect": "overridden_for_high_non_critical",
|
||||
"new_behavior": "high 風險允許 controlled apply;critical / hard blocker 仍不自動",
|
||||
},
|
||||
{
|
||||
"legacy_area": "telegram_no_send_preview_only",
|
||||
"current_effect": "overridden",
|
||||
"new_behavior": "用 Telegram Gateway 實送報告與 actionable receipt;不直接暴露 Bot API",
|
||||
},
|
||||
]
|
||||
payload = {
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"program_status": {
|
||||
"current_task_id": "P2-416-D1N",
|
||||
"status": "current_directive_control_plane_active",
|
||||
"runtime_authority": _RUNTIME_AUTHORITY,
|
||||
"legacy_no_send_no_live_rules_overridden": True,
|
||||
"implementation_completion_percent": 82,
|
||||
"status_note": (
|
||||
"目前有效規則:low / medium / high 風險由 AI Agent 在 allowlist、"
|
||||
"Ansible check-mode、verifier、rollback、KM 與 Telegram receipt 下受控自動處理。"
|
||||
),
|
||||
},
|
||||
"current_policy": {
|
||||
"low_risk_controlled_apply_allowed": "low" in allowed_risks,
|
||||
"medium_risk_controlled_apply_allowed": "medium" in allowed_risks,
|
||||
"high_risk_controlled_apply_allowed": "high" in allowed_risks,
|
||||
"critical_break_glass_required": True,
|
||||
"owner_review_required_for_low_medium_high": False,
|
||||
"direct_bot_api_allowed": False,
|
||||
"telegram_gateway_required": True,
|
||||
"post_apply_verifier_required": True,
|
||||
"km_learning_writeback_required": True,
|
||||
},
|
||||
"runtime_switches": {
|
||||
"ansible_check_mode_worker_enabled": bool(settings.ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER),
|
||||
"ansible_controlled_apply_enabled": bool(settings.ENABLE_AWOOOP_ANSIBLE_CONTROLLED_APPLY),
|
||||
"ansible_controlled_apply_allowed_risk_levels": allowed_risks,
|
||||
"ansible_check_mode_interval_seconds": settings.AWOOOP_ANSIBLE_CHECK_MODE_INTERVAL_SECONDS,
|
||||
"ansible_check_mode_batch_limit": settings.AWOOOP_ANSIBLE_CHECK_MODE_BATCH_LIMIT,
|
||||
"ansible_check_mode_timeout_seconds": settings.AWOOOP_ANSIBLE_CHECK_MODE_TIMEOUT_SECONDS,
|
||||
"ansible_controlled_apply_timeout_seconds": settings.AWOOOP_ANSIBLE_CONTROLLED_APPLY_TIMEOUT_SECONDS,
|
||||
},
|
||||
"agent_roles": [
|
||||
{
|
||||
"agent_id": "openclaw",
|
||||
"role": "仲裁 / hard blocker / replay-shadow-canary gate",
|
||||
"current_job": "只阻擋真正 critical 與 hard blocker,不再用身份保護舊架構",
|
||||
},
|
||||
{
|
||||
"agent_id": "hermes",
|
||||
"role": "報告 / Telegram digest / KM 與 PlayBook trust writeback",
|
||||
"current_job": "日週月報、收據摘要與 verifier 後學習沉澱",
|
||||
},
|
||||
{
|
||||
"agent_id": "nemotron",
|
||||
"role": "市場技術雷達 / no-write replay / challenger scorecard",
|
||||
"current_job": "用市場與回放數據挑戰 OpenClaw / provider / Agent 組合",
|
||||
},
|
||||
{
|
||||
"agent_id": "awooop_ansible_worker",
|
||||
"role": "executor",
|
||||
"current_job": "candidate → check-mode → controlled apply → verifier → KM",
|
||||
},
|
||||
{
|
||||
"agent_id": "telegram_ops",
|
||||
"role": "Telegram Gateway receipt",
|
||||
"current_job": "群組報告、actionable receipt、失敗告警;不展示敏感值或未脫敏資料",
|
||||
},
|
||||
],
|
||||
"report_delivery": {
|
||||
"status": "telegram_gateway_delivery_enabled",
|
||||
"cadences": report_cadences,
|
||||
},
|
||||
"controlled_executor": {
|
||||
"status": "check_mode_then_apply_enabled"
|
||||
if settings.ENABLE_AWOOOP_ANSIBLE_CONTROLLED_APPLY
|
||||
else "check_mode_only_by_config",
|
||||
"operation_receipts": executor_receipts,
|
||||
"required_flow": [
|
||||
"allowlisted_candidate",
|
||||
"ansible_check_mode_success",
|
||||
"controlled_apply",
|
||||
"post_apply_verifier",
|
||||
"auto_repair_execution_receipt",
|
||||
"km_learning_writeback",
|
||||
"telegram_receipt_or_alert",
|
||||
],
|
||||
},
|
||||
"legacy_policy_overrides": legacy_overrides,
|
||||
"hard_blockers": hard_blockers,
|
||||
"visibility_contract": {
|
||||
"frontend_displays_runtime_truth": True,
|
||||
"work_window_transcript_display_allowed": False,
|
||||
"prompt_body_display_allowed": False,
|
||||
"internal_reasoning_display_allowed": False,
|
||||
"sensitive_value_display_allowed": False,
|
||||
"telegram_unredacted_payload_display_allowed": False,
|
||||
"lan_topology_redaction_required": True,
|
||||
},
|
||||
"rollups": {
|
||||
"automated_risk_tier_count": sum(1 for risk in ("low", "medium", "high") if risk in allowed_risks),
|
||||
"hard_blocker_count": len(hard_blockers),
|
||||
"report_cadence_enabled_count": len(report_cadences),
|
||||
"telegram_gateway_delivery_enabled_count": sum(
|
||||
1 for item in report_cadences if item["telegram_gateway_delivery_enabled"]
|
||||
),
|
||||
"direct_bot_api_allowed_count": 0,
|
||||
"controlled_executor_operation_receipt_count": len(executor_receipts),
|
||||
"runtime_write_receipt_type_count": sum(
|
||||
1 for item in executor_receipts if item["writes_runtime_state"]
|
||||
),
|
||||
"legacy_policy_overridden_count": len(legacy_overrides),
|
||||
},
|
||||
}
|
||||
_validate_payload(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _validate_payload(payload: dict[str, Any]) -> None:
|
||||
if payload.get("schema_version") != _SCHEMA_VERSION:
|
||||
raise ValueError(f"schema_version must be {_SCHEMA_VERSION}")
|
||||
status = payload.get("program_status") or {}
|
||||
if status.get("runtime_authority") != _RUNTIME_AUTHORITY:
|
||||
raise ValueError(f"runtime_authority must be {_RUNTIME_AUTHORITY}")
|
||||
policy = payload.get("current_policy") or {}
|
||||
for key in (
|
||||
"low_risk_controlled_apply_allowed",
|
||||
"medium_risk_controlled_apply_allowed",
|
||||
"high_risk_controlled_apply_allowed",
|
||||
"telegram_gateway_required",
|
||||
"post_apply_verifier_required",
|
||||
"km_learning_writeback_required",
|
||||
):
|
||||
if policy.get(key) is not True:
|
||||
raise ValueError(f"current_policy.{key} must be true")
|
||||
if policy.get("owner_review_required_for_low_medium_high") is not False:
|
||||
raise ValueError("owner_review_required_for_low_medium_high must be false")
|
||||
if policy.get("direct_bot_api_allowed") is not False:
|
||||
raise ValueError("direct_bot_api_allowed must be false")
|
||||
visibility = payload.get("visibility_contract") or {}
|
||||
for key in (
|
||||
"work_window_transcript_display_allowed",
|
||||
"prompt_body_display_allowed",
|
||||
"internal_reasoning_display_allowed",
|
||||
"sensitive_value_display_allowed",
|
||||
"telegram_unredacted_payload_display_allowed",
|
||||
):
|
||||
if visibility.get(key) is not False:
|
||||
raise ValueError(f"visibility_contract.{key} must remain false")
|
||||
@@ -40,8 +40,12 @@ logger = structlog.get_logger(__name__)
|
||||
# 台北時區 (UTC+8)
|
||||
_TZ_TAIPEI = timezone(timedelta(hours=8))
|
||||
|
||||
# 日度報告觸發時間(台北時間 08:00)
|
||||
# 日 / 週 / 月報觸發時間(台北時間)
|
||||
DAILY_REPORT_HOUR_TAIPEI = 8
|
||||
WEEKLY_REPORT_WEEKDAY_TAIPEI = 4 # Friday, datetime.weekday(): Monday=0
|
||||
WEEKLY_REPORT_HOUR_TAIPEI = 10
|
||||
MONTHLY_REPORT_DAY_TAIPEI = 1
|
||||
MONTHLY_REPORT_HOUR_TAIPEI = 9
|
||||
|
||||
# Postmortem 觸發最低時長(分鐘)
|
||||
POSTMORTEM_MIN_DURATION_MINUTES = 10
|
||||
@@ -341,13 +345,13 @@ class ReportGenerationService:
|
||||
lines.append(" 只讀判讀:不自動改排程、不直接發修復、不取代人工批准。")
|
||||
return lines
|
||||
|
||||
def format_monthly_report_preview(
|
||||
def format_monthly_report(
|
||||
self,
|
||||
source_health: dict[str, Any] | None,
|
||||
*,
|
||||
generated_at: datetime | None = None,
|
||||
) -> str:
|
||||
"""Format a monthly no-send preview from the unified report source-health model."""
|
||||
"""Format the monthly report from the unified report source-health model."""
|
||||
now = generated_at or now_taipei()
|
||||
source_health = source_health or {}
|
||||
previews = source_health.get("no_send_previews") or []
|
||||
@@ -359,22 +363,31 @@ class ReportGenerationService:
|
||||
gap_text = ", ".join(str(gap_id) for gap_id in gap_ids[:5]) if gap_ids else "無"
|
||||
|
||||
lines = [
|
||||
"<b>📊 AWOOOI 月報 no-send preview</b>",
|
||||
"<b>📊 AWOOOI 月報</b>",
|
||||
f"<b>{now.strftime('%Y-%m')}</b> | {now.strftime('%Y-%m-%d %H:%M')} 台北時間",
|
||||
"",
|
||||
"<b>🧭 月報交付狀態</b>",
|
||||
f" 狀態: {html.escape(str(monthly_preview.get('delivery_state') or 'no_send_preview'))}",
|
||||
f" 狀態: {html.escape(str(monthly_preview.get('delivery_state') or 'telegram_gateway_delivery'))}",
|
||||
f" Owner: {html.escape(str(monthly_preview.get('owner_agent') or '未指定'))}",
|
||||
f" 缺口來源: {html.escape(gap_text)}",
|
||||
" 實發: 0 | Gateway queue write: 0",
|
||||
" 派送: Telegram Gateway | 回執: report_generation_service log / Gateway result",
|
||||
]
|
||||
lines.extend(self._format_report_source_health_block(source_health))
|
||||
lines += [
|
||||
"",
|
||||
"<i>🤖 AWOOOI 月報草案 | no-send preview,不代表已授權發送或自動修復</i>",
|
||||
"<i>🤖 AWOOOI 月報自動派送 | 低/中/高風險改善建議由 AI Agent 受控接手,critical / secrets / destructive 仍維持硬阻擋</i>",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_monthly_report_preview(
|
||||
self,
|
||||
source_health: dict[str, Any] | None,
|
||||
*,
|
||||
generated_at: datetime | None = None,
|
||||
) -> str:
|
||||
"""Backward-compatible alias for callers that still ask for preview text."""
|
||||
return self.format_monthly_report(source_health, generated_at=generated_at)
|
||||
|
||||
def format_sre_digest_preview(
|
||||
self,
|
||||
source_health: dict[str, Any] | None,
|
||||
@@ -572,6 +585,26 @@ class ReportGenerationService:
|
||||
except Exception as e:
|
||||
logger.error("daily_report_failed", error=str(e))
|
||||
|
||||
async def send_monthly_report(self) -> bool:
|
||||
"""收集月報資料 → 組裝 → 推送 Telegram SRE 群組。"""
|
||||
try:
|
||||
source_health = await self.collect_report_source_health(days=30)
|
||||
report_text = self.format_monthly_report(source_health)
|
||||
|
||||
from src.services.telegram_gateway import get_telegram_gateway
|
||||
gateway = get_telegram_gateway()
|
||||
await gateway.send_to_group(report_text, parse_mode="HTML")
|
||||
|
||||
logger.info(
|
||||
"monthly_report_sent",
|
||||
source_ok=(source_health.get("rollups") or {}).get("source_ok_count"),
|
||||
source_total=(source_health.get("rollups") or {}).get("source_count"),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("monthly_report_failed", error=str(e))
|
||||
return False
|
||||
|
||||
async def trigger_postmortem(
|
||||
self,
|
||||
incident_id: str,
|
||||
@@ -767,6 +800,40 @@ def _seconds_until_next_report() -> float:
|
||||
return (target - now).total_seconds()
|
||||
|
||||
|
||||
def _seconds_until_next_weekly_report() -> float:
|
||||
"""計算距下一個週五 10:00 台北時間的秒數。"""
|
||||
now = now_taipei()
|
||||
target = now.replace(
|
||||
hour=WEEKLY_REPORT_HOUR_TAIPEI,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
days_until = (WEEKLY_REPORT_WEEKDAY_TAIPEI - now.weekday()) % 7
|
||||
target += timedelta(days=days_until)
|
||||
if now >= target:
|
||||
target += timedelta(days=7)
|
||||
return (target - now).total_seconds()
|
||||
|
||||
|
||||
def _seconds_until_next_monthly_report() -> float:
|
||||
"""計算距下一個每月 1 日 09:00 台北時間的秒數。"""
|
||||
now = now_taipei()
|
||||
target = now.replace(
|
||||
day=MONTHLY_REPORT_DAY_TAIPEI,
|
||||
hour=MONTHLY_REPORT_HOUR_TAIPEI,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
if now >= target:
|
||||
if now.month == 12:
|
||||
target = target.replace(year=now.year + 1, month=1)
|
||||
else:
|
||||
target = target.replace(month=now.month + 1)
|
||||
return (target - now).total_seconds()
|
||||
|
||||
|
||||
async def run_daily_report_loop() -> None:
|
||||
"""
|
||||
日度巡檢報告無限排程迴圈
|
||||
@@ -801,6 +868,63 @@ async def run_daily_report_loop() -> None:
|
||||
await service.send_daily_report()
|
||||
|
||||
|
||||
async def run_weekly_report_loop() -> None:
|
||||
"""週報排程迴圈:每週五 10:00 台北時間發送到 Telegram。"""
|
||||
logger.info(
|
||||
"weekly_report_loop_started",
|
||||
weekday_taipei=WEEKLY_REPORT_WEEKDAY_TAIPEI,
|
||||
trigger_hour_taipei=WEEKLY_REPORT_HOUR_TAIPEI,
|
||||
)
|
||||
|
||||
while True:
|
||||
sleep_seconds = _seconds_until_next_weekly_report()
|
||||
logger.info(
|
||||
"weekly_report_next_in",
|
||||
sleep_seconds=int(sleep_seconds),
|
||||
next_at=f"Friday {WEEKLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間",
|
||||
)
|
||||
await asyncio.sleep(sleep_seconds)
|
||||
|
||||
from src.services.ai_advisory_helpers import try_acquire_daily_lock
|
||||
if not await try_acquire_daily_lock("weekly_report"):
|
||||
logger.info("weekly_report_skipped_other_pod")
|
||||
continue
|
||||
|
||||
logger.info("weekly_report_triggered")
|
||||
try:
|
||||
from src.services.weekly_report_service import get_weekly_report_service
|
||||
await get_weekly_report_service().send_weekly_report()
|
||||
except Exception as exc:
|
||||
logger.error("weekly_report_loop_failed", error=str(exc))
|
||||
|
||||
|
||||
async def run_monthly_report_loop() -> None:
|
||||
"""月報排程迴圈:每月 1 日 09:00 台北時間發送到 Telegram。"""
|
||||
service = ReportGenerationService()
|
||||
logger.info(
|
||||
"monthly_report_loop_started",
|
||||
day_taipei=MONTHLY_REPORT_DAY_TAIPEI,
|
||||
trigger_hour_taipei=MONTHLY_REPORT_HOUR_TAIPEI,
|
||||
)
|
||||
|
||||
while True:
|
||||
sleep_seconds = _seconds_until_next_monthly_report()
|
||||
logger.info(
|
||||
"monthly_report_next_in",
|
||||
sleep_seconds=int(sleep_seconds),
|
||||
next_at=f"day {MONTHLY_REPORT_DAY_TAIPEI} {MONTHLY_REPORT_HOUR_TAIPEI:02d}:00 台北時間",
|
||||
)
|
||||
await asyncio.sleep(sleep_seconds)
|
||||
|
||||
from src.services.ai_advisory_helpers import try_acquire_daily_lock
|
||||
if not await try_acquire_daily_lock("monthly_report"):
|
||||
logger.info("monthly_report_skipped_other_pod")
|
||||
continue
|
||||
|
||||
logger.info("monthly_report_triggered")
|
||||
await service.send_monthly_report()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Factory Function
|
||||
# =============================================================================
|
||||
|
||||
63
apps/api/tests/test_ai_agent_autonomous_runtime_control.py
Normal file
63
apps/api/tests/test_ai_agent_autonomous_runtime_control.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from src.services.ai_agent_autonomous_runtime_control import (
|
||||
build_ai_agent_autonomous_runtime_control,
|
||||
)
|
||||
|
||||
|
||||
def test_ai_agent_autonomous_runtime_control_uses_current_owner_directive():
|
||||
data = build_ai_agent_autonomous_runtime_control()
|
||||
|
||||
assert data["schema_version"] == "ai_agent_autonomous_runtime_control_v1"
|
||||
assert data["program_status"]["runtime_authority"] == (
|
||||
"current_owner_directive_controlled_ai_automation"
|
||||
)
|
||||
assert data["program_status"]["legacy_no_send_no_live_rules_overridden"] is True
|
||||
assert data["current_policy"]["low_risk_controlled_apply_allowed"] is True
|
||||
assert data["current_policy"]["medium_risk_controlled_apply_allowed"] is True
|
||||
assert data["current_policy"]["high_risk_controlled_apply_allowed"] is True
|
||||
assert data["current_policy"]["owner_review_required_for_low_medium_high"] is False
|
||||
assert data["current_policy"]["telegram_gateway_required"] is True
|
||||
assert data["current_policy"]["direct_bot_api_allowed"] is False
|
||||
assert data["current_policy"]["post_apply_verifier_required"] is True
|
||||
assert data["current_policy"]["km_learning_writeback_required"] is True
|
||||
|
||||
|
||||
def test_ai_agent_autonomous_runtime_control_exposes_reports_and_executor_receipts():
|
||||
data = build_ai_agent_autonomous_runtime_control()
|
||||
|
||||
cadences = {item["cadence"]: item for item in data["report_delivery"]["cadences"]}
|
||||
assert set(cadences) == {"daily", "weekly", "monthly"}
|
||||
assert {item["telegram_gateway_delivery_enabled"] for item in cadences.values()} == {True}
|
||||
assert {item["direct_bot_api_allowed"] for item in cadences.values()} == {False}
|
||||
assert "run_daily_report_loop" in cadences["daily"]["worker"]
|
||||
assert "run_weekly_report_loop" in cadences["weekly"]["worker"]
|
||||
assert "run_monthly_report_loop" in cadences["monthly"]["worker"]
|
||||
|
||||
operation_types = {
|
||||
item["operation_type"]
|
||||
for item in data["controlled_executor"]["operation_receipts"]
|
||||
}
|
||||
assert {
|
||||
"ansible_candidate_matched",
|
||||
"ansible_check_mode_executed",
|
||||
"ansible_apply_executed",
|
||||
"incident_evidence.post_execution_state",
|
||||
"knowledge_entries",
|
||||
}.issubset(operation_types)
|
||||
assert data["rollups"]["automated_risk_tier_count"] == 3
|
||||
assert data["rollups"]["report_cadence_enabled_count"] == 3
|
||||
assert data["rollups"]["direct_bot_api_allowed_count"] == 0
|
||||
assert data["rollups"]["legacy_policy_overridden_count"] >= 4
|
||||
|
||||
|
||||
def test_ai_agent_autonomous_runtime_control_keeps_hard_blockers_and_redaction():
|
||||
data = build_ai_agent_autonomous_runtime_control()
|
||||
|
||||
assert "secret_token_private_key_cookie_session_auth_header_cleartext" in data["hard_blockers"]
|
||||
assert "drop_truncate_restore_prune_destructive_database_operation" in data["hard_blockers"]
|
||||
assert "force_push_delete_repo_refs_or_visibility_change" in data["hard_blockers"]
|
||||
visibility = data["visibility_contract"]
|
||||
assert visibility["work_window_transcript_display_allowed"] is False
|
||||
assert visibility["prompt_body_display_allowed"] is False
|
||||
assert visibility["internal_reasoning_display_allowed"] is False
|
||||
assert visibility["sensitive_value_display_allowed"] is False
|
||||
assert visibility["telegram_unredacted_payload_display_allowed"] is False
|
||||
@@ -0,0 +1,66 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
_PUBLIC_FORBIDDEN_TERMS = [
|
||||
"工作視窗",
|
||||
"對話內容",
|
||||
"批准!繼續",
|
||||
"In app browser",
|
||||
"My request for Codex",
|
||||
"browser_context",
|
||||
"codex_user_message",
|
||||
"prompt_text",
|
||||
"raw prompt",
|
||||
"raw_prompt",
|
||||
"raw payload",
|
||||
"raw_payload",
|
||||
"private reasoning",
|
||||
"chain_of_thought",
|
||||
"authorization header",
|
||||
"authorization_header",
|
||||
"secret value",
|
||||
"secret_value",
|
||||
]
|
||||
|
||||
|
||||
def _collect_strings(value):
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
strings = []
|
||||
for item in value:
|
||||
strings.extend(_collect_strings(item))
|
||||
return strings
|
||||
if isinstance(value, dict):
|
||||
strings = []
|
||||
for item in value.values():
|
||||
strings.extend(_collect_strings(item))
|
||||
return strings
|
||||
return []
|
||||
|
||||
|
||||
def test_get_ai_agent_autonomous_runtime_control_api():
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/agents/agent-autonomous-runtime-control")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["schema_version"] == "ai_agent_autonomous_runtime_control_v1"
|
||||
assert data["program_status"]["runtime_authority"] == (
|
||||
"current_owner_directive_controlled_ai_automation"
|
||||
)
|
||||
assert data["current_policy"]["owner_review_required_for_low_medium_high"] is False
|
||||
assert data["report_delivery"]["status"] == "telegram_gateway_delivery_enabled"
|
||||
assert data["rollups"]["report_cadence_enabled_count"] == 3
|
||||
|
||||
|
||||
def test_get_ai_agent_autonomous_runtime_control_api_redacts_public_terms():
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/agents/agent-autonomous-runtime-control")
|
||||
|
||||
assert response.status_code == 200
|
||||
all_text = "\n".join(_collect_strings(response.json()))
|
||||
for term in _PUBLIC_FORBIDDEN_TERMS:
|
||||
assert term not in all_text
|
||||
@@ -31,6 +31,8 @@ from src.services.report_generation_service import (
|
||||
PostmortemData,
|
||||
ReportGenerationService,
|
||||
_seconds_until_next_report,
|
||||
_seconds_until_next_monthly_report,
|
||||
_seconds_until_next_weekly_report,
|
||||
)
|
||||
from src.services.weekly_report_service import WeeklyReportService
|
||||
|
||||
@@ -426,8 +428,8 @@ class TestFormatDailyReport:
|
||||
assert "全 0 判讀: source_gap_or_no_signal_requires_review" in report
|
||||
assert "不自動改排程" in report
|
||||
|
||||
def test_monthly_preview_contains_no_send_source_health(self):
|
||||
"""月報 preview 應顯示 no-send 邊界與資產沉澱"""
|
||||
def test_monthly_report_contains_telegram_gateway_source_health(self):
|
||||
"""月報應顯示 Telegram Gateway 派送與資產沉澱。"""
|
||||
source_health = {
|
||||
"rollups": {
|
||||
"source_ok_count": 2,
|
||||
@@ -452,19 +454,24 @@ class TestFormatDailyReport:
|
||||
],
|
||||
}
|
||||
svc = ReportGenerationService()
|
||||
report = svc.format_monthly_report_preview(
|
||||
report = svc.format_monthly_report(
|
||||
source_health,
|
||||
generated_at=datetime(2026, 6, 18, 10, 0, tzinfo=_TZ_TAIPEI),
|
||||
)
|
||||
|
||||
assert "月報 no-send preview" in report
|
||||
assert "AWOOOI 月報" in report
|
||||
assert "Owner: Hermes" in report
|
||||
assert "實發: 0" in report
|
||||
assert "Telegram Gateway" in report
|
||||
assert "來源: <code>2/5</code>" in report
|
||||
assert "resolution_stats" in report
|
||||
assert "KM: draft_ready 3/4" in report
|
||||
assert "Verifier: source_health_ready 1/2" in report
|
||||
assert "不代表已授權發送或自動修復" in report
|
||||
assert "AI Agent 受控接手" in report
|
||||
|
||||
def test_weekly_and_monthly_report_schedule_helpers_return_positive_seconds(self):
|
||||
assert _seconds_until_next_report() > 0
|
||||
assert _seconds_until_next_weekly_report() > 0
|
||||
assert _seconds_until_next_monthly_report() > 0
|
||||
|
||||
def test_sre_digest_preview_contains_assets_and_boundaries(self):
|
||||
"""SRE 戰情室 digest 應收斂缺口、資產與 no-send 邊界"""
|
||||
|
||||
@@ -50,7 +50,7 @@ def test_weekly_report_preview_exposes_source_health_no_send_preview():
|
||||
assert "不自動改排程" in preview
|
||||
|
||||
|
||||
def test_monthly_report_preview_exposes_source_health_no_send_preview():
|
||||
def test_monthly_report_preview_exposes_source_health_and_gateway_delivery():
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/stats/monthly/preview")
|
||||
|
||||
@@ -65,11 +65,11 @@ def test_monthly_report_preview_exposes_source_health_no_send_preview():
|
||||
assert "formatted_preview" in data
|
||||
|
||||
preview = data["formatted_preview"]
|
||||
assert "月報 no-send preview" in preview
|
||||
assert "AWOOOI 月報" in preview
|
||||
assert "報表資料源 / 沉澱" in preview
|
||||
assert f"來源: <code>{data['source_ok_count']}/{data['source_total_count']}</code>" in preview
|
||||
assert "實發: 0" in preview
|
||||
assert "不代表已授權發送或自動修復" in preview
|
||||
assert "Telegram Gateway" in preview
|
||||
assert "AI Agent 受控接手" in preview
|
||||
|
||||
|
||||
def test_sre_digest_preview_exposes_source_health_no_send_preview():
|
||||
|
||||
Reference in New Issue
Block a user