process-gpt-agent-sdk 0.2.10__py3-none-any.whl → 0.3.10__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.10.dist-info/METADATA +336 -0
- process_gpt_agent_sdk-0.3.10.dist-info/RECORD +5 -0
- processgpt_agent_sdk/processgpt_agent_framework.py +402 -0
- process_gpt_agent_sdk-0.2.10.dist-info/METADATA +0 -1026
- process_gpt_agent_sdk-0.2.10.dist-info/RECORD +0 -19
- processgpt_agent_sdk/__init__.py +0 -11
- processgpt_agent_sdk/core/__init__.py +0 -0
- processgpt_agent_sdk/core/database.py +0 -464
- processgpt_agent_sdk/server.py +0 -313
- processgpt_agent_sdk/simulator.py +0 -231
- processgpt_agent_sdk/tools/__init__.py +0 -0
- processgpt_agent_sdk/tools/human_query_tool.py +0 -211
- processgpt_agent_sdk/tools/knowledge_tools.py +0 -206
- processgpt_agent_sdk/tools/safe_tool_loader.py +0 -209
- processgpt_agent_sdk/utils/__init__.py +0 -0
- processgpt_agent_sdk/utils/context_manager.py +0 -45
- processgpt_agent_sdk/utils/crewai_event_listener.py +0 -205
- processgpt_agent_sdk/utils/event_handler.py +0 -72
- processgpt_agent_sdk/utils/logger.py +0 -97
- processgpt_agent_sdk/utils/summarizer.py +0 -146
- {process_gpt_agent_sdk-0.2.10.dist-info → process_gpt_agent_sdk-0.3.10.dist-info}/WHEEL +0 -0
- {process_gpt_agent_sdk-0.2.10.dist-info → process_gpt_agent_sdk-0.3.10.dist-info}/top_level.txt +0 -0
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
# =============================================================================
|
|
4
|
-
# Imports
|
|
5
|
-
# =============================================================================
|
|
6
|
-
|
|
7
|
-
import time
|
|
8
|
-
import uuid
|
|
9
|
-
from typing import Optional, List, Literal, Type, Dict, Any
|
|
10
|
-
from datetime import datetime, timezone
|
|
11
|
-
|
|
12
|
-
from pydantic import BaseModel, Field
|
|
13
|
-
from crewai.tools import BaseTool
|
|
14
|
-
|
|
15
|
-
from ..utils.context_manager import todo_id_var, proc_id_var, all_users_var
|
|
16
|
-
from ..utils.logger import write_log_message, handle_application_error
|
|
17
|
-
from ..core.database import fetch_human_response_sync, save_notification, initialize_db, get_db_client
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
# =============================================================================
|
|
21
|
-
# 입력 스키마
|
|
22
|
-
# =============================================================================
|
|
23
|
-
class HumanQuerySchema(BaseModel):
|
|
24
|
-
"""사용자 확인/추가정보 요청 입력 스키마."""
|
|
25
|
-
|
|
26
|
-
role: str = Field(..., description="누구에게(역할 또는 대상)")
|
|
27
|
-
text: str = Field(..., description="질의 내용")
|
|
28
|
-
type: Literal["text", "select", "confirm"] = Field(
|
|
29
|
-
default="text", description="질의 유형: 자유 텍스트, 선택형, 확인 여부"
|
|
30
|
-
)
|
|
31
|
-
options: Optional[List[str]] = Field(
|
|
32
|
-
default=None, description="type이 select일 때 선택지 목록"
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# =============================================================================
|
|
37
|
-
# HumanQueryTool
|
|
38
|
-
# 설명: 보안/모호성 관련 질문을 사용자에게 전송하고 응답을 동기 대기
|
|
39
|
-
# =============================================================================
|
|
40
|
-
class HumanQueryTool(BaseTool):
|
|
41
|
-
"""사람에게 질문을 보내고 응답을 동기적으로 대기한다."""
|
|
42
|
-
|
|
43
|
-
name: str = "human_asked"
|
|
44
|
-
description: str = (
|
|
45
|
-
"👀 질문은 반드시 '매우 구체적이고 세부적'으로 작성해야 합니다.\n"
|
|
46
|
-
"- 목적, 대상, 범위/경계, 입력/출력 형식, 성공/실패 기준, 제약조건(보안/권한/시간/용량),\n"
|
|
47
|
-
" 필요한 식별자/예시/반례까지 모두 명시하세요. 추측으로 진행하지 말고 누락 정보를 반드시 질문하세요.\n\n"
|
|
48
|
-
"[1] 언제 사용해야 하나\n"
|
|
49
|
-
"1. 보안에 민감한 정보(개인정보/인증정보/비밀키 등)를 다루거나 외부로 전송할 때\n"
|
|
50
|
-
"2. 데이터베이스에 '저장/수정/삭제' 작업을 수행할 때 (읽기 전용 조회는 제외)\n"
|
|
51
|
-
"3. 요구사항 및 작업지시사항이 모호·불완전·추정에 의존하거나, 전제조건/매개변수가 불명확할 때\n"
|
|
52
|
-
"4. 외부 시스템 연동, 파일 생성/이동/삭제 등 시스템 상태를 바꾸는 작업일 때\n"
|
|
53
|
-
"⛔ 위 조건에 해당하면 이 도구 없이 진행 금지\n\n"
|
|
54
|
-
"[2] 응답 타입과 작성 방식 (항상 JSON으로 질의 전송)\n"
|
|
55
|
-
"- 공통 형식: { role: <누구에게>, text: <질의>, type: <text|select|confirm>, options?: [선택지...] }\n"
|
|
56
|
-
"- 질의 작성 가이드(반드시 포함): 5W1H, 목적/맥락, 선택 이유 또는 승인 근거, 기본값/제약,\n"
|
|
57
|
-
" 입력/출력 형식과 예시, 반례/실패 시 처리, 보안/권한/감사 로그 요구사항, 마감/우선순위\n\n"
|
|
58
|
-
"// 1) type='text' — 정보 수집(모호/불완전할 때 필수)\n"
|
|
59
|
-
"{\n"
|
|
60
|
-
' "role": "user",\n'
|
|
61
|
-
' "text": "어떤 DB 테이블/스키마/키로 저장할까요? 입력값 예시/형식, 실패 시 처리, 보존 기간까지 구체히 알려주세요.",\n'
|
|
62
|
-
' "type": "text"\n'
|
|
63
|
-
"}\n\n"
|
|
64
|
-
"// 2) type='select' — 여러 옵션 중 선택(옵션은 상호배타적, 명확/완전하게 제시)\n"
|
|
65
|
-
"{\n"
|
|
66
|
-
' "role": "system",\n'
|
|
67
|
-
' "text": "배포 환경을 선택하세요. 선택 근거(위험/롤백/감사 로그)를 함께 알려주세요.",\n'
|
|
68
|
-
' "type": "select",\n'
|
|
69
|
-
' "options": ["dev", "staging", "prod"]\n'
|
|
70
|
-
"}\n\n"
|
|
71
|
-
"// 3) type='confirm' — 보안/DB 변경 등 민감 작업 승인(필수)\n"
|
|
72
|
-
"{\n"
|
|
73
|
-
' "role": "user",\n'
|
|
74
|
-
' "text": "DB에서 주문 상태를 shipped로 업데이트합니다. 대상: order_id=..., 영향 범위: ...건, 롤백: ..., 진행 승인하시겠습니까?",\n'
|
|
75
|
-
' "type": "confirm"\n'
|
|
76
|
-
"}\n\n"
|
|
77
|
-
"타입 선택 규칙\n"
|
|
78
|
-
"- text: 모호/누락 정보가 있을 때 먼저 세부사항을 수집 (여러 번 질문 가능)\n"
|
|
79
|
-
"- select: 옵션이 둘 이상이면 반드시 options로 제시하고, 선택 기준을 text에 명시\n"
|
|
80
|
-
"- confirm: DB 저장/수정/삭제, 외부 전송, 파일 조작 등은 승인 후에만 진행\n\n"
|
|
81
|
-
"[3] 주의사항\n"
|
|
82
|
-
"- 이 도구 없이 민감/변경 작업을 임의로 진행 금지.\n"
|
|
83
|
-
"- select 타입은 반드시 'options'를 포함.\n"
|
|
84
|
-
"- confirm 응답에 따라: ✅ 승인 → 즉시 수행 / ❌ 거절 → 즉시 중단(건너뛰기).\n"
|
|
85
|
-
"- 애매하면 추가 질문을 반복하고, 충분히 구체화되기 전에는 실행하지 말 것.\n"
|
|
86
|
-
"- 민감 정보는 최소한만 노출하고 필요 시 마스킹/요약.\n"
|
|
87
|
-
"- 예시를 그대로 사용하지 말고 컨텍스트에 맞게 반드시 자연스러운 질의를 재작성하세요.\n"
|
|
88
|
-
"- 타임아웃/미응답 시 '사용자 미응답 거절'을 반환하며, 후속 변경 작업을 중단하는 것이 안전.\n"
|
|
89
|
-
"- 한 번에 하나의 주제만 질문(여러 주제면 질문을 분리). 한국어 존댓말 사용, 간결하되 상세하게."
|
|
90
|
-
)
|
|
91
|
-
args_schema: Type[HumanQuerySchema] = HumanQuerySchema
|
|
92
|
-
|
|
93
|
-
# 선택적 컨텍스트(없어도 동작). ContextVar가 우선 사용됨
|
|
94
|
-
_tenant_id: Optional[str] = None
|
|
95
|
-
_user_id: Optional[str] = None
|
|
96
|
-
_todo_id: Optional[int] = None
|
|
97
|
-
_proc_inst_id: Optional[str] = None
|
|
98
|
-
|
|
99
|
-
def __init__(
|
|
100
|
-
self,
|
|
101
|
-
tenant_id: Optional[str] = None,
|
|
102
|
-
user_id: Optional[str] = None,
|
|
103
|
-
todo_id: Optional[int] = None,
|
|
104
|
-
proc_inst_id: Optional[str] = None,
|
|
105
|
-
agent_name: Optional[str] = None,
|
|
106
|
-
**kwargs,
|
|
107
|
-
):
|
|
108
|
-
"""도구 실행 컨텍스트(테넌트/유저/프로세스)를 옵션으로 설정."""
|
|
109
|
-
super().__init__(**kwargs)
|
|
110
|
-
self._tenant_id = tenant_id
|
|
111
|
-
self._user_id = user_id
|
|
112
|
-
self._todo_id = todo_id
|
|
113
|
-
self._proc_inst_id = proc_inst_id
|
|
114
|
-
self._agent_name = agent_name
|
|
115
|
-
|
|
116
|
-
# =============================================================================
|
|
117
|
-
# 동기 실행
|
|
118
|
-
# 설명: 질문 이벤트를 저장하고 DB 폴링으로 응답을 기다린다
|
|
119
|
-
# =============================================================================
|
|
120
|
-
def _run(
|
|
121
|
-
self, role: str, text: str, type: str = "text", options: Optional[List[str]] = None
|
|
122
|
-
) -> str:
|
|
123
|
-
"""질문 이벤트를 기록하고 최종 응답 문자열을 반환."""
|
|
124
|
-
try:
|
|
125
|
-
agent_name = getattr(self, "_agent_name", None)
|
|
126
|
-
write_log_message(f"HumanQueryTool 실행: role={role}, agent_name={agent_name}, type={type}, options={options}")
|
|
127
|
-
query_id = f"human_asked_{uuid.uuid4()}"
|
|
128
|
-
|
|
129
|
-
payload: Dict[str, Any] = {
|
|
130
|
-
"role": role,
|
|
131
|
-
"text": text,
|
|
132
|
-
"type": type,
|
|
133
|
-
"options": options or [],
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
todo_id = todo_id_var.get() or self._todo_id
|
|
137
|
-
proc_inst_id = proc_id_var.get() or self._proc_inst_id
|
|
138
|
-
|
|
139
|
-
payload_with_status = {
|
|
140
|
-
**payload,
|
|
141
|
-
"status": "ASKED",
|
|
142
|
-
"agent_profile": "/images/chat-icon.png"
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
initialize_db()
|
|
146
|
-
supabase = get_db_client()
|
|
147
|
-
record = {
|
|
148
|
-
"id": str(uuid.uuid4()),
|
|
149
|
-
"job_id": query_id,
|
|
150
|
-
"todo_id": str(todo_id) if todo_id is not None else None,
|
|
151
|
-
"proc_inst_id": str(proc_inst_id) if proc_inst_id is not None else None,
|
|
152
|
-
"event_type": "human_asked",
|
|
153
|
-
"crew_type": "action",
|
|
154
|
-
"data": payload_with_status,
|
|
155
|
-
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
156
|
-
}
|
|
157
|
-
supabase.table("events").insert(record).execute()
|
|
158
|
-
|
|
159
|
-
try:
|
|
160
|
-
tenant_id = self._tenant_id
|
|
161
|
-
target_emails_csv = all_users_var.get() or ""
|
|
162
|
-
if target_emails_csv and target_emails_csv.strip():
|
|
163
|
-
write_log_message(f"알림 저장 시도: target_emails_csv={target_emails_csv}, tenant_id={tenant_id}")
|
|
164
|
-
save_notification(
|
|
165
|
-
title=text,
|
|
166
|
-
notif_type="workitem_bpm",
|
|
167
|
-
description=agent_name,
|
|
168
|
-
user_ids_csv=target_emails_csv,
|
|
169
|
-
tenant_id=tenant_id,
|
|
170
|
-
url=f"/todolist/{todo_id}",
|
|
171
|
-
from_user_id=agent_name,
|
|
172
|
-
)
|
|
173
|
-
else:
|
|
174
|
-
write_log_message("알림 저장 생략: 대상 이메일 없음")
|
|
175
|
-
except Exception as e:
|
|
176
|
-
handle_application_error("알림저장HumanTool", e, raise_error=False)
|
|
177
|
-
|
|
178
|
-
answer = self._wait_for_response(query_id)
|
|
179
|
-
return answer
|
|
180
|
-
except Exception as e:
|
|
181
|
-
handle_application_error("HumanQueryTool", e, raise_error=False)
|
|
182
|
-
return "사용자 미응답 거절"
|
|
183
|
-
|
|
184
|
-
# =============================================================================
|
|
185
|
-
# 응답 대기
|
|
186
|
-
# 설명: events 테이블에서 human_response를 폴링하여 응답을 가져온다
|
|
187
|
-
# =============================================================================
|
|
188
|
-
def _wait_for_response(
|
|
189
|
-
self, job_id: str, timeout_sec: int = 180, poll_interval_sec: int = 5
|
|
190
|
-
) -> str:
|
|
191
|
-
"""DB 폴링으로 사람의 응답을 기다려 문자열로 반환."""
|
|
192
|
-
deadline = time.time() + timeout_sec
|
|
193
|
-
|
|
194
|
-
while time.time() < deadline:
|
|
195
|
-
try:
|
|
196
|
-
write_log_message(f"HumanQueryTool 응답 폴링: {job_id}")
|
|
197
|
-
event = fetch_human_response_sync(job_id=job_id)
|
|
198
|
-
if event:
|
|
199
|
-
write_log_message(f"HumanQueryTool 응답 수신: {event}")
|
|
200
|
-
data = event.get("data") or {}
|
|
201
|
-
answer = (data or {}).get("answer")
|
|
202
|
-
if isinstance(answer, str):
|
|
203
|
-
write_log_message("사람 응답 수신 완료")
|
|
204
|
-
return answer
|
|
205
|
-
return str(data)
|
|
206
|
-
|
|
207
|
-
except Exception as e:
|
|
208
|
-
write_log_message(f"인간 응답 대기 중... (오류: {str(e)[:100]})")
|
|
209
|
-
time.sleep(poll_interval_sec)
|
|
210
|
-
return "사용자 미응답 거절"
|
|
211
|
-
|
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from typing import Optional, List, Type
|
|
3
|
-
from pydantic import BaseModel, Field, PrivateAttr, field_validator
|
|
4
|
-
from crewai.tools import BaseTool
|
|
5
|
-
from dotenv import load_dotenv
|
|
6
|
-
from mem0 import Memory
|
|
7
|
-
import requests
|
|
8
|
-
from ..utils.logger import write_log_message, handle_application_error
|
|
9
|
-
|
|
10
|
-
# ============================================================================
|
|
11
|
-
# 설정 및 초기화
|
|
12
|
-
# ============================================================================
|
|
13
|
-
|
|
14
|
-
load_dotenv()
|
|
15
|
-
|
|
16
|
-
DB_USER = os.getenv("DB_USER")
|
|
17
|
-
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
|
18
|
-
DB_HOST = os.getenv("DB_HOST")
|
|
19
|
-
DB_PORT = os.getenv("DB_PORT")
|
|
20
|
-
DB_NAME = os.getenv("DB_NAME")
|
|
21
|
-
|
|
22
|
-
CONNECTION_STRING = None
|
|
23
|
-
if all([DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_NAME]):
|
|
24
|
-
CONNECTION_STRING = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
|
25
|
-
else:
|
|
26
|
-
write_log_message("mem0 연결 문자열이 설정되지 않았습니다(DB_* env). 기능은 제한될 수 있습니다.")
|
|
27
|
-
|
|
28
|
-
# ============================================================================
|
|
29
|
-
# 스키마 정의
|
|
30
|
-
# ============================================================================
|
|
31
|
-
|
|
32
|
-
class KnowledgeQuerySchema(BaseModel):
|
|
33
|
-
query: str = Field(..., description="검색할 지식 쿼리")
|
|
34
|
-
|
|
35
|
-
@field_validator('query', mode='before')
|
|
36
|
-
@classmethod
|
|
37
|
-
def validate_query(cls, v):
|
|
38
|
-
import json
|
|
39
|
-
if isinstance(v, dict):
|
|
40
|
-
for k in ("description", "query", "q", "text", "message"):
|
|
41
|
-
if k in v and v[k]:
|
|
42
|
-
return str(v[k])
|
|
43
|
-
return "" if not v else json.dumps(v, ensure_ascii=False)
|
|
44
|
-
return v if isinstance(v, str) else str(v)
|
|
45
|
-
|
|
46
|
-
# ============================================================================
|
|
47
|
-
# 지식 검색 도구
|
|
48
|
-
# ============================================================================
|
|
49
|
-
|
|
50
|
-
class Mem0Tool(BaseTool):
|
|
51
|
-
"""Supabase 기반 mem0 지식 검색 도구 - 에이전트별"""
|
|
52
|
-
name: str = "mem0"
|
|
53
|
-
description: str = (
|
|
54
|
-
"🧠 에이전트별 개인 지식 저장소 검색 도구\n\n"
|
|
55
|
-
"🚨 필수 검색 순서: 작업 전 반드시 피드백부터 검색!\n\n"
|
|
56
|
-
"저장된 정보:\n"
|
|
57
|
-
"🔴 과거 동일한 작업에 대한 피드백 및 교훈 (최우선 검색 대상)\n"
|
|
58
|
-
"🔴 과거 실패 사례 및 개선 방안\n"
|
|
59
|
-
"• 객관적 정보 (사람명, 수치, 날짜, 사물 등)\n"
|
|
60
|
-
"검색 목적:\n"
|
|
61
|
-
"- 작업지시사항을 올바르게 수행하기 위해 필요한 정보(매개변수, 제약, 의존성)와\n"
|
|
62
|
-
" 안전 수행을 위한 피드백/주의사항을 찾기 위함\n"
|
|
63
|
-
"- 과거 실패 경험을 통한 실수 방지\n"
|
|
64
|
-
"- 정확한 객관적 정보 조회\n\n"
|
|
65
|
-
"사용 지침:\n"
|
|
66
|
-
"- 현재 작업 맥락(사용자 요청, 시스템/도구 출력, 최근 단계)을 근거로 자연어의 완전한 문장으로 질의하세요.\n"
|
|
67
|
-
"- 핵심 키워드 + 엔터티(고객명, 테이블명, 날짜 등) + 제약(환경/범위)을 조합하세요.\n"
|
|
68
|
-
"- 동의어/영문 용어를 섞어 2~3개의 표현으로 재질의하여 누락을 줄이세요.\n"
|
|
69
|
-
"- 필요한 경우 좁은 쿼리 → 넓은 쿼리 순서로 반복 검색하세요. (필요 시 기간/버전 범위 명시)\n"
|
|
70
|
-
"- 동일 정보를 다른 표현으로 재질의하며, 최신/가장 관련 결과를 우선 검토하세요.\n\n"
|
|
71
|
-
"⚡ 핵심: 어떤 작업이든 시작 전에, 해당 작업을 안전하게 수행하기 위한 피드백/주의사항과\n"
|
|
72
|
-
" 필수 매개변수를 먼저 질의하여 확보하세요!"
|
|
73
|
-
)
|
|
74
|
-
args_schema: Type[KnowledgeQuerySchema] = KnowledgeQuerySchema
|
|
75
|
-
_tenant_id: Optional[str] = PrivateAttr()
|
|
76
|
-
_user_id: Optional[str] = PrivateAttr()
|
|
77
|
-
_namespace: Optional[str] = PrivateAttr()
|
|
78
|
-
_memory: Optional[Memory] = PrivateAttr(default=None)
|
|
79
|
-
|
|
80
|
-
def __init__(self, tenant_id: str = None, user_id: str = None, **kwargs):
|
|
81
|
-
super().__init__(**kwargs)
|
|
82
|
-
self._tenant_id = tenant_id
|
|
83
|
-
self._user_id = user_id
|
|
84
|
-
self._namespace = user_id
|
|
85
|
-
self._memory = self._initialize_memory()
|
|
86
|
-
write_log_message(f"Mem0Tool 초기화: user_id={self._user_id}, namespace={self._namespace}")
|
|
87
|
-
|
|
88
|
-
def _initialize_memory(self) -> Optional[Memory]:
|
|
89
|
-
"""Memory 인스턴스 초기화 - 에이전트별"""
|
|
90
|
-
if not CONNECTION_STRING:
|
|
91
|
-
return None
|
|
92
|
-
config = {
|
|
93
|
-
"vector_store": {
|
|
94
|
-
"provider": "supabase",
|
|
95
|
-
"config": {
|
|
96
|
-
"connection_string": CONNECTION_STRING,
|
|
97
|
-
"collection_name": "memories",
|
|
98
|
-
"index_method": "hnsw",
|
|
99
|
-
"index_measure": "cosine_distance"
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return Memory.from_config(config_dict=config)
|
|
104
|
-
|
|
105
|
-
def _run(self, query: str) -> str:
|
|
106
|
-
"""지식 검색 및 결과 반환 - 에이전트별 메모리에서"""
|
|
107
|
-
if not query:
|
|
108
|
-
return "검색할 쿼리를 입력해주세요."
|
|
109
|
-
if not self._user_id:
|
|
110
|
-
return "개인지식 검색 비활성화: user_id 없음"
|
|
111
|
-
if not self._memory:
|
|
112
|
-
return "mem0 비활성화: DB 연결 정보(DB_*)가 설정되지 않았습니다."
|
|
113
|
-
|
|
114
|
-
try:
|
|
115
|
-
write_log_message(f"에이전트별 검색 시작: user_id={self._user_id}, query='{query}'")
|
|
116
|
-
results = self._memory.search(query, user_id=self._user_id)
|
|
117
|
-
hits = results.get("results", [])
|
|
118
|
-
|
|
119
|
-
THRESHOLD = 0.5
|
|
120
|
-
MIN_RESULTS = 5
|
|
121
|
-
hits_sorted = sorted(hits, key=lambda x: x.get("score", 0), reverse=True)
|
|
122
|
-
filtered_hits = [h for h in hits_sorted if h.get("score", 0) >= THRESHOLD]
|
|
123
|
-
if len(filtered_hits) < MIN_RESULTS:
|
|
124
|
-
filtered_hits = hits_sorted[:MIN_RESULTS]
|
|
125
|
-
hits = filtered_hits
|
|
126
|
-
|
|
127
|
-
write_log_message(f"에이전트별 검색 결과: {len(hits)}개 항목 발견")
|
|
128
|
-
|
|
129
|
-
if not hits:
|
|
130
|
-
return f"'{query}'에 대한 개인 지식이 없습니다."
|
|
131
|
-
|
|
132
|
-
return self._format_results(hits)
|
|
133
|
-
|
|
134
|
-
except Exception as e:
|
|
135
|
-
handle_application_error("지식검색오류", e, raise_error=False)
|
|
136
|
-
return f"지식검색오류: {e}"
|
|
137
|
-
|
|
138
|
-
def _format_results(self, hits: List[dict]) -> str:
|
|
139
|
-
"""검색 결과 포맷팅"""
|
|
140
|
-
items = []
|
|
141
|
-
for idx, hit in enumerate(hits, start=1):
|
|
142
|
-
memory_text = hit.get("memory", "")
|
|
143
|
-
score = hit.get("score", 0)
|
|
144
|
-
items.append(f"개인지식 {idx} (관련도: {score:.2f})\n{memory_text}")
|
|
145
|
-
|
|
146
|
-
return "\n\n".join(items)
|
|
147
|
-
|
|
148
|
-
# ============================================================================
|
|
149
|
-
# 사내 문서 검색 (memento) 도구
|
|
150
|
-
# ============================================================================
|
|
151
|
-
|
|
152
|
-
class MementoQuerySchema(BaseModel):
|
|
153
|
-
query: str = Field(..., description="검색 키워드 또는 질문")
|
|
154
|
-
|
|
155
|
-
class MementoTool(BaseTool):
|
|
156
|
-
"""사내 문서 검색을 수행하는 도구"""
|
|
157
|
-
name: str = "memento"
|
|
158
|
-
description: str = (
|
|
159
|
-
"🔒 보안 민감한 사내 문서 검색 도구\n\n"
|
|
160
|
-
"저장된 정보:\n"
|
|
161
|
-
"• 보안 민감한 사내 기밀 문서\n"
|
|
162
|
-
"• 대용량 사내 문서 및 정책 자료\n"
|
|
163
|
-
"• 객관적이고 정확한 회사 내부 지식\n"
|
|
164
|
-
"• 업무 프로세스, 규정, 기술 문서\n\n"
|
|
165
|
-
"검색 목적:\n"
|
|
166
|
-
"- 작업지시사항을 올바르게 수행하기 위한 회사 정책/규정/프로세스/매뉴얼 확보\n"
|
|
167
|
-
"- 최신 버전의 표준과 가이드라인 확인\n\n"
|
|
168
|
-
"사용 지침:\n"
|
|
169
|
-
"- 현재 작업/요청과 직접 연결된 문맥을 담아 자연어의 완전한 문장으로 질의하세요.\n"
|
|
170
|
-
"- 문서 제목/버전/담당조직/기간/환경(프로덕션·스테이징·모듈 등) 조건을 명확히 포함하세요.\n"
|
|
171
|
-
"- 약어·정식명칭, 한·영 용어를 함께 사용해 2~3회 재질의하며 누락을 줄이세요.\n"
|
|
172
|
-
"- 처음엔 좁게, 필요 시 점진적으로 범위를 넓혀 검색하세요.\n\n"
|
|
173
|
-
"⚠️ 보안 민감 정보 포함 - 적절한 권한과 용도로만 사용"
|
|
174
|
-
)
|
|
175
|
-
args_schema: Type[MementoQuerySchema] = MementoQuerySchema
|
|
176
|
-
_tenant_id: str = PrivateAttr()
|
|
177
|
-
|
|
178
|
-
def __init__(self, tenant_id: str = "localhost", **kwargs):
|
|
179
|
-
super().__init__(**kwargs)
|
|
180
|
-
self._tenant_id = tenant_id
|
|
181
|
-
write_log_message(f"MementoTool 초기화: tenant_id={self._tenant_id}")
|
|
182
|
-
|
|
183
|
-
def _run(self, query: str) -> str:
|
|
184
|
-
try:
|
|
185
|
-
write_log_message(f"Memento 문서 검색 시작: tenant_id='{self._tenant_id}', query='{query}'")
|
|
186
|
-
response = requests.post(
|
|
187
|
-
"http://memento.process-gpt.io/retrieve",
|
|
188
|
-
json={"query": query, "options": {"tenant_id": self._tenant_id}}
|
|
189
|
-
)
|
|
190
|
-
if response.status_code != 200:
|
|
191
|
-
return f"API 오류: {response.status_code}"
|
|
192
|
-
data = response.json()
|
|
193
|
-
if not data.get("response"):
|
|
194
|
-
return f"테넌트 '{self._tenant_id}'에서 '{query}' 검색 결과가 없습니다."
|
|
195
|
-
results = []
|
|
196
|
-
docs = data.get("response", [])
|
|
197
|
-
write_log_message(f"Memento 검색 결과 개수: {len(docs)}")
|
|
198
|
-
for doc in docs:
|
|
199
|
-
fname = doc.get('metadata', {}).get('file_name', 'unknown')
|
|
200
|
-
idx = doc.get('metadata', {}).get('chunk_index', 'unknown')
|
|
201
|
-
content = doc.get('page_content', '')
|
|
202
|
-
results.append(f"📄 파일: {fname} (청크 #{idx})\n내용: {content}\n---")
|
|
203
|
-
return f"테넌트 '{self._tenant_id}'에서 '{query}' 검색 결과:\n\n" + "\n".join(results)
|
|
204
|
-
except Exception as e:
|
|
205
|
-
handle_application_error("문서검색오류", e, raise_error=False)
|
|
206
|
-
return f"문서검색오류: {e}"
|
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import subprocess
|
|
3
|
-
import time
|
|
4
|
-
from typing import List, Dict, Optional
|
|
5
|
-
import anyio
|
|
6
|
-
from mcp.client.stdio import StdioServerParameters
|
|
7
|
-
from crewai_tools import MCPServerAdapter
|
|
8
|
-
from .knowledge_tools import Mem0Tool, MementoTool
|
|
9
|
-
from ..utils.logger import write_log_message, handle_application_error
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# =============================================================================
|
|
13
|
-
# SafeToolLoader
|
|
14
|
-
# 설명: 로컬/외부 MCP 도구들을 안전하게 초기화·로드·종료 관리
|
|
15
|
-
# =============================================================================
|
|
16
|
-
class SafeToolLoader:
|
|
17
|
-
"""도구 로더 클래스"""
|
|
18
|
-
adapters = []
|
|
19
|
-
|
|
20
|
-
ANYIO_PATCHED: bool = False
|
|
21
|
-
|
|
22
|
-
def __init__(self, tenant_id: Optional[str] = None, user_id: Optional[str] = None, agent_name: Optional[str] = None, mcp_config: Optional[Dict] = None):
|
|
23
|
-
"""실행 컨텍스트(tenant/user/agent)와 MCP 설정을 보관한다."""
|
|
24
|
-
self.tenant_id = tenant_id
|
|
25
|
-
self.user_id = user_id
|
|
26
|
-
self.agent_name = agent_name
|
|
27
|
-
self._mcp_servers = (mcp_config or {}).get('mcpServers', {})
|
|
28
|
-
self.local_tools = ["mem0", "memento", "human_asked"]
|
|
29
|
-
write_log_message(f"SafeToolLoader 초기화 완료 (tenant_id: {tenant_id}, user_id: {user_id})")
|
|
30
|
-
|
|
31
|
-
# =============================================================================
|
|
32
|
-
# Warmup (npx 서버 사전 준비)
|
|
33
|
-
# =============================================================================
|
|
34
|
-
def warmup_server(self, server_key: str, mcp_config: Optional[Dict] = None):
|
|
35
|
-
"""npx 서버 패키지를 미리 캐싱해 최초 실행 지연을 줄인다."""
|
|
36
|
-
servers = (mcp_config or {}).get('mcpServers') or self._mcp_servers or {}
|
|
37
|
-
server_config = servers.get(server_key, {}) if isinstance(servers, dict) else {}
|
|
38
|
-
if not server_config or server_config.get("command") != "npx":
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
npx_command_path = self._find_npx_command()
|
|
42
|
-
if not npx_command_path:
|
|
43
|
-
return
|
|
44
|
-
|
|
45
|
-
arguments_list = server_config.get("args", [])
|
|
46
|
-
if not (arguments_list and arguments_list[0] == "-y"):
|
|
47
|
-
return
|
|
48
|
-
|
|
49
|
-
package_name = arguments_list[1]
|
|
50
|
-
|
|
51
|
-
try:
|
|
52
|
-
subprocess.run([npx_command_path, "-y", package_name, "--help"], capture_output=True, timeout=10, shell=True)
|
|
53
|
-
return
|
|
54
|
-
except subprocess.TimeoutExpired:
|
|
55
|
-
pass
|
|
56
|
-
except Exception:
|
|
57
|
-
pass
|
|
58
|
-
|
|
59
|
-
try:
|
|
60
|
-
subprocess.run([npx_command_path, "-y", package_name, "--help"], capture_output=True, timeout=60, shell=True)
|
|
61
|
-
except Exception:
|
|
62
|
-
pass
|
|
63
|
-
|
|
64
|
-
# =============================================================================
|
|
65
|
-
# 유틸: npx 경로 탐색
|
|
66
|
-
# =============================================================================
|
|
67
|
-
def _find_npx_command(self) -> str:
|
|
68
|
-
"""npx 실행 파일 경로를 탐색해 반환한다."""
|
|
69
|
-
try:
|
|
70
|
-
import shutil
|
|
71
|
-
npx_path = shutil.which("npx") or shutil.which("npx.cmd")
|
|
72
|
-
if npx_path:
|
|
73
|
-
return npx_path
|
|
74
|
-
except Exception:
|
|
75
|
-
pass
|
|
76
|
-
return "npx"
|
|
77
|
-
|
|
78
|
-
# =============================================================================
|
|
79
|
-
# 로컬 도구 생성
|
|
80
|
-
# =============================================================================
|
|
81
|
-
def create_tools_from_names(self, tool_names: List[str], mcp_config: Optional[Dict] = None) -> List:
|
|
82
|
-
"""tool_names 리스트에서 실제 Tool 객체들 생성"""
|
|
83
|
-
if isinstance(tool_names, str):
|
|
84
|
-
tool_names = [tool_names]
|
|
85
|
-
write_log_message(f"도구 생성 요청: {tool_names}")
|
|
86
|
-
|
|
87
|
-
tools = []
|
|
88
|
-
|
|
89
|
-
tools.extend(self._load_mem0())
|
|
90
|
-
tools.extend(self._load_memento())
|
|
91
|
-
tools.extend(self._load_human_asked())
|
|
92
|
-
|
|
93
|
-
for name in tool_names:
|
|
94
|
-
key = name.strip().lower()
|
|
95
|
-
if key in self.local_tools:
|
|
96
|
-
continue
|
|
97
|
-
else:
|
|
98
|
-
self.warmup_server(key, mcp_config)
|
|
99
|
-
tools.extend(self._load_mcp_tool(key, mcp_config))
|
|
100
|
-
|
|
101
|
-
write_log_message(f"총 {len(tools)}개 도구 생성 완료")
|
|
102
|
-
return tools
|
|
103
|
-
|
|
104
|
-
# =============================================================================
|
|
105
|
-
# 로컬 도구 로더들
|
|
106
|
-
# =============================================================================
|
|
107
|
-
def _load_mem0(self) -> List:
|
|
108
|
-
"""mem0 도구 로드 - 에이전트별 메모리"""
|
|
109
|
-
try:
|
|
110
|
-
if not self.user_id:
|
|
111
|
-
write_log_message("mem0 도구 로드 생략: user_id 없음")
|
|
112
|
-
return []
|
|
113
|
-
return [Mem0Tool(tenant_id=self.tenant_id, user_id=self.user_id)]
|
|
114
|
-
except Exception as error:
|
|
115
|
-
handle_application_error("툴mem0오류", error, raise_error=False)
|
|
116
|
-
return []
|
|
117
|
-
|
|
118
|
-
def _load_memento(self) -> List:
|
|
119
|
-
"""memento 도구 로드"""
|
|
120
|
-
try:
|
|
121
|
-
return [MementoTool(tenant_id=self.tenant_id)]
|
|
122
|
-
except Exception as error:
|
|
123
|
-
handle_application_error("툴memento오류", error, raise_error=False)
|
|
124
|
-
return []
|
|
125
|
-
|
|
126
|
-
def _load_human_asked(self) -> List:
|
|
127
|
-
"""human_asked 도구 로드 (선택사항: 사용 시 외부에서 주입)"""
|
|
128
|
-
try:
|
|
129
|
-
return []
|
|
130
|
-
except Exception as error:
|
|
131
|
-
handle_application_error("툴human오류", error, raise_error=False)
|
|
132
|
-
return []
|
|
133
|
-
|
|
134
|
-
# =============================================================================
|
|
135
|
-
# 외부 MCP 도구 로더
|
|
136
|
-
# =============================================================================
|
|
137
|
-
def _load_mcp_tool(self, tool_name: str, mcp_config: Optional[Dict] = None) -> List:
|
|
138
|
-
"""MCP 도구 로드 (timeout & retry 지원)"""
|
|
139
|
-
self._apply_anyio_patch()
|
|
140
|
-
|
|
141
|
-
servers = (mcp_config or {}).get('mcpServers') or self._mcp_servers or {}
|
|
142
|
-
server_config = servers.get(tool_name, {}) if isinstance(servers, dict) else {}
|
|
143
|
-
if not server_config:
|
|
144
|
-
return []
|
|
145
|
-
|
|
146
|
-
environment_variables = os.environ.copy()
|
|
147
|
-
environment_variables.update(server_config.get("env", {}))
|
|
148
|
-
timeout_seconds = server_config.get("timeout", 40)
|
|
149
|
-
|
|
150
|
-
max_retries = 2
|
|
151
|
-
retry_delay = 5
|
|
152
|
-
|
|
153
|
-
for attempt in range(1, max_retries + 1):
|
|
154
|
-
try:
|
|
155
|
-
cmd = server_config["command"]
|
|
156
|
-
if cmd == "npx":
|
|
157
|
-
cmd = self._find_npx_command() or cmd
|
|
158
|
-
|
|
159
|
-
params = StdioServerParameters(
|
|
160
|
-
command=cmd,
|
|
161
|
-
args=server_config.get("args", []),
|
|
162
|
-
env=environment_variables,
|
|
163
|
-
timeout=timeout_seconds
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
adapter = MCPServerAdapter(params)
|
|
167
|
-
SafeToolLoader.adapters.append(adapter)
|
|
168
|
-
write_log_message(f"{tool_name} MCP 로드 성공 (툴 {len(adapter.tools)}개): {[tool.name for tool in adapter.tools]}")
|
|
169
|
-
return adapter.tools
|
|
170
|
-
|
|
171
|
-
except Exception as e:
|
|
172
|
-
if attempt < max_retries:
|
|
173
|
-
time.sleep(retry_delay)
|
|
174
|
-
else:
|
|
175
|
-
handle_application_error(f"툴{tool_name}오류", e, raise_error=False)
|
|
176
|
-
return []
|
|
177
|
-
|
|
178
|
-
# =============================================================================
|
|
179
|
-
# anyio 서브프로세스 stderr 패치
|
|
180
|
-
# =============================================================================
|
|
181
|
-
def _apply_anyio_patch(self):
|
|
182
|
-
"""stderr에 fileno 없음 대비: PIPE로 보정해 예외를 방지한다."""
|
|
183
|
-
if SafeToolLoader.ANYIO_PATCHED:
|
|
184
|
-
return
|
|
185
|
-
from anyio._core._subprocesses import open_process as _orig
|
|
186
|
-
|
|
187
|
-
async def patched_open_process(*args, **kwargs):
|
|
188
|
-
stderr = kwargs.get('stderr')
|
|
189
|
-
if not (hasattr(stderr, 'fileno') and stderr.fileno()):
|
|
190
|
-
kwargs['stderr'] = subprocess.PIPE
|
|
191
|
-
return await _orig(*args, **kwargs)
|
|
192
|
-
|
|
193
|
-
anyio.open_process = patched_open_process
|
|
194
|
-
anyio._core._subprocesses.open_process = patched_open_process
|
|
195
|
-
SafeToolLoader.ANYIO_PATCHED = True
|
|
196
|
-
|
|
197
|
-
# =============================================================================
|
|
198
|
-
# 종료 처리
|
|
199
|
-
# =============================================================================
|
|
200
|
-
@classmethod
|
|
201
|
-
def shutdown_all_adapters(cls):
|
|
202
|
-
"""모든 MCPServerAdapter 연결을 안전하게 종료한다."""
|
|
203
|
-
for adapter in cls.adapters:
|
|
204
|
-
try:
|
|
205
|
-
adapter.stop()
|
|
206
|
-
except Exception as error:
|
|
207
|
-
handle_application_error("툴종료오류", error, raise_error=False)
|
|
208
|
-
write_log_message("모든 MCPServerAdapter 연결 종료 완료")
|
|
209
|
-
cls.adapters.clear()
|
|
File without changes
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
# =============================================================================
|
|
4
|
-
# Context Manager
|
|
5
|
-
# 설명: 요청/프로세스 범위의 컨텍스트 값을 ContextVar로 관리
|
|
6
|
-
# =============================================================================
|
|
7
|
-
|
|
8
|
-
from contextvars import ContextVar
|
|
9
|
-
from typing import Optional
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# 컨텍스트 변수 정의
|
|
13
|
-
todo_id_var: ContextVar[Optional[str]] = ContextVar("todo_id", default=None)
|
|
14
|
-
proc_id_var: ContextVar[Optional[str]] = ContextVar("proc_id", default=None)
|
|
15
|
-
crew_type_var: ContextVar[Optional[str]] = ContextVar("crew_type", default=None)
|
|
16
|
-
form_key_var: ContextVar[Optional[str]] = ContextVar("form_key", default=None)
|
|
17
|
-
form_id_var: ContextVar[Optional[str]] = ContextVar("form_id", default=None)
|
|
18
|
-
all_users_var: ContextVar[Optional[str]] = ContextVar("all_users", default=None)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def set_context(*, todo_id: Optional[str] = None, proc_inst_id: Optional[str] = None, crew_type: Optional[str] = None, form_key: Optional[str] = None, form_id: Optional[str] = None, all_users: Optional[str] = None) -> None:
|
|
22
|
-
"""전달된 값들만 ContextVar에 설정한다."""
|
|
23
|
-
if todo_id is not None:
|
|
24
|
-
todo_id_var.set(todo_id)
|
|
25
|
-
if proc_inst_id is not None:
|
|
26
|
-
proc_id_var.set(proc_inst_id)
|
|
27
|
-
if crew_type is not None:
|
|
28
|
-
crew_type_var.set(crew_type)
|
|
29
|
-
if form_key is not None:
|
|
30
|
-
form_key_var.set(form_key)
|
|
31
|
-
if form_id is not None:
|
|
32
|
-
form_id_var.set(form_id)
|
|
33
|
-
if all_users is not None:
|
|
34
|
-
all_users_var.set(all_users)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def reset_context() -> None:
|
|
38
|
-
"""모든 컨텍스트 값을 초기 상태(None)로 되돌린다."""
|
|
39
|
-
todo_id_var.set(None)
|
|
40
|
-
proc_id_var.set(None)
|
|
41
|
-
crew_type_var.set(None)
|
|
42
|
-
form_key_var.set(None)
|
|
43
|
-
form_id_var.set(None)
|
|
44
|
-
all_users_var.set(None)
|
|
45
|
-
|