From 121df5b63c7c6fd9cfde0749f10dcdd6bff955a8 Mon Sep 17 00:00:00 2001 From: sinmb79 Date: Sat, 4 Apr 2026 18:31:35 +0900 Subject: [PATCH 1/3] Add design spec for bird's-eye view generation and v2.0.0 Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-04-04-birdseye-view-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-04-birdseye-view-design.md diff --git a/docs/superpowers/specs/2026-04-04-birdseye-view-design.md b/docs/superpowers/specs/2026-04-04-birdseye-view-design.md new file mode 100644 index 0000000..bc85bc0 --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-birdseye-view-design.md @@ -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). From 800c7b6fa76beefd98e0f8a894bec9460dabd68e Mon Sep 17 00:00:00 2001 From: sinmb79 Date: Sat, 4 Apr 2026 18:38:12 +0900 Subject: [PATCH 2/3] plan: add implementation plan for birdseye view v2.0.0 Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-04-04-birdseye-view.md | 1311 +++++++++++++++++ 1 file changed, 1311 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-04-birdseye-view.md diff --git a/docs/superpowers/plans/2026-04-04-birdseye-view.md b/docs/superpowers/plans/2026-04-04-birdseye-view.md new file mode 100644 index 0000000..9e9eb6e --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-birdseye-view.md @@ -0,0 +1,1311 @@ +# Bird's-Eye View Generation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add 3D bird's-eye view and perspective rendering to CivilPlan MCP using Google's Nano Banana Pro (Gemini 3 Pro Image) API, bump version to 2.0.0, and create a polished release with comprehensive documentation. + +**Architecture:** New MCP tool `civilplan_generate_birdseye_view` calls the Gemini API via `google-genai` SDK. A service layer wraps API calls; prompt templates are specialized per project domain (road/building/water/river/landscape). SVG drawings from the existing generator can optionally feed in as reference images via `cairosvg`. + +**Tech Stack:** google-genai, cairosvg, Pillow, FastMCP 2.0+, Python 3.11+ + +--- + +### Task 1: Add GEMINI_API_KEY to Configuration + +**Files:** +- Modify: `civilplan_mcp/config.py` +- Modify: `.env.example` +- Test: `tests/test_config_and_secure_store.py` + +- [ ] **Step 1: Write failing test for GEMINI_API_KEY in settings** + +Add to `tests/test_config_and_secure_store.py`: + +```python +def test_settings_has_gemini_api_key(): + from civilplan_mcp.config import Settings + s = Settings() + assert hasattr(s, "gemini_api_key") + assert s.gemini_api_key == "" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_config_and_secure_store.py::test_settings_has_gemini_api_key -v` +Expected: FAIL — `Settings` has no `gemini_api_key` attribute + +- [ ] **Step 3: Add gemini_api_key to Settings and get_settings()** + +In `civilplan_mcp/config.py`, add field to `Settings` class: + +```python +class Settings(BaseModel): + # ... existing fields ... + gemini_api_key: str = Field(default_factory=lambda: os.getenv("GEMINI_API_KEY", "")) +``` + +In `get_settings()`, add after the existing vworld block: + +```python + if not settings.gemini_api_key: + settings.gemini_api_key = secure_keys.get("GEMINI_API_KEY", "") +``` + +In `check_api_keys()`, add: + +```python + if not settings.gemini_api_key: + missing.append("GEMINI_API_KEY") +``` + +- [ ] **Step 4: Update .env.example** + +Append to `.env.example`: + +``` +GEMINI_API_KEY= +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_config_and_secure_store.py::test_settings_has_gemini_api_key -v` +Expected: PASS + +- [ ] **Step 6: Run full test suite to check nothing broke** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/ -v` +Expected: All existing tests PASS + +- [ ] **Step 7: Commit** + +```bash +cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 +git add civilplan_mcp/config.py .env.example tests/test_config_and_secure_store.py +git commit -m "feat: add GEMINI_API_KEY to config system" +``` + +--- + +### Task 2: Create Gemini Image Service + +**Files:** +- Create: `civilplan_mcp/services/__init__.py` +- Create: `civilplan_mcp/services/gemini_image.py` +- Test: `tests/test_gemini_image.py` + +- [ ] **Step 1: Write failing tests for GeminiImageService** + +Create `tests/test_gemini_image.py`: + +```python +from __future__ import annotations + +import base64 +from unittest.mock import MagicMock, patch + +import pytest + + +def test_generate_image_returns_dict(): + from civilplan_mcp.services.gemini_image import GeminiImageService + + svc = GeminiImageService(api_key="test-key") + assert svc.api_key == "test-key" + assert svc.model == "gemini-3-pro-image-preview" + + +def test_generate_image_calls_api(tmp_path): + from civilplan_mcp.services.gemini_image import GeminiImageService + + # Create a fake 1x1 white PNG + import io + from PIL import Image as PILImage + + img = PILImage.new("RGB", (1, 1), "white") + buf = io.BytesIO() + img.save(buf, format="PNG") + fake_png_bytes = buf.getvalue() + + mock_image = MagicMock() + mock_image.save = MagicMock() + + mock_part = MagicMock() + mock_part.text = None + mock_part.inline_data = MagicMock() + mock_part.as_image.return_value = mock_image + + mock_response = MagicMock() + mock_response.parts = [mock_part] + + with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai: + mock_client = MagicMock() + mock_genai.Client.return_value = mock_client + mock_client.models.generate_content.return_value = mock_response + + svc = GeminiImageService(api_key="test-key") + result = svc.generate_image( + prompt="test prompt", + output_path=str(tmp_path / "test.png"), + aspect_ratio="16:9", + image_size="2K", + ) + + assert result["status"] == "success" + assert result["path"] == str(tmp_path / "test.png") + mock_image.save.assert_called_once_with(str(tmp_path / "test.png")) + + +def test_generate_image_with_reference(tmp_path): + from civilplan_mcp.services.gemini_image import GeminiImageService + + mock_image = MagicMock() + mock_image.save = MagicMock() + + mock_part = MagicMock() + mock_part.text = None + mock_part.inline_data = MagicMock() + mock_part.as_image.return_value = mock_image + + mock_response = MagicMock() + mock_response.parts = [mock_part] + + with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai: + mock_client = MagicMock() + mock_genai.Client.return_value = mock_client + mock_client.models.generate_content.return_value = mock_response + + svc = GeminiImageService(api_key="test-key") + + # Create a small reference image + from PIL import Image as PILImage + ref_path = tmp_path / "ref.png" + PILImage.new("RGB", (10, 10), "gray").save(str(ref_path)) + + result = svc.generate_image( + prompt="test prompt with reference", + output_path=str(tmp_path / "out.png"), + reference_image_path=str(ref_path), + ) + + assert result["status"] == "success" + # Verify contents list included the reference image + call_args = mock_client.models.generate_content.call_args + contents = call_args.kwargs.get("contents") or call_args[1].get("contents") + assert len(contents) == 2 # prompt + image + + +def test_generate_image_api_failure(tmp_path): + from civilplan_mcp.services.gemini_image import GeminiImageService + + with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai: + mock_client = MagicMock() + mock_genai.Client.return_value = mock_client + mock_client.models.generate_content.side_effect = Exception("API error") + + svc = GeminiImageService(api_key="test-key") + result = svc.generate_image( + prompt="test prompt", + output_path=str(tmp_path / "fail.png"), + ) + + assert result["status"] == "error" + assert "API error" in result["error"] + + +def test_generate_image_no_image_in_response(tmp_path): + from civilplan_mcp.services.gemini_image import GeminiImageService + + mock_part = MagicMock() + mock_part.text = "Sorry, I cannot generate that image." + mock_part.inline_data = None + + mock_response = MagicMock() + mock_response.parts = [mock_part] + + with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai: + mock_client = MagicMock() + mock_genai.Client.return_value = mock_client + mock_client.models.generate_content.return_value = mock_response + + svc = GeminiImageService(api_key="test-key") + result = svc.generate_image( + prompt="test prompt", + output_path=str(tmp_path / "noimg.png"), + ) + + assert result["status"] == "error" + assert "no image" in result["error"].lower() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_gemini_image.py -v` +Expected: FAIL — module `civilplan_mcp.services.gemini_image` not found + +- [ ] **Step 3: Create services package and gemini_image.py** + +Create `civilplan_mcp/services/__init__.py`: + +```python +``` + +Create `civilplan_mcp/services/gemini_image.py`: + +```python +from __future__ import annotations + +import logging +from pathlib import Path + +from google import genai +from google.genai import types +from PIL import Image + +logger = logging.getLogger(__name__) + + +class GeminiImageService: + def __init__(self, api_key: str, model: str = "gemini-3-pro-image-preview"): + self.api_key = api_key + self.model = model + self._client = genai.Client(api_key=api_key) + + 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: + try: + contents: list = [prompt] + if reference_image_path: + ref_img = Image.open(reference_image_path) + contents.append(ref_img) + + config = types.GenerateContentConfig( + response_modalities=["TEXT", "IMAGE"], + image_config=types.ImageConfig( + aspect_ratio=aspect_ratio, + image_size=image_size, + ), + ) + + response = self._client.models.generate_content( + model=self.model, + contents=contents, + config=config, + ) + + for part in response.parts: + if part.inline_data is not None: + image = part.as_image() + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + image.save(output_path) + return {"status": "success", "path": output_path} + + return {"status": "error", "error": "No image in API response"} + + except Exception as exc: + logger.error("Gemini image generation failed: %s", exc) + return {"status": "error", "error": str(exc)} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_gemini_image.py -v` +Expected: All 5 tests PASS + +- [ ] **Step 5: Commit** + +```bash +cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 +git add civilplan_mcp/services/__init__.py civilplan_mcp/services/gemini_image.py tests/test_gemini_image.py +git commit -m "feat: add Gemini image service for Nano Banana Pro API" +``` + +--- + +### Task 3: Create Prompt Templates + +**Files:** +- Create: `civilplan_mcp/prompts/__init__.py` +- Create: `civilplan_mcp/prompts/birdseye_templates.py` +- Test: `tests/test_birdseye_templates.py` + +- [ ] **Step 1: Write failing tests for prompt templates** + +Create `tests/test_birdseye_templates.py`: + +```python +from __future__ import annotations + +import pytest + + +def test_build_birdseye_prompt_road(): + 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() or "aerial" in prompt.lower() + assert "890" in prompt + assert "road" in prompt.lower() or "도로" in prompt + + +def test_build_perspective_prompt_building(): + 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() or "빌딩" in prompt + + +def test_all_project_types_have_templates(): + from civilplan_mcp.prompts.birdseye_templates import DOMAIN_PROMPTS + + expected = {"road", "building", "water", "river", "landscape", "mixed"} + assert set(DOMAIN_PROMPTS.keys()) == expected + + +def test_build_prompt_unknown_type_uses_mixed(): + from civilplan_mcp.prompts.birdseye_templates import build_prompt + + prompt = build_prompt( + view_type="birdseye", + project_type="unknown_type", + project_summary="Test project", + details={}, + ) + assert isinstance(prompt, str) + assert len(prompt) > 50 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_templates.py -v` +Expected: FAIL — module not found + +- [ ] **Step 3: Create prompts package and templates** + +Create `civilplan_mcp/prompts/__init__.py`: + +```python +``` + +Create `civilplan_mcp/prompts/birdseye_templates.py`: + +```python +from __future__ import annotations + +from typing import Any + +DOMAIN_PROMPTS: dict[str, str] = { + "road": ( + "Focus on the road alignment cutting through the terrain. " + "Show road surface markings, lane dividers, shoulders, drainage ditches along both sides, " + "utility corridors (water/sewer pipes if applicable), guard rails, and traffic signage. " + "Include surrounding land use context: rice paddies, hills, or residential areas typical of Korean rural/suburban settings." + ), + "building": ( + "Focus on the building mass, facade materials, and rooftop details. " + "Show the site context including parking areas, landscaping, pedestrian paths, and adjacent roads. " + "Include typical Korean urban context: neighboring buildings, street trees, and utility poles." + ), + "water": ( + "Focus on the pipeline route and treatment facility layout. " + "Show pipe trenches, manholes at regular intervals, pump stations, and connection points. " + "Include cross-section hints showing pipe burial depth. " + "Surrounding context: Korean suburban or rural terrain with roads running alongside." + ), + "river": ( + "Focus on the riverbank improvements, embankment structures, and flood control elements. " + "Show gabion walls, riprap, walking paths along the levee, bridge crossings, and flood gates. " + "Include the water surface with natural flow patterns and riparian vegetation." + ), + "landscape": ( + "Focus on the green space design, vegetation patterns, and hardscape elements. " + "Show walking trails, rest areas with benches, playground equipment, water features, " + "and ornamental planting beds. Include seasonal Korean trees (cherry blossom, pine, maple)." + ), + "mixed": ( + "Show a comprehensive development site with multiple infrastructure elements. " + "Include roads, buildings, utility corridors, and landscaped areas working together. " + "Emphasize how different systems connect and interact within the Korean development context." + ), +} + +VIEW_INSTRUCTIONS: dict[str, str] = { + "birdseye": ( + "Create a photorealistic aerial bird's-eye view rendering, " + "camera angle approximately 45-60 degrees from above, " + "showing the full project extent with surrounding context." + ), + "perspective": ( + "Create a photorealistic eye-level perspective rendering, " + "camera positioned at human eye height (1.6m), " + "showing the project from the most representative viewpoint." + ), +} + + +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_context = DOMAIN_PROMPTS.get(project_type, DOMAIN_PROMPTS["mixed"]) + + detail_lines = [] + for key, value in details.items(): + if value is not None: + detail_lines.append(f"- {key}: {value}") + detail_block = "\n".join(detail_lines) if detail_lines else "No additional details." + + return ( + f"{view_instruction}\n\n" + f"Project description: {project_summary}\n\n" + f"Technical details:\n{detail_block}\n\n" + f"Domain-specific guidance:\n{domain_context}\n\n" + f"Style: Professional architectural visualization for a Korean construction project plan. " + f"Clear daytime weather, natural lighting, high detail. " + f"Include a subtle north arrow and scale reference in the corner." + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_templates.py -v` +Expected: All 4 tests PASS + +- [ ] **Step 5: Commit** + +```bash +cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 +git add civilplan_mcp/prompts/__init__.py civilplan_mcp/prompts/birdseye_templates.py tests/test_birdseye_templates.py +git commit -m "feat: add project-type-specific prompt templates for birdseye rendering" +``` + +--- + +### Task 4: Create Bird's-Eye View Generator Tool + +**Files:** +- Create: `civilplan_mcp/tools/birdseye_generator.py` +- Test: `tests/test_birdseye_generator.py` + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_birdseye_generator.py`: + +```python +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +SAMPLE_PROJECT_SPEC = { + "project_id": "PRJ-20260404-001", + "domain": "토목_도로", + "project_type": ["도로"], + "road": {"class": "소로3류", "length_m": 890, "width_m": 6, "lanes": 2, "pavement": "아스콘"}, + "terrain": "구릉", + "region": "경기도", + "year_start": 2026, + "year_end": 2028, + "utilities": ["상수도", "하수도"], +} + +DOMAIN_MAP = { + "토목_도로": "road", + "건축": "building", + "토목_상하수도": "water", + "토목_하천": "river", + "조경": "landscape", + "복합": "mixed", +} + + +def _mock_generate_image(**kwargs): + return {"status": "success", "path": kwargs.get("output_path", "/tmp/test.png")} + + +@patch("civilplan_mcp.tools.birdseye_generator.get_settings") +@patch("civilplan_mcp.tools.birdseye_generator.GeminiImageService") +def test_generate_birdseye_returns_two_images(mock_svc_cls, mock_settings, tmp_path): + from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view + + mock_settings.return_value = MagicMock( + gemini_api_key="test-key", + output_dir=tmp_path, + ) + mock_svc = MagicMock() + mock_svc.generate_image.side_effect = lambda **kw: { + "status": "success", + "path": kw["output_path"], + } + mock_svc_cls.return_value = mock_svc + + result = generate_birdseye_view( + project_summary="경기도 지방도 890m 도로", + project_spec=SAMPLE_PROJECT_SPEC, + ) + + assert result["status"] == "success" + assert "birdseye_view" in result + assert "perspective_view" in result + assert result["birdseye_view"]["status"] == "success" + assert result["perspective_view"]["status"] == "success" + assert mock_svc.generate_image.call_count == 2 + + +@patch("civilplan_mcp.tools.birdseye_generator.get_settings") +@patch("civilplan_mcp.tools.birdseye_generator.GeminiImageService") +def test_generate_birdseye_with_svg_reference(mock_svc_cls, mock_settings, tmp_path): + from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view + + mock_settings.return_value = MagicMock( + gemini_api_key="test-key", + output_dir=tmp_path, + ) + mock_svc = MagicMock() + mock_svc.generate_image.side_effect = lambda **kw: { + "status": "success", + "path": kw["output_path"], + } + mock_svc_cls.return_value = mock_svc + + svg_content = '' + + with patch("civilplan_mcp.tools.birdseye_generator.svg_to_png") as mock_svg: + mock_svg.return_value = str(tmp_path / "ref.png") + # Create fake ref.png so path exists + (tmp_path / "ref.png").write_bytes(b"\x89PNG\r\n") + + result = generate_birdseye_view( + project_summary="경기도 도로", + project_spec=SAMPLE_PROJECT_SPEC, + svg_drawing=svg_content, + ) + + assert result["status"] == "success" + mock_svg.assert_called_once() + # Both calls should include reference_image_path + for call in mock_svc.generate_image.call_args_list: + assert call.kwargs.get("reference_image_path") is not None + + +@patch("civilplan_mcp.tools.birdseye_generator.get_settings") +def test_generate_birdseye_no_api_key(mock_settings): + from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view + + mock_settings.return_value = MagicMock(gemini_api_key="") + + result = generate_birdseye_view( + project_summary="Test", + project_spec=SAMPLE_PROJECT_SPEC, + ) + + assert result["status"] == "error" + assert "GEMINI_API_KEY" in result["error"] + + +@patch("civilplan_mcp.tools.birdseye_generator.get_settings") +@patch("civilplan_mcp.tools.birdseye_generator.GeminiImageService") +def test_generate_birdseye_partial_failure(mock_svc_cls, mock_settings, tmp_path): + from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view + + mock_settings.return_value = MagicMock( + gemini_api_key="test-key", + output_dir=tmp_path, + ) + call_count = [0] + + def side_effect(**kw): + call_count[0] += 1 + if call_count[0] == 1: + return {"status": "success", "path": kw["output_path"]} + return {"status": "error", "error": "Rate limit exceeded"} + + mock_svc = MagicMock() + mock_svc.generate_image.side_effect = side_effect + mock_svc_cls.return_value = mock_svc + + result = generate_birdseye_view( + project_summary="Test", + project_spec=SAMPLE_PROJECT_SPEC, + ) + + # Should still return partial results + assert result["status"] == "partial" + assert result["birdseye_view"]["status"] == "success" + assert result["perspective_view"]["status"] == "error" + + +def test_domain_to_project_type_mapping(): + 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" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_generator.py -v` +Expected: FAIL — module not found + +- [ ] **Step 3: Create birdseye_generator.py** + +Create `civilplan_mcp/tools/birdseye_generator.py`: + +```python +from __future__ import annotations + +import logging +import tempfile +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_MAP: dict[str, str] = { + "토목_도로": "road", + "건축": "building", + "토목_상하수도": "water", + "토목_하천": "river", + "조경": "landscape", + "복합": "mixed", +} + + +def _domain_to_project_type(domain: str) -> str: + return DOMAIN_MAP.get(domain, "mixed") + + +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", + output_dir: str | None = None, +) -> dict[str, Any]: + """Generate bird's-eye view and perspective renderings using Nano Banana Pro.""" + settings = get_settings() + + if not settings.gemini_api_key: + return { + "status": "error", + "error": "GEMINI_API_KEY is not configured. Set it in .env or run setup_keys.py.", + } + + out = Path(output_dir or settings.output_dir) / "renders" + out.mkdir(parents=True, exist_ok=True) + + domain = project_spec.get("domain", "복합") + project_type = _domain_to_project_type(domain) + project_id = project_spec.get("project_id", "unknown") + + # Extract details for prompt + details: dict[str, Any] = {} + road = project_spec.get("road") + if road: + details.update({k: v for k, v in road.items() if v is not None}) + details["terrain"] = project_spec.get("terrain") + details["region"] = project_spec.get("region") + details["utilities"] = project_spec.get("utilities") + + # Convert SVG to PNG reference if provided + ref_path: str | None = None + if svg_drawing: + try: + ref_png = str(out / f"{project_id}_ref.png") + ref_path = svg_to_png(svg_drawing, ref_png) + except Exception as exc: + logger.warning("SVG to PNG conversion failed, proceeding without reference: %s", exc) + ref_path = None + + svc = GeminiImageService(api_key=settings.gemini_api_key) + + results: dict[str, Any] = {} + for view_type in ("birdseye", "perspective"): + prompt = build_prompt( + view_type=view_type, + project_type=project_type, + project_summary=project_summary, + details=details, + ) + file_path = str(out / f"{project_id}_{view_type}.png") + + result = svc.generate_image( + prompt=prompt, + output_path=file_path, + reference_image_path=ref_path, + aspect_ratio="16:9", + image_size=resolution, + ) + results[f"{view_type}_view"] = result + + birdseye_ok = results["birdseye_view"].get("status") == "success" + perspective_ok = results["perspective_view"].get("status") == "success" + + if birdseye_ok and perspective_ok: + status = "success" + elif birdseye_ok or perspective_ok: + status = "partial" + else: + status = "error" + + domain_enum = ProjectDomain(domain) if domain in ProjectDomain.__members__.values() else ProjectDomain.복합 + + return wrap_response( + { + "status": status, + "project_id": project_id, + "model": "nano-banana-pro (gemini-3-pro-image-preview)", + "resolution": resolution, + "birdseye_view": results["birdseye_view"], + "perspective_view": results["perspective_view"], + }, + domain_enum, + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_generator.py -v` +Expected: All 5 tests PASS + +- [ ] **Step 5: Commit** + +```bash +cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 +git add civilplan_mcp/tools/birdseye_generator.py tests/test_birdseye_generator.py +git commit -m "feat: add birdseye view generator MCP tool" +``` + +--- + +### Task 5: Register Tool in Server and Update Dependencies + +**Files:** +- Modify: `civilplan_mcp/server.py` +- Modify: `civilplan_mcp/__init__.py` +- Modify: `requirements.txt` +- Modify: `pyproject.toml` +- Test: `tests/test_server_registration.py` + +- [ ] **Step 1: Write failing test for new tool registration** + +Add to `tests/test_server_registration.py`: + +```python +def test_birdseye_tool_registered(): + from civilplan_mcp.server import build_mcp + + app = build_mcp() + tool_names = [t.name for t in app._tool_manager.tools.values()] + assert "civilplan_generate_birdseye_view" in tool_names +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_server_registration.py::test_birdseye_tool_registered -v` +Expected: FAIL — tool not registered + +- [ ] **Step 3: Register tool in server.py** + +Add import at top of `civilplan_mcp/server.py`: + +```python +from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view +``` + +Add registration in `build_mcp()` after the last `_register_write_tool` call: + +```python + _register_write_tool(app, "civilplan_generate_birdseye_view", generate_birdseye_view) +``` + +- [ ] **Step 4: Update version to 2.0.0** + +In `civilplan_mcp/__init__.py`: + +```python +__all__ = ["__version__"] + +__version__ = "2.0.0" +``` + +In `pyproject.toml`, change: + +```toml +version = "2.0.0" +``` + +In `civilplan_mcp/config.py`, change: + +```python + version: str = "2.0.0" +``` + +- [ ] **Step 5: Update dependencies** + +In `requirements.txt`, add before `pytest`: + +``` +google-genai>=1.0.0 +cairosvg>=2.7.0 +Pillow>=10.0.0 +``` + +In `pyproject.toml`, add to dependencies list: + +```toml + "google-genai>=1.0.0", + "cairosvg>=2.7.0", + "Pillow>=10.0.0", +``` + +- [ ] **Step 6: Install new dependencies** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && pip install google-genai cairosvg Pillow` + +- [ ] **Step 7: Run test to verify registration passes** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_server_registration.py -v` +Expected: PASS (including the new test) + +- [ ] **Step 8: Run full test suite** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/ -v` +Expected: All tests PASS + +- [ ] **Step 9: Commit** + +```bash +cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 +git add civilplan_mcp/server.py civilplan_mcp/__init__.py civilplan_mcp/config.py requirements.txt pyproject.toml tests/test_server_registration.py +git commit -m "feat: register birdseye tool, bump to v2.0.0, add new dependencies" +``` + +--- + +### Task 6: Write Comprehensive README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Read current README for structure reference** + +Run: `cat C:/Users/sinmb/workspace/CivilPlan-MCP-v2/README.md` + +- [ ] **Step 2: Rewrite README.md** + +Full rewrite of `README.md` with these sections: + +```markdown +# CivilPlan MCP v2.0 + +> AI-powered construction project planning for Korean civil engineering and building projects. +> Connects to Claude, ChatGPT, and other AI agents via the Model Context Protocol (MCP). + +건설/건축 공사 사업계획을 AI와 함께 만듭니다. +MCP(Model Context Protocol)를 통해 Claude, ChatGPT 등 AI 에이전트와 연결됩니다. + +--- + +## What's New in v2.0 + +| Feature | Description | +|---------|-------------| +| **3D Bird's-Eye View** | Generate photorealistic aerial and perspective renderings using Nano Banana Pro (Gemini 3 Pro Image) | +| **Perspective Rendering** | Eye-level visualizations for project presentations | +| **SVG Reference Input** | Use existing drawings as reference for more accurate renderings | +| **20 MCP Tools** | Now includes rendering alongside planning, estimation, and documentation | + +--- + +## Features + +### Planning & Analysis (11 Tools) + +| Tool | Description | +|------|-------------| +| `civilplan_parse_project` | Parse natural language project descriptions into structured data | +| `civilplan_get_legal_procedures` | Identify required permits, approvals, and legal procedures | +| `civilplan_get_phase_checklist` | Get phase-by-phase project checklists | +| `civilplan_evaluate_impact_assessments` | Evaluate 9 environmental/feasibility assessments | +| `civilplan_estimate_quantities` | Calculate material volumes from standard cross-sections | +| `civilplan_get_unit_prices` | Retrieve regional unit cost data (조달청 기준) | +| `civilplan_get_applicable_guidelines` | Find applicable design guidelines | +| `civilplan_fetch_guideline_summary` | Fetch guideline details | +| `civilplan_select_bid_type` | Recommend bidding methodology | +| `civilplan_estimate_waste_disposal` | Calculate construction waste estimates | +| `civilplan_query_land_info` | Query land/cadastral information (V-World API) | + +### Financial Analysis (2 Tools) + +| Tool | Description | +|------|-------------| +| `civilplan_analyze_feasibility` | IRR, NPV, payback period, DSCR analysis | +| `civilplan_validate_against_benchmark` | Validate costs against government benchmarks | + +### Document Generation (7 Tools) + +| Tool | Description | +|------|-------------| +| `civilplan_generate_boq_excel` | Excel BOQ with cost breakdown (6 sheets) | +| `civilplan_generate_investment_doc` | Word investment plan document | +| `civilplan_generate_budget_report` | Budget report document | +| `civilplan_generate_schedule` | Gantt-style project timeline (Excel) | +| `civilplan_generate_svg_drawing` | SVG conceptual drawings | +| `civilplan_generate_dxf_drawing` | DXF CAD drawings | +| `civilplan_generate_birdseye_view` | **NEW** 3D aerial + perspective renderings | + +### Supported Domains + +| Domain | Korean | Status | +|--------|--------|--------| +| Roads | 토목_도로 | Full support | +| Buildings | 건축 | Full support | +| Water/Sewerage | 토목_상하수도 | Full support | +| Rivers | 토목_하천 | Full support | +| Landscaping | 조경 | Partial (in progress) | +| Mixed | 복합 | Full support | + +--- + +## Quick Start + +### 1. Clone & Install + +```bash +git clone https://github.com/sinmb79/Construction-project-master.git +cd Construction-project-master +python -m venv .venv + +# Windows +.venv\Scripts\activate +# macOS/Linux +source .venv/bin/activate + +pip install -r requirements.txt +``` + +### 2. Configure API Keys + +Copy the example environment file: + +```bash +cp .env.example .env +``` + +Edit `.env` and add your keys: + +```env +# Required for 3D rendering (Nano Banana Pro) +GEMINI_API_KEY=your_gemini_api_key_here + +# Optional - for land information queries +DATA_GO_KR_API_KEY=your_data_go_kr_key +VWORLD_API_KEY=your_vworld_key +``` + +**How to get a Gemini API key:** +1. Go to [Google AI Studio](https://aistudio.google.com/) +2. Click "Get API key" in the left sidebar +3. Create a new API key or use an existing project +4. Copy the key to your `.env` file + +**Alternative: Encrypted local storage (Windows)** + +```bash +python setup_keys.py +``` + +### 3. Start the Server + +```bash +python server.py +``` + +Server runs at: `http://127.0.0.1:8765/mcp` + +--- + +## Connecting to AI Agents + +### Claude Desktop + +Add to your Claude Desktop config file: + +**Windows:** `%APPDATA%\Claude\claude_desktop_config.json` +**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` + +```json +{ + "mcpServers": { + "civilplan": { + "url": "http://127.0.0.1:8765/mcp" + } + } +} +``` + +Restart Claude Desktop after editing. + +### Claude Code (CLI) + +```bash +claude mcp add civilplan http://127.0.0.1:8765/mcp +``` + +### ChatGPT / OpenAI + +ChatGPT supports MCP servers via compatible plugins or custom GPT configurations. +Connect using the server URL: `http://127.0.0.1:8765/mcp` + +### Other MCP Clients + +Any MCP-compatible client can connect using: +- **Transport:** Streamable HTTP +- **URL:** `http://127.0.0.1:8765/mcp` + +--- + +## Example: Full Project Workflow + +Input to AI agent: + +> "경기도 화성시 지방도 890m, 폭 6m, 2차선 아스팔트 도로, 상하수도 포함, 2026~2028년 시행" + +The AI agent can then use CivilPlan tools to: + +1. **Parse** → Structured project data (domain, dimensions, region, utilities) +2. **Legal procedures** → 18 required permits, 18-month approval timeline +3. **Impact assessment** → 9 assessment categories evaluated +4. **Quantity estimation** → Material volumes (토공, 포장, 배수, 상하수도) +5. **Unit pricing** → Regional cost data applied (경기도 factor: 1.05) +6. **BOQ generation** → Excel with 6 sheets, total ~10.67 billion KRW +7. **Investment document** → Word document for approval process +8. **Schedule** → Gantt timeline with phase durations +9. **Drawings** → SVG/DXF conceptual drawings +10. **3D Rendering** → Bird's-eye view + perspective visualization + +--- + +## 3D Rendering (Bird's-Eye View) + +The `civilplan_generate_birdseye_view` tool generates two images per call: + +| Output | Description | +|--------|-------------| +| Bird's-eye view | Aerial 45-60° angle showing full project extent | +| Perspective view | Eye-level view from the most representative viewpoint | + +**Input options:** +- Text-only: Uses project description and technical details +- Text + SVG: Uses existing SVG drawing as spatial reference for more accurate results + +**Resolution:** 2K (default) or 4K + +**Powered by:** Google Nano Banana Pro (Gemini 3 Pro Image) via the `google-genai` SDK + +--- + +## Architecture + +``` +MCP Client (Claude / ChatGPT / any MCP client) + │ + │ MCP Protocol (Streamable HTTP) + ▼ +┌─────────────────────────────────────┐ +│ CivilPlan MCP Server │ +│ (FastMCP 2.0+) │ +│ │ +│ ┌──────────┐ ┌──────────────┐ │ +│ │ 20 Tools │ │ Data Layer │ │ +│ │ (parse, │ │ (JSON, SQL, │ │ +│ │ legal, │ │ CSV) │ │ +│ │ render) │ │ │ │ +│ └────┬─────┘ └──────────────┘ │ +│ │ │ +│ ┌────▼─────────────────────┐ │ +│ │ Services │ │ +│ │ ├── Gemini Image API │ │ +│ │ ├── V-World API │ │ +│ │ └── Public Data Portal │ │ +│ └───────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +--- + +## Data Sources + +| Data | Source | Update Frequency | +|------|--------|-----------------| +| Unit prices | 조달청 표준시장단가 | Bi-annual (Jan, Jul) | +| Wages | 건설업 임금실태조사 | Bi-annual (Jan, Sep) | +| Legal procedures | 법제처 법령정보 | Manual updates | +| Region factors | 통계청 | Annual | +| Land prices | User-supplied CSV | As needed | + +The server includes an automatic update scheduler (APScheduler) that checks for new data releases. + +--- + +## Limitations + +| Item | Detail | +|------|--------| +| Accuracy | ±20-30% (planning stage estimates only) | +| Scope | Conceptual planning — not for detailed engineering design | +| Landscaping | Legal procedure data incomplete | +| Land data | Requires manual CSV download to `data/land_prices/` | +| Rendering | Requires internet + Gemini API key | +| Platform | Encrypted key storage (DPAPI) is Windows-only; use `.env` on other platforms | + +--- + +## Disclaimer + +> **참고용 개략 자료 — 공식 제출 불가** +> +> All outputs are approximate reference materials for planning purposes only. +> They cannot replace professional engineering design review and are not valid for official submission. +> Verify all results with domain experts before use in actual projects. + +--- + +## License + +MIT License. Free to use, modify, and distribute. + +## Author + +22B Labs — Hongik Ingan: reducing inequality in access to expert planning knowledge. +``` + +- [ ] **Step 3: Commit** + +```bash +cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 +git add README.md +git commit -m "docs: rewrite README for v2.0.0 with rendering guide and connection instructions" +``` + +--- + +### Task 7: Final Validation and Release + +**Files:** +- No new files + +- [ ] **Step 1: Run full test suite** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/ -v` +Expected: All tests PASS + +- [ ] **Step 2: Verify server starts without errors** + +Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && timeout 5 python server.py 2>&1 || true` +Expected: Server starts, shows warnings for missing API keys, no import errors + +- [ ] **Step 3: Tag release** + +```bash +cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 +git tag -a v2.0.0 -m "v2.0.0: Add 3D bird's-eye view rendering with Nano Banana Pro" +``` + +- [ ] **Step 4: Push to remote with tags** + +```bash +cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 +git push origin main --tags +``` + +- [ ] **Step 5: Create GitHub release** + +```bash +cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 +gh release create v2.0.0 --title "CivilPlan MCP v2.0.0 — 3D Bird's-Eye View Rendering" --notes "$(cat <<'NOTES' +## What's New + +### 3D Bird's-Eye View Rendering +- New `civilplan_generate_birdseye_view` MCP tool +- Powered by Google Nano Banana Pro (Gemini 3 Pro Image) +- Generates aerial (bird's-eye) + eye-level (perspective) renderings +- Supports text-only or text + SVG reference image input +- 2K / 4K resolution options +- Domain-specific prompt templates for road, building, water, river, landscape, and mixed projects + +### Infrastructure +- New Gemini API integration via `google-genai` SDK +- Added `GEMINI_API_KEY` to configuration system +- Now 20 MCP tools total + +### Documentation +- Complete README rewrite with: + - Tool reference tables + - Claude Desktop / Claude Code / ChatGPT connection guides + - API key setup instructions + - Full workflow example + - Architecture diagram + +## Installation + +```bash +git clone https://github.com/sinmb79/Construction-project-master.git +cd Construction-project-master +python -m venv .venv && .venv\Scripts\activate +pip install -r requirements.txt +cp .env.example .env +# Add your GEMINI_API_KEY to .env +python server.py +``` + +## Breaking Changes +None. All existing 19 tools remain unchanged. +NOTES +)" +``` + +Expected: Release created on GitHub From 5b96be31049aea62e690520f43028b4009480150 Mon Sep 17 00:00:00 2001 From: sinmb79 Date: Sat, 4 Apr 2026 19:29:27 +0900 Subject: [PATCH 3/3] feat: add Gemini-powered birdseye rendering --- .env.example | 1 + README.md | 680 +++++++++----------- civilplan_mcp/__init__.py | 2 +- civilplan_mcp/config.py | 7 +- civilplan_mcp/prompts/__init__.py | 3 + civilplan_mcp/prompts/birdseye_templates.py | 67 ++ civilplan_mcp/server.py | 2 + civilplan_mcp/services/__init__.py | 3 + civilplan_mcp/services/gemini_image.py | 122 ++++ civilplan_mcp/setup_keys.py | 2 + civilplan_mcp/tools/birdseye_generator.py | 129 ++++ pyproject.toml | 5 +- requirements.txt | 3 + tests/test_birdseye_generator.py | 142 ++++ tests/test_birdseye_templates.py | 51 ++ tests/test_config_and_secure_store.py | 28 + tests/test_gemini_image.py | 122 ++++ tests/test_server_registration.py | 5 +- tests/test_setup_keys.py | 26 + tests/test_smoke.py | 2 +- 20 files changed, 1006 insertions(+), 396 deletions(-) create mode 100644 civilplan_mcp/prompts/__init__.py create mode 100644 civilplan_mcp/prompts/birdseye_templates.py create mode 100644 civilplan_mcp/services/__init__.py create mode 100644 civilplan_mcp/services/gemini_image.py create mode 100644 civilplan_mcp/tools/birdseye_generator.py create mode 100644 tests/test_birdseye_generator.py create mode 100644 tests/test_birdseye_templates.py create mode 100644 tests/test_gemini_image.py create mode 100644 tests/test_setup_keys.py diff --git a/.env.example b/.env.example index 80e5186..2649cbe 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ # python setup_keys.py --from-env-file .env DATA_GO_KR_API_KEY= VWORLD_API_KEY= +GEMINI_API_KEY= diff --git a/README.md b/README.md index 7739fb3..d2ae45e 100644 --- a/README.md +++ b/README.md @@ -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["사용자 요청
Natural-language request"] --> B["civilplan_parse_project"] + B --> C["법적·기획 분석
legal / planning analysis"] + B --> D["물량·단가·사업성
quantity / pricing / feasibility"] + B --> E["문서·도면 생성
docs / drawings"] + B --> F["3D 렌더 생성
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 | +|---|---| +| 지자체·공공 발주 담당자
Local government and public-sector planners | 초기 타당성, 절차, 예산 초안이 빠르게 필요할 때 사용합니다.
Use it when you need a fast first-pass on feasibility, procedures, and budget. | +| 토목·건축 엔지니어
Civil and building engineers | 기획 단계 물량, 단가, 문서 초안을 자동화할 수 있습니다.
Automate early-stage quantity takeoff, pricing, and planning documents. | +| 개발사업 기획자
Development planners | 자연어 설명만으로 구조화된 프로젝트 데이터와 시각 자료를 얻을 수 있습니다.
Turn a plain-language project brief into structured project data and visuals. | +| AI 에이전트 운영자
AI agent builders | Claude, ChatGPT, 기타 MCP 클라이언트에 토목/건축 전용 도구 세트를 연결할 수 있습니다.
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를 한 번에 생성합니다.
Generates Gemini-based bird's-eye and perspective renders in one call. | +| `GEMINI_API_KEY` 설정 | `.env`와 Windows DPAPI 기반 `setup_keys.py` 양쪽에서 Gemini 키를 읽습니다.
Reads the Gemini key from both `.env` and Windows DPAPI-based `setup_keys.py`. | +| 도메인 프롬프트 템플릿 | 도로, 건축, 상하수도, 하천, 조경, 복합 프로젝트별 렌더 문구를 분리했습니다.
Adds domain-specific render prompts for road, building, water, river, landscape, and mixed projects. | +| 총 20개 MCP 도구 | 기존 기획/문서/도면 도구에 3D 시각화를 추가했습니다.
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으로 변환합니다.
Parses a natural-language project brief into structured JSON. | +| `civilplan_get_legal_procedures` | 사업 조건에 맞는 인허가·환경 절차를 정리합니다.
Finds permit and environmental procedures for the project. | +| `civilplan_get_phase_checklist` | 단계별 체크리스트를 생성합니다.
Builds phase-by-phase execution checklists. | +| `civilplan_evaluate_impact_assessments` | 영향평가 필요 여부를 검토합니다.
Evaluates impact-assessment requirements. | +| `civilplan_estimate_quantities` | 개략 물량을 산정합니다.
Estimates conceptual quantities. | +| `civilplan_get_unit_prices` | 지역 보정이 반영된 단가를 조회합니다.
Looks up unit prices with regional adjustments. | +| `civilplan_get_applicable_guidelines` | 적용 대상 설계 기준을 찾습니다.
Finds applicable design guidelines. | +| `civilplan_fetch_guideline_summary` | 기준 전문의 핵심 항목을 요약합니다.
Fetches summaries of guideline references. | +| `civilplan_select_bid_type` | 발주·입찰 방식을 추천합니다.
Recommends a bidding/procurement method. | +| `civilplan_estimate_waste_disposal` | 건설폐기물 물량과 처리비를 계산합니다.
Estimates construction waste volume and disposal cost. | +| `civilplan_query_land_info` | 토지·지목·용도지역 정보를 조회합니다.
Queries land, parcel, and zoning information. | +| `civilplan_analyze_feasibility` | IRR, NPV, DSCR 등 사업성을 계산합니다.
Calculates IRR, NPV, DSCR, and related feasibility metrics. | +| `civilplan_validate_against_benchmark` | 공공 기준이나 벤치마크와 비교합니다.
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 파일을 생성합니다.
Generates a BOQ Excel workbook. | +| `civilplan_generate_investment_doc` | 투자·사업계획 Word 문서를 생성합니다.
Generates an investment/planning Word document. | +| `civilplan_generate_budget_report` | 예산 보고서를 작성합니다.
Builds a budget report document. | +| `civilplan_generate_schedule` | 일정표 Excel 파일을 생성합니다.
Creates a schedule workbook. | +| `civilplan_generate_svg_drawing` | SVG 개략 도면을 생성합니다.
Generates conceptual SVG drawings. | +| `civilplan_generate_dxf_drawing` | DXF CAD 도면을 생성합니다.
Generates DXF CAD drawings. | +| `civilplan_generate_birdseye_view` | Bird's-Eye / Perspective PNG 렌더를 생성합니다.
Generates bird's-eye and perspective PNG renders. | ---- +### 지원 도메인 | Supported Domains + +| 도메인 Domain | 설명 Description | +|---|---| +| `토목_도로` | 도로, 진입로, 포장, 차선 중심 프로젝트
Roads, access roads, pavement, lane-focused projects | +| `건축` | 건물, 복지관, 학교, 오피스 등 건축 프로젝트
Buildings, welfare centers, schools, offices, and similar building projects | +| `토목_상하수도` | 상수도, 하수도, 우수도, 관로 중심 프로젝트
Water, sewer, stormwater, and pipeline-centric projects | +| `토목_하천` | 하천 정비, 제방, 배수, 수변 구조물 프로젝트
River improvement, levee, drainage, and riverside structure projects | +| `조경` | 공원, 녹지, 식재, 휴게 공간 프로젝트
Landscape, parks, planting, and open-space projects | +| `복합` | 다분야가 섞인 복합 개발 프로젝트
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)
`cp .env.example .env` (macOS/Linux) | 로컬 개발용으로 가장 단순합니다.
The simplest option for local development. | +| 암호화 저장소 | `python setup_keys.py` | Windows DPAPI에 키를 암호화 저장합니다.
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`를 켭니다.
Enable `Settings → Apps → Advanced settings → Developer mode` in ChatGPT. | +| 2 | `Create app`를 눌러 원격 MCP 서버를 등록합니다.
Click `Create app` to register a remote MCP server. | +| 3 | 로컬 서버는 직접 연결되지 않으므로 터널 URL이 필요합니다.
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="...", + 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` 저장 여부를 확인합니다.
Check `.env` or whether `setup_keys.py` stored the key. | `GEMINI_API_KEY`를 입력하고 서버를 재시작합니다.
Add `GEMINI_API_KEY` and restart the server. | +| ChatGPT에서 localhost 연결 실패 | ChatGPT는 로컬 URL을 직접 쓰지 못합니다.
ChatGPT cannot use a localhost URL directly. | `cloudflared` 또는 `ngrok`로 HTTPS 터널을 노출합니다.
Expose the server through an HTTPS tunnel such as `cloudflared` or `ngrok`. | +| Claude Code에서 도구가 안 보임 | `claude mcp list`로 등록 상태를 확인합니다.
Use `claude mcp list` to verify registration. | `claude mcp add --transport http civilplan http://127.0.0.1:8765/mcp`를 다시 실행합니다.
Re-run the HTTP MCP registration command. | +| SVG 참고 이미지가 반영되지 않음 | `cairosvg` 설치 여부와 SVG 문자열 유효성을 확인합니다.
Check whether `cairosvg` is installed and the SVG string is valid. | 잘못된 SVG면 텍스트 전용 렌더로 fallback 됩니다.
If SVG conversion fails, the tool falls back to text-only rendering. | +| 전체 테스트를 다시 돌리고 싶음 | 아래 명령을 사용합니다.
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 | +|---|---| +| 기획 단계 정확도 | 모든 수치와 절차는 개략 검토용입니다.
All numbers and procedures are intended for conceptual planning only. | +| 3D 렌더 의존성 | `civilplan_generate_birdseye_view`는 인터넷 연결과 `GEMINI_API_KEY`가 필요합니다.
`civilplan_generate_birdseye_view` requires internet access and a `GEMINI_API_KEY`. | +| 토지 정보 | 일부 토지 데이터는 외부 API 상태에 따라 결과가 달라질 수 있습니다.
Some land information depends on external API availability. | +| 조경·복합 도메인 | 프롬프트와 절차 데이터가 계속 보강 중입니다.
Landscape and mixed-domain support is still being expanded. | +| 공개 제출 문서 | 생성 결과는 공식 제출 문서가 아닙니다.
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 | 사용, 수정, 배포 가능
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) | diff --git a/civilplan_mcp/__init__.py b/civilplan_mcp/__init__.py index b79805f..a8e6871 100644 --- a/civilplan_mcp/__init__.py +++ b/civilplan_mcp/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "1.0.0" +__version__ = "2.0.0" diff --git a/civilplan_mcp/config.py b/civilplan_mcp/config.py index b05fe8e..ce5ff4f 100644 --- a/civilplan_mcp/config.py +++ b/civilplan_mcp/config.py @@ -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 diff --git a/civilplan_mcp/prompts/__init__.py b/civilplan_mcp/prompts/__init__.py new file mode 100644 index 0000000..9f3e482 --- /dev/null +++ b/civilplan_mcp/prompts/__init__.py @@ -0,0 +1,3 @@ +from civilplan_mcp.prompts.birdseye_templates import DOMAIN_PROMPTS, VIEW_INSTRUCTIONS, build_prompt + +__all__ = ["DOMAIN_PROMPTS", "VIEW_INSTRUCTIONS", "build_prompt"] diff --git a/civilplan_mcp/prompts/birdseye_templates.py b/civilplan_mcp/prompts/birdseye_templates.py new file mode 100644 index 0000000..856ad08 --- /dev/null +++ b/civilplan_mcp/prompts/birdseye_templates.py @@ -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." + ) diff --git a/civilplan_mcp/server.py b/civilplan_mcp/server.py index 53fa406..85ec13c 100644 --- a/civilplan_mcp/server.py +++ b/civilplan_mcp/server.py @@ -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 diff --git a/civilplan_mcp/services/__init__.py b/civilplan_mcp/services/__init__.py new file mode 100644 index 0000000..7e938e8 --- /dev/null +++ b/civilplan_mcp/services/__init__.py @@ -0,0 +1,3 @@ +from civilplan_mcp.services.gemini_image import GeminiImageService + +__all__ = ["GeminiImageService"] diff --git a/civilplan_mcp/services/gemini_image.py b/civilplan_mcp/services/gemini_image.py new file mode 100644 index 0000000..50285ee --- /dev/null +++ b/civilplan_mcp/services/gemini_image.py @@ -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() diff --git a/civilplan_mcp/setup_keys.py b/civilplan_mcp/setup_keys.py index 371c113..18f822f 100644 --- a/civilplan_mcp/setup_keys.py +++ b/civilplan_mcp/setup_keys.py @@ -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, } ) diff --git a/civilplan_mcp/tools/birdseye_generator.py b/civilplan_mcp/tools/birdseye_generator.py new file mode 100644 index 0000000..dc8cd20 --- /dev/null +++ b/civilplan_mcp/tools/birdseye_generator.py @@ -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, + ) diff --git a/pyproject.toml b/pyproject.toml index bad4eeb..9019cf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/requirements.txt b/requirements.txt index 12480d1..64b73cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_birdseye_generator.py b/tests/test_birdseye_generator.py new file mode 100644 index 0000000..f4af754 --- /dev/null +++ b/tests/test_birdseye_generator.py @@ -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="", + ) + + 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" diff --git a/tests/test_birdseye_templates.py b/tests/test_birdseye_templates.py new file mode 100644 index 0000000..72bac98 --- /dev/null +++ b/tests/test_birdseye_templates.py @@ -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() diff --git a/tests/test_config_and_secure_store.py b/tests/test_config_and_secure_store.py index fbaaf9b..235c0cf 100644 --- a/tests/test_config_and_secure_store.py +++ b/tests/test_config_and_secure_store.py @@ -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 diff --git a/tests/test_gemini_image.py b/tests/test_gemini_image.py new file mode 100644 index 0000000..56f9c9d --- /dev/null +++ b/tests/test_gemini_image.py @@ -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() diff --git a/tests/test_server_registration.py b/tests/test_server_registration.py index 3434cf2..a7077dc 100644 --- a/tests/test_server_registration.py +++ b/tests/test_server_registration.py @@ -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: diff --git a/tests/test_setup_keys.py b/tests/test_setup_keys.py new file mode 100644 index 0000000..cd49a55 --- /dev/null +++ b/tests/test_setup_keys.py @@ -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", + } diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 8ceb083..e152341 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -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: