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
+3
View File
@@ -0,0 +1,3 @@
from civilplan_mcp.updater.scheduler import build_scheduler
__all__ = ["build_scheduler"]
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
from datetime import datetime
import json
from pathlib import Path
import re
from typing import Any
import httpx
from civilplan_mcp.config import get_settings
LOG_FILE_NAME = "update_log.json"
def flag_manual_update_required(update_type: str, message: str, data_dir: Path | None = None) -> Path:
target_dir = data_dir or get_settings().data_dir
target_dir.mkdir(parents=True, exist_ok=True)
flag_path = target_dir / f".update_required_{update_type}"
flag_path.write_text(f"{datetime.now():%Y-%m-%d} {message}", encoding="utf-8")
return flag_path
def clear_update_flag(update_type: str, data_dir: Path | None = None) -> None:
target_dir = data_dir or get_settings().data_dir
flag_path = target_dir / f".update_required_{update_type}"
if flag_path.exists():
flag_path.unlink()
def check_update_flags(data_dir: Path | None = None) -> list[str]:
target_dir = data_dir or get_settings().data_dir
warnings: list[str] = []
for flag in target_dir.glob(".update_required_*"):
warnings.append(flag.read_text(encoding="utf-8"))
return warnings
def read_update_log(data_dir: Path | None = None) -> list[dict[str, Any]]:
target_dir = data_dir or get_settings().data_dir
log_path = target_dir / LOG_FILE_NAME
if not log_path.exists():
return []
return json.loads(log_path.read_text(encoding="utf-8"))
def write_update_log(entry: dict[str, Any], data_dir: Path | None = None) -> Path:
target_dir = data_dir or get_settings().data_dir
target_dir.mkdir(parents=True, exist_ok=True)
log_path = target_dir / LOG_FILE_NAME
log_data = read_update_log(target_dir)
log_data.append(entry)
log_path.write_text(json.dumps(log_data, ensure_ascii=False, indent=2), encoding="utf-8")
return log_path
def fetch_source_text(url: str) -> str:
response = httpx.get(
url,
timeout=30,
headers={"User-Agent": "CivilPlan-MCP/1.0 (+https://localhost:8765/mcp)"},
)
response.raise_for_status()
return response.text
def extract_marker(text: str, patterns: list[str]) -> str | None:
for pattern in patterns:
match = re.search(pattern, text, flags=re.IGNORECASE)
if match:
return match.group(0)
return None
def build_log_entry(
*,
update_type: str,
period: str | None,
status: str,
source_url: str,
detail: str,
marker: str | None = None,
) -> dict[str, Any]:
return {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"update_type": update_type,
"period": period,
"status": status,
"source_url": source_url,
"marker": marker,
"detail": detail,
}
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from civilplan_mcp.updater.standard_updater import update_standard_prices
from civilplan_mcp.updater.wage_updater import update_wage_rates
from civilplan_mcp.updater.waste_updater import update_waste_prices
def build_scheduler(*, start: bool = False) -> BackgroundScheduler:
scheduler = BackgroundScheduler(timezone="Asia/Seoul")
scheduler.add_job(update_wage_rates, CronTrigger(month="1", day="2", hour="9"), id="wage_h1", kwargs={"period": "상반기"})
scheduler.add_job(update_waste_prices, CronTrigger(month="1", day="2", hour="9"), id="waste_annual")
scheduler.add_job(update_standard_prices, CronTrigger(month="1", day="2", hour="9"), id="standard_h1", kwargs={"period": "상반기"})
scheduler.add_job(update_standard_prices, CronTrigger(month="7", day="10", hour="9"), id="standard_h2", kwargs={"period": "하반기"})
scheduler.add_job(update_wage_rates, CronTrigger(month="9", day="2", hour="9"), id="wage_h2", kwargs={"period": "하반기"})
if start:
scheduler.start()
return scheduler
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from typing import Any
from civilplan_mcp.config import get_settings
from civilplan_mcp.updater.common import (
build_log_entry,
clear_update_flag,
extract_marker,
fetch_source_text,
flag_manual_update_required,
write_update_log,
)
STANDARD_SOURCE_URL = "https://www.molit.go.kr/portal.do"
STANDARD_MARKER_PATTERNS = [
r"표준시장단가",
r"제비율",
r"20\d{2}",
]
def update_standard_prices(period: str = "상반기") -> dict[str, Any]:
data_dir = get_settings().data_dir
try:
text = fetch_source_text(STANDARD_SOURCE_URL)
marker = extract_marker(text, STANDARD_MARKER_PATTERNS)
if not marker:
detail = f"Fetched standard-cost source, but no recognizable bulletin marker was found for {period}."
flag_manual_update_required("standard", detail, data_dir=data_dir)
write_update_log(
build_log_entry(
update_type="standard",
period=period,
status="pending_manual_review",
source_url=STANDARD_SOURCE_URL,
detail=detail,
),
data_dir=data_dir,
)
return {"status": "pending_manual_review", "period": period, "source_url": STANDARD_SOURCE_URL}
clear_update_flag("standard", data_dir=data_dir)
write_update_log(
build_log_entry(
update_type="standard",
period=period,
status="fetched",
source_url=STANDARD_SOURCE_URL,
detail="Recognized standard-cost bulletin marker from source page.",
marker=marker,
),
data_dir=data_dir,
)
return {
"status": "fetched",
"period": period,
"marker": marker,
"source_url": STANDARD_SOURCE_URL,
}
except Exception as exc:
detail = f"Standard update fetch failed for {period}: {exc}"
flag_manual_update_required("standard", detail, data_dir=data_dir)
write_update_log(
build_log_entry(
update_type="standard",
period=period,
status="pending_manual_review",
source_url=STANDARD_SOURCE_URL,
detail=detail,
),
data_dir=data_dir,
)
return {
"status": "pending_manual_review",
"period": period,
"message": detail,
"source_url": STANDARD_SOURCE_URL,
}
+1
View File
@@ -0,0 +1 @@
[]
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from civilplan_mcp.config import get_settings
from civilplan_mcp.updater.common import (
build_log_entry,
check_update_flags,
clear_update_flag,
extract_marker,
fetch_source_text,
flag_manual_update_required,
write_update_log,
)
WAGE_SOURCE_URL = "https://gwangju.cak.or.kr/lay1/bbs/S1T41C42/A/14/list.do"
WAGE_MARKER_PATTERNS = [
r"20\d{2}\s*(상반기|하반기)\s*적용\s*건설업\s*임금실태조사",
r"20\d{2}\s*건설업\s*임금실태조사",
]
def update_wage_rates(period: str = "상반기") -> dict[str, Any]:
data_dir = get_settings().data_dir
try:
text = fetch_source_text(WAGE_SOURCE_URL)
marker = extract_marker(text, WAGE_MARKER_PATTERNS)
if not marker:
detail = f"Fetched wage source, but no recognizable wage bulletin marker was found for {period}."
flag_manual_update_required("wage", detail, data_dir=data_dir)
write_update_log(
build_log_entry(
update_type="wage",
period=period,
status="pending_manual_review",
source_url=WAGE_SOURCE_URL,
detail=detail,
),
data_dir=data_dir,
)
return {"status": "pending_manual_review", "period": period, "source_url": WAGE_SOURCE_URL}
clear_update_flag("wage", data_dir=data_dir)
write_update_log(
build_log_entry(
update_type="wage",
period=period,
status="fetched",
source_url=WAGE_SOURCE_URL,
detail="Recognized wage bulletin marker from source page.",
marker=marker,
),
data_dir=data_dir,
)
return {
"status": "fetched",
"period": period,
"marker": marker,
"source_url": WAGE_SOURCE_URL,
}
except Exception as exc:
detail = f"Wage update fetch failed for {period}: {exc}"
flag_manual_update_required("wage", detail, data_dir=data_dir)
write_update_log(
build_log_entry(
update_type="wage",
period=period,
status="pending_manual_review",
source_url=WAGE_SOURCE_URL,
detail=detail,
),
data_dir=data_dir,
)
return {
"status": "pending_manual_review",
"period": period,
"message": detail,
"source_url": WAGE_SOURCE_URL,
}
__all__ = ["check_update_flags", "flag_manual_update_required", "update_wage_rates"]
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Any
from civilplan_mcp.config import get_settings
from civilplan_mcp.updater.common import (
build_log_entry,
clear_update_flag,
extract_marker,
fetch_source_text,
flag_manual_update_required,
write_update_log,
)
WASTE_SOURCE_URL = "https://www.data.go.kr/data/15052266/fileData.do"
WASTE_MARKER_PATTERNS = [
r"20\d{2}[-./]\d{2}[-./]\d{2}",
r"20\d{2}",
]
def update_waste_prices() -> dict[str, Any]:
data_dir = get_settings().data_dir
try:
text = fetch_source_text(WASTE_SOURCE_URL)
marker = extract_marker(text, WASTE_MARKER_PATTERNS)
if not marker:
detail = "Fetched waste source, but no recognizable release marker was found."
flag_manual_update_required("waste", detail, data_dir=data_dir)
write_update_log(
build_log_entry(
update_type="waste",
period=None,
status="pending_manual_review",
source_url=WASTE_SOURCE_URL,
detail=detail,
),
data_dir=data_dir,
)
return {"status": "pending_manual_review", "source_url": WASTE_SOURCE_URL}
clear_update_flag("waste", data_dir=data_dir)
write_update_log(
build_log_entry(
update_type="waste",
period=None,
status="fetched",
source_url=WASTE_SOURCE_URL,
detail="Recognized release marker from waste source page.",
marker=marker,
),
data_dir=data_dir,
)
return {"status": "fetched", "marker": marker, "source_url": WASTE_SOURCE_URL}
except Exception as exc:
detail = f"Waste update fetch failed: {exc}"
flag_manual_update_required("waste", detail, data_dir=data_dir)
write_update_log(
build_log_entry(
update_type="waste",
period=None,
status="pending_manual_review",
source_url=WASTE_SOURCE_URL,
detail=detail,
),
data_dir=data_dir,
)
return {
"status": "pending_manual_review",
"message": detail,
"source_url": WASTE_SOURCE_URL,
}