Initial CivilPlan MCP implementation

This commit is contained in:
sinmb79
2026-04-03 09:08:08 +09:00
commit 544e4e0720
70 changed files with 3364 additions and 0 deletions

17
tests/test_base.py Normal file
View File

@@ -0,0 +1,17 @@
from civilplan_mcp.models import ProjectDomain
from civilplan_mcp.tools._base import wrap_response
def test_project_domain_contains_expected_values() -> None:
assert ProjectDomain.건축.value == "건축"
assert ProjectDomain.토목_도로.value == "토목_도로"
assert ProjectDomain.복합.value == "복합"
def test_wrap_response_adds_required_disclaimers() -> None:
wrapped = wrap_response({"status": "ok"}, ProjectDomain.토목_도로)
assert wrapped["status"] == "ok"
assert "domain_note" in wrapped
assert "validity_disclaimer" in wrapped
assert wrapped["data_as_of"] == "2026년 4월 기준"

39
tests/test_data_files.py Normal file
View File

@@ -0,0 +1,39 @@
from pathlib import Path
from civilplan_mcp.config import get_settings
from civilplan_mcp.db.bootstrap import bootstrap_database, load_json_data
def test_required_seed_files_exist() -> None:
data_dir = get_settings().data_dir
required = [
"unit_prices_2026.json",
"legal_procedures.json",
"region_factors.json",
"road_standards.json",
"guidelines_catalog.json",
"association_prices_catalog.json",
"waste_disposal_prices_2025.json",
"indirect_cost_rates.json",
"supervision_rates.json",
"rental_benchmark.json",
"price_update_calendar.json",
"data_validity_warnings.json",
]
for name in required:
assert (data_dir / name).exists(), name
def test_load_json_data_reads_seed_file() -> None:
data = load_json_data("region_factors.json")
assert "경기도" in data
def test_bootstrap_database_creates_sqlite_file(tmp_path: Path) -> None:
db_path = tmp_path / "civilplan.db"
bootstrap_database(db_path)
assert db_path.exists()

View File

@@ -0,0 +1,36 @@
from civilplan_mcp.models import ProjectDomain
from civilplan_mcp.tools.bid_type_selector import select_bid_type
from civilplan_mcp.tools.guideline_fetcher import fetch_guideline_summary
from civilplan_mcp.tools.guideline_resolver import get_applicable_guidelines
from civilplan_mcp.tools.waste_estimator import estimate_waste_disposal
def test_get_applicable_guidelines_returns_matches() -> None:
result = get_applicable_guidelines(
domain=ProjectDomain.토목_도로,
procedure_ids=["PER-01"],
project_type="도로",
)
assert result["guidelines"]
def test_fetch_guideline_summary_returns_catalog_summary() -> None:
result = fetch_guideline_summary(guideline_id="GL-001")
assert result["summary"]["id"] == "GL-001"
def test_select_bid_type_chooses_comprehensive_for_large_cost() -> None:
result = select_bid_type(total_cost_billion=350.0, domain=ProjectDomain.건축)
assert result["recommended_type"] == "종합심사낙찰제"
def test_estimate_waste_disposal_returns_cost() -> None:
result = estimate_waste_disposal(
project_type="도로",
waste_items={"폐콘크리트": 100, "폐아스팔트콘크리트": 50},
)
assert result["summary"]["total_cost_won"] > 0

88
tests/test_generators.py Normal file
View File

