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

@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: process-gpt-agent-sdk
3
+ Version: 0.3.4
4
+ Summary: Supabase 기반 이벤트/작업 폴링으로 A2A AgentExecutor를 실행하는 SDK
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/your-org/process-gpt-agent-sdk
7
+ Project-URL: Issues, https://github.com/your-org/process-gpt-agent-sdk/issues
8
+ Keywords: agent,a2a,supabase,workflow,sdk,processgpt
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: supabase>=2.0.0
16
+ Requires-Dist: python-dotenv>=1.0.0
17
+ Requires-Dist: a2a-sdk==0.3.0
18
+ Requires-Dist: typing-extensions>=4.0.0
19
+ Requires-Dist: process-gpt-llm-factory==1.0.2
20
+ Requires-Dist: pydantic>=2.0.0
21
+ Requires-Dist: requests>=2.25.0
22
+
23
+ # 📘 ProcessGPT Agent SDK – README
24
+
25
+ ## 1. 이게 뭐하는 건가요?
26
+ 이 SDK는 **ProcessGPT 에이전트 서버**를 만들 때 필요한 **공통 기능**을 제공합니다.
27
+
28
+ - DB에서 **작업(todo) 폴링** → 처리할 일감 가져오기
29
+ - **컨텍스트 준비** (사용자 정보, 폼 정의, MCP 설정 등 자동으로 조회)
30
+ - 다양한 **에이전트 오케스트레이션(A2A)** 과 호환
31
+ - **이벤트(Event) 전송 규격 통일화** → 결과를 DB에 안전하게 저장
32
+
33
+ 👉 쉽게 말하면: **여러 종류의 AI 에이전트를 같은 규칙으로 실행/저장/호출할 수 있게 해주는 통합 SDK** 입니다.
34
+
35
+ ---
36
+
37
+ ## 2. 아키텍처 다이어그램
38
+ ```mermaid
39
+ flowchart TD
40
+ subgraph DB[Postgres/Supabase]
41
+ T[todolist]:::db
42
+ E[events]:::db
43
+ end
44
+
45
+ subgraph SDK
46
+ P[Polling\n(fetch_pending_task)] --> C[Context 준비\n(fetch_context_bundle 등)]
47
+ C --> X[Executor\n(MinimalExecutor)]
48
+ X -->|TaskStatusUpdateEvent| E
49
+ X -->|TaskArtifactUpdateEvent| T
50
+ end
51
+
52
+ classDef db fill=#f2f2f2,stroke=#333,stroke-width=1px;
53
+ ```
54
+
55
+ - **todolist**: 각 작업(Task)의 진행 상태, 결과물 저장
56
+ - **events**: 실행 중간에 발생한 이벤트 로그 저장
57
+ - SDK는 두 테이블을 자동으로 연결해 줍니다.
58
+
59
+ ---
60
+
61
+ ## 3. A2A 타입과 이벤트 종류
62
+
63
+ ### A2A 타입 (2가지)
64
+ | A2A 타입 | 설명 | 매칭 테이블 |
65
+ |----------|------|-------------|
66
+ | **TaskStatusUpdateEvent** | 작업 상태 업데이트 | `events` 테이블 |
67
+ | **TaskArtifactUpdateEvent** | 작업 결과물 업데이트 | `todolist` 테이블 |
68
+
69
+ ### Event Type (4가지)
70
+ | Event Type | Python 클래스 | 저장 테이블 | 설명 |
71
+ |------------|---------------|-------------|------|
72
+ | **task_started** | `TaskStatusUpdateEvent` | `events` | 작업 시작 상태 |
73
+ | **task_working** | `TaskStatusUpdateEvent` | `events` | 작업 진행 중 상태 |
74
+ | **task_completed** | `TaskArtifactUpdateEvent` | `todolist` | 작업 완료 및 결과물 저장 |
75
+ | **task_error** | `TaskStatusUpdateEvent` | `events` | 작업 오류 발생 |
76
+
77
+ 👉 **A2A 타입 2가지**가 핵심이며, 각각 `events`와 `todolist` 테이블에 매칭됩니다. **Event Type 4가지**로 세부 상태를 구분합니다.
78
+
79
+ ---
80
+
81
+ ## 4. 미니멀 예제 (기본 사용법)
82
+
83
+ ### minimal_executor.py
84
+ ```python
85
+ class MinimalExecutor(AgentExecutor):
86
+ async def execute(self, context: RequestContext, event_queue: EventQueue):
87
+ # 1) 입력 가져오기
88
+ query = context.get_user_input()
89
+ print("User Query:", query)
90
+
91
+ # 2) 상태 이벤트 (events 테이블 저장)
92
+ payload = {"demo": "hello world"}
93
+ event_queue.enqueue_event(
94
+ TaskStatusUpdateEvent(
95
+ status={
96
+ "state": TaskState.working,
97
+ "message": new_agent_text_message(
98
+ json.dumps(payload, ensure_ascii=False), # ⚠️ str() 쓰지말고 반드시 json.dumps!
99
+ context.get_context_data()["row"]["proc_inst_id"],
100
+ context.get_context_data()["row"]["id"],
101
+ ),
102
+ },
103
+ contextId=context.get_context_data()["row"]["proc_inst_id"],
104
+ taskId=context.get_context_data()["row"]["id"],
105
+ metadata={"crew_type": "action", "event_type": "task_started"},
106
+ )
107
+ )
108
+
109
+ # 3) 최종 아티팩트 이벤트 (todolist 테이블 저장)
110
+ artifact = new_text_artifact(
111
+ name="result",
112
+ description="Demo Result",
113
+ text=json.dumps(payload, ensure_ascii=False), # ⚠️ 여기서도 str() 금지!
114
+ )
115
+ event_queue.enqueue_event(
116
+ TaskArtifactUpdateEvent(
117
+ artifact=artifact,
118
+ lastChunk=True,
119
+ contextId=context.get_context_data()["row"]["proc_inst_id"],
120
+ taskId=context.get_context_data()["row"]["id"],
121
+ )
122
+ )
123
+ ```
124
+
125
+ ### minimal_server.py
126
+ ```python
127
+ async def main():
128
+ load_dotenv()
129
+ server = ProcessGPTAgentServer(
130
+ agent_executor=MinimalExecutor(),
131
+ agent_type="crewai-action" # 오케스트레이터 타입
132
+ )
133
+ await server.run()
134
+ ```
135
+
136
+ 👉 실행하면 SDK가 자동으로:
137
+ 1. DB에서 작업 하나 가져오기 (`fetch_pending_task`)
138
+ 2. 컨텍스트 준비 (폼/유저/MCP 조회)
139
+ 3. Executor 실행 → 이벤트/결과 DB에 저장
140
+
141
+ ---
142
+
143
+ ## 5. ⚠️ JSON 직렬화 주의 (str() 절대 금지)
144
+
145
+ 반드시 `json.dumps()`로 직렬화해야 합니다.
146
+
147
+ - ❌ 이렇게 하면 안됨:
148
+ ```python
149
+ text = str({"key": "value"}) # Python dict string → JSON 아님
150
+ ```
151
+ DB에 `"'{key: value}'"` 꼴로 문자열 저장됨 → 파싱 실패
152
+
153
+ - ✅ 이렇게 해야 함:
154
+ ```python
155
+ text = json.dumps({"key": "value"}, ensure_ascii=False)
156
+ ```
157
+ DB에 `{"key": "value"}` JSON 저장됨 → 파싱 성공
158
+
159
+ 👉 **SDK는 내부에서 `json.loads`로 재파싱**하기 때문에, 표준 JSON 문자열이 아니면 무조건 문자열로만 남습니다.
160
+
161
+ ---
162
+
163
+ ## 6. 요약
164
+ - 이 SDK는 **ProcessGPT Agent**를 표준 규격으로 실행/저장/호출하는 공통 레이어
165
+ - 작업 → 컨텍스트 준비 → Executor 실행 → 이벤트 저장 전체를 자동화
166
+ - **A2A 타입 2가지**: `TaskStatusUpdateEvent`, `TaskArtifactUpdateEvent`
167
+ - **Event Type 4가지**: `task_started`, `task_working`, `task_completed`, `task_error`
168
+ - **DB 매핑**:
169
+ - `TaskStatusUpdateEvent` → `events` 테이블
170
+ - `TaskArtifactUpdateEvent` → `todolist` 테이블
171
+ - ⚠️ **str() 대신 무조건 `json.dumps` 사용!**
172
+
173
+
174
+
175
+ ## 7. 버전업
176
+ - ./release.sh 버전
177
+ - 오류 발생시 : python -m ensurepip --upgrade
@@ -0,0 +1,8 @@
1
+ processgpt_agent_sdk/__init__.py,sha256=4-Wt-FRImZa1tG4CWx2ZtICcnTkKoAiqAfva9vLitLQ,991
2
+ processgpt_agent_sdk/database.py,sha256=4oQxJ3y28pPdUpvcQ80DEcFmcvKH37RszhSSZKf6tJU,12690
3
+ processgpt_agent_sdk/processgpt_agent_framework.py,sha256=xsQnPXTnIjCkE5jYDVg9TZC-QiyLF0DmXHTvu-oMXa0,23080
4
+ processgpt_agent_sdk/utils.py,sha256=pK7plUKGMIHo5lA0e4442qMAmAtCwKrbewjrXdVbFdE,9228
5
+ process_gpt_agent_sdk-0.3.4.dist-info/METADATA,sha256=NR2O7M7WttHlU9oGJbRgFv3vcdt4pXSkIfWUOkE_ySU,6840
6
+ process_gpt_agent_sdk-0.3.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ process_gpt_agent_sdk-0.3.4.dist-info/top_level.txt,sha256=Xe6zrj3_3Vv7d0pl5RRtenVUckwOVBVLQn2P03j5REo,21
8
+ process_gpt_agent_sdk-0.3.4.dist-info/RECORD,,
@@ -1,11 +1,43 @@
1
- from .server import ProcessGPTAgentServer, ProcessGPTRequestContext, ProcessGPTEventQueue
2
- from .simulator import ProcessGPTAgentSimulator, SimulatorRequestContext, SimulatorEventQueue
3
-
4
- __all__ = [
5
- "ProcessGPTAgentServer",
6
- "ProcessGPTRequestContext",
7
- "ProcessGPTEventQueue",
8
- "ProcessGPTAgentSimulator",
9
- "SimulatorRequestContext",
10
- "SimulatorEventQueue",
11
- ]
1
+ from .processgpt_agent_framework import (
2
+ ProcessGPTAgentServer,
3
+ ProcessGPTRequestContext,
4
+ ProcessGPTEventQueue,
5
+ ContextPreparationError,
6
+ )
7
+ from .database import (
8
+ initialize_db,
9
+ get_consumer_id,
10
+ polling_pending_todos,
11
+ record_event,
12
+ record_events_bulk,
13
+ save_task_result,
14
+ update_task_error,
15
+ fetch_form_def,
16
+ fetch_users_grouped,
17
+ fetch_email_users_by_proc_inst_id,
18
+ fetch_tenant_mcp,
19
+ )
20
+ from .utils import (
21
+ summarize_error_to_user,
22
+ summarize_feedback,
23
+ )
24
+
25
+ __all__ = [
26
+ "ProcessGPTAgentServer",
27
+ "ProcessGPTRequestContext",
28
+ "ProcessGPTEventQueue",
29
+ "ContextPreparationError",
30
+ "initialize_db",
31
+ "get_consumer_id",
32
+ "polling_pending_todos",
33
+ "record_event",
34
+ "record_events_bulk",
35
+ "save_task_result",
36
+ "update_task_error",
37
+ "fetch_form_def",
38
+ "fetch_users_grouped",
39
+ "fetch_email_users_by_proc_inst_id",
40
+ "fetch_tenant_mcp",
41
+ "summarize_error_to_user",
42
+ "summarize_feedback",
43
+ ]
@@ -0,0 +1,383 @@
1
+ import os
2
+ import json
3
+ import asyncio
4
+ import socket
5
+ from typing import Any, Dict, List, Optional, Tuple, Callable, TypeVar
6
+
7
+ from dotenv import load_dotenv
8
+ from supabase import Client, create_client
9
+ import logging
10
+ import random
11
+
12
+ T = TypeVar("T")
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # ------------------------------ Retry & JSON utils ------------------------------
16
+ async def _async_retry(
17
+ fn: Callable[[], Any],
18
+ *,
19
+ name: str,
20
+ retries: int = 3,
21
+ base_delay: float = 0.8,
22
+ fallback: Optional[Callable[[], Any]] = None,
23
+ ) -> Optional[Any]:
24
+ """
25
+ - 각 시도 실패: warning 로깅(시도/지연/에러 포함)
26
+ - 최종 실패: FATAL 로깅(스택 포함), 예외는 재전파하지 않고 None 반환(기존 정책 유지)
27
+ - fallback 이 있으면 실행(실패 시에도 로깅 후 None)
28
+ """
29
+ last_err: Optional[Exception] = None
30
+ for attempt in range(1, retries + 1):
31
+ try:
32
+ return await asyncio.to_thread(fn)
33
+ except Exception as e:
34
+ last_err = e
35
+ jitter = random.uniform(0, 0.3)
36
+ delay = base_delay * (2 ** (attempt - 1)) + jitter
37
+ logger.warning(
38
+ "retry warn: name=%s attempt=%d/%d delay=%.2fs error=%s",
39
+ name, attempt, retries, delay, str(e),
40
+ exc_info=e
41
+ )
42
+ await asyncio.sleep(delay)
43
+
44
+ # 최종 실패
45
+ if last_err is not None:
46
+ logger.error(
47
+ "FATAL: retry failed: name=%s retries=%s error=%s",
48
+ name, retries, str(last_err), exc_info=last_err
49
+ )
50
+
51
+ if fallback is not None:
52
+ try:
53
+ return fallback()
54
+ except Exception as fb_err:
55
+ logger.error("fallback failed: name=%s error=%s", name, str(fb_err), exc_info=fb_err)
56
+ return None
57
+ return None
58
+
59
+ def _to_jsonable(value: Any) -> Any:
60
+ try:
61
+ if value is None or isinstance(value, (str, int, float, bool)):
62
+ return value
63
+ if isinstance(value, dict):
64
+ return {str(k): _to_jsonable(v) for k, v in value.items()}
65
+ if isinstance(value, (list, tuple, set)):
66
+ return [_to_jsonable(v) for v in list(value)]
67
+ if hasattr(value, "__dict__"):
68
+ return _to_jsonable(vars(value))
69
+ return repr(value)
70
+ except Exception:
71
+ return repr(value)
72
+
73
+ # ------------------------------ DB Client ------------------------------
74
+ _supabase_client: Optional[Client] = None
75
+
76
+ def initialize_db() -> None:
77
+ global _supabase_client
78
+ if _supabase_client is not None:
79
+ return
80
+ try:
81
+ if os.getenv("ENV") != "production":
82
+ load_dotenv()
83
+ supabase_url = os.getenv("SUPABASE_URL") or os.getenv("SUPABASE_KEY_URL")
84
+ supabase_key = os.getenv("SUPABASE_KEY") or os.getenv("SUPABASE_ANON_KEY")
85
+ if not supabase_url or not supabase_key:
86
+ raise RuntimeError("SUPABASE_URL 및 SUPABASE_KEY가 필요합니다")
87
+ _supabase_client = create_client(supabase_url, supabase_key)
88
+ except Exception as e:
89
+ logger.error("initialize_db failed: %s", str(e), exc_info=e)
90
+ raise
91
+
92
+ def get_db_client() -> Client:
93
+ if _supabase_client is None:
94
+ raise RuntimeError("DB 미초기화: initialize_db() 먼저 호출")
95
+ return _supabase_client
96
+
97
+ def get_consumer_id() -> str:
98
+ env_consumer = os.getenv("CONSUMER_ID")
99
+ if env_consumer:
100
+ return env_consumer
101
+ host = socket.gethostname()
102
+ pid = os.getpid()
103
+ return f"{host}:{pid}"
104
+
105
+ # ------------------------------ Polling ------------------------------
106
+ async def polling_pending_todos(agent_orch: str, consumer: str) -> Optional[Dict[str, Any]]:
107
+ """단일 RPC(fetch_pending_task) 호출: p_env 로 dev/prod 분기"""
108
+ if agent_orch is None:
109
+ agent_orch = ""
110
+ if consumer is None:
111
+ consumer = ""
112
+
113
+ def _call():
114
+ client = get_db_client()
115
+ consumer_id = consumer or socket.gethostname()
116
+
117
+ # ENV 값을 dev / (그외=prod) 로만 정규화
118
+ p_env = (os.getenv("ENV") or "").lower()
119
+ if p_env != "dev":
120
+ p_env = "prod"
121
+
122
+ resp = client.rpc(
123
+ "fetch_pending_task",
124
+ {
125
+ "p_agent_orch": agent_orch,
126
+ "p_consumer": consumer_id,
127
+ "p_limit": 1,
128
+ "p_env": p_env,
129
+ },
130
+ ).execute()
131
+
132
+ rows = resp.data or []
133
+ if not rows:
134
+ return None
135
+
136
+ row = rows[0]
137
+ # 빈 값들을 NULL로 변환
138
+ if row.get("feedback") in ([], {}):
139
+ row["feedback"] = None
140
+ if row.get("output") in ([], {}):
141
+ row["output"] = None
142
+ if row.get("draft") in ([], {}):
143
+ row["draft"] = None
144
+
145
+ return row
146
+
147
+ return await _async_retry(_call, name="polling_pending_todos", fallback=lambda: None)
148
+
149
+
150
+ # ------------------------------ Events & Results ------------------------------
151
+ async def record_events_bulk(payloads: List[Dict[str, Any]]) -> None:
152
+ """이벤트 다건 저장 함수"""
153
+
154
+ if not payloads:
155
+ return
156
+
157
+ safe_list: List[Dict[str, Any]] = []
158
+ for p in payloads:
159
+ sp = _to_jsonable(p)
160
+ if isinstance(sp, dict) and sp.get("status", "") == "":
161
+ sp["status"] = None
162
+ safe_list.append(sp)
163
+
164
+ def _call():
165
+ client = get_db_client()
166
+ return client.rpc("record_events_bulk", {"p_events": safe_list}).execute()
167
+
168
+ res = await _async_retry(_call, name="record_events_bulk", fallback=lambda: None)
169
+ if res is None:
170
+ logger.error("❌ record_events_bulk failed: events not persisted count=%d", len(safe_list))
171
+ else:
172
+ logger.info("record_events_bulk ok: count=%d", len(safe_list))
173
+
174
+ async def record_event(payload: Dict[str, Any]) -> None:
175
+ """단건 이벤트 저장 함수"""
176
+
177
+ if not payload:
178
+ return
179
+
180
+ def _call():
181
+ client = get_db_client()
182
+ safe_payload = _to_jsonable(payload)
183
+ if isinstance(safe_payload, dict) and safe_payload.get("status", "") == "":
184
+ safe_payload["status"] = None
185
+ return client.table("events").insert(safe_payload).execute()
186
+
187
+ res = await _async_retry(_call, name="record_event", fallback=lambda: None)
188
+ if res is None:
189
+ logger.error("❌ record_event failed =%s", payload.get("event_type"))
190
+ else:
191
+ logger.info("record_event ok: event_type=%s", payload.get("event_type"))
192
+
193
+ async def save_task_result(todo_id: str, result: Any, final: bool = False) -> None:
194
+ """결과 저장 함수"""
195
+
196
+ if not todo_id:
197
+ logger.error("save_task_result invalid todo_id: %s", str(todo_id))
198
+ return
199
+
200
+ def _safe(val: Any) -> Any:
201
+ try:
202
+ return _to_jsonable(val)
203
+ except Exception:
204
+ try:
205
+ return {"repr": repr(val)}
206
+ except Exception:
207
+ return {"error": "unserializable payload"}
208
+
209
+ def _call():
210
+ client = get_db_client()
211
+ payload = _safe(result)
212
+ return client.rpc("save_task_result", {"p_todo_id": todo_id, "p_payload": payload, "p_final": bool(final)}).execute()
213
+
214
+ res = await _async_retry(_call, name="save_task_result", fallback=lambda: None)
215
+ if res is None:
216
+ logger.error("❌ save_task_result failed todo_id=%s", todo_id)
217
+ else:
218
+ logger.info("save_task_result ok todo_id=%s", todo_id)
219
+
220
+ # ------------------------------ Failure Status ------------------------------
221
+ async def update_task_error(todo_id: str) -> None:
222
+ """작업 실패 상태 업데이트 함수"""
223
+
224
+ if not todo_id:
225
+ return
226
+
227
+ def _call():
228
+ client = get_db_client()
229
+ return client.table("todolist").update({"draft_status": "FAILED", "consumer": None}).eq("id", todo_id).execute()
230
+
231
+ res = await _async_retry(_call, name="update_task_error", fallback=lambda: None)
232
+ if res is None:
233
+ logger.error("❌ update_task_error failed todo_id=%s", todo_id)
234
+ else:
235
+ logger.info("update_task_error ok todo_id=%s", todo_id)
236
+
237
+ # ============================== Prepare Context ==============================
238
+
239
+ from typing import Any, Dict, List, Optional, Tuple
240
+
241
+ async def fetch_form_def(tool_val: str, tenant_id: str) -> Tuple[str, List[Dict[str, Any]], Optional[str]]:
242
+ """폼 정의 조회 함수"""
243
+ form_id = (tool_val or "").replace("formHandler:", "", 1)
244
+
245
+ def _call():
246
+ client = get_db_client()
247
+ resp = (
248
+ client.table("form_def")
249
+ .select("fields_json, html")
250
+ .eq("id", form_id)
251
+ .eq("tenant_id", tenant_id or "")
252
+ .execute()
253
+ )
254
+ data = (resp.data or [])
255
+ if not data:
256
+ return None
257
+
258
+ row = data[0]
259
+ return {
260
+ "fields": row.get("fields_json"),
261
+ "html": row.get("html"),
262
+ }
263
+
264
+ try:
265
+ res = await _async_retry(_call, name="fetch_form_def")
266
+ except Exception as e:
267
+ logger.error("fetch_form_def fatal: %s", str(e), exc_info=e)
268
+ res = None
269
+
270
+ if not res or not res.get("fields"):
271
+ # 기본(자유형식) 폼
272
+ return (
273
+ form_id or "freeform",
274
+ [{"key": "freeform", "type": "textarea", "text": "자유형식 입력", "placeholder": "원하는 내용을 자유롭게 입력해주세요."}],
275
+ None,
276
+ )
277
+ return (form_id or "freeform", res["fields"], res.get("html"))
278
+
279
+
280
+ async def fetch_users_grouped(user_ids: List[str]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
281
+ """해당 todo에서 사용자 목록과 에이전트 목록 조회하는는 함수"""
282
+ ids = [u for u in (user_ids or []) if u]
283
+ if not ids:
284
+ return ([], [])
285
+
286
+ def _call():
287
+ client = get_db_client()
288
+ resp = (
289
+ client.table("users")
290
+ .select("*")
291
+ .in_("id", ids)
292
+ .execute()
293
+ )
294
+ rows = resp.data or []
295
+ return rows
296
+
297
+ try:
298
+ rows = await _async_retry(_call, name="fetch_users_grouped", fallback=lambda: [])
299
+ except Exception as e:
300
+ logger.error("fetch_users_grouped fatal: %s", str(e), exc_info=e)
301
+ rows = []
302
+
303
+ agents, users = [], []
304
+ for r in rows:
305
+ if r.get("is_agent") is True:
306
+ agents.append(r)
307
+ else:
308
+ users.append(r)
309
+ return (agents, users)
310
+
311
+ async def fetch_email_users_by_proc_inst_id(proc_inst_id: str) -> str:
312
+ """proc_inst_id로 이메일 수집(사람만): todolist → users(in) 한 번에"""
313
+ if not proc_inst_id:
314
+ return ""
315
+
316
+ def _call():
317
+ client = get_db_client()
318
+ # 3-1) 해당 인스턴스의 user_id 수집(중복 제거)
319
+ tl = (
320
+ client.table("todolist")
321
+ .select("user_id")
322
+ .eq("proc_inst_id", proc_inst_id)
323
+ .execute()
324
+ )
325
+ ids_set = set()
326
+ for row in (tl.data or []):
327
+ uid_csv = (row.get("user_id") or "").strip()
328
+ if not uid_csv:
329
+ continue
330
+ # user_id는 문자열 CSV라고 전제
331
+ for uid in uid_csv.split(","):
332
+ u = uid.strip()
333
+ if u:
334
+ ids_set.add(u)
335
+ if not ids_set:
336
+ return []
337
+
338
+ # 3-2) 한 번의 IN 조회로 사람만 이메일 추출
339
+ ur = (
340
+ client.table("users")
341
+ .select("id, email, is_agent")
342
+ .in_("id", list(ids_set))
343
+ .eq("is_agent", False)
344
+ .execute()
345
+ )
346
+ emails = []
347
+ for u in (ur.data or []):
348
+ email = (u.get("email") or "").strip()
349
+ if email:
350
+ emails.append(email)
351
+ # 중복 제거 및 정렬(보기 좋게)
352
+ return sorted(set(emails))
353
+
354
+ try:
355
+ emails = await _async_retry(_call, name="fetch_email_users_by_proc_inst_id", fallback=lambda: [])
356
+ except Exception as e:
357
+ logger.error("fetch_email_users_by_proc_inst_id fatal: %s", str(e), exc_info=e)
358
+ emails = []
359
+
360
+ return ",".join(emails) if emails else ""
361
+
362
+ async def fetch_tenant_mcp(tenant_id: str) -> Optional[Dict[str, Any]]:
363
+ """mcp 설정 조회 함수"""
364
+ if not tenant_id:
365
+ return None
366
+
367
+ def _call():
368
+ client = get_db_client()
369
+ return (
370
+ client.table("tenants")
371
+ .select("mcp")
372
+ .eq("id", tenant_id)
373
+ .single()
374
+ .execute()
375
+ )
376
+
377
+ try:
378
+ resp = await _async_retry(_call, name="fetch_tenant_mcp", fallback=lambda: None)
379
+ except Exception as e:
380
+ logger.error("fetch_tenant_mcp fatal: %s", str(e), exc_info=e)
381
+ return None
382
+
383
+ return resp.data.get("mcp") if resp and getattr(resp, "data", None) else None