process-gpt-agent-sdk 0.1.5__py3-none-any.whl → 0.1.7__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.1.5.dist-info → process_gpt_agent_sdk-0.1.7.dist-info}/METADATA +1 -1
- process_gpt_agent_sdk-0.1.7.dist-info/RECORD +18 -0
- processgpt_agent_sdk/core/database.py +137 -2
- processgpt_agent_sdk/server.py +68 -61
- processgpt_agent_sdk/tools/human_query_tool.py +203 -0
- processgpt_agent_sdk/tools/knowledge_tools.py +206 -206
- processgpt_agent_sdk/tools/safe_tool_loader.py +41 -47
- processgpt_agent_sdk/utils/context_manager.py +5 -1
- processgpt_agent_sdk/utils/crewai_event_listener.py +8 -8
- processgpt_agent_sdk/utils/event_handler.py +30 -12
- processgpt_agent_sdk/utils/logger.py +6 -6
- process_gpt_agent_sdk-0.1.5.dist-info/RECORD +0 -17
- {process_gpt_agent_sdk-0.1.5.dist-info → process_gpt_agent_sdk-0.1.7.dist-info}/WHEEL +0 -0
- {process_gpt_agent_sdk-0.1.5.dist-info → process_gpt_agent_sdk-0.1.7.dist-info}/top_level.txt +0 -0
|
@@ -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=pntznVNt3FOsWunPe3-WIlRd7cB2ibyM6Tndyi6YQJk,10314
|
|
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.7.dist-info/METADATA,sha256=MVLiaYrXrOM2Fhfsvh_IkJcELfB188iFD0ropLkROvA,12898
|
|
16
|
+
process_gpt_agent_sdk-0.1.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
17
|
+
process_gpt_agent_sdk-0.1.7.dist-info/top_level.txt,sha256=Xe6zrj3_3Vv7d0pl5RRtenVUckwOVBVLQn2P03j5REo,21
|
|
18
|
+
process_gpt_agent_sdk-0.1.7.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
|
|
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)
|
processgpt_agent_sdk/server.py
CHANGED
|
@@ -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
|
-
|
|
17
|
+
update_task_error,
|
|
17
18
|
)
|
|
18
19
|
|
|
19
|
-
from .utils.logger import
|
|
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
|
|
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
|
-
|
|
43
|
+
write_log_message("ProcessGPT 서버 시작")
|
|
43
44
|
|
|
44
45
|
while self.is_running:
|
|
45
46
|
try:
|
|
46
|
-
|
|
47
|
-
if not
|
|
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
|
-
|
|
52
|
-
|
|
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(
|
|
56
|
-
|
|
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(
|
|
59
|
-
|
|
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
|
-
|
|
62
|
+
handle_application_error("작업 처리 오류", job_err, raise_error=False)
|
|
62
63
|
try:
|
|
63
|
-
await update_task_error(str(
|
|
64
|
+
await update_task_error(str(task_id))
|
|
64
65
|
except Exception as upd_err:
|
|
65
|
-
|
|
66
|
+
handle_application_error("FAILED 상태 업데이트 실패", upd_err, raise_error=False)
|
|
66
67
|
# 다음 루프로 진행
|
|
67
68
|
continue
|
|
68
69
|
|
|
69
70
|
except Exception as e:
|
|
70
|
-
|
|
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
|
-
|
|
76
|
+
write_log_message("ProcessGPT 서버 중지")
|
|
76
77
|
|
|
77
|
-
async def _prepare_service_data(self,
|
|
78
|
-
done_outputs = await fetch_done_data(
|
|
79
|
-
|
|
80
|
-
feedbacks =
|
|
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(
|
|
83
|
-
|
|
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(
|
|
86
|
-
|
|
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(
|
|
90
|
-
str(
|
|
90
|
+
str(task_record.get("tool", "")),
|
|
91
|
+
str(task_record.get("tenant_id", ""))
|
|
91
92
|
)
|
|
92
|
-
|
|
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 "",
|
|
96
|
+
done_outputs or [], feedbacks or "", task_record.get("description", "")
|
|
96
97
|
)
|
|
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}")
|
|
98
103
|
|
|
99
104
|
prepared: Dict[str, Any] = {
|
|
100
|
-
"
|
|
101
|
-
"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(
|
|
107
|
-
"message": str(
|
|
108
|
-
"agent_orch": str(
|
|
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,
|
|
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(
|
|
126
|
+
event_queue = ProcessGPTEventQueue(task_record)
|
|
121
127
|
|
|
122
128
|
# 실행 전 컨텍스트 변수 설정 (CrewAI 전역 리스너 등에서 활용)
|
|
123
129
|
try:
|
|
124
130
|
set_context(
|
|
125
|
-
todo_id=str(
|
|
126
|
-
proc_inst_id=str(
|
|
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
|
-
|
|
138
|
+
handle_application_error("컨텍스트 설정 실패", e, raise_error=False)
|
|
132
139
|
|
|
133
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
161
|
+
handle_application_error("컨텍스트 리셋 실패", e, raise_error=False)
|
|
155
162
|
try:
|
|
156
163
|
await event_queue.close()
|
|
157
164
|
except Exception as e:
|
|
158
|
-
|
|
159
|
-
|
|
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,
|
|
162
|
-
todo_id = str(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
+
handle_application_error("실행 태스크 즉시 취소 실패", e, raise_error=False)
|
|
181
188
|
try:
|
|
182
189
|
await event_queue.close()
|
|
183
190
|
except Exception as e:
|
|
184
|
-
|
|
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,
|
|
211
|
-
self.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
|
-
|
|
226
|
+
handle_application_error("이벤트 큐 삽입 실패", e, raise_error=False)
|
|
220
227
|
|
|
221
|
-
self._create_bg_task(
|
|
228
|
+
self._create_bg_task(process_event_message(self.todo, event), "process_event_message")
|
|
222
229
|
except Exception as e:
|
|
223
|
-
|
|
230
|
+
handle_application_error("이벤트 저장 실패", e, raise_error=False)
|
|
224
231
|
|
|
225
232
|
def task_done(self) -> None:
|
|
226
233
|
try:
|
|
227
|
-
|
|
234
|
+
write_log_message(f"태스크 완료: {self.todo['id']}")
|
|
228
235
|
except Exception as e:
|
|
229
|
-
|
|
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
|
-
|
|
247
|
+
handle_application_error(f"백그라운드 태스크 오류({label})", exc, raise_error=False)
|
|
241
248
|
task.add_done_callback(_cb)
|
|
242
249
|
except Exception as e:
|
|
243
|
-
|
|
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, all_users_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 = all_users_var.get() 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
|
+
|