From 3a8edebfef4146a8588c46077d87e10352792f23 Mon Sep 17 00:00:00 2001 From: Andrew Yim Date: Thu, 28 May 2026 00:24:33 +0900 Subject: [PATCH] estimate.py: scale local_seo/onpage_entity by portfolio size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add configurable sub-linear scope-scaling bands to rate_card.yaml; estimate.py now multiplies monthly line-item rates by properties_total (local_seo) and subbrands_total (onpage_entity), with the scope note written into the 견적. Validated: L'Escape (1 property) stays at base 23-47M; SHR (25 properties, 5 sub-brands) scales to 54.8-110.6M (local ×4.5, on-page ×2.2). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../references/rate_card.yaml | 12 ++++++ .../scripts/estimate.py | 39 ++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml b/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml index 08b6be6..19f593e 100644 --- a/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml +++ b/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml @@ -49,3 +49,15 @@ services: defaults: retainer_months: 6 # default contract length for monthly line items disclaimer_ko: "본 견적은 공개 데이터 기반 사전 추정 범위이며, Search Console/Analytics 권한 확보 후 정밀 진단을 통해 확정됩니다." + +# Scope scaling — monthly line items scale (sub-linearly) by portfolio size. +# driver: a count under findings.entity (properties_total | subbrands_total). +# bands: ordered [max_count, multiplier]; first band whose max_count >= count wins. +# A 25-property chain costs more to run than a single hotel, but not 25x. +scaling: + local_seo: + driver: properties_total + bands: [[1, 1.0], [5, 1.6], [15, 2.8], [30, 4.5], [999999, 6.5]] + onpage_entity: + driver: subbrands_total + bands: [[1, 1.0], [3, 1.6], [6, 2.2], [999999, 3.2]] diff --git a/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py b/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py index 3b99572..90497f4 100755 --- a/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py +++ b/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py @@ -71,7 +71,26 @@ def select_services(f): return chosen -def build_line_items(chosen, rate): +DRIVER_LABEL = {"properties_total": "프로퍼티", "subbrands_total": "서브브랜드"} + + +def scope_multiplier(rate, key, f): + """Sub-linear scope multiplier for a service, driven by portfolio size. + + Returns (multiplier, driver, count). count is floored at 1 (unknown→base). + """ + rule = rate.get("scaling", {}).get(key) + if not rule: + return 1.0, None, None + driver = rule["driver"] + count = max(int(f.get("entity", {}).get(driver, 0) or 0), 1) + for max_count, mult in rule["bands"]: + if count <= max_count: + return float(mult), driver, count + return 1.0, driver, count + + +def build_line_items(chosen, rate, f): months = rate["defaults"]["retainer_months"] order = {"one_time": 0, "project": 1, "monthly": 2} items = [] @@ -79,11 +98,21 @@ def build_line_items(chosen, rate): svc = rate["services"][key] unit = svc["unit"] qty = months if unit == "monthly" else 1 + mult, driver, count = scope_multiplier(rate, key, f) + umin = int(round(svc["min"] * mult)) + umax = int(round(svc["max"] * mult)) + reason = "; ".join(reasons) + scope_note = None + if mult != 1.0: + scope_note = f"{DRIVER_LABEL.get(driver, driver)} {count}개 기준 ×{mult:g}" + reason = f"{reason} [{scope_note}]" items.append({ "key": key, "label": svc["label_ko"], "unit": unit, "qty": qty, - "unit_min": svc["min"], "unit_max": svc["max"], - "amount_min": svc["min"] * qty, "amount_max": svc["max"] * qty, - "reason": "; ".join(reasons), + "unit_min": umin, "unit_max": umax, + "amount_min": umin * qty, "amount_max": umax * qty, + "reason": reason, + "scope_multiplier": mult, "scope_driver": driver, + "scope_count": count, "scope_note": scope_note, }) items.sort(key=lambda x: order.get(x["unit"], 9)) return items, months @@ -175,7 +204,7 @@ def main(): disclaimer = rate["defaults"]["disclaimer_ko"] chosen = select_services(f) - items, months = build_line_items(chosen, rate) + items, months = build_line_items(chosen, rate, f) tot = totals(items) os.makedirs(args.out_dir, exist_ok=True)