initial commit for mcp
This commit is contained in:
commit
3a257fbe26
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
.env
|
||||
.claude/
|
||||
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal file
@ -0,0 +1,23 @@
|
||||
# 작업 규칙
|
||||
|
||||
## 작업 방식
|
||||
|
||||
1. **시킨 것만 한다.** 요청하지 않은 것은 절대 하지 않는다.
|
||||
2. 파일이나 코드를 **만들기 전에 무엇을 만들지 말하고 승인을 받는다.**
|
||||
3. **한 번에 전부 만들지 않는다.** 도는 최소 단위 하나를 만들고 확인한 뒤 다음으로 넘어간다. 만들면서 설계가 바뀔 수 있다.
|
||||
4. 로직상 애매한 부분은 **물어본다.** 자의적으로 판단해 빈칸을 채우지 않는다.
|
||||
5. 테스트 코드 등 일회성 코드는 **요청할 때만** 작성한다. 선제적으로 테스트 명령을 실행하지 않는다.
|
||||
|
||||
## 코드
|
||||
|
||||
6. 오컴의 면도날. 코드는 장황하지 않게 **최소한으로** 쓴다.
|
||||
7. 변수명은 짧게 줄이기보다 **의미가 드러나게** 쓴다.
|
||||
|
||||
## 디렉터리 구조
|
||||
|
||||
프로젝트명 패키지로 감싸지 않는다. 기능별로 나눈다.
|
||||
|
||||
- `main.py` — 이 파일로 MCP 서버가 켜진다
|
||||
- `services/` — MCP 기능
|
||||
- `models/` — 각 기능의 입력·출력 모델. pydantic 검증을 여기에 둔다
|
||||
- `utils/` — 공통 함수
|
||||
12
main.py
Normal file
12
main.py
Normal 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
42
models/context.py
Normal 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
18
models/utterance_guide.py
Normal 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
|
||||
39
services/claim_extraction.py
Normal file
39
services/claim_extraction.py
Normal 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,
|
||||
)
|
||||
58
services/utterance_guide.py
Normal file
58
services/utterance_guide.py
Normal 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
11
settings.py
Normal 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
40
utils/common_llm.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user