process-gpt-agent-sdk 0.2.8__py3-none-any.whl → 0.2.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of process-gpt-agent-sdk might be problematic. Click here for more details.
- process_gpt_agent_sdk-0.2.10.dist-info/METADATA +1026 -0
- process_gpt_agent_sdk-0.2.10.dist-info/RECORD +19 -0
- processgpt_agent_sdk/__init__.py +11 -7
- processgpt_agent_sdk/core/database.py +464 -463
- processgpt_agent_sdk/server.py +313 -292
- processgpt_agent_sdk/simulator.py +231 -0
- processgpt_agent_sdk/tools/human_query_tool.py +211 -211
- processgpt_agent_sdk/tools/knowledge_tools.py +206 -206
- processgpt_agent_sdk/tools/safe_tool_loader.py +209 -209
- processgpt_agent_sdk/utils/context_manager.py +45 -45
- processgpt_agent_sdk/utils/crewai_event_listener.py +205 -205
- processgpt_agent_sdk/utils/event_handler.py +72 -72
- processgpt_agent_sdk/utils/logger.py +97 -39
- processgpt_agent_sdk/utils/summarizer.py +146 -146
- process_gpt_agent_sdk-0.2.8.dist-info/METADATA +0 -378
- process_gpt_agent_sdk-0.2.8.dist-info/RECORD +0 -18
- {process_gpt_agent_sdk-0.2.8.dist-info → process_gpt_agent_sdk-0.2.10.dist-info}/WHEEL +0 -0
- {process_gpt_agent_sdk-0.2.8.dist-info → process_gpt_agent_sdk-0.2.10.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
|
9
|
+
from a2a.server.events import EventQueue, Event
|
|
10
|
+
|
|
11
|
+
from .utils.logger import handle_application_error, write_log_message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# =============================================================================
|
|
15
|
+
# 시뮬레이터: ProcessGPTAgentSimulator
|
|
16
|
+
# 설명: 데이터베이스 연결 없이 작업을 시뮬레이션하는 핵심 시뮬레이터
|
|
17
|
+
# =============================================================================
|
|
18
|
+
class ProcessGPTAgentSimulator:
|
|
19
|
+
"""ProcessGPT 시뮬레이터
|
|
20
|
+
|
|
21
|
+
- 데이터베이스 연결 없이 에이전트 실행을 시뮬레이션
|
|
22
|
+
- CLI로 프롬프트를 받아 실행하고 진행상태를 stdout으로 출력
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, executor: AgentExecutor, agent_orch: str = ""):
|
|
26
|
+
"""시뮬레이터 실행기/오케스트레이션 값을 초기화한다."""
|
|
27
|
+
self.is_running = False
|
|
28
|
+
self._executor: AgentExecutor = executor
|
|
29
|
+
self.agent_orch: str = agent_orch or "simulator"
|
|
30
|
+
self.task_id = str(uuid.uuid4())
|
|
31
|
+
self.proc_inst_id = str(uuid.uuid4())
|
|
32
|
+
|
|
33
|
+
async def run_simulation(self, prompt: str, **kwargs) -> None:
|
|
34
|
+
"""단일 작업을 시뮬레이션한다."""
|
|
35
|
+
self.is_running = True
|
|
36
|
+
write_log_message("ProcessGPT 시뮬레이터 시작")
|
|
37
|
+
write_log_message(f"작업 시뮬레이션 시작: {self.task_id}")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
# 시뮬레이션용 작업 레코드 생성
|
|
41
|
+
task_record = self._create_mock_task_record(prompt, **kwargs)
|
|
42
|
+
|
|
43
|
+
# 서비스 데이터 준비 (모든 것을 모킹)
|
|
44
|
+
prepared_data = self._prepare_mock_service_data(task_record)
|
|
45
|
+
write_log_message(f"시뮬레이션 데이터 준비 완료 [task_id={self.task_id} agent={prepared_data.get('agent_orch','')}]")
|
|
46
|
+
|
|
47
|
+
# 실행
|
|
48
|
+
await self._execute_simulation(task_record, prepared_data)
|
|
49
|
+
write_log_message(f"시뮬레이션 실행 완료 [task_id={self.task_id}]")
|
|
50
|
+
|
|
51
|
+
except Exception as e:
|
|
52
|
+
handle_application_error("시뮬레이션 처리 오류", e, raise_error=False)
|
|
53
|
+
finally:
|
|
54
|
+
self.is_running = False
|
|
55
|
+
write_log_message("ProcessGPT 시뮬레이터 종료")
|
|
56
|
+
|
|
57
|
+
def _create_mock_task_record(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
|
58
|
+
"""시뮬레이션용 작업 레코드를 생성한다."""
|
|
59
|
+
return {
|
|
60
|
+
"id": self.task_id,
|
|
61
|
+
"proc_inst_id": self.proc_inst_id,
|
|
62
|
+
"agent_orch": self.agent_orch,
|
|
63
|
+
"description": prompt,
|
|
64
|
+
"activity_name": kwargs.get("activity_name", "simulation_task"),
|
|
65
|
+
"user_id": kwargs.get("user_id", str(uuid.uuid4())),
|
|
66
|
+
"tenant_id": kwargs.get("tenant_id", str(uuid.uuid4())),
|
|
67
|
+
"tool": kwargs.get("tool", "default"),
|
|
68
|
+
"feedback": kwargs.get("feedback", ""),
|
|
69
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def _prepare_mock_service_data(self, task_record: Dict[str, Any]) -> Dict[str, Any]:
|
|
73
|
+
"""시뮬레이션용 서비스 데이터를 준비한다."""
|
|
74
|
+
# 모킹된 에이전트 데이터
|
|
75
|
+
mock_agents = [
|
|
76
|
+
{
|
|
77
|
+
"id": str(uuid.uuid4()),
|
|
78
|
+
"name": "simulation_agent",
|
|
79
|
+
"role": "AI Assistant",
|
|
80
|
+
"goal": "Simulate task execution",
|
|
81
|
+
"persona": "A helpful AI assistant for simulation",
|
|
82
|
+
"tools": "mem0",
|
|
83
|
+
"profile": "Simulation Agent",
|
|
84
|
+
"model": "gpt-4",
|
|
85
|
+
"tenant_id": task_record.get("tenant_id"),
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
# 모킹된 폼 데이터
|
|
90
|
+
mock_form_types = [
|
|
91
|
+
{"key": "default", "type": "text", "text": "Default form field"}
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
# 모킹된 MCP 설정
|
|
95
|
+
mock_mcp_config = {
|
|
96
|
+
"enabled": True,
|
|
97
|
+
"tools": ["mem0", "search"],
|
|
98
|
+
"config": {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
prepared: Dict[str, Any] = {
|
|
102
|
+
"task_id": str(task_record.get("id")),
|
|
103
|
+
"proc_inst_id": task_record.get("proc_inst_id"),
|
|
104
|
+
"agent_list": mock_agents,
|
|
105
|
+
"mcp_config": mock_mcp_config,
|
|
106
|
+
"form_id": "default",
|
|
107
|
+
"form_types": mock_form_types,
|
|
108
|
+
"form_html": "<form>Default simulation form</form>",
|
|
109
|
+
"activity_name": str(task_record.get("activity_name", "")),
|
|
110
|
+
"message": str(task_record.get("description", "")),
|
|
111
|
+
"agent_orch": str(task_record.get("agent_orch", "")),
|
|
112
|
+
"done_outputs": [],
|
|
113
|
+
"output_summary": "",
|
|
114
|
+
"feedback_summary": "",
|
|
115
|
+
"all_users": "simulation@example.com",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return prepared
|
|
119
|
+
|
|
120
|
+
async def _execute_simulation(self, task_record: Dict[str, Any], prepared_data: Dict[str, Any]) -> None:
|
|
121
|
+
"""시뮬레이션 실행을 수행한다."""
|
|
122
|
+
executor = self._executor
|
|
123
|
+
|
|
124
|
+
context = SimulatorRequestContext(prepared_data)
|
|
125
|
+
event_queue = SimulatorEventQueue(task_record)
|
|
126
|
+
|
|
127
|
+
write_log_message(f"시뮬레이션 실행 시작 [task_id={task_record.get('id')} agent={prepared_data.get('agent_orch','')}]")
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
await executor.execute(context, event_queue)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
handle_application_error("시뮬레이터 실행 오류", e, raise_error=False)
|
|
133
|
+
finally:
|
|
134
|
+
try:
|
|
135
|
+
await event_queue.close()
|
|
136
|
+
except Exception as e:
|
|
137
|
+
handle_application_error("시뮬레이터 이벤트 큐 종료 실패", e, raise_error=False)
|
|
138
|
+
write_log_message(f"시뮬레이션 실행 종료 [task_id={task_record.get('id')}]")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# =============================================================================
|
|
142
|
+
# 시뮬레이터 요청 컨텍스트: SimulatorRequestContext
|
|
143
|
+
# 설명: 시뮬레이터용 요청 데이터/상태를 캡슐화
|
|
144
|
+
# =============================================================================
|
|
145
|
+
class SimulatorRequestContext(RequestContext):
|
|
146
|
+
def __init__(self, prepared_data: Dict[str, Any]):
|
|
147
|
+
"""시뮬레이션 실행에 필요한 데이터 묶음을 보관한다."""
|
|
148
|
+
self._prepared_data = prepared_data
|
|
149
|
+
self._message = prepared_data.get("message", "")
|
|
150
|
+
self._current_task = None
|
|
151
|
+
|
|
152
|
+
def get_user_input(self) -> str:
|
|
153
|
+
"""사용자 입력 메시지를 반환한다."""
|
|
154
|
+
return self._message
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def message(self) -> str:
|
|
158
|
+
"""현재 메시지(사용자 입력)를 반환한다."""
|
|
159
|
+
return self._message
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def current_task(self):
|
|
163
|
+
"""현재 실행 중 태스크(있다면)를 반환한다."""
|
|
164
|
+
return getattr(self, "_current_task", None)
|
|
165
|
+
|
|
166
|
+
def get_context_data(self) -> Dict[str, Any]:
|
|
167
|
+
"""실행 컨텍스트 전체 데이터를 dict로 반환한다."""
|
|
168
|
+
return self._prepared_data
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# =============================================================================
|
|
172
|
+
# 시뮬레이터 이벤트 큐: SimulatorEventQueue
|
|
173
|
+
# 설명: 시뮬레이터용 이벤트를 stdout으로 출력
|
|
174
|
+
# =============================================================================
|
|
175
|
+
class SimulatorEventQueue(EventQueue):
|
|
176
|
+
def __init__(self, task_record: Dict[str, Any]):
|
|
177
|
+
"""현재 처리 중인 작업 레코드를 보관한다."""
|
|
178
|
+
self.todo = task_record
|
|
179
|
+
super().__init__()
|
|
180
|
+
|
|
181
|
+
def enqueue_event(self, event: Event):
|
|
182
|
+
"""이벤트를 큐에 넣고, stdout으로 진행상태를 출력한다."""
|
|
183
|
+
try:
|
|
184
|
+
super().enqueue_event(event)
|
|
185
|
+
|
|
186
|
+
# 이벤트를 stdout으로 출력
|
|
187
|
+
event_data = self._convert_event_to_dict(event)
|
|
188
|
+
self._output_event_to_stdout(event_data)
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
handle_application_error("시뮬레이터 이벤트 처리 실패", e, raise_error=False)
|
|
192
|
+
|
|
193
|
+
def _convert_event_to_dict(self, event: Event) -> Dict[str, Any]:
|
|
194
|
+
"""이벤트를 딕셔너리로 변환한다."""
|
|
195
|
+
try:
|
|
196
|
+
if hasattr(event, "__dict__"):
|
|
197
|
+
return {k: v for k, v in event.__dict__.items() if not k.startswith("_")}
|
|
198
|
+
return {"event": str(event)}
|
|
199
|
+
except Exception as e:
|
|
200
|
+
handle_application_error("시뮬레이터 이벤트 변환 실패", e, raise_error=False)
|
|
201
|
+
return {"event": str(event)}
|
|
202
|
+
|
|
203
|
+
def _output_event_to_stdout(self, event_data: Dict[str, Any]) -> None:
|
|
204
|
+
"""이벤트 데이터를 stdout으로 출력한다."""
|
|
205
|
+
try:
|
|
206
|
+
# 타임스탬프 추가
|
|
207
|
+
output_data = {
|
|
208
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
209
|
+
"task_id": self.todo.get("id"),
|
|
210
|
+
"proc_inst_id": self.todo.get("proc_inst_id"),
|
|
211
|
+
"event": event_data
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# JSON 형태로 stdout에 출력
|
|
215
|
+
json_output = json.dumps(output_data, ensure_ascii=False, indent=2)
|
|
216
|
+
print(f"[EVENT] {json_output}", file=sys.stdout, flush=True)
|
|
217
|
+
|
|
218
|
+
except Exception as e:
|
|
219
|
+
handle_application_error("stdout 출력 실패", e, raise_error=False)
|
|
220
|
+
|
|
221
|
+
def task_done(self) -> None:
|
|
222
|
+
"""태스크 완료 로그를 남긴다."""
|
|
223
|
+
try:
|
|
224
|
+
write_log_message(f"시뮬레이션 태스크 완료: {self.todo['id']}")
|
|
225
|
+
self._output_event_to_stdout({"type": "task_completed", "message": "Task simulation completed"})
|
|
226
|
+
except Exception as e:
|
|
227
|
+
handle_application_error("시뮬레이터 태스크 완료 처리 실패", e, raise_error=False)
|
|
228
|
+
|
|
229
|
+
async def close(self) -> None:
|
|
230
|
+
"""큐 종료 훅."""
|
|
231
|
+
self._output_event_to_stdout({"type": "queue_closed", "message": "Event queue closed"})
|
|
@@ -1,211 +1,211 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
# =============================================================================
|
|
4
|
-
# Imports
|
|
5
|
-
# =============================================================================
|
|
6
|
-
|
|
7
|
-
import time
|
|
8
|
-
import uuid
|
|
9
|
-
from typing import Optional, List, Literal, Type, Dict, Any
|
|
10
|
-
from datetime import datetime, timezone
|
|
11
|
-
|
|
12
|
-
from pydantic import BaseModel, Field
|
|
13
|
-
from crewai.tools import BaseTool
|
|
14
|
-
|
|
15
|
-
from ..utils.context_manager import todo_id_var, proc_id_var, all_users_var
|
|
16
|
-
from ..utils.logger import write_log_message, handle_application_error
|
|
17
|
-
from ..core.database import fetch_human_response_sync, save_notification, initialize_db, get_db_client
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
# =============================================================================
|
|
21
|
-
# 입력 스키마
|
|
22
|
-
# =============================================================================
|
|
23
|
-
class HumanQuerySchema(BaseModel):
|
|
24
|
-
"""사용자 확인/추가정보 요청 입력 스키마."""
|
|
25
|
-
|
|
26
|
-
role: str = Field(..., description="누구에게(역할 또는 대상)")
|
|
27
|
-
text: str = Field(..., description="질의 내용")
|
|
28
|
-
type: Literal["text", "select", "confirm"] = Field(
|
|
29
|
-
default="text", description="질의 유형: 자유 텍스트, 선택형, 확인 여부"
|
|
30
|
-
)
|
|
31
|
-
options: Optional[List[str]] = Field(
|
|
32
|
-
default=None, description="type이 select일 때 선택지 목록"
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# =============================================================================
|
|
37
|
-
# HumanQueryTool
|
|
38
|
-
# 설명: 보안/모호성 관련 질문을 사용자에게 전송하고 응답을 동기 대기
|
|
39
|
-
# =============================================================================
|
|
40
|
-
class HumanQueryTool(BaseTool):
|
|
41
|
-
"""사람에게 질문을 보내고 응답을 동기적으로 대기한다."""
|
|
42
|
-
|
|
43
|
-
name: str = "human_asked"
|
|
44
|
-
description: str = (
|
|
45
|
-
"👀 질문은 반드시 '매우 구체적이고 세부적'으로 작성해야 합니다.\n"
|
|
46
|
-
"- 목적, 대상, 범위/경계, 입력/출력 형식, 성공/실패 기준, 제약조건(보안/권한/시간/용량),\n"
|
|
47
|
-
" 필요한 식별자/예시/반례까지 모두 명시하세요. 추측으로 진행하지 말고 누락 정보를 반드시 질문하세요.\n\n"
|
|
48
|
-
"[1] 언제 사용해야 하나\n"
|
|
49
|
-
"1. 보안에 민감한 정보(개인정보/인증정보/비밀키 등)를 다루거나 외부로 전송할 때\n"
|
|
50
|
-
"2. 데이터베이스에 '저장/수정/삭제' 작업을 수행할 때 (읽기 전용 조회는 제외)\n"
|
|
51
|
-
"3. 요구사항 및 작업지시사항이 모호·불완전·추정에 의존하거나, 전제조건/매개변수가 불명확할 때\n"
|
|
52
|
-
"4. 외부 시스템 연동, 파일 생성/이동/삭제 등 시스템 상태를 바꾸는 작업일 때\n"
|
|
53
|
-
"⛔ 위 조건에 해당하면 이 도구 없이 진행 금지\n\n"
|
|
54
|
-
"[2] 응답 타입과 작성 방식 (항상 JSON으로 질의 전송)\n"
|
|
55
|
-
"- 공통 형식: { role: <누구에게>, text: <질의>, type: <text|select|confirm>, options?: [선택지...] }\n"
|
|
56
|
-
"- 질의 작성 가이드(반드시 포함): 5W1H, 목적/맥락, 선택 이유 또는 승인 근거, 기본값/제약,\n"
|
|
57
|
-
" 입력/출력 형식과 예시, 반례/실패 시 처리, 보안/권한/감사 로그 요구사항, 마감/우선순위\n\n"
|
|
58
|
-
"// 1) type='text' — 정보 수집(모호/불완전할 때 필수)\n"
|
|
59
|
-
"{\n"
|
|
60
|
-
' "role": "user",\n'
|
|
61
|
-
' "text": "어떤 DB 테이블/스키마/키로 저장할까요? 입력값 예시/형식, 실패 시 처리, 보존 기간까지 구체히 알려주세요.",\n'
|
|
62
|
-
' "type": "text"\n'
|
|
63
|
-
"}\n\n"
|
|
64
|
-
"// 2) type='select' — 여러 옵션 중 선택(옵션은 상호배타적, 명확/완전하게 제시)\n"
|
|
65
|
-
"{\n"
|
|
66
|
-
' "role": "system",\n'
|
|
67
|
-
' "text": "배포 환경을 선택하세요. 선택 근거(위험/롤백/감사 로그)를 함께 알려주세요.",\n'
|
|
68
|
-
' "type": "select",\n'
|
|
69
|
-
' "options": ["dev", "staging", "prod"]\n'
|
|
70
|
-
"}\n\n"
|
|
71
|
-
"// 3) type='confirm' — 보안/DB 변경 등 민감 작업 승인(필수)\n"
|
|
72
|
-
"{\n"
|
|
73
|
-
' "role": "user",\n'
|
|
74
|
-
' "text": "DB에서 주문 상태를 shipped로 업데이트합니다. 대상: order_id=..., 영향 범위: ...건, 롤백: ..., 진행 승인하시겠습니까?",\n'
|
|
75
|
-
' "type": "confirm"\n'
|
|
76
|
-
"}\n\n"
|
|
77
|
-
"타입 선택 규칙\n"
|
|
78
|
-
"- text: 모호/누락 정보가 있을 때 먼저 세부사항을 수집 (여러 번 질문 가능)\n"
|
|
79
|
-
"- select: 옵션이 둘 이상이면 반드시 options로 제시하고, 선택 기준을 text에 명시\n"
|
|
80
|
-
"- confirm: DB 저장/수정/삭제, 외부 전송, 파일 조작 등은 승인 후에만 진행\n\n"
|
|
81
|
-
"[3] 주의사항\n"
|
|
82
|
-
"- 이 도구 없이 민감/변경 작업을 임의로 진행 금지.\n"
|
|
83
|
-
"- select 타입은 반드시 'options'를 포함.\n"
|
|
84
|
-
"- confirm 응답에 따라: ✅ 승인 → 즉시 수행 / ❌ 거절 → 즉시 중단(건너뛰기).\n"
|
|
85
|
-
"- 애매하면 추가 질문을 반복하고, 충분히 구체화되기 전에는 실행하지 말 것.\n"
|
|
86
|
-
"- 민감 정보는 최소한만 노출하고 필요 시 마스킹/요약.\n"
|
|
87
|
-
"- 예시를 그대로 사용하지 말고 컨텍스트에 맞게 반드시 자연스러운 질의를 재작성하세요.\n"
|
|
88
|
-
"- 타임아웃/미응답 시 '사용자 미응답 거절'을 반환하며, 후속 변경 작업을 중단하는 것이 안전.\n"
|
|
89
|
-
"- 한 번에 하나의 주제만 질문(여러 주제면 질문을 분리). 한국어 존댓말 사용, 간결하되 상세하게."
|
|
90
|
-
)
|
|
91
|
-
args_schema: Type[HumanQuerySchema] = HumanQuerySchema
|
|
92
|
-
|
|
93
|
-
# 선택적 컨텍스트(없어도 동작). ContextVar가 우선 사용됨
|
|
94
|
-
_tenant_id: Optional[str] = None
|
|
95
|
-
_user_id: Optional[str] = None
|
|
96
|
-
_todo_id: Optional[int] = None
|
|
97
|
-
_proc_inst_id: Optional[str] = None
|
|
98
|
-
|
|
99
|
-
def __init__(
|
|
100
|
-
self,
|
|
101
|
-
tenant_id: Optional[str] = None,
|
|
102
|
-
user_id: Optional[str] = None,
|
|
103
|
-
todo_id: Optional[int] = None,
|
|
104
|
-
proc_inst_id: Optional[str] = None,
|
|
105
|
-
agent_name: Optional[str] = None,
|
|
106
|
-
**kwargs,
|
|
107
|
-
):
|
|
108
|
-
"""도구 실행 컨텍스트(테넌트/유저/프로세스)를 옵션으로 설정."""
|
|
109
|
-
super().__init__(**kwargs)
|
|
110
|
-
self._tenant_id = tenant_id
|
|
111
|
-
self._user_id = user_id
|
|
112
|
-
self._todo_id = todo_id
|
|
113
|
-
self._proc_inst_id = proc_inst_id
|
|
114
|
-
self._agent_name = agent_name
|
|
115
|
-
|
|
116
|
-
# =============================================================================
|
|
117
|
-
# 동기 실행
|
|
118
|
-
# 설명: 질문 이벤트를 저장하고 DB 폴링으로 응답을 기다린다
|
|
119
|
-
# =============================================================================
|
|
120
|
-
def _run(
|
|
121
|
-
self, role: str, text: str, type: str = "text", options: Optional[List[str]] = None
|
|
122
|
-
) -> str:
|
|
123
|
-
"""질문 이벤트를 기록하고 최종 응답 문자열을 반환."""
|
|
124
|
-
try:
|
|
125
|
-
agent_name = getattr(self, "_agent_name", None)
|
|
126
|
-
write_log_message(f"HumanQueryTool 실행: role={role}, agent_name={agent_name}, type={type}, options={options}")
|
|
127
|
-
query_id = f"human_asked_{uuid.uuid4()}"
|
|
128
|
-
|
|
129
|
-
payload: Dict[str, Any] = {
|
|
130
|
-
"role": role,
|
|
131
|
-
"text": text,
|
|
132
|
-
"type": type,
|
|
133
|
-
"options": options or [],
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
todo_id = todo_id_var.get() or self._todo_id
|
|
137
|
-
proc_inst_id = proc_id_var.get() or self._proc_inst_id
|
|
138
|
-
|
|
139
|
-
payload_with_status = {
|
|
140
|
-
**payload,
|
|
141
|
-
"status": "ASKED",
|
|
142
|
-
"agent_profile": "/images/chat-icon.png"
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
initialize_db()
|
|
146
|
-
supabase = get_db_client()
|
|
147
|
-
record = {
|
|
148
|
-
"id": str(uuid.uuid4()),
|
|
149
|
-
"job_id": query_id,
|
|
150
|
-
"todo_id": str(todo_id) if todo_id is not None else None,
|
|
151
|
-
"proc_inst_id": str(proc_inst_id) if proc_inst_id is not None else None,
|
|
152
|
-
"event_type": "human_asked",
|
|
153
|
-
"crew_type": "action",
|
|
154
|
-
"data": payload_with_status,
|
|
155
|
-
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
156
|
-
}
|
|
157
|
-
supabase.table("events").insert(record).execute()
|
|
158
|
-
|
|
159
|
-
try:
|
|
160
|
-
tenant_id = self._tenant_id
|
|
161
|
-
target_emails_csv = all_users_var.get() or ""
|
|
162
|
-
if target_emails_csv and target_emails_csv.strip():
|
|
163
|
-
write_log_message(f"알림 저장 시도: target_emails_csv={target_emails_csv}, tenant_id={tenant_id}")
|
|
164
|
-
save_notification(
|
|
165
|
-
title=text,
|
|
166
|
-
notif_type="workitem_bpm",
|
|
167
|
-
description=agent_name,
|
|
168
|
-
user_ids_csv=target_emails_csv,
|
|
169
|
-
tenant_id=tenant_id,
|
|
170
|
-
url=f"/todolist/{todo_id}",
|
|
171
|
-
from_user_id=agent_name,
|
|
172
|
-
)
|
|
173
|
-
else:
|
|
174
|
-
write_log_message("알림 저장 생략: 대상 이메일 없음")
|
|
175
|
-
except Exception as e:
|
|
176
|
-
handle_application_error("알림저장HumanTool", e, raise_error=False)
|
|
177
|
-
|
|
178
|
-
answer = self._wait_for_response(query_id)
|
|
179
|
-
return answer
|
|
180
|
-
except Exception as e:
|
|
181
|
-
handle_application_error("HumanQueryTool", e, raise_error=False)
|
|
182
|
-
return "사용자 미응답 거절"
|
|
183
|
-
|
|
184
|
-
# =============================================================================
|
|
185
|
-
# 응답 대기
|
|
186
|
-
# 설명: events 테이블에서 human_response를 폴링하여 응답을 가져온다
|
|
187
|
-
# =============================================================================
|
|
188
|
-
def _wait_for_response(
|
|
189
|
-
self, job_id: str, timeout_sec: int = 180, poll_interval_sec: int = 5
|
|
190
|
-
) -> str:
|
|
191
|
-
"""DB 폴링으로 사람의 응답을 기다려 문자열로 반환."""
|
|
192
|
-
deadline = time.time() + timeout_sec
|
|
193
|
-
|
|
194
|
-
while time.time() < deadline:
|
|
195
|
-
try:
|
|
196
|
-
write_log_message(f"HumanQueryTool 응답 폴링: {job_id}")
|
|
197
|
-
event = fetch_human_response_sync(job_id=job_id)
|
|
198
|
-
if event:
|
|
199
|
-
write_log_message(f"HumanQueryTool 응답 수신: {event}")
|
|
200
|
-
data = event.get("data") or {}
|
|
201
|
-
answer = (data or {}).get("answer")
|
|
202
|
-
if isinstance(answer, str):
|
|
203
|
-
write_log_message("사람 응답 수신 완료")
|
|
204
|
-
return answer
|
|
205
|
-
return str(data)
|
|
206
|
-
|
|
207
|
-
except Exception as e:
|
|
208
|
-
write_log_message(f"인간 응답 대기 중... (오류: {str(e)[:100]})")
|
|
209
|
-
time.sleep(poll_interval_sec)
|
|
210
|
-
return "사용자 미응답 거절"
|
|
211
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# =============================================================================
|
|
4
|
+
# Imports
|
|
5
|
+
# =============================================================================
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Optional, List, Literal, Type, Dict, Any
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
from crewai.tools import BaseTool
|
|
14
|
+
|
|
15
|
+
from ..utils.context_manager import todo_id_var, proc_id_var, all_users_var
|
|
16
|
+
from ..utils.logger import write_log_message, handle_application_error
|
|
17
|
+
from ..core.database import fetch_human_response_sync, save_notification, initialize_db, get_db_client
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# =============================================================================
|
|
21
|
+
# 입력 스키마
|
|
22
|
+
# =============================================================================
|
|
23
|
+
class HumanQuerySchema(BaseModel):
|
|
24
|
+
"""사용자 확인/추가정보 요청 입력 스키마."""
|
|
25
|
+
|
|
26
|
+
role: str = Field(..., description="누구에게(역할 또는 대상)")
|
|
27
|
+
text: str = Field(..., description="질의 내용")
|
|
28
|
+
type: Literal["text", "select", "confirm"] = Field(
|
|
29
|
+
default="text", description="질의 유형: 자유 텍스트, 선택형, 확인 여부"
|
|
30
|
+
)
|
|
31
|
+
options: Optional[List[str]] = Field(
|
|
32
|
+
default=None, description="type이 select일 때 선택지 목록"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# =============================================================================
|
|
37
|
+
# HumanQueryTool
|
|
38
|
+
# 설명: 보안/모호성 관련 질문을 사용자에게 전송하고 응답을 동기 대기
|
|
39
|
+
# =============================================================================
|
|
40
|
+
class HumanQueryTool(BaseTool):
|
|
41
|
+
"""사람에게 질문을 보내고 응답을 동기적으로 대기한다."""
|
|
42
|
+
|
|
43
|
+
name: str = "human_asked"
|
|
44
|
+
description: str = (
|
|
45
|
+
"👀 질문은 반드시 '매우 구체적이고 세부적'으로 작성해야 합니다.\n"
|
|
46
|
+
"- 목적, 대상, 범위/경계, 입력/출력 형식, 성공/실패 기준, 제약조건(보안/권한/시간/용량),\n"
|
|
47
|
+
" 필요한 식별자/예시/반례까지 모두 명시하세요. 추측으로 진행하지 말고 누락 정보를 반드시 질문하세요.\n\n"
|
|
48
|
+
"[1] 언제 사용해야 하나\n"
|
|
49
|
+
"1. 보안에 민감한 정보(개인정보/인증정보/비밀키 등)를 다루거나 외부로 전송할 때\n"
|
|
50
|
+
"2. 데이터베이스에 '저장/수정/삭제' 작업을 수행할 때 (읽기 전용 조회는 제외)\n"
|
|
51
|
+
"3. 요구사항 및 작업지시사항이 모호·불완전·추정에 의존하거나, 전제조건/매개변수가 불명확할 때\n"
|
|
52
|
+
"4. 외부 시스템 연동, 파일 생성/이동/삭제 등 시스템 상태를 바꾸는 작업일 때\n"
|
|
53
|
+
"⛔ 위 조건에 해당하면 이 도구 없이 진행 금지\n\n"
|
|
54
|
+
"[2] 응답 타입과 작성 방식 (항상 JSON으로 질의 전송)\n"
|
|
55
|
+
"- 공통 형식: { role: <누구에게>, text: <질의>, type: <text|select|confirm>, options?: [선택지...] }\n"
|
|
56
|
+
"- 질의 작성 가이드(반드시 포함): 5W1H, 목적/맥락, 선택 이유 또는 승인 근거, 기본값/제약,\n"
|
|
57
|
+
" 입력/출력 형식과 예시, 반례/실패 시 처리, 보안/권한/감사 로그 요구사항, 마감/우선순위\n\n"
|
|
58
|
+
"// 1) type='text' — 정보 수집(모호/불완전할 때 필수)\n"
|
|
59
|
+
"{\n"
|
|
60
|
+
' "role": "user",\n'
|
|
61
|
+
' "text": "어떤 DB 테이블/스키마/키로 저장할까요? 입력값 예시/형식, 실패 시 처리, 보존 기간까지 구체히 알려주세요.",\n'
|
|
62
|
+
' "type": "text"\n'
|
|
63
|
+
"}\n\n"
|
|
64
|
+
"// 2) type='select' — 여러 옵션 중 선택(옵션은 상호배타적, 명확/완전하게 제시)\n"
|
|
65
|
+
"{\n"
|
|
66
|
+
' "role": "system",\n'
|
|
67
|
+
' "text": "배포 환경을 선택하세요. 선택 근거(위험/롤백/감사 로그)를 함께 알려주세요.",\n'
|
|
68
|
+
' "type": "select",\n'
|
|
69
|
+
' "options": ["dev", "staging", "prod"]\n'
|
|
70
|
+
"}\n\n"
|
|
71
|
+
"// 3) type='confirm' — 보안/DB 변경 등 민감 작업 승인(필수)\n"
|
|
72
|
+
"{\n"
|
|
73
|
+
' "role": "user",\n'
|
|
74
|
+
' "text": "DB에서 주문 상태를 shipped로 업데이트합니다. 대상: order_id=..., 영향 범위: ...건, 롤백: ..., 진행 승인하시겠습니까?",\n'
|
|
75
|
+
' "type": "confirm"\n'
|
|
76
|
+
"}\n\n"
|
|
77
|
+
"타입 선택 규칙\n"
|
|
78
|
+
"- text: 모호/누락 정보가 있을 때 먼저 세부사항을 수집 (여러 번 질문 가능)\n"
|
|
79
|
+
"- select: 옵션이 둘 이상이면 반드시 options로 제시하고, 선택 기준을 text에 명시\n"
|
|
80
|
+
"- confirm: DB 저장/수정/삭제, 외부 전송, 파일 조작 등은 승인 후에만 진행\n\n"
|
|
81
|
+
"[3] 주의사항\n"
|
|
82
|
+
"- 이 도구 없이 민감/변경 작업을 임의로 진행 금지.\n"
|
|
83
|
+
"- select 타입은 반드시 'options'를 포함.\n"
|
|
84
|
+
"- confirm 응답에 따라: ✅ 승인 → 즉시 수행 / ❌ 거절 → 즉시 중단(건너뛰기).\n"
|
|
85
|
+
"- 애매하면 추가 질문을 반복하고, 충분히 구체화되기 전에는 실행하지 말 것.\n"
|
|
86
|
+
"- 민감 정보는 최소한만 노출하고 필요 시 마스킹/요약.\n"
|
|
87
|
+
"- 예시를 그대로 사용하지 말고 컨텍스트에 맞게 반드시 자연스러운 질의를 재작성하세요.\n"
|
|
88
|
+
"- 타임아웃/미응답 시 '사용자 미응답 거절'을 반환하며, 후속 변경 작업을 중단하는 것이 안전.\n"
|
|
89
|
+
"- 한 번에 하나의 주제만 질문(여러 주제면 질문을 분리). 한국어 존댓말 사용, 간결하되 상세하게."
|
|
90
|
+
)
|
|
91
|
+
args_schema: Type[HumanQuerySchema] = HumanQuerySchema
|
|
92
|
+
|
|
93
|
+
# 선택적 컨텍스트(없어도 동작). ContextVar가 우선 사용됨
|
|
94
|
+
_tenant_id: Optional[str] = None
|
|
95
|
+
_user_id: Optional[str] = None
|
|
96
|
+
_todo_id: Optional[int] = None
|
|
97
|
+
_proc_inst_id: Optional[str] = None
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
tenant_id: Optional[str] = None,
|
|
102
|
+
user_id: Optional[str] = None,
|
|
103
|
+
todo_id: Optional[int] = None,
|
|
104
|
+
proc_inst_id: Optional[str] = None,
|
|
105
|
+
agent_name: Optional[str] = None,
|
|
106
|
+
**kwargs,
|
|
107
|
+
):
|
|
108
|
+
"""도구 실행 컨텍스트(테넌트/유저/프로세스)를 옵션으로 설정."""
|
|
109
|
+
super().__init__(**kwargs)
|
|
110
|
+
self._tenant_id = tenant_id
|
|
111
|
+
self._user_id = user_id
|
|
112
|
+
self._todo_id = todo_id
|
|
113
|
+
self._proc_inst_id = proc_inst_id
|
|
114
|
+
self._agent_name = agent_name
|
|
115
|
+
|
|
116
|
+
# =============================================================================
|
|
117
|
+
# 동기 실행
|
|
118
|
+
# 설명: 질문 이벤트를 저장하고 DB 폴링으로 응답을 기다린다
|
|
119
|
+
# =============================================================================
|
|
120
|
+
def _run(
|
|
121
|
+
self, role: str, text: str, type: str = "text", options: Optional[List[str]] = None
|
|
122
|
+
) -> str:
|
|
123
|
+
"""질문 이벤트를 기록하고 최종 응답 문자열을 반환."""
|
|
124
|
+
try:
|
|
125
|
+
agent_name = getattr(self, "_agent_name", None)
|
|
126
|
+
write_log_message(f"HumanQueryTool 실행: role={role}, agent_name={agent_name}, type={type}, options={options}")
|
|
127
|
+
query_id = f"human_asked_{uuid.uuid4()}"
|
|
128
|
+
|
|
129
|
+
payload: Dict[str, Any] = {
|
|
130
|
+
"role": role,
|
|
131
|
+
"text": text,
|
|
132
|
+
"type": type,
|
|
133
|
+
"options": options or [],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
todo_id = todo_id_var.get() or self._todo_id
|
|
137
|
+
proc_inst_id = proc_id_var.get() or self._proc_inst_id
|
|
138
|
+
|
|
139
|
+
payload_with_status = {
|
|
140
|
+
**payload,
|
|
141
|
+
"status": "ASKED",
|
|
142
|
+
"agent_profile": "/images/chat-icon.png"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
initialize_db()
|
|
146
|
+
supabase = get_db_client()
|
|
147
|
+
record = {
|
|
148
|
+
"id": str(uuid.uuid4()),
|
|
149
|
+
"job_id": query_id,
|
|
150
|
+
"todo_id": str(todo_id) if todo_id is not None else None,
|
|
151
|
+
"proc_inst_id": str(proc_inst_id) if proc_inst_id is not None else None,
|
|
152
|
+
"event_type": "human_asked",
|
|
153
|
+
"crew_type": "action",
|
|
154
|
+
"data": payload_with_status,
|
|
155
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
156
|
+
}
|
|
157
|
+
supabase.table("events").insert(record).execute()
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
tenant_id = self._tenant_id
|
|
161
|
+
target_emails_csv = all_users_var.get() or ""
|
|
162
|
+
if target_emails_csv and target_emails_csv.strip():
|
|
163
|
+
write_log_message(f"알림 저장 시도: target_emails_csv={target_emails_csv}, tenant_id={tenant_id}")
|
|
164
|
+
save_notification(
|
|
165
|
+
title=text,
|
|
166
|
+
notif_type="workitem_bpm",
|
|
167
|
+
description=agent_name,
|
|
168
|
+
user_ids_csv=target_emails_csv,
|
|
169
|
+
tenant_id=tenant_id,
|
|
170
|
+
url=f"/todolist/{todo_id}",
|
|
171
|
+
from_user_id=agent_name,
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
write_log_message("알림 저장 생략: 대상 이메일 없음")
|
|
175
|
+
except Exception as e:
|
|
176
|
+
handle_application_error("알림저장HumanTool", e, raise_error=False)
|
|
177
|
+
|
|
178
|
+
answer = self._wait_for_response(query_id)
|
|
179
|
+
return answer
|
|
180
|
+
except Exception as e:
|
|
181
|
+
handle_application_error("HumanQueryTool", e, raise_error=False)
|
|
182
|
+
return "사용자 미응답 거절"
|
|
183
|
+
|
|
184
|
+
# =============================================================================
|
|
185
|
+
# 응답 대기
|
|
186
|
+
# 설명: events 테이블에서 human_response를 폴링하여 응답을 가져온다
|
|
187
|
+
# =============================================================================
|
|
188
|
+
def _wait_for_response(
|
|
189
|
+
self, job_id: str, timeout_sec: int = 180, poll_interval_sec: int = 5
|
|
190
|
+
) -> str:
|
|
191
|
+
"""DB 폴링으로 사람의 응답을 기다려 문자열로 반환."""
|
|
192
|
+
deadline = time.time() + timeout_sec
|
|
193
|
+
|
|
194
|
+
while time.time() < deadline:
|
|
195
|
+
try:
|
|
196
|
+
write_log_message(f"HumanQueryTool 응답 폴링: {job_id}")
|
|
197
|
+
event = fetch_human_response_sync(job_id=job_id)
|
|
198
|
+
if event:
|
|
199
|
+
write_log_message(f"HumanQueryTool 응답 수신: {event}")
|
|
200
|
+
data = event.get("data") or {}
|
|
201
|
+
answer = (data or {}).get("answer")
|
|
202
|
+
if isinstance(answer, str):
|
|
203
|
+
write_log_message("사람 응답 수신 완료")
|
|
204
|
+
return answer
|
|
205
|
+
return str(data)
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
write_log_message(f"인간 응답 대기 중... (오류: {str(e)[:100]})")
|
|
209
|
+
time.sleep(poll_interval_sec)
|
|
210
|
+
return "사용자 미응답 거절"
|
|
211
|
+
|