@@ -0,0 +1,88 @@
from pathlib import Path
from openpyxl import load_workbook
from civilplan_mcp.tools.boq_generator import generate_boq_excel
from civilplan_mcp.tools.doc_generator import generate_investment_doc
from civilplan_mcp.tools.drawing_generator import generate_svg_drawing
from civilplan_mcp.tools.quantity_estimator import estimate_quantities
from civilplan_mcp.tools.project_parser import parse_project
from civilplan_mcp.tools.unit_price_query import get_unit_prices
def _sample_inputs(tmp_path: Path) -> tuple[dict, dict, dict]:
project_spec = parse_project(
description="소로 신설 L=890m B=6m 아스콘 2차선 상하수도 경기도 둔턱지역 2026~2028"
)
project_spec["output_dir"] = str(tmp_path)
quantities = estimate_quantities(
road_class="소로",
length_m=890,
width_m=6.0,
terrain="구릉",
pavement_type="아스콘",
include_water_supply=True,
include_sewage=True,
include_retaining_wall=True,
include_bridge=False,
bridge_length_m=0.0,
)
unit_prices = get_unit_prices(region="경기도", year=2026)
return project_spec, quantities, unit_prices
def test_generate_boq_excel_creates_workbook(tmp_path: Path) -> None:
project_spec, quantities, unit_prices = _sample_inputs(tmp_path)
result = generate_boq_excel(
project_name="소로 개설(신설) 공사",
project_spec=project_spec,
quantities=quantities,
unit_prices=unit_prices,
region="경기도",
year=2026,
output_filename="boq.xlsx",
)
workbook = load_workbook(result["file_path"], data_only=False)
assert "사업내역서(BOQ)" in workbook.sheetnames
def test_generate_investment_doc_creates_docx(tmp_path: Path) -> None:
project_spec, quantities, unit_prices = _sample_inputs(tmp_path)
boq = generate_boq_excel(
project_name="소로 개설(신설) 공사",
project_spec=project_spec,
quantities=quantities,
unit_prices=unit_prices,
region="경기도",
year=2026,
output_filename="boq_for_doc.xlsx",
)
result = generate_investment_doc(
project_name="소로 개설(신설) 공사",
project_spec=project_spec,
quantities=quantities,
legal_procedures={"summary": {"total_procedures": 3}, "phases": {}},
boq_summary=boq["summary"],
requester="22B Labs",
output_filename="investment.docx",
)
assert Path(result["file_path"]).exists()
def test_generate_svg_drawing_creates_svg(tmp_path: Path) -> None:
project_spec, quantities, _ = _sample_inputs(tmp_path)
result = generate_svg_drawing(
drawing_type="횡단면도",
project_spec=project_spec,
quantities=quantities,
scale="1:200",
output_filename="road.svg",
)
content = Path(result["file_path"]).read_text(encoding="utf-8")
assert "<svg" in content

20
tests/test_impacts.py Normal file
View File

