process-gpt-agent-sdk 0.3.16__py3-none-any.whl → 0.3.18__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.3.16.dist-info → process_gpt_agent_sdk-0.3.18.dist-info}/METADATA +7 -8
- process_gpt_agent_sdk-0.3.18.dist-info/RECORD +8 -0
- processgpt_agent_sdk/__init__.py +7 -15
- processgpt_agent_sdk/database.py +125 -397
- processgpt_agent_sdk/processgpt_agent_framework.py +320 -241
- processgpt_agent_sdk/utils.py +193 -0
- process_gpt_agent_sdk-0.3.16.dist-info/RECORD +0 -7
- {process_gpt_agent_sdk-0.3.16.dist-info → process_gpt_agent_sdk-0.3.18.dist-info}/WHEEL +0 -0
- {process_gpt_agent_sdk-0.3.16.dist-info → process_gpt_agent_sdk-0.3.18.dist-info}/top_level.txt +0 -0
|
@@ -1,111 +1,167 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
3
|
import json
|
|
4
|
-
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import uuid
|
|
5
7
|
from typing import Dict, Any, Optional
|
|
6
8
|
from dataclasses import dataclass
|
|
7
|
-
|
|
8
|
-
import os
|
|
9
|
+
|
|
9
10
|
from dotenv import load_dotenv
|
|
10
11
|
|
|
11
12
|
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
|
12
13
|
from a2a.server.events import EventQueue, Event
|
|
13
|
-
from a2a.types import
|
|
14
|
-
TaskArtifactUpdateEvent,
|
|
15
|
-
TaskState,
|
|
16
|
-
TaskStatusUpdateEvent,
|
|
17
|
-
)
|
|
14
|
+
from a2a.types import TaskArtifactUpdateEvent, TaskState, TaskStatusUpdateEvent
|
|
18
15
|
|
|
19
|
-
# DB 어댑터 사용
|
|
20
16
|
from .database import (
|
|
21
17
|
initialize_db,
|
|
22
18
|
polling_pending_todos,
|
|
23
|
-
|
|
19
|
+
record_events_bulk,
|
|
20
|
+
record_event, # 단건 이벤트 기록
|
|
24
21
|
save_task_result,
|
|
25
22
|
update_task_error,
|
|
26
23
|
get_consumer_id,
|
|
27
|
-
|
|
28
|
-
fetch_all_agents,
|
|
29
|
-
fetch_form_types,
|
|
30
|
-
fetch_tenant_mcp_config,
|
|
31
|
-
fetch_human_users_by_proc_inst_id,
|
|
24
|
+
fetch_context_bundle,
|
|
32
25
|
)
|
|
26
|
+
from .utils import summarize_error_to_user, summarize_feedback
|
|
33
27
|
|
|
34
28
|
load_dotenv()
|
|
35
|
-
|
|
36
29
|
logging.basicConfig(level=logging.INFO)
|
|
37
30
|
logger = logging.getLogger(__name__)
|
|
38
31
|
|
|
32
|
+
# ------------------------------ 커스텀 예외 ------------------------------
|
|
33
|
+
class ContextPreparationError(Exception):
|
|
34
|
+
"""컨텍스트 준비 실패를 상위 경계에서 단일 처리하기 위한 래퍼 예외."""
|
|
35
|
+
def __init__(self, original: Exception, friendly: Optional[str] = None):
|
|
36
|
+
super().__init__(f"{type(original).__name__}: {str(original)}")
|
|
37
|
+
self.original = original
|
|
38
|
+
self.friendly = friendly
|
|
39
|
+
|
|
40
|
+
# ------------------------------ Event Coalescing (env tunable) ------------------------------
|
|
41
|
+
COALESCE_DELAY = float(os.getenv("EVENT_COALESCE_DELAY_SEC", "1.0")) # 최대 지연
|
|
42
|
+
COALESCE_BATCH = int(os.getenv("EVENT_COALESCE_BATCH", "3")) # 즉시 flush 임계치
|
|
43
|
+
|
|
44
|
+
_EVENT_BUF: list[Dict[str, Any]] = []
|
|
45
|
+
_EVENT_TIMER: Optional[asyncio.TimerHandle] = None
|
|
46
|
+
_EVENT_LOCK = asyncio.Lock()
|
|
47
|
+
|
|
48
|
+
async def _flush_events_now():
|
|
49
|
+
"""버퍼된 이벤트를 bulk RPC로 즉시 저장"""
|
|
50
|
+
global _EVENT_BUF, _EVENT_TIMER
|
|
51
|
+
async with _EVENT_LOCK:
|
|
52
|
+
buf = _EVENT_BUF[:]
|
|
53
|
+
_EVENT_BUF.clear()
|
|
54
|
+
if _EVENT_TIMER and not _EVENT_TIMER.cancelled():
|
|
55
|
+
_EVENT_TIMER.cancel()
|
|
56
|
+
_EVENT_TIMER = None
|
|
57
|
+
if not buf:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
logger.info("📤 이벤트 버퍼 플러시 시작 - %d개 이벤트", len(buf))
|
|
61
|
+
# 실제 성공/실패 로깅은 record_events_bulk 내부에서 수행
|
|
62
|
+
await record_events_bulk(buf)
|
|
63
|
+
# 여기서는 시도 사실만 남김(성공처럼 보이는 'flushed' 오해 방지)
|
|
64
|
+
logger.info("🔄 이벤트 버퍼 플러시 시도 완료 - %d개 이벤트", len(buf))
|
|
65
|
+
|
|
66
|
+
def _schedule_delayed_flush():
|
|
67
|
+
global _EVENT_TIMER
|
|
68
|
+
if _EVENT_TIMER is None:
|
|
69
|
+
loop = asyncio.get_running_loop()
|
|
70
|
+
_EVENT_TIMER = loop.call_later(COALESCE_DELAY, lambda: asyncio.create_task(_flush_events_now()))
|
|
71
|
+
|
|
72
|
+
async def enqueue_ui_event_coalesced(payload: Dict[str, Any]):
|
|
73
|
+
"""1초 코얼레싱 / COALESCE_BATCH개 모이면 즉시 플러시 (환경변수로 조절 가능)"""
|
|
74
|
+
global _EVENT_BUF
|
|
75
|
+
to_flush_now = False
|
|
76
|
+
async with _EVENT_LOCK:
|
|
77
|
+
_EVENT_BUF.append(payload)
|
|
78
|
+
logger.info("📥 이벤트 버퍼에 추가 - 현재 %d개 (임계치: %d개)", len(_EVENT_BUF), COALESCE_BATCH)
|
|
79
|
+
if len(_EVENT_BUF) >= COALESCE_BATCH:
|
|
80
|
+
to_flush_now = True
|
|
81
|
+
logger.info("⚡ 임계치 도달 - 즉시 플러시 예정")
|
|
82
|
+
else:
|
|
83
|
+
_schedule_delayed_flush()
|
|
84
|
+
logger.info("⏰ 지연 플러시 스케줄링")
|
|
85
|
+
if to_flush_now:
|
|
86
|
+
await _flush_events_now()
|
|
87
|
+
|
|
88
|
+
# ------------------------------ Request Context ------------------------------
|
|
39
89
|
@dataclass
|
|
40
90
|
class TodoListRowContext:
|
|
41
|
-
"""fetch_pending_task/… 로부터 받은 todolist 행을 감싼 컨텍스트용 DTO"""
|
|
42
91
|
row: Dict[str, Any]
|
|
43
92
|
|
|
44
93
|
class ProcessGPTRequestContext(RequestContext):
|
|
45
|
-
"""DB row(스키마 준수) 기반 RequestContext 구현"""
|
|
46
94
|
def __init__(self, row: Dict[str, Any]):
|
|
47
95
|
self.row = row
|
|
48
|
-
self._user_input = (row.get(
|
|
96
|
+
self._user_input = (row.get("query") or "").strip()
|
|
49
97
|
self._message = self._user_input
|
|
50
98
|
self._current_task = None
|
|
51
|
-
self._task_state = row.get(
|
|
99
|
+
self._task_state = row.get("draft_status") or ""
|
|
52
100
|
self._extra_context: Dict[str, Any] = {}
|
|
53
101
|
|
|
54
102
|
async def prepare_context(self) -> None:
|
|
55
|
-
"""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
103
|
+
"""
|
|
104
|
+
컨텍스트 준비.
|
|
105
|
+
- 실패 시: 더 이상 진행하지 않고 ContextPreparationError를 발생시켜
|
|
106
|
+
상위 경계에서 FAILED 처리(이벤트 기록 포함)를 단일 경로로 수행.
|
|
107
|
+
"""
|
|
108
|
+
logger.info("\n🔧 컨텍스트 준비 시작...")
|
|
109
|
+
|
|
110
|
+
# 1단계: 기본 정보 추출
|
|
111
|
+
effective_proc_inst_id = self.row.get("root_proc_inst_id") or self.row.get("proc_inst_id")
|
|
112
|
+
tool_val = self.row.get("tool") or ""
|
|
113
|
+
tenant_id = self.row.get("tenant_id") or ""
|
|
114
|
+
user_ids = self.row.get("user_id") or ""
|
|
115
|
+
|
|
116
|
+
logger.info("📋 기본 정보 추출 완료 - proc_inst_id: %s, tool: %s, tenant: %s",
|
|
117
|
+
effective_proc_inst_id, tool_val, tenant_id)
|
|
64
118
|
|
|
65
119
|
try:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
notify_emails, tenant_mcp, form_tuple, agents = await asyncio.gather(
|
|
72
|
-
notif_task, mcp_task, form_task, agents_task
|
|
120
|
+
# 2단계: 컨텍스트 번들 조회
|
|
121
|
+
logger.info("🔍 컨텍스트 번들 조회 중...")
|
|
122
|
+
notify_emails, tenant_mcp, form_tuple, agents = await fetch_context_bundle(
|
|
123
|
+
effective_proc_inst_id, tenant_id, tool_val, user_ids
|
|
73
124
|
)
|
|
74
|
-
|
|
75
|
-
if not agents:
|
|
76
|
-
agents = await fetch_all_agents()
|
|
77
|
-
|
|
78
125
|
form_id, form_fields, form_html = form_tuple
|
|
126
|
+
|
|
127
|
+
logger.info("📦 컨텍스트 번들 조회 완료 - agents: %d개, notify_emails: %s, form_type: %s",
|
|
128
|
+
len(agents) if isinstance(agents, list) else 0,
|
|
129
|
+
"있음" if notify_emails else "없음",
|
|
130
|
+
"자유형식" if form_id == "freeform" else "정의된 폼")
|
|
131
|
+
|
|
79
132
|
except Exception as e:
|
|
80
|
-
logger.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
133
|
+
logger.error("❌ 컨텍스트 번들 조회 실패: %s", str(e))
|
|
134
|
+
# 사용자 친화 요약은 상위 경계에서 한 번만 기록하도록 넘김
|
|
135
|
+
raise ContextPreparationError(e)
|
|
136
|
+
|
|
137
|
+
# 3단계: 피드백 요약 처리
|
|
138
|
+
logger.info("📝 피드백 요약 처리 중...")
|
|
139
|
+
feedback_str = self.row.get("feedback", "")
|
|
140
|
+
contents_str = self.row.get("output", "") or self.row.get("draft", "")
|
|
141
|
+
summarized_feedback = ""
|
|
142
|
+
|
|
143
|
+
if feedback_str.strip():
|
|
144
|
+
summarized_feedback = await summarize_feedback(feedback_str, contents_str)
|
|
145
|
+
logger.info("✅ 피드백 요약 완료 - 원본: %d자 → 요약: %d자", len(feedback_str), len(summarized_feedback))
|
|
146
|
+
|
|
147
|
+
# 4단계: 컨텍스트 구성
|
|
148
|
+
logger.info("🏗️ 컨텍스트 구성 중...")
|
|
90
149
|
self._extra_context = {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
150
|
+
"id": self.row.get("id"),
|
|
151
|
+
"proc_inst_id": effective_proc_inst_id,
|
|
152
|
+
"root_proc_inst_id": self.row.get("root_proc_inst_id"),
|
|
153
|
+
"activity_name": self.row.get("activity_name"),
|
|
154
|
+
"agents": agents,
|
|
155
|
+
"tenant_mcp": tenant_mcp,
|
|
156
|
+
"form_fields": form_fields,
|
|
157
|
+
"form_html": form_html,
|
|
158
|
+
"form_id": form_id,
|
|
159
|
+
"notify_user_emails": notify_emails,
|
|
160
|
+
"summarized_feedback": summarized_feedback,
|
|
101
161
|
}
|
|
102
|
-
|
|
103
|
-
logger.info("
|
|
104
|
-
|
|
105
|
-
logger.info(" ⚡ Activity: %s", self.row.get('activity_name'))
|
|
106
|
-
logger.info(" 🏭 Process: %s", self.row.get('proc_inst_id'))
|
|
107
|
-
logger.info(" 🏢 Tenant: %s", self.row.get('tenant_id'))
|
|
108
|
-
logger.info("")
|
|
162
|
+
|
|
163
|
+
logger.info("✅ 컨텍스트 준비 완료! (agents=%d개)",
|
|
164
|
+
len(agents) if isinstance(agents, list) else 0)
|
|
109
165
|
|
|
110
166
|
def get_user_input(self) -> str:
|
|
111
167
|
return self._user_input
|
|
@@ -127,14 +183,10 @@ class ProcessGPTRequestContext(RequestContext):
|
|
|
127
183
|
return self._task_state
|
|
128
184
|
|
|
129
185
|
def get_context_data(self) -> Dict[str, Any]:
|
|
130
|
-
return {
|
|
131
|
-
'row': self.row,
|
|
132
|
-
'extras': self._extra_context,
|
|
133
|
-
}
|
|
186
|
+
return {"row": self.row, "extras": self._extra_context}
|
|
134
187
|
|
|
188
|
+
# ------------------------------ Event Queue ------------------------------
|
|
135
189
|
class ProcessGPTEventQueue(EventQueue):
|
|
136
|
-
"""Events 테이블에 이벤트를 저장하는 EventQueue 구현 (database.record_event 사용)"""
|
|
137
|
-
|
|
138
190
|
def __init__(self, todolist_id: str, agent_orch: str, proc_inst_id: Optional[str]):
|
|
139
191
|
self.todolist_id = todolist_id
|
|
140
192
|
self.agent_orch = agent_orch
|
|
@@ -142,106 +194,78 @@ class ProcessGPTEventQueue(EventQueue):
|
|
|
142
194
|
super().__init__()
|
|
143
195
|
|
|
144
196
|
def enqueue_event(self, event: Event):
|
|
145
|
-
"""A2A 이벤트 처리"""
|
|
146
197
|
try:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
logger.info(" 🔄 Process: %s", proc_inst_id_val)
|
|
153
|
-
|
|
154
|
-
# 1) 기본 매핑: Artifact → todolist 저장 (오직 결과물만). 실패해도 진행
|
|
198
|
+
proc_inst_id_val = getattr(event, "contextId", None) or self.proc_inst_id
|
|
199
|
+
todo_id_val = getattr(event, "taskId", None) or str(self.todolist_id)
|
|
200
|
+
logger.info("\n📨 이벤트 수신: %s (task=%s)", type(event).__name__, self.todolist_id)
|
|
201
|
+
|
|
202
|
+
# 1) 결과물 저장
|
|
155
203
|
if isinstance(event, TaskArtifactUpdateEvent):
|
|
204
|
+
logger.info("📄 아티팩트 업데이트 이벤트 처리 중...")
|
|
156
205
|
try:
|
|
157
206
|
is_final = bool(
|
|
158
|
-
getattr(event,
|
|
159
|
-
or getattr(event,
|
|
160
|
-
or getattr(event,
|
|
161
|
-
or getattr(event,
|
|
207
|
+
getattr(event, "final", None)
|
|
208
|
+
or getattr(event, "lastChunk", None)
|
|
209
|
+
or getattr(event, "last_chunk", None)
|
|
210
|
+
or getattr(event, "last", None)
|
|
162
211
|
)
|
|
163
212
|
artifact_content = self._extract_payload(event)
|
|
164
|
-
logger.info("
|
|
165
|
-
logger.info(" 📋 Task: %s", self.todolist_id)
|
|
166
|
-
logger.info(" 🏁 Final: %s", "예" if is_final else "아니오")
|
|
213
|
+
logger.info("💾 아티팩트 저장 중... (final=%s)", is_final)
|
|
167
214
|
asyncio.create_task(save_task_result(self.todolist_id, artifact_content, is_final))
|
|
168
|
-
logger.info("
|
|
215
|
+
logger.info("✅ 아티팩트 저장 완료")
|
|
169
216
|
except Exception as e:
|
|
170
|
-
logger.exception(
|
|
171
|
-
"enqueue_event artifact save failed (todolist_id=%s, proc_inst_id=%s, event=%s): %s",
|
|
172
|
-
self.todolist_id,
|
|
173
|
-
self.proc_inst_id,
|
|
174
|
-
type(event).__name__,
|
|
175
|
-
str(e),
|
|
176
|
-
)
|
|
217
|
+
logger.exception("❌ 아티팩트 저장 실패: %s", str(e))
|
|
177
218
|
return
|
|
178
219
|
|
|
179
|
-
# 2)
|
|
220
|
+
# 2) 상태 이벤트 저장(코얼레싱 → bulk)
|
|
180
221
|
if isinstance(event, TaskStatusUpdateEvent):
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
222
|
+
logger.info("📊 상태 업데이트 이벤트 처리 중...")
|
|
223
|
+
metadata = getattr(event, "metadata", None) or {}
|
|
224
|
+
crew_type_val = metadata.get("crew_type")
|
|
225
|
+
status_obj = getattr(event, "status", None)
|
|
226
|
+
state_val = getattr(status_obj, "state", None)
|
|
227
|
+
event_type_val = {TaskState.input_required: "human_asked"}.get(state_val) or metadata.get("event_type")
|
|
228
|
+
status_val = metadata.get("status")
|
|
229
|
+
job_id_val = metadata.get("job_id")
|
|
230
|
+
|
|
231
|
+
logger.info("🔍 이벤트 메타데이터 분석 - event_type: %s, status: %s", event_type_val, status_val)
|
|
232
|
+
|
|
191
233
|
try:
|
|
192
234
|
payload: Dict[str, Any] = {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
235
|
+
"id": str(uuid.uuid4()),
|
|
236
|
+
"job_id": job_id_val,
|
|
237
|
+
"todo_id": str(todo_id_val),
|
|
238
|
+
"proc_inst_id": proc_inst_id_val,
|
|
239
|
+
"crew_type": crew_type_val,
|
|
240
|
+
"event_type": event_type_val,
|
|
241
|
+
"data": self._extract_payload(event),
|
|
242
|
+
"status": status_val or None,
|
|
201
243
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
logger.info("
|
|
205
|
-
logger.info(" 🆔 Job: %s", job_id_val or 'N/A')
|
|
206
|
-
logger.info(" 🏷️ Type: %s", crew_type_val or 'N/A')
|
|
207
|
-
logger.info(" 🔍 Event: %s", event_type_val or 'N/A')
|
|
208
|
-
asyncio.create_task(record_event(payload))
|
|
209
|
-
logger.info(" ✅ 기록 완료!\n")
|
|
244
|
+
logger.info("📤 상태 이벤트 큐에 추가 중...")
|
|
245
|
+
asyncio.create_task(enqueue_ui_event_coalesced(payload))
|
|
246
|
+
logger.info("✅ 상태 이벤트 큐 추가 완료")
|
|
210
247
|
except Exception as e:
|
|
211
|
-
logger.exception(
|
|
212
|
-
"enqueue_event status record failed (todolist_id=%s, proc_inst_id=%s, job_id=%s, crew_type=%s): %s",
|
|
213
|
-
self.todolist_id,
|
|
214
|
-
self.proc_inst_id,
|
|
215
|
-
job_id_val,
|
|
216
|
-
crew_type_val,
|
|
217
|
-
str(e),
|
|
218
|
-
)
|
|
248
|
+
logger.exception("❌ 상태 이벤트 기록 실패: %s", str(e))
|
|
219
249
|
return
|
|
220
250
|
|
|
221
251
|
except Exception as e:
|
|
222
|
-
logger.error(
|
|
252
|
+
logger.error("❌ 이벤트 처리 실패: %s", str(e))
|
|
223
253
|
raise
|
|
224
254
|
|
|
225
255
|
def _extract_payload(self, event: Event) -> Any:
|
|
226
|
-
"""이벤트에서 실질 페이로드를 추출한다."""
|
|
227
256
|
try:
|
|
228
|
-
artifact_or_none = getattr(event,
|
|
229
|
-
status_or_none = getattr(event,
|
|
230
|
-
message_or_none = getattr(status_or_none,
|
|
231
|
-
|
|
257
|
+
artifact_or_none = getattr(event, "artifact", None)
|
|
258
|
+
status_or_none = getattr(event, "status", None)
|
|
259
|
+
message_or_none = getattr(status_or_none, "message", None)
|
|
232
260
|
source = artifact_or_none if artifact_or_none is not None else message_or_none
|
|
233
261
|
return self._parse_json_or_text(source)
|
|
234
262
|
except Exception:
|
|
235
263
|
return {}
|
|
236
264
|
|
|
237
265
|
def _parse_json_or_text(self, value: Any) -> Any:
|
|
238
|
-
"""간소화: new_* 유틸 출력(dict)과 문자열만 처리하여 순수 payload 반환."""
|
|
239
266
|
try:
|
|
240
|
-
# 1) None → 빈 구조
|
|
241
267
|
if value is None:
|
|
242
268
|
return {}
|
|
243
|
-
|
|
244
|
-
# 2) 문자열이면 JSON 파싱 시도
|
|
245
269
|
if isinstance(value, str):
|
|
246
270
|
text = value.strip()
|
|
247
271
|
if not text:
|
|
@@ -250,153 +274,208 @@ class ProcessGPTEventQueue(EventQueue):
|
|
|
250
274
|
return json.loads(text)
|
|
251
275
|
except Exception:
|
|
252
276
|
return text
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
except Exception:
|
|
259
|
-
pass
|
|
260
|
-
try:
|
|
261
|
-
if not isinstance(value, dict) and hasattr(value, "dict") and callable(getattr(value, "dict")):
|
|
262
|
-
value = value.dict()
|
|
263
|
-
except Exception:
|
|
264
|
-
pass
|
|
265
|
-
if not isinstance(value, dict) and hasattr(value, "__dict__"):
|
|
277
|
+
if hasattr(value, "model_dump") and callable(getattr(value, "model_dump")):
|
|
278
|
+
value = value.model_dump()
|
|
279
|
+
elif not isinstance(value, dict) and hasattr(value, "dict") and callable(getattr(value, "dict")):
|
|
280
|
+
value = value.dict()
|
|
281
|
+
elif not isinstance(value, dict) and hasattr(value, "__dict__"):
|
|
266
282
|
value = value.__dict__
|
|
267
|
-
|
|
268
|
-
# 4) dict만 대상으로 parts[0].text → parts[0].root.text → top-level text/content/data 순으로 추출
|
|
269
283
|
if isinstance(value, dict):
|
|
270
284
|
parts = value.get("parts")
|
|
271
285
|
if isinstance(parts, list) and parts:
|
|
272
286
|
first = parts[0] if isinstance(parts[0], dict) else None
|
|
273
|
-
if isinstance(first, dict):
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
text_candidate = (
|
|
281
|
-
root.get("text") or root.get("content") or root.get("data")
|
|
282
|
-
)
|
|
283
|
-
if isinstance(text_candidate, str):
|
|
284
|
-
return self._parse_json_or_text(text_candidate)
|
|
287
|
+
if first and isinstance(first, dict):
|
|
288
|
+
txt = first.get("text") or first.get("content") or first.get("data")
|
|
289
|
+
if isinstance(txt, str):
|
|
290
|
+
try:
|
|
291
|
+
return json.loads(txt)
|
|
292
|
+
except Exception:
|
|
293
|
+
return txt
|
|
285
294
|
top_text = value.get("text") or value.get("content") or value.get("data")
|
|
286
295
|
if isinstance(top_text, str):
|
|
287
|
-
|
|
296
|
+
try:
|
|
297
|
+
return json.loads(top_text)
|
|
298
|
+
except Exception:
|
|
299
|
+
return top_text
|
|
288
300
|
return value
|
|
289
|
-
|
|
290
|
-
# 5) 그 외 타입은 원형 반환
|
|
291
301
|
return value
|
|
292
302
|
except Exception:
|
|
293
303
|
return {}
|
|
294
304
|
|
|
295
305
|
def task_done(self) -> None:
|
|
296
306
|
try:
|
|
307
|
+
logger.info("🏁 작업 완료 이벤트 생성 중...")
|
|
297
308
|
payload: Dict[str, Any] = {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
309
|
+
"id": str(uuid.uuid4()),
|
|
310
|
+
"job_id": "CREW_FINISHED",
|
|
311
|
+
"todo_id": str(self.todolist_id),
|
|
312
|
+
"proc_inst_id": self.proc_inst_id,
|
|
313
|
+
"crew_type": "agent",
|
|
314
|
+
"data": "Task completed successfully",
|
|
315
|
+
"event_type": "crew_completed",
|
|
316
|
+
"status": None,
|
|
306
317
|
}
|
|
307
|
-
|
|
308
|
-
|
|
318
|
+
logger.info("📤 작업 완료 이벤트 큐에 추가 중...")
|
|
319
|
+
asyncio.create_task(enqueue_ui_event_coalesced(payload))
|
|
320
|
+
logger.info("✅ 작업 완료 이벤트 기록 완료")
|
|
309
321
|
except Exception as e:
|
|
310
|
-
logger.error(
|
|
322
|
+
logger.error("❌ 작업 완료 이벤트 기록 실패: %s", str(e))
|
|
311
323
|
raise
|
|
312
324
|
|
|
325
|
+
# ------------------------------ Agent Server ------------------------------
|
|
313
326
|
class ProcessGPTAgentServer:
|
|
314
|
-
"""DB 폴링 기반 Agent Server (database.py 사용)"""
|
|
315
|
-
|
|
316
327
|
def __init__(self, agent_executor: AgentExecutor, agent_type: str):
|
|
317
328
|
self.agent_executor = agent_executor
|
|
318
329
|
self.agent_orch = agent_type
|
|
319
|
-
self.polling_interval = 5 # seconds
|
|
320
330
|
self.is_running = False
|
|
331
|
+
self._shutdown_event = asyncio.Event()
|
|
332
|
+
self._current_todo_id: Optional[str] = None # 진행 중 작업 추적(참고용)
|
|
333
|
+
|
|
334
|
+
async def _install_signal_handlers(self):
|
|
335
|
+
loop = asyncio.get_running_loop()
|
|
336
|
+
try:
|
|
337
|
+
loop.add_signal_handler(signal.SIGTERM, lambda: self._shutdown_event.set())
|
|
338
|
+
loop.add_signal_handler(signal.SIGINT, lambda: self._shutdown_event.set())
|
|
339
|
+
except NotImplementedError:
|
|
340
|
+
# Windows 등 일부 환경은 지원 안 됨
|
|
341
|
+
pass
|
|
321
342
|
|
|
322
343
|
async def run(self):
|
|
323
|
-
"""메인 실행 루프"""
|
|
324
344
|
self.is_running = True
|
|
325
|
-
logger.info("\n\n🚀
|
|
326
|
-
logger.info(" ProcessGPT Agent Server STARTED")
|
|
327
|
-
logger.info(f" Agent Type: {self.agent_orch}")
|
|
328
|
-
logger.info("===============================================\n")
|
|
345
|
+
logger.info("\n\n🚀 ProcessGPT Agent Server START (agent=%s)\n", self.agent_orch)
|
|
329
346
|
initialize_db()
|
|
330
|
-
|
|
331
|
-
|
|
347
|
+
await self._install_signal_handlers()
|
|
348
|
+
|
|
349
|
+
while self.is_running and not self._shutdown_event.is_set():
|
|
332
350
|
try:
|
|
333
|
-
logger.info("
|
|
351
|
+
logger.info("🔍 Polling for tasks (agent_orch=%s)...", self.agent_orch)
|
|
334
352
|
row = await polling_pending_todos(self.agent_orch, get_consumer_id())
|
|
335
353
|
|
|
336
354
|
if row:
|
|
337
|
-
logger.info("
|
|
338
|
-
logger.info("📋 Task ID: %s", row.get('id'))
|
|
339
|
-
logger.info("🔄 Process: %s", row.get('proc_inst_id'))
|
|
340
|
-
logger.info("⚡ Activity: %s", row.get('activity_name'))
|
|
341
|
-
logger.info("")
|
|
355
|
+
logger.info("✅ 새 작업: %s (proc=%s, activity=%s)", row.get("id"), row.get("proc_inst_id"), row.get("activity_name"))
|
|
342
356
|
try:
|
|
357
|
+
self._current_todo_id = str(row.get("id"))
|
|
343
358
|
await self.process_todolist_item(row)
|
|
344
359
|
except Exception as e:
|
|
360
|
+
# 경계에서 처리(에러 이벤트 + FAILED 마킹) 후 예외 재전달됨.
|
|
345
361
|
logger.exception("process_todolist_item failed: %s", str(e))
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
362
|
+
finally:
|
|
363
|
+
self._current_todo_id = None
|
|
364
|
+
# 작업이 있었으므로 슬립 생략 → 즉시 다음 폴링
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
# 작업 없을 때만 10초 대기
|
|
368
|
+
await asyncio.sleep(10)
|
|
351
369
|
|
|
352
370
|
except Exception as e:
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
371
|
+
# 폴링 자체 오류는 특정 작업에 귀속되지 않으므로 상태 마킹 대상 없음
|
|
372
|
+
logger.exception("run loop error: %s", str(e))
|
|
373
|
+
await asyncio.sleep(10)
|
|
374
|
+
|
|
375
|
+
# 종료 시 남은 이벤트 강제 flush (오류로 간주하지 않음)
|
|
376
|
+
try:
|
|
377
|
+
await _flush_events_now()
|
|
378
|
+
logger.info("🧹 graceful shutdown: pending events flushed")
|
|
379
|
+
except Exception as e:
|
|
380
|
+
logger.exception("flush on shutdown failed: %s", str(e))
|
|
381
|
+
|
|
382
|
+
logger.info("👋 Agent server stopped.")
|
|
359
383
|
|
|
360
384
|
async def process_todolist_item(self, row: Dict[str, Any]):
|
|
361
|
-
"""
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
385
|
+
"""
|
|
386
|
+
경계 정책(최종본):
|
|
387
|
+
- 어떤 예외든 여기에서 잡힘
|
|
388
|
+
- 항상 단일 경로로:
|
|
389
|
+
1) 사용자 친화 5줄 설명 생성
|
|
390
|
+
2) event_type='error' 단건 이벤트 기록
|
|
391
|
+
3) todolist를 FAILED로 마킹
|
|
392
|
+
4) 예외 재전달(상위 루프는 죽지 않고 다음 폴링)
|
|
393
|
+
"""
|
|
394
|
+
task_id = row.get("id")
|
|
395
|
+
logger.info("\n🎯 작업 처리 시작 - Task ID: %s", task_id)
|
|
396
|
+
logger.info("📝 작업 정보 - proc_inst_id: %s, activity: %s, tool: %s",
|
|
397
|
+
row.get("proc_inst_id"), row.get("activity_name"), row.get("tool"))
|
|
367
398
|
|
|
399
|
+
friendly_text: Optional[str] = None
|
|
400
|
+
|
|
368
401
|
try:
|
|
402
|
+
# 1) 컨텍스트 준비 (실패 시 ContextPreparationError로 올라옴)
|
|
403
|
+
logger.info("🔧 컨텍스트 준비 단계 시작...")
|
|
369
404
|
context = ProcessGPTRequestContext(row)
|
|
370
405
|
await context.prepare_context()
|
|
371
|
-
|
|
406
|
+
logger.info("✅ 컨텍스트 준비 완료")
|
|
407
|
+
|
|
408
|
+
# 2) 실행
|
|
409
|
+
logger.info("🤖 에이전트 실행 단계 시작...")
|
|
410
|
+
event_queue = ProcessGPTEventQueue(str(task_id), self.agent_orch, row.get("proc_inst_id"))
|
|
372
411
|
await self.agent_executor.execute(context, event_queue)
|
|
412
|
+
logger.info("✅ 에이전트 실행 완료")
|
|
413
|
+
|
|
414
|
+
# 3) 정상 완료 이벤트
|
|
415
|
+
logger.info("🏁 작업 완료 처리 중...")
|
|
373
416
|
event_queue.task_done()
|
|
374
|
-
logger.info("
|
|
375
|
-
logger.info("✨ Task ID: %s", row.get('id'))
|
|
376
|
-
logger.info("=" * 45 + "\n\n")
|
|
377
|
-
|
|
378
|
-
except Exception as e:
|
|
379
|
-
logger.exception(
|
|
380
|
-
"process_todolist_item error (todolist_id=%s, proc_inst_id=%s): %s",
|
|
381
|
-
row.get('id'),
|
|
382
|
-
row.get('proc_inst_id'),
|
|
383
|
-
str(e),
|
|
384
|
-
)
|
|
385
|
-
await self.mark_task_failed(str(row.get('id')), str(e))
|
|
386
|
-
raise
|
|
417
|
+
logger.info("🎉 작업 완료: %s\n", task_id)
|
|
387
418
|
|
|
388
|
-
async def mark_task_failed(self, todolist_id: str, error_message: str):
|
|
389
|
-
"""태스크 실패 처리 (DB 상태 업데이트)"""
|
|
390
|
-
try:
|
|
391
|
-
await update_task_error(todolist_id)
|
|
392
419
|
except Exception as e:
|
|
393
|
-
logger.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
420
|
+
logger.error("❌ 작업 처리 중 오류 발생: %s", str(e))
|
|
421
|
+
|
|
422
|
+
# 컨텍스트 실패라면 friendly가 없을 수 있어, 여기서 반드시 생성
|
|
423
|
+
try:
|
|
424
|
+
logger.info("📝 사용자 친화 오류 메시지 생성 중...")
|
|
425
|
+
if isinstance(e, ContextPreparationError) and e.friendly:
|
|
426
|
+
friendly_text = e.friendly
|
|
427
|
+
else:
|
|
428
|
+
friendly_text = await summarize_error_to_user(
|
|
429
|
+
e if not isinstance(e, ContextPreparationError) else e.original,
|
|
430
|
+
{
|
|
431
|
+
"task_id": task_id,
|
|
432
|
+
"proc_inst_id": row.get("proc_inst_id"),
|
|
433
|
+
"agent_orch": self.agent_orch,
|
|
434
|
+
"tool": row.get("tool"),
|
|
435
|
+
},
|
|
436
|
+
)
|
|
437
|
+
logger.info("✅ 사용자 친화 오류 메시지 생성 완료")
|
|
438
|
+
except Exception:
|
|
439
|
+
logger.warning("⚠️ 사용자 친화 오류 메시지 생성 실패")
|
|
440
|
+
# 요약 생성 실패 시에도 처리 계속
|
|
441
|
+
friendly_text = None
|
|
442
|
+
|
|
443
|
+
# 에러 이벤트 기록(단건). 실패해도 로그만 남기고 진행.
|
|
444
|
+
logger.info("📤 오류 이벤트 기록 중...")
|
|
445
|
+
payload: Dict[str, Any] = {
|
|
446
|
+
"id": str(uuid.uuid4()),
|
|
447
|
+
"job_id": "TASK_ERROR",
|
|
448
|
+
"todo_id": str(task_id),
|
|
449
|
+
"proc_inst_id": row.get("proc_inst_id"),
|
|
450
|
+
"crew_type": "agent",
|
|
451
|
+
"event_type": "error",
|
|
452
|
+
"data": {
|
|
453
|
+
"name": "시스템 오류 알림",
|
|
454
|
+
"goal": "오류 원인과 대처 안내를 전달합니다.",
|
|
455
|
+
"agent_profile": "/images/chat-icon.png",
|
|
456
|
+
"friendly": friendly_text or "처리 중 오류가 발생했습니다. 로그를 확인해 주세요.",
|
|
457
|
+
"raw_error": f"{type(e).__name__}: {str(e)}" if not isinstance(e, ContextPreparationError) else f"{type(e.original).__name__}: {str(e.original)}",
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
try:
|
|
461
|
+
asyncio.create_task(record_event(payload))
|
|
462
|
+
logger.info("✅ 오류 이벤트 기록 완료")
|
|
463
|
+
except Exception:
|
|
464
|
+
logger.exception("❌ 오류 이벤트 기록 실패")
|
|
465
|
+
|
|
466
|
+
# 상태 FAILED 마킹
|
|
467
|
+
logger.info("🏷️ 작업 상태 FAILED로 마킹 중...")
|
|
468
|
+
try:
|
|
469
|
+
await update_task_error(str(task_id))
|
|
470
|
+
logger.info("✅ 작업 상태 FAILED 마킹 완료")
|
|
471
|
+
except Exception:
|
|
472
|
+
logger.exception("❌ 작업 상태 FAILED 마킹 실패")
|
|
473
|
+
|
|
474
|
+
# 상위로 재전달하여 루프는 계속(죽지 않음)
|
|
475
|
+
logger.error("🔄 오류 처리 완료 - 다음 작업으로 계속 진행")
|
|
476
|
+
raise
|
|
398
477
|
|
|
399
478
|
def stop(self):
|
|
400
|
-
"""서버 중지"""
|
|
401
479
|
self.is_running = False
|
|
402
|
-
|
|
480
|
+
self._shutdown_event.set()
|
|
481
|
+
logger.info("ProcessGPT Agent Server stopping...")
|