process-gpt-agent-sdk 0.1.5__py3-none-any.whl → 0.1.6__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-gpt-agent-sdk
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Supabase 기반 이벤트/작업 폴링으로 A2A AgentExecutor를 실행하는 SDK
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/your-org/process-gpt-agent-sdk
@@ -0,0 +1,18 @@
1
+ processgpt_agent_sdk/__init__.py,sha256=IvAL5WBZhI83LYQogRP6-i04bxZkhmkgmES4FRQY888,185
2
+ processgpt_agent_sdk/server.py,sha256=MQMAnaFFik_1h9aaQdd8yTHf6uYbLA_SMVCJAWdqMYU,8922
3
+ processgpt_agent_sdk/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ processgpt_agent_sdk/core/database.py,sha256=o5QEWoMdgliylFN-U7wlYd7Qng_GEnfzixRdhOZN_ZY,18790
5
+ processgpt_agent_sdk/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ processgpt_agent_sdk/tools/human_query_tool.py,sha256=2HSs277Q_8Jn-dS6Hc-ABNvgsXw1Jn94R2GML97rjwU,10308
7
+ processgpt_agent_sdk/tools/knowledge_tools.py,sha256=AOtxvLypu343877ZzzELGq3At-E_2NiAqEw0Njlephg,8937
8
+ processgpt_agent_sdk/tools/safe_tool_loader.py,sha256=7WSR6tMxPdNodY4z-BRAO6hH6zTniY_qM5VlQIdqqjM,6132
9
+ processgpt_agent_sdk/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ processgpt_agent_sdk/utils/context_manager.py,sha256=QDL5ya4FfUPx4EKScSVIllJGKS1-zctYvxg2FZB2b0A,1383
11
+ processgpt_agent_sdk/utils/crewai_event_listener.py,sha256=343btAePWM19KbjDwElYb9K3FIbVm80_ZLLdOyFSsj4,8815
12
+ processgpt_agent_sdk/utils/event_handler.py,sha256=ogmKcfhD2PHVQCQHpjVevZOe6LThwKIsrJcWFYqGlPA,2337
13
+ processgpt_agent_sdk/utils/logger.py,sha256=2hX1GClkrE3ZVzvV61ORlZg4QDh59g-lr3qJyIRAfD4,948
14
+ processgpt_agent_sdk/utils/summarizer.py,sha256=XV7e4pyKEK7NiGK_PAc1xorKSO2MKlSHCAiV7slOdus,4527
15
+ process_gpt_agent_sdk-0.1.6.dist-info/METADATA,sha256=oYK0zEBzxwle5ko8csSwsi9LwfhcooF-SN7HPXIzndI,12898
16
+ process_gpt_agent_sdk-0.1.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
+ process_gpt_agent_sdk-0.1.6.dist-info/top_level.txt,sha256=Xe6zrj3_3Vv7d0pl5RRtenVUckwOVBVLQn2P03j5REo,21
18
+ process_gpt_agent_sdk-0.1.6.dist-info/RECORD,,
@@ -27,7 +27,7 @@ from typing import Callable, TypeVar
27
27
 
28
28
  T = TypeVar("T")
29
29
 
30
- from ..utils.logger import handle_error as _emit_error, log as _emit_log
30
+ from ..utils.logger import handle_application_error as _emit_error, write_log_message as _emit_log
31
31
 
32
32
 
33
33
  async def _async_retry(
@@ -370,4 +370,139 @@ async def update_task_error(todo_id: str) -> None:
370
370
  .execute()
371
371
  )
372
372
 
373
- await _async_retry(_call, name="update_task_error", fallback=lambda: None)
373
+ await _async_retry(_call, name="update_task_error", fallback=lambda: None)
374
+
375
+
376
+ # ============================================================================
377
+ # 알림 저장
378
+ # ============================================================================
379
+
380
+ def save_notification(
381
+ *,
382
+ title: str,
383
+ notif_type: str,
384
+ description: Optional[str] = None,
385
+ user_ids_csv: Optional[str] = None,
386
+ tenant_id: Optional[str] = None,
387
+ url: Optional[str] = None,
388
+ from_user_id: Optional[str] = None,
389
+ ) -> None:
390
+ """notifications 테이블에 알림 저장
391
+
392
+ - user_ids_csv: 쉼표로 구분된 사용자 ID 목록. 비어있으면 저장 생략
393
+ - 테이블 스키마는 다음 컬럼을 가정: user_id, tenant_id, title, description, type, url, from_user_id
394
+ """
395
+ try:
396
+ # 대상 사용자가 없으면 작업 생략
397
+ if not user_ids_csv:
398
+ _emit_log(f"알림 저장 생략: 대상 사용자 없음 (user_ids_csv={user_ids_csv})")
399
+ return
400
+
401
+ supabase = get_db_client()
402
+
403
+ user_ids: List[str] = [uid.strip() for uid in user_ids_csv.split(',') if uid and uid.strip()]
404
+ if not user_ids:
405
+ _emit_log(f"알림 저장 생략: 유효한 사용자 ID 없음 (user_ids_csv={user_ids_csv})")
406
+ return
407
+
408
+ rows: List[Dict[str, Any]] = []
409
+ for uid in user_ids:
410
+ rows.append(
411
+ {
412
+ "id": str(uuid.uuid4()), # UUID 자동 생성
413
+ "user_id": uid,
414
+ "tenant_id": tenant_id,
415
+ "title": title,
416
+ "description": description,
417
+ "type": notif_type,
418
+ "url": url,
419
+ "from_user_id": from_user_id,
420
+ }
421
+ )
422
+
423
+ supabase.table("notifications").insert(rows).execute()
424
+ _emit_log(f"알림 저장 완료: {len(rows)}건")
425
+ except Exception as e:
426
+ # 알림 저장 실패는 치명적이지 않으므로 오류만 로깅
427
+ _emit_error("알림저장오류", e, raise_error=False)
428
+
429
+
430
+ def _is_valid_uuid(value: str) -> bool:
431
+ """UUID 문자열 형식 검증 (v1~v8 포함)"""
432
+ try:
433
+ uuid.UUID(value)
434
+ return True
435
+ except Exception:
436
+ return False
437
+
438
+
439
+ # ============================================================================
440
+ # 사용자 및 에이전트 정보 조회
441
+ # ============================================================================
442
+
443
+ async def fetch_human_users_by_proc_inst_id(proc_inst_id: str) -> str:
444
+ """proc_inst_id로 해당 프로세스의 실제 사용자(is_agent=false)들의 이메일만 쉼표로 구분하여 반환"""
445
+ if not proc_inst_id:
446
+ return ""
447
+
448
+ def _sync():
449
+ try:
450
+ supabase = get_db_client()
451
+
452
+ # 1. proc_inst_id로 todolist에서 user_id들 조회
453
+ resp = (
454
+ supabase
455
+ .table('todolist')
456
+ .select('user_id')
457
+ .eq('proc_inst_id', proc_inst_id)
458
+ .execute()
459
+ )
460
+
461
+ if not resp.data:
462
+ return ""
463
+
464
+ # 2. 모든 user_id를 수집 (중복 제거)
465
+ all_user_ids = set()
466
+ for row in resp.data:
467
+ user_id = row.get('user_id', '')
468
+ if user_id:
469
+ # 쉼표로 구분된 경우 분리
470
+ ids = [id.strip() for id in user_id.split(',') if id.strip()]
471
+ all_user_ids.update(ids)
472
+
473
+ if not all_user_ids:
474
+ return ""
475
+
476
+ # 3. 각 user_id가 실제 사용자(is_agent=false 또는 null)인지 확인 후 이메일 수집
477
+ human_user_emails = []
478
+ for user_id in all_user_ids:
479
+ # UUID 형식이 아니면 스킵
480
+ if not _is_valid_uuid(user_id):
481
+ continue
482
+
483
+ # users 테이블에서 해당 user_id 조회
484
+ user_resp = (
485
+ supabase
486
+ .table('users')
487
+ .select('id, email, is_agent')
488
+ .eq('id', user_id)
489
+ .execute()
490
+ )
491
+
492
+ if user_resp.data:
493
+ user = user_resp.data[0]
494
+ is_agent = user.get('is_agent')
495
+ # is_agent가 false이거나 null인 경우만 실제 사용자로 간주
496
+ if not is_agent: # False 또는 None
497
+ email = (user.get('email') or '').strip()
498
+ if email:
499
+ human_user_emails.append(email)
500
+
501
+ # 4. 쉼표로 구분된 문자열로 반환
502
+ return ','.join(human_user_emails)
503
+
504
+ except Exception as e:
505
+ _emit_error("사용자조회오류", e, raise_error=False)
506
+ return ""
507
+
508
+ return await asyncio.to_thread(_sync)
@@ -5,6 +5,7 @@ from a2a.server.agent_execution import AgentExecutor, RequestContext
5
5
  from a2a.server.events import EventQueue, Event
