diff --git a/CHANGELOG.md b/CHANGELOG.md index 39bc2a3bb..db42735b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,15 @@ ## [Unreleased] +### Source-bound 요약·업무·관계·일정 경계 + +- 입력과 무관한 고정 2023 fixture로 결정 사항과 미해결 질문, 업무와 마감일, + 발신자 조직 관계와 중요도, 회의 시간·장소 후보를 성공 응답으로 반환하던 + `thread_summarizer`, `action_item_extractor`, `sender_dag_analytics`, + `meeting_candidate_finder`를 내장 도구 레지스트리에서 제거했습니다. 이를 + 대신하는 고정값·템플릿 fallback은 없습니다. source-bound evidence와 선언된 + provider가 없는 동안 catalog에 노출하지 않으며 상세 조회와 실행은 `404`로 + fail closed 합니다. 아래의 과거 기능 추가 기록은 당시 변경 이력으로 보존하며 + 현재 지원 계약을 뜻하지 않습니다. + ### 이메일 보안 판정 경계 (Email Security Verdict Boundary) - 고정 키워드와 발신자 도메인 suffix만으로 확정적 spam/phishing boolean과 diff --git a/backend/api/tools.py b/backend/api/tools.py index a442d8044..8dd28a39b 100644 --- a/backend/api/tools.py +++ b/backend/api/tools.py @@ -168,45 +168,6 @@ def _validate_parameters(self, code: str, params: Dict[str, Any]) -> Dict[str, A # Initialize default tools -async def thread_summarizer_handler(params: Dict[str, Any]) -> Any: - thread_id = params.get("thread_id", "") - return { - "summary": f"이메일 스레드 {thread_id}에 대한 요약입니다. 여러 논의 사항이 정리되었습니다.", - "key_points": ["일정 조율 완료", "계약서 초안 검토 필요"], - "unresolved_questions": ["최종 승인자 확인"], - } - - -async def action_item_extractor_handler(params: Dict[str, Any]) -> Any: - return { - "action_items": [ - {"task": "문서 검토 및 피드백 작성", "deadline": "2023-10-25T12:00:00Z"}, - {"task": "주간 회의 자료 준비", "deadline": "2023-10-26T09:00:00Z"}, - ], - "source_length": len(params.get("email_content", "")), - } - - -async def sender_dag_analytics_handler(params: Dict[str, Any]) -> Any: - sender = params.get("sender_email", "") - return { - "sender": sender, - "importance": "high", - "department": "엔지니어링 팀", - "recent_interactions": 15, - } - - -async def meeting_candidate_finder_handler(params: Dict[str, Any]) -> Any: - return { - "candidates": [ - {"time": "2023-10-26T14:00:00Z", "location": "온라인 (Zoom)"}, - {"time": "2023-10-27T10:00:00Z", "location": "회의실 A"}, - ], - "context_preview": params.get("email_content", "")[:30] + "...", - } - - async def tone_analyzer_handler(params: Dict[str, Any]) -> Any: draft = params.get("draft_content", "") rel = params.get("recipient_relationship", "unknown") @@ -415,50 +376,6 @@ def _parameter_matches_type(value: Any, expected_type: str) -> bool: return validators.get(expected_type, validators["string"])(value) -registry.register( - ToolInfo( - code="thread_summarizer", - name="이메일 맥락 요약 (Thread Summarizer)", - description="긴 이메일 스레드를 분석하여 핵심 맥락, 결정 사항, 미해결 질문을 추출합니다.", - category="이메일 분석", - parameters={"thread_id": "string"}, - ), - thread_summarizer_handler, -) - -registry.register( - ToolInfo( - code="action_item_extractor", - name="실행 항목 자동 추출 (Action Item Extractor)", - description="이메일 본문에서 사용자가 수행해야 할 작업(Task)과 마감일을 자동으로 식별합니다.", - category="작업 관리", - parameters={"email_content": "string"}, - ), - action_item_extractor_handler, -) - -registry.register( - ToolInfo( - code="sender_dag_analytics", - name="발신자 관계 분석 (Sender DAG Analytics)", - description="과거 이메일 기록을 바탕으로 발신자와의 관계(조직도 상 위치, 중요도 등)를 분석합니다.", - category="관계 인텔리전스", - parameters={"sender_email": "string"}, - ), - sender_dag_analytics_handler, -) - -registry.register( - ToolInfo( - code="meeting_candidate_finder", - name="일정 후보 추출 (Meeting Candidate Finder)", - description="이메일 텍스트에서 회의나 약속으로 예상되는 시간대와 장소를 추출하여 캘린더 등록 초안을 생성합니다.", - category="일정 관리", - parameters={"email_content": "string"}, - ), - meeting_candidate_finder_handler, -) - registry.register( ToolInfo( code="tone_analyzer", diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py index 42d2b2e74..714ac666e 100644 --- a/backend/tests/test_tools_api.py +++ b/backend/tests/test_tools_api.py @@ -27,6 +27,14 @@ from main import app +REMOVED_CANNED_SOURCE_DERIVED_TOOL_CODES = ( + "thread_summarizer", + "action_item_extractor", + "sender_dag_analytics", + "meeting_candidate_finder", +) + + def _base64url_encode(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") @@ -105,16 +113,52 @@ def test_get_tools_returns_valid_data(): assert "is_active" in first_tool -def test_get_tool_success(): +def test_get_retained_tool_success(): with TestClient(app) as client: response = client.get( - "/api/tools/thread_summarizer", + "/api/tools/text_analyzer", headers={"Authorization": f"Bearer {_signed_session_token()}"}, ) + assert response.status_code == 200 - data = response.json() - assert data["code"] == "thread_summarizer" - assert data["name"] == "이메일 맥락 요약 (Thread Summarizer)" + assert response.json()["code"] == "text_analyzer" + + +@pytest.mark.parametrize("tool_code", REMOVED_CANNED_SOURCE_DERIVED_TOOL_CODES) +def test_startup_catalog_omits_canned_source_derived_tools(tool_code): + with TestClient(app) as client: + response = client.get( + "/api/tools", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + ) + + assert response.status_code == 200 + assert tool_code not in {tool["code"] for tool in response.json()} + + +@pytest.mark.parametrize("tool_code", REMOVED_CANNED_SOURCE_DERIVED_TOOL_CODES) +def test_removed_canned_source_derived_tool_detail_returns_not_found(tool_code): + with TestClient(app) as client: + response = client.get( + f"/api/tools/{tool_code}", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Tool not found"} + + +@pytest.mark.parametrize("tool_code", REMOVED_CANNED_SOURCE_DERIVED_TOOL_CODES) +def test_removed_canned_source_derived_tool_execute_returns_not_found(tool_code): + with TestClient(app) as client: + response = client.post( + f"/api/tools/{tool_code}/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {}}, + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Tool not found"} def test_get_tool_not_found(): @@ -183,70 +227,6 @@ def test_keyword_extractor_is_disclosed_as_lexical_term_frequency(): ) -@pytest.mark.asyncio -async def test_execute_tool_success(): - with TestClient(app) as client: - response = client.post( - "/api/tools/thread_summarizer/execute", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"parameters": {"thread_id": "123"}}, - ) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert "summary" in data["result"] - assert "123" in data["result"]["summary"] - assert "key_points" in data["result"] - assert "unresolved_questions" in data["result"] - - -@pytest.mark.asyncio -async def test_execute_action_item_extractor(): - with TestClient(app) as client: - response = client.post( - "/api/tools/action_item_extractor/execute", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"parameters": {"email_content": "Please review by tomorrow."}}, - ) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert "action_items" in data["result"] - assert len(data["result"]["action_items"]) == 2 - assert "source_length" in data["result"] - - -@pytest.mark.asyncio -async def test_execute_sender_dag_analytics(): - with TestClient(app) as client: - response = client.post( - "/api/tools/sender_dag_analytics/execute", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"parameters": {"sender_email": "test@example.com"}}, - ) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["result"]["sender"] == "test@example.com" - assert data["result"]["department"] == "엔지니어링 팀" - - -@pytest.mark.asyncio -async def test_execute_meeting_candidate_finder(): - with TestClient(app) as client: - response = client.post( - "/api/tools/meeting_candidate_finder/execute", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"parameters": {"email_content": "Let's meet tomorrow at 2pm."}}, - ) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert "candidates" in data["result"] - assert len(data["result"]["candidates"]) == 2 - assert "context_preview" in data["result"] - - @pytest.mark.asyncio async def test_execute_tone_analyzer(): with TestClient(app) as client: @@ -272,11 +252,11 @@ async def test_execute_tone_analyzer(): def test_execute_tool_rejects_unexpected_parameter(): with TestClient(app) as client: response = client.post( - "/api/tools/thread_summarizer/execute", + "/api/tools/text_analyzer/execute", headers={"Authorization": f"Bearer {_signed_session_token()}"}, json={ "parameters": { - "thread_id": "123", + "text": "123", "__proto__": {"polluted": True}, } }, @@ -292,9 +272,9 @@ def test_execute_tool_rejects_unexpected_parameter(): def test_execute_tool_rejects_invalid_parameter_type(): with TestClient(app) as client: response = client.post( - "/api/tools/thread_summarizer/execute", + "/api/tools/text_analyzer/execute", headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"parameters": {"thread_id": ["not", "a", "string"]}}, + json={"parameters": {"text": ["not", "a", "string"]}}, ) assert response.status_code == 200 @@ -361,7 +341,7 @@ def test_execute_tool_no_parameters_accepted(): def test_execute_tool_not_a_dict_parameter(): with TestClient(app) as client: response = client.post( - "/api/tools/thread_summarizer/execute", + "/api/tools/text_analyzer/execute", headers={"Authorization": f"Bearer {_signed_session_token()}"}, json={"parameters": "not_a_dict"}, # type: ignore ) @@ -737,8 +717,8 @@ def handler(_params): "category": "Test", }, ), - ("PATCH", "/api/tools/thread_summarizer", {"name": "Unauthorized"}), - ("DELETE", "/api/tools/thread_summarizer", None), + ("PATCH", "/api/tools/text_analyzer", {"name": "Unauthorized"}), + ("DELETE", "/api/tools/text_analyzer", None), ], ) def test_tool_mutation_routes_require_signed_session(method, path, payload):