From 31cf2ddbe777043f300b9d1525d131cc904c064b Mon Sep 17 00:00:00 2001 From: OG T Date: Wed, 25 Mar 2026 21:52:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20Phase=2016=20R4.1=20=E6=8A=BD?= =?UTF-8?q?=E5=8F=96=20OperationParser=20=E6=A8=A1=E7=B5=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strangler Fig Pattern: 從 approvals.py 抽取操作解析邏輯 新增: - src/services/operation_parser.py - ParsedOperation dataclass - 支援中英文指令解析 (kubectl/自然語言) 瘦身 approvals.py: 移除 117 行內嵌邏輯 Co-Authored-By: Claude Opus 4.5 --- apps/api/src/api/v1/approvals.py | 125 +--------------- apps/api/src/services/operation_parser.py | 173 ++++++++++++++++++++++ 2 files changed, 181 insertions(+), 117 deletions(-) create mode 100644 apps/api/src/services/operation_parser.py diff --git a/apps/api/src/api/v1/approvals.py b/apps/api/src/api/v1/approvals.py index ea74ffae..09010560 100644 --- a/apps/api/src/api/v1/approvals.py +++ b/apps/api/src/api/v1/approvals.py @@ -21,7 +21,6 @@ Endpoints: """ import asyncio -import re from typing import TYPE_CHECKING from uuid import UUID @@ -55,7 +54,8 @@ from src.models.approval import ( SignResponse, ) from src.services.approval_db import get_approval_service, get_timeline_service -from src.services.executor import OperationType, get_executor +from src.services.executor import get_executor +from src.services.operation_parser import parse_operation_from_action from src.services.proposal_service import get_proposal_service router = APIRouter(prefix="/approvals", tags=["HITL Approvals"]) @@ -162,121 +162,9 @@ async def test_k8s_connection() -> dict: # ============================================================================= # Background Execution Helper +# Phase 16 R4: parse_operation_from_action 已抽取至 src/services/operation_parser.py # ============================================================================= -def parse_operation_from_action(action: str) -> tuple[OperationType | None, str | None, str]: - """ - 從 action 字串解析操作類型與目標資源 - - Examples: - "kubectl delete pod nginx-xxx -n production" - → (DELETE_POD, "nginx-xxx", "production") - - "Restart deployment api-backend" - → (RESTART_DEPLOYMENT, "api-backend", "default") - - "Scale deployment web-frontend to 5 replicas" - → (SCALE_DEPLOYMENT, "web-frontend", "default") - - "重新啟動 awoooi-worker 服務" - → (RESTART_DEPLOYMENT, "awoooi-worker", "default") - - Returns: - (operation_type, resource_name, namespace) - """ - action_lower = action.lower() - - # Pattern: kubectl rollout restart deployment/ - kubectl_restart_match = re.search(r'kubectl\s+rollout\s+restart\s+deployment/([a-z0-9][\w.-]*)', action_lower) - if kubectl_restart_match: - deploy_name = kubectl_restart_match.group(1) - ns_match = re.search(r'-n\s+(\S+)', action_lower) - namespace = ns_match.group(1) if ns_match else "default" - return OperationType.RESTART_DEPLOYMENT, deploy_name, namespace - - # Pattern: kubectl delete pod - delete_pod_match = re.search(r'delete\s+pod[:\s]+([a-z0-9][\w.-]*)', action_lower) - if delete_pod_match: - pod_name = delete_pod_match.group(1) - # Extract namespace if present - ns_match = re.search(r'-n\s+(\S+)', action_lower) - namespace = ns_match.group(1) if ns_match else "default" - return OperationType.DELETE_POD, pod_name, namespace - - # Pattern: 刪除 Pod (Chinese delete) - chinese_delete_match = re.search(r'刪除\s*[Pp]od\s+([a-z0-9][\w.-]*)', action) - if chinese_delete_match: - pod_name = chinese_delete_match.group(1) - return OperationType.DELETE_POD, pod_name, "default" - - # Pattern: restart deployment (English - with explicit "deployment") - restart_deploy_match = re.search(r'restart\s+deployment[:\s]+([a-z0-9][\w.-]*)', action_lower) - if restart_deploy_match: - deploy_name = restart_deploy_match.group(1) - ns_match = re.search(r'-n\s+(\S+)', action_lower) - namespace = ns_match.group(1) if ns_match else "default" - return OperationType.RESTART_DEPLOYMENT, deploy_name, namespace - - # Pattern: restart (English - without "deployment" keyword) - restart_simple_match = re.search(r'restart\s+([a-z0-9][\w.-]*)', action_lower) - if restart_simple_match: - deploy_name = restart_simple_match.group(1) - # Skip if captured word is "deployment" (handled above) - if deploy_name != "deployment": - ns_match = re.search(r'-n\s+(\S+)', action_lower) - namespace = ns_match.group(1) if ns_match else "default" - return OperationType.RESTART_DEPLOYMENT, deploy_name, namespace - - # Pattern: 重新啟動 deployment (Chinese with "deployment" keyword) - chinese_restart_deploy_match = re.search(r'重新啟動\s+deployment\s+([a-z0-9][\w.-]*)', action, re.IGNORECASE) - if chinese_restart_deploy_match: - deploy_name = chinese_restart_deploy_match.group(1) - return OperationType.RESTART_DEPLOYMENT, deploy_name, "default" - - # Pattern: 重新啟動 服務 (Chinese) - chinese_restart_match = re.search(r'重新啟動\s+([a-z0-9][\w.-]*)\s*服務', action) - if chinese_restart_match: - resource_name = chinese_restart_match.group(1) - # StatefulSet Pod 格式: name-N (如 postgres-primary-0) - if re.match(r'.*-\d+$', resource_name): - return OperationType.DELETE_POD, resource_name, "default" - return OperationType.RESTART_DEPLOYMENT, resource_name, "default" - - # Pattern: scale deployment - scale_match = re.search(r'scale\s+(?:deployment[:\s]+)?([a-z0-9][\w.-]*)', action_lower) - if scale_match: - deploy_name = scale_match.group(1) - ns_match = re.search(r'-n\s+(\S+)', action_lower) - namespace = ns_match.group(1) if ns_match else "default" - return OperationType.SCALE_DEPLOYMENT, deploy_name, namespace - - # Pattern: 擴容 (Chinese scale) - chinese_scale_match = re.search(r'擴容\s+([a-z0-9][\w.-]*)', action) - if chinese_scale_match: - deploy_name = chinese_scale_match.group(1) - return OperationType.SCALE_DEPLOYMENT, deploy_name, "default" - - # Pattern: 擴展 副本數 (Chinese scale variant) - chinese_scale2_match = re.search(r'擴展\s+([a-z0-9][\w.-]*)\s*副本', action) - if chinese_scale2_match: - deploy_name = chinese_scale2_match.group(1) - # 移除常見的後綴如 -deployment - deploy_name = re.sub(r'-deployment$', '', deploy_name) - return OperationType.SCALE_DEPLOYMENT, deploy_name, "default" - - # Pattern: 重新啟動 (Chinese restart without 服務) - chinese_restart2_match = re.search(r'重新啟動\s+([a-z0-9][\w.-]*)', action) - if chinese_restart2_match: - resource_name = chinese_restart2_match.group(1) - # StatefulSet Pod 格式: name-N (如 postgres-primary-0) - if re.match(r'.*-\d+$', resource_name): - return OperationType.DELETE_POD, resource_name, "default" - # 移除常見的後綴 - deploy_name = re.sub(r'-deployment$', '', resource_name) - return OperationType.RESTART_DEPLOYMENT, deploy_name, "default" - - return None, None, "default" - async def execute_approved_action(approval: ApprovalRequest) -> None: """ @@ -299,8 +187,11 @@ async def execute_approved_action(approval: ApprovalRequest) -> None: service = get_approval_service() timeline = get_timeline_service() - # Parse operation details - operation_type, resource_name, namespace = parse_operation_from_action(approval.action) + # Parse operation details (Phase 16 R4: 使用新的 ParsedOperation dataclass) + parsed = parse_operation_from_action(approval.action) + operation_type = parsed.operation_type + resource_name = parsed.resource_name + namespace = parsed.namespace if operation_type is None or resource_name is None: logger.warning( diff --git a/apps/api/src/services/operation_parser.py b/apps/api/src/services/operation_parser.py new file mode 100644 index 00000000..99cbeffd --- /dev/null +++ b/apps/api/src/services/operation_parser.py @@ -0,0 +1,173 @@ +""" +Operation Parser Service - Phase 16 R4 瘦身 Router 抽取 +======================================================= + +從 approvals.py 抽取操作解析邏輯,支援中英文指令格式。 + +功能: +- 從 action 字串解析 OperationType +- 提取資源名稱與 Namespace +- 支援 kubectl 指令與中文自然語言 + +版本: v1.0 +建立: 2026-03-25 (台北時區) +建立者: Claude Code (Phase 16 R4) +""" + +import re +from dataclasses import dataclass + +from src.services.executor import OperationType + + +@dataclass +class ParsedOperation: + """ + 解析後的操作資訊 + + Attributes: + operation_type: K8s 操作類型 (RESTART_DEPLOYMENT, DELETE_POD, etc.) + resource_name: 目標資源名稱 + namespace: K8s Namespace (預設 "default") + """ + + operation_type: OperationType | None + resource_name: str | None + namespace: str + + +def parse_operation_from_action(action: str) -> ParsedOperation: + """ + 從 action 字串解析操作類型與目標資源 + + 支援格式: + - kubectl 指令: "kubectl delete pod nginx-xxx -n production" + - 英文自然語言: "Restart deployment api-backend" + - 中文自然語言: "重新啟動 awoooi-worker 服務" + + Examples: + "kubectl delete pod nginx-xxx -n production" + → ParsedOperation(DELETE_POD, "nginx-xxx", "production") + + "Restart deployment api-backend" + → ParsedOperation(RESTART_DEPLOYMENT, "api-backend", "default") + + "Scale deployment web-frontend to 5 replicas" + → ParsedOperation(SCALE_DEPLOYMENT, "web-frontend", "default") + + "重新啟動 awoooi-worker 服務" + → ParsedOperation(RESTART_DEPLOYMENT, "awoooi-worker", "default") + + Args: + action: 操作指令字串 + + Returns: + ParsedOperation: 解析後的操作資訊 + """ + action_lower = action.lower() + + # Pattern: kubectl rollout restart deployment/ + kubectl_restart_match = re.search( + r"kubectl\s+rollout\s+restart\s+deployment/([a-z0-9][\w.-]*)", action_lower + ) + if kubectl_restart_match: + deploy_name = kubectl_restart_match.group(1) + ns_match = re.search(r"-n\s+(\S+)", action_lower) + namespace = ns_match.group(1) if ns_match else "default" + return ParsedOperation(OperationType.RESTART_DEPLOYMENT, deploy_name, namespace) + + # Pattern: kubectl delete pod + delete_pod_match = re.search( + r"delete\s+pod[:\s]+([a-z0-9][\w.-]*)", action_lower + ) + if delete_pod_match: + pod_name = delete_pod_match.group(1) + ns_match = re.search(r"-n\s+(\S+)", action_lower) + namespace = ns_match.group(1) if ns_match else "default" + return ParsedOperation(OperationType.DELETE_POD, pod_name, namespace) + + # Pattern: 刪除 Pod (Chinese delete) + chinese_delete_match = re.search(r"刪除\s*[Pp]od\s+([a-z0-9][\w.-]*)", action) + if chinese_delete_match: + pod_name = chinese_delete_match.group(1) + return ParsedOperation(OperationType.DELETE_POD, pod_name, "default") + + # Pattern: restart deployment (English - with explicit "deployment") + restart_deploy_match = re.search( + r"restart\s+deployment[:\s]+([a-z0-9][\w.-]*)", action_lower + ) + if restart_deploy_match: + deploy_name = restart_deploy_match.group(1) + ns_match = re.search(r"-n\s+(\S+)", action_lower) + namespace = ns_match.group(1) if ns_match else "default" + return ParsedOperation(OperationType.RESTART_DEPLOYMENT, deploy_name, namespace) + + # Pattern: restart (English - without "deployment" keyword) + restart_simple_match = re.search(r"restart\s+([a-z0-9][\w.-]*)", action_lower) + if restart_simple_match: + deploy_name = restart_simple_match.group(1) + # Skip if captured word is "deployment" (handled above) + if deploy_name != "deployment": + ns_match = re.search(r"-n\s+(\S+)", action_lower) + namespace = ns_match.group(1) if ns_match else "default" + return ParsedOperation( + OperationType.RESTART_DEPLOYMENT, deploy_name, namespace + ) + + # Pattern: 重新啟動 deployment (Chinese with "deployment" keyword) + chinese_restart_deploy_match = re.search( + r"重新啟動\s+deployment\s+([a-z0-9][\w.-]*)", action, re.IGNORECASE + ) + if chinese_restart_deploy_match: + deploy_name = chinese_restart_deploy_match.group(1) + return ParsedOperation(OperationType.RESTART_DEPLOYMENT, deploy_name, "default") + + # Pattern: 重新啟動 服務 (Chinese) + chinese_restart_match = re.search(r"重新啟動\s+([a-z0-9][\w.-]*)\s*服務", action) + if chinese_restart_match: + resource_name = chinese_restart_match.group(1) + # StatefulSet Pod 格式: name-N (如 postgres-primary-0) + if re.match(r".*-\d+$", resource_name): + return ParsedOperation(OperationType.DELETE_POD, resource_name, "default") + return ParsedOperation( + OperationType.RESTART_DEPLOYMENT, resource_name, "default" + ) + + # Pattern: scale deployment + scale_match = re.search( + r"scale\s+(?:deployment[:\s]+)?([a-z0-9][\w.-]*)", action_lower + ) + if scale_match: + deploy_name = scale_match.group(1) + ns_match = re.search(r"-n\s+(\S+)", action_lower) + namespace = ns_match.group(1) if ns_match else "default" + return ParsedOperation(OperationType.SCALE_DEPLOYMENT, deploy_name, namespace) + + # Pattern: 擴容 (Chinese scale) + chinese_scale_match = re.search(r"擴容\s+([a-z0-9][\w.-]*)", action) + if chinese_scale_match: + deploy_name = chinese_scale_match.group(1) + return ParsedOperation(OperationType.SCALE_DEPLOYMENT, deploy_name, "default") + + # Pattern: 擴展 副本數 (Chinese scale variant) + chinese_scale2_match = re.search(r"擴展\s+([a-z0-9][\w.-]*)\s*副本", action) + if chinese_scale2_match: + deploy_name = chinese_scale2_match.group(1) + # 移除常見的後綴如 -deployment + deploy_name = re.sub(r"-deployment$", "", deploy_name) + return ParsedOperation(OperationType.SCALE_DEPLOYMENT, deploy_name, "default") + + # Pattern: 重新啟動 (Chinese restart without 服務) + chinese_restart2_match = re.search(r"重新啟動\s+([a-z0-9][\w.-]*)", action) + if chinese_restart2_match: + resource_name = chinese_restart2_match.group(1) + # StatefulSet Pod 格式: name-N (如 postgres-primary-0) + if re.match(r".*-\d+$", resource_name): + return ParsedOperation(OperationType.DELETE_POD, resource_name, "default") + # 移除常見的後綴 + deploy_name = re.sub(r"-deployment$", "", resource_name) + return ParsedOperation( + OperationType.RESTART_DEPLOYMENT, deploy_name, "default" + ) + + return ParsedOperation(None, None, "default")