@@ -0,0 +1,20 @@
from civilplan_mcp.tools.impact_evaluator import evaluate_impact_assessments
def test_evaluate_impact_assessments_flags_borderline_disaster_review() -> None:
result = evaluate_impact_assessments(
domain="토목_도로",
project_type="도로",
road_length_m=890,
development_area_m2=5340,
total_cost_billion=10.67,
building_floor_area_m2=None,
housing_units=None,
is_urban_area=True,
near_cultural_heritage=False,
near_river=False,
near_protected_area=False,
)
target = next(item for item in result["evaluations"] if item["name"] == "재해영향평가")
assert target["result"] == "BORDERLINE"

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import httpx
from civilplan_mcp.config import Settings
from civilplan_mcp.tools import benchmark_validator, land_info_query
def _json_response(url: str, payload: dict, *, status_code: int = 200) -> httpx.Response:
return httpx.Response(
status_code,
request=httpx.Request("GET", url),
json=payload,
)
def test_query_land_info_uses_eum_land_use_endpoint(monkeypatch, tmp_path) -> None:
settings = Settings(vworld_api_key="test-key", data_go_kr_api_key="public-key", data_dir=tmp_path)
calls: list[str] = []
def fake_get(url: str, *, params=None, timeout=None, headers=None): # type: ignore[no-untyped-def]
calls.append(url)
if url == land_info_query.VWORLD_ADDRESS_URL:
return _json_response(
url,
{
"response": {
"status": "OK",
"result": {
"point": {"x": "127.061", "y": "37.821"},
"items": [
{
"id": "4163011600101230000",
"address": {"parcel": "경기도 양주시 덕계동 123"},
}
],
},
}
},
)
if url == land_info_query.VWORLD_DATA_URL:
return _json_response(
url,
{
"response": {
"status": "OK",
"result": {
"featureCollection": {
"features": [
{
"properties": {
"area": "320.5",
"jimok": "",
}
}
]
}
},
}
},
)
if url == land_info_query.EUM_LAND_USE_URL:
return _json_response(
url,
{
"landUseInfo": {
"useDistrict": "제2종일반주거지역",
"district2": "고도지구",
"bcr": "60",
"far": "200",
"heightLimit": "16",
}
},
)
raise AssertionError(f"Unexpected URL: {url}")
monkeypatch.setattr(land_info_query, "get_settings", lambda: settings)
monkeypatch.setattr(land_info_query.httpx, "get", fake_get)
result = land_info_query.query_land_info(address="경기도 양주시 덕계동 123", pnu=None)
assert result["status"] == "success"
assert result["zoning"]["use_district"] == "제2종일반주거지역"
assert result["zoning"]["district_2"] == "고도지구"
assert result["zoning"]["bcr_pct"] == 60
assert result["zoning"]["far_pct"] == 200
assert land_info_query.EUM_LAND_USE_URL in calls
assert "https://api.vworld.kr/req/wfs" not in calls
def test_validate_against_benchmark_reports_backend_fallback_on_502(monkeypatch) -> None:
settings = Settings(data_go_kr_api_key="test-key")
def fake_get(url: str, *, params=None, timeout=None): # type: ignore[no-untyped-def]
return httpx.Response(
502,
request=httpx.Request("GET", url),
text="backend unavailable",
)
monkeypatch.setattr(benchmark_validator, "get_settings", lambda: settings)
monkeypatch.setattr(benchmark_validator.httpx, "get", fake_get)
result = benchmark_validator.validate_against_benchmark(
project_type="도로_포장",
road_length_m=890,
floor_area_m2=None,
region="경기도",
our_estimate_won=1_067_000_000,
)
assert result["benchmark"]["api_status"] == "fallback"
assert "나라장터 직접 검색" in result["benchmark"]["availability_note"]

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from io import BytesIO
from pathlib import Path
import zipfile
from civilplan_mcp.config import Settings
from civilplan_mcp.tools.land_info_query import (
_read_land_price_from_files,
extract_land_use_html_properties,
)
from civilplan_mcp.updater import wage_updater
def test_extract_land_use_html_properties_reads_regulation_table() -> None:
html = """
<div class="tbl04 mb">
<table>
<tbody>
<tr>
<td>제2종일반주거지역</td>
<td>60%</td>
<td>200%</td>
</tr>
<tr class="center"><td colspan="3" class="center bg">건폐율</td></tr>
<tr><td colspan="3" class="left" id="PopupG_pop"><p>최대 건폐율 60%</p></td></tr>
<tr class="center"><td colspan="3" class="center bg">용적률</td></tr>
<tr><td colspan="3" class="left" id="PopupY_pop"><p>최대 용적률 200%</p></td></tr>
</tbody>
</table>
</div>
"""
parsed = extract_land_use_html_properties(html)
assert parsed["usedistrictnm"] == "제2종일반주거지역"
assert parsed["bcr"] == "60"
assert parsed["far"] == "200"
def test_read_land_price_from_zip_file(tmp_path: Path, monkeypatch) -> None:
land_price_dir = tmp_path / "land_prices"
land_price_dir.mkdir()
archive_path = land_price_dir / "seoul_land_prices.zip"
buffer = BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr(
"prices.csv",
"PNU,LANDPRICE\n1111010100100010000,1250000\n",
)
archive_path.write_bytes(buffer.getvalue())
settings = Settings(data_dir=tmp_path)
monkeypatch.setattr("civilplan_mcp.tools.land_info_query.get_settings", lambda: settings)
price = _read_land_price_from_files("1111010100100010000")
assert price == {"individual_m2_won": 1250000, "source": "seoul_land_prices.zip:prices.csv"}
def test_update_wage_rates_creates_flag_and_log_on_parse_failure(tmp_path: Path, monkeypatch) -> None:
settings = Settings(data_dir=tmp_path)
monkeypatch.setattr(wage_updater, "get_settings", lambda: settings)
monkeypatch.setattr(
wage_updater,
"fetch_source_text",
lambda url: "<html><body>No structured wage table here</body></html>",
)
result = wage_updater.update_wage_rates(period="상반기")
assert result["status"] == "pending_manual_review"
assert (tmp_path / ".update_required_wage").exists()
assert (tmp_path / "update_log.json").exists()

34
tests/test_legal.py Normal file
View File

@@ -0,0 +1,34 @@
from civilplan_mcp.models import ProjectDomain
from civilplan_mcp.tools.legal_procedures import get_legal_procedures
from civilplan_mcp.tools.phase_checklist import get_phase_checklist
def test_get_legal_procedures_returns_domain_specific_items() -> None:
result = get_legal_procedures(
domain=ProjectDomain.토목_도로,
project_type="도로",
total_cost_billion=10.67,
road_length_m=890,
development_area_m2=5340,
region="경기도",
has_farmland=False,
has_forest=False,
has_river=False,
is_public=True,
)
assert result["summary"]["total_procedures"] >= 2
assert "인허가" in result["phases"]
def test_get_phase_checklist_returns_preparing_message_for_landscape() -> None:
result = get_phase_checklist(
domain=ProjectDomain.조경,
phase="기획",
project_type="조경",
total_cost_billion=3.0,
has_building=False,
has_bridge=False,
)
assert result["status"] == "not_ready"