6
6
 
7
7
  from .core.database import (
8
+ fetch_human_users_by_proc_inst_id,
8
9
  initialize_db,
9
10
  get_consumer_id,
10
11
  polling_pending_todos,
@@ -13,12 +14,12 @@ from .core.database import (
13
14
  fetch_form_types,
14
15
  fetch_task_status,
15
16
  fetch_tenant_mcp_config,
16
- update_task_error,
17
+ update_task_error,
17
18
  )
18
19
 
19
- from .utils.logger import handle_error as _emit_error, log as _emit_log
20
+ from .utils.logger import handle_application_error, write_log_message
20
21
  from .utils.summarizer import summarize_async
21
- from .utils.event_handler import route_event
22
+ from .utils.event_handler import process_event_message
22
23
  from .utils.context_manager import set_context, reset_context
23
24
 
24
25
 
@@ -39,100 +40,106 @@ class ProcessGPTAgentServer:
39
40
 
40
41
  async def run(self) -> None:
41
42
  self.is_running = True
42
- _emit_log("ProcessGPT 서버 시작")
43
+ write_log_message("ProcessGPT 서버 시작")
43
44
 
44
45
  while self.is_running:
45
46
  try:
46
- todo = await polling_pending_todos(self.agent_orch, get_consumer_id())
47
- if not todo:
47
+ task_record = await polling_pending_todos(self.agent_orch, get_consumer_id())
48
+ if not task_record:
48
49
  await asyncio.sleep(self.polling_interval)
49
50
  continue
50
51
 
51
- todo_id = todo["id"]
52
- _emit_log(f"[JOB START] todo_id={todo_id}")
52
+ task_id = task_record["id"]
53
+ write_log_message(f"[JOB START] task_id={task_id}")
53
54
 
54
55
  try:
55
- prepared_data = await self._prepare_service_data(todo)
56
- _emit_log(f"[RUN] 서비스 데이터 준비 완료 [todo_id={todo_id} agent={prepared_data.get('agent_orch','')}]")
56
+ prepared_data = await self._prepare_service_data(task_record)
57
+ write_log_message(f"[RUN] 서비스 데이터 준비 완료 [task_id={task_id} agent={prepared_data.get('agent_orch','')}]")
57
58
 
58
- await self._execute_with_cancel_watch(todo, prepared_data)
59
- _emit_log(f"[RUN] 서비스 실행 완료 [todo_id={todo_id} agent={prepared_data.get('agent_orch','')}]")
59
+ await self._execute_with_cancel_watch(task_record, prepared_data)
60
+ write_log_message(f"[RUN] 서비스 실행 완료 [task_id={task_id} agent={prepared_data.get('agent_orch','')}]")
60
61
  except Exception as job_err:
61
- _emit_error("작업 처리 오류", job_err, raise_error=False)
62
+ handle_application_error("작업 처리 오류", job_err, raise_error=False)
62
63
  try:
63
- await update_task_error(str(todo_id))
64
+ await update_task_error(str(task_id))
64
65
  except Exception as upd_err:
65
- _emit_error("FAILED 상태 업데이트 실패", upd_err, raise_error=False)
66
+ handle_application_error("FAILED 상태 업데이트 실패", upd_err, raise_error=False)
66
67
  # 다음 루프로 진행
67
68
  continue
68
69
 
69
70
  except Exception as e:
70
- _emit_error("폴링 루프 오류", e, raise_error=False)
71
+ handle_application_error("폴링 루프 오류", e, raise_error=False)
71
72
  await asyncio.sleep(self.polling_interval)
72
73
 
73
74
  def stop(self) -> None:
74
75
  self.is_running = False
75
- _emit_log("ProcessGPT 서버 중지")
76
+ write_log_message("ProcessGPT 서버 중지")
76
77
 
77
- async def _prepare_service_data(self, todo: Dict[str, Any]) -> Dict[str, Any]:
78
- done_outputs = await fetch_done_data(todo.get("proc_inst_id"))
79
- _emit_log(f"[PREP] done_outputs → {done_outputs}")
80
- feedbacks = todo.get("feedback")
78
+ async def _prepare_service_data(self, task_record: Dict[str, Any]) -> Dict[str, Any]:
79
+ done_outputs = await fetch_done_data(task_record.get("proc_inst_id"))
80
+ write_log_message(f"[PREP] done_outputs → {done_outputs}")
81
+ feedbacks = task_record.get("feedback")
81
82
 
82
- agent_list = await fetch_agent_data(str(todo.get("user_id", "")))
83
- _emit_log(f"[PREP] agent_list → {agent_list}")
83
+ agent_list = await fetch_agent_data(str(task_record.get("user_id", "")))
84
+ write_log_message(f"[PREP] agent_list → {agent_list}")
84
85
 
85
- mcp_config = await fetch_tenant_mcp_config(str(todo.get("tenant_id", "")))
86
- _emit_log(f"[PREP] mcp_config(툴) → {mcp_config}")
86
+ mcp_config = await fetch_tenant_mcp_config(str(task_record.get("tenant_id", "")))
87
+ write_log_message(f"[PREP] mcp_config(툴) → {mcp_config}")
87
88
 
88
89
  form_id, form_types = await fetch_form_types(
89
- str(todo.get("tool", "")),
90
- str(todo.get("tenant_id", ""))
90
+ str(task_record.get("tool", "")),
91
+ str(task_record.get("tenant_id", ""))
91
92
  )
92
- _emit_log(f"[PREP] form → id={form_id} types={form_types}")
93
+ write_log_message(f"[PREP] form → id={form_id} types={form_types}")
93
94
 
94
95
  output_summary, feedback_summary = await summarize_async(
95
- done_outputs or [], feedbacks or "", todo.get("description", "")
96
+ done_outputs or [], feedbacks or "", task_record.get("description", "")
96
97
  )
97
- _emit_log(f"[PREP] summary → output={output_summary} feedback={feedback_summary}")
98
+ write_log_message(f"[PREP] summary → output={output_summary} feedback={feedback_summary}")
99
+
100
+
101
+ all_users = await fetch_human_users_by_proc_inst_id(task_record.get("proc_inst_id"))
102
+ write_log_message(f"[PREP] all_users → {all_users}")
98
103
 
99
104
  prepared: Dict[str, Any] = {
100
- "todo_id": str(todo.get("id")),
101
- "proc_inst_id": todo.get("proc_inst_id"),
105
+ "task_id": str(task_record.get("id")),
106
+ "proc_inst_id": task_record.get("proc_inst_id"),
102
107
  "agent_list": agent_list or [],
103
108
  "mcp_config": mcp_config,
104
109
  "form_id": form_id,
105
110
  "form_types": form_types or [],
106
- "activity_name": str(todo.get("activity_name", "")),
107
- "message": str(todo.get("description", "")),
108
- "agent_orch": str(todo.get("agent_orch", "")),
111
+ "activity_name": str(task_record.get("activity_name", "")),
112
+ "message": str(task_record.get("description", "")),
113
+ "agent_orch": str(task_record.get("agent_orch", "")),
109
114
  "done_outputs": done_outputs or [],
110
115
  "output_summary": output_summary or "",
111
116
  "feedback_summary": feedback_summary or "",
117
+ "all_users": all_users or "",
112
118
  }
113
119
 
114
120
  return prepared
115
121
 
116
- async def _execute_with_cancel_watch(self, todo: Dict[str, Any], prepared_data: Dict[str, Any]) -> None:
122
+ async def _execute_with_cancel_watch(self, task_record: Dict[str, Any], prepared_data: Dict[str, Any]) -> None:
117
123
  executor = self._executor
118
124
 
119
125
  context = ProcessGPTRequestContext(prepared_data)
120
- event_queue = ProcessGPTEventQueue(todo)
126
+ event_queue = ProcessGPTEventQueue(task_record)
121
127
 
122
128
  # 실행 전 컨텍스트 변수 설정 (CrewAI 전역 리스너 등에서 활용)
123
129
  try:
124
130
  set_context(
125
- todo_id=str(todo.get("id")),
126
- proc_inst_id=str(todo.get("proc_inst_id") or ""),
131
+ todo_id=str(task_record.get("id")),
132
+ proc_inst_id=str(task_record.get("proc_inst_id") or ""),
127
133
  crew_type=str(prepared_data.get("agent_orch") or ""),
128
134
  form_id=str(prepared_data.get("form_id") or ""),
135
+ all_users=str(prepared_data.get("all_users") or ""),
129
136
  )
130
137
  except Exception as e:
131
- _emit_error("컨텍스트 설정 실패", e, raise_error=False)
138
+ handle_application_error("컨텍스트 설정 실패", e, raise_error=False)
132
139
 
133
- _emit_log(f"[EXEC START] todo_id={todo.get('id')} agent={prepared_data.get('agent_orch','')}")
140
+ write_log_message(f"[EXEC START] task_id={task_record.get('id')} agent={prepared_data.get('agent_orch','')}")
134
141
  execute_task = asyncio.create_task(executor.execute(context, event_queue))
135
- cancel_watch_task = asyncio.create_task(self._watch_cancellation(todo, executor, context, event_queue, execute_task))
142
+ cancel_watch_task = asyncio.create_task(self._watch_cancellation(task_record, executor, context, event_queue, execute_task))
136
143
 
137
144
  try:
138
145
  done, pending = await asyncio.wait(
@@ -143,7 +150,7 @@ class ProcessGPTAgentServer:
143
150
  task.cancel()
144
151
 
145
152
  except Exception as e:
146
- _emit_error("서비스 실행 오류", e, raise_error=False)
153
+ handle_application_error("서비스 실행 오류", e, raise_error=False)
147
154
  cancel_watch_task.cancel()
148
155
  execute_task.cancel()
149
156
  finally:
@@ -151,15 +158,15 @@ class ProcessGPTAgentServer:
151
158
  try:
152
159
  reset_context()
153
160
  except Exception as e:
154
- _emit_error("컨텍스트 리셋 실패", e, raise_error=False)
161
+ handle_application_error("컨텍스트 리셋 실패", e, raise_error=False)
155
162
  try:
156
163
  await event_queue.close()
157
164
  except Exception as e:
158
- _emit_error("이벤트 큐 종료 실패", e, raise_error=False)
159
- _emit_log(f"[EXEC END] todo_id={todo.get('id')} agent={prepared_data.get('agent_orch','')}")
165
+ handle_application_error("이벤트 큐 종료 실패", e, raise_error=False)
166
+ write_log_message(f"[EXEC END] task_id={task_record.get('id')} agent={prepared_data.get('agent_orch','')}")
160
167
 
161
- async def _watch_cancellation(self, todo: Dict[str, Any], executor: AgentExecutor, context: RequestContext, event_queue: EventQueue, execute_task: asyncio.Task) -> None:
162
- todo_id = str(todo.get("id"))
168
+ async def _watch_cancellation(self, task_record: Dict[str, Any], executor: AgentExecutor, context: RequestContext, event_queue: EventQueue, execute_task: asyncio.Task) -> None:
169
+ todo_id = str(task_record.get("id"))
163
170
 
164
171
  while True:
165
172
  await asyncio.sleep(self.cancel_check_interval)
@@ -167,21 +174,21 @@ class ProcessGPTAgentServer:
167
174
  status = await fetch_task_status(todo_id)
168
175
  normalized = (status or "").strip().lower()
169
176
  if normalized in ("cancelled", "fb_requested"):
170
- _emit_log(f"작업 취소 감지: {todo_id}, 상태: {status}")
177
+ write_log_message(f"작업 취소 감지: {todo_id}, 상태: {status}")
171
178
 
172
179
  try:
173
180
  await executor.cancel(context, event_queue)
174
181
  except Exception as e:
175
- _emit_error("취소 처리 실패", e, raise_error=False)
182
+ handle_application_error("취소 처리 실패", e, raise_error=False)
176
183
  finally:
177
184
  try:
178
185
  execute_task.cancel()
179
186
  except Exception as e:
180
- _emit_error("실행 태스크 즉시 취소 실패", e, raise_error=False)
187
+ handle_application_error("실행 태스크 즉시 취소 실패", e, raise_error=False)
181
188
  try:
182
189
  await event_queue.close()
183
190
  except Exception as e:
184
- _emit_error("취소 후 이벤트 큐 종료 실패", e, raise_error=False)
191
+ handle_application_error("취소 후 이벤트 큐 종료 실패", e, raise_error=False)
185
192
  break
186
193
 
187
194
 
@@ -207,8 +214,8 @@ class ProcessGPTRequestContext(RequestContext):
207
214
 
208
215
 
209
216
  class ProcessGPTEventQueue(EventQueue):
210
- def __init__(self, todo: Dict[str, Any]):
211
- self.todo = todo
217
+ def __init__(self, task_record: Dict[str, Any]):
218
+ self.todo = task_record
212
219
  super().__init__()
213
220
 
214
221
  def enqueue_event(self, event: Event):
@@ -216,17 +223,17 @@ class ProcessGPTEventQueue(EventQueue):
216
223
  try:
217
224
  super().enqueue_event(event)
218
225
  except Exception as e:
219
- _emit_error("이벤트 큐 삽입 실패", e, raise_error=False)
226
+ handle_application_error("이벤트 큐 삽입 실패", e, raise_error=False)
220
227
 
221
- self._create_bg_task(route_event(self.todo, event), "route_event")
228
+ self._create_bg_task(process_event_message(self.todo, event), "process_event_message")
222
229
  except Exception as e:
223
- _emit_error("이벤트 저장 실패", e, raise_error=False)
230
+ handle_application_error("이벤트 저장 실패", e, raise_error=False)
224
231
 
225
232
  def task_done(self) -> None:
226
233
  try:
227
- _emit_log(f"태스크 완료: {self.todo['id']}")
234
+ write_log_message(f"태스크 완료: {self.todo['id']}")
228
235
  except Exception as e:
229
- _emit_error("태스크 완료 처리 실패", e, raise_error=False)
236
+ handle_application_error("태스크 완료 처리 실패", e, raise_error=False)
230
237
 
231
238
  async def close(self) -> None:
232
239
  pass
@@ -237,7 +244,7 @@ class ProcessGPTEventQueue(EventQueue):
237
244
  def _cb(t: asyncio.Task):
238
245
  exc = t.exception()
239
246
  if exc:
240
- _emit_error(f"백그라운드 태스크 오류({label})", exc, raise_error=False)
247
+ handle_application_error(f"백그라운드 태스크 오류({label})", exc, raise_error=False)
241
248
  task.add_done_callback(_cb)
242
249
  except Exception as e:
243
- _emit_error(f"백그라운드 태스크 생성 실패({label})", e, raise_error=False)
250
+ handle_application_error(f"백그라운드 태스크 생성 실패({label})", e, raise_error=False)
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ import os
6
+ from typing import Optional, List, Literal, Type, Dict, Any
7
+
8
+ from pydantic import BaseModel, Field
9
+ from crewai.tools import BaseTool
10
+
11
+ from ..utils.crewai_event_listener import CrewAIEventLogger
12
+ from ..utils.context_manager import todo_id_var, proc_id_var
13
+ from ..utils.logger import write_log_message, handle_application_error
14
+ from ..core.database import fetch_human_response, save_notification
15
+
16
+
17
+ class HumanQuerySchema(BaseModel):
18
+ """사용자 확인/추가정보 요청용 스키마"""
19
+
20
+ role: str = Field(..., description="누구에게(역할 또는 대상)")
21
+ text: str = Field(..., description="질의 내용")
22
+ type: Literal["text", "select", "confirm"] = Field(
23
+ default="text", description="질의 유형: 자유 텍스트, 선택형, 확인 여부"
24
+ )
25
+ options: Optional[List[str]] = Field(
26
+ default=None, description="type이 select일 때 선택지 목록"
27
+ )
28
+
29
+
30
+ class HumanQueryTool(BaseTool):
31
+ """사람에게 보안/모호성 관련 확인을 요청하고 응답을 대기하는 도구"""
32
+
33
+ name: str = "human_asked"
34
+ description: str = (
35
+ "👀 질문은 반드시 '매우 구체적이고 세부적'으로 작성해야 합니다.\n"
36
+ "- 목적, 대상, 범위/경계, 입력/출력 형식, 성공/실패 기준, 제약조건(보안/권한/시간/용량),\n"
37
+ " 필요한 식별자/예시/반례까지 모두 명시하세요. 추측으로 진행하지 말고 누락 정보를 반드시 질문하세요.\n\n"
38
+ "[1] 언제 사용해야 하나\n"
39
+ "1. 보안에 민감한 정보(개인정보/인증정보/비밀키 등)를 다루거나 외부로 전송할 때\n"
40
+ "2. 데이터베이스에 '저장/수정/삭제' 작업을 수행할 때 (읽기 전용 조회는 제외)\n"
41
+ "3. 요구사항 및 작업지시사항이 모호·불완전·추정에 의존하거나, 전제조건/매개변수가 불명확할 때\n"
42
+ "4. 외부 시스템 연동, 파일 생성/이동/삭제 등 시스템 상태를 바꾸는 작업일 때\n"
43
+ "⛔ 위 조건에 해당하면 이 도구 없이 진행 금지\n\n"
44
+ "[2] 응답 타입과 작성 방식 (항상 JSON으로 질의 전송)\n"
45
+ "- 공통 형식: { role: <누구에게>, text: <질의>, type: <text|select|confirm>, options?: [선택지...] }\n"
46
+ "- 질의 작성 가이드(반드시 포함): 5W1H, 목적/맥락, 선택 이유 또는 승인 근거, 기본값/제약,\n"
47
+ " 입력/출력 형식과 예시, 반례/실패 시 처리, 보안/권한/감사 로그 요구사항, 마감/우선순위\n\n"
48
+ "// 1) type='text' — 정보 수집(모호/불완전할 때 필수)\n"
49
+ "{\n"
50
+ ' "role": "user",\n'
51
+ ' "text": "어떤 DB 테이블/스키마/키로 저장할까요? 입력값 예시/형식, 실패 시 처리, 보존 기간까지 구체히 알려주세요.",\n'
52
+ ' "type": "text"\n'
53
+ "}\n\n"
54
+ "// 2) type='select' — 여러 옵션 중 선택(옵션은 상호배타적, 명확/완전하게 제시)\n"
55
+ "{\n"
56
+ ' "role": "system",\n'
57
+ ' "text": "배포 환경을 선택하세요. 선택 근거(위험/롤백/감사 로그)를 함께 알려주세요.",\n'
58
+ ' "type": "select",\n'
59
+ ' "options": ["dev", "staging", "prod"]\n'
60
+ "}\n\n"
61
+ "// 3) type='confirm' — 보안/DB 변경 등 민감 작업 승인(필수)\n"
62
+ "{\n"
63
+ ' "role": "user",\n'
64
+ ' "text": "DB에서 주문 상태를 shipped로 업데이트합니다. 대상: order_id=..., 영향 범위: ...건, 롤백: ..., 진행 승인하시겠습니까?",\n'
65
+ ' "type": "confirm"\n'
66
+ "}\n\n"
67
+ "타입 선택 규칙\n"
68
+ "- text: 모호/누락 정보가 있을 때 먼저 세부사항을 수집 (여러 번 질문 가능)\n"
69
+ "- select: 옵션이 둘 이상이면 반드시 options로 제시하고, 선택 기준을 text에 명시\n"
70
+ "- confirm: DB 저장/수정/삭제, 외부 전송, 파일 조작 등은 승인 후에만 진행\n\n"
71
+ "[3] 주의사항\n"
72
+ "- 이 도구 없이 민감/변경 작업을 임의로 진행 금지.\n"
73
+ "- select 타입은 반드시 'options'를 포함.\n"
74
+ "- confirm 응답에 따라: ✅ 승인 → 즉시 수행 / ❌ 거절 → 즉시 중단(건너뛰기).\n"
75
+ "- 애매하면 추가 질문을 반복하고, 충분히 구체화되기 전에는 실행하지 말 것.\n"
76
+ "- 민감 정보는 최소한만 노출하고 필요 시 마스킹/요약.\n"
77
+ "- 예시를 그대로 사용하지 말고 컨텍스트에 맞게 반드시 자연스러운 질의를 재작성하세요.\n"
78
+ "- 타임아웃/미응답 시 '사용자 미응답 거절'을 반환하며, 후속 변경 작업을 중단하는 것이 안전.\n"
79
+ "- 한 번에 하나의 주제만 질문(여러 주제면 질문을 분리). 한국어 존댓말 사용, 간결하되 상세하게."
80
+ )
81
+ args_schema: Type[HumanQuerySchema] = HumanQuerySchema
82
+
83
+ # 선택적 컨텍스트(없어도 동작). ContextVar가 우선 사용됨
84
+ _tenant_id: Optional[str] = None
85
+ _user_id: Optional[str] = None
86
+ _todo_id: Optional[int] = None
87
+ _proc_inst_id: Optional[str] = None
88
+
89
+ def __init__(
90
+ self,
91
+ tenant_id: Optional[str] = None,
92
+ user_id: Optional[str] = None,
93
+ todo_id: Optional[int] = None,
94
+ proc_inst_id: Optional[str] = None,
95
+ agent_name: Optional[str] = None,
96
+ **kwargs,
97
+ ):
98
+ super().__init__(**kwargs)
99
+ self._tenant_id = tenant_id
100
+ self._user_id = user_id
101
+ self._todo_id = todo_id
102
+ self._proc_inst_id = proc_inst_id
103
+ self._agent_name = agent_name
104
+
105
+ # 동기 실행: CrewAI Tool 실행 컨텍스트에서 블로킹 폴링 허용
106
+ def _run(
107
+ self, role: str, text: str, type: str = "text", options: Optional[List[str]] = None
108
+ ) -> str:
109
+ try:
110
+ # 초기화된 기본 agent_name 사용
111
+ agent_name = getattr(self, "_agent_name", None)
112
+
113
+ write_log_message(f"HumanQueryTool 실행: role={role}, agent_name={agent_name}, type={type}, options={options}")
114
+ query_id = f"human_asked_{uuid.uuid4()}"
115
+
116
+ # 이벤트 발행 데이터
117
+ payload: Dict[str, Any] = {
118
+ "role": role,
119
+ "text": text,
120
+ "type": type,
121
+ "options": options or [],
122
+ }
123
+
124
+ # 컨텍스트 식별자
125
+ todo_id = todo_id_var.get() or self._todo_id
126
+ proc_inst_id = proc_id_var.get() or self._proc_inst_id
127
+
128
+ # 이벤트 발행
129
+ # 상태 정보는 data 안에 포함시켜 저장 (emit_event 시그니처에 status 없음)
130
+ payload_with_status = {
131
+ **payload,
132
+ "status": "ASKED",
133
+ "agent_profile": "/images/chat-icon.png"
134
+ }
135
+ ev = CrewAIEventLogger()
136
+ ev.emit_event(
137
+ event_type="human_asked",
138
+ data=payload_with_status,
139
+ job_id=query_id,
140
+ crew_type="action",
141
+ todo_id=str(todo_id) if todo_id is not None else None,
142
+ proc_inst_id=str(proc_inst_id) if proc_inst_id is not None else None,
143
+ )
144
+
145
+ # 알림 저장 (notifications 테이블)
146
+ try:
147
+ tenant_id = self._tenant_id
148
+ # 대상 이메일: context var(all_users_var)에 이메일 CSV가 있어야만 저장
149
+ target_emails_csv = os.getenv("HUMAN_USERS_CSV") or ""
150
+ if target_emails_csv and target_emails_csv.strip():
151
+ write_log_message(f"알림 저장 시도: target_emails_csv={target_emails_csv}, tenant_id={tenant_id}")
152
+ save_notification(
153
+ title=text,
154
+ notif_type="workitem_bpm",
155
+ description=agent_name,
156
+ user_ids_csv=target_emails_csv,
157
+ tenant_id=tenant_id,
158
+ url=f"/todolist/{todo_id}",
159
+ from_user_id=agent_name,
160
+ )
161
+ else:
162
+ write_log_message("알림 저장 생략: 대상 이메일 없음")
163
+ except Exception as e:
164
+ handle_application_error("알림저장HumanTool", e, raise_error=False)
165
+
166
+ # 응답 폴링 (events 테이블에서 동일 job_id, event_type=human_response)
167
+ answer = self._wait_for_response(query_id)
168
+ return answer
169
+ except Exception as e:
170
+ # 사용자 미응답 또는 기타 에러 시에도 작업이 즉시 중단되지 않도록 문자열 반환
171
+ handle_application_error("HumanQueryTool", e, raise_error=False)
172
+ return "사용자 미응답 거절"
173
+
174
+ def _wait_for_response(
175
+ self, job_id: str, timeout_sec: int = 180, poll_interval_sec: int = 5
176
+ ) -> str:
177
+ """DB events 테이블을 폴링하여 사람의 응답을 기다림"""
178
+ deadline = time.time() + timeout_sec
179
+
180
+ while time.time() < deadline:
181
+ try:
182
+ write_log_message(f"HumanQueryTool 응답 폴링: {job_id}")
183
+ event = fetch_human_response(job_id=job_id)
184
+ if event:
185
+ write_log_message(f"HumanQueryTool 응답 수신: {event}")
186
+ data = event.get("data") or {}
187
+ # 기대 형식: {"answer": str, ...}
188
+ answer = (data or {}).get("answer")
189
+ if isinstance(answer, str):
190
+ write_log_message("사람 응답 수신 완료")
191
+ return answer
192
+ # 문자열이 아니면 직렬화하여 반환
193
+ return str(data)
194
+
195
+ except Exception as e:
196
+ # 응답이 아직 없는 경우(0개 행) 또는 기타 DB 오류 시 계속 폴링
197
+ write_log_message(f"인간 응답 대기 중... (오류: {str(e)[:100]})")
198
+
199
+ time.sleep(poll_interval_sec)
200
+
201
+ # 타임아웃: 사용자 미응답으로 간주
202
+ return "사용자 미응답 거절"
203
+