Compare commits

...

3 Commits

Author SHA1 Message Date
6ac547e78f refactor(skills): clean skill names (strip NN- prefix from name:) — convention change
Some checks failed
Verify Skills / verify-skills (push) Has been cancelled
Adopt: directory keeps its NN- ordering prefix; skill `name:` is the clean form
without it (dir 16-seo-schema-validator → name: seo-schema-validator). Nicer to
invoke, matches the original desktop/SKILL.md names, still globally unique.

- 71 root SKILL.md: name: NN-foo → name: foo (flat skills + reference-curator suite).
  Plugins (mac-optimizer/multi-agent-guide/dintel-bootstrap) already clean; 95 already clean.
- scripts/migrate_skill_root.py: derive name = dirname minus NN- prefix (skill_name()).
- CLAUDE.md + SKILL-MIGRATION-GUIDE.md: document the dir-prefix / clean-name convention.

verify_skills.py: 0 name collisions across all renamed skills. (The ~/.claude/skills
symlinks were re-pointed to the clean names separately — filesystem only.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:11:01 +09:00
08cb20fc67 Add real digital_ads + digital_branding catalogs
Built from real D.intelligence docs (not the 견적 자료 folder, which has only
GA4/GTM + education — confirmed):
- digital_branding: TNS 유학원 디지털 브랜딩 진단 컨설팅 → ₩9,000,000 (5 fixed stages)
- digital_ads: 디하이브 디지털 광고·퍼포먼스 마케팅 대행 계약 → ₩6,000,000/월 retainer
  (media-spend commission % is per-deal, kept as a parameter — not invented)

effort method now supports fixed-amount tasks (Unit Cost) and monthly retainers
(unit: monthly); render shows 고정/—//월 and retainer/commission notes.
Validated: branding ₩9.0M, ads ₩6.0M/월; no regression (SEO 25.0M, coaching 1.57M).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:06:59 +09:00
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
94 changed files with 937 additions and 424 deletions

View File

@@ -204,7 +204,7 @@ directly loadable. Migrate incrementally, not in bulk.
```markdown
---
name: skill-name-kebab-case # letters, numbers, hyphens only
name: skill-name-kebab-case # clean name: dir minus the NN- prefix
description: |
What it does + when to use. Triggers: keyword1, keyword2, 한국어 트리거.
---
@@ -214,6 +214,9 @@ description: |
Content starts here...
```
**Naming convention:** the directory keeps its `NN-` prefix for ordering
(`16-seo-schema-validator/`), but the skill `name:` is the **clean** form without it
(`name: seo-schema-validator`). Names must be kebab-case and globally unique.
Whole frontmatter ≤ 1024 chars. Full rules + migration recipe: `reference/SKILL-MIGRATION-GUIDE.md`.
## Directory Layout

View File

@@ -1,5 +1,5 @@
---
name: 01-ourdigital-brand-guide
name: ourdigital-brand-guide
description: |
OurDigital 브랜드 기준 및 스타일 가이드 참조 스킬.
Activated with "ourdigital" keyword for brand-related queries.

View File

@@ -1,5 +1,5 @@
---
name: 02-ourdigital-blog
name: ourdigital-blog
description: |
Korean blog draft creation for blog.ourdigital.org.
Activated with "ourdigital" keyword for blog writing tasks.

View File

@@ -1,5 +1,5 @@
---
name: 03-ourdigital-journal
name: ourdigital-journal
description: |
English essay and article creation for journal.ourdigital.org.
Activated with "ourdigital" keyword for English writing tasks.

View File

@@ -1,5 +1,5 @@
---
name: 04-ourdigital-research
name: ourdigital-research
description: |
Deep research and structured prompt generation for OurDigital workflows.
Activated with "ourdigital" keyword for research tasks.

View File

@@ -1,5 +1,5 @@
---
name: 05-ourdigital-document
name: ourdigital-document
description: |
Notion-to-presentation workflow for OurDigital.
Activated with "ourdigital" keyword for document creation.

View File

@@ -1,5 +1,5 @@
---
name: 06-ourdigital-designer
name: ourdigital-designer
description: |
Visual storytelling and image prompt generation for OurDigital.
Activated with "ourdigital" keyword for design tasks.

View File

@@ -1,5 +1,5 @@
---
name: 07-ourdigital-ad-manager
name: ourdigital-ad-manager
description: |
Ad copywriting and keyword research for OurDigital marketing.
Activated with "ourdigital" keyword for advertising tasks.

View File

@@ -1,5 +1,5 @@
---
name: 08-ourdigital-trainer
name: ourdigital-trainer
description: |
Training material creation and workshop planning for OurDigital.
Activated with "ourdigital" keyword for education tasks.

View File

@@ -1,5 +1,5 @@
---
name: 09-ourdigital-backoffice
name: ourdigital-backoffice
description: |
Business document creation for OurDigital consulting services.
Activated with "ourdigital" keyword for business documents.

View File

@@ -1,5 +1,5 @@
---
name: 10-ourdigital-skill-creator
name: ourdigital-skill-creator
description: |
Meta skill for creating and managing OurDigital Claude Skills.
Activated when user includes "ourdigital" keyword with skill creation requests.

View File

@@ -1,5 +1,5 @@
---
name: 11-seo-comprehensive-audit
name: seo-comprehensive-audit
description: |
Comprehensive SEO audit orchestrator. Runs 6-stage audit pipeline (Technical, On-Page, CWV, Schema, Local, GSC) and produces a unified report with weighted health score.
Triggers: comprehensive SEO, full SEO audit, 종합 SEO 감사, site audit, SEO health check.

View File

@@ -1,5 +1,5 @@
---
name: 12-seo-technical-audit
name: seo-technical-audit
description: |
Technical SEO analyzer for robots.txt, sitemap, and crawlability fundamentals.
Triggers: technical SEO, robots.txt, sitemap validation, crawlability, URL accessibility.

View File

@@ -1,5 +1,5 @@
---
name: 13-seo-on-page-audit
name: seo-on-page-audit
description: |
On-page SEO analyzer for meta tags, headings, links, images, and Open Graph.
Triggers: on-page SEO, meta tags, title tag, heading structure, alt text.

View File

@@ -1,5 +1,5 @@
---
name: 14-seo-core-web-vitals
name: seo-core-web-vitals
description: |
Core Web Vitals analyzer for LCP, FID, CLS, and INP optimization recommendations.
Triggers: Core Web Vitals, page speed, LCP optimization, CLS fix, INP analysis.

View File

@@ -1,5 +1,5 @@
---
name: 15-seo-search-console
name: seo-search-console
description: |
Google Search Console data analyzer for performance, queries, and index coverage.
Triggers: Search Console, GSC analysis, search performance, rankings, CTR optimization.

View File

@@ -1,5 +1,5 @@
---
name: 16-seo-schema-validator
name: seo-schema-validator
description: |
Validates an AUTHORED JSON-LD schema dataset (pre-deployment QA) and audits
live structured data (post-deployment). Runs a 5-layer offline validation

View File

@@ -1,5 +1,5 @@
---
name: 17-seo-schema-generator
name: seo-schema-generator
description: |
Generates validation-ready JSON-LD structured data for a site, covering BOTH
scenarios: (1) from an existing website — extract facts from live pages; and

View File

@@ -1,5 +1,5 @@
---
name: 18-seo-local-audit
name: seo-local-audit
description: |
Local business SEO auditor for Korean-market businesses. Covers business identity extraction,
NAP consistency, Google Business Profile, Naver Smart Place, Kakao Map, local citations,

View File

@@ -1,5 +1,5 @@
---
name: 19-seo-keyword-strategy
name: seo-keyword-strategy
description: |
Keyword strategy and research for SEO campaigns.
Triggers: keyword research, keyword analysis, keyword gap, search volume,

View File

@@ -1,5 +1,5 @@
---
name: 20-seo-serp-analysis
name: seo-serp-analysis
description: |
SERP analysis for Google and Naver search results.
Triggers: SERP analysis, search results, featured snippet, SERP features, Naver SERP, 검색결과 분석, SERP 분석.

View File

@@ -1,5 +1,5 @@
---
name: 21-seo-position-tracking
name: seo-position-tracking
description: |
Keyword position tracking for keyword ranking monitoring.
Triggers: rank tracking, position monitoring, keyword rankings, visibility score, ranking report, 키워드 순위, 순위 추적.

View File

@@ -1,5 +1,5 @@
---
name: 22-seo-link-building
name: seo-link-building
description: |
Link building diagnosis and backlink analysis tool.
Triggers: backlink audit, link building, referring domains, toxic links, link gap, broken backlinks, 백링크 분석, 링크빌딩.

View File

@@ -1,5 +1,5 @@
---
name: 23-seo-content-strategy
name: seo-content-strategy
description: |
Content strategy and planning for SEO. Triggers: content audit, content strategy, content gap, topic clusters, content brief, editorial calendar, content decay, 콘텐츠 전략, 콘텐츠 감사.
---

View File

@@ -1,5 +1,5 @@
---
name: 24-seo-ecommerce
name: seo-ecommerce
description: |
E-commerce SEO audit and optimization for product pages, product schema, category taxonomy,
and Korean marketplace presence.

View File

@@ -1,5 +1,5 @@
---
name: 25-seo-kpi-framework
name: seo-kpi-framework
description: |
SEO KPI and performance framework for unified metrics, health scores, ROI, and period-over-period reporting.
Triggers: SEO KPI, performance report, health score, SEO metrics, ROI,

View File

@@ -1,5 +1,5 @@
---
name: 26-seo-international
name: seo-international
description: |
International SEO audit and hreflang validation for multi-language and multi-region websites.
Triggers: hreflang, international SEO, multi-language, multi-region, content parity, x-default, ccTLD, 다국어 SEO.

View File

@@ -1,5 +1,5 @@
---
name: 27-seo-ai-visibility
name: seo-ai-visibility
description: |
AI search visibility and brand radar monitoring. Tracks how a brand appears
in AI-generated search answers using our-seo-agent CLI or pre-fetched data.

View File

@@ -1,5 +1,5 @@
---
name: 28-seo-knowledge-graph
name: seo-knowledge-graph
description: |
Knowledge Graph and entity SEO analysis.
Triggers: knowledge panel, entity SEO, knowledge graph, PAA, FAQ schema,

View File

@@ -1,5 +1,5 @@
---
name: 29-seo-gateway-architect
name: seo-gateway-architect
description: |
Gateway page strategy planner for keyword research, content architecture, and SEO KPIs.
Triggers: SEO strategy, gateway pages, keyword research, content architecture.

View File

@@ -1,5 +1,5 @@
---
name: 30-seo-gateway-builder
name: seo-gateway-builder
description: |
Gateway page content builder with templates, schema markup, and local SEO optimization.
Triggers: build gateway page, create landing page, local service page, location pages.

View File

@@ -1,5 +1,5 @@
---
name: 31-notion-organizer
name: notion-organizer
description: |
Notion workspace manager for database optimization, property cleanup, and bulk operations.
Triggers: organize Notion, workspace cleanup, database schema, property standardization.

View File

@@ -1,5 +1,5 @@
---
name: 31-seo-competitor-intel
name: seo-competitor-intel
description: |
Competitor intelligence and SEO benchmarking.
Triggers: competitor analysis, competitive intelligence, competitor comparison,

View File

@@ -1,5 +1,5 @@
---
name: 32-notion-writer
name: notion-writer
description: |
Markdown to Notion page writer with database row creation support.
Triggers: write to Notion, export to Notion, push content, create Notion page.

View File

@@ -1,5 +1,5 @@
---
name: 32-seo-crawl-budget
name: seo-crawl-budget
description: |
Crawl budget optimization and server log analysis for search engine bots.
Triggers: crawl budget, log analysis, bot crawling, Googlebot, crawl waste,

View File

@@ -1,5 +1,5 @@
---
name: 33-seo-migration-planner
name: seo-migration-planner
description: |
SEO site migration planning and monitoring. Triggers: site migration, domain move, redirect mapping, platform migration, URL restructuring, HTTPS migration, subdomain consolidation, 사이트 이전, 도메인 이전, 리디렉트 매핑.
---

View File

@@ -1,5 +1,5 @@
---
name: 34-seo-reporting-dashboard
name: seo-reporting-dashboard
description: |
SEO reporting dashboard and executive report generation. Aggregates data from all SEO skills
into stakeholder-ready reports and interactive HTML dashboards.

View File

@@ -1,5 +1,5 @@
---
name: 40-jamie-brand-editor
name: jamie-brand-editor
description: |
Jamie Plastic Surgery branded content generator for blog posts and marketing.
Triggers: write Jamie blog, Jamie content, brand copywriting, 제이미 콘텐츠.

View File

@@ -1,5 +1,5 @@
---
name: 41-jamie-brand-audit
name: jamie-brand-audit
description: |
Jamie Plastic Surgery brand compliance reviewer and content evaluator.
Triggers: review content, brand audit, 제이미 브랜드 검토, tone and manner check.

View File

@@ -1,5 +1,5 @@
---
name: 42-jamie-faq-entry
name: jamie-faq-entry
description: "카카오톡 플러스 채널 Kanana 상담매니저 Q&A 답변 생성 및 검토 스킬. 제이미성형외과의 카카오톡 채널에 등록할 고객 문의 질문과 답변 엔트리를 생성, 검토, 수정합니다. 의료광고 심의 준수, 브랜드 보이스 일관성, 카카오 카나나 가이드 규격을 모두 반영합니다. Triggers: 카나나 답변, Kanana Q&A, 카카오톡 챗봇, 카카오 상담 답변, 챗봇 문답, 자동답변 등록, 카나나 엔트리, chatbot QA, KakaoTalk channel reply, 카카오 자동응답. jamie-marketing-editor 및 jamie-brand-guardian 스킬과 연계하여 사용합니다."
---

View File

@@ -1,5 +1,5 @@
---
name: 43-jamie-youtube-manager
name: jamie-youtube-manager
description: |
Jamie Clinic YouTube channel SEO auditor and content manager.
Triggers: YouTube SEO, video audit, 제이미 유튜브, channel optimization.

View File

@@ -1,5 +1,5 @@
---
name: 44-jamie-youtube-subtitle-checker
name: jamie-youtube-subtitle-checker
description: |
SBV subtitle file typo corrector and YouTube metadata generator for Jamie Clinic.
Triggers: check subtitles, subtitle QA, SBV correction, 자막 교정.

View File

@@ -1,5 +1,5 @@
---
name: 45-jamie-instagram-manager
name: jamie-instagram-manager
description: |
Jamie Clinic Instagram account manager for engagement, content planning, and boost strategy.
Triggers: Instagram management, 제이미 인스타그램, IG strategy, social media.

View File

@@ -1,5 +1,5 @@
---
name: 46-jamie-journal-editor
name: jamie-journal-editor
description: |
Jamie Clinic journal/blog content editor for "정기호의 성형외과 진료실 이야기" (journal.jamie.clinic).
Creates educational medical blog posts in Dr. Jung's authentic voice with Korean medical ad compliance.

View File

@@ -1,5 +1,5 @@
---
name: 47-jamie-marketing-editor
name: jamie-marketing-editor
description: |
Jamie Clinic marketing content editor for digital channels, ads, communications, and internal docs.
Creates compliant marketing copy for website, blog, SNS, ads, and patient communications.

View File

@@ -1,5 +1,5 @@
---
name: 50-notebooklm-agent
name: notebooklm-agent
description: |
Q&A agent for NotebookLM notebooks. Ask questions and get grounded, citation-backed answers from your sources.
Triggers: ask NotebookLM, query notebook, research question, 노트북 질문, NotebookLM 에이전트.

View File

@@ -1,5 +1,5 @@
---
name: 51-notebooklm-automation
name: notebooklm-automation
description: |
Complete NotebookLM automation for notebooks, sources, and artifacts management.
Triggers: manage NotebookLM, create notebook, add sources, 노트북 관리, NotebookLM 자동화.

View File

@@ -1,5 +1,5 @@
---
name: 52-notebooklm-studio
name: notebooklm-studio
description: |
Content generation for NotebookLM Studio artifacts - podcasts, videos, quizzes, flashcards, and more.
Triggers: create podcast, generate video, make quiz, 팟캐스트 만들기, 퀴즈 생성, NotebookLM 스튜디오.

View File

@@ -1,5 +1,5 @@
---
name: 53-notebooklm-research
name: notebooklm-research
description: |
Research and source discovery for NotebookLM. Web/Drive research, auto-import, and source text extraction.
Triggers: research topic, find sources, web research, 리서치, 자료 조사, NotebookLM 연구.

View File

@@ -1,5 +1,5 @@
---
name: 60-gtm-audit
name: gtm-audit
description: |
GTM container audit using Chrome DevTools and DTM Agent for tag verification.
Triggers: audit GTM, GTM analysis, tag debugging, dataLayer inspection.

View File

@@ -1,5 +1,5 @@
---
name: 61-gtm-editor
name: gtm-editor
description: >
GTM implementation toolkit. Creates, updates, and modifies GTM tags, triggers,
variables via API. Generates Custom HTML with ES5 compliance. Handles workspace

View File

@@ -1,5 +1,5 @@
---
name: 62-gtm-validator
name: gtm-validator
description: >
GTM QA and validation toolkit. Verifies tags fire correctly on live pages,
tests trigger conditions against actual DOM, validates dataLayer schemas,

View File

@@ -1,5 +1,5 @@
---
name: 70-dintel-brand-guardian
name: dintel-brand-guardian
version: 1.2.0
last_updated: 2026-05-18
canon_compliance: v1.3

View File

@@ -1,5 +1,5 @@
---
name: 71-dintel-brand-editor
name: dintel-brand-editor
description: |
This skill should be used when the user asks to "write D.intelligence content",
"create Magazine D. article", "evaluate brand compliance", "check brand tone",

View File

@@ -1,5 +1,5 @@
---
name: 72-dintel-doc-secretary
name: dintel-doc-secretary
description: |
D.intelligence Documentation Secretary. Format meeting notes, reports, proposals, and deliverables with brand-compliant templates. Triggers: "format meeting notes", "회의록 정리", "prepare deliverable", "산출물 준비", "format report", "리포트 포맷팅", "apply brand template", "브랜드 템플릿 적용", "convert to DOCX", "문서 변환", "quality check document", "문서 품질 검토", "meeting minutes", "의사록 작성", "document formatting", "문서 정리"
version: 1.1.0

View File

@@ -1,5 +1,5 @@
---
name: 73-dintel-quotation-mgr
name: dintel-quotation-mgr
version: 1.1.0
last_updated: 2026-05-18
canon_compliance: v1.3

View File

@@ -1,5 +1,5 @@
---
name: 74-dintel-service-architect
name: dintel-service-architect
version: 1.1.0
last_updated: 2026-05-18
canon_compliance: v1.3

View File

@@ -1,5 +1,5 @@
---
name: 75-dintel-marketing-mgr
name: dintel-marketing-mgr
description: |
This skill should be used when the user asks to "draft Magazine D. article",
"prepare newsletter", "draft LinkedIn post", "plan content calendar",

View File

@@ -1,5 +1,5 @@
---
name: 76-dintel-backoffice-mgr
name: dintel-backoffice-mgr
version: 1.1.0
last_updated: 2026-05-18
canon_compliance: v1.3

View File

@@ -1,5 +1,5 @@
---
name: 77-dintel-account-mgr
name: dintel-account-mgr
version: 1.1.0
last_updated: 2026-05-18
canon_compliance: v1.3

View File

@@ -1,5 +1,5 @@
---
name: 79-dintel-skill-update
name: dintel-skill-update
description: |
Meta-agent for D.intelligence Agent Corps cross-skill consistency.
Use when updating brand guide, pricing, service architecture, terms, or any shared reference

View File

@@ -1,5 +1,5 @@
---
name: 80-claude-settings-optimizer
name: claude-settings-optimizer
description: |
Diagnose all Claude Desktop errors and optimize context usage.
Covers 8 error categories: context limits, output interruption,

View File

@@ -1,5 +1,5 @@
---
name: 82-our-gdrive-organizer
name: our-gdrive-organizer
description: |
Organize a Google Drive folder under OurDigital conventions: refresh the
root README.md index, refresh per-subfolder README.md meta files, propose

View File

@@ -1,5 +1,5 @@
---
name: 01-reference-discovery
name: reference-discovery
description: |
Search and discover authoritative reference sources with credibility validation.
Triggers: find sources, search documentation, discover references, source validation.

View File

@@ -1,5 +1,5 @@
---
name: 02-web-crawler-orchestrator
name: web-crawler-orchestrator
description: |
Multi-backend web crawler with Firecrawl MCP, rate limiting, and format handling.
Triggers: crawl URLs, fetch pages, scrape content, web crawler.

View File

@@ -1,5 +1,5 @@
---
name: 03-content-repository
name: content-repository
description: |
MySQL storage manager for reference library with versioning and deduplication.
Triggers: store content, manage repository, document database, content storage.

View File

@@ -1,5 +1,5 @@
---
name: 04-content-distiller
name: content-distiller
description: |
Raw content summarizer extracting key concepts, code snippets, and structured output.
Triggers: distill content, summarize document, extract key concepts, compress content.

View File

@@ -1,5 +1,5 @@
---
name: 05-quality-reviewer
name: quality-reviewer
description: |
Content quality evaluator with multi-criteria scoring and decision routing.
Triggers: review quality, score content, QA review, approve refactor reject.

View File

@@ -1,5 +1,5 @@
---
name: 06-markdown-exporter
name: markdown-exporter
description: |
Export approved content to markdown files or JSONL for fine-tuning.
Triggers: export markdown, generate files, create JSONL, export content.

View File

@@ -1,5 +1,5 @@
---
name: 07-pipeline-orchestrator
name: pipeline-orchestrator
description: |
Full reference curation pipeline coordinator with QA loop and state management.
Triggers: run pipeline, orchestrate workflow, full curation, pipeline start.

View File

@@ -1,5 +1,5 @@
---
name: 90-reference-curator
name: reference-curator
description: |
Full reference-documentation curation pipeline: discover authoritative sources →
crawl → store → distill → quality-review (QA loop) → export to markdown / project files

View File

@@ -1,5 +1,5 @@
---
name: 92-tui-design-template
name: tui-design-template
description: Build Norton Commander / Gopher style TUI wizard interfaces for CLI tools using Python Rich. Covers architecture, components, keyboard input, bilingual i18n, and battle-tested gotchas.
version: 1.0.0
triggers:

View File

@@ -55,10 +55,12 @@ headless Chrome, python-pptx. Create `data/` subfolder. Initialize `findings.jso
→ write `03_presales-opportunity-brief.md`.
## 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]`
- **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`. Premium verticals (luxury/5성/특1급…) are auto-floored to `basic` via `rate_card.tiering` (so a luxury single property won't drop to `smb`).
- 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; SMB entry ~₩3M; chains stay SMB-acceptable (e.g. 25-property ~₩29.5M).
Pricing is delegated to the **`ourdigital-estimate-engine`** skill (`../96-ourdigital-estimate-engine`); this skill only maps findings → scope, then calls the engine:
- `python scripts/findings_to_scope.py --findings <out>/data/findings.json --out <out>/data/scope.json --seq <N> [--tier auto|smb|basic|treatment] [--billing 0.70]`
- `ENG=../96-ourdigital-estimate-engine; python $ENG/scripts/estimate.py --rate-card $ENG/references/rate_card.yaml --catalog-dir $ENG/catalog --scope <out>/data/scope.json --out-dir <out>`
- Engine (effort method, `seo` catalog): auto-tier (smb/basic/treatment) by portfolio + premium-vertical floor; On-page hours scale by `subbrands_total` (cap ×2.0); 제안가 = 합계 절사. Reproduces Basic ₩10.5M / Treatment ₩25.0M; SMB ~₩3M; 25-property chain ~₩29.5M.
- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json` (effort shape → consumed by `build_deck.py`). Present the 견적; get sign-off.
- SEO findings→scope/tier mapping lives here (`findings_to_service.md`); **rates/hours/tiers live in the engine** (`rate_card.yaml` + `catalog/seo.yaml`) — edit pricing there, not here.
## 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),

View File

@@ -44,5 +44,8 @@ markup if billed through us. VAT 별도 · 유효기간 14d · 현금 · 절사
- `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` + `sow_templates.yaml` together when rates, hours, tiers, or
scaling change.
**Pricing now lives in the `ourdigital-estimate-engine` skill** (`../96-ourdigital-estimate-engine`):
role rates/billing/tiering/scaling in `references/rate_card.yaml`, SEO tiers/hours in
`catalog/seo.yaml`. This skill maps findings → `scope.json` (`scripts/findings_to_scope.py`)
and calls the engine. Edit rates/hours/tiers **in the engine**, not here. This file documents
only the SEO findings→tier/scope mapping.

View File

@@ -1,66 +0,0 @@
# OurDigital / D.intelligence — rate card (effort-based)
# Source of truth: 06_Working Template 견적서 (Google Drive), 2026-05.
# Model: 비용 = 시간단가(role) × 청구율(billing_rate) × 표준 업무시간(hours).
# Estimates are assembled bottom-up from sow_templates.yaml. Keep PRIVATE.
company:
legal_name: "(주)디인텔리전스 (D.intelligence Lab)"
brand: "OurDigital"
ceo: "임명재"
contact: "info@ourdigital.org"
address: "경기도 성남시 수정구 창업로 43 판교글로벌비즈센터 업무동 1층 128호"
quote_prefix: OD # OD-YYYY-NNN
currency: KRW
billing_rate: 0.70 # 청구율 표준 70%
billing_rate_floor: 0.60 # 협상 시 -10%p 까지(>10%p 조정은 CEO/CFO 승인)
basis:
hours_per_day: 8 # 일 8시간
weeks_per_month: 4 # 월 4주
procurement_markup: 0.15 # 조달-관리 수수료 15% (외부 조달 물품/서비스)
rounding_unit: 500000 # 제안가 = 합계 절사. 50만원 단위가 실제 견적(25,340,000→25,000,000;
# 10,612,000→10,500,000)을 재현. 필요시 100000/1000000 으로 조정.
terms:
vat: "부가세 별도"
validity_days: 14 # 견적 유효기간(약 2주)
payment: "현금"
# 자동 티어링 보정. 프리미엄/럭셔리 prospect 는 규모가 작아도 entry(smb) 아래로
# 자동 선택되지 않도록 최소 티어를 보장. prospect.vertical 문자열에 아래 용어가
# 포함되면 프리미엄으로 간주(부분 일치, 대소문자 무시).
tiering:
premium_verticals: ["luxury", "premium", "deluxe", "5_star", "5성", "특1급", "boutique_luxury"]
premium_min_tier: basic # 프리미엄 단일 프로퍼티 최소 티어 (smb < basic < treatment)
# 시간 단가 (KRW/hr) — 직급 기준. sow_templates.yaml의 task.role 이 이 키를 참조.
role_rates:
ceo: 180000 # 대표
evp: 150000 # 전무
svp: 120000 # 상무
technical_advisor: 120000 # 기술고문
director: 100000 # 이사 / 본부장
senior_manager: 90000 # 부장 (Senior SEO·PM·Senior Analyst·Account Director(국장))
deputy_manager: 80000 # 차장
manager: 70000 # 과장 (SEO·Data Analyst·Data Engineer·Engineer|Developer)
assistant_manager: 60000 # 대리
junior: 50000 # 주임
associate: 30000 # 사원
intern: 12000 # 인턴
# 포트폴리오 규모에 따른 '시간' 스케일(서브선형). On-page(scale:true) task 에만 적용.
# driver = findings.entity 의 카운트. 체인은 페이지 템플릿을 공유하므로 '프로퍼티 수'가 아닌
# '브랜드/템플릿 수(subbrands_total)'를 기준으로 하고, 캡을 ×2.0 로 낮게 둔다.
# (Technical SEO 는 사이트 단위 고정 작업이므로 스케일하지 않음 — sow_templates 의 scale:false)
scaling:
driver: subbrands_total
bands: [[1, 1.0], [3, 1.3], [6, 1.6], [999999, 2.0]]
# 별도 조달 항목(인력비와 분리). 청구 시 procurement_markup 적용 가능.
tools:
semrush_guru:
label: "Advanced SEO Tools — SEMrush Guru"
unit: "월간 구독"
price_usd: 249.95
note: "고객사 별도 구독(TBD), 사용자별 과금"

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()

View File

@@ -0,0 +1,121 @@
# Design — `ourdigital-estimate-engine` skill
- **Status**: Approved design (2026-05-28). Implementing.
- **Origin**: Extracted from `ourdigital-presales-seo`'s estimate engine and generalized to
cover all OurDigital / D.intelligence professional services via multiple costing methods.
- **Source data**: real quotes in Google Drive `06_Working Template` (SEO) and `견적 자료`
(GA4/GTM, education). Company: (주)디인텔리전스 / D.intelligence Lab, info@ourdigital.org.
## 1. Purpose & scope
A reusable, **method-aware** estimate engine that produces OurDigital 견적 for any service
line. Consuming skills (e.g. `ourdigital-presales-seo`) map their context → a generic
`scope.json` and call the engine CLI. The engine owns the rate card + service catalog
(single source of truth).
### Costing methods (from real data)
1. **effort**`role_rate × billing_rate(0.70) × hours`, grouped into modules; tiering,
sub-brand scaling, premium-vertical floor, 절사. (SEO Audit & Treatment, etc.)
2. **coaching**`Σ(lesson_type_price × hours)` over a lesson plan, with student-count
discount. Base type prices reproduce real quotes; optional subject×level list-price
matrix (`base × level_multiple`). (GA4/GTM courses, workshops, education.)
3. **procurement**`unit_cost × qty × (1 + 0.15)` for non-labor items (tools, 3rd-party).
All methods share: cover-sheet output, 부가세 별도, 유효기간 14d, 현금, 십만 단위 미만 절사
(500k floor for project quotes), quote no `OD-YYYY-NNN`.
### Non-goals
- No invented rates. `digital_ads` / `digital_branding` are built from real docs (디하이브 ads
대행 계약, TNS 브랜딩 진단 견적); any future service without real data ships as a flagged stub.
- No GA4/GTM *effort-implementation* catalog (delivered as coaching → a course in education).
- Does not replace consuming skills' domain logic (e.g. SEO findings→scope mapping stays in
presales-seo).
## 2. Structure
```
96-ourdigital-estimate-engine/
SKILL.md
references/
rate_card.yaml # UNIVERSAL pricing config (see §3)
catalog/
seo.yaml # method: effort — tiers smb/basic/treatment (real)
education.yaml # method: coaching — courses + per-subject levels (real)
digital_ads.yaml # method: effort — REAL (₩6.0M/월 retainer + per-deal media commission)
digital_branding.yaml # method: effort — REAL (fixed 진단 stages → ₩9.0M)
scripts/
estimate.py # CLI dispatcher
methods/effort.py
methods/coaching.py
methods/procurement.py
render.py # md / xlsx / json output (cover-sheet)
scope.schema.json
```
## 3. `rate_card.yaml` (universal)
- `company`, `quote_prefix: OD`, `currency: KRW`, `rounding_unit: 500000`
- `terms`: vat 부가세 별도, validity_days 14, payment 현금
- **effort**: `billing_rate: 0.70` (floor 0.60), `basis {8h/day, 4wk/mo}`,
`procurement_markup: 0.15`, `role_rates {대표 180k … 과장 70k … 인턴 12k}`,
`tiering {premium_verticals, premium_min_tier}`, `scaling {driver: subbrands_total,
bands cap ×2.0}`, `tools`.
- **coaching**:
- `lesson_type_prices`: 화상 80,000 · 대면 100,000 · 실습 150,000 · 워크숍 300,000 · 트리트먼트 500,000
- `level_multiple`: Beginners 1.0 · Intermediate 1.5 · Advanced 2.0 · Expert 2.5 · Trainers 3.0
- `subject_levels`: {Content Marketing: Beginners, SEO: Beginners, Google Analytics: Intermediate,
Google Tag Manager: Advanced, Digital Marketing Strategy: Advanced, Digital Communication:
Intermediate, KPI Setup & Measurement Plan: Expert, Business Model Canvas: Expert,
e-Commerce Audit: Advanced}
- `pricing_mode: base` (default; reproduces real quotes) | `matrix` (list price = base×level_multiple)
- `student_discount_bands`: [[5,0.20],[10,0.0],[20,0.20],[30,0.30]] (30+ = 별도 협의)
## 4. Catalog entries
Each file: `service`, `method`, and method-specific body.
- **seo.yaml** (effort): the current `sow_templates.yaml` verbatim (tiers smb/basic/treatment,
modules→tasks with role/hours/scale). Validated to reproduce 10.5M/25.0M.
- **education.yaml** (coaching): `courses:` — named lesson plans, each `lessons: [{subject,
type, hours}]`. Seed with `ga4_gtm_intermediate` (the real 17-lesson, ₩1,570,000 plan) and
`ga4_gtm_marketing_analytics`. Subjects resolve levels from rate_card.subject_levels.
- **digital_ads.yaml / digital_branding.yaml** (effort): STUB — one placeholder tier +
`_stub: true`, with a header comment to populate from a real quote.
## 5. `scope.json` (generic input — `scope.schema.json`)
```jsonc
// effort
{"service":"seo","method":"effort","tier":"auto", // or smb|basic|treatment
"signals":{"properties_total":25,"subbrands_total":5,"vertical":"hotel_resort"},
"billing_rate":null, "seq":1, "prospect":{"name":"","audit_date":""}}
// coaching
{"service":"education","method":"coaching","course":"ga4_gtm_intermediate",
"students":1, "pricing_mode":"base", "prospect":{...}} // or "lessons":[{subject,type,hours}]
```
`tier:"auto"` → engine runs tiering (size + premium floor) from `signals`.
## 6. CLI
`python scripts/estimate.py --catalog-dir catalog --rate-card references/rate_card.yaml
--scope scope.json --out-dir <dir> [--seq N]`
- Dispatcher loads scope → catalog entry → method module → `render.py`.
- Outputs `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`.
- **effort `estimate.json` keeps the current shape** (modules + 제안가 + scope + terms) so
`ourdigital-presales-seo/build_deck.py` keeps working unchanged.
## 7. `ourdigital-presales-seo` refactor (consumption = CLI)
- **Move** `references/rate_card.yaml` + `references/sow_templates.yaml` → engine
(`catalog/seo.yaml`). Delete `scripts/estimate.py` from presales-seo.
- **Add** `scripts/findings_to_scope.py` (thin): findings.json → scope.json (tier signals,
vertical, prospect). Keeps SEO-specific mapping out of the engine.
- **Stage 5** in SKILL.md: `findings_to_scope.py` → call engine
`estimate.py --service seo --scope scope.json`.
- `build_deck.py`, `kg_query.py`, `render_pdf.sh`, `findings_to_service.md`,
`findings.schema.json`, templates → unchanged.
## 8. Validation (before commit)
- effort: SEO basic ₩10.5M, treatment ₩25.0M (1 property); SHR ₩29.5M; L'Escape `basic` ₩10.5M
— identical to pre-refactor.
- coaching: `ga4_gtm_intermediate` (17 lessons, 1 student) → **₩1,570,000** exactly.
- procurement: unit_cost × qty × 1.15 sanity check.
- presales-seo end-to-end: findings→scope→engine→견적 + deck reproduces SHR/L'Escape.
## 9. Future
`digital_ads` / `digital_branding` now real (effort: monthly retainer / fixed stages). Next:
optional **commission** line for ad media spend (per-deal %); add `content_marketing` as a
project-service if effort quotes emerge (currently a coaching subject); optional matrix-mode
coaching quotes; more education courses.