21
tests/test_parser.py Normal file
View File

@@ -0,0 +1,21 @@
from civilplan_mcp.models import ProjectDomain
from civilplan_mcp.tools.project_parser import parse_project
def test_parse_project_extracts_core_road_fields() -> None:
result = parse_project(
description="소로 신설 L=890m B=6m 아스콘 2차선 상하수도 경기도 둔턱지역 2026~2028"
)
assert result["road"]["length_m"] == 890
assert result["road"]["width_m"] == 6.0
assert result["road"]["lanes"] == 2
assert result["region"] == "경기도"
assert result["domain"] == ProjectDomain.토목_도로.value
def test_parse_project_marks_composite_domain_warning() -> None:
result = parse_project(description="복지관 신축 및 진입도로 개설")
assert result["domain"] == ProjectDomain.복합.value
assert "domain_warning" in result

27
tests/test_quantities.py Normal file
View File

@@ -0,0 +1,27 @@
from civilplan_mcp.tools.quantity_estimator import estimate_quantities
from civilplan_mcp.tools.unit_price_query import get_unit_prices
def test_estimate_quantities_returns_expected_pavement_area() -> None:
result = estimate_quantities(
road_class="소로",
length_m=890,
width_m=6.0,
terrain="구릉",
pavement_type="아스콘",
include_water_supply=True,
include_sewage=True,
include_retaining_wall=True,
include_bridge=False,
bridge_length_m=0.0,
)
assert result["road_spec"]["pavement_area_m2"] == 5340.0
assert result["quantities"]["포장"]["아스콘표층_t"] > 0
def test_get_unit_prices_applies_region_factor() -> None:
result = get_unit_prices(category="포장", item_name="아스콘", region="경기도", year=2026)
assert result["results"]
assert result["results"][0]["adjusted_price"] > result["results"][0]["base_price"]

View File

@@ -0,0 +1,29 @@
import asyncio
from civilplan_mcp.server import build_mcp, build_server_config
def test_build_server_config_defaults() -> None:
config = build_server_config()
assert config["host"] == "127.0.0.1"
assert config["port"] == 8765
assert config["path"] == "/mcp"
def test_server_registers_all_19_tools() -> None:
app = build_mcp()
tools = asyncio.run(app.list_tools())
names = {tool.name for tool in tools}
assert len(names) == 19
assert "civilplan_parse_project" in names
assert "civilplan_generate_dxf_drawing" in names
def test_read_tools_have_read_only_hint() -> None:
app = build_mcp()
tools = {tool.name: tool for tool in asyncio.run(app.list_tools())}
assert tools["civilplan_parse_project"].annotations.readOnlyHint is True
assert tools["civilplan_generate_boq_excel"].annotations.readOnlyHint is None

18
tests/test_smoke.py Normal file
View File

@@ -0,0 +1,18 @@
from civilplan_mcp import __version__
from civilplan_mcp.config import Settings, get_settings
def test_package_version_present() -> None:
assert __version__ == "1.0.0"
def test_settings_have_expected_defaults() -> None:
settings = Settings()
assert settings.host == "127.0.0.1"
assert settings.port == 8765
assert settings.http_path == "/mcp"
def test_get_settings_is_cached() -> None:
assert get_settings() is get_settings()

23
tests/test_updater.py Normal file
View File

@@ -0,0 +1,23 @@
from pathlib import Path
from civilplan_mcp.updater.scheduler import build_scheduler
from civilplan_mcp.updater.wage_updater import check_update_flags, flag_manual_update_required
def test_build_scheduler_registers_expected_jobs() -> None:
scheduler = build_scheduler(start=False)
assert {job.id for job in scheduler.get_jobs()} == {
"wage_h1",
"waste_annual",
"standard_h1",
"standard_h2",
"wage_h2",
}
def test_flag_manual_update_required_creates_flag(tmp_path: Path) -> None:
flag_manual_update_required("wage", "manual review needed", data_dir=tmp_path)
warnings = check_update_flags(data_dir=tmp_path)
assert warnings

159
tests/test_v11_tools.py Normal file
View File

