Files
our-claude-skills/custom-skills/96-ourdigital-estimate-engine/scripts/methods/effort.py
Andrew Yim c9bdbb57f7 Extract ourdigital-estimate-engine; presales-seo now calls it
New skill 96-ourdigital-estimate-engine: method-aware quoting engine
(effort / coaching / procurement) with universal rate_card + per-service
catalog. Real catalogs: seo (effort), education (coaching); stubs:
digital_ads, digital_branding. Validated to reproduce real quotes —
SEO basic ₩10.5M / treatment ₩25.0M, SHR chain ₩29.5M, L'Escape basic
₩10.5M, GA4/GTM coaching ₩1,570,000, procurement +15%.

Refactor 95-ourdigital-presales-seo: remove rate_card.yaml, sow_templates.yaml,
estimate.py (migrated to engine); add findings_to_scope.py; Stage 5 now maps
findings→scope.json and calls the engine CLI. build_deck/kg_query unchanged;
end-to-end validated on SHR (29.5M) + deck renders engine estimate.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:54:11 +09:00

85 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Effort method: cost = role_rate × billing_rate × hours, grouped by module.
Tier auto-selection (size + premium-vertical floor) and sub-brand hours scaling
are driven by rate_card config. Reproduces real SEO quotes (10.5M/25.0M).
"""
import math
TIER_ORDER = {"smb": 0, "basic": 1, "treatment": 2}
def _higher(a, b):
return a if TIER_ORDER.get(a, 0) >= TIER_ORDER.get(b, 0) else b
def _is_premium(signals, rate):
v = (signals.get("vertical") or "").lower()
return any(t.lower() in v for t in rate.get("tiering", {}).get("premium_verticals", []))
def _scope_mult(rate, signals):
sc = rate.get("scaling", {})
driver = sc.get("driver", "subbrands_total")
bands = sc.get("bands", [[1, 1.0]])
count = max(int(signals.get(driver, 0) or 0), 1)
for mx, m in bands:
if count <= mx:
return float(m), driver, count
return float(bands[-1][1]), driver, count
def _pick_tier(signals, rate, available):
props = signals.get("properties_total", 0) or 0
subs = signals.get("subbrands_total", 0) or 0
if props > 5 or subs > 3:
tier = "treatment"
elif props <= 1 and subs == 0:
tier = "smb"
else:
tier = "basic"
if _is_premium(signals, rate):
tier = _higher(tier, rate.get("tiering", {}).get("premium_min_tier", "basic"))
if tier not in available:
tier = "basic" if "basic" in available else sorted(available, key=lambda t: TIER_ORDER.get(t, 9))[0]
return tier
def build(scope, rate, catalog):
tiers = catalog["tiers"]
signals = scope.get("signals", {})
tier = scope.get("tier") or "auto"
if tier == "auto":
tier = _pick_tier(signals, rate, set(tiers))
if tier not in tiers:
raise SystemExit(f"tier '{tier}' not in catalog tiers {list(tiers)}")
t = tiers[tier]
billing = scope.get("billing_rate") or t.get("billing_rate") or rate["billing_rate"]
mult, driver, dcount = _scope_mult(rate, signals)
roles = rate["role_rates"]
modules, grand = [], 0.0
for mod in t["modules"]:
tasks, sub = [], 0.0
for task in mod["tasks"]:
applied = mult if (task.get("scale") and mult != 1.0) else 1.0
hours = round(task["hours"] * applied, 1)
rr = roles[task["role"]]
amt = rr * billing * hours
tasks.append({"task": task["task"], "desc": task.get("desc", ""), "role": task["role"],
"role_rate": rr, "hours": hours, "amount": amt, "scaled": applied != 1.0})
sub += amt
modules.append({"name": mod["name"], "subtotal": sub, "tasks": tasks})
grand += sub
rounding = rate["rounding_unit"]
proposal = int(math.floor(grand / rounding) * rounding)
return {
"kind": "effort", "service": catalog["service"], "label": t.get("label", catalog["service"]),
"tier": tier, "billing_rate": billing,
"scope": {"driver": driver, "driver_count": dcount,
"properties_total": signals.get("properties_total", 0),
"subbrands_total": signals.get("subbrands_total", 0), "hours_multiplier": mult},
"modules": modules, "subtotal_sum": grand, "proposal": proposal,
"rounding_unit": rounding, "stub": bool(catalog.get("_stub", False)),
}