View File

@@ -0,0 +1,59 @@
---
name: ourdigital-estimate-engine
description: Method-aware estimate/견적 engine for OurDigital / D.intelligence professional services (SEO, GA4/GTM, education/coaching, digital ads, branding, …). Generates a Korean 견적 (md/xlsx/json) from a generic scope.json using the real company rate card. Use for "OurDigital 견적", "estimate", "quote", "proposal pricing", "cost estimate", "견적서 생성", or when another skill needs to price a service. Costing methods: effort (role×billing×hours), coaching (lesson×hours), procurement (+15%).
---
# OurDigital Estimate Engine
Single source of truth for OurDigital/D.intelligence pricing. Consuming skills map their
context into a `scope.json` and call the engine CLI; the engine owns the rate card + service
catalog and renders the 견적.
## Costing methods (catalog entry declares its `method`)
- **effort** — `role_rate × 청구율(0.70) × 표준 업무시간`, by module; auto-tier (smb/basic/treatment)
by portfolio size + premium-vertical floor; On-page hours scale by sub-brands (cap ×2.0);
제안가 = 합계 절사. Also supports **fixed-amount tasks** (`fixed:` Unit Cost — e.g. branding
진단 stages) and **monthly retainers** (`unit: monthly` — e.g. ads 대행). Reproduces real
SEO (10.5M/25.0M), branding (₩9.0M), ads (₩6.0M/월).
- **coaching** — `Σ(lesson_type_price × hours)` over a lesson plan; student discount opt-in.
Reproduces real GA4/GTM 코칭 (₩1,570,000). Base prices default; subject×level matrix optional.
- **procurement** — `unit_cost × qty × (1 + 0.15)` for non-labor items.
## Files
- `references/rate_card.yaml` — universal: role rates, billing, basis, terms, tiering, scaling,
procurement, coaching prices/levels/discounts, tools. **Private.**
- `catalog/<service>.yaml` — per-service: `service`, `method`, body. All real:
`seo` (effort, role-hours tiers), `education` (coaching, incl. GA4/GTM course),
`digital_branding` (effort, fixed 진단 stages → ₩9.0M), `digital_ads` (effort, ₩6.0M/월
retainer; media commission % is per-deal, not in catalog).
- `scripts/estimate.py` — CLI dispatcher; `scripts/methods/{effort,coaching,procurement}.py`;
`scripts/render.py` (md/xlsx/json). `scope.schema.json` — input contract.
## Usage
```
python scripts/estimate.py \
--rate-card references/rate_card.yaml --catalog-dir catalog \
--scope scope.json --out-dir <engagement> [--seq N]
```
`scope.json` examples (see `scope.schema.json`):
- effort: `{"service":"seo","tier":"auto","signals":{"properties_total":25,"subbrands_total":5,"vertical":"hotel_resort"},"prospect":{"name":"…","audit_date":"YYYY-MM-DD"}}`
- coaching: `{"service":"education","course":"ga4_gtm_intermediate","students":1,"prospect":{…}}`
- procurement: `{"service":"seo","method":"procurement","items":[{"label":"SEMrush Guru","unit_cost":330000,"qty":6}]}`
Outputs `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`. The effort `estimate.json`
shape is consumed by `ourdigital-presales-seo/build_deck.py`.
## Consuming from another skill (CLI)
1. Map your context → `scope.json` (service, tier signals or lessons, prospect).
2. Call `estimate.py`. 3. Use the rendered 견적; for SEO, feed `data/estimate.json` to the deck.
Example consumer: `ourdigital-presales-seo` (`findings_to_scope.py` → engine → 견적 + deck).
## Adding / editing services
- New real service: add `catalog/<service>.yaml` with `method` + body from a **real quote**.
- Any new stub should carry `_stub: true` (the 견적 prints a ⚠STUB banner) until replaced with
real quote data. All current catalogs are real.
- Rates change in `rate_card.yaml` only (single source). Validate against a known real quote.
## Conventions
Korean-first output · 부가세 별도 · 유효기간 14d · 현금 · `OD-YYYY-NNN`. Don't invent rates —
stub and flag instead. Legal entity (주)디인텔리전스 / info@ourdigital.org.

