Merge pull request #1 from sinmb79/codex/birdseye-v2

[codex] add Gemini-powered birdseye rendering
This commit is contained in:
22B
2026-04-04 19:33:18 +09:00
committed by GitHub
22 changed files with 2475 additions and 396 deletions

View File

@@ -2,3 +2,4 @@
# python setup_keys.py --from-env-file .env
DATA_GO_KR_API_KEY=
VWORLD_API_KEY=
GEMINI_API_KEY=

680
README.md
View File

@@ -1,513 +1,413 @@
# Construction-Project-Planning-Master-MCP
# CivilPlan MCP v2.0.0
**건설/건축 공사 사업계획을 AI와 함께 만듭니다**
**Plan Korean construction projects with AI assistance**
한국형 토목·건축 프로젝트 기획을 MCP 도구로 구조화하고 문서·도면·3D 렌더까지 생성하는 서버입니다.
CivilPlan MCP is an MCP server for Korean civil and building project planning that produces structured analysis, documents, drawings, and 3D renders.
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/Python-3.11+-green.svg)](https://python.org)
[![FastMCP](https://img.shields.io/badge/FastMCP-2.0+-orange.svg)](https://github.com/jlowin/fastmcp)
---
[![FastMCP 2.0+](https://img.shields.io/badge/FastMCP-2.0+-orange.svg)](https://github.com/jlowin/fastmcp)
[![Version 2.0.0](https://img.shields.io/badge/version-2.0.0-black.svg)](pyproject.toml)
## 소개 | 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와 함께 새로운 세상을 만들어갑니다. -- 전문 기획 지식에 대한 접근 불평등을 줄입니다.
> Reduce inequality in access to expert planning knowledge. Free to use, modify, and distribute.
![시스템 개요 System Overview](docs/images/01_system_overview.png)
![워크플로우 Workflow](docs/images/02_workflow.png)
![단계별 다이어그램 Phase Diagram](docs/images/03_phase_diagram.png)
![프로세스 흐름 Process Flow](docs/images/04_process_flow.png)
![사업비 요약 Cost Summary](docs/images/05_cost_summary.png)
![사업 일정 Project Timeline](docs/images/06_project_timeline.png)
![종합 뷰 Comprehensive View](docs/images/07_comprehensive_view.png)
![출력 예시 Output Examples](docs/images/08_output_examples.png)
![문서 생성 Document Generation](docs/images/09_doc_generation.png)
![상세 워크플로우 Detailed Workflow](docs/images/10_detailed_workflow.png)
![평면도 Plan View](docs/images/11_plan_view.png)
---
```mermaid
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
| 대상 | 활용 예시 |
|------|----------|
| **지자체 공무원** | 도로/상하수도 사업 기획 시 개략 사업비와 인허가 절차를 빠르게 파악 |
| **건설 엔지니어** | 기획 단계 물량/단가 산출, 투자계획서 초안 작성 자동화 |
| **부동산 개발 기획자** | 개발 사업의 법적 절차, 영향평가 대상 여부 확인 |
| **건축주/시행사** | AI에게 자연어로 사업 설명 -> 구조화된 사업 계획 문서 일괄 생성 |
| **학생/연구자** | 한국 건설 법령/표준품셈 학습 및 시뮬레이션 |
| 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 |
---
| 대상 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 에이전트 운영자<br/>AI agent builders | Claude, ChatGPT, 기타 MCP 클라이언트에 토목/건축 전용 도구 세트를 연결할 수 있습니다.<br/>Attach a Korean construction-planning toolset to Claude, ChatGPT, and other MCP clients. |
## 주요 기능 | 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 |
|---|-----------|-----------------|
| 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 |
### 도구 목록 | Tool Catalog
### 지원 사업 분야 | Supported Project Domains
#### 기획·분석 도구 | Planning and Analysis Tools
- `건축` -- 건축물 (Buildings)
- `토목_도로` -- 도로 (Roads)
- `토목_상하수도` -- 상하수도 (Water & Sewerage)
- `토목_하천` -- 하천 (Rivers)
- `조경` -- 조경 (Landscaping)
- `복합` -- 복합 사업 (Mixed projects)
| 도구 Tool | 설명 Description |
|---|---|
| `civilplan_parse_project` | 자연어 프로젝트 설명을 구조화된 JSON으로 변환합니다.<br/>Parses a natural-language project brief into structured JSON. |
| `civilplan_get_legal_procedures` | 사업 조건에 맞는 인허가·환경 절차를 정리합니다.<br/>Finds permit and environmental procedures for the project. |
| `civilplan_get_phase_checklist` | 단계별 체크리스트를 생성합니다.<br/>Builds phase-by-phase execution checklists. |
| `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), 일정표, 예산 보고서
- **Word (.docx)**: 투자계획서(사업계획서)
- **SVG**: 평면도, 횡단면도, 종단면도
- **DXF**: CAD 호환 도면
- **JSON**: 모든 도구의 구조화된 응답 데이터
| 도구 Tool | 설명 Description |
|---|---|
| `civilplan_generate_boq_excel` | BOQ Excel 파일을 생성합니다.<br/>Generates a BOQ Excel workbook. |
| `civilplan_generate_investment_doc` | 투자·사업계획 Word 문서를 생성합니다.<br/>Generates an investment/planning Word document. |
| `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
### 1단계: 설치 | Step 1: Install
### 1. 저장소 받기 | Clone the Repository
```bash
# 저장소 클론 | Clone the repository
git clone https://github.com/sinmb79/Construction-project-master.git
cd Construction-project-master
# 가상환경 생성 및 활성화 | Create and activate virtual environment
python -m venv .venv
```
# Windows:
### 2. 가상환경 활성화와 패키지 설치 | Activate the Environment and Install Dependencies
```bash
# Windows
.venv\Scripts\activate
# macOS/Linux:
# macOS / Linux
source .venv/bin/activate
# 패키지 설치 | Install dependencies
pip install -r requirements.txt
python -m 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.
**방법 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` 예시는 아래와 같습니다.
An example `.env` looks like this.
```env
# 공공데이터포털 (https://www.data.go.kr) 에서 발급
DATA_GO_KR_API_KEY=your_key_here
# 브이월드 (https://www.vworld.kr) 에서 발급
VWORLD_API_KEY=your_key_here
DATA_GO_KR_API_KEY=
VWORLD_API_KEY=
GEMINI_API_KEY=
```
**방법 B: 암호화 저장 | Option B: Encrypted local storage**
```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
### 4. 서버 실행 | Start the Server
```bash
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_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
{
"mcpServers": {
"civilplan": {
"command": "mcp-remote",
"args": ["http://127.0.0.1:8765/mcp"]
}
}
}
```
#### ChatGPT Developer Mode
#### 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
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:
```bash
# ngrok으로 서버를 외부에 노출
ngrok http 8765
```
생성된 HTTPS URL을 ChatGPT 설정 -> Connectors -> Create에 입력합니다.
Use the generated HTTPS URL in ChatGPT Settings -> Connectors -> Create.
---
| 항목 Item | 값 Value |
|---|---|
| 프로토콜 Protocol | Streaming HTTP |
| URL | `http://127.0.0.1:8765/mcp` |
## 실전 사용 예시 | 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.
#### AI에게 이렇게 말하세요 | Say this to your AI:
```
소로 신설 L=890m B=6m 아스콘 2차선 상하수도 경기도 둔턱지역 2026~2028
```text
도로 신설 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
{
"project_id": "PRJ-20260402-001",
"project_type": ["도로", "상수도", "하수도"],
"project_id": "PRJ-20260404-001",
"domain": "토목_도로",
"sub_domains": ["토목_상하수도"],
"project_type": ["도로", "하수도"],
"road": {
"class": "소로",
"length_m": 890,
"length_m": 890.0,
"width_m": 6.0,
"lanes": 2,
"pavement": "아스콘"
},
"terrain": "구릉(둔턱)",
"terrain_factor": 1.4,
"region": "경기도",
"region_factor": 1.05,
"year_start": 2026,
"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**
```
도로 포장: 아스콘 표층 523t, 기층 628t
토공: 절토 8,000m3, 성토 5,400m3
배수: L형측구 1,780m, 횡단암거 60m
상수도: PE관 DN100 890m, 소화전 3개소
하수도: 오수관 890m, 우수관 890m, 맨홀 37개소
```text
경기도 공공 도로 사업(총사업비 10.67억, 연장 890m)에 필요한 인허가를 정리해줘
```
**3) 사업비 산출 | Cost Estimation** (`civilplan_generate_boq_excel`)
**호출되는 도구 | Tool called**
6개 시트로 구성된 사업내역서 Excel 파일을 생성합니다:
| 시트 Sheet | 내용 Contents |
|-----------|--------------|
| 사업개요 | 프로젝트 정보, 면책문구 |
| 사업내역서(BOQ) | 8개 대공종별 수량 x 단가 = 금액 (수식 포함) |
| 물량산출근거 | 공종별 계산식 (예: 아스콘 표층 = 4,450m2 x 0.05m x 2.35t/m3) |
| 간접비산출 | 설계비 3.5%, 감리비 3.0%, 부대비 2.0%, 예비비 10% |
| 총사업비요약 | 직접공사비 + 간접비 = **약 10.67억원** |
| 연도별투자계획 | 2026: 30%, 2027: 50%, 2028: 20% |
**4) 법적 절차 확인 | Legal Procedures** (`civilplan_get_legal_procedures`)
18개 법적 절차를 자동으로 식별하고, 필수/선택 여부, 소요 기간, 근거 법령을 제공합니다:
```
필수 절차 12건, 선택 절차 6건
예상 인허가 소요: 약 18개월
핵심 경로: 도시계획시설결정 -> 개발행위허가 -> 실시계획인가
```python
civilplan_get_legal_procedures(
domain="토목_도로",
project_type="도로",
total_cost_billion=10.67,
road_length_m=890,
development_area_m2=None,
region="경기도",
has_farmland=False,
has_forest=False,
has_river=False,
is_public=True
)
```
**5) 영향평가 판단 | Impact Assessments** (`civilplan_evaluate_impact_assessments`)
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`를 호출하여 지역계수가 반영된 단가를 조회합니다:
**결과 예시 | Example result**
```json
{
"item": "아스콘표층(밀입도13mm)",
"spec": "t=50mm",
"unit": "t",
"base_price": 96000,
"region_factor": 1.05,
"adjusted_price": 100800,
"source": "조달청 표준시장단가 2026 상반기"
"summary": {
"total_procedures": 3,
"mandatory_count": 2,
"optional_count": 1,
"estimated_prep_months": 12,
"critical_path": [
"도시·군관리계획 결정",
"개발행위허가",
"소규모환경영향평가"
]
},
"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**
```
[필수] 착공신고 -- 건설산업기본법 제39조, 착공 전
미이행 시 500만원 이하 과태료
[필수] 품질시험계획 수립 -- 미제출 시 기성 지급 불가
[필수] 안전관리계획 수립/인가
...
```python
civilplan_generate_svg_drawing(
drawing_type="평면도",
project_spec=project_spec,
quantities=quantities,
scale="1:200",
output_filename="road-plan.svg"
)
```
---
**결과 예시 | Example result**
## 시스템 아키텍처 | System Architecture
```
Claude / ChatGPT / AI Agent
| MCP Protocol (Streamable HTTP)
v
+------------------------------------------+
| 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 |
+-------------+ +--------------+
```json
{
"status": "success",
"file_path": "output/road-plan.svg",
"drawing_type": "평면도",
"quantity_sections": ["earthwork", "pavement", "drainage"]
}
```
---
### 예시 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
```
```text
Construction-project-master/
|-- server.py # 메인 서버 진입점 | Main server entry point
|-- setup_keys.py # API 키 설정 도구 | API key setup utility
|-- pyproject.toml # 프로젝트 메타데이터 | Project metadata
|-- requirements.txt # 의존성 목록 | Dependencies
|-- .env.example # 환경변수 템플릿 | Environment template
|-- LICENSE # MIT 라이선스 | MIT License
|
|-- civilplan_mcp/ # 메인 패키지 | Main package
| |-- server.py # FastMCP 서버 정의 | FastMCP server definition
| |-- config.py # 설정, 경로, 상수 | Config, paths, constants
| |-- models.py # Pydantic 모델 | Pydantic models
| |-- secure_store.py # 암호화 키 저장 | Encrypted key storage
| |-- tools/ # 19개 MCP 도구 구현 | 19 MCP tool implementations
| |-- data/ # JSON 참조 데이터 | JSON reference data
| |-- db/ # SQLite 스키마 및 시드 | SQLite schema & seeds
| +-- updater/ # 자동 데이터 갱신 | Automated data updaters
|
+-- tests/ # 테스트 스위트 | Test suite
|-- test_smoke.py # 기본 동작 확인 | Basic smoke tests
|-- test_parser.py # 파서 테스트 | Parser tests
|-- test_legal.py # 법적 절차 테스트 | Legal procedure tests
|-- test_quantities.py # 물량 산출 테스트 | Quantity tests
|-- test_generators.py # 파일 생성 테스트 | Generator tests
+-- ... # 기타 테스트 | Other tests
├─ server.py # 서버 실행 진입점 | Server entrypoint
├─ setup_keys.py # 암호화 키 저장 유틸 | Encrypted key setup helper
├─ requirements.txt # 런타임 의존성 | Runtime dependencies
├─ pyproject.toml # 패키지 메타데이터 | Package metadata
├─ README.md # 사용 가이드 | Usage guide
├─ civilplan_mcp/
│ ├─ __init__.py # 버전 정보 | Version metadata
│ ├─ config.py # 설정·경로·API 키 로딩 | Settings, paths, API key loading
├─ models.py # 도메인 enum | Domain enums
│ ├─ secure_store.py # DPAPI 키 저장 | DPAPI-backed key store
│ ├─ prompts/
│ └─ birdseye_templates.py # 도메인별 렌더 프롬프트 | Domain-specific render prompts
│ ├─ services/
│ └─ gemini_image.py # Gemini 이미지 래퍼 | Gemini image wrapper
│ ├─ tools/
│ ├─ birdseye_generator.py # 3D 렌더 도구 | 3D rendering tool
│ │ ├─ drawing_generator.py # SVG 도면 생성 | SVG drawing generator
│ │ ├─ dxf_generator.py # DXF 도면 생성 | DXF drawing generator
└─ ... # 나머지 MCP 도구 | Remaining MCP tools
│ ├─ data/ # 기준 JSON 데이터 | Reference JSON data
├─ db/ # SQLite schema/bootstrap | SQLite schema/bootstrap
│ └─ updater/ # 데이터 갱신 로직 | Data update logic
└─ 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
CivilPlan은 단가/임금/폐기물 처리비 등 참조 데이터의 정기 갱신을 지원합니다:
CivilPlan supports scheduled updates for reference data (wages, prices, waste rates):
| 시기 Timing | 갱신 항목 Update Item |
|------------|---------------------|
| 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).
---
| 문제 Problem | 확인 방법 What to Check | 해결 방법 Fix |
|---|---|---|
| `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`. |
| 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. |
| 전체 테스트를 다시 돌리고 싶음 | 아래 명령을 사용합니다.<br/>Use the following command. | `python -m pytest tests/ -q` |
## 알려진 제한사항 | Known Limitations
- **개략 산출**: 모든 사업비/물량은 기획 단계용 개략 산출이며, 실시설계를 대체하지 않습니다 (+-20~30% 오차 가능).
*All estimates are preliminary (+-20-30% variance) and do not replace detailed design.*
| 항목 Item | 설명 Description |
|---|---|
| 기획 단계 정확도 | 모든 수치와 절차는 개략 검토용입니다.<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. |
- **토지 용도 데이터**: 외부 서비스 불안정으로 일부 필지의 용도지역 정보가 불완전할 수 있습니다.
*External land-use services can be unstable; some parcels may return partial zoning data.*
## 면책사항 | Disclaimer
- **공시지가 조회**: 수동 다운로드 필요 (`civilplan_mcp/data/land_prices/`).
*Land price lookup requires manually downloaded source files.*
- **나라장터 벤치마크**: 공공 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.
---
> 본 저장소의 결과물은 기획 단계 참고자료이며, 상세 설계·발주·공식 제출용 문서를 대체하지 않습니다.
> Outputs from this repository are planning-stage references and do not replace detailed design, procurement, or official submission documents.
## 라이선스 | License
MIT License -- 자유롭게 사용, 수정, 배포할 수 있습니다.
MIT License -- Free to use, modify, and distribute.
---
| 항목 Item | 내용 Detail |
|---|---|
| 라이선스 License | MIT |
| 사용 범위 Usage | 사용, 수정, 배포 가능<br/>Free to use, modify, and distribute |
## 만든 사람 | Author
**22B Labs** (sinmb79)
문의사항이나 기여는 [Issues](https://github.com/sinmb79/Construction-project-master/issues)를 이용해 주세요.
For questions or contributions, please use [Issues](https://github.com/sinmb79/Construction-project-master/issues).
| 항목 Item | 내용 Detail |
|---|---|
| 팀 Team | **22B Labs** |
| 저장소 Repository | [sinmb79/Construction-project-master](https://github.com/sinmb79/Construction-project-master) |
| 문의 Contact | [Issues](https://github.com/sinmb79/Construction-project-master/issues) |

View File

@@ -1,3 +1,3 @@
__all__ = ["__version__"]
__version__ = "1.0.0"
__version__ = "2.0.0"

View File

@@ -28,7 +28,7 @@ def _load_secure_api_keys(path: Path) -> dict[str, str]:
class Settings(BaseModel):
app_name: str = "civilplan_mcp"
version: str = "1.0.0"
version: str = "2.0.0"
host: str = "127.0.0.1"
port: int = 8765
http_path: str = "/mcp"
@@ -38,6 +38,7 @@ class Settings(BaseModel):
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", ""))
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)
@@ -50,6 +51,8 @@ def get_settings() -> Settings:
settings.data_go_kr_api_key = secure_keys.get("DATA_GO_KR_API_KEY", "")
if not settings.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)
return settings
@@ -62,4 +65,6 @@ def check_api_keys() -> list[str]:
missing.append("DATA_GO_KR_API_KEY")
if not settings.vworld_api_key:
missing.append("VWORLD_API_KEY")
if not settings.gemini_api_key:
missing.append("GEMINI_API_KEY")
return missing

View 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"]

View 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."
)

View File

@@ -16,6 +16,7 @@ from civilplan_mcp import __version__
from civilplan_mcp.config import check_api_keys, get_settings
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.birdseye_generator import generate_birdseye_view
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.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_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_birdseye_view", generate_birdseye_view)
return app

View File

@@ -0,0 +1,3 @@
from civilplan_mcp.services.gemini_image import GeminiImageService
__all__ = ["GeminiImageService"]

View 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()

View File

@@ -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", ""))
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(
{
"DATA_GO_KR_API_KEY": data_go_kr_api_key,
"VWORLD_API_KEY": vworld_api_key,
"GEMINI_API_KEY": gemini_api_key,
}
)

View 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,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
# CivilPlan MCP v2 - Bird's-Eye View Generation Design Spec
## Overview
Add a new MCP tool `generate_birdseye_view` to CivilPlan MCP that generates 3D architectural/civil engineering bird's-eye view and perspective renderings using Google's Nano Banana Pro (Gemini 3 Pro Image) API. Additionally, remove all local LLM dependencies and create a polished release with comprehensive documentation.
## Scope
### In Scope
1. **New MCP tool**: `generate_birdseye_view` — generates 2 images (bird's-eye + perspective)
2. **Nano Banana Pro integration** via `google-genai` Python SDK
3. **Project-type-specific prompt templates** (road, building, water/sewerage, river, landscaping)
4. **Local LLM removal** — delete all local LLM code and dependencies
5. **Release v2.0.0** — GitHub release with detailed README and connection guides
### Out of Scope
- Night/day or seasonal variations
- Video/animation generation
- 3D model file export (OBJ, FBX, etc.)
## Architecture
### Data Flow
```
MCP Client (Claude / ChatGPT)
|
| MCP Protocol (HTTP)
v
CivilPlan MCP Server (FastMCP)
|
| generate_birdseye_view tool called
v
BirdseyeViewGenerator
|
|-- [If SVG drawing exists] Convert SVG to PNG reference image
|-- [Always] Build optimized prompt from project data
|
v
Google Gemini API (Nano Banana Pro model)
|
v
2x PNG images returned (bird's-eye + perspective)
|
|-- Save to output directory
|-- Return base64 + file paths via MCP response
```
### New Files
| File | Purpose |
|------|---------|
| `civilplan_mcp/tools/birdseye_generator.py` | MCP tool implementation |
| `civilplan_mcp/prompts/birdseye_templates.py` | Project-type prompt templates |
| `civilplan_mcp/services/gemini_image.py` | Nano Banana Pro API client wrapper |
| `tests/test_birdseye_generator.py` | Unit tests |
### Tool Interface
```python
@mcp.tool()
async def generate_birdseye_view(
project_summary: str, # Parsed project description (from project_parser)
project_type: str, # "road" | "building" | "water" | "river" | "landscape" | "mixed"
svg_drawing: str | None, # Optional SVG drawing content from drawing_generator
resolution: str = "2k", # "2k" | "4k"
output_dir: str = "./output/renders"
) -> dict:
"""
Returns:
{
"birdseye_view": {"path": str, "base64": str},
"perspective_view": {"path": str, "base64": str},
"prompt_used": str,
"model": "nano-banana-pro"
}
"""
```
### Prompt Template Strategy
Each project type gets a specialized prompt template:
- **Road**: Emphasize road alignment, terrain, surrounding land use, utility corridors
- **Building**: Emphasize building mass, facade, site context, parking/landscaping
- **Water/Sewerage**: Emphasize pipeline routes, treatment facilities, connection points
- **River**: Emphasize riverbank, embankments, bridges, flood plains
- **Landscape**: Emphasize vegetation, pathways, public spaces, terrain grading
- **Mixed**: Combine relevant elements from applicable types
Template format:
```
"Create a photorealistic {view_type} of a {project_type} project:
{project_details}
Style: Professional architectural visualization, Korean construction context,
clear weather, daytime, {resolution} resolution"
```
### API Configuration
- API key stored via existing `.env` / `secure_store.py` pattern
- New env var: `GEMINI_API_KEY`
- SDK: `google-genai` (official Google Gen AI Python SDK)
- Model: `gemini-3-pro-image` (Nano Banana Pro)
- Error handling: On API failure, return error message without crashing the MCP tool
### SVG-to-PNG Conversion
When an SVG drawing is provided as reference:
1. Convert SVG to PNG using `cairosvg` or `Pillow`
2. Send as reference image alongside the text prompt
3. Nano Banana Pro uses it for spatial understanding
### Local LLM Removal
Identify and remove:
- Any local model loading code (transformers, llama-cpp, ollama, etc.)
- Related dependencies in `requirements.txt` / `pyproject.toml`
- Config entries referencing local models
- Replace with Gemini API calls where needed
## Release Plan
### Version: v2.0.0
### README Overhaul
- Project overview with feature highlights
- Quick start guide (clone, install, configure, run)
- Tool reference table (all 20 tools including new birdseye)
- Claude Desktop connection guide (step-by-step with screenshots description)
- ChatGPT / OpenAI connection guide
- API key setup guide (Gemini, public data portal)
- Example outputs (birdseye rendering description)
- Troubleshooting FAQ
### GitHub Release
- Tag: `v2.0.0`
- Release notes summarizing changes
- Installation instructions
## Testing Strategy
- Unit test for prompt template generation
- Unit test for SVG-to-PNG conversion
- Integration test with mocked Gemini API response
- Manual end-to-end test with real API key
## Dependencies Added
| Package | Purpose |
|---------|---------|
| `google-genai` | Gemini API SDK (Nano Banana Pro) |
| `cairosvg` | SVG to PNG conversion |
| `Pillow` | Image processing |
## Dependencies Removed
All local LLM packages (to be identified during implementation by scanning current requirements).

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "civilplan-mcp"
version = "1.0.0"
version = "2.0.0"
description = "CivilPlan MCP server for Korean civil and building project planning."
readme = "README.md"
requires-python = ">=3.11"
@@ -24,6 +24,9 @@ dependencies = [
"apscheduler>=3.10.0",
"python-dotenv>=1.0.1",
"python-dateutil>=2.9.0",
"google-genai>=1.0.0",
"cairosvg>=2.7.0",
"Pillow>=10.0.0",
]
[project.scripts]

View File

@@ -9,4 +9,7 @@ httpx>=0.27.0
apscheduler>=3.10.0
python-dotenv>=1.0.1
python-dateutil>=2.9.0
google-genai>=1.0.0
cairosvg>=2.7.0
Pillow>=10.0.0
pytest>=8.0.0

View 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"

View 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()

View File

@@ -45,15 +45,18 @@ def test_get_settings_uses_secure_store_when_env_missing(tmp_path: Path, monkeyp
lambda path: {
"DATA_GO_KR_API_KEY": "secure-data-key",
"VWORLD_API_KEY": "secure-vworld-key",
"GEMINI_API_KEY": "secure-gemini-key",
},
)
monkeypatch.delenv("DATA_GO_KR_API_KEY", raising=False)
monkeypatch.delenv("VWORLD_API_KEY", raising=False)
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
settings = config.get_settings()
assert settings.data_go_kr_api_key == "secure-data-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:
@@ -65,12 +68,37 @@ def test_get_settings_prefers_env_values_over_secure_store(tmp_path: Path, monke
lambda path: {
"DATA_GO_KR_API_KEY": "secure-data-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("VWORLD_API_KEY", "env-vworld-key")
monkeypatch.setenv("GEMINI_API_KEY", "env-gemini-key")
settings = config.get_settings()
assert settings.data_go_kr_api_key == "env-data-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
View 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()

View File

@@ -11,14 +11,15 @@ def test_build_server_config_defaults() -> None:
assert config["path"] == "/mcp"
def test_server_registers_all_19_tools() -> None:
def test_server_registers_all_20_tools() -> None:
app = build_mcp()
tools = asyncio.run(app.list_tools())
names = {tool.name for tool in tools}
assert len(names) == 19
assert len(names) == 20
assert "civilplan_parse_project" 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:

26
tests/test_setup_keys.py Normal file
View 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",
}

View File

@@ -3,7 +3,7 @@ from civilplan_mcp.config import Settings, get_settings
def test_package_version_present() -> None:
assert __version__ == "1.0.0"
assert __version__ == "2.0.0"
def test_settings_have_expected_defaults() -> None: