diff --git a/custom-skills/95-ourdigital-presales-seo/SKILL.md b/custom-skills/95-ourdigital-presales-seo/SKILL.md index 56949a8..d820e34 100644 --- a/custom-skills/95-ourdigital-presales-seo/SKILL.md +++ b/custom-skills/95-ourdigital-presales-seo/SKILL.md @@ -56,9 +56,9 @@ headless Chrome, python-pptx. Create `data/` subfolder. Initialize `findings.jso ## Stage 5 — Estimate (견적) — REVIEW GATE - `python scripts/estimate.py --findings /data/findings.json --rate-card references/rate_card.yaml --sow references/sow_templates.yaml --out-dir --seq [--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. -- 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 - **Client PDF**: author the short brief HTML from `templates/client_brief.html` (fill the content; keep the CSS), diff --git a/custom-skills/95-ourdigital-presales-seo/references/findings_to_service.md b/custom-skills/95-ourdigital-presales-seo/references/findings_to_service.md index ab55ff6..465aa8a 100644 --- a/custom-skills/95-ourdigital-presales-seo/references/findings_to_service.md +++ b/custom-skills/95-ourdigital-presales-seo/references/findings_to_service.md @@ -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`: -**cost = role_rate × billing_rate (0.70) × standard_hours**, grouped by module; -제안가 = 합계 floored to `rounding_unit`. This mirrors OurDigital/D.intelligence's -real SOW-based quoting. +**cost = role_rate × billing_rate × standard_hours**, grouped by module; 제안가 = 합계 +floored to `rounding_unit`. Mirrors OurDigital/D.intelligence's real SOW-based quoting. -## Baseline selection (basic vs treatment) -- `treatment` if any finding `severity == critical` **OR** `entity.properties_total > 3` -- else `basic` -- override with `--baseline`. +## Tier selection (smb / basic / treatment) +- `treatment` if `properties_total > 5` OR `subbrands_total > 3` (multi-brand / chain) +- `smb` if `properties_total <= 1` AND `subbrands_total == 0` (single-property SMB) +- `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 -Each baseline carries the standard module set (P&M · Technical SEO · On-page SEO · -SEO Growth), matching real quotes. Findings justify modules via the `trigger` field -in `sow_templates.yaml`: +Each tier carries its standard module set (P&M · Technical SEO · On-page SEO · +SEO Growth). Findings annotate modules via the `trigger` field: | Module | trigger finding classes | |---|---| @@ -23,18 +26,21 @@ in `sow_templates.yaml`: | SEO Growth | measurement, always | ## Hours scaling (portfolio) -Tasks marked `scale: true` (Technical SEO + On-page SEO) have their **hours** -multiplied sub-linearly by `entity.properties_total` per `rate_card.scaling.bands`. -P&M and SEO Growth stay fixed (management/KPI overhead is ~flat). A single-property -prospect → ×1.0, which reproduces the real single-site quotes exactly. +Only **On-page** tasks (`scale: true`) scale, sub-linearly, by the +`rate_card.scaling.driver` (= `subbrands_total`, i.e. distinct brands/templates), +capped at **×2.0**. Rationale: chains share page templates, so on-page work grows +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` (e.g. SEMrush Guru) are listed separately — client-subscribed, with +15% -procurement markup if billed through us. VAT 별도 · 유효기간 14d · 현금 · 절사 from -`rate_card.terms`. +`tools` (e.g. SEMrush Guru) listed separately — client-subscribed, +15% procurement +markup if billed through us. VAT 별도 · 유효기간 14d · 현금 · 절사 from `rate_card.terms`. -## Validated reproduction (2026-05-28) +## Validated (2026-05-28) +- `basic` ×1.0 → 합계 10,612,000 → 제안가 **10,500,000** (real Basic quote) - `treatment` ×1.0 → 합계 25,340,000 → 제안가 **25,000,000** (real Treatment quote) -- `basic` ×1.0 → 합계 10,612,000 → 제안가 **10,500,000** (real Basic 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. 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 cce2b7f..a93cdb0 100644 --- a/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml +++ b/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml @@ -42,11 +42,13 @@ role_rates: associate: 30000 # 사원 intern: 12000 # 인턴 -# 포트폴리오 규모에 따른 '시간' 스케일(서브선형). scale:true 인 task 에만 적용. -# driver: findings.entity 의 카운트. bands: [최대값, 배수]; count <= 최대값 인 첫 밴드. +# 포트폴리오 규모에 따른 '시간' 스케일(서브선형). On-page(scale:true) task 에만 적용. +# driver = findings.entity 의 카운트. 체인은 페이지 템플릿을 공유하므로 '프로퍼티 수'가 아닌 +# '브랜드/템플릿 수(subbrands_total)'를 기준으로 하고, 캡을 ×2.0 로 낮게 둔다. +# (Technical SEO 는 사이트 단위 고정 작업이므로 스케일하지 않음 — sow_templates 의 scale:false) scaling: - driver: properties_total - bands: [[1, 1.0], [5, 1.6], [15, 2.8], [30, 4.5], [999999, 6.5]] + driver: subbrands_total + bands: [[1, 1.0], [3, 1.3], [6, 1.6], [999999, 2.0]] # 별도 조달 항목(인력비와 분리). 청구 시 procurement_markup 적용 가능. tools: diff --git a/custom-skills/95-ourdigital-presales-seo/references/sow_templates.yaml b/custom-skills/95-ourdigital-presales-seo/references/sow_templates.yaml index c229453..9df39cc 100644 --- a/custom-skills/95-ourdigital-presales-seo/references/sow_templates.yaml +++ b/custom-skills/95-ourdigital-presales-seo/references/sow_templates.yaml @@ -1,13 +1,40 @@ # SOW task templates — standard 업무 시간(hours) by module. -# Seeded from the two real OurDigital quotes so estimate.py reproduces them at -# billing_rate 0.70: -# basic -> 제안가 ₩10,500,000 (SEO Basic & Coaching, 3개월 프로젝트) -# treatment -> 제안가 ₩25,000,000 (SEO Audit & Treatment, 월 정기) -# task.role references rate_card.role_rates. scale:true → hours scaled by portfolio. -# trigger: finding classes that justify the module (for annotation + selection). +# Priced via rate_card.yaml: cost = role_rate × billing_rate × hours. +# Tiers (제안가 at default billing): smb < basic < treatment. +# basic -> ₩10,500,000 (real SEO Basic & Coaching quote, billing 0.70) +# treatment -> ₩25,000,000 (real SEO Audit & Treatment quote, billing 0.70) +# smb -> lean entry tier for single-property SMBs (billing 0.55) +# 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: + 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: service: "SEO Audit & Basic Treatment" modules: @@ -19,10 +46,10 @@ baselines: - name: "Technical SEO" trigger: [crawlability, cwv, schema_entity] tasks: - - {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리, Site Health Check 도구 설정", role: technical_advisor, hours: 16, scale: true} - - {task: "속도·UX·수집 설정", desc: "로딩속도·페이지 UX·링크·수집 제외 설정", role: manager, hours: 16, scale: true} - - {task: "사이트/URL 구조·메타", desc: "구조·URL·메타데이터·사이트맵·리다이렉션", role: senior_manager, hours: 12, scale: true} - - {task: "색인·CWV 진단", desc: "GSC·SEO Tools 활용 색인/크롤오류/Core Web Vitals 진단", role: senior_manager, 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: false} + - {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: false} - name: "On-page SEO" trigger: [onpage, schema_entity] tasks: @@ -46,10 +73,10 @@ baselines: - name: "Technical SEO" trigger: [crawlability, cwv, schema_entity] tasks: - - {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리, Site Health Check 도구 설정", role: manager, hours: 24, scale: true} - - {task: "속도·UX·리다이렉트", desc: "로딩속도·페이지 UX·링크·리다이렉트·수집 제외 설정", role: technical_advisor, hours: 30, scale: true} - - {task: "사이트/URL 구조·보안", desc: "구조·URL·메타데이터·사이트맵·보안 관리 진단", role: senior_manager, hours: 24, scale: true} - - {task: "모바일·CWV·개선과제", desc: "모바일 최적화·Core Web Vitals 진단·개선 과제 도출", role: manager, hours: 20, 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: false} + - {task: "사이트/URL 구조·보안", desc: "구조·URL·메타데이터·사이트맵·보안 관리 진단", role: senior_manager, hours: 24, scale: false} + - {task: "모바일·CWV·개선과제", desc: "모바일 최적화·Core Web Vitals 진단·개선 과제 도출", role: manager, hours: 20, scale: false} - name: "On-page SEO" trigger: [onpage, schema_entity] tasks: diff --git a/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py b/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py index 63645ae..ce1303b 100755 --- a/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py +++ b/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py @@ -286,7 +286,8 @@ def main(): sc = EST.get("scope", {}) note = f"청구율 {int(EST.get('billing_rate', 0.7) * 100)}% · 일8h/월4주 · SOW 기반" 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, [ [(note, 10, GREY, False)], [(EST.get("disclaimer", ""), 9, GREY, False)], diff --git a/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py b/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py index 1338493..f5271d7 100755 --- a/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py +++ b/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py @@ -32,28 +32,34 @@ def won(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", {}) + driver = sc.get("driver", "subbrands_total") 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: - if c <= mx: - return float(m) - return float(bands[-1][1]) + if count <= mx: + return float(m), driver, count + return float(bands[-1][1]), driver, count def pick_baseline(f, override): if override: return override - severities = {x.get("severity") for x in f.get("findings", [])} - props = f.get("entity", {}).get("properties_total", 0) or 0 - return "treatment" if ("critical" in severities or props > 3) else "basic" + 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 + 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): roles = rate["role_rates"] - props = f.get("entity", {}).get("properties_total", 0) - mult = scope_multiplier(rate, props) + mult, driver, dcount = scope_multiplier(rate, f) tpl = sow["baselines"][baseline] modules = [] grand = 0.0 @@ -72,7 +78,7 @@ def assemble(f, rate, sow, baseline, billing): sub += amount modules.append({"name": mod["name"], "subtotal": sub, "tasks": tasks}) grand += sub - return modules, grand, mult, props, tpl["service"] + return modules, grand, mult, driver, dcount, tpl["service"] 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']}", ""] 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 += ["## 견적 내역", "", "| 구분 | 세부 업무 | 담당 | 시간(h) | 합계 |", @@ -158,7 +165,7 @@ def main(): 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=["basic", "treatment"], default=None) + ap.add_argument("--baseline", choices=["smb", "basic", "treatment"], default=None) ap.add_argument("--billing", type=float, default=None) args = ap.parse_args() @@ -169,9 +176,13 @@ def main(): with open(args.sow, encoding="utf-8") as 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) - 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"] proposal = int(math.floor(grand / rounding) * rounding) @@ -190,8 +201,8 @@ def main(): "prospect": f.get("prospect", {}).get("name", "(prospect)"), "service": service, "baseline": baseline, "billing_rate": billing, "company": rate["company"], "terms": rate["terms"], - "scope": {"properties_total": props, - "subbrands_total": f.get("entity", {}).get("subbrands_total", 0), + "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, @@ -209,7 +220,7 @@ def main(): json.dump(q, fh, ensure_ascii=False, indent=2) 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: print(f" {m['name']:24} {won(m['subtotal'])}")