View File

@@ -0,0 +1,18 @@
# Digital Ads / Performance Marketing catalog — method: effort (monthly retainer).
# Source: real signed contract "D.intelligence_디지털 광고 및 퍼포먼스 마케팅 대행 계약서_(주)디하이브"
# (2023-04-03) → 퍼포먼스 마케팅 대행 = 월 ₩6,000,000 (부가세 별도), 월말 세금계산서.
# Media spend (매체비) is separate; 광고대행수수료(%) on media spend is agreed per contract
# (NOT a fixed rate — kept as a per-engagement parameter, not invented here).
service: digital_ads
method: effort
tiers:
performance_retainer:
label: "디지털 광고·퍼포먼스 마케팅 대행 (월 정기)"
notes:
- "매체비(media spend)는 별도. 집행금액 대비 광고대행수수료(%)는 계약 시 약정 — 본 견적 미포함."
- "제작비·프로모션비는 외주 실비 + 대행수수료 별도. Ad-hoc 진단/분석 보고서는 별도 약정."
- "media 커미션이 필요하면 procurement 메서드로 별도 라인 산정(요율은 per-deal)."
modules:
- name: "퍼포먼스 마케팅 대행"
tasks:
- {task: "퍼포먼스 마케팅 대행", desc: "KPI 설정·측정계획·데이터/GTM 관리·정기 리포팅·SEO 모니터링 등 (계약 별첨 SOW)", fixed: 6000000, unit: monthly}

