initial commit for mcp

This commit is contained in:
jaehwang 2026-09-11 13:48:04 +09:00
commit 3a257fbe26
10 changed files with 249 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
.env
.claude/

23
CLAUDE.md Normal file
View File

@ -0,0 +1,23 @@
# 작업 규칙
## 작업 방식
1. **시킨 것만 한다.** 요청하지 않은 것은 절대 하지 않는다.
2. 파일이나 코드를 **만들기 전에 무엇을 만들지 말하고 승인을 받는다.**
3. **한 번에 전부 만들지 않는다.** 도는 최소 단위 하나를 만들고 확인한 뒤 다음으로 넘어간다. 만들면서 설계가 바뀔 수 있다.
4. 로직상 애매한 부분은 **물어본다.** 자의적으로 판단해 빈칸을 채우지 않는다.
5. 테스트 코드 등 일회성 코드는 **요청할 때만** 작성한다. 선제적으로 테스트 명령을 실행하지 않는다.
## 코드
6. 오컴의 면도날. 코드는 장황하지 않게 **최소한으로** 쓴다.
7. 변수명은 짧게 줄이기보다 **의미가 드러나게** 쓴다.
## 디렉터리 구조
프로젝트명 패키지로 감싸지 않는다. 기능별로 나눈다.
- `main.py` — 이 파일로 MCP 서버가 켜진다
- `services/` — MCP 기능
- `models/` — 각 기능의 입력·출력 모델. pydantic 검증을 여기에 둔다
- `utils/` — 공통 함수

0
Readme.md Normal file
View File

12
main.py Normal file
View File

@ -0,0 +1,12 @@
"""Empathy Decision Agent MCP 서버."""
from mcp.server.mcpserver import MCPServer
from services.utterance_guide import generate_style_guide
mcp = MCPServer("Empathy Decision Agent")
mcp.add_tool(generate_style_guide)
if __name__ == "__main__":
mcp.run()

42
models/context.py Normal file
View File

@ -0,0 +1,42 @@
"""심층 분석의 입력 모델."""
from pydantic import BaseModel
class BookMetadata(BaseModel):
title: str
author: str
class Excerpt(BaseModel):
text: str
locator: str | None = None # 페이지·장 표기
class DiscussionTurn(BaseModel):
speaker: str
text: str
class Persona(BaseModel):
name: str
personality: str
speaking_style: str
perspective: str
class AnalysisInput(BaseModel):
book: BookMetadata
persona: Persona
excerpts: list[Excerpt] = []
turns: list[DiscussionTurn] = []
class Claim(BaseModel):
speaker: str
statement: str
evidence: list[str] = []
class DiscussionContext(BaseModel):
claims: list[Claim] = []

18
models/utterance_guide.py Normal file
View File

@ -0,0 +1,18 @@
"""발화 가이드 생성의 입력·출력 모델."""
from pydantic import BaseModel
from models.context import BookMetadata, Claim, Persona
class UtteranceGuideInput(BaseModel):
book: BookMetadata
persona: Persona
claims: list[Claim] = []
class UtteranceGuide(BaseModel):
target_claim: str
intent: str
key_message: str
tone: str

View File

@ -0,0 +1,39 @@
"""독서토론 발언에서 주장을 추출한다."""
from models.context import AnalysisInput, DiscussionContext
from settings import settings
from utils.common_llm import StructuredLLM
SYSTEM_PROMPT = """너는 독서토론 기록에서 참여자의 주장을 추출한다.
- 화자가 실제로 내세운 주장만 뽑는다. 없는 주장을 추측해서 만들지 않는다.
- statement: 주장을 문장으로 정리한다.
- evidence: 화자가 주장을 뒷받침하려고 근거를 옮긴다. 근거를 대지 않았으면 목록으로 둔다.
- speaker: 발언자 이름을 그대로 쓴다."""
claim_llm = StructuredLLM(settings.llm_model, settings.chatgpt_api_key)
def _render_input(analysis_input: AnalysisInput) -> str:
lines = [f"책: {analysis_input.book.title} ({analysis_input.book.author})"]
if analysis_input.excerpts:
lines.append("\n[원문 발췌]")
for excerpt in analysis_input.excerpts:
locator = f"({excerpt.locator}) " if excerpt.locator else ""
lines.append(f"{locator}{excerpt.text}")
if analysis_input.turns:
lines.append("\n[토론 발언]")
for turn in analysis_input.turns:
lines.append(f"{turn.speaker}: {turn.text}")
return "\n".join(lines)
async def extract_claims(analysis_input: AnalysisInput) -> DiscussionContext:
return await claim_llm.ask(
DiscussionContext,
_render_input(analysis_input),
system=SYSTEM_PROMPT,
)

View File

@ -0,0 +1,58 @@
"""토론에서 나온 주장과 페르소나로 다음 발화의 가이드를 만든다."""
from models.context import AnalysisInput
from models.utterance_guide import UtteranceGuide, UtteranceGuideInput
from services.claim_extraction import extract_claims
from settings import settings
from utils.common_llm import StructuredLLM
SYSTEM_PROMPT = """너는 독서토론에 참여하는 AI의 다음 발화를 설계한다.
주어진 페르소나로 토론에 참여한다고 보고, 발화문은 쓰지 말고 어떻게 발화할지만 정한다.
- target_claim: 반응할 주장 하나를 고르고 문장으로 옮긴다.
- intent: 주장에 대해 무엇을 할지 쓴다. 동의, 반박, 질문, 확장 .
- key_message: 핵심으로 전할 내용을 문장으로 쓴다.
- tone: 페르소나의 성격과 말투에 맞는 태도를 쓴다."""
guide_llm = StructuredLLM(settings.llm_model, settings.chatgpt_api_key)
def _render_input(guide_input: UtteranceGuideInput) -> str:
persona = guide_input.persona
lines = [
f"책: {guide_input.book.title} ({guide_input.book.author})",
"\n[페르소나]",
f"이름: {persona.name}",
f"성격: {persona.personality}",
f"말투: {persona.speaking_style}",
f"관점: {persona.perspective}",
]
if guide_input.claims:
lines.append("\n[토론에서 나온 주장]")
for claim in guide_input.claims:
lines.append(f"{claim.speaker}: {claim.statement}")
for evidence in claim.evidence:
lines.append(f" 근거: {evidence}")
return "\n".join(lines)
async def build_utterance_guide(guide_input: UtteranceGuideInput) -> UtteranceGuide:
return await guide_llm.ask(
UtteranceGuide,
_render_input(guide_input),
system=SYSTEM_PROMPT,
)
async def generate_style_guide(analysis_input: AnalysisInput) -> UtteranceGuide:
"""지식 기반 스타일 가이드를 생성한다."""
discussion = await extract_claims(analysis_input)
return await build_utterance_guide(
UtteranceGuideInput(
book=analysis_input.book,
persona=analysis_input.persona,
claims=discussion.claims,
)
)

11
settings.py Normal file
View File

@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
chatgpt_api_key: str
llm_model: str = "gpt-4o"
settings = Settings()

40
utils/common_llm.py Normal file
View File

@ -0,0 +1,40 @@
"""structured output 공용 래퍼.
OpenAI 호환 /chat/completions를 쓰는 프로바이더면 base_url만 바꿔 그대로 쓴다.
"""
from typing import TypeVar
from openai import AsyncOpenAI
from pydantic import BaseModel
T = TypeVar("T", bound=BaseModel)
class StructuredLLM:
def __init__(self, model: str, api_key: str, base_url: str | None = None):
self.model = model
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url)
async def ask(
self,
schema: type[T],
prompt: str,
*,
system: str | None = None,
temperature: float = 0.0,
) -> T:
messages = [{"role": "system", "content": system}] if system else []
messages.append({"role": "user", "content": prompt})
msg = (await self._client.chat.completions.parse(
model=self.model,
messages=messages,
response_format=schema,
temperature=temperature,
)).choices[0].message
if msg.refusal:
raise RuntimeError(f"{self.model} 거절: {msg.refusal}")
if msg.parsed is None:
raise RuntimeError(f"{self.model} 파싱 실패: {msg.content!r}")
return msg.parsed