Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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과
Expand Down
83 changes: 0 additions & 83 deletions backend/api/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down
132 changes: 56 additions & 76 deletions backend/tests/test_tools_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand All @@ -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},
}
},
Expand All @@ -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
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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):
Expand Down