View File

@@ -0,0 +1,20 @@
# Digital Branding catalog — method: effort (fixed-amount stages).
# Source: real quote "D.intelligence_TNS 유학원_디지털 브랜딩 진단 컨설팅_20250625" → ₩9,000,000
# (5 fixed stages, 6~8주 진단 컨설팅). Stages are lump-sum Unit Cost (no labor-hour basis),
# per the SOW rule for non-hour-measurable tasks. Follow-on execution = separate quote.
service: digital_branding
method: effort
tiers:
diagnostic:
label: "디지털 브랜딩 진단 컨설팅"
notes:
- "6~8주 일정 (착수 후 업무 수행계획서 제시)."
- "후속 실행(브랜드 아이덴티티·웹사이트·콘텐츠 마케팅)은 진단 결과에 따라 별도 견적."
modules:
- name: "브랜딩 진단 컨설팅"
tasks:
- {task: "1단계 킥오프·과업 정의", desc: "인터뷰·자료 수집·목표 정의", fixed: 2000000, unit: one_time}
- {task: "2단계 디지털 자산 진단", desc: "채널/계정/콘텐츠 구조 분석", fixed: 2000000, unit: one_time}
- {task: "3단계 브랜드 가시성 진단", desc: "검색 키워드·브랜드 명칭 중심", fixed: 2000000, unit: one_time}
- {task: "4단계 콘텐츠/채널 가시성 조사", desc: "블로그/카페/소셜 노출 현황 정리", fixed: 1500000, unit: one_time}
- {task: "5단계 KPI 구조·전략 로드맵", desc: "성과 프레임 기초 설계·로드맵 보고서", fixed: 1500000, unit: one_time}

