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>
This commit is contained in:
2026-05-28 01:54:11 +09:00
parent 34c3a1df4f
commit c9bdbb57f7
20 changed files with 822 additions and 349 deletions

View File

@@ -259,7 +259,7 @@ def main():
tbl = s.shapes.add_table(rows, 2, Inches(0.85), Inches(2.0), Inches(11.6), Inches(0.5 * rows)).table
tbl.columns[0].width = Inches(8.4)
tbl.columns[1].width = Inches(3.2)
for j, htxt in enumerate([f"{EST.get('service', 'SEO')} — 구분", "소계"]):
for j, htxt in enumerate([f"{EST.get('label') or EST.get('service', 'SEO')} — 구분", "소계"]):
c = tbl.cell(0, j)
c.text = htxt
c.fill.solid()

View File

@@ -1,247 +0,0 @@
#!/usr/bin/env python3
"""Effort-based 견적 generator for the ourdigital-presales-seo skill (Stage 5).
Reproduces OurDigital/D.intelligence's real quoting model:
cost(task) = role_rate × billing_rate × standard_hours
assembled from sow_templates.yaml, grouped into modules, summed, then
제안가 = 합계 floored to rounding_unit (십만단위 절사).
findings.json selects the baseline (basic vs treatment) and scales scoped
task hours sub-linearly by portfolio size. Outputs:
- 05_estimate_ko.md (cover-sheet 견적)
- data/estimate.json (consumed by build_deck.py)
- 05_estimate.xlsx
Usage:
python estimate.py --findings data/findings.json \
--rate-card ../references/rate_card.yaml --sow ../references/sow_templates.yaml \
--out-dir <engagement> --seq 1 [--baseline basic|treatment] [--billing 0.70]
"""
import argparse
import datetime
import json
import math
import os
import yaml
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
def won(n):
return f"{int(round(n)):,}"
def scope_multiplier(rate, f):
"""Sub-linear hours multiplier from the configured driver (default subbrands_total)."""
sc = rate.get("scaling", {})
driver = sc.get("driver", "subbrands_total")
bands = sc.get("bands", [[1, 1.0]])
count = max(int(f.get("entity", {}).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
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(f, rate):
vertical = (f.get("prospect", {}).get("vertical") or "").lower()
terms = [t.lower() for t in rate.get("tiering", {}).get("premium_verticals", [])]
return any(t in vertical for t in terms)
def pick_baseline(f, override, rate):
if override:
return override
e = f.get("entity", {})
props = e.get("properties_total", 0) or 0
subs = e.get("subbrands_total", 0) or 0
if props > 5 or subs > 3: # multi-brand / chain
tier = "treatment"
elif props <= 1 and subs == 0: # single property
tier = "smb"
else: # small multi-property / mid
tier = "basic"
# premium/luxury floor: don't auto-drop a premium prospect to the entry tier
if is_premium(f, rate):
tier = _higher(tier, rate.get("tiering", {}).get("premium_min_tier", "basic"))
return tier
def assemble(f, rate, sow, baseline, billing):
roles = rate["role_rates"]
mult, driver, dcount = scope_multiplier(rate, f)
tpl = sow["baselines"][baseline]
modules = []
grand = 0.0
for mod in tpl["modules"]:
tasks, sub = [], 0.0
for t in mod["tasks"]:
applied = mult if (t.get("scale") and mult != 1.0) else 1.0
hours = round(t["hours"] * applied, 1)
rate_hr = roles[t["role"]]
amount = rate_hr * billing * hours
tasks.append({
"task": t["task"], "desc": t.get("desc", ""), "role": t["role"],
"role_rate": rate_hr, "hours": hours, "amount": amount,
"scaled": applied != 1.0,
})
sub += amount
modules.append({"name": mod["name"], "subtotal": sub, "tasks": tasks})
grand += sub
return modules, grand, mult, driver, dcount, tpl["service"]
ROLE_KO = {
"ceo": "대표", "evp": "전무", "svp": "상무", "technical_advisor": "기술고문",
"director": "이사", "senior_manager": "부장", "deputy_manager": "차장",
"manager": "과장", "assistant_manager": "대리", "junior": "주임",
"associate": "사원", "intern": "인턴",
}
def write_md(path, q):
L = [f"# 견적서 — {q['prospect']}",
"", f"- **제공 서비스**: {q['service']}",
f"- **견적번호**: {q['quote_no']} · **작성일**: {q['date']} · **유효기간**: ~{q['valid_until']}",
f"- **공급자**: {q['company']['legal_name']} (대표 {q['company']['ceo']}, {q['company']['contact']})",
f"- **산정 기준**: SOW 기반 · 청구율 {int(q['billing_rate']*100)}% · 일 8시간/월 4주 · {q['terms']['vat']} · 지급 {q['terms']['payment']}",
""]
if q["scope"]["hours_multiplier"] != 1.0:
dl = "브랜드/템플릿" if q["scope"]["driver"] == "subbrands_total" else "프로퍼티"
L.append(f"> 규모 반영: {dl} {q['scope']['driver_count']}개 기준 On-page 업무시간 ×{q['scope']['hours_multiplier']:g} (서브선형)")
L.append("")
L += ["## 견적 내역", "",
"| 구분 | 세부 업무 | 담당 | 시간(h) | 합계 |",
"|---|---|:--:|--:|--:|"]
for m in q["modules"]:
for i, t in enumerate(m["tasks"]):
grp = m["name"] if i == 0 else ""
mark = " *" if t["scaled"] else ""
L.append(f"| {grp} | {t['task']}{mark} | {ROLE_KO.get(t['role'], t['role'])} | {t['hours']:g} | {won(t['amount'])} |")
L.append(f"| | **{m['name']} 소계** | | | **{won(m['subtotal'])}** |")
L += ["", "## 합계", "",
f"- 합계: **{won(q['subtotal_sum'])}**",
f"- **제안가(절사 적용): {won(q['proposal'])}** ({q['terms']['vat']})", ""]
if q.get("tools"):
L += ["## 별도 비용 (조달 — 인력비와 별도)", ""]
for t in q["tools"]:
L.append(f"- {t['label']}: {t['unit']} ${t['price_usd']}{t['note']}")
L.append("")
L += ["---", f"> {q['disclaimer']}"]
if any(t["scaled"] for m in q["modules"] for t in m["tasks"]):
L.append("> \\* 포트폴리오 규모에 따라 업무시간이 스케일된 항목.")
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(L) + "\n")
def write_xlsx(path, q):
wb = Workbook()
ws = wb.active
ws.title = "견적"
ws.append([f"견적서 — {q['prospect']} ({q['service']})"])
ws.append([f"견적번호 {q['quote_no']}", f"작성일 {q['date']}", f"유효 ~{q['valid_until']}",
f"청구율 {int(q['billing_rate']*100)}%", q["terms"]["vat"]])
ws.append([])
cols = ["구분", "세부 업무", "담당", "시간(h)", "합계(원)"]
ws.append(cols)
hdr_row = ws.max_row
for c in range(1, len(cols) + 1):
cell = ws.cell(row=hdr_row, column=c)
cell.fill = PatternFill("solid", fgColor="11243D")
cell.font = Font(color="FFFFFF", bold=True)
for m in q["modules"]:
for i, t in enumerate(m["tasks"]):
ws.append([m["name"] if i == 0 else "", t["task"], ROLE_KO.get(t["role"], t["role"]),
t["hours"], int(round(t["amount"]))])
ws.append(["", f"{m['name']} 소계", "", "", int(round(m["subtotal"]))])
ws.cell(row=ws.max_row, column=2).font = Font(bold=True)
ws.cell(row=ws.max_row, column=5).font = Font(bold=True)
ws.append([])
ws.append(["", "합계", "", "", int(round(q["subtotal_sum"]))])
ws.append(["", "제안가(절사 적용)", "", "", int(q["proposal"])])
ws.cell(row=ws.max_row, column=2).font = Font(bold=True)
ws.cell(row=ws.max_row, column=5).font = Font(bold=True, color="C0392B")
ws.append([])
ws.append([q["disclaimer"]])
for idx, w in enumerate([22, 40, 8, 8, 16], 1):
ws.column_dimensions[chr(64 + idx)].width = w
wb.save(path)
def main():
ap = argparse.ArgumentParser(description="Effort-based 견적 from findings.json")
ap.add_argument("--findings", required=True)
ap.add_argument("--rate-card", required=True)
ap.add_argument("--sow", required=True)
ap.add_argument("--out-dir", default=".")
ap.add_argument("--seq", type=int, default=1)
ap.add_argument("--baseline", choices=["smb", "basic", "treatment"], default=None)
ap.add_argument("--billing", type=float, default=None)
args = ap.parse_args()
with open(args.findings, encoding="utf-8") as fh:
f = json.load(fh)
with open(args.rate_card, encoding="utf-8") as fh:
rate = yaml.safe_load(fh)
with open(args.sow, encoding="utf-8") as fh:
sow = yaml.safe_load(fh)
baseline = pick_baseline(f, args.baseline, rate)
tpl_billing = sow["baselines"][baseline].get("billing_rate")
billing = (args.billing if args.billing is not None
else tpl_billing if tpl_billing is not None else rate["billing_rate"])
modules, grand, mult, driver, dcount, service = assemble(f, rate, sow, baseline, billing)
props = f.get("entity", {}).get("properties_total", 0)
subs = f.get("entity", {}).get("subbrands_total", 0)
rounding = rate["rounding_unit"]
proposal = int(math.floor(grand / rounding) * rounding)
date = f.get("prospect", {}).get("audit_date") or datetime.date.today().isoformat()
d0 = datetime.date.fromisoformat(date)
valid_until = (d0 + datetime.timedelta(days=rate["terms"]["validity_days"])).isoformat()
quote_no = f"{rate.get('quote_prefix', 'OD')}-{date[:4]}-{args.seq:03d}"
disclaimer = ("본 견적은 공개 데이터 기반 사전 추정이며 표준 업무시간(SOW)·청구율 "
f"{int(billing*100)}% 기준입니다. Search Console/Analytics 권한 확보 후 정밀 "
"진단을 통해 과업 시간과 범위를 확정합니다. 외부 조달 항목은 인력비와 별도이며 조달 수수료 15%가 적용될 수 있습니다.")
q = {
"quote_no": quote_no, "date": date, "valid_until": valid_until,
"prospect": f.get("prospect", {}).get("name", "(prospect)"),
"service": service, "baseline": baseline, "billing_rate": billing,
"company": rate["company"], "terms": rate["terms"],
"scope": {"driver": driver, "driver_count": dcount,
"properties_total": props, "subbrands_total": subs,
"hours_multiplier": mult},
"modules": modules, "subtotal_sum": grand, "proposal": proposal,
"rounding_unit": rounding,
"tools": [dict(label=v["label"], unit=v["unit"], price_usd=v["price_usd"], note=v["note"])
for v in rate.get("tools", {}).values()],
"disclaimer": disclaimer,
}
os.makedirs(args.out_dir, exist_ok=True)
data_dir = os.path.join(args.out_dir, "data")
os.makedirs(data_dir, exist_ok=True)
write_md(os.path.join(args.out_dir, "05_estimate_ko.md"), q)
write_xlsx(os.path.join(args.out_dir, "05_estimate.xlsx"), q)
with open(os.path.join(data_dir, "estimate.json"), "w", encoding="utf-8") as fh:
json.dump(q, fh, ensure_ascii=False, indent=2)
print(f"견적 {quote_no} [{baseline}] 제안가 {won(proposal)} (합계 {won(grand)}) "
f"| {driver}={dcount} ×{mult:g} | 청구율 {int(billing*100)}%")
for m in modules:
print(f" {m['name']:24} {won(m['subtotal'])}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Map an SEO findings.json → estimate-engine scope.json (service=seo, effort).
Stage 5 of ourdigital-presales-seo: keeps the SEO-specific mapping here; the engine
(96-ourdigital-estimate-engine) owns the costing. Then call:
python <engine>/scripts/estimate.py --rate-card <engine>/references/rate_card.yaml \
--catalog-dir <engine>/catalog --scope scope.json --out-dir <engagement>
Usage:
python findings_to_scope.py --findings data/findings.json --out data/scope.json [--tier auto] [--seq N]
"""
import argparse
import json
def main():
ap = argparse.ArgumentParser(description="SEO findings.json -> engine scope.json")
ap.add_argument("--findings", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--tier", default="auto", help="auto|smb|basic|treatment")
ap.add_argument("--billing", type=float, default=None)
ap.add_argument("--seq", type=int, default=1)
args = ap.parse_args()
with open(args.findings, encoding="utf-8") as fh:
f = json.load(fh)
e = f.get("entity", {})
p = f.get("prospect", {})
severity = sorted({x.get("severity") for x in f.get("findings", []) if x.get("severity")})
scope = {
"service": "seo",
"method": "effort",
"tier": args.tier,
"billing_rate": args.billing,
"signals": {
"properties_total": e.get("properties_total", 0),
"subbrands_total": e.get("subbrands_total", 0),
"vertical": p.get("vertical", ""),
"severity": severity,
},
"prospect": {
"name": p.get("name", ""), "domain": p.get("domain", ""),
"audit_date": p.get("audit_date", ""), "account_code": p.get("account_code", ""),
},
"seq": args.seq,
}
with open(args.out, "w", encoding="utf-8") as fh:
json.dump(scope, fh, ensure_ascii=False, indent=2)
print(f"scope -> {args.out} | service=seo tier={args.tier} "
f"properties={scope['signals']['properties_total']} subbrands={scope['signals']['subbrands_total']} "
f"vertical={scope['signals']['vertical']!r}")
if __name__ == "__main__":
main()