process-gpt-agent-sdk 0.1.4__tar.gz → 0.1.6__tar.gz

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.

Files changed (27) hide show
  1. {process_gpt_agent_sdk-0.1.4/process_gpt_agent_sdk.egg-info → process_gpt_agent_sdk-0.1.6}/PKG-INFO +2 -3
  2. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6/process_gpt_agent_sdk.egg-info}/PKG-INFO +2 -3
  3. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/process_gpt_agent_sdk.egg-info/SOURCES.txt +1 -0
  4. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/process_gpt_agent_sdk.egg-info/requires.txt +1 -3
  5. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/core/database.py +137 -2
  6. process_gpt_agent_sdk-0.1.6/processgpt_agent_sdk/server.py +250 -0
  7. process_gpt_agent_sdk-0.1.6/processgpt_agent_sdk/tools/human_query_tool.py +203 -0
  8. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/tools/knowledge_tools.py +206 -206
  9. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/tools/safe_tool_loader.py +41 -47
  10. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/utils/context_manager.py +5 -1
  11. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/utils/crewai_event_listener.py +8 -8
  12. process_gpt_agent_sdk-0.1.6/processgpt_agent_sdk/utils/event_handler.py +66 -0
  13. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/utils/logger.py +6 -6
  14. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/pyproject.toml +2 -4
  15. process_gpt_agent_sdk-0.1.4/processgpt_agent_sdk/server.py +0 -243
  16. process_gpt_agent_sdk-0.1.4/processgpt_agent_sdk/utils/event_handler.py +0 -48
  17. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/MANIFEST.in +0 -0
  18. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/README.md +0 -0
  19. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/function.sql +0 -0
  20. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/process_gpt_agent_sdk.egg-info/dependency_links.txt +0 -0
  21. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/process_gpt_agent_sdk.egg-info/top_level.txt +0 -0
  22. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/__init__.py +0 -0
  23. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/core/__init__.py +0 -0
  24. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/tools/__init__.py +0 -0
  25. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/utils/__init__.py +0 -0
  26. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/utils/summarizer.py +0 -0
  27. {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-gpt-agent-sdk
3
- Version: 0.1.4
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
@@ -27,9 +27,8 @@ Requires-Dist: anyio>=4.4.0
27
27
  Requires-Dist: pydantic>=2.7.0
28
28
  Requires-Dist: crewai>=0.51.0
29
29
  Requires-Dist: crewai-tools>=0.8.2
30
+ Requires-Dist: mem0ai>=0.1.0
30
31
  Requires-Dist: mcp>=1.0.0
31
- Provides-Extra: mem0
32
- Requires-Dist: mem0>=0.1.0; extra == "mem0"
33
32
 
34
33
  # ProcessGPT Agent Framework
35
34
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-gpt-agent-sdk
3
- Version: 0.1.4
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
@@ -27,9 +27,8 @@ Requires-Dist: anyio>=4.4.0
27
27
  Requires-Dist: pydantic>=2.7.0
28
28
  Requires-Dist: crewai>=0.51.0
29
29
  Requires-Dist: crewai-tools>=0.8.2
30
+ Requires-Dist: mem0ai>=0.1.0
30
31
  Requires-Dist: mcp>=1.0.0
31
- Provides-Extra: mem0
32
- Requires-Dist: mem0>=0.1.0; extra == "mem0"
33
32
 
34
33
  # ProcessGPT Agent Framework
35
34
 
@@ -12,6 +12,7 @@ processgpt_agent_sdk/server.py
12
12
  processgpt_agent_sdk/core/__init__.py
13
13
  processgpt_agent_sdk/core/database.py
14
14
  processgpt_agent_sdk/tools/__init__.py
15
+ processgpt_agent_sdk/tools/human_query_tool.py
15
16
  processgpt_agent_sdk/tools/knowledge_tools.py
16
17
  processgpt_agent_sdk/tools/safe_tool_loader.py
17
18
  processgpt_agent_sdk/utils/__init__.py
@@ -13,7 +13,5 @@ anyio>=4.4.0
13
13
  pydantic>=2.7.0
14
14
  crewai>=0.51.0
15
15
  crewai-tools>=0.8.2
16
+ mem0ai>=0.1.0
16
17
  mcp>=1.0.0
17
-
18
- [mem0]
19
- mem0>=0.1.0
@@ -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)
@@ -0,0 +1,250 @@
1
+ import asyncio
2
+ from typing import Any, Dict
3
+
4
+ from a2a.server.agent_execution import AgentExecutor, RequestContext
5
+ from a2a.server.events import EventQueue, Event
6
+
7
+ from .core.database import (
8
+ fetch_human_users_by_proc_inst_id,
9
+ initialize_db,
10
+ get_consumer_id,
11
+ polling_pending_todos,
12
+ fetch_done_data,
13
+ fetch_agent_data,
14
+ fetch_form_types,
15
+ fetch_task_status,
16
+ fetch_tenant_mcp_config,
17
+ update_task_error,
18
+ )
19
+
20
+ from .utils.logger import handle_application_error, write_log_message
21
+ from .utils.summarizer import summarize_async
22
+ from .utils.event_handler import process_event_message
23
+ from .utils.context_manager import set_context, reset_context
24
+
25
+
26
+ class ProcessGPTAgentServer:
27
+ """ProcessGPT 핵심 서버
28
+
29
+ - 단일 실행기 모델: 실행기는 하나이며, 작업별 분기는 실행기 내부 로직에 위임합니다.
30
+ - 폴링은 타입 필터 없이(빈 값) 가져온 뒤, 작업 레코드의 정보로 처리합니다.
31
+ """
32
+
33
+ def __init__(self, executor: AgentExecutor, polling_interval: int = 5, agent_orch: str = ""):
34
+ self.polling_interval = polling_interval
35
+ self.is_running = False
36
+ self._executor: AgentExecutor = executor
37
+ self.cancel_check_interval: float = 0.5
38
+ self.agent_orch: str = agent_orch or ""
39
+ initialize_db()
40
+
41
+ async def run(self) -> None:
42
+ self.is_running = True
43
+ write_log_message("ProcessGPT 서버 시작")
44
+
45
+ while self.is_running:
46
+ try:
47
+ task_record = await polling_pending_todos(self.agent_orch, get_consumer_id())
48
+ if not task_record:
49
+ await asyncio.sleep(self.polling_interval)
50
+ continue
51
+
52
+ task_id = task_record["id"]
53
+ write_log_message(f"[JOB START] task_id={task_id}")
54
+
55
+ try:
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','')}]")
58
+
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','')}]")
61
+ except Exception as job_err:
62
+ handle_application_error("작업 처리 오류", job_err, raise_error=False)
63
+ try:
64
+ await update_task_error(str(task_id))
65
+ except Exception as upd_err:
66
+ handle_application_error("FAILED 상태 업데이트 실패", upd_err, raise_error=False)
67
+ # 다음 루프로 진행
68
+ continue
69
+
70
+ except Exception as e:
71
+ handle_application_error("폴링 루프 오류", e, raise_error=False)
72
+ await asyncio.sleep(self.polling_interval)
73
+
74
+ def stop(self) -> None:
75
+ self.is_running = False
76
+ write_log_message("ProcessGPT 서버 중지")
77
+
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")
82
+
83
+ agent_list = await fetch_agent_data(str(task_record.get("user_id", "")))
84
+ write_log_message(f"[PREP] agent_list → {agent_list}")
85
+
86
+ mcp_config = await fetch_tenant_mcp_config(str(task_record.get("tenant_id", "")))
87
+ write_log_message(f"[PREP] mcp_config(툴) → {mcp_config}")
88
+
89
+ form_id, form_types = await fetch_form_types(
90
+ str(task_record.get("tool", "")),
91
+ str(task_record.get("tenant_id", ""))
92
+ )
93
+ write_log_message(f"[PREP] form → id={form_id} types={form_types}")
94
+
95
+ output_summary, feedback_summary = await summarize_async(
96
+ done_outputs or [], feedbacks or "", task_record.get("description", "")
97
+ )
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}")
103
+
104
+ prepared: Dict[str, Any] = {
105
+ "task_id": str(task_record.get("id")),
106
+ "proc_inst_id": task_record.get("proc_inst_id"),
107
+ "agent_list": agent_list or [],
108
+ "mcp_config": mcp_config,
109
+ "form_id": form_id,
110
+ "form_types": form_types or [],
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", "")),
114
+ "done_outputs": done_outputs or [],
115
+ "output_summary": output_summary or "",
116
+ "feedback_summary": feedback_summary or "",
117
+ "all_users": all_users or "",
118
+ }
119
+
120
+ return prepared
121
+
122
+ async def _execute_with_cancel_watch(self, task_record: Dict[str, Any], prepared_data: Dict[str, Any]) -> None:
123
+ executor = self._executor
124
+
125
+ context = ProcessGPTRequestContext(prepared_data)
126
+ event_queue = ProcessGPTEventQueue(task_record)
127
+
128
+ # 실행 전 컨텍스트 변수 설정 (CrewAI 전역 리스너 등에서 활용)
129
+ try:
130
+ set_context(
131
+ todo_id=str(task_record.get("id")),
132
+ proc_inst_id=str(task_record.get("proc_inst_id") or ""),
133
+ crew_type=str(prepared_data.get("agent_orch") or ""),
134
+ form_id=str(prepared_data.get("form_id") or ""),
135
+ all_users=str(prepared_data.get("all_users") or ""),
136
+ )
137
+ except Exception as e:
138
+ handle_application_error("컨텍스트 설정 실패", e, raise_error=False)
139
+
140
+ write_log_message(f"[EXEC START] task_id={task_record.get('id')} agent={prepared_data.get('agent_orch','')}")
141
+ execute_task = asyncio.create_task(executor.execute(context, event_queue))
142
+ cancel_watch_task = asyncio.create_task(self._watch_cancellation(task_record, executor, context, event_queue, execute_task))
143
+
144
+ try:
145
+ done, pending = await asyncio.wait(
146
+ [cancel_watch_task, execute_task],
147
+ return_when=asyncio.FIRST_COMPLETED
148
+ )
149
+ for task in pending:
150
+ task.cancel()
151
+
152
+ except Exception as e:
153
+ handle_application_error("서비스 실행 오류", e, raise_error=False)
154
+ cancel_watch_task.cancel()
155
+ execute_task.cancel()
156
+ finally:
157
+ # 컨텍스트 정리
158
+ try:
159
+ reset_context()
160
+ except Exception as e:
161
+ handle_application_error("컨텍스트 리셋 실패", e, raise_error=False)
162
+ try:
163
+ await event_queue.close()
164
+ except Exception as e:
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','')}")
167
+
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"))
170
+
171
+ while True:
172
+ await asyncio.sleep(self.cancel_check_interval)
173
+
174
+ status = await fetch_task_status(todo_id)
175
+ normalized = (status or "").strip().lower()
176
+ if normalized in ("cancelled", "fb_requested"):
177
+ write_log_message(f"작업 취소 감지: {todo_id}, 상태: {status}")
178
+
179
+ try:
180
+ await executor.cancel(context, event_queue)
181
+ except Exception as e:
182
+ handle_application_error("취소 처리 실패", e, raise_error=False)
183
+ finally:
184
+ try:
185
+ execute_task.cancel()
186
+ except Exception as e:
187
+ handle_application_error("실행 태스크 즉시 취소 실패", e, raise_error=False)
188
+ try:
189
+ await event_queue.close()
190
+ except Exception as e:
191
+ handle_application_error("취소 후 이벤트 큐 종료 실패", e, raise_error=False)
192
+ break
193
+
194
+
195
+ class ProcessGPTRequestContext(RequestContext):
196
+ def __init__(self, prepared_data: Dict[str, Any]):
197
+ self._prepared_data = prepared_data
198
+ self._message = prepared_data.get("message", "")
199
+ self._current_task = None
200
+
201
+ def get_user_input(self) -> str:
202
+ return self._message
203
+
204
+ @property
205
+ def message(self) -> str:
206
+ return self._message
207
+
208
+ @property
209
+ def current_task(self):
210
+ return getattr(self, "_current_task", None)
211
+
212
+ def get_context_data(self) -> Dict[str, Any]:
213
+ return self._prepared_data
214
+
215
+
216
+ class ProcessGPTEventQueue(EventQueue):
217
+ def __init__(self, task_record: Dict[str, Any]):
218
+ self.todo = task_record
219
+ super().__init__()
220
+
221
+ def enqueue_event(self, event: Event):
222
+ try:
223
+ try:
224
+ super().enqueue_event(event)
225
+ except Exception as e:
226
+ handle_application_error("이벤트 큐 삽입 실패", e, raise_error=False)
227
+
228
+ self._create_bg_task(process_event_message(self.todo, event), "process_event_message")
229
+ except Exception as e:
230
+ handle_application_error("이벤트 저장 실패", e, raise_error=False)
231
+
232
+ def task_done(self) -> None:
233
+ try:
234
+ write_log_message(f"태스크 완료: {self.todo['id']}")
235
+ except Exception as e:
236
+ handle_application_error("태스크 완료 처리 실패", e, raise_error=False)
237
+
238
+ async def close(self) -> None:
239
+ pass
240
+
241
+ def _create_bg_task(self, coro: Any, label: str) -> None:
242
+ try:
243
+ task = asyncio.create_task(coro)
244
+ def _cb(t: asyncio.Task):
245
+ exc = t.exception()
246
+ if exc:
247
+ handle_application_error(f"백그라운드 태스크 오류({label})", exc, raise_error=False)
248
+ task.add_done_callback(_cb)
249
+ except Exception as e:
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
+