View File

@@ -0,0 +1,31 @@
# Education / coaching catalog — method: coaching.
# Cost = Σ(lesson_type_prices[type] × hours) from rate_card.coaching (pricing_mode: base).
# Subjects resolve levels from rate_card.coaching.subject_levels (used only in matrix mode).
service: education
method: coaching
courses:
# Real quote: GA4/GTM 중급 과정 1:1 코칭 → ₩1,570,000 (7 대면×100k + 9 화상×80k + 1 실습×150k).
ga4_gtm_intermediate:
title: "GA4/GTM 중급 과정 1:1 코칭"
lessons:
- {subject: "Google Analytics", title: "설치-설정 진단", type: 대면, hours: 1}
- {subject: "Google Analytics", title: "측정 계획 수립", type: 대면, hours: 1}
- {subject: "Google Analytics", title: "기본 리포트 설정", type: 대면, hours: 1}
- {subject: "Google Analytics", title: "맞춤 리포트 구성", type: 대면, hours: 1}
- {subject: "Google Analytics", title: "획득 보고서의 이해", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "참여도 보고서 해석", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "수익창출 보고서 관리", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "주요 이벤트와 전환", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "탐색 분석 활용", type: 화상, hours: 1}
- {subject: "Google Tag Manager", title: "이벤트 태깅 관리 준비", type: 화상, hours: 1}
- {subject: "Google Tag Manager", title: "GTM 설정 분석과 검수", type: 대면, hours: 1}
- {subject: "Google Tag Manager", title: "이벤트 태깅 실습", type: 대면, hours: 1}
- {subject: "Google Tag Manager", title: "마케팅 태그 설정", type: 대면, hours: 1}
- {subject: "Google Tag Manager", title: "e-Commerce 태그 설정", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "잠재 고객 설정과 활용", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "캠페인 성과 분석", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "Looker Studio 대시보드", type: 실습, hours: 1}
# Add more courses (workshops, SEO/Content Marketing coaching, etc.) as real lesson plans arrive.
# For an ad-hoc plan, pass scope.lessons directly instead of a named course.

