Recalibrate estimate for SMB acceptability

Real-world feedback: list-rate calc overshot SMB-acceptable levels.
- scaling: driver properties_total -> subbrands_total (chains share templates),
  cap x6.5 -> x2.0, applied to On-page only (Technical now fixed site-wide)
- add 'smb' entry tier (lean hours @ 0.55 billing); 3-tier auto-select
  (smb/basic/treatment) by portfolio size; per-tier billing; --baseline smb enabled
- docs (findings_to_service.md, SKILL.md) synced to 3-tier model

Effect: SHR 25-property chain 71.5M -> 29.5M; SMB single hotel ~3.0M;
basic/treatment still reproduce real 10.5M/25.0M at 1 property.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 01:13:38 +09:00
parent e519a49cc4
commit 60734dbde7
6 changed files with 107 additions and 60 deletions

View File

@@ -56,9 +56,9 @@ headless Chrome, python-pptx. Create `data/` subfolder. Initialize `findings.jso
## Stage 5 — Estimate (견적) — REVIEW GATE ## Stage 5 — Estimate (견적) — REVIEW GATE
- `python scripts/estimate.py --findings <out>/data/findings.json --rate-card references/rate_card.yaml --sow references/sow_templates.yaml --out-dir <out> --seq <N> [--baseline basic|treatment] [--billing 0.70]` - `python scripts/estimate.py --findings <out>/data/findings.json --rate-card references/rate_card.yaml --sow references/sow_templates.yaml --out-dir <out> --seq <N> [--baseline basic|treatment] [--billing 0.70]`
- **Effort-based** (OurDigital real model): cost = role_rate × 청구율 70% × 표준 업무시간, by module; 제안가 = 합계 절사. findings auto-select baseline (basic/treatment) and scale Technical/On-page hours by `properties_total`. - **Effort-based** (OurDigital real model): cost = role_rate × 청구율 × 표준 업무시간, by module; 제안가 = 합계 절사. findings auto-select a tier (**smb / basic / treatment**) and scale **On-page** hours sub-linearly by `subbrands_total` (cap ×2.0); Technical is fixed. `smb` tier bills at 0.55, others 0.70. Override with `--baseline` / `--billing` (quote premium single properties as basic/treatment manually).
- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`. Present the 견적; get sign-off. - Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`. Present the 견적; get sign-off.
- Logic in `references/findings_to_service.md`; rates/hours in `rate_card.yaml` + `sow_templates.yaml` (edit together). Reproduces real Basic ₩10.5M / Treatment ₩25.0M quotes. - Logic in `references/findings_to_service.md`; rates/hours in `rate_card.yaml` + `sow_templates.yaml` (edit together). Reproduces real Basic ₩10.5M / Treatment ₩25.0M; SMB entry ~₩3M; chains stay SMB-acceptable (e.g. 25-property → ~₩29.5M).
## Stage 6 — Deliverables — REVIEW GATE before send ## Stage 6 — Deliverables — REVIEW GATE before send
- **Client PDF**: author the short brief HTML from `templates/client_brief.html` (fill the content; keep the CSS), - **Client PDF**: author the short brief HTML from `templates/client_brief.html` (fill the content; keep the CSS),

View File

@@ -1,19 +1,22 @@
# Findings → estimate mapping (effort-based) # Findings → estimate mapping (effort-based, SMB-aware)
`estimate.py` builds the 견적 from `sow_templates.yaml` priced via `rate_card.yaml`: `estimate.py` builds the 견적 from `sow_templates.yaml` priced via `rate_card.yaml`:
**cost = role_rate × billing_rate (0.70) × standard_hours**, grouped by module; **cost = role_rate × billing_rate × standard_hours**, grouped by module; 제안가 = 합계
제안가 = 합계 floored to `rounding_unit`. This mirrors OurDigital/D.intelligence's floored to `rounding_unit`. Mirrors OurDigital/D.intelligence's real SOW-based quoting.
real SOW-based quoting.
## Baseline selection (basic vs treatment) ## Tier selection (smb / basic / treatment)
- `treatment` if any finding `severity == critical` **OR** `entity.properties_total > 3` - `treatment` if `properties_total > 5` OR `subbrands_total > 3` (multi-brand / chain)
- else `basic` - `smb` if `properties_total <= 1` AND `subbrands_total == 0` (single-property SMB)
- override with `--baseline`. - `basic` otherwise (small multi-property / mid)
- override with `--baseline`. **Note:** tiering is by portfolio size only — quote a
single *premium* property as `basic`/`treatment` manually.
Per-tier billing: `smb` uses `billing_rate: 0.55` (set in sow_templates); `basic`/
`treatment` use `rate_card.billing_rate` (0.70). Override with `--billing`.
## Module inclusion ## Module inclusion
Each baseline carries the standard module set (P&M · Technical SEO · On-page SEO · Each tier carries its standard module set (P&M · Technical SEO · On-page SEO ·
SEO Growth), matching real quotes. Findings justify modules via the `trigger` field SEO Growth). Findings annotate modules via the `trigger` field:
in `sow_templates.yaml`:
| Module | trigger finding classes | | Module | trigger finding classes |
|---|---| |---|---|
@@ -23,18 +26,21 @@ in `sow_templates.yaml`:
| SEO Growth | measurement, always | | SEO Growth | measurement, always |
## Hours scaling (portfolio) ## Hours scaling (portfolio)
Tasks marked `scale: true` (Technical SEO + On-page SEO) have their **hours** Only **On-page** tasks (`scale: true`) scale, sub-linearly, by the
multiplied sub-linearly by `entity.properties_total` per `rate_card.scaling.bands`. `rate_card.scaling.driver` (= `subbrands_total`, i.e. distinct brands/templates),
P&M and SEO Growth stay fixed (management/KPI overhead is ~flat). A single-property capped at **×2.0**. Rationale: chains share page templates, so on-page work grows
prospect → ×1.0, which reproduces the real single-site quotes exactly. with template variety, not raw property count. **Technical SEO is fixed** site-wide
work (`scale: false`); P&M and SEO Growth are fixed too. Single-brand → ×1.0.
## Tools & terms ## Tools & terms
`tools` (e.g. SEMrush Guru) are listed separately — client-subscribed, with +15% `tools` (e.g. SEMrush Guru) listed separately — client-subscribed, +15% procurement
procurement markup if billed through us. VAT 별도 · 유효기간 14d · 현금 · 절사 from markup if billed through us. VAT 별도 · 유효기간 14d · 현금 · 절사 from `rate_card.terms`.
`rate_card.terms`.
## Validated reproduction (2026-05-28) ## Validated (2026-05-28)
- `treatment` ×1.0 → 합계 25,340,000 → 제안가 **25,000,000** (real Treatment quote)
- `basic` ×1.0 → 합계 10,612,000 → 제안가 **10,500,000** (real Basic quote) - `basic` ×1.0 → 합계 10,612,000 → 제안가 **10,500,000** (real Basic quote)
- `treatment` ×1.0 → 합계 25,340,000 → 제안가 **25,000,000** (real Treatment quote)
- `smb` single property → ~**3,000,000** (lean × 55% billing)
- chain example (SHR, 5 sub-brands ×1.6, treatment) → ~**29,500,000** (vs naive 71.5M)
Edit `rate_card.yaml` and `sow_templates.yaml` together when rates or standard hours change. Edit `rate_card.yaml` + `sow_templates.yaml` together when rates, hours, tiers, or
scaling change.

View File

@@ -42,11 +42,13 @@ role_rates:
associate: 30000 # 사원 associate: 30000 # 사원
intern: 12000 # 인턴 intern: 12000 # 인턴
# 포트폴리오 규모에 따른 '시간' 스케일(서브선형). scale:true task 에만 적용. # 포트폴리오 규모에 따른 '시간' 스케일(서브선형). On-page(scale:true) task 에만 적용.
# driver: findings.entity 의 카운트. bands: [최대값, 배수]; count <= 최대값 인 첫 밴드. # driver = findings.entity 의 카운트. 체인은 페이지 템플릿을 공유하므로 '프로퍼티 수'가 아닌
# '브랜드/템플릿 수(subbrands_total)'를 기준으로 하고, 캡을 ×2.0 로 낮게 둔다.
# (Technical SEO 는 사이트 단위 고정 작업이므로 스케일하지 않음 — sow_templates 의 scale:false)
scaling: scaling:
driver: properties_total driver: subbrands_total
bands: [[1, 1.0], [5, 1.6], [15, 2.8], [30, 4.5], [999999, 6.5]] bands: [[1, 1.0], [3, 1.3], [6, 1.6], [999999, 2.0]]
# 별도 조달 항목(인력비와 분리). 청구 시 procurement_markup 적용 가능. # 별도 조달 항목(인력비와 분리). 청구 시 procurement_markup 적용 가능.
tools: tools:

View File

@@ -1,13 +1,40 @@
# SOW task templates — standard 업무 시간(hours) by module. # SOW task templates — standard 업무 시간(hours) by module.
# Seeded from the two real OurDigital quotes so estimate.py reproduces them at # Priced via rate_card.yaml: cost = role_rate × billing_rate × hours.
# billing_rate 0.70: # Tiers (제안가 at default billing): smb < basic < treatment.
# basic -> 제안가 ₩10,500,000 (SEO Basic & Coaching, 3개월 프로젝트) # basic -> ₩10,500,000 (real SEO Basic & Coaching quote, billing 0.70)
# treatment -> 제안가 ₩25,000,000 (SEO Audit & Treatment, 월 정기) # treatment -> ₩25,000,000 (real SEO Audit & Treatment quote, billing 0.70)
# task.role references rate_card.role_rates. scale:true → hours scaled by portfolio. # smb -> lean entry tier for single-property SMBs (billing 0.55)
# trigger: finding classes that justify the module (for annotation + selection). # Scaling: only On-page tasks (scale:true) scale by sub-brands/templates
# (rate_card.scaling); Technical SEO is fixed site-wide work. P&M & Growth fixed.
# Each baseline may override billing_rate; else rate_card.billing_rate (0.70).
baselines: baselines:
smb:
service: "SEO Quick Audit (SMB)"
billing_rate: 0.55 # SMB 진입 티어 — 낮은 청구율
modules:
- name: "Planning & Management"
trigger: [always]
tasks:
- {task: "업무 관리·착수", desc: "과업 정의·일정·리포팅", role: senior_manager, hours: 8, scale: false}
- {task: "웹 사이트 분석", desc: "유입·행동·전환 flow 요약 분석", role: senior_manager, hours: 8, scale: false}
- name: "Technical SEO"
trigger: [crawlability, cwv, schema_entity]
tasks:
- {task: "Crawling·Indexing 점검", desc: "검색사이트 등록·수집·Site Health 점검", role: manager, hours: 8, scale: false}
- {task: "속도·CWV 점검", desc: "로딩속도·Core Web Vitals·모바일 점검", role: manager, hours: 8, scale: false}
- {task: "구조·메타·사이트맵 점검", desc: "사이트/URL 구조·메타·사이트맵·색인", role: manager, hours: 8, scale: false}
- name: "On-page SEO"
trigger: [onpage, schema_entity]
tasks:
- {task: "키워드·메타 진단", desc: "중점 키워드·페이지 메타·템플릿 진단", role: manager, hours: 12, scale: true}
- {task: "on-page 퀵윈 가이드", desc: "타이틀·메타·헤더·링크·이미지 핵심 개선 가이드", role: manager, hours: 16, scale: true}
- name: "SEO Growth"
trigger: [measurement, always]
tasks:
- {task: "기본 성과 지표 설정", desc: "핵심 SEO 지표·중점 키워드 트래킹 설정", role: manager, hours: 8, scale: false}
basic: basic:
service: "SEO Audit & Basic Treatment" service: "SEO Audit & Basic Treatment"
modules: modules:
@@ -19,10 +46,10 @@ baselines:
- name: "Technical SEO" - name: "Technical SEO"
trigger: [crawlability, cwv, schema_entity] trigger: [crawlability, cwv, schema_entity]
tasks: tasks:
- {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리, Site Health Check 도구 설정", role: technical_advisor, hours: 16, scale: true} - {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리, Site Health Check 도구 설정", role: technical_advisor, hours: 16, scale: false}
- {task: "속도·UX·수집 설정", desc: "로딩속도·페이지 UX·링크·수집 제외 설정", role: manager, hours: 16, scale: true} - {task: "속도·UX·수집 설정", desc: "로딩속도·페이지 UX·링크·수집 제외 설정", role: manager, hours: 16, scale: false}
- {task: "사이트/URL 구조·메타", desc: "구조·URL·메타데이터·사이트맵·리다이렉션", role: senior_manager, hours: 12, scale: true} - {task: "사이트/URL 구조·메타", desc: "구조·URL·메타데이터·사이트맵·리다이렉션", role: senior_manager, hours: 12, scale: false}
- {task: "색인·CWV 진단", desc: "GSC·SEO Tools 활용 색인/크롤오류/Core Web Vitals 진단", role: senior_manager, hours: 16, scale: true} - {task: "색인·CWV 진단", desc: "GSC·SEO Tools 활용 색인/크롤오류/Core Web Vitals 진단", role: senior_manager, hours: 16, scale: false}
- name: "On-page SEO" - name: "On-page SEO"
trigger: [onpage, schema_entity] trigger: [onpage, schema_entity]
tasks: tasks:
@@ -46,10 +73,10 @@ baselines:
- name: "Technical SEO" - name: "Technical SEO"
trigger: [crawlability, cwv, schema_entity] trigger: [crawlability, cwv, schema_entity]
tasks: tasks:
- {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리, Site Health Check 도구 설정", role: manager, hours: 24, scale: true} - {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리, Site Health Check 도구 설정", role: manager, hours: 24, scale: false}
- {task: "속도·UX·리다이렉트", desc: "로딩속도·페이지 UX·링크·리다이렉트·수집 제외 설정", role: technical_advisor, hours: 30, scale: true} - {task: "속도·UX·리다이렉트", desc: "로딩속도·페이지 UX·링크·리다이렉트·수집 제외 설정", role: technical_advisor, hours: 30, scale: false}
- {task: "사이트/URL 구조·보안", desc: "구조·URL·메타데이터·사이트맵·보안 관리 진단", role: senior_manager, hours: 24, scale: true} - {task: "사이트/URL 구조·보안", desc: "구조·URL·메타데이터·사이트맵·보안 관리 진단", role: senior_manager, hours: 24, scale: false}
- {task: "모바일·CWV·개선과제", desc: "모바일 최적화·Core Web Vitals 진단·개선 과제 도출", role: manager, hours: 20, scale: true} - {task: "모바일·CWV·개선과제", desc: "모바일 최적화·Core Web Vitals 진단·개선 과제 도출", role: manager, hours: 20, scale: false}
- name: "On-page SEO" - name: "On-page SEO"
trigger: [onpage, schema_entity] trigger: [onpage, schema_entity]
tasks: tasks:

View File

@@ -286,7 +286,8 @@ def main():
sc = EST.get("scope", {}) sc = EST.get("scope", {})
note = f"청구율 {int(EST.get('billing_rate', 0.7) * 100)}% · 일8h/월4주 · SOW 기반" note = f"청구율 {int(EST.get('billing_rate', 0.7) * 100)}% · 일8h/월4주 · SOW 기반"
if sc.get("hours_multiplier", 1.0) != 1.0: if sc.get("hours_multiplier", 1.0) != 1.0:
note += f" · 프로퍼티 {sc.get('properties_total')}×{sc['hours_multiplier']:g}" dl = "브랜드/템플릿" if sc.get("driver") == "subbrands_total" else "프로퍼티"
note += f" · {dl} {sc.get('driver_count')}×{sc['hours_multiplier']:g}"
textbox(s, 0.85, 2.2 + 0.5 * rows, 11.6, 1.3, [ textbox(s, 0.85, 2.2 + 0.5 * rows, 11.6, 1.3, [
[(note, 10, GREY, False)], [(note, 10, GREY, False)],
[(EST.get("disclaimer", ""), 9, GREY, False)], [(EST.get("disclaimer", ""), 9, GREY, False)],

View File

@@ -32,28 +32,34 @@ def won(n):
return f"{int(round(n)):,}" return f"{int(round(n)):,}"
def scope_multiplier(rate, count): def scope_multiplier(rate, f):
"""Sub-linear hours multiplier from the configured driver (default subbrands_total)."""
sc = rate.get("scaling", {}) sc = rate.get("scaling", {})
driver = sc.get("driver", "subbrands_total")
bands = sc.get("bands", [[1, 1.0]]) bands = sc.get("bands", [[1, 1.0]])
c = max(int(count or 0), 1) count = max(int(f.get("entity", {}).get(driver, 0) or 0), 1)
for mx, m in bands: for mx, m in bands:
if c <= mx: if count <= mx:
return float(m) return float(m), driver, count
return float(bands[-1][1]) return float(bands[-1][1]), driver, count
def pick_baseline(f, override): def pick_baseline(f, override):
if override: if override:
return override return override
severities = {x.get("severity") for x in f.get("findings", [])} e = f.get("entity", {})
props = f.get("entity", {}).get("properties_total", 0) or 0 props = e.get("properties_total", 0) or 0
return "treatment" if ("critical" in severities or props > 3) else "basic" subs = e.get("subbrands_total", 0) or 0
if props > 5 or subs > 3: # multi-brand / chain
return "treatment"
if props <= 1 and subs == 0: # single-property SMB
return "smb"
return "basic" # small multi-property / mid
def assemble(f, rate, sow, baseline, billing): def assemble(f, rate, sow, baseline, billing):
roles = rate["role_rates"] roles = rate["role_rates"]
props = f.get("entity", {}).get("properties_total", 0) mult, driver, dcount = scope_multiplier(rate, f)
mult = scope_multiplier(rate, props)
tpl = sow["baselines"][baseline] tpl = sow["baselines"][baseline]
modules = [] modules = []
grand = 0.0 grand = 0.0
@@ -72,7 +78,7 @@ def assemble(f, rate, sow, baseline, billing):
sub += amount sub += amount
modules.append({"name": mod["name"], "subtotal": sub, "tasks": tasks}) modules.append({"name": mod["name"], "subtotal": sub, "tasks": tasks})
grand += sub grand += sub
return modules, grand, mult, props, tpl["service"] return modules, grand, mult, driver, dcount, tpl["service"]
ROLE_KO = { ROLE_KO = {
@@ -91,7 +97,8 @@ def write_md(path, q):
f"- **산정 기준**: SOW 기반 · 청구율 {int(q['billing_rate']*100)}% · 일 8시간/월 4주 · {q['terms']['vat']} · 지급 {q['terms']['payment']}", f"- **산정 기준**: SOW 기반 · 청구율 {int(q['billing_rate']*100)}% · 일 8시간/월 4주 · {q['terms']['vat']} · 지급 {q['terms']['payment']}",
""] ""]
if q["scope"]["hours_multiplier"] != 1.0: if q["scope"]["hours_multiplier"] != 1.0:
L.append(f"> 포트폴리오 규모 반영: 프로퍼티 {q['scope']['properties_total']}개 기준 Technical/On-page 업무시간 ×{q['scope']['hours_multiplier']:g} (서브선형)") 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.append("")
L += ["## 견적 내역", "", L += ["## 견적 내역", "",
"| 구분 | 세부 업무 | 담당 | 시간(h) | 합계 |", "| 구분 | 세부 업무 | 담당 | 시간(h) | 합계 |",
@@ -158,7 +165,7 @@ def main():
ap.add_argument("--sow", required=True) ap.add_argument("--sow", required=True)
ap.add_argument("--out-dir", default=".") ap.add_argument("--out-dir", default=".")
ap.add_argument("--seq", type=int, default=1) ap.add_argument("--seq", type=int, default=1)
ap.add_argument("--baseline", choices=["basic", "treatment"], default=None) ap.add_argument("--baseline", choices=["smb", "basic", "treatment"], default=None)
ap.add_argument("--billing", type=float, default=None) ap.add_argument("--billing", type=float, default=None)
args = ap.parse_args() args = ap.parse_args()
@@ -169,9 +176,13 @@ def main():
with open(args.sow, encoding="utf-8") as fh: with open(args.sow, encoding="utf-8") as fh:
sow = yaml.safe_load(fh) sow = yaml.safe_load(fh)
billing = args.billing if args.billing is not None else rate["billing_rate"]
baseline = pick_baseline(f, args.baseline) baseline = pick_baseline(f, args.baseline)
modules, grand, mult, props, service = assemble(f, rate, sow, baseline, billing) 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"] rounding = rate["rounding_unit"]
proposal = int(math.floor(grand / rounding) * rounding) proposal = int(math.floor(grand / rounding) * rounding)
@@ -190,8 +201,8 @@ def main():
"prospect": f.get("prospect", {}).get("name", "(prospect)"), "prospect": f.get("prospect", {}).get("name", "(prospect)"),
"service": service, "baseline": baseline, "billing_rate": billing, "service": service, "baseline": baseline, "billing_rate": billing,
"company": rate["company"], "terms": rate["terms"], "company": rate["company"], "terms": rate["terms"],
"scope": {"properties_total": props, "scope": {"driver": driver, "driver_count": dcount,
"subbrands_total": f.get("entity", {}).get("subbrands_total", 0), "properties_total": props, "subbrands_total": subs,
"hours_multiplier": mult}, "hours_multiplier": mult},
"modules": modules, "subtotal_sum": grand, "proposal": proposal, "modules": modules, "subtotal_sum": grand, "proposal": proposal,
"rounding_unit": rounding, "rounding_unit": rounding,
@@ -209,7 +220,7 @@ def main():
json.dump(q, fh, ensure_ascii=False, indent=2) json.dump(q, fh, ensure_ascii=False, indent=2)
print(f"견적 {quote_no} [{baseline}] 제안가 {won(proposal)} (합계 {won(grand)}) " print(f"견적 {quote_no} [{baseline}] 제안가 {won(proposal)} (합계 {won(grand)}) "
f"| 프로퍼티 {props} ×{mult:g} | 청구율 {int(billing*100)}%") f"| {driver}={dcount} ×{mult:g} | 청구율 {int(billing*100)}%")
for m in modules: for m in modules:
print(f" {m['name']:24} {won(m['subtotal'])}") print(f" {m['name']:24} {won(m['subtotal'])}")