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.
- {process_gpt_agent_sdk-0.1.4/process_gpt_agent_sdk.egg-info → process_gpt_agent_sdk-0.1.6}/PKG-INFO +2 -3
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6/process_gpt_agent_sdk.egg-info}/PKG-INFO +2 -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
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/process_gpt_agent_sdk.egg-info/requires.txt +1 -3
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/core/database.py +137 -2
- process_gpt_agent_sdk-0.1.6/processgpt_agent_sdk/server.py +250 -0
- process_gpt_agent_sdk-0.1.6/processgpt_agent_sdk/tools/human_query_tool.py +203 -0
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/tools/knowledge_tools.py +206 -206
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/tools/safe_tool_loader.py +41 -47
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/utils/context_manager.py +5 -1
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/utils/crewai_event_listener.py +8 -8
- process_gpt_agent_sdk-0.1.6/processgpt_agent_sdk/utils/event_handler.py +66 -0
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/utils/logger.py +6 -6
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/pyproject.toml +2 -4
- process_gpt_agent_sdk-0.1.4/processgpt_agent_sdk/server.py +0 -243
- process_gpt_agent_sdk-0.1.4/processgpt_agent_sdk/utils/event_handler.py +0 -48
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/MANIFEST.in +0 -0
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/README.md +0 -0
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/function.sql +0 -0
- {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
- {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
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/__init__.py +0 -0
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/core/__init__.py +0 -0
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/tools/__init__.py +0 -0
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/utils/__init__.py +0 -0
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/utils/summarizer.py +0 -0
- {process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/setup.cfg +0 -0
{process_gpt_agent_sdk-0.1.4/process_gpt_agent_sdk.egg-info → process_gpt_agent_sdk-0.1.6}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: process-gpt-agent-sdk
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
{process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6/process_gpt_agent_sdk.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: process-gpt-agent-sdk
|
|
3
|
-
Version: 0.1.
|
|
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
|
{process_gpt_agent_sdk-0.1.4 → process_gpt_agent_sdk-0.1.6}/processgpt_agent_sdk/core/database.py
RENAMED
|
@@ -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)
|
|
@@ -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
|
+
|