Initial CivilPlan MCP implementation
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from civilplan_mcp.updater.scheduler import build_scheduler
|
||||
|
||||
__all__ = ["build_scheduler"]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user