View File

@@ -1,90 +1,73 @@
# SOW task templates — standard 업무 시간(hours) by module.
# 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:
# SEO service catalog — method: effort. Tiers priced via rate_card (role×billing×hours).
# Reproduces real quotes: basic ₩10.5M, treatment ₩25.0M (1 property, billing 0.70).
# Only On-page tasks (scale:true) scale by sub-brands; Technical/P&M/Growth fixed.
service: seo
method: effort
tiers:
smb:
service: "SEO Quick Audit (SMB)"
billing_rate: 0.55 # SMB 진입 티어 — 낮은 청구율
label: "SEO Quick Audit (SMB)"
billing_rate: 0.55
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"
label: "SEO Audit & Basic Treatment"
modules:
- name: "Planning & Management"
trigger: [always]
tasks:
- {task: "업무 관리", desc: "프로젝트 설계·과업 정의·일정/산출물 관리·리포팅", role: senior_manager, hours: 20, scale: false}
- {task: "웹 사이트 분석", desc: "사용자 유입·채널 내 행동·전환 flow 분석", role: senior_manager, hours: 12, scale: false}
- name: "Technical SEO"
trigger: [crawlability, cwv, schema_entity]
tasks:
- {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리, Site Health Check 도구 설정", role: technical_advisor, hours: 16, scale: false}
- {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:
- {task: "키워드·템플릿·메타 진단", desc: "중점 키워드·페이지 템플릿·메타·개체 활용 진단", role: manager, hours: 24, scale: true}
- {task: "링크·콘텐츠 도구 진단", desc: "내외부 링크·공유 메타·콘텐츠 생성-관리 도구", role: manager, hours: 12, scale: true}
- {task: "on-page 관리 가이드", desc: "타이틀·메타·헤더태그·링크·이미지 메타 가이드", role: manager, hours: 36, scale: true}
- name: "SEO Growth"
trigger: [measurement, always]
tasks:
- {task: "성과 지표 관리", desc: "SEO 관리지표·중점 키워드·포지셔닝 트래킹·3rd party 데이터 설정", role: manager, hours: 24, scale: false}
treatment:
service: "SEO Audit & Treatment"
label: "SEO Audit & Treatment"
modules:
- name: "Planning & Management"
trigger: [always]
tasks:
- {task: "업무 관리", desc: "프로젝트 설계·과업 정의·일정/산출물 관리·리포팅", role: senior_manager, hours: 36, scale: false}
- {task: "웹 사이트 분석", desc: "사용자 유입·채널 내 행동·전환 flow 분석", role: senior_manager, hours: 60, scale: false}
- {task: "측정 계획 수립", desc: "채널 운영 목적·사용자 세그먼트·기대행동 가설 도출", role: senior_manager, hours: 20, scale: false}
- name: "Technical SEO"
trigger: [crawlability, cwv, schema_entity]
tasks:
- {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리, Site Health Check 도구 설정", role: manager, hours: 24, scale: false}
- {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:
- {task: "키워드·템플릿·메타 진단", desc: "중점 키워드·페이지 템플릿·메타·개체 활용 진단", role: manager, hours: 48, scale: true}
- {task: "링크·콘텐츠 도구 진단", desc: "내외부 링크·공유 메타·콘텐츠 생성-관리 도구", role: manager, hours: 36, scale: true}
- {task: "on-page 관리 가이드", desc: "타이틀·메타·헤더태그·링크·이미지 메타 가이드", role: manager, hours: 60, scale: true}
- name: "SEO Growth"
trigger: [measurement, always]
tasks:
- {task: "핵심 성과 지표 설정", desc: "중점과제·목표·SEO 핵심성과지표·모니터링 지표 설정", role: senior_manager, hours: 20, scale: false}
- {task: "지표 정의·리포팅 설계", desc: "데이터 소스·지표 산식·리포팅/진단 주기 설정", role: manager, hours: 16, scale: false}

View File

@@ -0,0 +1,67 @@
# OurDigital / D.intelligence — UNIVERSAL rate card (shared across all service lines).
# Methods: effort (role×billing×hours), coaching (lesson_type×hours), procurement (+15%).
# Source: real quotes (06_Working Template = SEO; 견적 자료 = GA4/GTM, education). Keep PRIVATE.
company:
legal_name: "(주)디인텔리전스 (D.intelligence Lab)"
brand: "OurDigital"
ceo: "임명재"
contact: "info@ourdigital.org"
address: "경기도 성남시 수정구 창업로 43 판교글로벌비즈센터 업무동 1층 128호"
quote_prefix: OD
currency: KRW
rounding_unit: 500000 # 제안가 절사 단위(효과 기반 프로젝트 견적). 실제 견적 25,340,000→25,000,000 재현.
terms:
vat: "부가세 별도"
validity_days: 14
payment: "현금"
# ── effort method (project services: SEO Audit & Treatment 등) ──────────────
billing_rate: 0.70 # 청구율 표준 70%
billing_rate_floor: 0.60 # 협상 시 -10%p 까지(>10%p 는 CEO/CFO 승인)
basis: {hours_per_day: 8, weeks_per_month: 4}
procurement_markup: 0.15 # 외부 조달 물품/서비스 수수료
role_rates: # 시간 단가 KRW/hr (직급 기준)
ceo: 180000
evp: 150000
svp: 120000
technical_advisor: 120000
director: 100000
senior_manager: 90000 # 부장 (Senior SEO·PM·Senior Analyst·Account Director)
deputy_manager: 80000 # 차장
manager: 70000 # 과장 (SEO·Data Analyst·Engineer|Developer)
assistant_manager: 60000 # 대리
junior: 50000 # 주임
associate: 30000 # 사원
intern: 12000
tiering: # 자동 티어 선택 보정(effort 카탈로그가 tier 를 가질 때)
premium_verticals: ["luxury", "premium", "deluxe", "5_star", "5성", "특1급", "boutique_luxury"]
premium_min_tier: basic # 프리미엄 단일 프로퍼티 최소 티어
scaling: # On-page(scale:true) 시간 스케일 — 브랜드/템플릿 수 기준, 캡 ×2.0
driver: subbrands_total
bands: [[1, 1.0], [3, 1.3], [6, 1.6], [999999, 2.0]]
tools:
semrush_guru: {label: "Advanced SEO Tools — SEMrush Guru", unit: "월간 구독", price_usd: 249.95, note: "고객사 별도 구독(TBD)"}
# ── coaching method (education: 1:1 코칭, 워크숍) ────────────────────────────
# 실제 견적 재현: 비용 = Σ(lesson_type_prices[type] × hours). subject 행렬은 base×level_multiple
# (list price) 이나 실제 견적은 base 가격으로 청구되므로 pricing_mode 기본값 base.
coaching:
pricing_mode: base # base | matrix
lesson_type_prices: {화상: 80000, 대면: 100000, 실습: 150000, 워크숍: 300000, 트리트먼트: 500000}
level_multiple: {Beginners: 1.0, Intermediate: 1.5, Advanced: 2.0, Expert: 2.5, Trainers: 3.0}
subject_levels: # matrix 모드에서 subject×type 단가 = base × level_multiple[level]
"Content Marketing": Beginners
"SEO": Beginners
"Google Analytics": Intermediate
"Google Tag Manager": Advanced
"Digital Marketing Strategy": Advanced
"Digital Communication": Intermediate
"KPI Setup & Measurement Plan": Expert
"Business Model Canvas": Expert
"e-Commerce Audit": Advanced
student_discount:
apply_default: false # 실제 1:1 견적은 할인 미적용 → 기본 off (opt-in)
bands: [[5, 0.20], [10, 0.0], [20, 0.20], [30, 0.30]] # 30+ = 별도 협의

View File

@@ -0,0 +1,63 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ourdigital-estimate-engine scope input",
"description": "Generic estimate request. Consuming skills map their context into this.",
"type": "object",
"required": ["service"],
"properties": {
"service": {"type": "string", "description": "catalog entry name, e.g. seo | education | digital_ads"},
"method": {"type": "string", "enum": ["effort", "coaching", "procurement"], "description": "optional; defaults to the catalog entry's method"},
"prospect": {
"type": "object",
"properties": {
"name": {"type": "string"}, "domain": {"type": "string"},
"audit_date": {"type": "string"}, "account_code": {"type": "string"}
}
},
"seq": {"type": "integer", "default": 1},
"tier": {"type": "string", "description": "effort: smb|basic|treatment|auto (default auto)"},
"billing_rate": {"type": ["number", "null"], "description": "effort: override billing rate"},
"signals": {
"type": "object",
"description": "effort auto-tiering inputs",
"properties": {
"properties_total": {"type": "integer"},
"subbrands_total": {"type": "integer"},
"vertical": {"type": "string"},
"severity": {"type": "array", "items": {"type": "string"}}
}
},
"course": {"type": "string", "description": "coaching: named course in catalog (else use lessons)"},
"lessons": {
"type": "array",
"description": "coaching: explicit lesson plan",
"items": {
"type": "object",
"required": ["type", "hours"],
"properties": {
"subject": {"type": "string"}, "title": {"type": "string"},
"type": {"type": "string", "enum": ["화상", "대면", "실습", "워크숍", "트리트먼트"]},
"hours": {"type": "number"}
}
}
},
"students": {"type": "integer", "default": 1},
"pricing_mode": {"type": "string", "enum": ["base", "matrix"]},
"apply_student_discount": {"type": "boolean"},
"items": {
"type": "array",
"description": "procurement: non-labor line items",
"items": {
"type": "object",
"required": ["label", "unit_cost"],
"properties": {
"label": {"type": "string"}, "unit_cost": {"type": "number"},
"qty": {"type": "number", "default": 1}, "currency": {"type": "string"}
}
}
}
}
}

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""OurDigital estimate engine — method-aware 견적 generator (CLI dispatcher).
Loads a generic scope.json + the universal rate_card + the service catalog entry,
routes to the costing method (effort | coaching | procurement), enriches with
quote metadata/terms, and renders 05_estimate_ko.md / .xlsx / data/estimate.json.
Usage:
python estimate.py --rate-card references/rate_card.yaml --catalog-dir catalog \
--scope scope.json --out-dir <dir> [--seq N]
scope.json: see scope.schema.json. Consuming skills (e.g. ourdigital-presales-seo)
map their context into scope.json and call this CLI.
"""
import argparse
import datetime
import json
import os
import sys
import yaml
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from methods import coaching, effort, procurement # noqa: E402
import render # noqa: E402
METHODS = {"effort": effort, "coaching": coaching, "procurement": procurement}
DISCLAIMER = {
"effort": ("본 견적은 공개/사전 정보 기반 추정이며 표준 업무시간(SOW)·청구율 {billing}% 기준입니다. "
"권한 확보 후 정밀 진단을 통해 과업 시간과 범위를 확정합니다. 외부 조달 항목은 인력비와 별도, 조달 수수료 15%가 적용될 수 있습니다."),
"coaching": ("본 견적은 레슨 시간 기준으로 작성되었으며(대면 100,000원/h, 화상 80,000원/h 등), 1:1 레슨 전제입니다. "
"그룹 레슨·단체 워크숍/실습은 별도 협의가 필요합니다. 수업 구성은 사전상담으로 맞춤 조율됩니다."),
"procurement": "조달 물품/서비스 견적이며 조달-관리 수수료 15%를 포함합니다. 실제 공급가는 계약 시점에 확정됩니다.",
}
def main():
ap = argparse.ArgumentParser(description="OurDigital estimate engine")
ap.add_argument("--rate-card", required=True)
ap.add_argument("--catalog-dir", required=True)
ap.add_argument("--scope", required=True)
ap.add_argument("--out-dir", default=".")
ap.add_argument("--seq", type=int, default=None)
args = ap.parse_args()
with open(args.rate_card, encoding="utf-8") as fh:
rate = yaml.safe_load(fh)
with open(args.scope, encoding="utf-8") as fh:
scope = json.load(fh)
service = scope["service"]
cat_path = os.path.join(args.catalog_dir, f"{service}.yaml")
if not os.path.exists(cat_path):
sys.exit(f"ERROR: no catalog for service '{service}' at {cat_path}")
with open(cat_path, encoding="utf-8") as fh:
catalog = yaml.safe_load(fh)
method = scope.get("method") or catalog.get("method")
if method not in METHODS:
sys.exit(f"ERROR: unknown method '{method}' (have {list(METHODS)})")
q = METHODS[method].build(scope, rate, catalog)
# enrich with universal metadata
prospect = scope.get("prospect", {})
date = prospect.get("audit_date") or datetime.date.today().isoformat()
d0 = datetime.date.fromisoformat(date)
seq = args.seq if args.seq is not None else scope.get("seq", 1)
q.update({
"quote_no": f"{rate.get('quote_prefix', 'OD')}-{date[:4]}-{seq:03d}",
"date": date,
"valid_until": (d0 + datetime.timedelta(days=rate["terms"]["validity_days"])).isoformat(),
"prospect": prospect, "company": rate["company"], "terms": rate["terms"],
"disclaimer": DISCLAIMER[method].format(billing=int(q.get("billing_rate", rate["billing_rate"]) * 100)),
})
render.write_all(q, args.out_dir)
print(f"견적 {q['quote_no']} [{service}/{method}"
+ (f"/{q['tier']}" if q['kind'] == 'effort' else "")
+ f"] 제안가 {q['proposal']:,}원 (합계 {int(round(q['subtotal_sum'])):,}원)"
+ (" ⚠STUB" if q.get("stub") else ""))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
# estimate-engine costing methods: effort, coaching, procurement.

View File

@@ -0,0 +1,61 @@
"""Coaching method: cost = Σ(lesson_type_price × hours) over a lesson plan.
Base lesson-type prices reproduce real quotes (GA4/GTM 중급 → ₩1,570,000).
Optional matrix mode prices by subject level (base × level_multiple).
Student-count discount is opt-in (real 1:1 quotes apply none).
"""
def _discount(rate, students, apply):
if not apply:
return 0.0
for mx, r in rate["coaching"]["student_discount"]["bands"]:
if students <= mx:
return float(r)
return 0.0 # 30+ → 별도 협의
def _unit_price(rate, lesson, mode):
c = rate["coaching"]
base = c["lesson_type_prices"][lesson["type"]]
if mode == "matrix":
lvl = c.get("subject_levels", {}).get(lesson.get("subject"))
if lvl:
base = base * c["level_multiple"].get(lvl, 1.0)
return base
def build(scope, rate, catalog):
c = rate["coaching"]
mode = scope.get("pricing_mode") or c.get("pricing_mode", "base")
if scope.get("lessons"):
lessons = scope["lessons"]
title = scope.get("course") or "맞춤 코칭"
else:
cname = scope.get("course")
courses = catalog.get("courses", {})
if cname not in courses:
raise SystemExit(f"course '{cname}' not in catalog {list(courses)}")
lessons = courses[cname]["lessons"]
title = courses[cname].get("title", cname)
items, subtotal = [], 0.0
for L in lessons:
up = _unit_price(rate, L, mode)
amt = up * L["hours"]
items.append({"subject": L.get("subject", ""), "title": L.get("title", ""),
"type": L["type"], "hours": L["hours"], "unit_price": up, "amount": amt})
subtotal += amt
students = scope.get("students", 1)
apply = scope.get("apply_student_discount", c["student_discount"]["apply_default"])
disc = _discount(rate, students, apply)
disc_amt = subtotal * disc
total = subtotal - disc_amt
return {
"kind": "coaching", "service": catalog["service"], "label": title,
"pricing_mode": mode, "students": students,
"discount_rate": disc, "discount_amount": disc_amt,
"lessons": items, "subtotal_sum": subtotal, "proposal": int(round(total)),
"stub": bool(catalog.get("_stub", False)),
}

View File

@@ -0,0 +1,95 @@
"""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, recurring = [], 0.0, False
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
unit = task.get("unit", "one_time")
if unit == "monthly":
recurring = True
if "fixed" in task: # quoted lump sum / Unit Cost (no labor-hour basis)
amt = task["fixed"] * applied
tasks.append({"task": task["task"], "desc": task.get("desc", ""), "role": "fixed",
"role_rate": None, "hours": None, "amount": amt,
"scaled": applied != 1.0, "unit": unit})
else:
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, "unit": unit})
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, "recurring": recurring,
"notes": t.get("notes", []),
"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)),
}

