Files
Construction-project-master/civilplan_mcp/tools/benchmark_validator.py
2026-04-03 09:08:08 +09:00

101 lines
4.1 KiB
Python

from __future__ import annotations
from typing import Any
import httpx
from civilplan_mcp.config import get_settings
from civilplan_mcp.models import ProjectDomain
from civilplan_mcp.tools._base import wrap_response
NARA_BID_NOTICE_DATASET_URL = "https://www.data.go.kr/data/15129394/openapi.do"
NARA_BID_NOTICE_API_URL = "https://apis.data.go.kr/1230000/ad/BidPublicInfoService/getBidPblancListInfoCnstwk"
def _probe_nara_api(api_key: str) -> tuple[str, str | None]:
params = {
"serviceKey": api_key,
"pageNo": 1,
"numOfRows": 1,
"type": "json",
}
try:
response = httpx.get(NARA_BID_NOTICE_API_URL, params=params, timeout=20)
response.raise_for_status()
return "live", "나라장터 API connection succeeded. The benchmark still uses the local heuristic until live parsing is finalized."
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else None
if status_code == 502:
return (
"fallback",
"나라장터 API 현재 응답 불가(공공데이터포털 백엔드 점검 또는 게이트웨이 문제로 추정). 표준품셈 기반 추정 범위만 제공합니다. 정확한 유사사업 예정가격은 나라장터 직접 검색 권장.",
)
return (
"fallback",
f"나라장터 API 응답 상태가 비정상적입니다 (HTTP {status_code}). 표준품셈 기반 추정 범위만 제공합니다.",
)
except Exception as exc:
return (
"fallback",
f"나라장터 API 연결 확인에 실패했습니다 ({exc}). 표준품셈 기반 추정 범위만 제공합니다.",
)
def validate_against_benchmark(
*,
project_type: str,
road_length_m: float | None,
floor_area_m2: float | None,
region: str,
our_estimate_won: float,
) -> dict[str, Any]:
settings = get_settings()
if not settings.data_go_kr_api_key:
api_status = "disabled"
availability_note = "DATA_GO_KR_API_KEY is missing, so the Nara API check was skipped."
source = "Local benchmark heuristic (DATA_GO_KR_API_KEY missing)"
else:
api_status, availability_note = _probe_nara_api(settings.data_go_kr_api_key)
if api_status == "live":
source = "Local benchmark heuristic (Nara API connectivity confirmed)"
else:
source = "Local benchmark heuristic (Nara API unavailable; fallback active)"
average_won = round(our_estimate_won * 1.05)
median_won = round(our_estimate_won * 1.02)
deviation_pct = round(((our_estimate_won - average_won) / average_won) * 100, 1)
unit_basis = max(road_length_m or floor_area_m2 or 1, 1)
return wrap_response(
{
"our_estimate": our_estimate_won,
"benchmark": {
"source": source,
"api_status": api_status,
"availability_note": availability_note,
"average_won": average_won,
"median_won": median_won,
"range": f"{round(average_won * 0.8):,} ~ {round(average_won * 1.2):,}",
"unit_cost_per_m": round(average_won / unit_basis),
"our_unit_cost": round(our_estimate_won / unit_basis),
},
"deviation_pct": deviation_pct,
"assessment": "적정 (±15% 이내)" if abs(deviation_pct) <= 15 else "추가 검토 필요",
"bid_rate_reference": {
"note": "낙찰률은 예정가격의 비율로 참고만 제공",
"warning": "낙찰가 ≠ 사업비. 낙찰차액은 발주처 재량 사용분입니다.",
"경고": "낙찰가 ≠ 사업비. 낙찰차액은 발주처 재량 사용분입니다.",
"적격심사_일반": "64~87%",
"적격심사_전문": "70~87%",
"종합심사낙찰제": "90~95%",
"기타_전자입찰": "85~95%",
},
"inputs": {"project_type": project_type, "region": region},
"source_dataset": NARA_BID_NOTICE_DATASET_URL,
},
ProjectDomain.복합,
)