@@ -0,0 +1,159 @@
from pathlib import Path
import ezdxf
from civilplan_mcp.config import Settings
from civilplan_mcp.tools.benchmark_validator import validate_against_benchmark
from civilplan_mcp.tools.budget_report_generator import generate_budget_report
from civilplan_mcp.tools.dxf_generator import generate_dxf_drawing
from civilplan_mcp.tools.feasibility_analyzer import analyze_feasibility
from civilplan_mcp.tools.land_info_query import (
build_land_use_bbox_params,
extract_address_result,
extract_feature_properties,
query_land_info,
)
from civilplan_mcp.tools.project_parser import parse_project
from civilplan_mcp.tools.quantity_estimator import estimate_quantities
def test_query_land_info_returns_graceful_message_without_api_keys() -> None:
result = query_land_info(address="경기도 양주시 덕계동 123", pnu=None)
assert result["status"] == "disabled"
def test_settings_keep_public_data_key_for_nara_usage() -> None:
settings = Settings(data_go_kr_api_key="abc")
assert settings.data_go_kr_api_key == "abc"
def test_extract_address_result_reads_vworld_shape() -> None:
payload = {
"response": {
"status": "OK",
"result": {
"point": {"x": "127.061", "y": "37.821"},
"items": [
{
"id": "4163010100101230000",
"address": {"parcel": "경기도 양주시 덕계동 123"}
}
],
},
}
}
parsed = extract_address_result(payload)
assert parsed["pnu"] == "4163010100101230000"
assert parsed["x"] == 127.061
def test_extract_feature_properties_returns_first_feature() -> None:
payload = {
"response": {
"status": "OK",
"result": {
"featureCollection": {
"features": [
{"properties": {"pnu": "4163010100101230000", "jimok": ""}}
]
}
},
}
}
parsed = extract_feature_properties(payload)
assert parsed["jimok"] == ""
def test_build_land_use_bbox_params_uses_wfs_bbox_pattern() -> None:
params = build_land_use_bbox_params(127.0, 37.0, "test-key")
assert params["SERVICE"] == "WFS"
assert params["REQUEST"] == "GetFeature"
assert params["TYPENAME"] == "lt_c_lhblpn"
assert params["SRSNAME"] == "EPSG:4326"
assert params["KEY"] == "test-key"
assert params["OUTPUTFORMAT"] == "application/json"
assert params["BBOX"] == "126.9995,36.9995,127.0005,37.0005,EPSG:4326"
def test_analyze_feasibility_returns_positive_cost_structure() -> None:
result = analyze_feasibility(
land_area_m2=600,
land_price_per_m2=850000,
land_price_multiplier=1.0,
construction_cost_total=890000000,
other_costs_million=50,
revenue_type="임대",
building_floor_area_m2=720,
sale_price_per_m2=None,
monthly_rent_per_m2=16000,
vacancy_rate_pct=10.0,
operating_expense_pct=20.0,
equity_ratio_pct=30.0,
loan_rate_pct=5.5,
loan_term_years=10,
construction_months=24,
sale_months=12,
)
assert result["cost_structure"]["total_investment"] > 0
assert "irr_pct" in result["returns"]
def test_validate_against_benchmark_includes_bid_warning() -> None:
result = validate_against_benchmark(
project_type="소로_도로",
road_length_m=890,
floor_area_m2=None,
region="경기도",
our_estimate_won=1067000000,
)
assert "낙찰가 ≠ 사업비" in result["bid_rate_reference"]["경고"]
def test_generate_budget_report_creates_docx(tmp_path: Path) -> None:
project_data = parse_project(description="복지관 신축 2026~2028 경기도")
project_data["output_dir"] = str(tmp_path)
result = generate_budget_report(
report_type="예산편성요구서",
project_data=project_data,
boq_summary={"total_cost": 1067000000, "direct_cost": 900422000, "indirect_cost": 166578000},
department="도로과",
requester="22B Labs",
output_filename="budget.docx",
)
assert Path(result["file_path"]).exists()
def test_generate_dxf_drawing_creates_dxf(tmp_path: Path) -> None:
project_spec = parse_project(description="소로 신설 L=890m B=6m 경기도")
project_spec["output_dir"] = str(tmp_path)
quantities = estimate_quantities(
road_class="소로",
length_m=890,
width_m=6.0,
terrain="평지",
pavement_type="아스콘",
include_water_supply=False,
include_sewage=False,
include_retaining_wall=False,
include_bridge=False,
bridge_length_m=0.0,
)
result = generate_dxf_drawing(
drawing_type="횡단면도",
project_spec=project_spec,
quantities=quantities,
scale="1:200",
output_filename="road.dxf",
)
doc = ezdxf.readfile(result["file_path"])
assert doc is not None