View File

@@ -0,0 +1,18 @@
"""Procurement method: cost = Σ(unit_cost × qty × (1 + markup)) for non-labor items."""
def build(scope, rate, catalog):
markup = rate.get("procurement_markup", 0.15)
items, total = [], 0.0
for it in scope.get("items", []):
qty = it.get("qty", 1)
amt = it["unit_cost"] * qty * (1 + markup)
items.append({"label": it["label"], "unit_cost": it["unit_cost"], "qty": qty,
"markup": markup, "amount": amt, "currency": it.get("currency", "KRW")})
total += amt
return {
"kind": "procurement", "service": catalog.get("service", "procurement"),
"label": "조달 항목 (Buying & Supplying)", "markup": markup,
"items": items, "subtotal_sum": total, "proposal": int(round(total)),
"stub": bool(catalog.get("_stub", False)),
}

View File

@@ -0,0 +1,133 @@
"""Render an enriched quote dict to 05_estimate_ko.md, 05_estimate.xlsx, data/estimate.json.
Handles all kinds: effort (modules), coaching (lessons), procurement (items)."""
import json
import os
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
ROLE_KO = {"ceo": "대표", "evp": "전무", "svp": "상무", "technical_advisor": "기술고문",
"director": "이사", "senior_manager": "부장", "deputy_manager": "차장",
"manager": "과장", "assistant_manager": "대리", "junior": "주임",
"associate": "사원", "intern": "인턴"}
NAVY = "11243D"
def won(n):
return f"{int(round(n)):,}"
def _header_md(q):
svc = q.get("label", q["service"])
if q["kind"] == "effort":
svc += f" ({q['tier']})"
L = [f"# 견적서 — {q['prospect'].get('name', '(prospect)')}", "",
f"- **제공 서비스**: {svc}",
f"- **견적번호**: {q['quote_no']} · **작성일**: {q['date']} · **유효기간**: ~{q['valid_until']}",
f"- **공급자**: {q['company']['legal_name']} (대표 {q['company']['ceo']}, {q['company']['contact']})"]
if q.get("stub"):
L.append("- ⚠️ **STUB 카탈로그** — 실제 단가 미반영(placeholder). 실제 견적 자료로 교체 필요.")
L.append("")
return L
def _md(q, path):
L = _header_md(q)
if q["kind"] == "effort":
if q["scope"]["hours_multiplier"] != 1.0:
dl = "브랜드/템플릿" if q["scope"]["driver"] == "subbrands_total" else "프로퍼티"
L += [f"> 규모 반영: {dl} {q['scope']['driver_count']}개 기준 On-page 업무시간 ×{q['scope']['hours_multiplier']:g}", ""]
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 ""
role_lbl = "고정" if t["role"] == "fixed" else ROLE_KO.get(t["role"], t["role"])
hrs = f"{t['hours']:g}" if t.get("hours") is not None else ""
umark = " /월" if t.get("unit") == "monthly" else ""
L.append(f"| {grp} | {t['task']}{mark} | {role_lbl} | {hrs} | {won(t['amount'])}{umark} |")
L.append(f"| | **{m['name']} 소계** | | | **{won(m['subtotal'])}** |")
plabel = "제안가(월/retainer)" if q.get("recurring") else "제안가(절사 적용)"
L += ["", f"- 합계: **{won(q['subtotal_sum'])}** · 청구율 {int(q['billing_rate']*100)}% · 일8h/월4주",
f"- **{plabel}: {won(q['proposal'])}** ({q['terms']['vat']})"]
if q.get("recurring"):
L.append("- ※ '/월' 항목은 월 정기(retainer) 비용 — 계약 기간에 따라 합산.")
elif q["kind"] == "coaching":
L += ["## 견적 내역", "", "| 구분 | 세부 | 방식 | 시간 | 단가 | 합계 |", "|---|---|:--:|--:|--:|--:|"]
for it in q["lessons"]:
L.append(f"| {it['subject']} | {it['title']} | {it['type']} | {it['hours']:g} | {won(it['unit_price'])} | {won(it['amount'])} |")
L += ["", f"- 합계: **{won(q['subtotal_sum'])}** ({q['pricing_mode']} 단가, 수강생 {q['students']}명)"]
if q["discount_rate"]:
L.append(f"- 할인({int(q['discount_rate']*100)}%): -{won(q['discount_amount'])}")
L.append(f"- **제안가: {won(q['proposal'])}** ({q['terms']['vat']})")
elif q["kind"] == "procurement":
L += ["## 조달 내역", "", "| 항목 | 단가 | 수량 | 수수료 | 합계 |", "|---|--:|--:|:--:|--:|"]
for it in q["items"]:
L.append(f"| {it['label']} | {it['unit_cost']:,}{it['currency']} | {it['qty']:g} | {int(it['markup']*100)}% | {won(it['amount'])} |")
L += ["", f"- **합계: {won(q['proposal'])}** ({q['terms']['vat']})"]
L += ["", "---", f"> {q['disclaimer']}"]
if q["kind"] == "effort" and any(t["scaled"] for m in q["modules"] for t in m["tasks"]):
L.append("> \\* 포트폴리오 규모에 따라 업무시간이 스케일된 항목.")
for n in q.get("notes", []):
L.append(f"> {n}")
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(L) + "\n")
def _xlsx(q, path):
wb = Workbook()
ws = wb.active
ws.title = "견적"
ws.append([f"견적서 — {q['prospect'].get('name', '(prospect)')} ({q.get('label', q['service'])})"])
ws.append([f"견적번호 {q['quote_no']}", f"작성일 {q['date']}", f"유효 ~{q['valid_until']}", q["terms"]["vat"]])
ws.append([])
def hdr(cols):
ws.append(cols)
for c in range(1, len(cols) + 1):
cell = ws.cell(row=ws.max_row, column=c)
cell.fill = PatternFill("solid", fgColor=NAVY)
cell.font = Font(color="FFFFFF", bold=True)
if q["kind"] == "effort":
hdr(["구분", "세부 업무", "담당", "시간(h)", "합계(원)"])
for m in q["modules"]:
for i, t in enumerate(m["tasks"]):
role_lbl = "고정" if t["role"] == "fixed" else ROLE_KO.get(t["role"], t["role"])
hrs = t["hours"] if t.get("hours") is not None else ""
ws.append([m["name"] if i == 0 else "", t["task"], role_lbl, hrs, int(round(t["amount"]))])
ws.append(["", f"{m['name']} 소계", "", "", int(round(m["subtotal"]))])
ws.append([])
ws.append(["", "제안가(월/retainer)" if q.get("recurring") else "제안가(절사 적용)", "", "", int(q["proposal"])])
widths = [22, 40, 8, 8, 16]
elif q["kind"] == "coaching":
hdr(["구분", "세부", "방식", "시간", "단가", "합계(원)"])
for it in q["lessons"]:
ws.append([it["subject"], it["title"], it["type"], it["hours"], int(it["unit_price"]), int(round(it["amount"]))])
ws.append([])
ws.append(["", "제안가", "", "", "", int(q["proposal"])])
widths = [18, 34, 8, 6, 12, 14]
else: # procurement
hdr(["항목", "단가", "수량", "수수료", "합계(원)"])
for it in q["items"]:
ws.append([it["label"], it["unit_cost"], it["qty"], it["markup"], int(round(it["amount"]))])
ws.append([])
ws.append(["", "합계", "", "", int(q["proposal"])])
widths = [34, 14, 8, 10, 16]
ws.cell(row=ws.max_row, column=2).font = Font(bold=True)
ws.cell(row=ws.max_row, column=len(widths)).font = Font(bold=True, color="C0392B")
ws.append([])
ws.append([q["disclaimer"]])
for idx, w in enumerate(widths, 1):
ws.column_dimensions[chr(64 + idx)].width = w
wb.save(path)
def write_all(q, out_dir):
os.makedirs(out_dir, exist_ok=True)
ddir = os.path.join(out_dir, "data")
os.makedirs(ddir, exist_ok=True)
with open(os.path.join(ddir, "estimate.json"), "w", encoding="utf-8") as fh:
json.dump(q, fh, ensure_ascii=False, indent=2)
_md(q, os.path.join(out_dir, "05_estimate_ko.md"))
_xlsx(q, os.path.join(out_dir, "05_estimate.xlsx"))

View File

@@ -51,7 +51,7 @@ description: | # third person; START with "Use when…" / wh
---
```
- [ ] `name`: kebab-case, no spaces/parens/special chars. (May keep the `NN-` prefix.)
- [ ] `name`: the **clean** skill name = directory name **minus its `NN-` ordering prefix** (dir `16-seo-schema-validator``name: seo-schema-validator`). kebab-case, no spaces/parens/special chars. Dirs keep the number prefix for ordering; the skill `name` never carries it.
- [ ] `description`: third-person; says **when to use** + trigger keywords (EN + KO).
- [ ] **Whole frontmatter ≤ 1024 characters.**
- [ ] Body: concise markdown (aim < 500 words of always-on content); push detail into

View File

@@ -4,7 +4,8 @@ migrate_skill_root.py — additive bulk migration: give each skill a root SKILL.
Non-destructive. For every skill directory under custom-skills/ that lacks a root
SKILL.md, it generates one by copying the skill's existing directive and:
- setting `name:` to the directory name (guaranteed kebab-case + unique),
- setting `name:` to the directory name MINUS its NN- ordering prefix
(dir `16-seo-schema-validator` -> name `seo-schema-validator`; clean + unique),
- preserving `description` and the markdown body verbatim,
- validating the result (frontmatter <= 1024 chars, kebab name, description present).
@@ -29,6 +30,11 @@ FM_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.S)
MAX_FM = 1024
def skill_name(dirname):
"""Skill `name:` = directory name without its NN- ordering prefix."""
return re.sub(r"^\d+-", "", dirname)
def is_skill_dir(d):
"""A skill dir is a real skill, not shared infra."""
if not d.is_dir():
@@ -106,7 +112,7 @@ def main(argv=None):
rows.append((name, "MANUAL", "no desktop/ or code/ SKILL.md source (commands/README only)"))
manual += 1
continue
text, issues = build_root_skill(src.read_text(encoding="utf-8"), d.name)
text, issues = build_root_skill(src.read_text(encoding="utf-8"), skill_name(d.name))
rel = src.relative_to(d)
if text is None:
rows.append((name, "MANUAL", f"{rel}: {issues[0]}"))