feat: add Gemini-powered birdseye rendering
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
# python setup_keys.py --from-env-file .env
|
# python setup_keys.py --from-env-file .env
|
||||||
DATA_GO_KR_API_KEY=
|
DATA_GO_KR_API_KEY=
|
||||||
VWORLD_API_KEY=
|
VWORLD_API_KEY=
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
|||||||
680
README.md
680
README.md
@@ -1,513 +1,413 @@
|
|||||||
# Construction-Project-Planning-Master-MCP
|
# CivilPlan MCP v2.0.0
|
||||||
|
|
||||||
**건설/건축 공사 사업계획을 AI와 함께 만듭니다**
|
한국형 토목·건축 프로젝트 기획을 MCP 도구로 구조화하고 문서·도면·3D 렌더까지 생성하는 서버입니다.
|
||||||
**Plan Korean construction projects with AI assistance**
|
CivilPlan MCP is an MCP server for Korean civil and building project planning that produces structured analysis, documents, drawings, and 3D renders.
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://python.org)
|
[](https://python.org)
|
||||||
[](https://github.com/jlowin/fastmcp)
|
[](https://github.com/jlowin/fastmcp)
|
||||||
|
[](pyproject.toml)
|
||||||
---
|
|
||||||
|
|
||||||
## 소개 | Introduction
|
## 소개 | Introduction
|
||||||
|
|
||||||
CivilPlan MCP는 한국 토목/건축 사업의 **기획 단계 전 과정**을 AI가 지원하는 MCP(Model Context Protocol) 서버입니다. Claude, ChatGPT 등 AI 에이전트가 이 서버의 도구를 호출하여 사업비 산출, 법적 절차 확인, 도면 생성 등을 자동으로 수행합니다.
|
CivilPlan MCP는 자연어 프로젝트 설명을 받아 인허가 검토, 물량 산정, 단가 조회, 투자 문서 작성, SVG/DXF 도면 생성, 3D Bird's-Eye View 렌더링까지 연결하는 20개 MCP 도구를 제공합니다.
|
||||||
|
CivilPlan MCP provides 20 MCP tools that turn natural-language project requests into permit reviews, quantity takeoff, pricing, planning documents, SVG/DXF drawings, and 3D bird's-eye renders.
|
||||||
|
|
||||||
CivilPlan MCP is a FastMCP server that helps AI agents (Claude, ChatGPT, etc.) plan Korean civil engineering and building projects. It automates cost estimation, legal procedure identification, drawing generation, and more -- all through the MCP protocol.
|
주요 흐름은 `프로젝트 파싱 → 법적·사업성 검토 → 산출물 생성`입니다.
|
||||||
|
The core flow is `project parsing → legal/financial review → output generation`.
|
||||||
|
|
||||||
> **철학 | Philosophy**: 제4의길-AI와 함께 새로운 세상을 만들어갑니다. -- 전문 기획 지식에 대한 접근 불평등을 줄입니다.
|
```mermaid
|
||||||
> Reduce inequality in access to expert planning knowledge. Free to use, modify, and distribute.
|
flowchart LR
|
||||||

|
A["사용자 요청<br/>Natural-language request"] --> B["civilplan_parse_project"]
|
||||||

|
B --> C["법적·기획 분석<br/>legal / planning analysis"]
|
||||||

|
B --> D["물량·단가·사업성<br/>quantity / pricing / feasibility"]
|
||||||

|
B --> E["문서·도면 생성<br/>docs / drawings"]
|
||||||

|
B --> F["3D 렌더 생성<br/>bird's-eye / perspective render"]
|
||||||

|
C --> G["MCP 응답 JSON"]
|
||||||

|
D --> G
|
||||||

|
E --> G
|
||||||

|
F --> G
|
||||||

|
```
|
||||||

|
|
||||||
---
|
|
||||||
|
|
||||||
## 이런 분들에게 유용합니다 | Who Is This For?
|
## 누가 쓰면 좋은가 | Who Is This For
|
||||||
|
|
||||||
| 대상 | 활용 예시 |
|
| 대상 Audience | 쓰는 이유 Why |
|
||||||
|------|----------|
|
|---|---|
|
||||||
| **지자체 공무원** | 도로/상하수도 사업 기획 시 개략 사업비와 인허가 절차를 빠르게 파악 |
|
| 지자체·공공 발주 담당자<br/>Local government and public-sector planners | 초기 타당성, 절차, 예산 초안이 빠르게 필요할 때 사용합니다.<br/>Use it when you need a fast first-pass on feasibility, procedures, and budget. |
|
||||||
| **건설 엔지니어** | 기획 단계 물량/단가 산출, 투자계획서 초안 작성 자동화 |
|
| 토목·건축 엔지니어<br/>Civil and building engineers | 기획 단계 물량, 단가, 문서 초안을 자동화할 수 있습니다.<br/>Automate early-stage quantity takeoff, pricing, and planning documents. |
|
||||||
| **부동산 개발 기획자** | 개발 사업의 법적 절차, 영향평가 대상 여부 확인 |
|
| 개발사업 기획자<br/>Development planners | 자연어 설명만으로 구조화된 프로젝트 데이터와 시각 자료를 얻을 수 있습니다.<br/>Turn a plain-language project brief into structured project data and visuals. |
|
||||||
| **건축주/시행사** | AI에게 자연어로 사업 설명 -> 구조화된 사업 계획 문서 일괄 생성 |
|
| AI 에이전트 운영자<br/>AI agent builders | Claude, ChatGPT, 기타 MCP 클라이언트에 토목/건축 전용 도구 세트를 연결할 수 있습니다.<br/>Attach a Korean construction-planning toolset to Claude, ChatGPT, and other MCP clients. |
|
||||||
| **학생/연구자** | 한국 건설 법령/표준품셈 학습 및 시뮬레이션 |
|
|
||||||
|
|
||||||
| Who | Use Case |
|
|
||||||
|-----|----------|
|
|
||||||
| **Local government officials** | Quickly estimate project costs and permits for road/water projects |
|
|
||||||
| **Civil engineers** | Automate preliminary quantity takeoff, unit pricing, and investment reports |
|
|
||||||
| **Real estate developers** | Identify legal procedures and impact assessments for development projects |
|
|
||||||
| **Project owners** | Describe a project in natural language -> get structured planning documents |
|
|
||||||
| **Students & researchers** | Learn Korean construction law, standard specifications, and cost estimation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 주요 기능 | Key Features
|
## 주요 기능 | Key Features
|
||||||
|
|
||||||
### 19개 AI 도구 | 19 AI Tools
|
### v2.0.0 변경점 | What's New in v2.0.0
|
||||||
|
|
||||||
CivilPlan은 사업 기획의 전 과정을 커버하는 19개 도구를 제공합니다:
|
| 항목 Item | 설명 Description |
|
||||||
|
|---|---|
|
||||||
|
| `civilplan_generate_birdseye_view` | Gemini 기반 3D Bird's-Eye View와 Perspective View를 한 번에 생성합니다.<br/>Generates Gemini-based bird's-eye and perspective renders in one call. |
|
||||||
|
| `GEMINI_API_KEY` 설정 | `.env`와 Windows DPAPI 기반 `setup_keys.py` 양쪽에서 Gemini 키를 읽습니다.<br/>Reads the Gemini key from both `.env` and Windows DPAPI-based `setup_keys.py`. |
|
||||||
|
| 도메인 프롬프트 템플릿 | 도로, 건축, 상하수도, 하천, 조경, 복합 프로젝트별 렌더 문구를 분리했습니다.<br/>Adds domain-specific render prompts for road, building, water, river, landscape, and mixed projects. |
|
||||||
|
| 총 20개 MCP 도구 | 기존 기획/문서/도면 도구에 3D 시각화를 추가했습니다.<br/>Expands the server to 20 MCP tools by adding 3D visualization. |
|
||||||
|
|
||||||
| # | 도구 Tool | 설명 Description |
|
### 도구 목록 | Tool Catalog
|
||||||
|---|-----------|-----------------|
|
|
||||||
| 1 | `civilplan_parse_project` | 자연어 사업 설명 -> 구조화된 사업 정보 추출 / Parse natural language project description |
|
|
||||||
| 2 | `civilplan_get_legal_procedures` | 사업 유형/규모별 법적 절차 자동 산출 / Identify applicable legal procedures |
|
|
||||||
| 3 | `civilplan_get_phase_checklist` | 사업 단계별 체크리스트 생성 / Generate phase-specific checklists |
|
|
||||||
| 4 | `civilplan_evaluate_impact_assessments` | 9종 영향평가 대상 여부 판단 / Evaluate 9 types of impact assessments |
|
|
||||||
| 5 | `civilplan_estimate_quantities` | 표준 횡단면 기반 개략 물량 산출 / Estimate quantities from standard cross-sections |
|
|
||||||
| 6 | `civilplan_get_unit_prices` | 공종별 단가 조회 (지역계수 반영) / Query unit prices with regional factors |
|
|
||||||
| 7 | `civilplan_generate_boq_excel` | 사업내역서(BOQ) Excel 생성 / Generate BOQ spreadsheet |
|
|
||||||
| 8 | `civilplan_generate_investment_doc` | 투자계획서(사업계획서) Word 생성 / Generate investment plan document |
|
|
||||||
| 9 | `civilplan_generate_schedule` | 사업 추진 일정표 (간트차트형) 생성 / Generate project schedule |
|
|
||||||
| 10 | `civilplan_generate_svg_drawing` | 개략 도면 SVG 생성 (평면도, 횡단면도) / Generate SVG drawings |
|
|
||||||
| 11 | `civilplan_get_applicable_guidelines` | 적용 기준/지침 조회 / Get applicable guidelines |
|
|
||||||
| 12 | `civilplan_fetch_guideline_summary` | 기준/지침 요약 조회 / Fetch guideline summaries |
|
|
||||||
| 13 | `civilplan_select_bid_type` | 발주 방식 선정 / Select bid type |
|
|
||||||
| 14 | `civilplan_estimate_waste_disposal` | 건설폐기물 처리비 산출 / Estimate waste disposal costs |
|
|
||||||
| 15 | `civilplan_query_land_info` | 토지 정보 조회 (PNU, 용도지역) / Query land info |
|
|
||||||
| 16 | `civilplan_analyze_feasibility` | 사업 타당성 분석 / Analyze project feasibility |
|
|
||||||
| 17 | `civilplan_validate_against_benchmark` | 유사 사업비 벤치마크 검증 / Validate against benchmarks |
|
|
||||||
| 18 | `civilplan_generate_budget_report` | 예산 보고서 생성 / Generate budget report |
|
|
||||||
| 19 | `civilplan_generate_dxf_drawing` | DXF 도면 생성 (CAD 호환) / Generate DXF drawings |
|
|
||||||
|
|
||||||
### 지원 사업 분야 | Supported Project Domains
|
#### 기획·분석 도구 | Planning and Analysis Tools
|
||||||
|
|
||||||
- `건축` -- 건축물 (Buildings)
|
| 도구 Tool | 설명 Description |
|
||||||
- `토목_도로` -- 도로 (Roads)
|
|---|---|
|
||||||
- `토목_상하수도` -- 상하수도 (Water & Sewerage)
|
| `civilplan_parse_project` | 자연어 프로젝트 설명을 구조화된 JSON으로 변환합니다.<br/>Parses a natural-language project brief into structured JSON. |
|
||||||
- `토목_하천` -- 하천 (Rivers)
|
| `civilplan_get_legal_procedures` | 사업 조건에 맞는 인허가·환경 절차를 정리합니다.<br/>Finds permit and environmental procedures for the project. |
|
||||||
- `조경` -- 조경 (Landscaping)
|
| `civilplan_get_phase_checklist` | 단계별 체크리스트를 생성합니다.<br/>Builds phase-by-phase execution checklists. |
|
||||||
- `복합` -- 복합 사업 (Mixed projects)
|
| `civilplan_evaluate_impact_assessments` | 영향평가 필요 여부를 검토합니다.<br/>Evaluates impact-assessment requirements. |
|
||||||
|
| `civilplan_estimate_quantities` | 개략 물량을 산정합니다.<br/>Estimates conceptual quantities. |
|
||||||
|
| `civilplan_get_unit_prices` | 지역 보정이 반영된 단가를 조회합니다.<br/>Looks up unit prices with regional adjustments. |
|
||||||
|
| `civilplan_get_applicable_guidelines` | 적용 대상 설계 기준을 찾습니다.<br/>Finds applicable design guidelines. |
|
||||||
|
| `civilplan_fetch_guideline_summary` | 기준 전문의 핵심 항목을 요약합니다.<br/>Fetches summaries of guideline references. |
|
||||||
|
| `civilplan_select_bid_type` | 발주·입찰 방식을 추천합니다.<br/>Recommends a bidding/procurement method. |
|
||||||
|
| `civilplan_estimate_waste_disposal` | 건설폐기물 물량과 처리비를 계산합니다.<br/>Estimates construction waste volume and disposal cost. |
|
||||||
|
| `civilplan_query_land_info` | 토지·지목·용도지역 정보를 조회합니다.<br/>Queries land, parcel, and zoning information. |
|
||||||
|
| `civilplan_analyze_feasibility` | IRR, NPV, DSCR 등 사업성을 계산합니다.<br/>Calculates IRR, NPV, DSCR, and related feasibility metrics. |
|
||||||
|
| `civilplan_validate_against_benchmark` | 공공 기준이나 벤치마크와 비교합니다.<br/>Checks estimates against public benchmarks. |
|
||||||
|
|
||||||
### 출력 형식 | Output Formats
|
#### 문서·도면 도구 | Document and Drawing Tools
|
||||||
|
|
||||||
- **Excel (.xlsx)**: 사업내역서(BOQ), 일정표, 예산 보고서
|
| 도구 Tool | 설명 Description |
|
||||||
- **Word (.docx)**: 투자계획서(사업계획서)
|
|---|---|
|
||||||
- **SVG**: 평면도, 횡단면도, 종단면도
|
| `civilplan_generate_boq_excel` | BOQ Excel 파일을 생성합니다.<br/>Generates a BOQ Excel workbook. |
|
||||||
- **DXF**: CAD 호환 도면
|
| `civilplan_generate_investment_doc` | 투자·사업계획 Word 문서를 생성합니다.<br/>Generates an investment/planning Word document. |
|
||||||
- **JSON**: 모든 도구의 구조화된 응답 데이터
|
| `civilplan_generate_budget_report` | 예산 보고서를 작성합니다.<br/>Builds a budget report document. |
|
||||||
|
| `civilplan_generate_schedule` | 일정표 Excel 파일을 생성합니다.<br/>Creates a schedule workbook. |
|
||||||
|
| `civilplan_generate_svg_drawing` | SVG 개략 도면을 생성합니다.<br/>Generates conceptual SVG drawings. |
|
||||||
|
| `civilplan_generate_dxf_drawing` | DXF CAD 도면을 생성합니다.<br/>Generates DXF CAD drawings. |
|
||||||
|
| `civilplan_generate_birdseye_view` | Bird's-Eye / Perspective PNG 렌더를 생성합니다.<br/>Generates bird's-eye and perspective PNG renders. |
|
||||||
|
|
||||||
---
|
### 지원 도메인 | Supported Domains
|
||||||
|
|
||||||
|
| 도메인 Domain | 설명 Description |
|
||||||
|
|---|---|
|
||||||
|
| `토목_도로` | 도로, 진입로, 포장, 차선 중심 프로젝트<br/>Roads, access roads, pavement, lane-focused projects |
|
||||||
|
| `건축` | 건물, 복지관, 학교, 오피스 등 건축 프로젝트<br/>Buildings, welfare centers, schools, offices, and similar building projects |
|
||||||
|
| `토목_상하수도` | 상수도, 하수도, 우수도, 관로 중심 프로젝트<br/>Water, sewer, stormwater, and pipeline-centric projects |
|
||||||
|
| `토목_하천` | 하천 정비, 제방, 배수, 수변 구조물 프로젝트<br/>River improvement, levee, drainage, and riverside structure projects |
|
||||||
|
| `조경` | 공원, 녹지, 식재, 휴게 공간 프로젝트<br/>Landscape, parks, planting, and open-space projects |
|
||||||
|
| `복합` | 다분야가 섞인 복합 개발 프로젝트<br/>Mixed multi-domain development projects |
|
||||||
|
|
||||||
## 빠른 시작 가이드 | Quick Start Guide
|
## 빠른 시작 가이드 | Quick Start Guide
|
||||||
|
|
||||||
### 1단계: 설치 | Step 1: Install
|
### 1. 저장소 받기 | Clone the Repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 저장소 클론 | Clone the repository
|
|
||||||
git clone https://github.com/sinmb79/Construction-project-master.git
|
git clone https://github.com/sinmb79/Construction-project-master.git
|
||||||
cd Construction-project-master
|
cd Construction-project-master
|
||||||
|
|
||||||
# 가상환경 생성 및 활성화 | Create and activate virtual environment
|
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
|
```
|
||||||
|
|
||||||
# Windows:
|
### 2. 가상환경 활성화와 패키지 설치 | Activate the Environment and Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
.venv\Scripts\activate
|
.venv\Scripts\activate
|
||||||
# macOS/Linux:
|
|
||||||
|
# macOS / Linux
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
|
||||||
# 패키지 설치 | Install dependencies
|
python -m pip install -r requirements.txt
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2단계: API 키 설정 | Step 2: Configure API Keys
|
### 3. API 키 설정 | Configure API Keys
|
||||||
|
|
||||||
일부 도구(토지 정보 조회 등)는 공공 API 키가 필요합니다. 없어도 대부분의 기능은 동작합니다.
|
| 방법 Method | 명령 Command | 설명 Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `.env` 파일 | `copy .env.example .env` (Windows)<br/>`cp .env.example .env` (macOS/Linux) | 로컬 개발용으로 가장 단순합니다.<br/>The simplest option for local development. |
|
||||||
|
| 암호화 저장소 | `python setup_keys.py` | Windows DPAPI에 키를 암호화 저장합니다.<br/>Stores keys in Windows DPAPI-encrypted storage. |
|
||||||
|
|
||||||
Some tools (land info queries, etc.) require public API keys. Most features work without them.
|
`.env` 예시는 아래와 같습니다.
|
||||||
|
An example `.env` looks like this.
|
||||||
**방법 A: `.env` 파일 | Option A: `.env` file**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# .env.example을 복사하여 키를 입력합니다
|
|
||||||
# Copy .env.example and fill in your keys
|
|
||||||
copy .env.example .env # Windows
|
|
||||||
cp .env.example .env # macOS/Linux
|
|
||||||
```
|
|
||||||
|
|
||||||
`.env` 파일을 편집하여 키를 입력하세요:
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# 공공데이터포털 (https://www.data.go.kr) 에서 발급
|
DATA_GO_KR_API_KEY=
|
||||||
DATA_GO_KR_API_KEY=your_key_here
|
VWORLD_API_KEY=
|
||||||
|
GEMINI_API_KEY=
|
||||||
# 브이월드 (https://www.vworld.kr) 에서 발급
|
|
||||||
VWORLD_API_KEY=your_key_here
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**방법 B: 암호화 저장 | Option B: Encrypted local storage**
|
### 4. 서버 실행 | Start the Server
|
||||||
|
|
||||||
```bash
|
|
||||||
# 대화형으로 키 입력 | Enter keys interactively
|
|
||||||
python setup_keys.py
|
|
||||||
|
|
||||||
# 또는 기존 .env 파일을 암호화 저장소로 가져오기
|
|
||||||
# Or import from existing .env file
|
|
||||||
python setup_keys.py --from-env-file .env
|
|
||||||
```
|
|
||||||
|
|
||||||
> Windows에서는 DPAPI를 사용하여 현재 사용자 프로필에 암호화 저장됩니다.
|
|
||||||
> On Windows, keys are encrypted with DPAPI under your user profile.
|
|
||||||
|
|
||||||
### 3단계: 서버 실행 | Step 3: Start the Server
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python server.py
|
python server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
서버가 `http://127.0.0.1:8765/mcp`에서 시작됩니다.
|
실행 주소는 `http://127.0.0.1:8765/mcp` 입니다.
|
||||||
|
The server runs at `http://127.0.0.1:8765/mcp`.
|
||||||
|
|
||||||
The server starts at `http://127.0.0.1:8765/mcp`.
|
### 5. MCP 클라이언트 연결 | Connect an MCP Client
|
||||||
|
|
||||||
### 4단계: AI 클라이언트 연결 | Step 4: Connect Your AI Client
|
#### Claude Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add --transport http civilplan http://127.0.0.1:8765/mcp
|
||||||
|
```
|
||||||
|
|
||||||
#### Claude Desktop
|
#### Claude Desktop
|
||||||
|
|
||||||
`claude_desktop_config.json` (또는 설정 파일)에 다음을 추가하세요:
|
| 항목 Item | 값 Value |
|
||||||
|
|---|---|
|
||||||
|
| 서버 유형 Server type | HTTP MCP server |
|
||||||
|
| URL | `http://127.0.0.1:8765/mcp` |
|
||||||
|
| Windows 설정 파일 Common Windows config path | `%APPDATA%\Claude\claude_desktop_config.json` |
|
||||||
|
|
||||||
Add the following to your `claude_desktop_config.json`:
|
HTTP MCP 서버를 추가한 뒤 Claude Desktop을 재시작하세요.
|
||||||
|
Add the HTTP MCP server and restart Claude Desktop.
|
||||||
|
|
||||||
```json
|
#### ChatGPT Developer Mode
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"civilplan": {
|
|
||||||
"command": "mcp-remote",
|
|
||||||
"args": ["http://127.0.0.1:8765/mcp"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Claude Code (CLI)
|
| 단계 Step | 설명 Description |
|
||||||
|
|---|---|
|
||||||
|
| 1 | ChatGPT에서 `Settings → Apps → Advanced settings → Developer mode`를 켭니다.<br/>Enable `Settings → Apps → Advanced settings → Developer mode` in ChatGPT. |
|
||||||
|
| 2 | `Create app`를 눌러 원격 MCP 서버를 등록합니다.<br/>Click `Create app` to register a remote MCP server. |
|
||||||
|
| 3 | 로컬 서버는 직접 연결되지 않으므로 터널 URL이 필요합니다.<br/>Local servers cannot be connected directly, so you need a tunnel URL. |
|
||||||
|
|
||||||
|
`cloudflared` 예시는 아래와 같습니다.
|
||||||
|
An example `cloudflared` tunnel command is shown below.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add civilplan http://127.0.0.1:8765/mcp
|
cloudflared tunnel --url http://127.0.0.1:8765
|
||||||
```
|
```
|
||||||
|
|
||||||
#### ChatGPT (Developer Mode)
|
터널이 만든 HTTPS URL을 ChatGPT 앱 생성 화면에 넣으세요.
|
||||||
|
Use the HTTPS URL produced by the tunnel when creating the ChatGPT app.
|
||||||
|
|
||||||
ChatGPT는 localhost에 직접 연결할 수 없습니다. ngrok 또는 Cloudflare Tunnel을 사용하세요.
|
#### 기타 MCP 클라이언트 | Other MCP Clients
|
||||||
|
|
||||||
ChatGPT cannot connect to localhost directly. Use ngrok or Cloudflare Tunnel:
|
| 항목 Item | 값 Value |
|
||||||
|
|---|---|
|
||||||
```bash
|
| 프로토콜 Protocol | Streaming HTTP |
|
||||||
# ngrok으로 서버를 외부에 노출
|
| URL | `http://127.0.0.1:8765/mcp` |
|
||||||
ngrok http 8765
|
|
||||||
```
|
|
||||||
|
|
||||||
생성된 HTTPS URL을 ChatGPT 설정 -> Connectors -> Create에 입력합니다.
|
|
||||||
|
|
||||||
Use the generated HTTPS URL in ChatGPT Settings -> Connectors -> Create.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 실전 사용 예시 | Real-World Usage Examples
|
## 실전 사용 예시 | Real-World Usage Examples
|
||||||
|
|
||||||
### 예시 1: 소로 개설(신설) 공사 기획 | Example 1: Planning a New Local Road
|
### 예시 1: 도로 프로젝트 파싱 | Example 1: Parse a Road Project
|
||||||
|
|
||||||
아래는 실제로 CivilPlan MCP를 사용하여 생성한 예시입니다.
|
**AI에게 이렇게 말하세요 | Say this to the AI**
|
||||||
|
|
||||||
Below is a real example generated using CivilPlan MCP.
|
```text
|
||||||
|
도로 신설 L=890m B=6m 아스콘 2차선 상하수도 경기도 화성시 2026~2028
|
||||||
#### AI에게 이렇게 말하세요 | Say this to your AI:
|
|
||||||
|
|
||||||
```
|
|
||||||
소로 신설 L=890m B=6m 아스콘 2차선 상하수도 경기도 둔턱지역 2026~2028
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### CivilPlan이 자동으로 수행하는 작업 | What CivilPlan does automatically:
|
**호출되는 도구 | Tool called**
|
||||||
|
|
||||||
**1) 사업 정보 파싱 | Project Parsing** (`civilplan_parse_project`)
|
```python
|
||||||
|
civilplan_parse_project(
|
||||||
|
description="도로 신설 L=890m B=6m 아스콘 2차선 상하수도 경기도 화성시 2026~2028"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
자연어 입력을 구조화된 데이터로 변환합니다:
|
**결과 예시 | Example result**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"project_id": "PRJ-20260402-001",
|
"project_id": "PRJ-20260404-001",
|
||||||
"project_type": ["도로", "상수도", "하수도"],
|
"domain": "토목_도로",
|
||||||
|
"sub_domains": ["토목_상하수도"],
|
||||||
|
"project_type": ["도로", "하수도"],
|
||||||
"road": {
|
"road": {
|
||||||
"class": "소로",
|
"length_m": 890.0,
|
||||||
"length_m": 890,
|
|
||||||
"width_m": 6.0,
|
"width_m": 6.0,
|
||||||
"lanes": 2,
|
"lanes": 2,
|
||||||
"pavement": "아스콘"
|
"pavement": "아스콘"
|
||||||
},
|
},
|
||||||
"terrain": "구릉(둔턱)",
|
|
||||||
"terrain_factor": 1.4,
|
|
||||||
"region": "경기도",
|
"region": "경기도",
|
||||||
"region_factor": 1.05,
|
|
||||||
"year_start": 2026,
|
"year_start": 2026,
|
||||||
"year_end": 2028,
|
"year_end": 2028,
|
||||||
"utilities": ["상수도", "하수도"]
|
"parsed_confidence": 0.92
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**2) 개략 물량 산출 | Quantity Estimation** (`civilplan_estimate_quantities`)
|
### 예시 2: 인허가 절차 확인 | Example 2: Check Legal Procedures
|
||||||
|
|
||||||
표준 횡단면 기준으로 주요 물량을 자동 산출합니다:
|
**AI에게 이렇게 말하세요 | Say this to the AI**
|
||||||
|
|
||||||
```
|
```text
|
||||||
도로 포장: 아스콘 표층 523t, 기층 628t
|
경기도 공공 도로 사업(총사업비 10.67억, 연장 890m)에 필요한 인허가를 정리해줘
|
||||||
토공: 절토 8,000m3, 성토 5,400m3
|
|
||||||
배수: L형측구 1,780m, 횡단암거 60m
|
|
||||||
상수도: PE관 DN100 890m, 소화전 3개소
|
|
||||||
하수도: 오수관 890m, 우수관 890m, 맨홀 37개소
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**3) 사업비 산출 | Cost Estimation** (`civilplan_generate_boq_excel`)
|
**호출되는 도구 | Tool called**
|
||||||
|
|
||||||
6개 시트로 구성된 사업내역서 Excel 파일을 생성합니다:
|
```python
|
||||||
|
civilplan_get_legal_procedures(
|
||||||
| 시트 Sheet | 내용 Contents |
|
domain="토목_도로",
|
||||||
|-----------|--------------|
|
project_type="도로",
|
||||||
| 사업개요 | 프로젝트 정보, 면책문구 |
|
total_cost_billion=10.67,
|
||||||
| 사업내역서(BOQ) | 8개 대공종별 수량 x 단가 = 금액 (수식 포함) |
|
road_length_m=890,
|
||||||
| 물량산출근거 | 공종별 계산식 (예: 아스콘 표층 = 4,450m2 x 0.05m x 2.35t/m3) |
|
development_area_m2=None,
|
||||||
| 간접비산출 | 설계비 3.5%, 감리비 3.0%, 부대비 2.0%, 예비비 10% |
|
region="경기도",
|
||||||
| 총사업비요약 | 직접공사비 + 간접비 = **약 10.67억원** |
|
has_farmland=False,
|
||||||
| 연도별투자계획 | 2026: 30%, 2027: 50%, 2028: 20% |
|
has_forest=False,
|
||||||
|
has_river=False,
|
||||||
**4) 법적 절차 확인 | Legal Procedures** (`civilplan_get_legal_procedures`)
|
is_public=True
|
||||||
|
)
|
||||||
18개 법적 절차를 자동으로 식별하고, 필수/선택 여부, 소요 기간, 근거 법령을 제공합니다:
|
|
||||||
|
|
||||||
```
|
|
||||||
필수 절차 12건, 선택 절차 6건
|
|
||||||
예상 인허가 소요: 약 18개월
|
|
||||||
핵심 경로: 도시계획시설결정 -> 개발행위허가 -> 실시계획인가
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**5) 영향평가 판단 | Impact Assessments** (`civilplan_evaluate_impact_assessments`)
|
**결과 예시 | Example result**
|
||||||
|
|
||||||
9종 영향평가 대상 여부를 자동 판단합니다:
|
|
||||||
|
|
||||||
| 평가 항목 | 대상 여부 | 근거 |
|
|
||||||
|----------|----------|------|
|
|
||||||
| 예비타당성조사 | 비대상 | 총사업비 500억 미만 |
|
|
||||||
| 지방재정투자심사 | **대상** | 총사업비 10.7억 > 10억 |
|
|
||||||
| 소규모환경영향평가 | **검토 필요** | 개발면적 5,340m2 |
|
|
||||||
| 재해영향평가 | **경계선** | 개발면적 5,000m2 이상 |
|
|
||||||
| 매장문화재 지표조사 | **검토 필요** | 개발면적 3,000m2 이상 |
|
|
||||||
|
|
||||||
**6) 도면 생성 | Drawing Generation** (`civilplan_generate_svg_drawing`)
|
|
||||||
|
|
||||||
평면도와 횡단면도를 SVG 형식으로 생성합니다:
|
|
||||||
- **평면도**: 도로 중심선, 측점, 관로 배치, 지형(둔턱) 표시, 구조물 위치
|
|
||||||
- **횡단면도**: 포장 단면(표층->기층->보조기층->동상방지층), 절토/성토 비탈면, 매설 관로
|
|
||||||
|
|
||||||
**7) 투자계획서 | Investment Document** (`civilplan_generate_investment_doc`)
|
|
||||||
|
|
||||||
위 모든 결과를 종합하여 Word 투자계획서를 자동 생성합니다:
|
|
||||||
|
|
||||||
```
|
|
||||||
표지
|
|
||||||
목차
|
|
||||||
제1장 사업 개요 (목적, 위치, 기간)
|
|
||||||
제2장 사업 규모 및 내용 (도로 현황, 부대시설, 지형)
|
|
||||||
제3장 사업비 산출 (BOQ 요약, 간접비, 연도별 투자계획)
|
|
||||||
제4장 법적 절차 및 추진 일정
|
|
||||||
제5장 기대 효과 및 결론
|
|
||||||
별첨: 위치도, 횡단면도
|
|
||||||
```
|
|
||||||
|
|
||||||
### 예시 2: 단가 조회 | Example 2: Unit Price Query
|
|
||||||
|
|
||||||
```
|
|
||||||
경기도 지역의 포장 관련 단가를 알려줘
|
|
||||||
```
|
|
||||||
|
|
||||||
AI가 `civilplan_get_unit_prices`를 호출하여 지역계수가 반영된 단가를 조회합니다:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"item": "아스콘표층(밀입도13mm)",
|
"summary": {
|
||||||
"spec": "t=50mm",
|
"total_procedures": 3,
|
||||||
"unit": "t",
|
"mandatory_count": 2,
|
||||||
"base_price": 96000,
|
"optional_count": 1,
|
||||||
"region_factor": 1.05,
|
"estimated_prep_months": 12,
|
||||||
"adjusted_price": 100800,
|
"critical_path": [
|
||||||
"source": "조달청 표준시장단가 2026 상반기"
|
"도시·군관리계획 결정",
|
||||||
|
"개발행위허가",
|
||||||
|
"소규모환경영향평가"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timeline_estimate": {
|
||||||
|
"인허가완료목표": "착공 18개월 전"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 예시 3: 단계별 체크리스트 | Example 3: Phase Checklist
|
### 예시 3: SVG 도면 생성 | Example 3: Generate an SVG Drawing
|
||||||
|
|
||||||
```
|
**AI에게 이렇게 말하세요 | Say this to the AI**
|
||||||
도로 공사 단계에서 해야 할 의무사항을 알려줘
|
|
||||||
|
```text
|
||||||
|
위 프로젝트로 개략 평면도 SVG를 만들어줘
|
||||||
```
|
```
|
||||||
|
|
||||||
AI가 `civilplan_get_phase_checklist`를 호출합니다:
|
**호출되는 도구 | Tool called**
|
||||||
|
|
||||||
```
|
```python
|
||||||
[필수] 착공신고 -- 건설산업기본법 제39조, 착공 전
|
civilplan_generate_svg_drawing(
|
||||||
미이행 시 500만원 이하 과태료
|
drawing_type="평면도",
|
||||||
[필수] 품질시험계획 수립 -- 미제출 시 기성 지급 불가
|
project_spec=project_spec,
|
||||||
[필수] 안전관리계획 수립/인가
|
quantities=quantities,
|
||||||
...
|
scale="1:200",
|
||||||
|
output_filename="road-plan.svg"
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
**결과 예시 | Example result**
|
||||||
|
|
||||||
## 시스템 아키텍처 | System Architecture
|
```json
|
||||||
|
{
|
||||||
```
|
"status": "success",
|
||||||
Claude / ChatGPT / AI Agent
|
"file_path": "output/road-plan.svg",
|
||||||
| MCP Protocol (Streamable HTTP)
|
"drawing_type": "평면도",
|
||||||
v
|
"quantity_sections": ["earthwork", "pavement", "drainage"]
|
||||||
+------------------------------------------+
|
}
|
||||||
| CivilPlan MCP (FastMCP) |
|
|
||||||
| |
|
|
||||||
| parse_project -> JSON |
|
|
||||||
| get_legal_procedures -> JSON |
|
|
||||||
| evaluate_impact -> JSON |
|
|
||||||
| estimate_quantities -> JSON |
|
|
||||||
| generate_boq_excel -> .xlsx |
|
|
||||||
| generate_investment -> .docx |
|
|
||||||
| generate_schedule -> .xlsx |
|
|
||||||
| generate_svg_drawing -> .svg |
|
|
||||||
| generate_dxf_drawing -> .dxf |
|
|
||||||
| ... (19 tools total) |
|
|
||||||
+--------------------+---------------------+
|
|
||||||
|
|
|
||||||
+------v------+ +--------------+
|
|
||||||
| SQLite DB | | JSON Data |
|
|
||||||
| unit_prices | | legal_procs |
|
|
||||||
| legal_procs | | region_facts |
|
|
||||||
| project_log | | road_stds |
|
|
||||||
+-------------+ +--------------+
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 예시 4: Bird's-Eye View 렌더 생성 | Example 4: Generate a Bird's-Eye Render
|
||||||
|
|
||||||
|
**AI에게 이렇게 말하세요 | Say this to the AI**
|
||||||
|
|
||||||
|
```text
|
||||||
|
이 도로 사업을 발표용 3D 조감도와 사람 시점 투시도로 만들어줘
|
||||||
|
```
|
||||||
|
|
||||||
|
**호출되는 도구 | Tool called**
|
||||||
|
|
||||||
|
```python
|
||||||
|
civilplan_generate_birdseye_view(
|
||||||
|
project_summary="경기도 화성시 도로 신설 890m, 폭 6m, 2차선 아스콘 포장, 상하수도 포함",
|
||||||
|
project_spec=project_spec,
|
||||||
|
svg_drawing="<svg>...</svg>",
|
||||||
|
resolution="2K"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**결과 예시 | Example result**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"project_id": "PRJ-20260404-001",
|
||||||
|
"model": "gemini-3-pro-image-preview",
|
||||||
|
"resolution": "2K",
|
||||||
|
"reference_image_path": "output/PRJ-20260404-001_reference.png",
|
||||||
|
"birdseye_view": {
|
||||||
|
"status": "success",
|
||||||
|
"path": "output/PRJ-20260404-001_birdseye.png"
|
||||||
|
},
|
||||||
|
"perspective_view": {
|
||||||
|
"status": "success",
|
||||||
|
"path": "output/PRJ-20260404-001_perspective.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 프로젝트 구조 | Project Structure
|
## 프로젝트 구조 | Project Structure
|
||||||
|
|
||||||
```
|
```text
|
||||||
Construction-project-master/
|
Construction-project-master/
|
||||||
|-- server.py # 메인 서버 진입점 | Main server entry point
|
├─ server.py # 서버 실행 진입점 | Server entrypoint
|
||||||
|-- setup_keys.py # API 키 설정 도구 | API key setup utility
|
├─ setup_keys.py # 암호화 키 저장 유틸 | Encrypted key setup helper
|
||||||
|-- pyproject.toml # 프로젝트 메타데이터 | Project metadata
|
├─ requirements.txt # 런타임 의존성 | Runtime dependencies
|
||||||
|-- requirements.txt # 의존성 목록 | Dependencies
|
├─ pyproject.toml # 패키지 메타데이터 | Package metadata
|
||||||
|-- .env.example # 환경변수 템플릿 | Environment template
|
├─ README.md # 사용 가이드 | Usage guide
|
||||||
|-- LICENSE # MIT 라이선스 | MIT License
|
├─ civilplan_mcp/
|
||||||
|
|
│ ├─ __init__.py # 버전 정보 | Version metadata
|
||||||
|-- civilplan_mcp/ # 메인 패키지 | Main package
|
│ ├─ config.py # 설정·경로·API 키 로딩 | Settings, paths, API key loading
|
||||||
| |-- server.py # FastMCP 서버 정의 | FastMCP server definition
|
│ ├─ models.py # 도메인 enum | Domain enums
|
||||||
| |-- config.py # 설정, 경로, 상수 | Config, paths, constants
|
│ ├─ secure_store.py # DPAPI 키 저장 | DPAPI-backed key store
|
||||||
| |-- models.py # Pydantic 모델 | Pydantic models
|
│ ├─ prompts/
|
||||||
| |-- secure_store.py # 암호화 키 저장 | Encrypted key storage
|
│ │ └─ birdseye_templates.py # 도메인별 렌더 프롬프트 | Domain-specific render prompts
|
||||||
| |-- tools/ # 19개 MCP 도구 구현 | 19 MCP tool implementations
|
│ ├─ services/
|
||||||
| |-- data/ # JSON 참조 데이터 | JSON reference data
|
│ │ └─ gemini_image.py # Gemini 이미지 래퍼 | Gemini image wrapper
|
||||||
| |-- db/ # SQLite 스키마 및 시드 | SQLite schema & seeds
|
│ ├─ tools/
|
||||||
| +-- updater/ # 자동 데이터 갱신 | Automated data updaters
|
│ │ ├─ birdseye_generator.py # 3D 렌더 도구 | 3D rendering tool
|
||||||
|
|
│ │ ├─ drawing_generator.py # SVG 도면 생성 | SVG drawing generator
|
||||||
+-- tests/ # 테스트 스위트 | Test suite
|
│ │ ├─ dxf_generator.py # DXF 도면 생성 | DXF drawing generator
|
||||||
|-- test_smoke.py # 기본 동작 확인 | Basic smoke tests
|
│ │ └─ ... # 나머지 MCP 도구 | Remaining MCP tools
|
||||||
|-- test_parser.py # 파서 테스트 | Parser tests
|
│ ├─ data/ # 기준 JSON 데이터 | Reference JSON data
|
||||||
|-- test_legal.py # 법적 절차 테스트 | Legal procedure tests
|
│ ├─ db/ # SQLite schema/bootstrap | SQLite schema/bootstrap
|
||||||
|-- test_quantities.py # 물량 산출 테스트 | Quantity tests
|
│ └─ updater/ # 데이터 갱신 로직 | Data update logic
|
||||||
|-- test_generators.py # 파일 생성 테스트 | Generator tests
|
└─ tests/
|
||||||
+-- ... # 기타 테스트 | Other tests
|
├─ test_config_and_secure_store.py
|
||||||
|
├─ test_gemini_image.py
|
||||||
|
├─ test_birdseye_templates.py
|
||||||
|
├─ test_birdseye_generator.py
|
||||||
|
└─ ... # 전체 회귀 테스트 | Full regression tests
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 자주 겪는 문제 | FAQ and Troubleshooting
|
||||||
|
|
||||||
## 데이터 자동 갱신 | Automated Data Updates
|
| 문제 Problem | 확인 방법 What to Check | 해결 방법 Fix |
|
||||||
|
|---|---|---|
|
||||||
CivilPlan은 단가/임금/폐기물 처리비 등 참조 데이터의 정기 갱신을 지원합니다:
|
| `GEMINI_API_KEY is not configured` | `.env` 또는 `setup_keys.py` 저장 여부를 확인합니다.<br/>Check `.env` or whether `setup_keys.py` stored the key. | `GEMINI_API_KEY`를 입력하고 서버를 재시작합니다.<br/>Add `GEMINI_API_KEY` and restart the server. |
|
||||||
|
| ChatGPT에서 localhost 연결 실패 | ChatGPT는 로컬 URL을 직접 쓰지 못합니다.<br/>ChatGPT cannot use a localhost URL directly. | `cloudflared` 또는 `ngrok`로 HTTPS 터널을 노출합니다.<br/>Expose the server through an HTTPS tunnel such as `cloudflared` or `ngrok`. |
|
||||||
CivilPlan supports scheduled updates for reference data (wages, prices, waste rates):
|
| Claude Code에서 도구가 안 보임 | `claude mcp list`로 등록 상태를 확인합니다.<br/>Use `claude mcp list` to verify registration. | `claude mcp add --transport http civilplan http://127.0.0.1:8765/mcp`를 다시 실행합니다.<br/>Re-run the HTTP MCP registration command. |
|
||||||
|
| SVG 참고 이미지가 반영되지 않음 | `cairosvg` 설치 여부와 SVG 문자열 유효성을 확인합니다.<br/>Check whether `cairosvg` is installed and the SVG string is valid. | 잘못된 SVG면 텍스트 전용 렌더로 fallback 됩니다.<br/>If SVG conversion fails, the tool falls back to text-only rendering. |
|
||||||
| 시기 Timing | 갱신 항목 Update Item |
|
| 전체 테스트를 다시 돌리고 싶음 | 아래 명령을 사용합니다.<br/>Use the following command. | `python -m pytest tests/ -q` |
|
||||||
|------------|---------------------|
|
|
||||||
| 1월 2일 09:00 | 상반기 임금, 폐기물 처리비, 간접비율 |
|
|
||||||
| 7월 10일 09:00 | 하반기 표준시장단가, 간접비율 |
|
|
||||||
| 9월 2일 09:00 | 하반기 임금 |
|
|
||||||
|
|
||||||
갱신 실패 시 `.update_required_*` 플래그 파일이 생성되고, 서버 시작 시 경고가 표시됩니다.
|
|
||||||
|
|
||||||
If an update fails, `.update_required_*` flag files are created and startup warnings are shown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 토지 정보 데이터 설정 | Land Price Data Setup
|
|
||||||
|
|
||||||
토지 가격 조회 기능을 사용하려면 수동으로 데이터를 다운로드해야 합니다:
|
|
||||||
|
|
||||||
To use land price lookup, manually download data files:
|
|
||||||
|
|
||||||
1. 국토교통부 또는 한국부동산원에서 공시지가 CSV/TSV 파일 다운로드
|
|
||||||
2. `civilplan_mcp/data/land_prices/` 폴더에 넣기
|
|
||||||
3. UTF-8, CP949, EUC-KR 인코딩 모두 지원
|
|
||||||
|
|
||||||
```
|
|
||||||
civilplan_mcp/data/land_prices/
|
|
||||||
(여기에 CSV/TSV/ZIP 파일을 넣으세요)
|
|
||||||
(Place your CSV/TSV/ZIP files here)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 테스트 실행 | Running Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests -q
|
|
||||||
```
|
|
||||||
|
|
||||||
모든 테스트는 외부 API 키 없이도 실행 가능합니다 (로컬 폴백 사용).
|
|
||||||
|
|
||||||
All tests run without external API keys (using local fallbacks).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 알려진 제한사항 | Known Limitations
|
## 알려진 제한사항 | Known Limitations
|
||||||
|
|
||||||
- **개략 산출**: 모든 사업비/물량은 기획 단계용 개략 산출이며, 실시설계를 대체하지 않습니다 (+-20~30% 오차 가능).
|
| 항목 Item | 설명 Description |
|
||||||
*All estimates are preliminary (+-20-30% variance) and do not replace detailed design.*
|
|---|---|
|
||||||
|
| 기획 단계 정확도 | 모든 수치와 절차는 개략 검토용입니다.<br/>All numbers and procedures are intended for conceptual planning only. |
|
||||||
|
| 3D 렌더 의존성 | `civilplan_generate_birdseye_view`는 인터넷 연결과 `GEMINI_API_KEY`가 필요합니다.<br/>`civilplan_generate_birdseye_view` requires internet access and a `GEMINI_API_KEY`. |
|
||||||
|
| 토지 정보 | 일부 토지 데이터는 외부 API 상태에 따라 결과가 달라질 수 있습니다.<br/>Some land information depends on external API availability. |
|
||||||
|
| 조경·복합 도메인 | 프롬프트와 절차 데이터가 계속 보강 중입니다.<br/>Landscape and mixed-domain support is still being expanded. |
|
||||||
|
| 공개 제출 문서 | 생성 결과는 공식 제출 문서가 아닙니다.<br/>Generated outputs are not valid submission documents. |
|
||||||
|
|
||||||
- **토지 용도 데이터**: 외부 서비스 불안정으로 일부 필지의 용도지역 정보가 불완전할 수 있습니다.
|
## 면책사항 | Disclaimer
|
||||||
*External land-use services can be unstable; some parcels may return partial zoning data.*
|
|
||||||
|
|
||||||
- **공시지가 조회**: 수동 다운로드 필요 (`civilplan_mcp/data/land_prices/`).
|
> 본 저장소의 결과물은 기획 단계 참고자료이며, 상세 설계·발주·공식 제출용 문서를 대체하지 않습니다.
|
||||||
*Land price lookup requires manually downloaded source files.*
|
> Outputs from this repository are planning-stage references and do not replace detailed design, procurement, or official submission documents.
|
||||||
|
|
||||||
- **나라장터 벤치마크**: 공공 API가 불안정하여 로컬 휴리스틱으로 폴백합니다.
|
|
||||||
*Nara benchmark validation falls back to local heuristics when the public API is unavailable.*
|
|
||||||
|
|
||||||
- **조경 분야**: 법적 절차 데이터가 아직 완전하지 않습니다.
|
|
||||||
*Landscape-specific legal/procedure data is not fully implemented yet.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 면책 조항 | Disclaimer
|
|
||||||
|
|
||||||
> 본 도구의 산출 결과는 **기획 단계 참고용**이며, 실시설계/시공을 위한 공식 문서로 사용할 수 없습니다.
|
|
||||||
> 실제 사업 집행 시에는 반드시 관련 분야 전문가의 검토를 받으시기 바랍니다.
|
|
||||||
>
|
|
||||||
> All outputs are for **preliminary planning reference only** and cannot be used as official documents for detailed design or construction.
|
|
||||||
> Please consult qualified professionals before executing any actual project.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 라이선스 | License
|
## 라이선스 | License
|
||||||
|
|
||||||
MIT License -- 자유롭게 사용, 수정, 배포할 수 있습니다.
|
| 항목 Item | 내용 Detail |
|
||||||
|
|---|---|
|
||||||
MIT License -- Free to use, modify, and distribute.
|
| 라이선스 License | MIT |
|
||||||
|
| 사용 범위 Usage | 사용, 수정, 배포 가능<br/>Free to use, modify, and distribute |
|
||||||
---
|
|
||||||
|
|
||||||
## 만든 사람 | Author
|
## 만든 사람 | Author
|
||||||
|
|
||||||
**22B Labs** (sinmb79)
|
| 항목 Item | 내용 Detail |
|
||||||
|
|---|---|
|
||||||
문의사항이나 기여는 [Issues](https://github.com/sinmb79/Construction-project-master/issues)를 이용해 주세요.
|
| 팀 Team | **22B Labs** |
|
||||||
|
| 저장소 Repository | [sinmb79/Construction-project-master](https://github.com/sinmb79/Construction-project-master) |
|
||||||
For questions or contributions, please use [Issues](https://github.com/sinmb79/Construction-project-master/issues).
|
| 문의 Contact | [Issues](https://github.com/sinmb79/Construction-project-master/issues) |
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "2.0.0"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def _load_secure_api_keys(path: Path) -> dict[str, str]:
|
|||||||
|
|
||||||
class Settings(BaseModel):
|
class Settings(BaseModel):
|
||||||
app_name: str = "civilplan_mcp"
|
app_name: str = "civilplan_mcp"
|
||||||
version: str = "1.0.0"
|
version: str = "2.0.0"
|
||||||
host: str = "127.0.0.1"
|
host: str = "127.0.0.1"
|
||||||
port: int = 8765
|
port: int = 8765
|
||||||
http_path: str = "/mcp"
|
http_path: str = "/mcp"
|
||||||
@@ -38,6 +38,7 @@ class Settings(BaseModel):
|
|||||||
key_store_path: Path = Field(default_factory=default_key_store_path)
|
key_store_path: Path = Field(default_factory=default_key_store_path)
|
||||||
data_go_kr_api_key: str = Field(default_factory=lambda: os.getenv("DATA_GO_KR_API_KEY", ""))
|
data_go_kr_api_key: str = Field(default_factory=lambda: os.getenv("DATA_GO_KR_API_KEY", ""))
|
||||||
vworld_api_key: str = Field(default_factory=lambda: os.getenv("VWORLD_API_KEY", ""))
|
vworld_api_key: str = Field(default_factory=lambda: os.getenv("VWORLD_API_KEY", ""))
|
||||||
|
gemini_api_key: str = Field(default_factory=lambda: os.getenv("GEMINI_API_KEY", ""))
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
@@ -50,6 +51,8 @@ def get_settings() -> Settings:
|
|||||||
settings.data_go_kr_api_key = secure_keys.get("DATA_GO_KR_API_KEY", "")
|
settings.data_go_kr_api_key = secure_keys.get("DATA_GO_KR_API_KEY", "")
|
||||||
if not settings.vworld_api_key:
|
if not settings.vworld_api_key:
|
||||||
settings.vworld_api_key = secure_keys.get("VWORLD_API_KEY", "")
|
settings.vworld_api_key = secure_keys.get("VWORLD_API_KEY", "")
|
||||||
|
if not settings.gemini_api_key:
|
||||||
|
settings.gemini_api_key = secure_keys.get("GEMINI_API_KEY", "")
|
||||||
|
|
||||||
settings.output_dir.mkdir(parents=True, exist_ok=True)
|
settings.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return settings
|
return settings
|
||||||
@@ -62,4 +65,6 @@ def check_api_keys() -> list[str]:
|
|||||||
missing.append("DATA_GO_KR_API_KEY")
|
missing.append("DATA_GO_KR_API_KEY")
|
||||||
if not settings.vworld_api_key:
|
if not settings.vworld_api_key:
|
||||||
missing.append("VWORLD_API_KEY")
|
missing.append("VWORLD_API_KEY")
|
||||||
|
if not settings.gemini_api_key:
|
||||||
|
missing.append("GEMINI_API_KEY")
|
||||||
return missing
|
return missing
|
||||||
|
|||||||
3
civilplan_mcp/prompts/__init__.py
Normal file
3
civilplan_mcp/prompts/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from civilplan_mcp.prompts.birdseye_templates import DOMAIN_PROMPTS, VIEW_INSTRUCTIONS, build_prompt
|
||||||
|
|
||||||
|
__all__ = ["DOMAIN_PROMPTS", "VIEW_INSTRUCTIONS", "build_prompt"]
|
||||||
67
civilplan_mcp/prompts/birdseye_templates.py
Normal file
67
civilplan_mcp/prompts/birdseye_templates.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DOMAIN_PROMPTS: dict[str, str] = {
|
||||||
|
"road": (
|
||||||
|
"Focus on the road alignment, lane markings, shoulders, drainage channels, utility corridors, "
|
||||||
|
"guard rails, and the surrounding Korean rural or suburban context."
|
||||||
|
),
|
||||||
|
"building": (
|
||||||
|
"Focus on the building massing, facade materials, rooftop equipment, parking, pedestrian circulation, "
|
||||||
|
"and the surrounding Korean urban block."
|
||||||
|
),
|
||||||
|
"water": (
|
||||||
|
"Focus on pipeline routing, manholes, pump stations, treatment structures, trench alignment, "
|
||||||
|
"and road-side utility coordination."
|
||||||
|
),
|
||||||
|
"river": (
|
||||||
|
"Focus on embankments, flood-control structures, riprap, levee walks, bridge crossings, "
|
||||||
|
"and natural riparian vegetation."
|
||||||
|
),
|
||||||
|
"landscape": (
|
||||||
|
"Focus on planting composition, trails, plazas, seating, play areas, water features, "
|
||||||
|
"and seasonal Korean vegetation."
|
||||||
|
),
|
||||||
|
"mixed": (
|
||||||
|
"Show a comprehensive development site where roads, buildings, utility systems, and landscape work together "
|
||||||
|
"as one coordinated Korean construction project."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
VIEW_INSTRUCTIONS: dict[str, str] = {
|
||||||
|
"birdseye": (
|
||||||
|
"Create a photorealistic bird's-eye view rendering with an aerial camera angle around 45 to 60 degrees, "
|
||||||
|
"covering the full project extent and nearby context."
|
||||||
|
),
|
||||||
|
"perspective": (
|
||||||
|
"Create a photorealistic perspective rendering from a representative human-scale viewpoint, "
|
||||||
|
"showing how the project feels on the ground."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(
|
||||||
|
*,
|
||||||
|
view_type: str,
|
||||||
|
project_type: str,
|
||||||
|
project_summary: str,
|
||||||
|
details: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
view_instruction = VIEW_INSTRUCTIONS.get(view_type, VIEW_INSTRUCTIONS["birdseye"])
|
||||||
|
domain_instruction = DOMAIN_PROMPTS.get(project_type, DOMAIN_PROMPTS["mixed"])
|
||||||
|
detail_lines = [f"- {key}: {value}" for key, value in details.items() if value not in (None, "", [], {})]
|
||||||
|
detail_block = "\n".join(detail_lines) if detail_lines else "- No additional technical details provided."
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{view_instruction}\n\n"
|
||||||
|
f"Project summary:\n{project_summary}\n\n"
|
||||||
|
f"Technical details:\n{detail_block}\n\n"
|
||||||
|
f"Domain guidance:\n{domain_instruction}\n\n"
|
||||||
|
"Style requirements:\n"
|
||||||
|
"- Professional architectural visualization for a Korean civil or building project.\n"
|
||||||
|
"- Clear daytime weather, realistic materials, and readable spatial hierarchy.\n"
|
||||||
|
"- Include surrounding terrain, access roads, and scale cues where appropriate.\n"
|
||||||
|
"- Avoid people-heavy staging, exaggerated concept-art effects, or fantasy aesthetics."
|
||||||
|
)
|
||||||
@@ -16,6 +16,7 @@ from civilplan_mcp import __version__
|
|||||||
from civilplan_mcp.config import check_api_keys, get_settings
|
from civilplan_mcp.config import check_api_keys, get_settings
|
||||||
from civilplan_mcp.tools.benchmark_validator import validate_against_benchmark
|
from civilplan_mcp.tools.benchmark_validator import validate_against_benchmark
|
||||||
from civilplan_mcp.tools.bid_type_selector import select_bid_type
|
from civilplan_mcp.tools.bid_type_selector import select_bid_type
|
||||||
|
from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view
|
||||||
from civilplan_mcp.tools.boq_generator import generate_boq_excel
|
from civilplan_mcp.tools.boq_generator import generate_boq_excel
|
||||||
from civilplan_mcp.tools.budget_report_generator import generate_budget_report
|
from civilplan_mcp.tools.budget_report_generator import generate_budget_report
|
||||||
from civilplan_mcp.tools.doc_generator import generate_investment_doc
|
from civilplan_mcp.tools.doc_generator import generate_investment_doc
|
||||||
@@ -113,6 +114,7 @@ def build_mcp() -> FastMCP:
|
|||||||
_register_read_tool(app, "civilplan_validate_against_benchmark", validate_against_benchmark)
|
_register_read_tool(app, "civilplan_validate_against_benchmark", validate_against_benchmark)
|
||||||
_register_write_tool(app, "civilplan_generate_budget_report", generate_budget_report)
|
_register_write_tool(app, "civilplan_generate_budget_report", generate_budget_report)
|
||||||
_register_write_tool(app, "civilplan_generate_dxf_drawing", generate_dxf_drawing)
|
_register_write_tool(app, "civilplan_generate_dxf_drawing", generate_dxf_drawing)
|
||||||
|
_register_write_tool(app, "civilplan_generate_birdseye_view", generate_birdseye_view)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
3
civilplan_mcp/services/__init__.py
Normal file
3
civilplan_mcp/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||||
|
|
||||||
|
__all__ = ["GeminiImageService"]
|
||||||
122
civilplan_mcp/services/gemini_image.py
Normal file
122
civilplan_mcp/services/gemini_image.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
try:
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types as genai_types
|
||||||
|
except ImportError: # pragma: no cover - exercised in tests via runtime guard
|
||||||
|
genai = None
|
||||||
|
genai_types = None
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiImageService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
model: str = "gemini-3-pro-image-preview",
|
||||||
|
client: Any | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self._client = client or self._build_client()
|
||||||
|
|
||||||
|
def _build_client(self) -> Any:
|
||||||
|
if genai is None:
|
||||||
|
raise RuntimeError("google-genai is not installed. Install it to use GeminiImageService.")
|
||||||
|
return genai.Client(api_key=self.api_key)
|
||||||
|
|
||||||
|
def _build_config(self, *, aspect_ratio: str, image_size: str) -> Any:
|
||||||
|
if genai_types is None:
|
||||||
|
return {
|
||||||
|
"response_modalities": ["TEXT", "IMAGE"],
|
||||||
|
"image_config": {
|
||||||
|
"aspect_ratio": aspect_ratio,
|
||||||
|
"image_size": image_size,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
image_config_factory = getattr(genai_types, "ImageConfig", None)
|
||||||
|
generate_config_factory = getattr(genai_types, "GenerateContentConfig", None)
|
||||||
|
image_config = (
|
||||||
|
image_config_factory(aspect_ratio=aspect_ratio, image_size=image_size)
|
||||||
|
if callable(image_config_factory)
|
||||||
|
else {
|
||||||
|
"aspect_ratio": aspect_ratio,
|
||||||
|
"image_size": image_size,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if callable(generate_config_factory):
|
||||||
|
return generate_config_factory(
|
||||||
|
response_modalities=["TEXT", "IMAGE"],
|
||||||
|
image_config=image_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response_modalities": ["TEXT", "IMAGE"],
|
||||||
|
"image_config": image_config,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_parts(response: Any) -> list[Any]:
|
||||||
|
direct_parts = getattr(response, "parts", None)
|
||||||
|
if direct_parts:
|
||||||
|
return list(direct_parts)
|
||||||
|
|
||||||
|
candidates = getattr(response, "candidates", None) or []
|
||||||
|
for candidate in candidates:
|
||||||
|
candidate_parts = getattr(getattr(candidate, "content", None), "parts", None)
|
||||||
|
if candidate_parts:
|
||||||
|
return list(candidate_parts)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def generate_image(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
output_path: str,
|
||||||
|
reference_image_path: str | None = None,
|
||||||
|
aspect_ratio: str = "16:9",
|
||||||
|
image_size: str = "2K",
|
||||||
|
) -> dict[str, str]:
|
||||||
|
reference_image: PILImage.Image | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
contents: list[Any] = [prompt]
|
||||||
|
if reference_image_path:
|
||||||
|
reference_image = PILImage.open(reference_image_path)
|
||||||
|
contents.append(reference_image)
|
||||||
|
|
||||||
|
response = self._client.models.generate_content(
|
||||||
|
model=self.model,
|
||||||
|
contents=contents,
|
||||||
|
config=self._build_config(aspect_ratio=aspect_ratio, image_size=image_size),
|
||||||
|
)
|
||||||
|
|
||||||
|
for part in self._extract_parts(response):
|
||||||
|
if getattr(part, "inline_data", None) is not None and hasattr(part, "as_image"):
|
||||||
|
output = Path(output_path)
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
part.as_image().save(str(output))
|
||||||
|
return {"status": "success", "path": str(output)}
|
||||||
|
|
||||||
|
text_parts = [str(part.text).strip() for part in self._extract_parts(response) if getattr(part, "text", None)]
|
||||||
|
message = "No image in API response."
|
||||||
|
if text_parts:
|
||||||
|
message = f"{message} {' '.join(text_parts)}"
|
||||||
|
return {"status": "error", "error": message}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Gemini image generation failed.")
|
||||||
|
return {"status": "error", "error": str(exc)}
|
||||||
|
finally:
|
||||||
|
if reference_image is not None:
|
||||||
|
reference_image.close()
|
||||||
@@ -42,11 +42,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
data_go_kr_api_key = _prompt_value("DATA_GO_KR_API_KEY", imported.get("DATA_GO_KR_API_KEY", ""))
|
data_go_kr_api_key = _prompt_value("DATA_GO_KR_API_KEY", imported.get("DATA_GO_KR_API_KEY", ""))
|
||||||
vworld_api_key = _prompt_value("VWORLD_API_KEY", imported.get("VWORLD_API_KEY", ""))
|
vworld_api_key = _prompt_value("VWORLD_API_KEY", imported.get("VWORLD_API_KEY", ""))
|
||||||
|
gemini_api_key = _prompt_value("GEMINI_API_KEY", imported.get("GEMINI_API_KEY", ""))
|
||||||
|
|
||||||
target = save_api_keys(
|
target = save_api_keys(
|
||||||
{
|
{
|
||||||
"DATA_GO_KR_API_KEY": data_go_kr_api_key,
|
"DATA_GO_KR_API_KEY": data_go_kr_api_key,
|
||||||
"VWORLD_API_KEY": vworld_api_key,
|
"VWORLD_API_KEY": vworld_api_key,
|
||||||
|
"GEMINI_API_KEY": gemini_api_key,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
129
civilplan_mcp/tools/birdseye_generator.py
Normal file
129
civilplan_mcp/tools/birdseye_generator.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from civilplan_mcp.config import get_settings
|
||||||
|
from civilplan_mcp.models import ProjectDomain
|
||||||
|
from civilplan_mcp.prompts.birdseye_templates import build_prompt
|
||||||
|
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||||
|
from civilplan_mcp.tools._base import wrap_response
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DOMAIN_TO_PROJECT_TYPE = {
|
||||||
|
"토목_도로": "road",
|
||||||
|
"건축": "building",
|
||||||
|
"토목_상하수도": "water",
|
||||||
|
"토목_하천": "river",
|
||||||
|
"조경": "landscape",
|
||||||
|
"복합": "mixed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_to_project_type(domain: str) -> str:
|
||||||
|
return DOMAIN_TO_PROJECT_TYPE.get(domain, "mixed")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_domain(domain: str | None) -> ProjectDomain:
|
||||||
|
try:
|
||||||
|
return ProjectDomain(domain or ProjectDomain.복합.value)
|
||||||
|
except ValueError:
|
||||||
|
return ProjectDomain.복합
|
||||||
|
|
||||||
|
|
||||||
|
def svg_to_png(svg_content: str, output_path: str) -> str:
|
||||||
|
import cairosvg
|
||||||
|
|
||||||
|
cairosvg.svg2png(bytestring=svg_content.encode("utf-8"), write_to=output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def generate_birdseye_view(
|
||||||
|
*,
|
||||||
|
project_summary: str,
|
||||||
|
project_spec: dict[str, Any],
|
||||||
|
svg_drawing: str | None = None,
|
||||||
|
resolution: str = "2K",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
domain = _resolve_domain(project_spec.get("domain"))
|
||||||
|
project_id = project_spec.get("project_id", "birdseye-render")
|
||||||
|
|
||||||
|
if not settings.gemini_api_key:
|
||||||
|
return wrap_response(
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"project_id": project_id,
|
||||||
|
"error": "GEMINI_API_KEY is not configured. Add it to .env or store it with python setup_keys.py.",
|
||||||
|
},
|
||||||
|
domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_dir = Path(project_spec.get("output_dir") or settings.output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
details: dict[str, Any] = {}
|
||||||
|
if isinstance(project_spec.get("road"), dict):
|
||||||
|
details.update({key: value for key, value in project_spec["road"].items() if value is not None})
|
||||||
|
for key in ("terrain", "region", "utilities", "year_start", "year_end"):
|
||||||
|
value = project_spec.get(key)
|
||||||
|
if value not in (None, "", [], {}):
|
||||||
|
details[key] = value
|
||||||
|
|
||||||
|
reference_image_path: str | None = None
|
||||||
|
if svg_drawing:
|
||||||
|
try:
|
||||||
|
reference_image_path = svg_to_png(svg_drawing, str(output_dir / f"{project_id}_reference.png"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to convert SVG reference for birdseye render: %s", exc)
|
||||||
|
|
||||||
|
service = GeminiImageService(api_key=settings.gemini_api_key)
|
||||||
|
project_type = _domain_to_project_type(domain.value)
|
||||||
|
|
||||||
|
birdseye_result = service.generate_image(
|
||||||
|
prompt=build_prompt(
|
||||||
|
view_type="birdseye",
|
||||||
|
project_type=project_type,
|
||||||
|
project_summary=project_summary,
|
||||||
|
details=details,
|
||||||
|
),
|
||||||
|
output_path=str(output_dir / f"{project_id}_birdseye.png"),
|
||||||
|
reference_image_path=reference_image_path,
|
||||||
|
aspect_ratio="16:9",
|
||||||
|
image_size=resolution,
|
||||||
|
)
|
||||||
|
perspective_result = service.generate_image(
|
||||||
|
prompt=build_prompt(
|
||||||
|
view_type="perspective",
|
||||||
|
project_type=project_type,
|
||||||
|
project_summary=project_summary,
|
||||||
|
details=details,
|
||||||
|
),
|
||||||
|
output_path=str(output_dir / f"{project_id}_perspective.png"),
|
||||||
|
reference_image_path=reference_image_path,
|
||||||
|
aspect_ratio="16:9",
|
||||||
|
image_size=resolution,
|
||||||
|
)
|
||||||
|
|
||||||
|
if birdseye_result["status"] == "success" and perspective_result["status"] == "success":
|
||||||
|
status = "success"
|
||||||
|
elif birdseye_result["status"] == "success" or perspective_result["status"] == "success":
|
||||||
|
status = "partial"
|
||||||
|
else:
|
||||||
|
status = "error"
|
||||||
|
|
||||||
|
return wrap_response(
|
||||||
|
{
|
||||||
|
"status": status,
|
||||||
|
"project_id": project_id,
|
||||||
|
"model": service.model,
|
||||||
|
"resolution": resolution,
|
||||||
|
"reference_image_path": reference_image_path,
|
||||||
|
"birdseye_view": birdseye_result,
|
||||||
|
"perspective_view": perspective_result,
|
||||||
|
},
|
||||||
|
domain,
|
||||||
|
)
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "civilplan-mcp"
|
name = "civilplan-mcp"
|
||||||
version = "1.0.0"
|
version = "2.0.0"
|
||||||
description = "CivilPlan MCP server for Korean civil and building project planning."
|
description = "CivilPlan MCP server for Korean civil and building project planning."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -24,6 +24,9 @@ dependencies = [
|
|||||||
"apscheduler>=3.10.0",
|
"apscheduler>=3.10.0",
|
||||||
"python-dotenv>=1.0.1",
|
"python-dotenv>=1.0.1",
|
||||||
"python-dateutil>=2.9.0",
|
"python-dateutil>=2.9.0",
|
||||||
|
"google-genai>=1.0.0",
|
||||||
|
"cairosvg>=2.7.0",
|
||||||
|
"Pillow>=10.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -9,4 +9,7 @@ httpx>=0.27.0
|
|||||||
apscheduler>=3.10.0
|
apscheduler>=3.10.0
|
||||||
python-dotenv>=1.0.1
|
python-dotenv>=1.0.1
|
||||||
python-dateutil>=2.9.0
|
python-dateutil>=2.9.0
|
||||||
|
google-genai>=1.0.0
|
||||||
|
cairosvg>=2.7.0
|
||||||
|
Pillow>=10.0.0
|
||||||
pytest>=8.0.0
|
pytest>=8.0.0
|
||||||
|
|||||||
142
tests/test_birdseye_generator.py
Normal file
142
tests/test_birdseye_generator.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from civilplan_mcp.tools.project_parser import parse_project
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_project_spec(tmp_path) -> dict:
|
||||||
|
project_spec = parse_project(
|
||||||
|
description="도로 신설 L=890m B=6m 아스콘 2차선 상하수도 경기도 화성시 2026~2028"
|
||||||
|
)
|
||||||
|
project_spec["project_id"] = "PRJ-20260404-001"
|
||||||
|
project_spec["output_dir"] = str(tmp_path)
|
||||||
|
return project_spec
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_birdseye_view_returns_both_images(monkeypatch, tmp_path) -> None:
|
||||||
|
from civilplan_mcp.tools import birdseye_generator
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.generate_image.side_effect = lambda **kwargs: {
|
||||||
|
"status": "success",
|
||||||
|
"path": kwargs["output_path"],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
birdseye_generator,
|
||||||
|
"get_settings",
|
||||||
|
lambda: MagicMock(gemini_api_key="test-key", output_dir=tmp_path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
birdseye_generator,
|
||||||
|
"GeminiImageService",
|
||||||
|
lambda **kwargs: mock_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = birdseye_generator.generate_birdseye_view(
|
||||||
|
project_summary="경기도 화성시 도로 신설 890m",
|
||||||
|
project_spec=_sample_project_spec(tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert result["birdseye_view"]["status"] == "success"
|
||||||
|
assert result["perspective_view"]["status"] == "success"
|
||||||
|
assert mock_service.generate_image.call_count == 2
|
||||||
|
assert "validity_disclaimer" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_birdseye_view_uses_svg_reference(monkeypatch, tmp_path) -> None:
|
||||||
|
from civilplan_mcp.tools import birdseye_generator
|
||||||
|
|
||||||
|
reference_path = tmp_path / "reference.png"
|
||||||
|
reference_path.write_bytes(b"png")
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.generate_image.return_value = {"status": "success", "path": str(tmp_path / "out.png")}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
birdseye_generator,
|
||||||
|
"get_settings",
|
||||||
|
lambda: MagicMock(gemini_api_key="test-key", output_dir=tmp_path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
birdseye_generator,
|
||||||
|
"GeminiImageService",
|
||||||
|
lambda **kwargs: mock_service,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
birdseye_generator,
|
||||||
|
"svg_to_png",
|
||||||
|
lambda svg_content, output_path: str(reference_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = birdseye_generator.generate_birdseye_view(
|
||||||
|
project_summary="경기도 화성시 도로 신설 890m",
|
||||||
|
project_spec=_sample_project_spec(tmp_path),
|
||||||
|
svg_drawing="<svg></svg>",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
for call in mock_service.generate_image.call_args_list:
|
||||||
|
assert call.kwargs["reference_image_path"] == str(reference_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_birdseye_view_requires_gemini_key(monkeypatch, tmp_path) -> None:
|
||||||
|
from civilplan_mcp.tools import birdseye_generator
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
birdseye_generator,
|
||||||
|
"get_settings",
|
||||||
|
lambda: MagicMock(gemini_api_key="", output_dir=tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = birdseye_generator.generate_birdseye_view(
|
||||||
|
project_summary="경기도 화성시 도로 신설 890m",
|
||||||
|
project_spec=_sample_project_spec(tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert "GEMINI_API_KEY" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_birdseye_view_returns_partial_if_one_view_fails(monkeypatch, tmp_path) -> None:
|
||||||
|
from civilplan_mcp.tools import birdseye_generator
|
||||||
|
|
||||||
|
mock_service = MagicMock()
|
||||||
|
mock_service.generate_image.side_effect = [
|
||||||
|
{"status": "success", "path": str(tmp_path / "birdseye.png")},
|
||||||
|
{"status": "error", "error": "rate limit"},
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
birdseye_generator,
|
||||||
|
"get_settings",
|
||||||
|
lambda: MagicMock(gemini_api_key="test-key", output_dir=tmp_path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
birdseye_generator,
|
||||||
|
"GeminiImageService",
|
||||||
|
lambda **kwargs: mock_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = birdseye_generator.generate_birdseye_view(
|
||||||
|
project_summary="경기도 화성시 도로 신설 890m",
|
||||||
|
project_spec=_sample_project_spec(tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "partial"
|
||||||
|
assert result["birdseye_view"]["status"] == "success"
|
||||||
|
assert result["perspective_view"]["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_domain_to_project_type_mapping() -> None:
|
||||||
|
from civilplan_mcp.tools.birdseye_generator import _domain_to_project_type
|
||||||
|
|
||||||
|
assert _domain_to_project_type("토목_도로") == "road"
|
||||||
|
assert _domain_to_project_type("건축") == "building"
|
||||||
|
assert _domain_to_project_type("토목_상하수도") == "water"
|
||||||
|
assert _domain_to_project_type("토목_하천") == "river"
|
||||||
|
assert _domain_to_project_type("조경") == "landscape"
|
||||||
|
assert _domain_to_project_type("복합") == "mixed"
|
||||||
|
assert _domain_to_project_type("unknown") == "mixed"
|
||||||
51
tests/test_birdseye_templates.py
Normal file
51
tests/test_birdseye_templates.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_birdseye_prompt_for_road() -> None:
|
||||||
|
from civilplan_mcp.prompts.birdseye_templates import build_prompt
|
||||||
|
|
||||||
|
prompt = build_prompt(
|
||||||
|
view_type="birdseye",
|
||||||
|
project_type="road",
|
||||||
|
project_summary="경기도 지방도 890m, 폭 6m, 2차선 아스팔트 포장 도로",
|
||||||
|
details={"length_m": 890, "width_m": 6, "lanes": 2, "pavement": "아스팔트"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "bird's-eye view" in prompt.lower()
|
||||||
|
assert "890" in prompt
|
||||||
|
assert "road" in prompt.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_perspective_prompt_for_building() -> None:
|
||||||
|
from civilplan_mcp.prompts.birdseye_templates import build_prompt
|
||||||
|
|
||||||
|
prompt = build_prompt(
|
||||||
|
view_type="perspective",
|
||||||
|
project_type="building",
|
||||||
|
project_summary="서울시 강남구 5층 오피스 빌딩",
|
||||||
|
details={"floors": 5, "use": "오피스"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "perspective" in prompt.lower()
|
||||||
|
assert "building" in prompt.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_project_types_have_templates() -> None:
|
||||||
|
from civilplan_mcp.prompts.birdseye_templates import DOMAIN_PROMPTS
|
||||||
|
|
||||||
|
assert set(DOMAIN_PROMPTS) == {"road", "building", "water", "river", "landscape", "mixed"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_project_type_falls_back_to_mixed() -> None:
|
||||||
|
from civilplan_mcp.prompts.birdseye_templates import build_prompt
|
||||||
|
|
||||||
|
prompt = build_prompt(
|
||||||
|
view_type="birdseye",
|
||||||
|
project_type="unknown",
|
||||||
|
project_summary="복합 개발 프로젝트",
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(prompt, str)
|
||||||
|
assert len(prompt) > 50
|
||||||
|
assert "comprehensive development site" in prompt.lower()
|
||||||
@@ -45,15 +45,18 @@ def test_get_settings_uses_secure_store_when_env_missing(tmp_path: Path, monkeyp
|
|||||||
lambda path: {
|
lambda path: {
|
||||||
"DATA_GO_KR_API_KEY": "secure-data-key",
|
"DATA_GO_KR_API_KEY": "secure-data-key",
|
||||||
"VWORLD_API_KEY": "secure-vworld-key",
|
"VWORLD_API_KEY": "secure-vworld-key",
|
||||||
|
"GEMINI_API_KEY": "secure-gemini-key",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
monkeypatch.delenv("DATA_GO_KR_API_KEY", raising=False)
|
monkeypatch.delenv("DATA_GO_KR_API_KEY", raising=False)
|
||||||
monkeypatch.delenv("VWORLD_API_KEY", raising=False)
|
monkeypatch.delenv("VWORLD_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||||
|
|
||||||
settings = config.get_settings()
|
settings = config.get_settings()
|
||||||
|
|
||||||
assert settings.data_go_kr_api_key == "secure-data-key"
|
assert settings.data_go_kr_api_key == "secure-data-key"
|
||||||
assert settings.vworld_api_key == "secure-vworld-key"
|
assert settings.vworld_api_key == "secure-vworld-key"
|
||||||
|
assert settings.gemini_api_key == "secure-gemini-key"
|
||||||
|
|
||||||
|
|
||||||
def test_get_settings_prefers_env_values_over_secure_store(tmp_path: Path, monkeypatch) -> None:
|
def test_get_settings_prefers_env_values_over_secure_store(tmp_path: Path, monkeypatch) -> None:
|
||||||
@@ -65,12 +68,37 @@ def test_get_settings_prefers_env_values_over_secure_store(tmp_path: Path, monke
|
|||||||
lambda path: {
|
lambda path: {
|
||||||
"DATA_GO_KR_API_KEY": "secure-data-key",
|
"DATA_GO_KR_API_KEY": "secure-data-key",
|
||||||
"VWORLD_API_KEY": "secure-vworld-key",
|
"VWORLD_API_KEY": "secure-vworld-key",
|
||||||
|
"GEMINI_API_KEY": "secure-gemini-key",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
monkeypatch.setenv("DATA_GO_KR_API_KEY", "env-data-key")
|
monkeypatch.setenv("DATA_GO_KR_API_KEY", "env-data-key")
|
||||||
monkeypatch.setenv("VWORLD_API_KEY", "env-vworld-key")
|
monkeypatch.setenv("VWORLD_API_KEY", "env-vworld-key")
|
||||||
|
monkeypatch.setenv("GEMINI_API_KEY", "env-gemini-key")
|
||||||
|
|
||||||
settings = config.get_settings()
|
settings = config.get_settings()
|
||||||
|
|
||||||
assert settings.data_go_kr_api_key == "env-data-key"
|
assert settings.data_go_kr_api_key == "env-data-key"
|
||||||
assert settings.vworld_api_key == "env-vworld-key"
|
assert settings.vworld_api_key == "env-vworld-key"
|
||||||
|
assert settings.gemini_api_key == "env-gemini-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_has_gemini_api_key(monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||||
|
|
||||||
|
settings = config.Settings()
|
||||||
|
|
||||||
|
assert hasattr(settings, "gemini_api_key")
|
||||||
|
assert settings.gemini_api_key == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_api_keys_includes_gemini_when_missing(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(config, "BASE_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(config, "load_local_env", lambda: None)
|
||||||
|
monkeypatch.setattr(config, "_load_secure_api_keys", lambda path: {})
|
||||||
|
monkeypatch.delenv("DATA_GO_KR_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("VWORLD_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||||
|
|
||||||
|
missing = config.check_api_keys()
|
||||||
|
|
||||||
|
assert "GEMINI_API_KEY" in missing
|
||||||
|
|||||||
122
tests/test_gemini_image.py
Normal file
122
tests/test_gemini_image.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_defaults_to_nano_banana_pro_model() -> None:
|
||||||
|
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||||
|
|
||||||
|
service = GeminiImageService(api_key="test-key", client=MagicMock())
|
||||||
|
|
||||||
|
assert service.api_key == "test-key"
|
||||||
|
assert service.model == "gemini-3-pro-image-preview"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_requires_sdk_or_client() -> None:
|
||||||
|
from civilplan_mcp.services import gemini_image
|
||||||
|
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||||
|
|
||||||
|
original_genai = gemini_image.genai
|
||||||
|
gemini_image.genai = None
|
||||||
|
try:
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
GeminiImageService(api_key="test-key")
|
||||||
|
finally:
|
||||||
|
gemini_image.genai = original_genai
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_image_calls_api_and_saves_output(tmp_path) -> None:
|
||||||
|
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_image = MagicMock()
|
||||||
|
mock_part = MagicMock()
|
||||||
|
mock_part.inline_data = MagicMock()
|
||||||
|
mock_part.text = None
|
||||||
|
mock_part.as_image.return_value = mock_image
|
||||||
|
mock_client.models.generate_content.return_value = MagicMock(parts=[mock_part])
|
||||||
|
|
||||||
|
service = GeminiImageService(api_key="test-key", client=mock_client)
|
||||||
|
output_path = tmp_path / "generated.png"
|
||||||
|
|
||||||
|
result = service.generate_image(
|
||||||
|
prompt="Generate a bird's-eye render of a Korean road project.",
|
||||||
|
output_path=str(output_path),
|
||||||
|
aspect_ratio="16:9",
|
||||||
|
image_size="2K",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert result["path"] == str(output_path)
|
||||||
|
mock_image.save.assert_called_once_with(str(output_path))
|
||||||
|
call_kwargs = mock_client.models.generate_content.call_args.kwargs
|
||||||
|
assert call_kwargs["model"] == "gemini-3-pro-image-preview"
|
||||||
|
assert call_kwargs["contents"] == ["Generate a bird's-eye render of a Korean road project."]
|
||||||
|
assert call_kwargs["config"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_image_with_reference_includes_image_content(tmp_path) -> None:
|
||||||
|
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||||
|
|
||||||
|
reference_path = tmp_path / "reference.png"
|
||||||
|
PILImage.new("RGB", (8, 8), "gray").save(reference_path)
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_image = MagicMock()
|
||||||
|
mock_part = MagicMock()
|
||||||
|
mock_part.inline_data = MagicMock()
|
||||||
|
mock_part.text = None
|
||||||
|
mock_part.as_image.return_value = mock_image
|
||||||
|
mock_client.models.generate_content.return_value = MagicMock(parts=[mock_part])
|
||||||
|
|
||||||
|
service = GeminiImageService(api_key="test-key", client=mock_client)
|
||||||
|
output_path = tmp_path / "generated.png"
|
||||||
|
|
||||||
|
result = service.generate_image(
|
||||||
|
prompt="Generate a road perspective render.",
|
||||||
|
output_path=str(output_path),
|
||||||
|
reference_image_path=str(reference_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
contents = mock_client.models.generate_content.call_args.kwargs["contents"]
|
||||||
|
assert len(contents) == 2
|
||||||
|
assert contents[0] == "Generate a road perspective render."
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_image_returns_error_on_api_failure(tmp_path) -> None:
|
||||||
|
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.models.generate_content.side_effect = RuntimeError("API error")
|
||||||
|
service = GeminiImageService(api_key="test-key", client=mock_client)
|
||||||
|
|
||||||
|
result = service.generate_image(
|
||||||
|
prompt="Generate a river project render.",
|
||||||
|
output_path=str(tmp_path / "generated.png"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert "API error" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_image_returns_error_when_response_has_no_image(tmp_path) -> None:
|
||||||
|
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_part = MagicMock()
|
||||||
|
mock_part.inline_data = None
|
||||||
|
mock_part.text = "No image available."
|
||||||
|
mock_client.models.generate_content.return_value = MagicMock(parts=[mock_part])
|
||||||
|
service = GeminiImageService(api_key="test-key", client=mock_client)
|
||||||
|
|
||||||
|
result = service.generate_image(
|
||||||
|
prompt="Generate a building render.",
|
||||||
|
output_path=str(tmp_path / "generated.png"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert "no image" in result["error"].lower()
|
||||||
@@ -11,14 +11,15 @@ def test_build_server_config_defaults() -> None:
|
|||||||
assert config["path"] == "/mcp"
|
assert config["path"] == "/mcp"
|
||||||
|
|
||||||
|
|
||||||
def test_server_registers_all_19_tools() -> None:
|
def test_server_registers_all_20_tools() -> None:
|
||||||
app = build_mcp()
|
app = build_mcp()
|
||||||
tools = asyncio.run(app.list_tools())
|
tools = asyncio.run(app.list_tools())
|
||||||
names = {tool.name for tool in tools}
|
names = {tool.name for tool in tools}
|
||||||
|
|
||||||
assert len(names) == 19
|
assert len(names) == 20
|
||||||
assert "civilplan_parse_project" in names
|
assert "civilplan_parse_project" in names
|
||||||
assert "civilplan_generate_dxf_drawing" in names
|
assert "civilplan_generate_dxf_drawing" in names
|
||||||
|
assert "civilplan_generate_birdseye_view" in names
|
||||||
|
|
||||||
|
|
||||||
def test_read_tools_have_read_only_hint() -> None:
|
def test_read_tools_have_read_only_hint() -> None:
|
||||||
|
|||||||
26
tests/test_setup_keys.py
Normal file
26
tests/test_setup_keys.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from civilplan_mcp import setup_keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_prompts_and_saves_gemini_api_key(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
prompted_values = iter(["data-key", "vworld-key", "gemini-key"])
|
||||||
|
saved_payload: dict[str, str] = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(setup_keys, "_prompt_value", lambda name, current="": next(prompted_values))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
setup_keys,
|
||||||
|
"save_api_keys",
|
||||||
|
lambda payload: saved_payload.update(payload) or tmp_path / "api-keys.dpapi.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
exit_code = setup_keys.main([])
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert saved_payload == {
|
||||||
|
"DATA_GO_KR_API_KEY": "data-key",
|
||||||
|
"VWORLD_API_KEY": "vworld-key",
|
||||||
|
"GEMINI_API_KEY": "gemini-key",
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ from civilplan_mcp.config import Settings, get_settings
|
|||||||
|
|
||||||
|
|
||||||
def test_package_version_present() -> None:
|
def test_package_version_present() -> None:
|
||||||
assert __version__ == "1.0.0"
|
assert __version__ == "2.0.0"
|
||||||
|
|
||||||
|
|
||||||
def test_settings_have_expected_defaults() -> None:
|
def test_settings_have_expected_defaults() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user