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

@@ -1,508 +1,460 @@
1
- """ProcessGPT DB utilities
2
-
3
- 역할:
4
- - Supabase 연결/초기화
5
- - 안전한 RPC/CRUD 호출(재시도/폴백 포함)
6
- - 이벤트 기록, 작업 클레임/상태/저장/조회, 사용자·에이전트·폼·테넌트 조회
7
-
8
- 반환 규칙(폴백 포함):
9
- - Optional 단건 조회 계열 → 실패 시 None
10
- - 목록/시퀀스 계열 → 실패 시 빈 리스트 []
11
- - 변경/기록 계열 → 실패 시 경고 로그만 남기고 None
12
- """
13
-
14
- import os
15
- import json
16
- import asyncio
17
- import socket
18
- import uuid
19
- from datetime import datetime
20
- from typing import Any, Dict, List, Optional, Tuple
21
-
22
- from dotenv import load_dotenv
23
- from supabase import Client, create_client
24
- import logging
25
- import random
26
- from typing import Callable, TypeVar
27
-
28
- T = TypeVar("T")
29
-
30
- from ..utils.logger import handle_application_error, write_log_message
31
-
32
-
33
- async def _async_retry(
34
- fn: Callable[[], T],
35
- *,
36
- name: str,
37
- retries: int = 3,
38
- base_delay: float = 0.8,
39
- fallback: Optional[Callable[[], T]] = None,
40
- ) -> Optional[T]:
41
- """재시도 유틸(지수 백오프+jitter).
42
-
43
- - 최종 실패 시: fallback이 있으면 실행, 없으면 None
44
- - 로그: 시도/지연/최종 실패/폴백 사용/폴백 실패
45
- """
46
- last_err: Optional[Exception] = None
47
- for attempt in range(1, retries + 1):
48
- try:
49
- # 블로킹 DB 호출은 스레드로 위임해 이벤트 루프 차단 방지
50
- return await asyncio.to_thread(fn)
51
- except Exception as e:
52
- last_err = e
53
- jitter = random.uniform(0, 0.3)
54
- delay = base_delay * (2 ** (attempt - 1)) + jitter
55
- write_log_message(f"{name} 재시도 {attempt}/{retries} (delay={delay:.2f}s): {e}", level=logging.WARNING)
56
- await asyncio.sleep(delay)
57
- write_log_message(f"{name} 최종 실패: {last_err}", level=logging.ERROR)
58
- if fallback is not None:
59
- try:
60
- fb_val = fallback()
61
- write_log_message(f"{name} 폴백 사용", level=logging.WARNING)
62
- return fb_val
63
- except Exception as e:
64
- write_log_message(f"{name} 폴백 실패: {e}", level=logging.ERROR)
65
- return None
66
-
67
-
68
-
69
- # ------------------------------
70
- # Consumer 식별자 도우미
71
- # ------------------------------
72
- _supabase_client: Optional[Client] = None
73
-
74
-
75
- def initialize_db() -> None:
76
- """환경변수 로드 및 Supabase 클라이언트 초기화"""
77
- global _supabase_client
78
- if _supabase_client is not None:
79
- return
80
- if os.getenv("ENV") != "production":
81
- load_dotenv()
82
- supabase_url = os.getenv("SUPABASE_URL") or os.getenv("SUPABASE_KEY_URL")
83
- supabase_key = os.getenv("SUPABASE_KEY") or os.getenv("SUPABASE_ANON_KEY")
84
- if not supabase_url or not supabase_key:
85
- raise RuntimeError("SUPABASE_URL SUPABASE_KEY가 필요합니다")
86
- _supabase_client = create_client(supabase_url, supabase_key)
87
-
88
-
89
- def get_db_client() -> Client:
90
- """초기화된 Supabase 클라이언트 반환."""
91
- if _supabase_client is None:
92
- raise RuntimeError("DB 미초기화: initialize_db() 먼저 호출")
93
- return _supabase_client
94
-
95
-
96
- def get_consumer_id() -> str:
97
- """파드/프로세스 식별자 생성(CONSUMER_ID>HOST:PID)."""
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
-
106
- # ------------------------------
107
- # 폴링: 대기 작업 1건 클레임(RPC 내부에서 상태 변경 포함)
108
- # ------------------------------
109
- async def polling_pending_todos(agent_orch: str, consumer: str) -> Optional[Dict[str, Any]]:
110
- """대기중 작업 하나를 RPC로 클레임하고 반환. 실패/없음 시 None."""
111
- def _call():
112
- client = get_db_client()
113
- return client.rpc(
114
- "fetch_pending_task",
115
- {"p_agent_orch": agent_orch, "p_consumer": consumer, "p_limit": 1},
116
- ).execute()
117
-
118
- resp = await _async_retry(_call, name="polling_pending_todos", fallback=lambda: None)
119
- if not resp or not resp.data:
120
- return None
121
- return resp.data[0]
122
-
123
-
124
- # ------------------------------
125
- # 단건 todo 조회
126
- # ------------------------------
127
- async def fetch_todo_by_id(todo_id: str) -> Optional[Dict[str, Any]]:
128
- """todolist에서 특정 id의 row 단건 조회. 실패 시 None."""
129
- if not todo_id:
130
- return None
131
- def _call():
132
- client = get_db_client()
133
- return (
134
- client.table("todolist").select("*").eq("id", todo_id).single().execute()
135
- )
136
-
137
- resp = await _async_retry(_call, name="fetch_todo_by_id")
138
- if not resp or not resp.data:
139
- return None
140
- return resp.data
141
-
142
-
143
- # ------------------------------
144
- # 이벤트 기록
145
- # ------------------------------
146
- async def record_event(todo: Dict[str, Any], data: Dict[str, Any], event_type: Optional[str] = None) -> None:
147
- """UI용 events 테이블에 이벤트 기록. 실패해도 플로우 지속."""
148
- def _call():
149
- client = get_db_client()
150
- payload: Dict[str, Any] = {
151
- "id": str(uuid.uuid4()),
152
- "job_id": todo.get("proc_inst_id") or str(todo.get("id")),
153
- "todo_id": str(todo.get("id")),
154
- "proc_inst_id": todo.get("proc_inst_id"),
155
- "crew_type": todo.get("agent_orch"),
156
- "data": data,
157
- }
158
- if event_type is not None:
159
- payload["event_type"] = event_type
160
- return client.table("events").insert(payload).execute()
161
-
162
- resp = await _async_retry(_call, name="record_event", fallback=lambda: None)
163
- if resp is None:
164
- write_log_message("record_event 최종 실패(무시)", level=logging.WARNING)
165
-
166
-
167
- # ------------------------------
168
- # 완료된 데이터 조회
169
- # ------------------------------
170
- async def fetch_done_data(proc_inst_id: Optional[str]) -> List[Any]:
171
- """같은 proc_inst_id의 완료 output 목록 조회. 실패 시 []."""
172
- if not proc_inst_id:
173
- return []
174
- def _call():
175
- client = get_db_client()
176
- return client.rpc("fetch_done_data", {"p_proc_inst_id": proc_inst_id}).execute()
177
-
178
- resp = await _async_retry(_call, name="fetch_done_data", fallback=lambda: None)
179
- if not resp:
180
- return []
181
- return [row.get("output") for row in (resp.data or [])]
182
-
183
-
184
- # ------------------------------
185
- # 결과 저장 (중간/최종)
186
- # ------------------------------
187
- async def save_task_result(todo_id: str, result: Any, final: bool = False) -> None:
188
- """결과 저장 RPC(중간/최종). 실패해도 경고 로그 후 지속."""
189
- def _call():
190
- client = get_db_client()
191
- payload = result if isinstance(result, (dict, list)) else json.loads(json.dumps(result))
192
- return client.rpc(
193
- "save_task_result",
194
- {"p_todo_id": todo_id, "p_payload": payload, "p_final": final},
195
- ).execute()
196
-
197
- await _async_retry(_call, name="save_task_result", fallback=lambda: None)
198
-
199
-
200
- # ------------------------------
201
- # 추가 유틸: 이벤트/작업/사용자/에이전트/폼/테넌트 조회
202
- # ------------------------------
203
- async def fetch_human_response(job_id: str) -> Optional[Dict[str, Any]]:
204
- """events에서 특정 job_id의 human_response 1건 조회. 실패 시 None."""
205
- def _call():
206
- client = get_db_client()
207
- return (
208
- client.table("events")
209
- .select("*")
210
- .eq("job_id", job_id)
211
- .eq("event_type", "human_response")
212
- .execute()
213
- )
214
-
215
- resp = await _async_retry(_call, name="fetch_human_response")
216
- if not resp or not resp.data:
217
- return None
218
- return resp.data[0]
219
-
220
-
221
- async def fetch_task_status(todo_id: str) -> Optional[str]:
222
- """todolist.draft_status 조회. 실패 시 None."""
223
- def _call():
224
- client = get_db_client()
225
- return (
226
- client.table("todolist").select("draft_status").eq("id", todo_id).single().execute()
227
- )
228
-
229
- resp = await _async_retry(_call, name="fetch_task_status")
230
- if not resp or not resp.data:
231
- return None
232
- return resp.data.get("draft_status")
233
-
234
-
235
- async def fetch_all_agents() -> List[Dict[str, Any]]:
236
- """모든 에이전트 목록 정규화 반환. 실패 시 []."""
237
- def _call():
238
- client = get_db_client()
239
- return (
240
- client.table("users")
241
- .select("id, username, role, goal, persona, tools, profile, model, tenant_id, is_agent")
242
- .eq("is_agent", True)
243
- .execute()
244
- )
245
-
246
- resp = await _async_retry(_call, name="fetch_all_agents")
247
- rows = resp.data or [] if resp else []
248
- normalized: List[Dict[str, Any]] = []
249
- for row in rows:
250
- normalized.append(
251
- {
252
- "id": row.get("id"),
253
- "name": row.get("username"),
254
- "role": row.get("role"),
255
- "goal": row.get("goal"),
256
- "persona": row.get("persona"),
257
- "tools": row.get("tools") or "mem0",
258
- "profile": row.get("profile"),
259
- "model": row.get("model"),
260
- "tenant_id": row.get("tenant_id"),
261
- }
262
- )
263
- return normalized
264
-
265
-
266
- async def fetch_agent_data(user_ids: str) -> List[Dict[str, Any]]:
267
- """user_id(들)로 에이전트를 조회. 없거나 유효하지 않으면 모든 에이전트를 반환.
268
-
269
- - 입력은 UUID 또는 콤마(,)로 구분된 UUID 목록을 허용
270
- - 유효한 UUID가 하나도 없으면 전체 에이전트 반환
271
- - 유효한 UUID로 조회했는데 결과가 비면 전체 에이전트 반환
272
- """
273
- def _is_valid_uuid(value: str) -> bool:
274
- try:
275
- uuid.UUID(str(value))
276
- return True
277
- except Exception:
278
- return False
279
-
280
- # 1) 입력 정규화 및 UUID 필터링
281
- raw_ids = [x.strip() for x in (user_ids or "").split(",") if x.strip()]
282
- valid_ids = [x for x in raw_ids if _is_valid_uuid(x)]
283
-
284
- # 2) 유효한 UUID가 없으면 전체 에이전트 반환
285
- if not valid_ids:
286
- return await fetch_all_agents()
287
-
288
- # 3) 유효한 UUID로 에이전트 조회
289
- def _call():
290
- client = get_db_client()
291
- resp = (
292
- client
293
- .table("users")
294
- .select("id, username, role, goal, persona, tools, profile, model, tenant_id, is_agent")
295
- .in_("id", valid_ids)
296
- .eq("is_agent", True)
297
- .execute()
298
- )
299
- rows = resp.data or []
300
- normalized: List[Dict[str, Any]] = []
301
- for row in rows:
302
- normalized.append(
303
- {
304
- "id": row.get("id"),
305
- "name": row.get("username"),
306
- "role": row.get("role"),
307
- "goal": row.get("goal"),
308
- "persona": row.get("persona"),
309
- "tools": row.get("tools") or "mem0",
310
- "profile": row.get("profile"),
311
- "model": row.get("model"),
312
- "tenant_id": row.get("tenant_id"),
313
- }
314
- )
315
- return normalized
316
-
317
- result = await _async_retry(_call, name="fetch_agent_data", fallback=lambda: [])
318
-
319
- # 4) 결과가 없으면 전체 에이전트로 폴백
320
- if not result:
321
- return await fetch_all_agents()
322
-
323
- return result
324
-
325
-
326
- async def fetch_form_types(tool_val: str, tenant_id: str) -> Tuple[str, List[Dict[str, Any]]]:
327
- """폼 타입 정의 조회 및 정규화. 실패 시 기본값 반환."""
328
- def _call():
329
- client = get_db_client()
330
- form_id = tool_val[12:] if tool_val.startswith("formHandler:") else tool_val
331
- resp = (
332
- client.table("form_def").select("fields_json").eq("id", form_id).eq("tenant_id", tenant_id).execute()
333
- )
334
- fields_json = resp.data[0].get("fields_json") if resp.data else None
335
- if not fields_json:
336
- return form_id, [{"key": form_id, "type": "default", "text": ""}]
337
- return form_id, fields_json
338
-
339
- resp = await _async_retry(_call, name="fetch_form_types", fallback=lambda: (tool_val, [{"key": tool_val, "type": "default", "text": ""}]))
340
- return resp if resp else (tool_val, [{"key": tool_val, "type": "default", "text": ""}])
341
-
342
-
343
- async def fetch_tenant_mcp_config(tenant_id: str) -> Optional[Dict[str, Any]]:
344
- """테넌트 MCP 설정 조회. 실패 시 None."""
345
- def _call():
346
- client = get_db_client()
347
- return client.table("tenants").select("mcp").eq("id", tenant_id).single().execute()
348
- try:
349
- resp = await _async_retry(_call, name="fetch_tenant_mcp_config", fallback=lambda: None)
350
- return resp.data.get("mcp") if resp and resp.data else None
351
- except Exception as e:
352
- handle_application_error("fetch_tenant_mcp_config 실패", e, raise_error=False)
353
- return None
354
-
355
-
356
- # ------------------------------
357
- # 오류 상태 업데이트 (FAILED)
358
- # ------------------------------
359
- async def update_task_error(todo_id: str) -> None:
360
- """작업 오류 상태 업데이트 (FAILED) - 로그 컬럼은 건드리지 않음"""
361
- if not todo_id:
362
- return
363
- def _call():
364
- client = get_db_client()
365
- return (
366
- client
367
- .table('todolist')
368
- .update({'draft_status': 'FAILED', 'consumer': None})
369
- .eq('id', todo_id)
370
- .execute()
371
- )
372
-
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
- write_log_message(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
- write_log_message(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
- write_log_message(f"알림 저장 완료: {len(rows)}건")
425
- except Exception as e:
426
- # 알림 저장 실패는 치명적이지 않으므로 오류만 로깅
427
- handle_application_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
- handle_application_error("사용자조회오류", e, raise_error=False)
506
- return ""
507
-
508
- return await asyncio.to_thread(_sync)
1
+ import os
2
+ import json
3
+ import asyncio
4
+ import socket
5
+ import uuid
6
+ from typing import Any, Dict, List, Optional, Tuple, Callable, TypeVar
7
+
8
+ from dotenv import load_dotenv
9
+ from supabase import Client, create_client
10
+ import logging
11
+ import random
12
+
13
+ T = TypeVar("T")
14
+
15
+ from ..utils.logger import handle_application_error, write_log_message
16
+
17
+ # ============================================================================
18
+ # Utility: 재시도 헬퍼 및 유틸
19
+ # 설명: 동기 DB 호출을 안전하게 재시도 (지수 백오프 + 지터) 및 유틸
20
+ # ============================================================================
21
+
22
+ async def _async_retry(
23
+ fn: Callable[[], T],
24
+ *,
25
+ name: str,
26
+ retries: int = 3,
27
+ base_delay: float = 0.8,
28
+ fallback: Optional[Callable[[], T]] = None,
29
+ ) -> Optional[T]:
30
+ """지수 백오프+jitter로 재시도하고 실패 시 fallback/None 반환."""
31
+ last_err: Optional[Exception] = None
32
+ for attempt in range(1, retries + 1):
33
+ try:
34
+ return await asyncio.to_thread(fn)
35
+ except Exception as e:
36
+ last_err = e
37
+ jitter = random.uniform(0, 0.3)
38
+ delay = base_delay * (2 ** (attempt - 1)) + jitter
39
+ write_log_message(f"{name} 재시도 {attempt}/{retries} (delay={delay:.2f}s): {e}", level=logging.WARNING)
40
+ await asyncio.sleep(delay)
41
+ write_log_message(f"{name} 최종 실패: {last_err}", level=logging.ERROR)
42
+ if fallback is not None:
43
+ try:
44
+ fb_val = fallback()
45
+ write_log_message(f"{name} 폴백 사용", level=logging.WARNING)
46
+ return fb_val
47
+ except Exception as e:
48
+ write_log_message(f"{name} 폴백 실패: {e}", level=logging.ERROR)
49
+ return None
50
+
51
+
52
+ def _is_valid_uuid(value: str) -> bool:
53
+ """UUID 문자열 형식 검증 (v1~v8 포함)"""
54
+ try:
55
+ uuid.UUID(value)
56
+ return True
57
+ except Exception:
58
+ return False
59
+
60
+
61
+ # ============================================================================
62
+ # DB 연결/클라이언트
63
+ # 설명: 환경 변수 로드, Supabase 클라이언트 초기화/반환, 컨슈머 식별자
64
+ # ============================================================================
65
+ _supabase_client: Optional[Client] = None
66
+
67
+
68
+ def initialize_db() -> None:
69
+ """환경변수 로드 및 Supabase 클라이언트 초기화"""
70
+ global _supabase_client
71
+ if _supabase_client is not None:
72
+ return
73
+ if os.getenv("ENV") != "production":
74
+ load_dotenv()
75
+ supabase_url = os.getenv("SUPABASE_URL") or os.getenv("SUPABASE_KEY_URL")
76
+ supabase_key = os.getenv("SUPABASE_KEY") or os.getenv("SUPABASE_ANON_KEY")
77
+ if not supabase_url or not supabase_key:
78
+ raise RuntimeError("SUPABASE_URL SUPABASE_KEY가 필요합니다")
79
+ _supabase_client = create_client(supabase_url, supabase_key)
80
+
81
+
82
+ def get_db_client() -> Client:
83
+ """초기화된 Supabase 클라이언트 반환."""
84
+ if _supabase_client is None:
85
+ raise RuntimeError("DB 미초기화: initialize_db() 먼저 호출")
86
+ return _supabase_client
87
+
88
+
89
+ def get_consumer_id() -> str:
90
+ """파드/프로세스 식별자 생성(CONSUMER_ID>HOST:PID)."""
91
+ env_consumer = os.getenv("CONSUMER_ID")
92
+ if env_consumer:
93
+ return env_consumer
94
+ host = socket.gethostname()
95
+ pid = os.getpid()
96
+ return f"{host}:{pid}"
97
+
98
+
99
+ # ============================================================================
100
+ # 데이터 조회
101
+ # 설명: TODOLIST 테이블 조회, 완료 output 목록 조회, 이벤트 조회, 폼 조회, 테넌트 MCP 설정 조회, 사용자 및 에이전트 조회
102
+ # ============================================================================
103
+ async def polling_pending_todos(agent_orch: str, consumer: str) -> Optional[Dict[str, Any]]:
104
+ """TODOLIST 테이블에서 대기중인 워크아이템을 조회"""
105
+ def _call():
106
+ client = get_db_client()
107
+ return client.rpc(
108
+ "fetch_pending_task",
109
+ {"p_agent_orch": agent_orch, "p_consumer": consumer, "p_limit": 1},
110
+ ).execute()
111
+
112
+ resp = await _async_retry(_call, name="polling_pending_todos", fallback=lambda: None)
113
+ if not resp or not resp.data:
114
+ return None
115
+ return resp.data[0]
116
+
117
+
118
+ async def fetch_todo_by_id(todo_id: str) -> Optional[Dict[str, Any]]:
119
+ """특정 todo id로 todolist의 단건을 조회"""
120
+ if not todo_id:
121
+ return None
122
+ def _call():
123
+ client = get_db_client()
124
+ return (
125
+ client.table("todolist").select("*").eq("id", todo_id).single().execute()
126
+ )
127
+
128
+ resp = await _async_retry(_call, name="fetch_todo_by_id")
129
+ if not resp or not resp.data:
130
+ return None
131
+ return resp.data
132
+
133
+
134
+ async def fetch_done_data(proc_inst_id: Optional[str]) -> List[Any]:
135
+ """proc_inst_id로 완료된 워크아이템의 output 목록을 조회"""
136
+ if not proc_inst_id:
137
+ return []
138
+ def _call():
139
+ client = get_db_client()
140
+ return client.rpc("fetch_done_data", {"p_proc_inst_id": proc_inst_id}).execute()
141
+
142
+ resp = await _async_retry(_call, name="fetch_done_data", fallback=lambda: None)
143
+ if not resp:
144
+ return []
145
+ return [row.get("output") for row in (resp.data or [])]
146
+
147
+
148
+ def fetch_human_response_sync(job_id: str) -> Optional[Dict[str, Any]]:
149
+ """events에서 특정 job_id의 human_response 조회"""
150
+ if not job_id:
151
+ return None
152
+ try:
153
+ client = get_db_client()
154
+ resp = (
155
+ client
156
+ .table("events")
157
+ .select("*")
158
+ .eq("job_id", job_id)
159
+ .eq("event_type", "human_response")
160
+ .execute()
161
+ )
162
+ rows = resp.data or []
163
+ return rows[0] if rows else None
164
+ except Exception as e:
165
+ handle_application_error("fetch_human_response_sync 실패", e, raise_error=False)
166
+ return None
167
+
168
+
169
+ async def fetch_task_status(todo_id: str) -> Optional[str]:
170
+ """todo의 draft_status를 조회한다."""
171
+ def _call():
172
+ client = get_db_client()
173
+ return (
174
+ client.table("todolist").select("draft_status").eq("id", todo_id).single().execute()
175
+ )
176
+
177
+ resp = await _async_retry(_call, name="fetch_task_status")
178
+ if not resp or not resp.data:
179
+ return None
180
+ return resp.data.get("draft_status")
181
+
182
+
183
+
184
+ async def fetch_all_agents() -> List[Dict[str, Any]]:
185
+ """모든 에이전트 목록을 정규화하여 반환한다."""
186
+ def _call():
187
+ client = get_db_client()
188
+ return (
189
+ client.table("users")
190
+ .select("id, username, role, goal, persona, tools, profile, model, tenant_id, is_agent")
191
+ .eq("is_agent", True)
192
+ .execute()
193
+ )
194
+
195
+ resp = await _async_retry(_call, name="fetch_all_agents")
196
+ rows = resp.data or [] if resp else []
197
+ normalized: List[Dict[str, Any]] = []
198
+ for row in rows:
199
+ normalized.append(
200
+ {
201
+ "id": row.get("id"),
202
+ "name": row.get("username"),
203
+ "role": row.get("role"),
204
+ "goal": row.get("goal"),
205
+ "persona": row.get("persona"),
206
+ "tools": row.get("tools") or "mem0",
207
+ "profile": row.get("profile"),
208
+ "model": row.get("model"),
209
+ "tenant_id": row.get("tenant_id"),
210
+ }
211
+ )
212
+ return normalized
213
+
214
+
215
+ async def fetch_agent_data(user_ids: str) -> List[Dict[str, Any]]:
216
+ """TODOLIST의 user_id 값으로, 역할로 지정된 에이전트를 조회하고 정규화해 반환한다."""
217
+
218
+ raw_ids = [x.strip() for x in (user_ids or "").split(",") if x.strip()]
219
+ valid_ids = [x for x in raw_ids if _is_valid_uuid(x)]
220
+
221
+ if not valid_ids:
222
+ return await fetch_all_agents()
223
+
224
+ def _call():
225
+ client = get_db_client()
226
+ resp = (
227
+ client
228
+ .table("users")
229
+ .select("id, username, role, goal, persona, tools, profile, model, tenant_id, is_agent")
230
+ .in_("id", valid_ids)
231
+ .eq("is_agent", True)
232
+ .execute()
233
+ )
234
+ rows = resp.data or []
235
+ normalized: List[Dict[str, Any]] = []
236
+ for row in rows:
237
+ normalized.append(
238
+ {
239
+ "id": row.get("id"),
240
+ "name": row.get("username"),
241
+ "role": row.get("role"),
242
+ "goal": row.get("goal"),
243
+ "persona": row.get("persona"),
244
+ "tools": row.get("tools") or "mem0",
245
+ "profile": row.get("profile"),
246
+ "model": row.get("model"),
247
+ "tenant_id": row.get("tenant_id"),
248
+ }
249
+ )
250
+ return normalized
251
+
252
+ result = await _async_retry(_call, name="fetch_agent_data", fallback=lambda: [])
253
+
254
+ if not result:
255
+ return await fetch_all_agents()
256
+
257
+ return result
258
+
259
+
260
+ async def fetch_form_types(tool_val: str, tenant_id: str) -> Tuple[str, List[Dict[str, Any]], Optional[str]]:
261
+ """폼 타입 정의를 조회해 (form_id, fields, html)로 반환한다."""
262
+ form_id = tool_val[12:] if tool_val.startswith("formHandler:") else tool_val
263
+
264
+ def _call():
265
+ client = get_db_client()
266
+ resp = (
267
+ client
268
+ .table("form_def")
269
+ .select("fields_json, html")
270
+ .eq("id", form_id)
271
+ .eq("tenant_id", tenant_id)
272
+ .execute()
273
+ )
274
+ fields_json = resp.data[0].get("fields_json") if resp.data else None
275
+ form_html = resp.data[0].get("html") if resp.data else None
276
+ if not fields_json:
277
+ return form_id, [{"key": form_id, "type": "default", "text": ""}], form_html
278
+ return form_id, fields_json, form_html
279
+
280
+ resp = await _async_retry(
281
+ _call,
282
+ name="fetch_form_types",
283
+ fallback=lambda: (form_id, [{"key": form_id, "type": "default", "text": ""}], None),
284
+ )
285
+ return resp if resp else (form_id, [{"key": form_id, "type": "default", "text": ""}], None)
286
+
287
+
288
+ async def fetch_tenant_mcp_config(tenant_id: str) -> Optional[Dict[str, Any]]:
289
+ """테넌트 MCP 설정을 조회해 반환한다."""
290
+ def _call():
291
+ client = get_db_client()
292
+ return client.table("tenants").select("mcp").eq("id", tenant_id).single().execute()
293
+
294
+ resp = await _async_retry(_call, name="fetch_tenant_mcp_config", fallback=lambda: None)
295
+ return resp.data.get("mcp") if resp and resp.data else None
296
+
297
+
298
+ async def fetch_human_users_by_proc_inst_id(proc_inst_id: str) -> str:
299
+ """proc_inst_id로 현재 프로세스의 모든 사용자 이메일 목록을 쉼표로 반환한다."""
300
+ if not proc_inst_id:
301
+ return ""
302
+
303
+ def _sync():
304
+ try:
305
+ supabase = get_db_client()
306
+
307
+ resp = (
308
+ supabase
309
+ .table('todolist')
310
+ .select('user_id')
311
+ .eq('proc_inst_id', proc_inst_id)
312
+ .execute()
313
+ )
314
+
315
+ if not resp.data:
316
+ return ""
317
+
318
+ all_user_ids = set()
319
+ for row in resp.data:
320
+ user_id = row.get('user_id', '')
321
+ if user_id:
322
+ ids = [id.strip() for id in user_id.split(',') if id.strip()]
323
+ all_user_ids.update(ids)
324
+
325
+ if not all_user_ids:
326
+ return ""
327
+
328
+ human_user_emails = []
329
+ for user_id in all_user_ids:
330
+ if not _is_valid_uuid(user_id):
331
+ continue
332
+
333
+ user_resp = (
334
+ supabase
335
+ .table('users')
336
+ .select('id, email, is_agent')
337
+ .eq('id', user_id)
338
+ .execute()
339
+ )
340
+
341
+ if user_resp.data:
342
+ user = user_resp.data[0]
343
+ is_agent = user.get('is_agent')
344
+ if not is_agent:
345
+ email = (user.get('email') or '').strip()
346
+ if email:
347
+ human_user_emails.append(email)
348
+
349
+ return ','.join(human_user_emails)
350
+
351
+ except Exception as e:
352
+ handle_application_error("사용자조회오류", e, raise_error=False)
353
+ return ""
354
+
355
+ return await asyncio.to_thread(_sync)
356
+
357
+
358
+ # ============================================================================
359
+ # 데이터 저장
360
+ # 설명: 이벤트/알림/작업 결과 저장
361
+ # ============================================================================
362
+ async def record_event(todo: Dict[str, Any], data: Dict[str, Any], event_type: Optional[str] = None) -> None:
363
+ """UI용 events 테이블에 이벤트 기록"""
364
+ def _call():
365
+ client = get_db_client()
366
+ payload: Dict[str, Any] = {
367
+ "id": str(uuid.uuid4()),
368
+ "job_id": todo.get("proc_inst_id") or str(todo.get("id")),
369
+ "todo_id": str(todo.get("id")),
370
+ "proc_inst_id": todo.get("proc_inst_id"),
371
+ "crew_type": todo.get("agent_orch"),
372
+ "data": data,
373
+ }
374
+ if event_type is not None:
375
+ payload["event_type"] = event_type
376
+ return client.table("events").insert(payload).execute()
377
+
378
+ resp = await _async_retry(_call, name="record_event", fallback=lambda: None)
379
+ if resp is None:
380
+ write_log_message("record_event 최종 실패(무시)", level=logging.WARNING)
381
+
382
+
383
+
384
+ async def save_task_result(todo_id: str, result: Any, final: bool = False) -> None:
385
+ """작업 결과를 저장한다(중간/최종)."""
386
+ def _call():
387
+ client = get_db_client()
388
+ payload = result if isinstance(result, (dict, list)) else json.loads(json.dumps(result))
389
+ return client.rpc(
390
+ "save_task_result",
391
+ {"p_todo_id": todo_id, "p_payload": payload, "p_final": final},
392
+ ).execute()
393
+
394
+ await _async_retry(_call, name="save_task_result", fallback=lambda: None)
395
+
396
+
397
+ def save_notification(
398
+ *,
399
+ title: str,
400
+ notif_type: str,
401
+ description: Optional[str] = None,
402
+ user_ids_csv: Optional[str] = None,
403
+ tenant_id: Optional[str] = None,
404
+ url: Optional[str] = None,
405
+ from_user_id: Optional[str] = None,
406
+ ) -> None:
407
+ """notifications 테이블에 알림 저장"""
408
+ try:
409
+ # 대상 사용자가 없으면 작업 생략
410
+ if not user_ids_csv:
411
+ write_log_message(f"알림 저장 생략: 대상 사용자 없음 (user_ids_csv={user_ids_csv})")
412
+ return
413
+
414
+ supabase = get_db_client()
415
+
416
+ user_ids: List[str] = [uid.strip() for uid in user_ids_csv.split(',') if uid and uid.strip()]
417
+ if not user_ids:
418
+ write_log_message(f"알림 저장 생략: 유효한 사용자 ID 없음 (user_ids_csv={user_ids_csv})")
419
+ return
420
+
421
+ rows: List[Dict[str, Any]] = []
422
+ for uid in user_ids:
423
+ rows.append(
424
+ {
425
+ "id": str(uuid.uuid4()), # UUID 자동 생성
426
+ "user_id": uid,
427
+ "tenant_id": tenant_id,
428
+ "title": title,
429
+ "description": description,
430
+ "type": notif_type,
431
+ "url": url,
432
+ "from_user_id": from_user_id,
433
+ }
434
+ )
435
+
436
+ supabase.table("notifications").insert(rows).execute()
437
+ write_log_message(f"알림 저장 완료: {len(rows)}건")
438
+ except Exception as e:
439
+ handle_application_error("알림저장오류", e, raise_error=False)
440
+
441
+ # ============================================================================
442
+ # 상태 변경
443
+ # 설명: 실패 작업 상태 업데이트
444
+ # ============================================================================
445
+
446
+ async def update_task_error(todo_id: str) -> None:
447
+ """실패 작업의 상태를 FAILED로 갱신한다."""
448
+ if not todo_id:
449
+ return
450
+ def _call():
451
+ client = get_db_client()
452
+ return (
453
+ client
454
+ .table('todolist')
455
+ .update({'draft_status': 'FAILED', 'consumer': None})
456
+ .eq('id', todo_id)
457
+ .execute()
458
+ )
459
+
460
+ await _async_retry(_call, name="update_task_error", fallback=lambda: None)