Compare commits
1 Commits
main
...
eedab0b254
| Author | SHA1 | Date | |
|---|---|---|---|
| eedab0b254 |
@@ -59,7 +59,6 @@
|
|||||||
"./custom-skills/32-seo-crawl-budget",
|
"./custom-skills/32-seo-crawl-budget",
|
||||||
"./custom-skills/33-seo-migration-planner",
|
"./custom-skills/33-seo-migration-planner",
|
||||||
"./custom-skills/34-seo-reporting-dashboard",
|
"./custom-skills/34-seo-reporting-dashboard",
|
||||||
"./custom-skills/35-seo-signal-validation",
|
|
||||||
"./custom-skills/95-ourdigital-presales-seo"
|
"./custom-skills/95-ourdigital-presales-seo"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
description: D.intelligence campaign/promotion planning as a 3-gate process (cross-brand)
|
|
||||||
---
|
|
||||||
|
|
||||||
# D.intelligence Campaign Designer
|
|
||||||
|
|
||||||
Plans campaigns, promotions, events, and launches as a 3-gate process -- Discovery & Debate → Brief → Plan -- instead of jumping straight to a finished document. Draft & Wait autonomy: each gate stops for explicit approval. Not D.intelligence-exclusive -- works for any brand.
|
|
||||||
|
|
||||||
## Triggers
|
|
||||||
- "campaign plan", "plan a promotion", "캠페인 설계"
|
|
||||||
- 캠페인 기획, 프로모션 기획, 기획안 만들어, 이벤트 기획
|
|
||||||
|
|
||||||
## The 3 Gates
|
|
||||||
1. **Discovery & Debate** -- agree ONE primary objective; steelman + devil's-advocate debate; pre-mortem; 1-3 reference cases; effects as hypotheses. → `shared/templates/gate1-decision-log.md`
|
|
||||||
2. **Brief** -- objective, audience, offer, message, tone, channel; outcome metrics across 4 tiers. → `shared/templates/gate2-campaign-brief.md`
|
|
||||||
3. **Plan** -- full plan, handed off to `marketing:campaign-plan` + `doc-generator`; all risk/compliance consolidated in one closing "준비 점검 사항" section. → `shared/templates/gate3-plan-outline.md`
|
|
||||||
|
|
||||||
## 4-Tier Outcome Framework
|
|
||||||
Awareness/cognitive → Qualitative (name the brand asset) → Relationship/advocacy → Quantitative conversion (label as hypothesis if no baseline)
|
|
||||||
|
|
||||||
## Cross-Brand Routing
|
|
||||||
| Brand | Copy & tone | Compliance |
|
|
||||||
|---|---|---|
|
|
||||||
| D.intelligence | dintel-brand-editor (#71) | dintel-brand-guardian (#70) |
|
|
||||||
| Jamie | jamie-copy-trimmer (48) | jamie-brand-audit (41) |
|
|
||||||
| OurDigital | ourdigital-ad-manager (07) | ourdigital-brand-guide (01) |
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
- Never advance a gate without explicit user approval
|
|
||||||
- Never commit pricing/quantitative targets without a baseline -- label as hypothesis
|
|
||||||
- Mark gaps `[확인]` instead of inventing facts
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
description: Trims and sharpens Korean plastic-surgery/aesthetic marketing copy against cliché & compliance corpus
|
|
||||||
---
|
|
||||||
|
|
||||||
# Jamie Copy Trimmer
|
|
||||||
|
|
||||||
Trim and sharpen Korean plastic-surgery / aesthetic-medical marketing copy against an industry expression corpus, within 의료광고 심의 limits. Guidance-only skill (no scripts).
|
|
||||||
|
|
||||||
## Triggers
|
|
||||||
- "카피 다듬어", "카피 트리밍", "네이밍 검토", "슬로건 다듬어"
|
|
||||||
- "심의 안전하게", "copy trim", "make it catchier"
|
|
||||||
|
|
||||||
## Core Philosophy
|
|
||||||
- The corpus is a map for AVOIDING clichés, not a library to copy
|
|
||||||
- 의료광고 심의 is a gate, not a score -- one 🔴 risk expression fails the option outright
|
|
||||||
- Trim first, dazzle second
|
|
||||||
- Don't guess -- mark `[확인]`
|
|
||||||
|
|
||||||
## Workflow (5 steps)
|
|
||||||
1. **Diagnose** -- tag each phrase 🟢 effective / 🟡 cliché / 🔴 compliance risk / ⚪ flat / 🟦 brand asset
|
|
||||||
2. **Trim** -- remove all 🔴, replace 🟡, delete redundancy
|
|
||||||
3. **Elevate** -- 2-3 alternatives per element within 심의 limits, matched to channel tone
|
|
||||||
4. **Re-score** -- 5-axis rubric (감각/차별성/브랜드적합성/심의 PASS-FAIL/명료성)
|
|
||||||
5. **Recursive Improvement** -- propose feeding adopted/rejected expressions back into the corpus
|
|
||||||
|
|
||||||
## Output (Korean)
|
|
||||||
진단 → 트리밍 → 대안 → 재평가 → 추천안 → 준비 점검 사항 (risks/gaps consolidated at the end)
|
|
||||||
|
|
||||||
## References
|
|
||||||
- `references/corpus_compliance_risk.md` -- medical-ad risk expressions (most important)
|
|
||||||
- `references/corpus_cliche.md`, `corpus_effective.md`, `corpus_examples.md`
|
|
||||||
- `references/witty_within_limits.md`, `evaluation_rubric.md`, `recursive_protocol.md`
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
- Compliance judgment here is guidance, not legal advice -- always recommend pre-publication 의료광고 자율심의
|
|
||||||
- A specific brand's tone guide (e.g. jamie-brand-audit) overrides this skill's taste defaults
|
|
||||||
@@ -24,7 +24,7 @@ Push markdown content to Notion pages or databases via the Notion API.
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
cd ~/Projects/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
||||||
|
|
||||||
# Test connection
|
# Test connection
|
||||||
python notion_writer.py --test
|
python notion_writer.py --test
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -99,7 +99,3 @@ build/
|
|||||||
# Temporary files
|
# Temporary files
|
||||||
output/
|
output/
|
||||||
keyword_analysis_*.json
|
keyword_analysis_*.json
|
||||||
|
|
||||||
# graphify: keep graph.json/html/report, drop regenerable cache + dated backups
|
|
||||||
graphify-out/cache/
|
|
||||||
graphify-out/????-??-??/
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# graphify scope control — exclude non-standard virtualenvs that the default
|
|
||||||
# rules (.venv, venv) don't catch, so pip-package source doesn't pollute the graph.
|
|
||||||
.venv-ourdigital/
|
|
||||||
.venv-*/
|
|
||||||
@@ -77,7 +77,6 @@ This is a Claude Skills collection repository containing:
|
|||||||
| 45 | jamie-instagram-manager | Instagram account management | "Instagram management", "IG strategy" |
|
| 45 | jamie-instagram-manager | Instagram account management | "Instagram management", "IG strategy" |
|
||||||
| 46 | jamie-journal-editor | Journal/blog content for journal.jamie.clinic | "Jamie journal", "제이미 저널", "진료실 이야기" |
|
| 46 | jamie-journal-editor | Journal/blog content for journal.jamie.clinic | "Jamie journal", "제이미 저널", "진료실 이야기" |
|
||||||
| 47 | jamie-marketing-editor | Multi-channel marketing content & ad copy | "Jamie marketing", "제이미 마케팅", "광고 카피" |
|
| 47 | jamie-marketing-editor | Multi-channel marketing content & ad copy | "Jamie marketing", "제이미 마케팅", "광고 카피" |
|
||||||
| 48 | jamie-copy-trimmer | Trim/sharpen Korean aesthetic-medical copy against cliché & compliance corpus | "카피 다듬어", "카피 트리밍", "심의 안전하게", "copy trim" |
|
|
||||||
|
|
||||||
### NotebookLM Tools (50-59)
|
### NotebookLM Tools (50-59)
|
||||||
|
|
||||||
@@ -110,7 +109,6 @@ This is a Claude Skills collection repository containing:
|
|||||||
| 75 | dintel-marketing-mgr | Content pipeline (Magazine D., newsletter, LinkedIn) | Draft & Wait | "콘텐츠 발행", "newsletter" |
|
| 75 | dintel-marketing-mgr | Content pipeline (Magazine D., newsletter, LinkedIn) | Draft & Wait | "콘텐츠 발행", "newsletter" |
|
||||||
| 76 | dintel-backoffice-mgr | Invoicing, contracts, NDA, HR operations | Draft & Wait | "계약서", "인보이스" |
|
| 76 | dintel-backoffice-mgr | Invoicing, contracts, NDA, HR operations | Draft & Wait | "계약서", "인보이스" |
|
||||||
| 77 | dintel-account-mgr | Client relationship management & monitoring | Mixed | "client status", "미팅 준비" |
|
| 77 | dintel-account-mgr | Client relationship management & monitoring | Mixed | "client status", "미팅 준비" |
|
||||||
| 78 | dintel-campaign-designer | Campaign/promotion planning as a 3-gate process (Discovery & Debate → Brief → Plan); cross-brand, not D.intelligence-exclusive | Draft & Wait | "campaign plan", "캠페인 기획", "기획안 만들어" |
|
|
||||||
| 79 | dintel-skill-update | Cross-skill consistency management (meta-agent) | Triggered | "skill sync", "스킬 업데이트" |
|
| 79 | dintel-skill-update | Cross-skill consistency management (meta-agent) | Triggered | "skill sync", "스킬 업데이트" |
|
||||||
|
|
||||||
**Shared infrastructure:** `_dintel-shared/` (Python package + reference docs)
|
**Shared infrastructure:** `_dintel-shared/` (Python package + reference docs)
|
||||||
@@ -255,7 +253,6 @@ our-claude-skills/
|
|||||||
│ ├── 45-jamie-instagram-manager/
|
│ ├── 45-jamie-instagram-manager/
|
||||||
│ ├── 46-jamie-journal-editor/
|
│ ├── 46-jamie-journal-editor/
|
||||||
│ ├── 47-jamie-marketing-editor/
|
│ ├── 47-jamie-marketing-editor/
|
||||||
│ ├── 48-jamie-copy-trimmer/
|
|
||||||
│ │
|
│ │
|
||||||
│ ├── 50-notebooklm-agent/
|
│ ├── 50-notebooklm-agent/
|
||||||
│ ├── 51-notebooklm-automation/
|
│ ├── 51-notebooklm-automation/
|
||||||
@@ -274,7 +271,6 @@ our-claude-skills/
|
|||||||
│ ├── 75-dintel-marketing-mgr/
|
│ ├── 75-dintel-marketing-mgr/
|
||||||
│ ├── 76-dintel-backoffice-mgr/
|
│ ├── 76-dintel-backoffice-mgr/
|
||||||
│ ├── 77-dintel-account-mgr/
|
│ ├── 77-dintel-account-mgr/
|
||||||
│ ├── 78-dintel-campaign-designer/
|
|
||||||
│ ├── 79-dintel-skill-update/
|
│ ├── 79-dintel-skill-update/
|
||||||
│ │
|
│ │
|
||||||
│ ├── 80-claude-settings-optimizer/
|
│ ├── 80-claude-settings-optimizer/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,200 +0,0 @@
|
|||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"version": "1.0",
|
|
||||||
"scope": "Curated, hotel-focused subset of schema.org + Google rich-result requirements.",
|
|
||||||
"intent": "Self-contained offline rules (the runtime cannot reach schema.org or Google). Unknown types/properties degrade to warnings, never hard errors, to avoid false positives. To support a new type or tighten a rule, edit THIS file only.",
|
|
||||||
"sources": "schema.org/Hotel, schema.org/LocalBusiness, Google Search Central 'Structured data' rich-result docs (as of 2025)."
|
|
||||||
},
|
|
||||||
|
|
||||||
"valid_contexts": [
|
|
||||||
"https://schema.org",
|
|
||||||
"http://schema.org",
|
|
||||||
"https://schema.org/",
|
|
||||||
"http://schema.org/",
|
|
||||||
"https://www.schema.org",
|
|
||||||
"http://www.schema.org"
|
|
||||||
],
|
|
||||||
|
|
||||||
"global_properties": [
|
|
||||||
"@context", "@type", "@id", "@graph", "@reverse",
|
|
||||||
"name", "alternateName", "legalName", "description", "disambiguatingDescription",
|
|
||||||
"url", "image", "logo", "sameAs", "identifier", "mainEntityOfPage",
|
|
||||||
"additionalType", "subjectOf", "potentialAction", "inLanguage"
|
|
||||||
],
|
|
||||||
|
|
||||||
"known_types": {
|
|
||||||
"Organization": {
|
|
||||||
"required": ["name", "url"],
|
|
||||||
"recommended": ["logo", "sameAs", "contactPoint", "address"],
|
|
||||||
"allowed": ["legalName", "foundingDate", "parentOrganization", "subOrganization", "brand", "telephone", "email", "founder", "numberOfEmployees", "memberOf", "hasMerchantReturnPolicy", "member"]
|
|
||||||
},
|
|
||||||
"Corporation": {
|
|
||||||
"required": ["name", "url"],
|
|
||||||
"recommended": ["logo", "sameAs", "address"],
|
|
||||||
"allowed": ["legalName", "foundingDate", "parentOrganization", "tickerSymbol", "telephone", "email", "brand"]
|
|
||||||
},
|
|
||||||
"WebSite": {
|
|
||||||
"required": ["name", "url"],
|
|
||||||
"recommended": ["publisher", "potentialAction", "inLanguage"],
|
|
||||||
"allowed": ["alternateName", "about", "copyrightHolder", "copyrightYear"]
|
|
||||||
},
|
|
||||||
"WebPage": {
|
|
||||||
"required": ["name"],
|
|
||||||
"recommended": ["url", "isPartOf", "primaryImageOfPage", "breadcrumb", "datePublished", "dateModified"],
|
|
||||||
"allowed": ["about", "mentions", "speakable", "lastReviewed", "reviewedBy", "significantLink"]
|
|
||||||
},
|
|
||||||
"LocalBusiness": {
|
|
||||||
"required": ["name", "address"],
|
|
||||||
"recommended": ["telephone", "openingHoursSpecification", "geo", "image", "url", "priceRange", "aggregateRating"],
|
|
||||||
"allowed": ["email", "openingHours", "paymentAccepted", "currenciesAccepted", "areaServed", "hasMap", "department", "menu", "review", "containedInPlace", "containsPlace", "amenityFeature"]
|
|
||||||
},
|
|
||||||
"Hotel": {
|
|
||||||
"required": ["name", "address"],
|
|
||||||
"recommended": ["telephone", "image", "priceRange", "geo", "url", "starRating", "aggregateRating", "checkinTime", "checkoutTime"],
|
|
||||||
"allowed": ["email", "amenityFeature", "petsAllowed", "numberOfRooms", "availableLanguage", "containedInPlace", "containsPlace", "makesOffer", "brand", "currenciesAccepted", "smokingAllowed", "openingHoursSpecification", "audience", "review"]
|
|
||||||
},
|
|
||||||
"LodgingBusiness": {
|
|
||||||
"required": ["name", "address"],
|
|
||||||
"recommended": ["telephone", "image", "priceRange", "geo", "url", "starRating", "aggregateRating", "checkinTime", "checkoutTime"],
|
|
||||||
"allowed": ["email", "amenityFeature", "petsAllowed", "numberOfRooms", "availableLanguage", "containedInPlace", "containsPlace", "makesOffer", "currenciesAccepted", "smokingAllowed"]
|
|
||||||
},
|
|
||||||
"Resort": {
|
|
||||||
"required": ["name", "address"],
|
|
||||||
"recommended": ["telephone", "image", "priceRange", "geo", "url", "starRating", "aggregateRating"],
|
|
||||||
"allowed": ["email", "amenityFeature", "numberOfRooms", "containedInPlace", "containsPlace", "checkinTime", "checkoutTime"]
|
|
||||||
},
|
|
||||||
"Restaurant": {
|
|
||||||
"required": ["name", "address"],
|
|
||||||
"recommended": ["servesCuisine", "priceRange", "telephone", "menu", "openingHoursSpecification", "image", "url", "geo", "acceptsReservations"],
|
|
||||||
"allowed": ["email", "hasMenu", "starRating", "aggregateRating", "review", "containedInPlace", "smokingAllowed"]
|
|
||||||
},
|
|
||||||
"FoodEstablishment": {
|
|
||||||
"required": ["name", "address"],
|
|
||||||
"recommended": ["servesCuisine", "priceRange", "telephone", "menu", "openingHoursSpecification"],
|
|
||||||
"allowed": ["email", "hasMenu", "acceptsReservations", "containedInPlace"]
|
|
||||||
},
|
|
||||||
"BarOrPub": {
|
|
||||||
"required": ["name", "address"],
|
|
||||||
"recommended": ["telephone", "openingHoursSpecification", "priceRange", "servesCuisine"],
|
|
||||||
"allowed": ["menu", "hasMenu", "image", "url"]
|
|
||||||
},
|
|
||||||
"FAQPage": {
|
|
||||||
"required": ["mainEntity"],
|
|
||||||
"recommended": [],
|
|
||||||
"allowed": ["about", "headline", "datePublished", "dateModified"]
|
|
||||||
},
|
|
||||||
"Question": {
|
|
||||||
"required": ["name", "acceptedAnswer"],
|
|
||||||
"recommended": [],
|
|
||||||
"allowed": ["text", "answerCount", "suggestedAnswer", "upvoteCount", "author"]
|
|
||||||
},
|
|
||||||
"Answer": {
|
|
||||||
"required": ["text"],
|
|
||||||
"recommended": [],
|
|
||||||
"allowed": ["url", "upvoteCount", "author", "dateCreated"]
|
|
||||||
},
|
|
||||||
"BreadcrumbList": {
|
|
||||||
"required": ["itemListElement"],
|
|
||||||
"recommended": [],
|
|
||||||
"allowed": ["numberOfItems", "itemListOrder"]
|
|
||||||
},
|
|
||||||
"ItemList": {
|
|
||||||
"required": ["itemListElement"],
|
|
||||||
"recommended": [],
|
|
||||||
"allowed": ["numberOfItems", "itemListOrder"]
|
|
||||||
},
|
|
||||||
"ListItem": {
|
|
||||||
"required": ["position"],
|
|
||||||
"recommended": ["item", "name"],
|
|
||||||
"allowed": ["url", "image", "nextItem", "previousItem"]
|
|
||||||
},
|
|
||||||
"Product": {
|
|
||||||
"required": ["name"],
|
|
||||||
"recommended": ["image", "offers", "brand", "aggregateRating", "review", "description", "sku"],
|
|
||||||
"allowed": ["gtin", "gtin13", "gtin8", "gtin12", "mpn", "color", "material", "category", "audience", "isVariantOf", "additionalProperty", "hasMerchantReturnPolicy"]
|
|
||||||
},
|
|
||||||
"Offer": {
|
|
||||||
"required": ["price", "priceCurrency"],
|
|
||||||
"recommended": ["availability", "url", "validFrom", "priceValidUntil"],
|
|
||||||
"allowed": ["itemCondition", "seller", "eligibleRegion", "priceSpecification", "shippingDetails", "availabilityStarts"]
|
|
||||||
},
|
|
||||||
"AggregateOffer": {
|
|
||||||
"required": ["lowPrice", "priceCurrency"],
|
|
||||||
"recommended": ["highPrice", "offerCount"],
|
|
||||||
"allowed": ["offers", "availability"]
|
|
||||||
},
|
|
||||||
"Article": {
|
|
||||||
"required": ["headline"],
|
|
||||||
"recommended": ["author", "datePublished", "image", "dateModified", "publisher"],
|
|
||||||
"allowed": ["articleBody", "articleSection", "wordCount", "keywords", "speakable"]
|
|
||||||
},
|
|
||||||
"NewsArticle": {
|
|
||||||
"required": ["headline"],
|
|
||||||
"recommended": ["author", "datePublished", "image", "dateModified", "publisher"],
|
|
||||||
"allowed": ["articleBody", "dateline", "printSection"]
|
|
||||||
},
|
|
||||||
"BlogPosting": {
|
|
||||||
"required": ["headline"],
|
|
||||||
"recommended": ["author", "datePublished", "image", "dateModified", "publisher"],
|
|
||||||
"allowed": ["articleBody", "keywords", "wordCount"]
|
|
||||||
},
|
|
||||||
"Event": {
|
|
||||||
"required": ["name", "startDate", "location"],
|
|
||||||
"recommended": ["endDate", "offers", "performer", "image", "eventStatus", "eventAttendanceMode", "organizer"],
|
|
||||||
"allowed": ["doorTime", "previousStartDate", "typicalAgeRange", "maximumAttendeeCapacity"]
|
|
||||||
},
|
|
||||||
"Review": {
|
|
||||||
"required": ["reviewRating", "author"],
|
|
||||||
"recommended": ["datePublished", "reviewBody", "itemReviewed"],
|
|
||||||
"allowed": ["publisher", "name"]
|
|
||||||
},
|
|
||||||
"AggregateRating": {
|
|
||||||
"required": ["ratingValue"],
|
|
||||||
"recommended": ["reviewCount", "ratingCount", "bestRating"],
|
|
||||||
"allowed": ["worstRating", "itemReviewed"]
|
|
||||||
},
|
|
||||||
"MemberProgram": {
|
|
||||||
"required": ["name"],
|
|
||||||
"recommended": ["hasTiers", "hostingOrganization", "url"],
|
|
||||||
"allowed": ["description", "membershipPointsEarned"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"container_types": [
|
|
||||||
"PostalAddress", "GeoCoordinates", "GeoShape", "ImageObject", "VideoObject",
|
|
||||||
"ContactPoint", "OpeningHoursSpecification", "Rating", "QuantitativeValue",
|
|
||||||
"MonetaryAmount", "PriceSpecification", "Brand", "EntryPoint", "Place",
|
|
||||||
"OfferCatalog", "ReserveAction", "OrderAction", "SearchAction", "ViewAction",
|
|
||||||
"MeetingRoom", "Room", "HotelRoom", "Suite", "LocationFeatureSpecification",
|
|
||||||
"MemberProgramTier", "MobileApplication", "WebApplication", "SoftwareApplication",
|
|
||||||
"Menu", "MenuItem", "MenuSection", "Country", "AdministrativeArea", "Duration",
|
|
||||||
"PropertyValue", "Person", "Audience", "Language"
|
|
||||||
],
|
|
||||||
|
|
||||||
"value_formats": {
|
|
||||||
"url_props": ["url", "logo", "sameAs", "image", "contentUrl", "thumbnailUrl", "target", "urlTemplate", "installUrl", "menu", "hasMap", "downloadUrl", "embedUrl"],
|
|
||||||
"date_props": ["datePublished", "dateModified", "dateCreated", "startDate", "endDate", "validFrom", "validThrough", "priceValidUntil", "foundingDate", "uploadDate", "availabilityStarts", "availabilityEnds", "lastReviewed", "previousStartDate"],
|
|
||||||
"lang_props": ["inLanguage", "availableLanguage"],
|
|
||||||
"currency_props": ["priceCurrency", "currenciesAccepted"],
|
|
||||||
"number_props": ["price", "lowPrice", "highPrice", "ratingValue", "reviewCount", "ratingCount", "bestRating", "worstRating", "position", "numberOfRooms", "maxValue", "minValue", "offerCount"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"valid_currencies": ["KRW", "USD", "EUR", "JPY", "CNY", "GBP", "HKD", "SGD", "THB", "AUD", "CAD", "CHF", "TWD", "MYR", "PHP", "VND", "IDR", "INR"],
|
|
||||||
|
|
||||||
"valid_language_codes": ["ko", "en", "ja", "zh", "zh-CN", "zh-TW", "zh-Hans", "zh-Hant", "ko-KR", "en-US", "en-GB", "ja-JP", "fr", "de", "es", "ru", "th", "vi", "id", "ms"],
|
|
||||||
|
|
||||||
"placeholder_tokens": [
|
|
||||||
"lorem ipsum", "lorem", "ipsum", "dolor sit", "todo", "tbd", "fixme",
|
|
||||||
"xxx", "yyy", "zzz", "placeholder", "insert here", "insert text",
|
|
||||||
"example.com", "your-domain", "yourdomain", "changeme", "sample text",
|
|
||||||
"{{", "}}", "<insert", "[insert", "n/a", "샘플", "예시", "여기에",
|
|
||||||
"변경필요", "수정필요", "입력필요", "내용입력", "테스트", "임시"
|
|
||||||
],
|
|
||||||
|
|
||||||
"geo": {
|
|
||||||
"lat_min": -90.0, "lat_max": 90.0,
|
|
||||||
"lon_min": -180.0, "lon_max": 180.0,
|
|
||||||
"kr_lat_range": [33.0, 39.0],
|
|
||||||
"kr_lon_range": [124.0, 132.0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,876 +0,0 @@
|
|||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"version": "1.1",
|
|
||||||
"scope": "Curated, hotel-focused subset of schema.org + Google rich-result requirements.",
|
|
||||||
"intent": "Self-contained offline rules (the runtime cannot reach schema.org or Google). Unknown types/properties degrade to warnings, never hard errors, to avoid false positives. To support a new type or tighten a rule, edit THIS file only. [v1.1 2026-05-29: +EventVenue/ExerciseGym/SportsActivityLocation/HealthAndBeautyBusiness/DaySpa/CafeOrCoffeeShop, +LodgingReservation container, Organization/Corporation url->recommended per Google, +slogan/founder/ceo/parentOrganization/award/hasOfferCatalog/openingDate/isPartOf allowances.]",
|
|
||||||
"sources": "schema.org/Hotel, schema.org/LocalBusiness, Google Search Central 'Structured data' rich-result docs (as of 2025)."
|
|
||||||
},
|
|
||||||
"valid_contexts": [
|
|
||||||
"https://schema.org",
|
|
||||||
"http://schema.org",
|
|
||||||
"https://schema.org/",
|
|
||||||
"http://schema.org/",
|
|
||||||
"https://www.schema.org",
|
|
||||||
"http://www.schema.org"
|
|
||||||
],
|
|
||||||
"global_properties": [
|
|
||||||
"@context",
|
|
||||||
"@type",
|
|
||||||
"@id",
|
|
||||||
"@graph",
|
|
||||||
"@reverse",
|
|
||||||
"name",
|
|
||||||
"alternateName",
|
|
||||||
"legalName",
|
|
||||||
"description",
|
|
||||||
"disambiguatingDescription",
|
|
||||||
"url",
|
|
||||||
"image",
|
|
||||||
"logo",
|
|
||||||
"sameAs",
|
|
||||||
"identifier",
|
|
||||||
"mainEntityOfPage",
|
|
||||||
"additionalType",
|
|
||||||
"subjectOf",
|
|
||||||
"potentialAction",
|
|
||||||
"inLanguage",
|
|
||||||
"isPartOf"
|
|
||||||
],
|
|
||||||
"known_types": {
|
|
||||||
"Organization": {
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"logo",
|
|
||||||
"sameAs",
|
|
||||||
"contactPoint",
|
|
||||||
"address",
|
|
||||||
"url"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"legalName",
|
|
||||||
"foundingDate",
|
|
||||||
"parentOrganization",
|
|
||||||
"subOrganization",
|
|
||||||
"brand",
|
|
||||||
"telephone",
|
|
||||||
"email",
|
|
||||||
"founder",
|
|
||||||
"numberOfEmployees",
|
|
||||||
"memberOf",
|
|
||||||
"hasMerchantReturnPolicy",
|
|
||||||
"member",
|
|
||||||
"slogan",
|
|
||||||
"hasOfferCatalog"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Corporation": {
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"logo",
|
|
||||||
"sameAs",
|
|
||||||
"address",
|
|
||||||
"url"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"legalName",
|
|
||||||
"foundingDate",
|
|
||||||
"parentOrganization",
|
|
||||||
"tickerSymbol",
|
|
||||||
"telephone",
|
|
||||||
"email",
|
|
||||||
"brand",
|
|
||||||
"founder",
|
|
||||||
"ceo",
|
|
||||||
"subOrganization",
|
|
||||||
"memberOf",
|
|
||||||
"slogan",
|
|
||||||
"hasOfferCatalog"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"WebSite": {
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"url"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"publisher",
|
|
||||||
"potentialAction",
|
|
||||||
"inLanguage"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"alternateName",
|
|
||||||
"about",
|
|
||||||
"copyrightHolder",
|
|
||||||
"copyrightYear"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"WebPage": {
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"url",
|
|
||||||
"isPartOf",
|
|
||||||
"primaryImageOfPage",
|
|
||||||
"breadcrumb",
|
|
||||||
"datePublished",
|
|
||||||
"dateModified"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"about",
|
|
||||||
"mentions",
|
|
||||||
"speakable",
|
|
||||||
"lastReviewed",
|
|
||||||
"reviewedBy",
|
|
||||||
"significantLink"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"LocalBusiness": {
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"address"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"telephone",
|
|
||||||
"openingHoursSpecification",
|
|
||||||
"geo",
|
|
||||||
"image",
|
|
||||||
"url",
|
|
||||||
"priceRange",
|
|
||||||
"aggregateRating"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"email",
|
|
||||||
"openingHours",
|
|
||||||
"paymentAccepted",
|
|
||||||
"currenciesAccepted",
|
|
||||||
"areaServed",
|
|
||||||
"hasMap",
|
|
||||||
"department",
|
|
||||||
"menu",
|
|
||||||
"review",
|
|
||||||
"containedInPlace",
|
|
||||||
"containsPlace",
|
|
||||||
"amenityFeature"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Hotel": {
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"address"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"telephone",
|
|
||||||
"image",
|
|
||||||
"priceRange",
|
|
||||||
"geo",
|
|
||||||
"url",
|
|
||||||
"starRating",
|
|
||||||
"aggregateRating",
|
|
||||||
"checkinTime",
|
|
||||||
"checkoutTime"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"email",
|
|
||||||
"amenityFeature",
|
|
||||||
"petsAllowed",
|
|
||||||
"numberOfRooms",
|
|
||||||
"availableLanguage",
|
|
||||||
"containedInPlace",
|
|
||||||
"containsPlace",
|
|
||||||
"makesOffer",
|
|
||||||
"brand",
|
|
||||||
"currenciesAccepted",
|
|
||||||
"smokingAllowed",
|
|
||||||
"openingHoursSpecification",
|
|
||||||
"audience",
|
|
||||||
"review",
|
|
||||||
"parentOrganization",
|
|
||||||
"openingDate",
|
|
||||||
"award"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"LodgingBusiness": {
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"address"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"telephone",
|
|
||||||
"image",
|
|
||||||
"priceRange",
|
|
||||||
"geo",
|
|
||||||
"url",
|
|
||||||
"starRating",
|
|
||||||
"aggregateRating",
|
|
||||||
"checkinTime",
|
|
||||||
"checkoutTime"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"email",
|
|
||||||
"amenityFeature",
|
|
||||||
"petsAllowed",
|
|
||||||
"numberOfRooms",
|
|
||||||
"availableLanguage",
|
|
||||||
"containedInPlace",
|
|
||||||
"containsPlace",
|
|
||||||
"makesOffer",
|
|
||||||
"currenciesAccepted",
|
|
||||||
"smokingAllowed"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Resort": {
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"address"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"telephone",
|
|
||||||
"image",
|
|
||||||
"priceRange",
|
|
||||||
"geo",
|
|
||||||
"url",
|
|
||||||
"starRating",
|
|
||||||
"aggregateRating"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"email",
|
|
||||||
"amenityFeature",
|
|
||||||
"numberOfRooms",
|
|
||||||
"containedInPlace",
|
|
||||||
"containsPlace",
|
|
||||||
"checkinTime",
|
|
||||||
"checkoutTime"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Restaurant": {
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"address"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"servesCuisine",
|
|
||||||
"priceRange",
|
|
||||||
"telephone",
|
|
||||||
"menu",
|
|
||||||
"openingHoursSpecification",
|
|
||||||
"image",
|
|
||||||
"url",
|
|
||||||
"geo",
|
|
||||||
"acceptsReservations"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"email",
|
|
||||||
"hasMenu",
|
|
||||||
"starRating",
|
|
||||||
"aggregateRating",
|
|
||||||
"review",
|
|
||||||
"containedInPlace",
|
|
||||||
"smokingAllowed",
|
|
||||||
"award"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"FoodEstablishment": {
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"address"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"servesCuisine",
|
|
||||||
"priceRange",
|
|
||||||
"telephone",
|
|
||||||
"menu",
|
|
||||||
"openingHoursSpecification"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"email",
|
|
||||||
"hasMenu",
|
|
||||||
"acceptsReservations",
|
|
||||||
"containedInPlace"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"BarOrPub": {
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"address"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"telephone",
|
|
||||||
"openingHoursSpecification",
|
|
||||||
"priceRange",
|
|
||||||
"servesCuisine"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"menu",
|
|
||||||
"hasMenu",
|
|
||||||
"image",
|
|
||||||
"url",
|
|
||||||
"containedInPlace",
|
|
||||||
"acceptsReservations",
|
|
||||||
"award"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"FAQPage": {
|
|
||||||
"required": [
|
|
||||||
"mainEntity"
|
|
||||||
],
|
|
||||||
"recommended": [],
|
|
||||||
"allowed": [
|
|
||||||
"about",
|
|
||||||
"headline",
|
|
||||||
"datePublished",
|
|
||||||
"dateModified",
|
|
||||||
"isPartOf"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Question": {
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"acceptedAnswer"
|
|
||||||
],
|
|
||||||
"recommended": [],
|
|
||||||
"allowed": [
|
|
||||||
"text",
|
|
||||||
"answerCount",
|
|
||||||
"suggestedAnswer",
|
|
||||||
"upvoteCount",
|
|
||||||
"author"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Answer": {
|
|
||||||
"required": [
|
|
||||||
"text"
|
|
||||||
],
|
|
||||||
"recommended": [],
|
|
||||||
"allowed": [
|
|
||||||
"url",
|
|
||||||
"upvoteCount",
|
|
||||||
"author",
|
|
||||||
"dateCreated"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"BreadcrumbList": {
|
|
||||||
"required": [
|
|
||||||
"itemListElement"
|
|
||||||
],
|
|
||||||
"recommended": [],
|
|
||||||
"allowed": [
|
|
||||||
"numberOfItems",
|
|
||||||
"itemListOrder"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"ItemList": {
|
|
||||||
"required": [
|
|
||||||
"itemListElement"
|
|
||||||
],
|
|
||||||
"recommended": [],
|
|
||||||
"allowed": [
|
|
||||||
"numberOfItems",
|
|
||||||
"itemListOrder"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"ListItem": {
|
|
||||||
"required": [
|
|
||||||
"position"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"item",
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"url",
|
|
||||||
"image",
|
|
||||||
"nextItem",
|
|
||||||
"previousItem"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Product": {
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"image",
|
|
||||||
"offers",
|
|
||||||
"brand",
|
|
||||||
"aggregateRating",
|
|
||||||
"review",
|
|
||||||
"description",
|
|
||||||
"sku"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"gtin",
|
|
||||||
"gtin13",
|
|
||||||
"gtin8",
|
|
||||||
"gtin12",
|
|
||||||
"mpn",
|
|
||||||
"color",
|
|
||||||
"material",
|
|
||||||
"category",
|
|
||||||
"audience",
|
|
||||||
"isVariantOf",
|
|
||||||
"additionalProperty",
|
|
||||||
"hasMerchantReturnPolicy"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Offer": {
|
|
||||||
"required": [
|
|
||||||
"price",
|
|
||||||
"priceCurrency"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"availability",
|
|
||||||
"url",
|
|
||||||
"validFrom",
|
|
||||||
"priceValidUntil"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"itemCondition",
|
|
||||||
"seller",
|
|
||||||
"eligibleRegion",
|
|
||||||
"priceSpecification",
|
|
||||||
"shippingDetails",
|
|
||||||
"availabilityStarts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"AggregateOffer": {
|
|
||||||
"required": [
|
|
||||||
"lowPrice",
|
|
||||||
"priceCurrency"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"highPrice",
|
|
||||||
"offerCount"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"offers",
|
|
||||||
"availability"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Article": {
|
|
||||||
"required": [
|
|
||||||
"headline"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"author",
|
|
||||||
"datePublished",
|
|
||||||
"image",
|
|
||||||
"dateModified",
|
|
||||||
"publisher"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"articleBody",
|
|
||||||
"articleSection",
|
|
||||||
"wordCount",
|
|
||||||
"keywords",
|
|
||||||
"speakable"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"NewsArticle": {
|
|
||||||
"required": [
|
|
||||||
"headline"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"author",
|
|
||||||
"datePublished",
|
|
||||||
"image",
|
|
||||||
"dateModified",
|
|
||||||
"publisher"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"articleBody",
|
|
||||||
"dateline",
|
|
||||||
"printSection"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"BlogPosting": {
|
|
||||||
"required": [
|
|
||||||
"headline"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"author",
|
|
||||||
"datePublished",
|
|
||||||
"image",
|
|
||||||
"dateModified",
|
|
||||||
"publisher"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"articleBody",
|
|
||||||
"keywords",
|
|
||||||
"wordCount"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Event": {
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"startDate",
|
|
||||||
"location"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"endDate",
|
|
||||||
"offers",
|
|
||||||
"performer",
|
|
||||||
"image",
|
|
||||||
"eventStatus",
|
|
||||||
"eventAttendanceMode",
|
|
||||||
"organizer"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"doorTime",
|
|
||||||
"previousStartDate",
|
|
||||||
"typicalAgeRange",
|
|
||||||
"maximumAttendeeCapacity"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Review": {
|
|
||||||
"required": [
|
|
||||||
"reviewRating",
|
|
||||||
"author"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"datePublished",
|
|
||||||
"reviewBody",
|
|
||||||
"itemReviewed"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"publisher",
|
|
||||||
"name"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"AggregateRating": {
|
|
||||||
"required": [
|
|
||||||
"ratingValue"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"reviewCount",
|
|
||||||
"ratingCount",
|
|
||||||
"bestRating"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"worstRating",
|
|
||||||
"itemReviewed"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"MemberProgram": {
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"hasTiers",
|
|
||||||
"hostingOrganization",
|
|
||||||
"url"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"description",
|
|
||||||
"membershipPointsEarned"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"EventVenue": {
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"url",
|
|
||||||
"address",
|
|
||||||
"maximumAttendeeCapacity",
|
|
||||||
"image"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"containedInPlace",
|
|
||||||
"amenityFeature",
|
|
||||||
"openingHoursSpecification",
|
|
||||||
"photo",
|
|
||||||
"telephone",
|
|
||||||
"alternateName",
|
|
||||||
"geo"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"ExerciseGym": {
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"url",
|
|
||||||
"address",
|
|
||||||
"openingHoursSpecification",
|
|
||||||
"image"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"containedInPlace",
|
|
||||||
"amenityFeature",
|
|
||||||
"telephone",
|
|
||||||
"priceRange",
|
|
||||||
"alternateName"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"SportsActivityLocation": {
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"url",
|
|
||||||
"address"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"containedInPlace",
|
|
||||||
"amenityFeature",
|
|
||||||
"openingHoursSpecification",
|
|
||||||
"telephone",
|
|
||||||
"alternateName"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"HealthAndBeautyBusiness": {
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"url",
|
|
||||||
"address",
|
|
||||||
"telephone",
|
|
||||||
"openingHoursSpecification",
|
|
||||||
"priceRange",
|
|
||||||
"image"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"containedInPlace",
|
|
||||||
"amenityFeature",
|
|
||||||
"potentialAction",
|
|
||||||
"parentOrganization",
|
|
||||||
"geo",
|
|
||||||
"alternateName"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"DaySpa": {
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"url",
|
|
||||||
"address",
|
|
||||||
"telephone",
|
|
||||||
"openingHoursSpecification",
|
|
||||||
"priceRange",
|
|
||||||
"image"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"containedInPlace",
|
|
||||||
"amenityFeature",
|
|
||||||
"potentialAction",
|
|
||||||
"parentOrganization",
|
|
||||||
"geo",
|
|
||||||
"alternateName"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"CafeOrCoffeeShop": {
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"recommended": [
|
|
||||||
"url",
|
|
||||||
"address",
|
|
||||||
"servesCuisine",
|
|
||||||
"priceRange",
|
|
||||||
"telephone",
|
|
||||||
"openingHoursSpecification",
|
|
||||||
"image"
|
|
||||||
],
|
|
||||||
"allowed": [
|
|
||||||
"menu",
|
|
||||||
"hasMenu",
|
|
||||||
"containedInPlace",
|
|
||||||
"acceptsReservations",
|
|
||||||
"alternateName"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"container_types": [
|
|
||||||
"PostalAddress",
|
|
||||||
"GeoCoordinates",
|
|
||||||
"GeoShape",
|
|
||||||
"ImageObject",
|
|
||||||
"VideoObject",
|
|
||||||
"ContactPoint",
|
|
||||||
"OpeningHoursSpecification",
|
|
||||||
"Rating",
|
|
||||||
"QuantitativeValue",
|
|
||||||
"MonetaryAmount",
|
|
||||||
"PriceSpecification",
|
|
||||||
"Brand",
|
|
||||||
"EntryPoint",
|
|
||||||
"Place",
|
|
||||||
"OfferCatalog",
|
|
||||||
"ReserveAction",
|
|
||||||
"OrderAction",
|
|
||||||
"SearchAction",
|
|
||||||
"ViewAction",
|
|
||||||
"MeetingRoom",
|
|
||||||
"Room",
|
|
||||||
"HotelRoom",
|
|
||||||
"Suite",
|
|
||||||
"LocationFeatureSpecification",
|
|
||||||
"MemberProgramTier",
|
|
||||||
"MobileApplication",
|
|
||||||
"WebApplication",
|
|
||||||
"SoftwareApplication",
|
|
||||||
"Menu",
|
|
||||||
"MenuItem",
|
|
||||||
"MenuSection",
|
|
||||||
"Country",
|
|
||||||
"AdministrativeArea",
|
|
||||||
"Duration",
|
|
||||||
"PropertyValue",
|
|
||||||
"Person",
|
|
||||||
"Audience",
|
|
||||||
"Language",
|
|
||||||
"LodgingReservation"
|
|
||||||
],
|
|
||||||
"value_formats": {
|
|
||||||
"url_props": [
|
|
||||||
"url",
|
|
||||||
"logo",
|
|
||||||
"sameAs",
|
|
||||||
"image",
|
|
||||||
"contentUrl",
|
|
||||||
"thumbnailUrl",
|
|
||||||
"target",
|
|
||||||
"urlTemplate",
|
|
||||||
"installUrl",
|
|
||||||
"menu",
|
|
||||||
"hasMap",
|
|
||||||
"downloadUrl",
|
|
||||||
"embedUrl"
|
|
||||||
],
|
|
||||||
"date_props": [
|
|
||||||
"datePublished",
|
|
||||||
"dateModified",
|
|
||||||
"dateCreated",
|
|
||||||
"startDate",
|
|
||||||
"endDate",
|
|
||||||
"validFrom",
|
|
||||||
"validThrough",
|
|
||||||
"priceValidUntil",
|
|
||||||
"foundingDate",
|
|
||||||
"uploadDate",
|
|
||||||
"availabilityStarts",
|
|
||||||
"availabilityEnds",
|
|
||||||
"lastReviewed",
|
|
||||||
"previousStartDate"
|
|
||||||
],
|
|
||||||
"lang_props": [
|
|
||||||
"inLanguage",
|
|
||||||
"availableLanguage"
|
|
||||||
],
|
|
||||||
"currency_props": [
|
|
||||||
"priceCurrency",
|
|
||||||
"currenciesAccepted"
|
|
||||||
],
|
|
||||||
"number_props": [
|
|
||||||
"price",
|
|
||||||
"lowPrice",
|
|
||||||
"highPrice",
|
|
||||||
"ratingValue",
|
|
||||||
"reviewCount",
|
|
||||||
"ratingCount",
|
|
||||||
"bestRating",
|
|
||||||
"worstRating",
|
|
||||||
"position",
|
|
||||||
"numberOfRooms",
|
|
||||||
"maxValue",
|
|
||||||
"minValue",
|
|
||||||
"offerCount"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"valid_currencies": [
|
|
||||||
"KRW",
|
|
||||||
"USD",
|
|
||||||
"EUR",
|
|
||||||
"JPY",
|
|
||||||
"CNY",
|
|
||||||
"GBP",
|
|
||||||
"HKD",
|
|
||||||
"SGD",
|
|
||||||
"THB",
|
|
||||||
"AUD",
|
|
||||||
"CAD",
|
|
||||||
"CHF",
|
|
||||||
"TWD",
|
|
||||||
"MYR",
|
|
||||||
"PHP",
|
|
||||||
"VND",
|
|
||||||
"IDR",
|
|
||||||
"INR"
|
|
||||||
],
|
|
||||||
"valid_language_codes": [
|
|
||||||
"ko",
|
|
||||||
"en",
|
|
||||||
"ja",
|
|
||||||
"zh",
|
|
||||||
"zh-CN",
|
|
||||||
"zh-TW",
|
|
||||||
"zh-Hans",
|
|
||||||
"zh-Hant",
|
|
||||||
"ko-KR",
|
|
||||||
"en-US",
|
|
||||||
"en-GB",
|
|
||||||
"ja-JP",
|
|
||||||
"fr",
|
|
||||||
"de",
|
|
||||||
"es",
|
|
||||||
"ru",
|
|
||||||
"th",
|
|
||||||
"vi",
|
|
||||||
"id",
|
|
||||||
"ms"
|
|
||||||
],
|
|
||||||
"placeholder_tokens": [
|
|
||||||
"lorem ipsum",
|
|
||||||
"lorem",
|
|
||||||
"ipsum",
|
|
||||||
"dolor sit",
|
|
||||||
"todo",
|
|
||||||
"tbd",
|
|
||||||
"fixme",
|
|
||||||
"xxx",
|
|
||||||
"yyy",
|
|
||||||
"zzz",
|
|
||||||
"placeholder",
|
|
||||||
"insert here",
|
|
||||||
"insert text",
|
|
||||||
"example.com",
|
|
||||||
"your-domain",
|
|
||||||
"yourdomain",
|
|
||||||
"changeme",
|
|
||||||
"sample text",
|
|
||||||
"{{",
|
|
||||||
"}}",
|
|
||||||
"<insert",
|
|
||||||
"[insert",
|
|
||||||
"n/a",
|
|
||||||
"샘플",
|
|
||||||
"예시",
|
|
||||||
"여기에",
|
|
||||||
"변경필요",
|
|
||||||
"수정필요",
|
|
||||||
"입력필요",
|
|
||||||
"내용입력",
|
|
||||||
"테스트",
|
|
||||||
"임시"
|
|
||||||
],
|
|
||||||
"geo": {
|
|
||||||
"lat_min": -90.0,
|
|
||||||
"lat_max": 90.0,
|
|
||||||
"lon_min": -180.0,
|
|
||||||
"lon_max": 180.0,
|
|
||||||
"kr_lat_range": [
|
|
||||||
33.0,
|
|
||||||
39.0
|
|
||||||
],
|
|
||||||
"kr_lon_range": [
|
|
||||||
124.0,
|
|
||||||
132.0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -607,16 +607,6 @@ def _address_street(node):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _address_locality(node):
|
|
||||||
"""City/region key, used to keep distinct same-name locations of a chain apart."""
|
|
||||||
addr = node.get("address")
|
|
||||||
if isinstance(addr, list) and addr and isinstance(addr[0], dict):
|
|
||||||
addr = addr[0]
|
|
||||||
if isinstance(addr, dict):
|
|
||||||
return normalize_name(addr.get("addressLocality") or addr.get("addressRegion"))
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _walk_ids(obj, defined, referenced):
|
def _walk_ids(obj, defined, referenced):
|
||||||
"""Collect @id definitions vs pure references by walking the whole document.
|
"""Collect @id definitions vs pure references by walking the whole document.
|
||||||
|
|
||||||
@@ -655,26 +645,21 @@ def layer4_consistency(node_index, parsed_docs, rules, defects):
|
|||||||
break # one placeholder defect per node is enough signal
|
break # one placeholder defect per node is enough signal
|
||||||
|
|
||||||
# ---- NAP consistency (P0) ----
|
# ---- NAP consistency (P0) ----
|
||||||
# Group by (name, locality): a multi-location chain legitimately shares a name
|
|
||||||
# across cities (e.g. "더 파크뷰" in Seoul AND Jeju). A real NAP conflict is a
|
|
||||||
# SINGLE location with contradictory phone/street, so scope the check per city.
|
|
||||||
by_name = defaultdict(list)
|
by_name = defaultdict(list)
|
||||||
for entry, node in node_index:
|
for entry, node in node_index:
|
||||||
if type_of(node) in NAP_TYPES and node.get("name"):
|
if type_of(node) in NAP_TYPES and node.get("name"):
|
||||||
key = (normalize_name(first_text(node.get("name"))), _address_locality(node))
|
by_name[normalize_name(first_text(node.get("name")))].append((entry, node))
|
||||||
by_name[key].append((entry, node))
|
for name, group in by_name.items():
|
||||||
for (name, locality), group in by_name.items():
|
|
||||||
loc = f" ({locality})" if locality else ""
|
|
||||||
phones = {str(first_text(n.get("telephone"))).strip()
|
phones = {str(first_text(n.get("telephone"))).strip()
|
||||||
for _, n in group if n.get("telephone")}
|
for _, n in group if n.get("telephone")}
|
||||||
streets = {_address_street(n) for _, n in group if _address_street(n)}
|
streets = {_address_street(n) for _, n in group if _address_street(n)}
|
||||||
if len(phones) > 1:
|
if len(phones) > 1:
|
||||||
defects.add("P0", "L4", "NAP_PHONE_MISMATCH",
|
defects.add("P0", "L4", "NAP_PHONE_MISMATCH",
|
||||||
f"Business '{name}'{loc} has conflicting telephone values across "
|
f"Business '{name}' has conflicting telephone values across "
|
||||||
f"entries: {sorted(phones)}.", entry_id="(dataset)")
|
f"entries: {sorted(phones)}.", entry_id="(dataset)")
|
||||||
if len(streets) > 1:
|
if len(streets) > 1:
|
||||||
defects.add("P0", "L4", "NAP_ADDRESS_MISMATCH",
|
defects.add("P0", "L4", "NAP_ADDRESS_MISMATCH",
|
||||||
f"Business '{name}'{loc} has conflicting streetAddress values across "
|
f"Business '{name}' has conflicting streetAddress values across "
|
||||||
f"entries: {sorted(streets)}.", entry_id="(dataset)")
|
f"entries: {sorted(streets)}.", entry_id="(dataset)")
|
||||||
|
|
||||||
# ---- @id duplicates + dangling references (P1) ----
|
# ---- @id duplicates + dangling references (P1) ----
|
||||||
@@ -734,147 +719,10 @@ def layer4_consistency(node_index, parsed_docs, rules, defects):
|
|||||||
f"(e.g. {sorted(eids)[:3]}): {desc[:50]!r}…", entry_id="(dataset)")
|
f"(e.g. {sorted(eids)[:3]}): {desc[:50]!r}…", entry_id="(dataset)")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Layer R — Reference-URL integrity (sameAs / external identity links)
|
|
||||||
# Hardened after the Shilla incident (2026-05-29): LLM-fabricated Wikidata IDs
|
|
||||||
# (Q-numbers pointing to unrelated entities) and google.com/search reference
|
|
||||||
# URLs shipped undetected. Offline: forbid search-result URLs (P0) and flag any
|
|
||||||
# external identity ref as REFERENCE_UNVERIFIED (P1). Online (--verify-refs):
|
|
||||||
# resolve every ref; for Wikidata, fetch the label and compare to the entity
|
|
||||||
# name — a mismatch is a FALSE_REFERENCE (P0).
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def _http_get(url, timeout=12, accept=None):
|
|
||||||
import urllib.request, urllib.parse
|
|
||||||
# percent-encode non-ASCII path/query (e.g. ko.wikipedia.org/wiki/호텔신라)
|
|
||||||
p = urllib.parse.urlsplit(url)
|
|
||||||
url = urllib.parse.urlunsplit((p.scheme, p.netloc,
|
|
||||||
urllib.parse.quote(p.path),
|
|
||||||
urllib.parse.quote(p.query, safe="=&?"),
|
|
||||||
p.fragment))
|
|
||||||
req = urllib.request.Request(url, headers={
|
|
||||||
"User-Agent": "schema-ref-validator/1.0 (+offline-qa)",
|
|
||||||
**({"Accept": accept} if accept else {})})
|
|
||||||
return urllib.request.urlopen(req, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _wikidata_labels(qid, timeout=12):
|
|
||||||
import urllib.request, json as _json
|
|
||||||
url = f"https://www.wikidata.org/w/api.php?action=wbgetentities&ids={qid}&props=labels&format=json"
|
|
||||||
with _http_get(url, timeout=timeout, accept="application/json") as r:
|
|
||||||
data = _json.loads(r.read().decode("utf-8"))
|
|
||||||
ent = data.get("entities", {}).get(qid, {})
|
|
||||||
if "missing" in ent:
|
|
||||||
return None
|
|
||||||
return {lang: v.get("value", "") for lang, v in ent.get("labels", {}).items()}
|
|
||||||
|
|
||||||
|
|
||||||
def layer_references(node_index, rules, defects, verify_refs=False):
|
|
||||||
policy = rules.get("reference_policy")
|
|
||||||
if not policy:
|
|
||||||
return
|
|
||||||
forbidden = policy.get("forbidden_url_substrings", [])
|
|
||||||
ref_props = set(policy.get("identity_ref_props", ["sameAs"]))
|
|
||||||
import re as _re
|
|
||||||
qid_re = _re.compile(r"wikidata\.org/(?:wiki|entity)/(Q\d+)")
|
|
||||||
def every_string(obj):
|
|
||||||
# Unlike all_strings(), this also yields strings nested inside lists
|
|
||||||
# (e.g. each URL in a sameAs array) — the exact case missed before.
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
for v in obj.values():
|
|
||||||
yield from every_string(v)
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
for v in obj:
|
|
||||||
yield from every_string(v)
|
|
||||||
elif isinstance(obj, str):
|
|
||||||
yield obj
|
|
||||||
|
|
||||||
for entry, node in node_index:
|
|
||||||
eid, url, ntype = entry["entry_id"], entry["url"], type_of(node)
|
|
||||||
# (1) forbidden search-result URLs anywhere in the node (incl. list items) -> P0
|
|
||||||
for val in every_string(node):
|
|
||||||
low = val.lower()
|
|
||||||
hit = next((s for s in forbidden if s in low), None)
|
|
||||||
if hit:
|
|
||||||
defects.add("P0", "LR", "FORBIDDEN_REFERENCE",
|
|
||||||
f"Search-result URL used as reference (contains {hit!r}); "
|
|
||||||
f"not a valid entity reference: {val[:80]!r}.", eid, url, ntype)
|
|
||||||
# (2) external identity references (sameAs)
|
|
||||||
refs = []
|
|
||||||
for prop in ref_props:
|
|
||||||
v = node.get(prop)
|
|
||||||
if isinstance(v, str):
|
|
||||||
refs.append(v)
|
|
||||||
elif isinstance(v, list):
|
|
||||||
refs += [x for x in v if isinstance(x, str)]
|
|
||||||
if not refs:
|
|
||||||
continue
|
|
||||||
# (2a) discouraged reference sources (policy: prefer Wikipedia over Wikidata)
|
|
||||||
for dd in policy.get("discouraged_ref_domains", []):
|
|
||||||
for ref in refs:
|
|
||||||
if dd in ref:
|
|
||||||
defects.add("P1", "LR", "DISCOURAGED_REFERENCE",
|
|
||||||
f"{ref} uses a discouraged source ({dd}). Policy: prefer "
|
|
||||||
"Wikipedia; if none verified, omit — never fabricate.",
|
|
||||||
eid, url, ntype)
|
|
||||||
if not verify_refs:
|
|
||||||
defects.add("P1", "LR", "REFERENCE_UNVERIFIED",
|
|
||||||
f"{len(refs)} external reference(s) on '{ntype}' not machine-verified "
|
|
||||||
f"(run with --verify-refs / confirm online): {refs}.", eid, url, ntype)
|
|
||||||
continue
|
|
||||||
# online verification
|
|
||||||
name = normalize_name(first_text(node.get("name")))
|
|
||||||
alts = node.get("alternateName") or []
|
|
||||||
if isinstance(alts, str):
|
|
||||||
alts = [alts]
|
|
||||||
names = {name} | {normalize_name(a) for a in alts if isinstance(a, str)}
|
|
||||||
for ref in refs:
|
|
||||||
m = qid_re.search(ref)
|
|
||||||
if m:
|
|
||||||
try:
|
|
||||||
labels = _wikidata_labels(m.group(1))
|
|
||||||
except Exception as e:
|
|
||||||
defects.add("P1", "LR", "REFERENCE_UNREACHABLE",
|
|
||||||
f"Could not fetch Wikidata {m.group(1)} ({e}).", eid, url, ntype)
|
|
||||||
continue
|
|
||||||
if labels is None:
|
|
||||||
defects.add("P0", "LR", "FALSE_REFERENCE",
|
|
||||||
f"sameAs {ref} → Wikidata item is missing/deleted.", eid, url, ntype)
|
|
||||||
continue
|
|
||||||
lab = {normalize_name(v) for v in labels.values()}
|
|
||||||
# match if any entity name appears in any label or vice-versa
|
|
||||||
ok = any(n and (n in l or l in n) for n in names for l in lab)
|
|
||||||
if not ok:
|
|
||||||
defects.add("P0", "LR", "FALSE_REFERENCE",
|
|
||||||
f"sameAs {ref} label {sorted(lab)[:3]} does NOT match entity "
|
|
||||||
f"name {sorted(n for n in names if n)[:3]} — fabricated/incorrect ID.",
|
|
||||||
eid, url, ntype)
|
|
||||||
else:
|
|
||||||
is_social = any(d in ref for d in policy.get("social_profile_domains", []))
|
|
||||||
try:
|
|
||||||
code = _http_get(ref).status
|
|
||||||
except Exception as e:
|
|
||||||
code = f"error: {e}"
|
|
||||||
if is_social:
|
|
||||||
# HTTP 200 does NOT prove official ownership, and platforms
|
|
||||||
# often bot-block live pages (e.g. Facebook 400). Always hand
|
|
||||||
# social/profile refs to a human. (Shilla: a 200 YouTube
|
|
||||||
# channel was not the official one; FB page was closed.)
|
|
||||||
defects.add("P1", "LR", "SOCIAL_UNVERIFIED",
|
|
||||||
f"sameAs {ref} is a social/profile URL (HTTP {code}). "
|
|
||||||
"Confirm official ownership AND active status manually — "
|
|
||||||
"a 200 is not proof of ownership.", eid, url, ntype)
|
|
||||||
elif isinstance(code, int) and code >= 400:
|
|
||||||
defects.add("P0", "LR", "BROKEN_REFERENCE",
|
|
||||||
f"sameAs {ref} returned HTTP {code}.", eid, url, ntype)
|
|
||||||
elif not isinstance(code, int):
|
|
||||||
defects.add("P1", "LR", "REFERENCE_UNREACHABLE",
|
|
||||||
f"sameAs {ref} not reachable ({code}).", eid, url, ntype)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Orchestration + output
|
# Orchestration + output
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def run(entries, rules, inventory, strict, no_recommended, verify_refs=False):
|
def run(entries, rules, inventory, strict, no_recommended):
|
||||||
defects = DefectLog()
|
defects = DefectLog()
|
||||||
if inventory is not None:
|
if inventory is not None:
|
||||||
layer0_coverage(entries, inventory, defects)
|
layer0_coverage(entries, inventory, defects)
|
||||||
@@ -895,7 +743,6 @@ def run(entries, rules, inventory, strict, no_recommended, verify_refs=False):
|
|||||||
node_index.append((entry, node))
|
node_index.append((entry, node))
|
||||||
|
|
||||||
layer4_consistency(node_index, parsed_docs, rules, defects)
|
layer4_consistency(node_index, parsed_docs, rules, defects)
|
||||||
layer_references(node_index, rules, defects, verify_refs=verify_refs)
|
|
||||||
return defects, valid_entries, len(node_index)
|
return defects, valid_entries, len(node_index)
|
||||||
|
|
||||||
|
|
||||||
@@ -975,9 +822,6 @@ def main(argv=None):
|
|||||||
ap.add_argument("--live", nargs="+", metavar="URL",
|
ap.add_argument("--live", nargs="+", metavar="URL",
|
||||||
help="Mode B: validate live URLs (extract embedded JSON-LD)")
|
help="Mode B: validate live URLs (extract embedded JSON-LD)")
|
||||||
ap.add_argument("--rules", default=str(RULES_DEFAULT), help="path to schema_rules.json")
|
ap.add_argument("--rules", default=str(RULES_DEFAULT), help="path to schema_rules.json")
|
||||||
ap.add_argument("--verify-refs", action="store_true",
|
|
||||||
help="online: resolve every sameAs and verify Wikidata labels match the "
|
|
||||||
"entity name (catches fabricated/incorrect reference IDs). Needs network.")
|
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
if not args.dataset and not args.live:
|
if not args.dataset and not args.live:
|
||||||
@@ -994,8 +838,7 @@ def main(argv=None):
|
|||||||
inventory = load_url_inventory(args.url_list) if args.url_list else None
|
inventory = load_url_inventory(args.url_list) if args.url_list else None
|
||||||
|
|
||||||
defects, valid_entries, nodes = run(entries, rules, inventory,
|
defects, valid_entries, nodes = run(entries, rules, inventory,
|
||||||
args.strict, args.no_recommended,
|
args.strict, args.no_recommended)
|
||||||
verify_refs=args.verify_refs)
|
|
||||||
meta = {"entries": len(entries), "valid_entries": valid_entries, "nodes": nodes,
|
meta = {"entries": len(entries), "valid_entries": valid_entries, "nodes": nodes,
|
||||||
"mode": "B-live" if args.live else "A-dataset", "strict": args.strict,
|
"mode": "B-live" if args.live else "A-dataset", "strict": args.strict,
|
||||||
"coverage": inventory is not None}
|
"coverage": inventory is not None}
|
||||||
|
|||||||
@@ -1,854 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
validate_schema.py — 5-layer offline JSON-LD schema validator.
|
|
||||||
|
|
||||||
WHY THIS EXISTS
|
|
||||||
---------------
|
|
||||||
When a client reviews hundreds of authored schema entries and says "there are too
|
|
||||||
many errors," the root cause is almost always that nobody ran a machine lint first.
|
|
||||||
Humans end up eyeballing raw JSON in a meeting. This tool moves every cheap,
|
|
||||||
machine-checkable error OUT of human review and INTO an automated gate that runs
|
|
||||||
first — so the client only ever sees clean, P0-free entries plus a defect report.
|
|
||||||
|
|
||||||
It is OFFLINE by design (the runtime cannot reach schema.org or Google). All rules
|
|
||||||
live in schema_rules.json; unknown types/properties degrade to warnings, never hard
|
|
||||||
errors, so the gate does not invent false positives.
|
|
||||||
|
|
||||||
THE 5 LAYERS
|
|
||||||
------------
|
|
||||||
L0 Coverage — URLs with no entry; entries whose URL isn't in the inventory.
|
|
||||||
L1 Syntax — invalid JSON, bad/missing @context, missing @type, encoding corruption.
|
|
||||||
L2 Vocabulary — unknown type, value-format errors (URL/date/lang/currency/number),
|
|
||||||
(strict only) unexpected properties on a known type.
|
|
||||||
L3 Rich-result — Google REQUIRED property missing (blocks rich result); recommended absent.
|
|
||||||
L4 Consistency — NAP mismatch, @id duplicates/dangling refs, swapped geo,
|
|
||||||
placeholder text, duplicate descriptions across entries.
|
|
||||||
|
|
||||||
GATE: PASS iff zero P0. Process exits 1 when the gate fails (so CI/`&&` chains stop).
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python validate_schema.py DATASET [--url-list URLLIST] [--out DIR]
|
|
||||||
[--strict] [--no-recommended]
|
|
||||||
[--live URL ...] [--rules schema_rules.json]
|
|
||||||
DATASET may be .xlsx / .csv (one row per entry, a JSON-LD column) / .jsonl / .json
|
|
||||||
/ a directory of .json|.jsonld files. With --live, validate live URLs instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import csv
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from collections import Counter, defaultdict
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
RULES_DEFAULT = Path(__file__).resolve().parent / "schema_rules.json"
|
|
||||||
|
|
||||||
SEVERITY_ORDER = {"P0": 0, "P1": 1, "P2": 2}
|
|
||||||
|
|
||||||
# Header aliases for tabular input. Keys are normalized (lowercased, spaces removed).
|
|
||||||
COLUMN_ALIASES = {
|
|
||||||
"jsonld": ["jsonld", "jsonld", "json-ld", "json_ld", "schema", "schemamarkup",
|
|
||||||
"structureddata", "structured_data", "markup", "스키마", "구조화데이터",
|
|
||||||
"구조화된데이터", "jsonldcode", "스키마코드"],
|
|
||||||
"url": ["url", "메뉴url", "pageurl", "주소", "링크", "loc", "uri", "캐노니컬", "canonical"],
|
|
||||||
"lang": ["lang", "language", "언어", "언어코드", "locale", "lng"],
|
|
||||||
"device": ["device", "pc/mobile", "pcmobile", "pc_mobile", "platform", "디바이스", "기기"],
|
|
||||||
"page_type": ["page_type", "pagetype", "type", "페이지유형", "페이지타입", "menulevel",
|
|
||||||
"menu_level", "메뉴레벨", "template", "템플릿", "유형"],
|
|
||||||
}
|
|
||||||
|
|
||||||
URL_RE = re.compile(r"^https?://[^\s]+$", re.IGNORECASE)
|
|
||||||
# ISO-8601 date or datetime (date, date+time, optional tz). Loose but rejects free text.
|
|
||||||
DATE_RE = re.compile(
|
|
||||||
r"^\d{4}-\d{2}-\d{2}"
|
|
||||||
r"(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$"
|
|
||||||
)
|
|
||||||
LANG_RE = re.compile(r"^[a-zA-Z]{2,3}(?:-[A-Za-z0-9]{2,4})?$")
|
|
||||||
JSONLD_SCRIPT_RE = re.compile(
|
|
||||||
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
|
|
||||||
re.IGNORECASE | re.DOTALL,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Defect collection
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
class DefectLog:
|
|
||||||
"""Accumulates findings. One row per finding, ready for triage."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.rows = []
|
|
||||||
|
|
||||||
def add(self, severity, layer, code, message, entry_id="", url="", node_type=""):
|
|
||||||
self.rows.append({
|
|
||||||
"entry_id": str(entry_id),
|
|
||||||
"url": url or "",
|
|
||||||
"node_type": node_type or "",
|
|
||||||
"layer": layer,
|
|
||||||
"code": code,
|
|
||||||
"severity": severity,
|
|
||||||
"message": message,
|
|
||||||
"status": "open",
|
|
||||||
"owner": "",
|
|
||||||
"note": "",
|
|
||||||
})
|
|
||||||
|
|
||||||
def counts(self):
|
|
||||||
c = Counter(r["severity"] for r in self.rows)
|
|
||||||
return {"P0": c.get("P0", 0), "P1": c.get("P1", 0), "P2": c.get("P2", 0)}
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Input adapters (Mode A: authored dataset / Mode B: live URLs)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def _norm_header(h):
|
|
||||||
return re.sub(r"\s+", "", str(h or "").strip().lower())
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_columns(headers):
|
|
||||||
"""Map normalized headers to canonical column roles. Returns {role: index}."""
|
|
||||||
found = {}
|
|
||||||
for idx, h in enumerate(headers):
|
|
||||||
nh = _norm_header(h)
|
|
||||||
for role, aliases in COLUMN_ALIASES.items():
|
|
||||||
if role in found:
|
|
||||||
continue
|
|
||||||
if nh in aliases:
|
|
||||||
found[role] = idx
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _row_to_entry(row, cols, entry_id, source_ref):
|
|
||||||
def cell(role):
|
|
||||||
i = cols.get(role)
|
|
||||||
if i is None or i >= len(row):
|
|
||||||
return None
|
|
||||||
v = row[i]
|
|
||||||
return None if v is None else str(v).strip()
|
|
||||||
raw = cell("jsonld")
|
|
||||||
if not raw:
|
|
||||||
return None # blank JSON-LD cell → no entry to validate
|
|
||||||
return {
|
|
||||||
"entry_id": entry_id,
|
|
||||||
"url": cell("url") or "",
|
|
||||||
"lang": cell("lang") or "",
|
|
||||||
"device": cell("device") or "",
|
|
||||||
"page_type": cell("page_type") or "",
|
|
||||||
"raw": raw,
|
|
||||||
"source_ref": source_ref,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _load_csv(path):
|
|
||||||
entries = []
|
|
||||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
|
||||||
reader = csv.reader(f)
|
|
||||||
rows = list(reader)
|
|
||||||
if not rows:
|
|
||||||
return entries
|
|
||||||
cols = _detect_columns(rows[0])
|
|
||||||
if "jsonld" not in cols:
|
|
||||||
raise ValueError(
|
|
||||||
f"No JSON-LD column found in {path}. Looked for: "
|
|
||||||
f"{', '.join(COLUMN_ALIASES['jsonld'][:6])} … Headers were: {rows[0]}"
|
|
||||||
)
|
|
||||||
for n, row in enumerate(rows[1:], start=2):
|
|
||||||
e = _row_to_entry(row, cols, f"{Path(path).stem}#r{n}", f"{path}:row{n}")
|
|
||||||
if e:
|
|
||||||
entries.append(e)
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def _load_xlsx(path):
|
|
||||||
try:
|
|
||||||
from openpyxl import load_workbook
|
|
||||||
except ImportError:
|
|
||||||
raise SystemExit(
|
|
||||||
"Reading .xlsx needs openpyxl: pip install openpyxl\n"
|
|
||||||
"(or export the sheet to .csv and pass that instead)."
|
|
||||||
)
|
|
||||||
entries = []
|
|
||||||
wb = load_workbook(path, read_only=True, data_only=True)
|
|
||||||
for sheet in wb.worksheets:
|
|
||||||
rows = list(sheet.iter_rows(values_only=True))
|
|
||||||
if not rows:
|
|
||||||
continue
|
|
||||||
cols = _detect_columns(rows[0])
|
|
||||||
if "jsonld" not in cols:
|
|
||||||
continue # a tab without a JSON-LD column (e.g. summary) — skip silently
|
|
||||||
for n, row in enumerate(rows[1:], start=2):
|
|
||||||
e = _row_to_entry(list(row), cols, f"{sheet.title}#r{n}",
|
|
||||||
f"{path}:{sheet.title}:row{n}")
|
|
||||||
if e:
|
|
||||||
entries.append(e)
|
|
||||||
if not entries:
|
|
||||||
raise ValueError(
|
|
||||||
f"No sheet in {path} had a recognizable JSON-LD column. "
|
|
||||||
f"Looked for: {', '.join(COLUMN_ALIASES['jsonld'][:6])} …"
|
|
||||||
)
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_schema(obj):
|
|
||||||
"""True if a parsed object is itself JSON-LD (vs a wrapper row)."""
|
|
||||||
if isinstance(obj, list):
|
|
||||||
return True
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return any(k in obj for k in ("@context", "@type", "@graph"))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _wrapper_to_entry(obj, entry_id, source_ref):
|
|
||||||
"""A JSONL/JSON wrapper object that carries url/lang + a jsonld payload."""
|
|
||||||
cols = {k: k for k in obj.keys()}
|
|
||||||
norm = {_norm_header(k): k for k in obj.keys()}
|
|
||||||
def pick(role):
|
|
||||||
for alias in COLUMN_ALIASES[role]:
|
|
||||||
if alias in norm:
|
|
||||||
v = obj[norm[alias]]
|
|
||||||
return v
|
|
||||||
return None
|
|
||||||
payload = pick("jsonld")
|
|
||||||
raw = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False)
|
|
||||||
url = pick("url")
|
|
||||||
return {
|
|
||||||
"entry_id": entry_id,
|
|
||||||
"url": str(url).strip() if url else "",
|
|
||||||
"lang": str(pick("lang") or "").strip(),
|
|
||||||
"device": str(pick("device") or "").strip(),
|
|
||||||
"page_type": str(pick("page_type") or "").strip(),
|
|
||||||
"raw": raw,
|
|
||||||
"source_ref": source_ref,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _load_jsonl(path):
|
|
||||||
entries = []
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
for n, line in enumerate(f, start=1):
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
sref = f"{path}:line{n}"
|
|
||||||
try:
|
|
||||||
obj = json.loads(line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Keep the bad line so L1 reports it as a syntax error.
|
|
||||||
entries.append({"entry_id": f"{Path(path).stem}#l{n}", "url": "",
|
|
||||||
"lang": "", "device": "", "page_type": "",
|
|
||||||
"raw": line, "source_ref": sref})
|
|
||||||
continue
|
|
||||||
eid = f"{Path(path).stem}#l{n}"
|
|
||||||
if _looks_like_schema(obj):
|
|
||||||
entries.append({"entry_id": eid, "url": "", "lang": "", "device": "",
|
|
||||||
"page_type": "", "raw": line, "source_ref": sref})
|
|
||||||
else:
|
|
||||||
entries.append(_wrapper_to_entry(obj, eid, sref))
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def _load_json(path):
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
entries = []
|
|
||||||
if isinstance(data, dict) and not _looks_like_schema(data) and all(
|
|
||||||
isinstance(v, (dict, list, str)) for v in data.values()
|
|
||||||
) and not any(k.startswith("@") for k in data):
|
|
||||||
# url -> jsonld map
|
|
||||||
for url, payload in data.items():
|
|
||||||
raw = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False)
|
|
||||||
entries.append({"entry_id": url, "url": url, "lang": "", "device": "",
|
|
||||||
"page_type": "", "raw": raw, "source_ref": f"{path}:{url}"})
|
|
||||||
elif isinstance(data, list):
|
|
||||||
for n, item in enumerate(data, start=1):
|
|
||||||
sref = f"{path}:[{n}]"
|
|
||||||
eid = f"{Path(path).stem}#{n}"
|
|
||||||
if _looks_like_schema(item) or not isinstance(item, dict):
|
|
||||||
raw = item if isinstance(item, str) else json.dumps(item, ensure_ascii=False)
|
|
||||||
entries.append({"entry_id": eid, "url": "", "lang": "", "device": "",
|
|
||||||
"page_type": "", "raw": raw, "source_ref": sref})
|
|
||||||
else:
|
|
||||||
entries.append(_wrapper_to_entry(item, eid, sref))
|
|
||||||
else:
|
|
||||||
entries.append({"entry_id": Path(path).stem, "url": "", "lang": "", "device": "",
|
|
||||||
"page_type": "", "raw": json.dumps(data, ensure_ascii=False),
|
|
||||||
"source_ref": path})
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def _load_dir(path):
|
|
||||||
entries = []
|
|
||||||
for p in sorted(Path(path).rglob("*")):
|
|
||||||
if p.suffix.lower() in (".json", ".jsonld"):
|
|
||||||
entries.append({"entry_id": p.stem, "url": "", "lang": "", "device": "",
|
|
||||||
"page_type": "", "raw": p.read_text(encoding="utf-8"),
|
|
||||||
"source_ref": str(p)})
|
|
||||||
if not entries:
|
|
||||||
raise ValueError(f"No .json/.jsonld files found under {path}")
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def _load_live(urls):
|
|
||||||
try:
|
|
||||||
import requests
|
|
||||||
except ImportError:
|
|
||||||
raise SystemExit("Live mode (--live) needs requests: pip install requests")
|
|
||||||
entries = []
|
|
||||||
headers = {"User-Agent": "Mozilla/5.0 (compatible; SchemaValidator/1.0)"}
|
|
||||||
for url in urls:
|
|
||||||
try:
|
|
||||||
resp = requests.get(url, headers=headers, timeout=20)
|
|
||||||
resp.raise_for_status()
|
|
||||||
except Exception as exc: # noqa: BLE001 — best-effort live fetch
|
|
||||||
entries.append({"entry_id": url, "url": url, "lang": "", "device": "",
|
|
||||||
"page_type": "", "raw": "", "source_ref": url,
|
|
||||||
"_fetch_error": str(exc)})
|
|
||||||
continue
|
|
||||||
scripts = JSONLD_SCRIPT_RE.findall(resp.text)
|
|
||||||
if not scripts:
|
|
||||||
entries.append({"entry_id": url, "url": url, "lang": "", "device": "",
|
|
||||||
"page_type": "", "raw": "", "source_ref": url,
|
|
||||||
"_no_schema": True})
|
|
||||||
continue
|
|
||||||
for i, block in enumerate(scripts, start=1):
|
|
||||||
entries.append({"entry_id": f"{url}#{i}", "url": url, "lang": "",
|
|
||||||
"device": "", "page_type": "", "raw": block.strip(),
|
|
||||||
"source_ref": f"{url} (script {i})"})
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def load_entries(input_path, live_urls):
|
|
||||||
if live_urls:
|
|
||||||
return _load_live(live_urls)
|
|
||||||
p = Path(input_path)
|
|
||||||
if p.is_dir():
|
|
||||||
return _load_dir(p)
|
|
||||||
suffix = p.suffix.lower()
|
|
||||||
if suffix == ".csv":
|
|
||||||
return _load_csv(p)
|
|
||||||
if suffix in (".xlsx", ".xlsm"):
|
|
||||||
return _load_xlsx(p)
|
|
||||||
if suffix == ".jsonl":
|
|
||||||
return _load_jsonl(p)
|
|
||||||
if suffix in (".json", ".jsonld"):
|
|
||||||
return _load_json(p)
|
|
||||||
raise ValueError(f"Unsupported input: {input_path} (suffix {suffix!r})")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Node helpers
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def type_of(node):
|
|
||||||
"""Return the primary @type as a string (first if it's a list)."""
|
|
||||||
t = node.get("@type")
|
|
||||||
if isinstance(t, list):
|
|
||||||
return t[0] if t else ""
|
|
||||||
return t or ""
|
|
||||||
|
|
||||||
|
|
||||||
def iter_typed_nodes(parsed):
|
|
||||||
"""Yield every dict that has an @type, top-level and nested (recursively)."""
|
|
||||||
seen = []
|
|
||||||
|
|
||||||
def walk(obj):
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
if "@type" in obj:
|
|
||||||
seen.append(obj)
|
|
||||||
for v in obj.values():
|
|
||||||
walk(v)
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
for v in obj:
|
|
||||||
walk(v)
|
|
||||||
|
|
||||||
# @graph documents: walk the graph; otherwise walk the object/array directly.
|
|
||||||
if isinstance(parsed, dict) and "@graph" in parsed:
|
|
||||||
walk(parsed["@graph"])
|
|
||||||
else:
|
|
||||||
walk(parsed)
|
|
||||||
return seen
|
|
||||||
|
|
||||||
|
|
||||||
def all_strings(obj):
|
|
||||||
"""Yield (key, value) for every string value anywhere in the structure."""
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
for k, v in obj.items():
|
|
||||||
if isinstance(v, str):
|
|
||||||
yield k, v
|
|
||||||
else:
|
|
||||||
yield from all_strings(v)
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
for v in obj:
|
|
||||||
yield from all_strings(v)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_name(s):
|
|
||||||
return re.sub(r"\s+", " ", str(s or "").strip().lower())
|
|
||||||
|
|
||||||
|
|
||||||
def first_text(value):
|
|
||||||
"""Coerce a property value to a comparable scalar (handles list/dict)."""
|
|
||||||
if isinstance(value, list):
|
|
||||||
return first_text(value[0]) if value else ""
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return value.get("name") or value.get("@id") or value.get("streetAddress") or ""
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Layer 0 — Coverage
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def load_url_inventory(url_list_path):
|
|
||||||
urls = set()
|
|
||||||
p = Path(url_list_path)
|
|
||||||
suffix = p.suffix.lower()
|
|
||||||
if suffix in (".xlsx", ".xlsm"):
|
|
||||||
from openpyxl import load_workbook
|
|
||||||
wb = load_workbook(p, read_only=True, data_only=True)
|
|
||||||
for sheet in wb.worksheets:
|
|
||||||
for row in sheet.iter_rows(values_only=True):
|
|
||||||
for cell in row:
|
|
||||||
if isinstance(cell, str) and URL_RE.match(cell.strip()):
|
|
||||||
urls.add(cell.strip())
|
|
||||||
elif suffix == ".csv":
|
|
||||||
with open(p, newline="", encoding="utf-8-sig") as f:
|
|
||||||
for row in csv.reader(f):
|
|
||||||
for cell in row:
|
|
||||||
if isinstance(cell, str) and URL_RE.match(cell.strip()):
|
|
||||||
urls.add(cell.strip())
|
|
||||||
else: # plain text, one URL per line
|
|
||||||
for line in p.read_text(encoding="utf-8").splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if URL_RE.match(line):
|
|
||||||
urls.add(line)
|
|
||||||
return urls
|
|
||||||
|
|
||||||
|
|
||||||
def layer0_coverage(entries, inventory, defects):
|
|
||||||
entry_urls = {e["url"] for e in entries if e.get("url")}
|
|
||||||
missing = inventory - entry_urls
|
|
||||||
for url in sorted(missing):
|
|
||||||
defects.add("P1", "L0", "COVERAGE_MISSING",
|
|
||||||
"Inventory URL has no authored schema entry.", url=url)
|
|
||||||
orphans = entry_urls - inventory
|
|
||||||
for url in sorted(orphans):
|
|
||||||
defects.add("P2", "L0", "COVERAGE_ORPHAN",
|
|
||||||
"Entry URL is not in the canonical URL inventory "
|
|
||||||
"(typo, stale path, or missing from list).", url=url)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Layer 1 — Syntax
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def layer1_syntax(entry, rules, defects):
|
|
||||||
"""Parse + structural checks. Returns parsed object or None (fatal)."""
|
|
||||||
eid, url = entry["entry_id"], entry["url"]
|
|
||||||
if entry.get("_fetch_error"):
|
|
||||||
defects.add("P1", "L1", "FETCH_ERROR",
|
|
||||||
f"Could not fetch live URL: {entry['_fetch_error']}", eid, url)
|
|
||||||
return None
|
|
||||||
if entry.get("_no_schema"):
|
|
||||||
defects.add("P0", "L1", "NO_SCHEMA_IN_HTML",
|
|
||||||
"Live page has no application/ld+json script block.", eid, url)
|
|
||||||
return None
|
|
||||||
raw = entry["raw"]
|
|
||||||
if "<22>" in raw:
|
|
||||||
defects.add("P1", "L1", "ENCODING_CORRUPTION",
|
|
||||||
"Replacement character (\\ufffd) present — encoding corruption.",
|
|
||||||
eid, url)
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
defects.add("P0", "L1", "INVALID_JSON",
|
|
||||||
f"JSON does not parse: {exc.msg} at line {exc.lineno} col {exc.colno}.",
|
|
||||||
eid, url)
|
|
||||||
return None
|
|
||||||
|
|
||||||
nodes = iter_typed_nodes(parsed)
|
|
||||||
if not nodes:
|
|
||||||
defects.add("P1", "L1", "NO_TYPE",
|
|
||||||
"No @type found anywhere in the entry — not a usable schema object.",
|
|
||||||
eid, url)
|
|
||||||
|
|
||||||
# @context lives at the top of the document; nested nodes inherit it.
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
ctx = parsed.get("@context")
|
|
||||||
if ctx is None:
|
|
||||||
defects.add("P1", "L1", "MISSING_CONTEXT",
|
|
||||||
"Top-level @context is missing.", eid, url)
|
|
||||||
else:
|
|
||||||
ctx_urls = [ctx] if isinstance(ctx, str) else (
|
|
||||||
[c for c in ctx if isinstance(c, str)] if isinstance(ctx, list) else []
|
|
||||||
)
|
|
||||||
valid = rules["valid_contexts"]
|
|
||||||
if ctx_urls and not any(c.rstrip("/") in [v.rstrip("/") for v in valid]
|
|
||||||
for c in ctx_urls):
|
|
||||||
defects.add("P1", "L1", "WRONG_CONTEXT",
|
|
||||||
f"@context is not schema.org: {ctx_urls}.", eid, url)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Layer 2 — Vocabulary + value formats
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def _check_value_formats(node, rules, defects, eid, url, ntype, severity):
|
|
||||||
vf = rules["value_formats"]
|
|
||||||
|
|
||||||
def each(value):
|
|
||||||
if isinstance(value, list):
|
|
||||||
for v in value:
|
|
||||||
yield from each(v)
|
|
||||||
else:
|
|
||||||
yield value
|
|
||||||
|
|
||||||
for prop, value in node.items():
|
|
||||||
if prop.startswith("@"):
|
|
||||||
continue
|
|
||||||
if prop in vf["url_props"]:
|
|
||||||
for v in each(value):
|
|
||||||
if isinstance(v, str) and not URL_RE.match(v.strip()):
|
|
||||||
defects.add(severity, "L2", "BAD_URL",
|
|
||||||
f"'{prop}' is not an http(s) URL: {v!r}.", eid, url, ntype)
|
|
||||||
if prop in vf["date_props"]:
|
|
||||||
for v in each(value):
|
|
||||||
if isinstance(v, str) and not DATE_RE.match(v.strip()):
|
|
||||||
defects.add(severity, "L2", "BAD_DATE",
|
|
||||||
f"'{prop}' is not ISO-8601: {v!r}.", eid, url, ntype)
|
|
||||||
if prop in vf["lang_props"]:
|
|
||||||
for v in each(value):
|
|
||||||
if isinstance(v, str) and not LANG_RE.match(v.strip()):
|
|
||||||
defects.add(severity, "L2", "BAD_LANG",
|
|
||||||
f"'{prop}' is not a BCP-47 language code: {v!r}.",
|
|
||||||
eid, url, ntype)
|
|
||||||
if prop in vf["currency_props"]:
|
|
||||||
for v in each(value):
|
|
||||||
if isinstance(v, str) and not re.match(r"^[A-Z]{3}$", v.strip()):
|
|
||||||
defects.add(severity, "L2", "BAD_CURRENCY",
|
|
||||||
f"'{prop}' is not a 3-letter ISO-4217 code: {v!r}.",
|
|
||||||
eid, url, ntype)
|
|
||||||
if prop in vf["number_props"]:
|
|
||||||
for v in each(value):
|
|
||||||
if isinstance(v, str):
|
|
||||||
try:
|
|
||||||
float(v.replace(",", ""))
|
|
||||||
except ValueError:
|
|
||||||
defects.add(severity, "L2", "BAD_NUMBER",
|
|
||||||
f"'{prop}' is not numeric: {v!r}.", eid, url, ntype)
|
|
||||||
|
|
||||||
|
|
||||||
def layer2_vocabulary(node, rules, defects, eid, url, strict):
|
|
||||||
ntype = type_of(node)
|
|
||||||
known = rules["known_types"]
|
|
||||||
containers = set(rules["container_types"])
|
|
||||||
minor = "P1" if strict else "P2"
|
|
||||||
|
|
||||||
if ntype and ntype not in known and ntype not in containers:
|
|
||||||
defects.add(minor, "L2", "UNKNOWN_TYPE",
|
|
||||||
f"@type '{ntype}' is not in the curated rule set "
|
|
||||||
"(treated as a warning — add it to schema_rules.json if intended).",
|
|
||||||
eid, url, ntype)
|
|
||||||
|
|
||||||
_check_value_formats(node, rules, defects, eid, url, ntype, minor)
|
|
||||||
|
|
||||||
# Unexpected-property check is OPT-IN (--strict). Off by default to avoid the
|
|
||||||
# exact noise explosion that makes clients say "too many errors".
|
|
||||||
if strict and ntype in known:
|
|
||||||
spec = known[ntype]
|
|
||||||
allowed = set(spec["required"]) | set(spec["recommended"]) | set(spec["allowed"])
|
|
||||||
allowed |= set(rules["global_properties"])
|
|
||||||
for prop in node:
|
|
||||||
if prop.startswith("@"):
|
|
||||||
continue
|
|
||||||
if prop not in allowed:
|
|
||||||
defects.add("P1", "L2", "UNEXPECTED_PROPERTY",
|
|
||||||
f"'{prop}' is not a known property of {ntype} (strict mode).",
|
|
||||||
eid, url, ntype)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Layer 3 — Rich-result (required / recommended)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def layer3_richresult(node, rules, defects, eid, url, no_recommended):
|
|
||||||
ntype = type_of(node)
|
|
||||||
known = rules["known_types"]
|
|
||||||
if ntype not in known:
|
|
||||||
return # containers + unknown types have no required-property contract
|
|
||||||
spec = known[ntype]
|
|
||||||
|
|
||||||
for prop in spec["required"]:
|
|
||||||
if not node.get(prop):
|
|
||||||
defects.add("P0", "L3", "MISSING_REQUIRED",
|
|
||||||
f"{ntype} is missing required property '{prop}' "
|
|
||||||
"(blocks the rich result).", eid, url, ntype)
|
|
||||||
|
|
||||||
if not no_recommended:
|
|
||||||
missing_rec = [p for p in spec["recommended"] if not node.get(p)]
|
|
||||||
if missing_rec:
|
|
||||||
# Aggregate to ONE line per node — never one defect per property.
|
|
||||||
defects.add("P2", "L3", "MISSING_RECOMMENDED",
|
|
||||||
f"{ntype} is missing recommended properties: "
|
|
||||||
f"{', '.join(missing_rec)}.", eid, url, ntype)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Layer 4 — Consistency (cross-node / cross-entry)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
NAP_TYPES = {"Organization", "Corporation", "LocalBusiness", "Hotel",
|
|
||||||
"LodgingBusiness", "Resort", "Restaurant", "FoodEstablishment", "BarOrPub"}
|
|
||||||
|
|
||||||
|
|
||||||
def _address_street(node):
|
|
||||||
addr = node.get("address")
|
|
||||||
if isinstance(addr, dict):
|
|
||||||
return normalize_name(addr.get("streetAddress"))
|
|
||||||
if isinstance(addr, list) and addr and isinstance(addr[0], dict):
|
|
||||||
return normalize_name(addr[0].get("streetAddress"))
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _walk_ids(obj, defined, referenced):
|
|
||||||
"""Collect @id definitions vs pure references by walking the whole document.
|
|
||||||
|
|
||||||
A *reference* is an object whose only key is @id (e.g. {"@id": "...#org"}).
|
|
||||||
A *definition* is any object carrying @id plus other content. References live
|
|
||||||
in untyped wrapper dicts, so this must walk the raw doc — not just typed nodes.
|
|
||||||
"""
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
nid = obj.get("@id")
|
|
||||||
if nid:
|
|
||||||
if set(obj.keys()) == {"@id"}:
|
|
||||||
referenced.add(nid)
|
|
||||||
else:
|
|
||||||
defined.setdefault(nid, []).append(obj)
|
|
||||||
for v in obj.values():
|
|
||||||
_walk_ids(v, defined, referenced)
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
for v in obj:
|
|
||||||
_walk_ids(v, defined, referenced)
|
|
||||||
|
|
||||||
|
|
||||||
def layer4_consistency(node_index, parsed_docs, rules, defects):
|
|
||||||
"""node_index: (entry, node) for every TYPED node.
|
|
||||||
parsed_docs: (entry, parsed) for every entry that parsed — used for @id scan."""
|
|
||||||
# ---- placeholder text (P0) ----
|
|
||||||
tokens = [t.lower() for t in rules["placeholder_tokens"]]
|
|
||||||
for entry, node in node_index:
|
|
||||||
ntype = type_of(node)
|
|
||||||
for key, val in all_strings(node):
|
|
||||||
low = val.lower()
|
|
||||||
hit = next((t for t in tokens if t in low), None)
|
|
||||||
if hit:
|
|
||||||
defects.add("P0", "L4", "PLACEHOLDER_TEXT",
|
|
||||||
f"Placeholder/boilerplate token {hit!r} in '{key}': {val[:60]!r}.",
|
|
||||||
entry["entry_id"], entry["url"], ntype)
|
|
||||||
break # one placeholder defect per node is enough signal
|
|
||||||
|
|
||||||
# ---- NAP consistency (P0) ----
|
|
||||||
by_name = defaultdict(list)
|
|
||||||
for entry, node in node_index:
|
|
||||||
if type_of(node) in NAP_TYPES and node.get("name"):
|
|
||||||
by_name[normalize_name(first_text(node.get("name")))].append((entry, node))
|
|
||||||
for name, group in by_name.items():
|
|
||||||
phones = {str(first_text(n.get("telephone"))).strip()
|
|
||||||
for _, n in group if n.get("telephone")}
|
|
||||||
streets = {_address_street(n) for _, n in group if _address_street(n)}
|
|
||||||
if len(phones) > 1:
|
|
||||||
defects.add("P0", "L4", "NAP_PHONE_MISMATCH",
|
|
||||||
f"Business '{name}' has conflicting telephone values across "
|
|
||||||
f"entries: {sorted(phones)}.", entry_id="(dataset)")
|
|
||||||
if len(streets) > 1:
|
|
||||||
defects.add("P0", "L4", "NAP_ADDRESS_MISMATCH",
|
|
||||||
f"Business '{name}' has conflicting streetAddress values across "
|
|
||||||
f"entries: {sorted(streets)}.", entry_id="(dataset)")
|
|
||||||
|
|
||||||
# ---- @id duplicates + dangling references (P1) ----
|
|
||||||
defined = {} # @id -> list of definition dicts (walked across all docs)
|
|
||||||
referenced = set() # @id values used purely as references
|
|
||||||
for _, parsed in parsed_docs:
|
|
||||||
_walk_ids(parsed, defined, referenced)
|
|
||||||
for nid, defs in defined.items():
|
|
||||||
if len(defs) > 1:
|
|
||||||
# duplicate only matters if the definitions actually differ
|
|
||||||
shapes = {json.dumps(n, sort_keys=True, ensure_ascii=False) for n in defs}
|
|
||||||
if len(shapes) > 1:
|
|
||||||
defects.add("P1", "L4", "DUPLICATE_ID",
|
|
||||||
f"@id {nid!r} is defined {len(defs)} times with differing content.",
|
|
||||||
entry_id="(dataset)")
|
|
||||||
for nid in sorted(referenced - set(defined)):
|
|
||||||
defects.add("P1", "L4", "DANGLING_ID",
|
|
||||||
f"@id reference {nid!r} points to a node that is never defined.",
|
|
||||||
entry_id="(dataset)")
|
|
||||||
|
|
||||||
# ---- swapped / out-of-range geo (P1) ----
|
|
||||||
g = rules["geo"]
|
|
||||||
for entry, node in node_index:
|
|
||||||
if type_of(node) != "GeoCoordinates":
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
lat = float(first_text(node.get("latitude")))
|
|
||||||
lon = float(first_text(node.get("longitude")))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
lat_ok = g["lat_min"] <= lat <= g["lat_max"]
|
|
||||||
lon_ok = g["lon_min"] <= lon <= g["lon_max"]
|
|
||||||
if lat_ok and lon_ok:
|
|
||||||
continue # both in valid global range
|
|
||||||
# Invalid — distinguish a clean transposition from plain garbage.
|
|
||||||
swap_ok = (g["lat_min"] <= lon <= g["lat_max"]) and (g["lon_min"] <= lat <= g["lon_max"])
|
|
||||||
if swap_ok:
|
|
||||||
defects.add("P1", "L4", "GEO_SWAPPED",
|
|
||||||
f"GeoCoordinates look transposed (latitude={lat}, longitude={lon}) "
|
|
||||||
"— swapping them yields valid coordinates.",
|
|
||||||
entry["entry_id"], entry["url"], "GeoCoordinates")
|
|
||||||
else:
|
|
||||||
defects.add("P1", "L4", "GEO_OUT_OF_RANGE",
|
|
||||||
f"GeoCoordinates out of range (latitude={lat}, longitude={lon}).",
|
|
||||||
entry["entry_id"], entry["url"], "GeoCoordinates")
|
|
||||||
|
|
||||||
# ---- duplicate descriptions across entries (P1) ----
|
|
||||||
desc_groups = defaultdict(set)
|
|
||||||
for entry, node in node_index:
|
|
||||||
d = first_text(node.get("description"))
|
|
||||||
if isinstance(d, str) and len(d.strip()) >= 30:
|
|
||||||
desc_groups[d.strip()].add(entry["entry_id"])
|
|
||||||
for desc, eids in desc_groups.items():
|
|
||||||
if len(eids) >= 3:
|
|
||||||
defects.add("P1", "L4", "DUPLICATE_DESCRIPTION",
|
|
||||||
f"Identical description reused across {len(eids)} entries "
|
|
||||||
f"(e.g. {sorted(eids)[:3]}): {desc[:50]!r}…", entry_id="(dataset)")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Orchestration + output
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def run(entries, rules, inventory, strict, no_recommended):
|
|
||||||
defects = DefectLog()
|
|
||||||
if inventory is not None:
|
|
||||||
layer0_coverage(entries, inventory, defects)
|
|
||||||
|
|
||||||
node_index = []
|
|
||||||
parsed_docs = []
|
|
||||||
valid_entries = 0
|
|
||||||
for entry in entries:
|
|
||||||
parsed = layer1_syntax(entry, rules, defects)
|
|
||||||
if parsed is None:
|
|
||||||
continue
|
|
||||||
valid_entries += 1
|
|
||||||
parsed_docs.append((entry, parsed))
|
|
||||||
for node in iter_typed_nodes(parsed):
|
|
||||||
layer2_vocabulary(node, rules, defects, entry["entry_id"], entry["url"], strict)
|
|
||||||
layer3_richresult(node, rules, defects, entry["entry_id"], entry["url"],
|
|
||||||
no_recommended)
|
|
||||||
node_index.append((entry, node))
|
|
||||||
|
|
||||||
layer4_consistency(node_index, parsed_docs, rules, defects)
|
|
||||||
return defects, valid_entries, len(node_index)
|
|
||||||
|
|
||||||
|
|
||||||
def write_outputs(defects, outdir, meta):
|
|
||||||
outdir = Path(outdir)
|
|
||||||
outdir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# defect_log.csv — the client-facing triage artifact
|
|
||||||
fields = ["entry_id", "url", "node_type", "layer", "code", "severity",
|
|
||||||
"message", "status", "owner", "note"]
|
|
||||||
rows = sorted(defects.rows, key=lambda r: (SEVERITY_ORDER[r["severity"]],
|
|
||||||
r["layer"], r["code"]))
|
|
||||||
with open(outdir / "defect_log.csv", "w", newline="", encoding="utf-8-sig") as f:
|
|
||||||
w = csv.DictWriter(f, fieldnames=fields)
|
|
||||||
w.writeheader()
|
|
||||||
w.writerows(rows)
|
|
||||||
|
|
||||||
counts = defects.counts()
|
|
||||||
gate = "PASS" if counts["P0"] == 0 else "FAIL"
|
|
||||||
by_code = Counter((r["severity"], r["code"]) for r in defects.rows)
|
|
||||||
|
|
||||||
# results.json — machine-readable
|
|
||||||
results = {
|
|
||||||
"summary": {**meta, **counts, "total": len(rows), "gate": gate},
|
|
||||||
"by_code": [{"severity": s, "code": c, "count": n}
|
|
||||||
for (s, c), n in by_code.most_common()],
|
|
||||||
"defects": rows,
|
|
||||||
}
|
|
||||||
(outdir / "results.json").write_text(
|
|
||||||
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
||||||
|
|
||||||
# report.md — human summary
|
|
||||||
lines = [
|
|
||||||
"# Schema Validation Report", "",
|
|
||||||
f"- Entries read: **{meta['entries']}** | parsed OK: **{meta['valid_entries']}** "
|
|
||||||
f"| nodes checked: **{meta['nodes']}**",
|
|
||||||
f"- Defects: **P0 {counts['P0']}** · **P1 {counts['P1']}** · **P2 {counts['P2']}** "
|
|
||||||
f"(total {len(rows)})",
|
|
||||||
"",
|
|
||||||
f"## Gate: **{gate}**",
|
|
||||||
("> ✅ Zero P0 — entries may advance to client review."
|
|
||||||
if gate == "PASS" else
|
|
||||||
"> ⛔ P0 present — these entries must NOT reach client review. Fix P0 first."),
|
|
||||||
"",
|
|
||||||
"## Defects by code", "",
|
|
||||||
"| Severity | Code | Count |", "|---|---|---|",
|
|
||||||
]
|
|
||||||
for (sev, code), n in by_code.most_common():
|
|
||||||
lines.append(f"| {sev} | {code} | {n} |")
|
|
||||||
|
|
||||||
p0 = [r for r in rows if r["severity"] == "P0"]
|
|
||||||
if p0:
|
|
||||||
lines += ["", "## P0 blockers (top 15)", "",
|
|
||||||
"| Entry | Type | Code | Message |", "|---|---|---|---|"]
|
|
||||||
for r in p0[:15]:
|
|
||||||
msg = r["message"].replace("|", "\\|")
|
|
||||||
lines.append(f"| {r['entry_id']} | {r['node_type']} | {r['code']} | {msg} |")
|
|
||||||
|
|
||||||
lines += ["", "## Next step",
|
|
||||||
("Triage P1 in `defect_log.csv`; client reviews the clean entries against this report."
|
|
||||||
if gate == "PASS" else
|
|
||||||
"Assign and fix every P0, re-run the validator, and only then open client review."),
|
|
||||||
""]
|
|
||||||
(outdir / "report.md").write_text("\n".join(lines), encoding="utf-8")
|
|
||||||
return gate, counts
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
ap = argparse.ArgumentParser(description="5-layer offline JSON-LD schema validator.")
|
|
||||||
ap.add_argument("dataset", nargs="?", help="xlsx/csv/jsonl/json file or a directory")
|
|
||||||
ap.add_argument("--url-list", help="canonical URL inventory (xlsx/csv/txt) → enables Layer 0")
|
|
||||||
ap.add_argument("--out", default="schema_qa_out", help="output directory")
|
|
||||||
ap.add_argument("--strict", action="store_true",
|
|
||||||
help="unexpected props on known types → P1; unknown types → P1")
|
|
||||||
ap.add_argument("--no-recommended", action="store_true",
|
|
||||||
help="drop L3 recommended (P2) findings — highest-signal gate")
|
|
||||||
ap.add_argument("--live", nargs="+", metavar="URL",
|
|
||||||
help="Mode B: validate live URLs (extract embedded JSON-LD)")
|
|
||||||
ap.add_argument("--rules", default=str(RULES_DEFAULT), help="path to schema_rules.json")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
|
|
||||||
if not args.dataset and not args.live:
|
|
||||||
ap.error("provide a DATASET path or --live URL ...")
|
|
||||||
|
|
||||||
rules = json.loads(Path(args.rules).read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
try:
|
|
||||||
entries = load_entries(args.dataset, args.live)
|
|
||||||
except (ValueError, FileNotFoundError) as exc:
|
|
||||||
print(f"ERROR loading input: {exc}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
inventory = load_url_inventory(args.url_list) if args.url_list else None
|
|
||||||
|
|
||||||
defects, valid_entries, nodes = run(entries, rules, inventory,
|
|
||||||
args.strict, args.no_recommended)
|
|
||||||
meta = {"entries": len(entries), "valid_entries": valid_entries, "nodes": nodes,
|
|
||||||
"mode": "B-live" if args.live else "A-dataset", "strict": args.strict,
|
|
||||||
"coverage": inventory is not None}
|
|
||||||
gate, counts = write_outputs(defects, args.out, meta)
|
|
||||||
|
|
||||||
print(f"[{gate}] entries={len(entries)} nodes={nodes} "
|
|
||||||
f"P0={counts['P0']} P1={counts['P1']} P2={counts['P2']} → {args.out}/")
|
|
||||||
# Exit 1 when the gate fails so CI and `&&` chains stop on P0.
|
|
||||||
return 0 if gate == "PASS" else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -11,14 +11,14 @@ Push markdown content to Notion pages or databases via Claude Code.
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts/venv`
|
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts/venv`
|
||||||
- Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below)
|
- Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below)
|
||||||
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
|
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
cd ~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,19 +139,6 @@ python notion_writer.py -d DATABASE_URL -t "Entry Title" -f content.md
|
|||||||
| `---` | Divider |
|
| `---` | Divider |
|
||||||
| Paragraphs | Paragraph |
|
| Paragraphs | Paragraph |
|
||||||
|
|
||||||
### Engines and image uploads
|
|
||||||
|
|
||||||
Two write engines via `--engine {blocks,markdown}` (default: `blocks`).
|
|
||||||
|
|
||||||
The **blocks engine** (default) converts markdown locally to Notion block objects. Local images (``) are auto-uploaded via the `ntn` CLI and embedded at their original position in the page. Requires `ntn` installed and `ntn login`.
|
|
||||||
|
|
||||||
The **markdown engine** (`--engine markdown`) posts the document through Notion's native enhanced-markdown API (`Notion-Version: 2026-03-11`, set automatically; override with `--notion-version`). The skill's authoring dialect — GitHub alerts (`[!NOTE]`), Pandoc columns (`::: columns`), `<details>` toggles, and `@[mention]` — is auto-translated before posting. Note: local images are appended at the end of the page rather than inline with this engine; use `--engine blocks` when image position matters. Pass `--allow-deleting-content` when `--replace` needs to remove child pages or databases.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Markdown engine — create a DB row from a doc with callouts or columns
|
|
||||||
python notion_writer.py -d DB_URL -t "Notes" --engine markdown -f notes.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Example
|
## Workflow Example
|
||||||
|
|
||||||
Integrate with Jamie YouTube Manager to log video info:
|
Integrate with Jamie YouTube Manager to log video info:
|
||||||
|
|||||||
@@ -189,52 +189,6 @@ print("Hello")
|
|||||||
|
|
||||||
Notion's URL validator requires absolute URLs for link annotations. The parser converts TOC-style anchor links to bold to preserve navigation intent and silently strips relative paths.
|
Notion's URL validator requires absolute URLs for link annotations. The parser converts TOC-style anchor links to bold to preserve navigation intent and silently strips relative paths.
|
||||||
|
|
||||||
### File uploads
|
|
||||||
|
|
||||||
Standalone local-image lines (``) are auto-uploaded to Notion and embedded as `file_upload` image blocks. Remote images (``) are left as external links unchanged.
|
|
||||||
|
|
||||||
**Requirements:**
|
|
||||||
- `ntn` CLI installed: `curl -fsSL https://ntn.dev | bash`
|
|
||||||
|
|
||||||
Uploads run as the **same integration** that writes the page (`NOTION_API_KEY`). The script injects `NOTION_API_TOKEN=$NOTION_API_KEY` into every `ntn` subprocess, so the file upload and the page share one identity — no separate `ntn login` or workspace matching is needed. `ntn` only needs to be installed, not logged in.
|
|
||||||
|
|
||||||
### Engines
|
|
||||||
|
|
||||||
Two write engines, selected with `--engine {blocks,markdown}`. Default is `blocks`.
|
|
||||||
|
|
||||||
| Engine | Flag | When to use |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| **blocks** (default) | `--engine blocks` | General use; images embed at their exact position; full table + container support |
|
|
||||||
| **markdown** | `--engine markdown` | Richer Notion-native formatting via enhanced-markdown endpoints |
|
|
||||||
|
|
||||||
**blocks engine** converts markdown to Notion block objects locally (via `markdown_to_notion_blocks`). Local images are uploaded via `ntn` and embedded at their exact position in the document.
|
|
||||||
|
|
||||||
**markdown engine** posts the document through Notion's native enhanced-markdown endpoints (`Notion-Version: 2026-03-11`, set automatically). The skill's authoring dialect is auto-translated before posting:
|
|
||||||
|
|
||||||
| Skill dialect | Translated to |
|
|
||||||
|---------------|---------------|
|
|
||||||
| `> [!NOTE]` / `[!TIP]` / `[!IMPORTANT]` / `[!WARNING]` / `[!CAUTION]` | `<callout icon="..." color="...">` |
|
|
||||||
| `::: columns` / `::: column` / `:::` | `<columns><column>...</column></columns>` |
|
|
||||||
| `<details><summary>...</summary>` | `<details>` (Notion toggle) |
|
|
||||||
| `@[Title](id-or-url)` | `<mention-page url="...">` |
|
|
||||||
|
|
||||||
**Local image limitation with `--engine markdown`**: local images cannot be placed inline via the enhanced-markdown API. They are appended at the end of the page as a second write pass. Use `--engine blocks` when image placement matters.
|
|
||||||
|
|
||||||
**`--notion-version`**: Override the API version for the markdown engine (default: `2026-03-11`).
|
|
||||||
|
|
||||||
**`--allow-deleting-content`**: Required when `--replace` needs to delete child pages or databases under the target page; the Notion API refuses such deletions unless this flag is present. Applies only to the markdown engine's `--replace` path (`--engine markdown --replace`); has no effect with `--engine blocks`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Markdown engine, create a row from a doc with callouts/columns
|
|
||||||
python notion_writer.py -d DB_URL -t "Notes" --engine markdown -f notes.md
|
|
||||||
|
|
||||||
# Blocks engine with a local image (auto-uploaded via ntn)
|
|
||||||
python notion_writer.py -p PAGE_URL -f post.md # post.md contains 
|
|
||||||
|
|
||||||
# Markdown engine, replace page allowing child deletion
|
|
||||||
python notion_writer.py -p PAGE_URL -f doc.md --replace --engine markdown --allow-deleting-content
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@@ -444,10 +398,9 @@ python notion_writer.py -d DB_URL -t "Title" --upsert-by "Name" -f content.md
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Version 1.3.0 | Claude Code | 2026-06-27*
|
*Version 1.2.0 | Claude Code | 2026-04-27*
|
||||||
|
|
||||||
Changelog:
|
Changelog:
|
||||||
- 1.3.0 — Local file/image uploads via the `ntn` CLI (`` → file_upload image blocks). New `--engine markdown` path writing through Notion's native enhanced-markdown endpoints with a dialect translator. Added `--notion-version` and `--allow-deleting-content`.
|
|
||||||
- 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `<details>` toggles, Pandoc `::: columns` fenced div, inline `@[Title](id-or-url)` page mentions. Parser made reentrant to support full recursion inside container blocks.
|
- 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `<details>` toggles, Pandoc `::: columns` fenced div, inline `@[Title](id-or-url)` page mentions. Parser made reentrant to support full recursion inside container blocks.
|
||||||
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages.
|
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages.
|
||||||
- 1.0.0 — Initial release with markdown→Notion block conversion.
|
- 1.0.0 — Initial release with markdown→Notion block conversion.
|
||||||
|
|||||||
@@ -16,11 +16,8 @@ from notion_client import Client
|
|||||||
from notion_client.errors import APIErrorCode, APIResponseError
|
from notion_client.errors import APIErrorCode, APIResponseError
|
||||||
|
|
||||||
|
|
||||||
def make_client(api_key: str, notion_version: str = None) -> Client:
|
def make_client(api_key: str) -> Client:
|
||||||
"""Build a sync Notion client. Pass notion_version to override the SDK
|
"""Build a sync Notion client on the SDK default API version (2025-09-03+)."""
|
||||||
default (needed: 2026-03-11 for the markdown content endpoints)."""
|
|
||||||
if notion_version:
|
|
||||||
return Client(auth=api_key, notion_version=notion_version)
|
|
||||||
return Client(auth=api_key)
|
return Client(auth=api_key)
|
||||||
|
|
||||||
|
|
||||||
@@ -213,10 +210,7 @@ def explain_api_error(exc: APIResponseError, context: str = "") -> str:
|
|||||||
"https://www.notion.so/my-integrations."
|
"https://www.notion.so/my-integrations."
|
||||||
)
|
)
|
||||||
if code == APIErrorCode.ValidationError:
|
if code == APIErrorCode.ValidationError:
|
||||||
msg = exc.body.get('message', str(exc))
|
return f"Validation error{suffix}: {exc.body.get('message', str(exc))}"
|
||||||
if 'delet' in msg.lower():
|
|
||||||
msg += " — re-run with --allow-deleting-content to permit this."
|
|
||||||
return f"Validation error{suffix}: {msg}"
|
|
||||||
if code == APIErrorCode.RateLimited:
|
if code == APIErrorCode.RateLimited:
|
||||||
return f"Rate limited{suffix}. Back off and retry."
|
return f"Rate limited{suffix}. Back off and retry."
|
||||||
return f"Notion API error [{code}]{suffix}: {exc}"
|
return f"Notion API error [{code}]{suffix}: {exc}"
|
||||||
@@ -259,33 +253,3 @@ def find_existing_page(
|
|||||||
)
|
)
|
||||||
results = response.get("results") or []
|
results = response.get("results") or []
|
||||||
return results[0] if results else None
|
return results[0] if results else None
|
||||||
|
|
||||||
|
|
||||||
def create_page_markdown(client, parent, properties, markdown):
|
|
||||||
"""POST /v1/pages with the enhanced-markdown body param.
|
|
||||||
`markdown` is only included in the body when non-empty to avoid sending
|
|
||||||
a blank markdown field when no --file/--stdin was supplied."""
|
|
||||||
body: Dict[str, Any] = {"parent": parent, "properties": properties}
|
|
||||||
if markdown:
|
|
||||||
body["markdown"] = markdown
|
|
||||||
return client.request(path="pages", method="POST", body=body)
|
|
||||||
|
|
||||||
|
|
||||||
def append_markdown(client, page_id, markdown):
|
|
||||||
"""PATCH /v1/pages/:id/markdown — append at end (insert_content)."""
|
|
||||||
return client.request(
|
|
||||||
path=f"pages/{page_id}/markdown", method="PATCH",
|
|
||||||
body={"type": "insert_content",
|
|
||||||
"insert_content": {"content": markdown,
|
|
||||||
"position": {"type": "end"}}},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def replace_markdown(client, page_id, markdown, allow_deleting=False):
|
|
||||||
"""PATCH /v1/pages/:id/markdown — replace all content."""
|
|
||||||
return client.request(
|
|
||||||
path=f"pages/{page_id}/markdown", method="PATCH",
|
|
||||||
body={"type": "replace_content",
|
|
||||||
"replace_content": {"new_str": markdown,
|
|
||||||
"allow_deleting_content": allow_deleting}},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Translate the notion-writer markdown dialect into Notion enhanced
|
|
||||||
markdown for the markdown write engine. Pure functions, no I/O."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
# NOTE: `notion_writer` is imported lazily inside _translate_mentions to avoid
|
|
||||||
# a circular import (notion_writer imports this module at its top level).
|
|
||||||
|
|
||||||
# Skill alert type -> (emoji, Notion enhanced-markdown background color)
|
|
||||||
CALLOUT_MAP = {
|
|
||||||
"NOTE": ("ℹ️", "blue_bg"),
|
|
||||||
"TIP": ("💡", "green_bg"),
|
|
||||||
"IMPORTANT": ("☝️", "purple_bg"),
|
|
||||||
"WARNING": ("⚠️", "yellow_bg"),
|
|
||||||
"CAUTION": ("🚨", "red_bg"),
|
|
||||||
}
|
|
||||||
_ALERT_RE = re.compile(r'^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$')
|
|
||||||
_MENTION_RE = re.compile(r'@\[([^\]]+)\]\(([^)\s]+)\)')
|
|
||||||
|
|
||||||
|
|
||||||
def _translate_mentions(text: str) -> str:
|
|
||||||
from notion_writer import extract_notion_id, format_id_with_dashes # lazy: breaks import cycle
|
|
||||||
|
|
||||||
def repl(m: "re.Match") -> str:
|
|
||||||
title, target = m.group(1), m.group(2)
|
|
||||||
page_id = extract_notion_id(target)
|
|
||||||
if page_id:
|
|
||||||
return (f'<mention-page url="{format_id_with_dashes(page_id)}">'
|
|
||||||
f'{title}</mention-page>')
|
|
||||||
return f"@{title}"
|
|
||||||
return _MENTION_RE.sub(repl, text)
|
|
||||||
|
|
||||||
|
|
||||||
def _indent(lines: List[str]) -> List[str]:
|
|
||||||
return ["\t" + ln if ln.strip() else ln for ln in lines]
|
|
||||||
|
|
||||||
|
|
||||||
def translate(content: str) -> str:
|
|
||||||
lines = content.split("\n")
|
|
||||||
out: List[str] = []
|
|
||||||
i = 0
|
|
||||||
in_fence = False
|
|
||||||
while i < len(lines):
|
|
||||||
line = lines[i]
|
|
||||||
|
|
||||||
# Fence tracking: pass through unchanged, toggle state
|
|
||||||
if line.strip().startswith("```"):
|
|
||||||
in_fence = not in_fence
|
|
||||||
out.append(line)
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Inside fence: pass through verbatim, no transformation
|
|
||||||
if in_fence:
|
|
||||||
out.append(line)
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Callout: > [!TYPE] then contiguous > body lines
|
|
||||||
if line.lstrip().startswith(">"):
|
|
||||||
body_first = line.lstrip()[1:].strip()
|
|
||||||
alert = _ALERT_RE.match(body_first)
|
|
||||||
if alert:
|
|
||||||
emoji, color = CALLOUT_MAP[alert.group(1)]
|
|
||||||
i += 1
|
|
||||||
body: List[str] = []
|
|
||||||
while i < len(lines) and lines[i].lstrip().startswith(">"):
|
|
||||||
body.append(lines[i].lstrip()[1:].lstrip())
|
|
||||||
i += 1
|
|
||||||
out.append(f'<callout icon="{emoji}" color="{color}">')
|
|
||||||
out.extend(_indent([_translate_mentions(b) for b in body]))
|
|
||||||
out.append("</callout>")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Columns: ::: columns / ::: column / :::
|
|
||||||
# State machine: "::: column" opens a column; ":::" closes a column
|
|
||||||
# when one is open, otherwise closes the wrapper. The Pandoc layout
|
|
||||||
# emits N "::: column" opens, N ":::" column-closes, then one final
|
|
||||||
# ":::" wrapper-close.
|
|
||||||
if line.strip() == "::: columns":
|
|
||||||
i += 1
|
|
||||||
cols: List[List[str]] = []
|
|
||||||
cur: List[str] = None
|
|
||||||
while i < len(lines):
|
|
||||||
s = lines[i].strip()
|
|
||||||
if s == "::: column":
|
|
||||||
if cur is not None:
|
|
||||||
cols.append(cur)
|
|
||||||
cur = []
|
|
||||||
i += 1
|
|
||||||
elif s == ":::":
|
|
||||||
if cur is not None: # close the open column
|
|
||||||
cols.append(cur)
|
|
||||||
cur = None
|
|
||||||
i += 1
|
|
||||||
else: # close the wrapper
|
|
||||||
i += 1
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
if cur is not None:
|
|
||||||
cur.append(lines[i])
|
|
||||||
i += 1
|
|
||||||
out.append("<columns>")
|
|
||||||
for col in cols:
|
|
||||||
out.append("\t<column>")
|
|
||||||
out.extend(_indent(_indent(
|
|
||||||
[_translate_mentions(c) for c in col])))
|
|
||||||
out.append("\t</column>")
|
|
||||||
out.append("</columns>")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Toggle: <details><summary>..</summary> body </details>
|
|
||||||
if line.strip() == "<details>":
|
|
||||||
out.append("<details>")
|
|
||||||
i += 1
|
|
||||||
if i < len(lines) and lines[i].lstrip().startswith("<summary>"):
|
|
||||||
out.append(lines[i].strip())
|
|
||||||
i += 1
|
|
||||||
body = []
|
|
||||||
while i < len(lines) and lines[i].strip() != "</details>":
|
|
||||||
body.append(_translate_mentions(lines[i]))
|
|
||||||
i += 1
|
|
||||||
out.extend(_indent(body))
|
|
||||||
out.append("</details>")
|
|
||||||
if i < len(lines): # skip closing </details>
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
out.append(_translate_mentions(line))
|
|
||||||
i += 1
|
|
||||||
return "\n".join(out)
|
|
||||||
@@ -17,11 +17,6 @@ from notion_client import Client
|
|||||||
from notion_client.errors import APIResponseError
|
from notion_client.errors import APIResponseError
|
||||||
|
|
||||||
import _notion_compat as compat
|
import _notion_compat as compat
|
||||||
import ntn_files
|
|
||||||
from ntn_files import NtnUploadError
|
|
||||||
import md_translate
|
|
||||||
|
|
||||||
MARKDOWN_NOTION_VERSION = "2026-03-11"
|
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv(Path(__file__).parent / '.env')
|
load_dotenv(Path(__file__).parent / '.env')
|
||||||
@@ -61,7 +56,6 @@ def format_id_with_dashes(raw_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$')
|
TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$')
|
||||||
IMAGE_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)\)\s*$')
|
|
||||||
|
|
||||||
|
|
||||||
def _is_table_row(line: str) -> bool:
|
def _is_table_row(line: str) -> bool:
|
||||||
@@ -216,13 +210,6 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]:
|
|||||||
blocks.append(create_column_list_block(column_blocks))
|
blocks.append(create_column_list_block(column_blocks))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
image_match = IMAGE_RE.match(line)
|
|
||||||
if image_match:
|
|
||||||
blocks.append(create_image_block(
|
|
||||||
image_match.group(1), image_match.group(2)))
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
|
if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
|
||||||
header_cells = _split_table_row(line)
|
header_cells = _split_table_row(line)
|
||||||
i += 2
|
i += 2
|
||||||
@@ -520,55 +507,6 @@ def create_divider_block() -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_image_block(alt: str, target: str) -> Dict[str, Any]:
|
|
||||||
"""Image block in external shape. Local targets are converted to
|
|
||||||
file_upload shape later by ntn_files.materialize_local_media."""
|
|
||||||
block: Dict[str, Any] = {
|
|
||||||
"object": "block",
|
|
||||||
"type": "image",
|
|
||||||
"image": {"type": "external", "external": {"url": target}},
|
|
||||||
}
|
|
||||||
if alt:
|
|
||||||
block["image"]["caption"] = parse_rich_text(alt)
|
|
||||||
return block
|
|
||||||
|
|
||||||
|
|
||||||
def _content_has_local_images(content: str) -> bool:
|
|
||||||
in_fence = False
|
|
||||||
for line in content.split('\n'):
|
|
||||||
if line.strip().startswith('```'):
|
|
||||||
in_fence = not in_fence
|
|
||||||
continue # fence delimiter lines are never image lines
|
|
||||||
if in_fence:
|
|
||||||
continue
|
|
||||||
m = IMAGE_RE.match(line)
|
|
||||||
if m and not m.group(2).startswith(('http://', 'https://')):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def extract_local_images(content: str):
|
|
||||||
"""Remove standalone LOCAL image lines; keep remote ones inline.
|
|
||||||
Returns (content_without_local_images, [(alt, target), ...]).
|
|
||||||
Lines inside fenced code blocks are passed through unchanged."""
|
|
||||||
kept, imgs = [], []
|
|
||||||
in_fence = False
|
|
||||||
for line in content.split('\n'):
|
|
||||||
if line.strip().startswith('```'):
|
|
||||||
in_fence = not in_fence
|
|
||||||
kept.append(line)
|
|
||||||
continue
|
|
||||||
if in_fence:
|
|
||||||
kept.append(line)
|
|
||||||
continue
|
|
||||||
m = IMAGE_RE.match(line)
|
|
||||||
if m and not m.group(2).startswith(('http://', 'https://')):
|
|
||||||
imgs.append((m.group(1), m.group(2)))
|
|
||||||
continue
|
|
||||||
kept.append(line)
|
|
||||||
return "\n".join(kept), imgs
|
|
||||||
|
|
||||||
|
|
||||||
def create_table_block(header_cells: List[str], body_rows: List[List[str]]) -> Dict[str, Any]:
|
def create_table_block(header_cells: List[str], body_rows: List[List[str]]) -> Dict[str, Any]:
|
||||||
"""Build a Notion `table` block with header + body rows.
|
"""Build a Notion `table` block with header + body rows.
|
||||||
|
|
||||||
@@ -808,6 +746,21 @@ def update_page_properties(notion: Client, page_id: str, properties: Dict) -> bo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def write_to_page(notion: Client, page_id: str, markdown_content: str, mode: str = 'append') -> bool:
|
||||||
|
"""Write markdown content to a Notion page."""
|
||||||
|
blocks = markdown_to_notion_blocks(markdown_content)
|
||||||
|
|
||||||
|
if not blocks:
|
||||||
|
print("No content to write")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if mode == 'replace':
|
||||||
|
if not clear_page_content(notion, page_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return append_to_page(notion, page_id, blocks)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Push markdown content to Notion pages or databases',
|
description='Push markdown content to Notion pages or databases',
|
||||||
@@ -843,28 +796,9 @@ Examples:
|
|||||||
parser.add_argument('--list', '-l', nargs='?', const='all', choices=['all', 'pages', 'databases'],
|
parser.add_argument('--list', '-l', nargs='?', const='all', choices=['all', 'pages', 'databases'],
|
||||||
help='List accessible pages and/or databases (default: all)')
|
help='List accessible pages and/or databases (default: all)')
|
||||||
parser.add_argument('--info', action='store_true', help='Show page/database info')
|
parser.add_argument('--info', action='store_true', help='Show page/database info')
|
||||||
parser.add_argument('--engine', choices=['blocks', 'markdown'],
|
|
||||||
default='blocks',
|
|
||||||
help='Write engine: blocks (default) or markdown')
|
|
||||||
parser.add_argument('--notion-version', dest='notion_version',
|
|
||||||
help='Override Notion API version')
|
|
||||||
parser.add_argument('--allow-deleting-content', dest='allow_deleting',
|
|
||||||
action='store_true',
|
|
||||||
help='Markdown replace may delete child pages/dbs')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
|
||||||
_main_body(parser, args)
|
|
||||||
except NtnUploadError as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
if e.stderr:
|
|
||||||
print(e.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def _main_body(parser, args):
|
|
||||||
"""Body of main() after argparse; separated so NtnUploadError can be caught cleanly."""
|
|
||||||
if not NOTION_TOKEN:
|
if not NOTION_TOKEN:
|
||||||
print("Error: NOTION_API_KEY not set in environment.")
|
print("Error: NOTION_API_KEY not set in environment.")
|
||||||
print("Preferred: fetch from 1Password at runtime —")
|
print("Preferred: fetch from 1Password at runtime —")
|
||||||
@@ -951,64 +885,23 @@ def _main_body(parser, args):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
content = file_path.read_text(encoding='utf-8')
|
content = file_path.read_text(encoding='utf-8')
|
||||||
|
|
||||||
base_dir = Path(args.file).parent if args.file else Path.cwd()
|
|
||||||
|
|
||||||
def _md_client():
|
|
||||||
version = args.notion_version or MARKDOWN_NOTION_VERSION
|
|
||||||
return compat.make_client(NOTION_TOKEN, notion_version=version)
|
|
||||||
|
|
||||||
def _upload_blocks_for(images):
|
|
||||||
"""Build + materialize image blocks for two-phase markdown writes."""
|
|
||||||
blocks = [create_image_block(alt, target) for alt, target in images]
|
|
||||||
return ntn_files.materialize_local_media(blocks, base_dir, ntn_files.upload)
|
|
||||||
|
|
||||||
# Write to page
|
# Write to page
|
||||||
if args.page:
|
if args.page:
|
||||||
if not content:
|
if not content:
|
||||||
print("Error: No content provided. Use --file or --stdin")
|
print("Error: No content provided. Use --file or --stdin")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
page_id = extract_notion_id(args.page)
|
page_id = extract_notion_id(args.page)
|
||||||
if not page_id:
|
if not page_id:
|
||||||
print(f"Error: Invalid Notion page URL/ID: {args.page}")
|
print(f"Error: Invalid Notion page URL/ID: {args.page}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
mode = 'replace' if args.replace else 'append'
|
||||||
|
print(f"{'Replacing' if mode == 'replace' else 'Appending'} content to page...")
|
||||||
|
|
||||||
|
if write_to_page(notion, page_id, content, mode):
|
||||||
|
print(f"✅ Successfully wrote content to page")
|
||||||
formatted_id = format_id_with_dashes(page_id)
|
formatted_id = format_id_with_dashes(page_id)
|
||||||
|
|
||||||
if args.engine == 'markdown':
|
|
||||||
if _content_has_local_images(content):
|
|
||||||
ntn_files.preflight()
|
|
||||||
md_body, local_imgs = extract_local_images(content)
|
|
||||||
md_body = md_translate.translate(md_body)
|
|
||||||
mc = _md_client()
|
|
||||||
try:
|
|
||||||
if args.replace:
|
|
||||||
compat.replace_markdown(mc, formatted_id, md_body,
|
|
||||||
allow_deleting=args.allow_deleting)
|
|
||||||
else:
|
|
||||||
compat.append_markdown(mc, formatted_id, md_body)
|
|
||||||
except APIResponseError as exc:
|
|
||||||
print(f"Error: {compat.explain_api_error(exc, formatted_id)}")
|
|
||||||
sys.exit(1)
|
|
||||||
if local_imgs and not append_to_page(notion, page_id, _upload_blocks_for(local_imgs)):
|
|
||||||
print("❌ Text was written but image append failed")
|
|
||||||
sys.exit(1)
|
|
||||||
print("✅ Successfully wrote content to page (markdown engine)")
|
|
||||||
print(f" https://notion.so/{formatted_id.replace('-', '')}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# blocks engine (default)
|
|
||||||
blocks = markdown_to_notion_blocks(content)
|
|
||||||
if _content_has_local_images(content):
|
|
||||||
ntn_files.preflight()
|
|
||||||
blocks = ntn_files.materialize_local_media(blocks, base_dir, ntn_files.upload)
|
|
||||||
if not blocks:
|
|
||||||
print("No content to write")
|
|
||||||
sys.exit(1)
|
|
||||||
print(f"{'Replacing' if args.replace else 'Appending'} content to page...")
|
|
||||||
if args.replace and not clear_page_content(notion, page_id):
|
|
||||||
print("❌ Failed to write content")
|
|
||||||
sys.exit(1)
|
|
||||||
if append_to_page(notion, page_id, blocks):
|
|
||||||
print("✅ Successfully wrote content to page")
|
|
||||||
print(f" https://notion.so/{formatted_id.replace('-', '')}")
|
print(f" https://notion.so/{formatted_id.replace('-', '')}")
|
||||||
else:
|
else:
|
||||||
print("❌ Failed to write content")
|
print("❌ Failed to write content")
|
||||||
@@ -1067,8 +960,6 @@ def _main_body(parser, args):
|
|||||||
|
|
||||||
content_blocks = markdown_to_notion_blocks(content) if content else None
|
content_blocks = markdown_to_notion_blocks(content) if content else None
|
||||||
|
|
||||||
existing = None
|
|
||||||
|
|
||||||
# Upsert path: look for existing row by the named property
|
# Upsert path: look for existing row by the named property
|
||||||
if args.upsert_by:
|
if args.upsert_by:
|
||||||
if args.upsert_by not in schema_props:
|
if args.upsert_by not in schema_props:
|
||||||
@@ -1089,39 +980,6 @@ def _main_body(parser, args):
|
|||||||
print(f"Error during upsert lookup: {compat.explain_api_error(exc)}")
|
print(f"Error during upsert lookup: {compat.explain_api_error(exc)}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if args.engine == 'markdown':
|
|
||||||
md_content = content or ""
|
|
||||||
if _content_has_local_images(md_content):
|
|
||||||
ntn_files.preflight()
|
|
||||||
md_body, local_imgs = extract_local_images(md_content)
|
|
||||||
md_body = md_translate.translate(md_body)
|
|
||||||
mc = _md_client()
|
|
||||||
try:
|
|
||||||
if args.upsert_by and existing:
|
|
||||||
compat.replace_markdown(mc, existing['id'], md_body,
|
|
||||||
allow_deleting=args.allow_deleting)
|
|
||||||
if not update_page_properties(notion, existing['id'], properties):
|
|
||||||
sys.exit(1)
|
|
||||||
new_id = existing['id']
|
|
||||||
else:
|
|
||||||
parent = compat.build_data_source_parent(data_source_id)
|
|
||||||
result = compat.create_page_markdown(mc, parent, properties, md_body)
|
|
||||||
new_id = result['id']
|
|
||||||
except APIResponseError as exc:
|
|
||||||
print(f"Error: {compat.explain_api_error(exc)}")
|
|
||||||
sys.exit(1)
|
|
||||||
if local_imgs and not append_to_page(notion, new_id, _upload_blocks_for(local_imgs)):
|
|
||||||
print("❌ Text was written but image append failed")
|
|
||||||
sys.exit(1)
|
|
||||||
print("✅ Successfully wrote database row (markdown engine)")
|
|
||||||
print(f" https://notion.so/{new_id.replace('-', '')}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if content_blocks and _content_has_local_images(content or ""):
|
|
||||||
ntn_files.preflight()
|
|
||||||
content_blocks = ntn_files.materialize_local_media(
|
|
||||||
content_blocks, base_dir, ntn_files.upload)
|
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
page_id = existing['id']
|
page_id = existing['id']
|
||||||
print(f"Updating existing row (matched on {args.upsert_by}={lookup_value!r})...")
|
print(f"Updating existing row (matched on {args.upsert_by}={lookup_value!r})...")
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Local file uploads to Notion via the `ntn` CLI.
|
|
||||||
|
|
||||||
Owns all subprocess I/O for the skill. The CLI handles the full File Upload
|
|
||||||
lifecycle (create -> send bytes -> complete) and prints the upload ID.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional, Callable, Any
|
|
||||||
|
|
||||||
_WORKSPACE: Optional[Dict[str, str]] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _ntn_env():
|
|
||||||
"""Env for ntn subprocesses: force ntn to authenticate as the SAME
|
|
||||||
integration the script uses (NOTION_API_KEY/NOTION_TOKEN), so an uploaded
|
|
||||||
file and the page that references it share one identity. Notion scopes
|
|
||||||
file uploads to the creating integration, so a mismatch makes the upload
|
|
||||||
un-attachable."""
|
|
||||||
env = dict(os.environ)
|
|
||||||
token = os.environ.get("NOTION_API_KEY") or os.environ.get("NOTION_TOKEN")
|
|
||||||
if token:
|
|
||||||
env["NOTION_API_TOKEN"] = token
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
class NtnUploadError(Exception):
|
|
||||||
"""Raised when `ntn` is unavailable or a file upload fails."""
|
|
||||||
|
|
||||||
def __init__(self, message: str, path: str = "", stderr: str = ""):
|
|
||||||
super().__init__(message)
|
|
||||||
self.path = path
|
|
||||||
self.stderr = stderr
|
|
||||||
|
|
||||||
|
|
||||||
def preflight() -> Dict[str, str]:
|
|
||||||
"""Verify `ntn` is installed and logged in; return its target workspace.
|
|
||||||
|
|
||||||
Cached for the process. Raises NtnUploadError with an actionable hint
|
|
||||||
on failure. Prints one informational line naming the workspace `ntn`
|
|
||||||
targets (file uploads are workspace-scoped).
|
|
||||||
"""
|
|
||||||
global _WORKSPACE
|
|
||||||
if _WORKSPACE is not None:
|
|
||||||
return _WORKSPACE
|
|
||||||
|
|
||||||
if shutil.which("ntn") is None:
|
|
||||||
raise NtnUploadError(
|
|
||||||
"ntn CLI not found. Install it with: "
|
|
||||||
"curl -fsSL https://ntn.dev | bash"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
["ntn", "api", "v1/users/me"],
|
|
||||||
capture_output=True, text=True, env=_ntn_env(),
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise NtnUploadError(
|
|
||||||
"ntn is not logged in. Run: ntn login\n" + result.stderr.strip()
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
me = json.loads(result.stdout)
|
|
||||||
bot = me.get("bot", {})
|
|
||||||
info = {
|
|
||||||
"workspace_name": bot.get("workspace_name", "unknown"),
|
|
||||||
"workspace_id": bot.get("workspace_id", "unknown"),
|
|
||||||
}
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
info = {"workspace_name": "unknown", "workspace_id": "unknown"}
|
|
||||||
|
|
||||||
print(f'ntn -> workspace "{info["workspace_name"]}"', file=sys.stderr)
|
|
||||||
_WORKSPACE = info
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
def upload(path: Path) -> str:
|
|
||||||
"""Upload a local file via `ntn files create --plain`; return its id."""
|
|
||||||
path = Path(path)
|
|
||||||
with open(path, "rb") as fh:
|
|
||||||
result = subprocess.run(
|
|
||||||
["ntn", "files", "create", "--plain"],
|
|
||||||
stdin=fh, capture_output=True, text=True, env=_ntn_env(),
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise NtnUploadError(
|
|
||||||
f"ntn upload failed for {path}", path=str(path),
|
|
||||||
stderr=result.stderr.strip(),
|
|
||||||
)
|
|
||||||
if not result.stdout.strip():
|
|
||||||
raise NtnUploadError(
|
|
||||||
"ntn returned no output", path=str(path), stderr=result.stderr.strip()
|
|
||||||
)
|
|
||||||
first_line = result.stdout.strip().splitlines()[0]
|
|
||||||
upload_id = first_line.split("\t")[0].strip()
|
|
||||||
return upload_id
|
|
||||||
|
|
||||||
|
|
||||||
_REMOTE_PREFIXES = ("http://", "https://")
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_local(url: str, base_dir: Path) -> Optional[Path]:
|
|
||||||
"""Return the resolved path for a local media url, or None if remote."""
|
|
||||||
if url.startswith(_REMOTE_PREFIXES):
|
|
||||||
return None
|
|
||||||
p = Path(url)
|
|
||||||
return p if p.is_absolute() else Path(base_dir) / p
|
|
||||||
|
|
||||||
|
|
||||||
def materialize_local_media(
|
|
||||||
blocks: List[Dict[str, Any]],
|
|
||||||
base_dir: Path,
|
|
||||||
upload_fn: Callable[[Path], str] = upload,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Walk blocks (and nested children); upload local image files and
|
|
||||||
rewrite them to file_upload shape. Remote images are left as-is."""
|
|
||||||
for block in blocks:
|
|
||||||
if block.get("type") == "image":
|
|
||||||
img = block["image"]
|
|
||||||
if img.get("type") == "external":
|
|
||||||
url = img.get("external", {}).get("url", "")
|
|
||||||
resolved = _resolve_local(url, base_dir)
|
|
||||||
if resolved is not None:
|
|
||||||
if not resolved.is_file():
|
|
||||||
raise NtnUploadError(
|
|
||||||
f"image references missing file: {url}",
|
|
||||||
path=str(resolved),
|
|
||||||
)
|
|
||||||
upload_id = upload_fn(resolved)
|
|
||||||
caption = img.get("caption")
|
|
||||||
new_img: Dict[str, Any] = {
|
|
||||||
"type": "file_upload",
|
|
||||||
"file_upload": {"id": upload_id},
|
|
||||||
}
|
|
||||||
if caption:
|
|
||||||
new_img["caption"] = caption
|
|
||||||
block["image"] = new_img
|
|
||||||
# Recurse into any nested children list.
|
|
||||||
btype = block.get("type")
|
|
||||||
nested = block.get(btype, {}) if isinstance(block.get(btype), dict) else {}
|
|
||||||
if isinstance(nested.get("children"), list):
|
|
||||||
materialize_local_media(nested["children"], base_dir, upload_fn)
|
|
||||||
if btype == "column_list":
|
|
||||||
for col in nested.get("children", []):
|
|
||||||
col_children = col.get("column", {}).get("children", [])
|
|
||||||
materialize_local_media(col_children, base_dir, upload_fn)
|
|
||||||
return blocks
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Tests for markdown transport helpers — run with `python test_engine_routing.py`."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
|
|
||||||
import _notion_compat as compat
|
|
||||||
import notion_writer
|
|
||||||
|
|
||||||
|
|
||||||
def _fake_client():
|
|
||||||
c = mock.MagicMock()
|
|
||||||
c.request.return_value = {"object": "page", "id": "p1"}
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_page_markdown():
|
|
||||||
c = _fake_client()
|
|
||||||
parent = {"type": "data_source_id", "data_source_id": "ds1"}
|
|
||||||
compat.create_page_markdown(c, parent, {"Name": {"title": []}}, "# Hi")
|
|
||||||
_, kwargs = c.request.call_args
|
|
||||||
assert kwargs["path"] == "pages"
|
|
||||||
assert kwargs["method"] == "POST"
|
|
||||||
assert kwargs["body"]["markdown"] == "# Hi"
|
|
||||||
assert kwargs["body"]["parent"] == parent
|
|
||||||
|
|
||||||
|
|
||||||
def test_append_markdown():
|
|
||||||
c = _fake_client()
|
|
||||||
compat.append_markdown(c, "page-123", "## More")
|
|
||||||
_, kwargs = c.request.call_args
|
|
||||||
assert kwargs["path"] == "pages/page-123/markdown"
|
|
||||||
assert kwargs["method"] == "PATCH"
|
|
||||||
assert kwargs["body"]["type"] == "insert_content"
|
|
||||||
assert kwargs["body"]["insert_content"]["content"] == "## More"
|
|
||||||
assert kwargs["body"]["insert_content"]["position"] == {"type": "end"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_replace_markdown():
|
|
||||||
c = _fake_client()
|
|
||||||
compat.replace_markdown(c, "page-123", "# Fresh", allow_deleting=True)
|
|
||||||
_, kwargs = c.request.call_args
|
|
||||||
assert kwargs["path"] == "pages/page-123/markdown"
|
|
||||||
assert kwargs["body"]["type"] == "replace_content"
|
|
||||||
assert kwargs["body"]["replace_content"]["new_str"] == "# Fresh"
|
|
||||||
assert kwargs["body"]["replace_content"]["allow_deleting_content"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_local_images_splits():
|
|
||||||
content = ("# Title\n\n\n\n"
|
|
||||||
"\n\nbody")
|
|
||||||
without, imgs = notion_writer.extract_local_images(content)
|
|
||||||
assert imgs == [("local", "./b.png")]
|
|
||||||
assert "" not in without
|
|
||||||
assert "" in without # remote stays
|
|
||||||
|
|
||||||
|
|
||||||
def test_content_has_local_images():
|
|
||||||
assert notion_writer._content_has_local_images("") is True
|
|
||||||
assert notion_writer._content_has_local_images("") is False
|
|
||||||
assert notion_writer._content_has_local_images("no images") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_local_image_inside_fence_ignored():
|
|
||||||
"""A local image reference inside a fenced code block must NOT be detected
|
|
||||||
as a real image by _content_has_local_images or extracted by extract_local_images."""
|
|
||||||
fenced = "```markdown\n\n```"
|
|
||||||
assert notion_writer._content_has_local_images(fenced) is False, \
|
|
||||||
"_content_has_local_images should return False for image inside fence"
|
|
||||||
without, imgs = notion_writer.extract_local_images(fenced)
|
|
||||||
assert len(imgs) == 0, "extract_local_images should not extract image inside fence"
|
|
||||||
# The fenced lines (including the image line) should be preserved verbatim
|
|
||||||
assert "" in without, "fence content must be preserved in output"
|
|
||||||
|
|
||||||
|
|
||||||
def run_all():
|
|
||||||
tests = [
|
|
||||||
test_create_page_markdown,
|
|
||||||
test_append_markdown,
|
|
||||||
test_replace_markdown,
|
|
||||||
test_extract_local_images_splits,
|
|
||||||
test_content_has_local_images,
|
|
||||||
test_local_image_inside_fence_ignored,
|
|
||||||
]
|
|
||||||
for t in tests:
|
|
||||||
print(f"\n{t.__name__}")
|
|
||||||
t()
|
|
||||||
print("\n" + "=" * 50)
|
|
||||||
print(f"✅ All {len(tests)} tests passed")
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
run_all()
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Tests for md_translate.py — run with `python test_md_translate.py`."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
|
|
||||||
import md_translate
|
|
||||||
|
|
||||||
|
|
||||||
def test_passthrough_basics():
|
|
||||||
src = "# Heading\n\n- item\n\n```python\nx=1\n```"
|
|
||||||
assert md_translate.translate(src) == src
|
|
||||||
|
|
||||||
|
|
||||||
def test_callout_note():
|
|
||||||
out = md_translate.translate("> [!NOTE]\n> Be careful")
|
|
||||||
assert '<callout icon="ℹ️" color="blue_bg">' in out
|
|
||||||
assert "\tBe careful" in out
|
|
||||||
assert "</callout>" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_callout_warning_color():
|
|
||||||
out = md_translate.translate("> [!WARNING]\n> Danger")
|
|
||||||
assert 'color="yellow_bg"' in out
|
|
||||||
assert 'icon="⚠️"' in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_columns():
|
|
||||||
src = "::: columns\n::: column\nLeft\n:::\n::: column\nRight\n:::\n:::"
|
|
||||||
out = md_translate.translate(src)
|
|
||||||
assert "<columns>" in out
|
|
||||||
assert out.count("<column>") == 2
|
|
||||||
assert "</columns>" in out
|
|
||||||
assert "\tLeft" in out or "\t\tLeft" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_toggle_children_indented():
|
|
||||||
src = "<details>\n<summary>More</summary>\nBody line\n</details>"
|
|
||||||
out = md_translate.translate(src)
|
|
||||||
assert "<summary>More</summary>" in out
|
|
||||||
assert "\tBody line" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_mention_url():
|
|
||||||
out = md_translate.translate(
|
|
||||||
"See @[ADR](https://notion.so/X-abcdef0123456789abcdef0123456789).")
|
|
||||||
assert "<mention-page url=" in out
|
|
||||||
assert ">ADR</mention-page>" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_mention_invalid_plain():
|
|
||||||
out = md_translate.translate("ping @[Bob](not-an-id)")
|
|
||||||
assert "@Bob" in out
|
|
||||||
assert "mention-page" not in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_fence_passthrough_no_transform():
|
|
||||||
"""Lines inside fenced code blocks must pass through verbatim — no callout,
|
|
||||||
columns, or mention transformation applied."""
|
|
||||||
raw_id = "abcdef0123456789abcdef0123456789"
|
|
||||||
src = (
|
|
||||||
"```\n"
|
|
||||||
"> [!NOTE]\n"
|
|
||||||
"::: columns\n"
|
|
||||||
f"@[Page]({raw_id})\n"
|
|
||||||
"```"
|
|
||||||
)
|
|
||||||
out = md_translate.translate(src)
|
|
||||||
# No transformation should have occurred
|
|
||||||
assert "<callout" not in out, "callout must not be emitted inside fence"
|
|
||||||
assert "<columns>" not in out, "columns must not be emitted inside fence"
|
|
||||||
assert "<mention-page" not in out, "mention must not be emitted inside fence"
|
|
||||||
# Original lines must be present verbatim
|
|
||||||
assert "> [!NOTE]" in out
|
|
||||||
assert "::: columns" in out
|
|
||||||
assert f"@[Page]({raw_id})" in out
|
|
||||||
|
|
||||||
|
|
||||||
def run_all():
|
|
||||||
tests = [
|
|
||||||
test_passthrough_basics,
|
|
||||||
test_callout_note,
|
|
||||||
test_callout_warning_color,
|
|
||||||
test_columns,
|
|
||||||
test_toggle_children_indented,
|
|
||||||
test_mention_url,
|
|
||||||
test_mention_invalid_plain,
|
|
||||||
test_fence_passthrough_no_transform,
|
|
||||||
]
|
|
||||||
for t in tests:
|
|
||||||
print(f"\n{t.__name__}")
|
|
||||||
t()
|
|
||||||
print("\n" + "=" * 50)
|
|
||||||
print(f"✅ All {len(tests)} tests passed")
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
run_all()
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Tests for ntn_files.py — run with `python test_ntn_files.py`."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
|
|
||||||
import ntn_files
|
|
||||||
from ntn_files import NtnUploadError
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_cache():
|
|
||||||
ntn_files._WORKSPACE = None
|
|
||||||
|
|
||||||
|
|
||||||
def test_preflight_missing_ntn():
|
|
||||||
_reset_cache()
|
|
||||||
with mock.patch("shutil.which", return_value=None):
|
|
||||||
try:
|
|
||||||
ntn_files.preflight()
|
|
||||||
assert False, "expected NtnUploadError"
|
|
||||||
except NtnUploadError as e:
|
|
||||||
assert "ntn" in str(e).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_preflight_returns_workspace():
|
|
||||||
_reset_cache()
|
|
||||||
fake = subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=0,
|
|
||||||
stdout='{"bot":{"workspace_name":"D.Intelligence",'
|
|
||||||
'"workspace_id":"ws-123"}}',
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
with mock.patch("shutil.which", return_value="/usr/bin/ntn"), \
|
|
||||||
mock.patch("subprocess.run", return_value=fake):
|
|
||||||
info = ntn_files.preflight()
|
|
||||||
assert info["workspace_name"] == "D.Intelligence"
|
|
||||||
assert info["workspace_id"] == "ws-123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_upload_returns_id():
|
|
||||||
_reset_cache()
|
|
||||||
fake = subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=0,
|
|
||||||
stdout="43833259-72ae-404e-8441-b6577f3159b4\tphoto.png\tuploaded\n",
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
# upload() opens the file before subprocess.run; mock open so the file
|
|
||||||
# need not exist (subprocess.run is mocked and never reads the handle).
|
|
||||||
with mock.patch("builtins.open", mock.mock_open(read_data=b"x")), \
|
|
||||||
mock.patch("subprocess.run", return_value=fake):
|
|
||||||
upload_id = ntn_files.upload(Path("/tmp/photo.png"))
|
|
||||||
assert upload_id == "43833259-72ae-404e-8441-b6577f3159b4"
|
|
||||||
|
|
||||||
|
|
||||||
def test_upload_failure_raises():
|
|
||||||
_reset_cache()
|
|
||||||
fake = subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=1, stdout="", stderr="boom: invalid file",
|
|
||||||
)
|
|
||||||
with mock.patch("builtins.open", mock.mock_open(read_data=b"x")), \
|
|
||||||
mock.patch("subprocess.run", return_value=fake):
|
|
||||||
try:
|
|
||||||
ntn_files.upload(Path("/tmp/bad.png"))
|
|
||||||
assert False, "expected NtnUploadError"
|
|
||||||
except NtnUploadError as e:
|
|
||||||
assert e.path == "/tmp/bad.png"
|
|
||||||
assert "boom" in e.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def _img(url):
|
|
||||||
return {"object": "block", "type": "image",
|
|
||||||
"image": {"type": "external", "external": {"url": url}}}
|
|
||||||
|
|
||||||
|
|
||||||
def test_materialize_remote_untouched():
|
|
||||||
blocks = [_img("https://ex.com/a.png")]
|
|
||||||
out = ntn_files.materialize_local_media(
|
|
||||||
blocks, Path("/tmp"), upload_fn=lambda p: "SHOULD_NOT_RUN")
|
|
||||||
assert out[0]["image"]["type"] == "external"
|
|
||||||
|
|
||||||
|
|
||||||
def test_materialize_local_uploaded(tmp_path=None):
|
|
||||||
import tempfile, os
|
|
||||||
d = Path(tempfile.mkdtemp())
|
|
||||||
(d / "x.png").write_bytes(b"fake")
|
|
||||||
blocks = [_img("x.png")]
|
|
||||||
out = ntn_files.materialize_local_media(
|
|
||||||
blocks, d, upload_fn=lambda p: "UP123")
|
|
||||||
assert out[0]["image"]["type"] == "file_upload"
|
|
||||||
assert out[0]["image"]["file_upload"]["id"] == "UP123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_materialize_nested_children():
|
|
||||||
import tempfile
|
|
||||||
d = Path(tempfile.mkdtemp())
|
|
||||||
(d / "y.png").write_bytes(b"fake")
|
|
||||||
toggle = {"object": "block", "type": "toggle",
|
|
||||||
"toggle": {"rich_text": [], "children": [_img("y.png")]}}
|
|
||||||
out = ntn_files.materialize_local_media(
|
|
||||||
[toggle], d, upload_fn=lambda p: "UPNESTED")
|
|
||||||
child = out[0]["toggle"]["children"][0]
|
|
||||||
assert child["image"]["file_upload"]["id"] == "UPNESTED"
|
|
||||||
|
|
||||||
|
|
||||||
def test_materialize_missing_file_raises():
|
|
||||||
blocks = [_img("nope.png")]
|
|
||||||
try:
|
|
||||||
ntn_files.materialize_local_media(
|
|
||||||
blocks, Path("/tmp"), upload_fn=lambda p: "x")
|
|
||||||
assert False, "expected NtnUploadError"
|
|
||||||
except NtnUploadError as e:
|
|
||||||
assert "nope.png" in str(e) or "nope.png" in e.path
|
|
||||||
|
|
||||||
|
|
||||||
def test_upload_passes_token_env():
|
|
||||||
_reset_cache()
|
|
||||||
fake = subprocess.CompletedProcess(args=[], returncode=0,
|
|
||||||
stdout="ID123\tphoto.png\tuploaded\n", stderr="")
|
|
||||||
with mock.patch.dict("os.environ", {"NOTION_API_KEY": "tok-abc"}, clear=False), \
|
|
||||||
mock.patch("builtins.open", mock.mock_open(read_data=b"x")), \
|
|
||||||
mock.patch("subprocess.run", return_value=fake) as m:
|
|
||||||
ntn_files.upload(Path("/tmp/p.png"))
|
|
||||||
passed_env = m.call_args.kwargs["env"]
|
|
||||||
assert passed_env["NOTION_API_TOKEN"] == "tok-abc"
|
|
||||||
|
|
||||||
|
|
||||||
def run_all():
|
|
||||||
tests = [
|
|
||||||
test_preflight_missing_ntn,
|
|
||||||
test_preflight_returns_workspace,
|
|
||||||
test_upload_returns_id,
|
|
||||||
test_upload_failure_raises,
|
|
||||||
test_materialize_remote_untouched,
|
|
||||||
test_materialize_local_uploaded,
|
|
||||||
test_materialize_nested_children,
|
|
||||||
test_materialize_missing_file_raises,
|
|
||||||
test_upload_passes_token_env,
|
|
||||||
]
|
|
||||||
for t in tests:
|
|
||||||
print(f"\n{t.__name__}")
|
|
||||||
t()
|
|
||||||
print("\n" + "=" * 50)
|
|
||||||
print(f"✅ All {len(tests)} tests passed")
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
run_all()
|
|
||||||
@@ -413,28 +413,6 @@ def test_no_literal_markers_leak():
|
|||||||
_assert("bold" in joined and "link" in joined, "visible words preserved")
|
_assert("bold" in joined and "link" in joined, "visible words preserved")
|
||||||
|
|
||||||
|
|
||||||
def test_image_remote_external():
|
|
||||||
blocks = markdown_to_notion_blocks("")
|
|
||||||
assert len(blocks) == 1
|
|
||||||
b = blocks[0]
|
|
||||||
assert b["type"] == "image"
|
|
||||||
assert b["image"]["type"] == "external"
|
|
||||||
assert b["image"]["external"]["url"] == "https://ex.com/c.png"
|
|
||||||
assert b["image"]["caption"][0]["text"]["content"] == "a chart"
|
|
||||||
|
|
||||||
|
|
||||||
def test_image_local_external_shape_preupload():
|
|
||||||
blocks = markdown_to_notion_blocks("")
|
|
||||||
assert len(blocks) == 1
|
|
||||||
assert blocks[0]["image"]["external"]["url"] == "./pics/x.png"
|
|
||||||
|
|
||||||
|
|
||||||
def test_image_only_when_standalone():
|
|
||||||
# An inline bang-bracket inside prose is NOT an image block.
|
|
||||||
blocks = markdown_to_notion_blocks("see  inline")
|
|
||||||
assert blocks[0]["type"] == "paragraph"
|
|
||||||
|
|
||||||
|
|
||||||
def run_all():
|
def run_all():
|
||||||
tests = [
|
tests = [
|
||||||
test_rich_text_plain,
|
test_rich_text_plain,
|
||||||
@@ -469,9 +447,6 @@ def run_all():
|
|||||||
test_rich_text_relative_link_becomes_plain,
|
test_rich_text_relative_link_becomes_plain,
|
||||||
test_rich_text_absolute_link_preserved,
|
test_rich_text_absolute_link_preserved,
|
||||||
test_no_literal_markers_leak,
|
test_no_literal_markers_leak,
|
||||||
test_image_remote_external,
|
|
||||||
test_image_local_external_shape_preupload,
|
|
||||||
test_image_only_when_standalone,
|
|
||||||
]
|
]
|
||||||
for t in tests:
|
for t in tests:
|
||||||
print(f"\n{t.__name__}")
|
print(f"\n{t.__name__}")
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ Push markdown content to Notion pages or databases via Claude Code.
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts/venv`
|
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts/venv`
|
||||||
- Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below)
|
- Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below)
|
||||||
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
|
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
cd ~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
# Design Spec — `35-seo-signal-validation`
|
|
||||||
|
|
||||||
- **Status:** Draft for review
|
|
||||||
- **Date:** 2026-06-26
|
|
||||||
- **Author:** Andrew Yim (andrew.yim@ourdigital.org) + Claude Code
|
|
||||||
- **Genesis:** JHR josunhotel.com — SEMrush reported an organic "surge" attributed to "호텔" 16→3. Cross-checking GSC/GA4/live-SERP proved it a modeling artifact (real position ~12, ~5 clicks/mo; growth was all brand/seasonal). See workspace memory `feedback-semrush-serp-signal-validation`.
|
|
||||||
- **Related skills:** delegates to `20-seo-serp-analysis`, `21-seo-position-tracking`, `28-seo-knowledge-graph`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
Given a `(term/intent, entity)` pair — and optionally a *claim* (a third-party tool's reported movement) or a *baseline* (a prior state to compare against) — return an **evidence-backed verdict** on whether SERP and Knowledge-Graph impact is **real**, **misattributed**, an **artifact**, or **unprovable with available data**.
|
|
||||||
|
|
||||||
The skill exists because OurDigital/clients repeatedly face *modeled* third-party signals (SEMrush/Ahrefs estimated organic traffic, position snapshots) that are easy to over-trust. This skill makes the validation cascade — measured → live → entity → attribution — a single repeatable procedure that ends in a defensible verdict and a client-safe narrative.
|
|
||||||
|
|
||||||
It generalizes the genesis case to **any term/intent and any entity** (a brand, a company, or a person), and to two additional jobs beyond refuting external claims: proving our own work's impact, and standalone "where do we really stand" checks.
|
|
||||||
|
|
||||||
## 2. Boundary — how this differs from neighbors
|
|
||||||
|
|
||||||
| Skill | Owns | This skill instead |
|
|
||||||
|---|---|---|
|
|
||||||
| `20-seo-serp-analysis` | What the SERP *looks like* (features, competitor positions, intent) | …calls it for the live-SERP layer |
|
|
||||||
| `21-seo-position-tracking` | Rank *over time*, change detection, visibility | …calls it for GSC-as-ground-truth |
|
|
||||||
| `28-seo-knowledge-graph` | Entity presence audit (KG panel, Wikidata, Naver) | …calls it for the entity layer |
|
|
||||||
|
|
||||||
None of the three **adjudicates the truth of a claimed cross-layer movement**. This skill is the *conductor*: signal/claim in → verdict + evidence ledger out. It duplicates none of their measurement logic; it sequences and synthesizes them.
|
|
||||||
|
|
||||||
## 3. Engine — the validation loop
|
|
||||||
|
|
||||||
A **cost-ordered evidence cascade** that short-circuits when a cheap layer is already decisive (this is exactly how the JHR "호텔" claim was refuted before any expensive step). The "loop" is the cascade, not a scheduler.
|
|
||||||
|
|
||||||
### 3.0 Pre-step — classify the entity (gates which layers are available)
|
|
||||||
|
|
||||||
- **First-party entity** — a site/property the user owns or has GSC/GA4 access to (e.g., JHR `sc-domain:josunhotel.com`, GA4 `258308769`). → **L1 measured ground truth available.**
|
|
||||||
- **Third-party entity** — a competitor brand or a person the user does NOT control. → **L1 unavailable**; rely on L2 + L3 + clearly-tiered third-party estimates; cap confidence lower and prefer INCONCLUSIVE over guessing.
|
|
||||||
|
|
||||||
The skill detects this from whether a verified GSC property / GA4 property is supplied or resolvable; if ambiguous, ask once.
|
|
||||||
|
|
||||||
### 3.1 L1 — Measured (first-party, native history) → delegates to `21-seo-position-tracking`
|
|
||||||
|
|
||||||
- **GSC** via `mcp__dda__gsc_fetch_performance`:
|
|
||||||
- term query-level (exact match) AND site-wide, **recent vs prior** window.
|
|
||||||
- report real avg position, clicks, impressions, CTR; **day-normalize** (compare periods often differ in calendar-day count).
|
|
||||||
- note **~43% query-level anonymization** — query-sum ≠ aggregate; never treat the disclosed subset as the whole.
|
|
||||||
- **query-clicks delta** (recent − prior) to name which terms actually moved (brand/seasonal vs the claimed term).
|
|
||||||
- **GA4** via `mcp__dda__ga4_run_report`: `Organic Search` sessions monthly trend (dimensions `yearMonth` + `sessionDefaultChannelGroup`, metric `sessions`); GA4 captures **all engines incl. Naver**, so use it to test whether a "surge" exceeds normal month-to-month variance.
|
|
||||||
- **Short-circuit:** if the claimed keyword has trivial clicks and a real position nowhere near the claim → **ARTIFACT**, stop (skip L2/L3 unless caller wants the full picture).
|
|
||||||
|
|
||||||
### 3.2 L2 — Live SERP (third-party measured, point-in-time) → delegates to `20-seo-serp-analysis`
|
|
||||||
|
|
||||||
- **Live geo-correct Google render** via `claude-in-chrome` (`navigate` → `read_page`/`computer`): force `gl`/`hl` + correct geo, `pws=0`; **decline precise-location prompts** (privacy). Confirm whether the domain actually holds the claimed position; capture the feature landscape (ads, local map-pack, PAA, knowledge panel, image/video) that explains *why* a brand site can't hold a head term.
|
|
||||||
- **Cheap rank spot-check** via `mcp__ourseo__check_serp(keyword, domain)` when a full render is unnecessary.
|
|
||||||
- **[KR market]** Naver SERP composition via `our research naver serp` (blog/cafe/지식iN/Smart Store/brand zone) — required for Korean entities since Semrush/Ahrefs don't model Naver.
|
|
||||||
|
|
||||||
### 3.3 L3 — Entity / Knowledge Graph (the differentiator) → delegates to `28-seo-knowledge-graph`
|
|
||||||
|
|
||||||
A real impact event should leave corroborating traces in the **entity layer**, not just a rank number. Five checks:
|
|
||||||
|
|
||||||
1. **Google KG API** entity match + `resultScore` — `mcp__ourseo__search_knowledge_graph(query)` (uses `GOOGLE_KG_API_KEY`).
|
|
||||||
2. **Wikidata** QID presence + key claims — **verify the QID against `Special:EntityData/{Q}.json` labels** before trusting it (bakes in the JHR false-match guard: Q109455878 = office tower ≠ hotel; Q490787 = Shinsegae Inc. ≠ Group).
|
|
||||||
3. **Knowledge Panel** presence/attributes on the live entity-name SERP (Chrome).
|
|
||||||
4. **sameAs** consistency on the entity's `Organization`/`Person` JSON-LD.
|
|
||||||
5. **[KR]** Naver 백과사전 / 지식iN presence.
|
|
||||||
|
|
||||||
`mcp__ourseo__monitor_brand` supplements with brand-mention/brand-SERP ownership signal.
|
|
||||||
|
|
||||||
### 3.4 L4 — Attribution synthesis → verdict
|
|
||||||
|
|
||||||
Cross-check: does the **measured delta (L1)** corroborate the **live reality (L2)**, and does the **entity layer (L3)** show consistent movement? The query-clicks delta names the true drivers. Output a verdict (§5) with an evidence ledger.
|
|
||||||
|
|
||||||
## 4. Entry modes (thin wrappers over the engine)
|
|
||||||
|
|
||||||
| Mode | Input contract | Engine use |
|
|
||||||
|---|---|---|
|
|
||||||
| **`adjudicate(claim)`** | `{term, entity, claim:{source, metric, from→to}}` e.g. `SEMrush: 호텔 pos 16→3, organic surge` | Full cascade; verdict confirms/refutes the claim |
|
|
||||||
| **`prove(baseline)`** | `{term, entity, change:{what, when}}` | Measured before/after from GSC/GA4 history; entity baseline = most recent existing Notion KG-audit archive — **if none exists, report current entity state only and mark the entity-layer delta INCONCLUSIVE** (the change pre-dates any captured baseline); live captured now |
|
|
||||||
| **`snapshot()`** | `{term, entity}` | Cascade with no claim; "where do we really stand" across all four layers |
|
|
||||||
|
|
||||||
All three call the *same* engine; they differ only in what they compare against.
|
|
||||||
|
|
||||||
## 5. Verdict logic
|
|
||||||
|
|
||||||
| Verdict | Condition |
|
|
||||||
|---|---|
|
|
||||||
| **CONFIRMED** | Measured + live + (where relevant) entity all corroborate movement attributable to the term/intent |
|
|
||||||
| **PARTIAL** | Real movement, but misattributed (e.g., growth is brand/seasonal, not the claimed head term) or only some layers agree |
|
|
||||||
| **ARTIFACT** | Modeling/snapshot artifact — measured + live reality don't support it (the JHR 호텔 case) |
|
|
||||||
| **INCONCLUSIVE** | Insufficient data (query anonymized, GSC lag, no entity baseline, third-party entity with no measured access) — names exactly what's missing + how to resolve |
|
|
||||||
|
|
||||||
**Confidence cap:** third-party entities (no L1) cannot reach CONFIRMED on traffic claims — at most PARTIAL, and ARTIFACT only when live+entity reality clearly contradicts the claim.
|
|
||||||
|
|
||||||
**Standing skepticism rules** (baked in from `feedback-semrush-serp-signal-validation`):
|
|
||||||
- Estimated organic traffic = **smoke-detector, not scale** (Σ est-volume × position-CTR curve).
|
|
||||||
- **Head-term over-fire**: one high-volume keyword caught at an estimated high rank inflates the whole modeled number.
|
|
||||||
- **KR Naver blind spot**: Semrush models Google only; misses a large share of Korean organic.
|
|
||||||
- **Single-geo/device snapshot** diverges from GSC's national average.
|
|
||||||
- **Data-trust hierarchy**: 1st-party measured (GA4/GSC) > 3rd-party measured (backlinks, crawled rank) > 3rd-party modeled (estimated traffic).
|
|
||||||
|
|
||||||
### Output of the verdict
|
|
||||||
- **Evidence ledger** — per layer: finding + its data-trust tier + whether it corroborates or contradicts the claim.
|
|
||||||
- **Client-safe narrative** — the defensible story (e.g., "summer brand/long-tail demand lifted impressions +18%, clicks modest" — NOT "ranked #3 for 호텔").
|
|
||||||
|
|
||||||
## 6. Output
|
|
||||||
|
|
||||||
- **Always:** inline structured report (verdict + ledger + narrative + "what would raise confidence").
|
|
||||||
- **Optional:** archive to Notion **Working with AI DB** (`data_source_id f8f19ede-32bd-43ac-9f60-0651f6f40afe`) via the **notion-writer script** (per global policy — never Notion MCP write tools). Properties follow the DB schema (Type=Memo/Research, Account Code, Topic=SEO, etc.).
|
|
||||||
- **Optional:** if the run surfaces a new *generalizable* gotcha, append a memory entry to the active workspace's memory dir.
|
|
||||||
|
|
||||||
## 7. Repo layout & conventions
|
|
||||||
|
|
||||||
```
|
|
||||||
35-seo-signal-validation/
|
|
||||||
SKILL.md self-contained: classification, 4-layer cascade,
|
|
||||||
5 KG checks, 4-way verdict, skepticism rules, output
|
|
||||||
DESIGN.md PLAN.md spec + plan (live with the skill; no new top-level dir)
|
|
||||||
code/
|
|
||||||
CLAUDE.md code-environment notes (env, export→script flow)
|
|
||||||
scripts/
|
|
||||||
gsc_signal_delta.py deterministic L1/L4 GSC delta + mover ranking
|
|
||||||
test_gsc_signal_delta.py
|
|
||||||
requirements.txt (stdlib only)
|
|
||||||
```
|
|
||||||
|
|
||||||
Target environment: Claude Code only (no desktop/ variant — matches precedent 95/96). Registered in .claude-plugin/marketplace.json under ourdigital-seo.
|
|
||||||
|
|
||||||
**Triggers:** `validate serp signal`, `is this ranking real`, `prove SEO impact`, `SEMrush surge real?`, `signal validation`, `신호 검증`, `순위 변화 진짜?`, `오가닉 급증 검증`.
|
|
||||||
|
|
||||||
**Conventions honored:** no new output directories beyond this approved folder; Notion writes via notion-writer script only; never crawl/audit Marriott for JHR (sameAs reference only); KR deliverables in Korean, English internal notes OK.
|
|
||||||
|
|
||||||
## 8. Non-goals (YAGNI)
|
|
||||||
|
|
||||||
- No cron/scheduler and no snapshot DB (stateless, on-demand). A snapshot store + watchlist monitor is a documented **future option**, built only if proven needed.
|
|
||||||
- Does **not** replace the three instrument skills — it sequences them.
|
|
||||||
- Does **not** fabricate a verdict when data is thin — returns INCONCLUSIVE with a remediation list.
|
|
||||||
- Not a general SEO audit; scoped to validating a specific `(term, entity)` impact question.
|
|
||||||
|
|
||||||
## 9. Future options (explicitly out of v1)
|
|
||||||
|
|
||||||
- Lightweight snapshot store in the existing `dda` SQLite workspace to enable true over-time entity-layer deltas.
|
|
||||||
- Optional scheduled monitor over a `(term, entity)` watchlist that flags anomalies for `adjudicate`.
|
|
||||||
- Multi-engine claim intake (parse a pasted SEMrush/Ahrefs export directly).
|
|
||||||
@@ -1,729 +0,0 @@
|
|||||||
# SEO Signal Validation — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Build the `35-seo-signal-validation` Claude Skill — a conductor that adjudicates whether a claimed SERP / Knowledge-Graph movement for a `(term, entity)` pair is real, misattributed, an artifact, or unprovable.
|
|
||||||
|
|
||||||
**Architecture:** A self-contained `SKILL.md` carries the decision procedure (entity classification → 4-layer evidence cascade → 4-way verdict). One stdlib Python helper (`gsc_signal_delta.py`) makes the L1/L4 GSC delta + mover-ranking deterministic (the part that was ad-hoc and overflowed context in the genesis case). The skill delegates measurement to existing skills (`20-seo-serp-analysis`, `21-seo-position-tracking`, `28-seo-knowledge-graph`) and is registered in the repo's marketplace manifest.
|
|
||||||
|
|
||||||
**Tech Stack:** Markdown skill (Claude Code format), Python 3 stdlib (`json`, `argparse`, `csv`), repo `.claude-plugin/marketplace.json`. No third-party deps. Code-only skill (no `desktop/` variant — matches precedent `95-ourdigital-presales-seo`, `96-ourdigital-estimate-engine`).
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- **Skill structure** = root `SKILL.md` (self-contained, ~180–220 lines, content NOT split into `references/`) + `code/` (CLAUDE.md + scripts). Code-only; **no `desktop/` variant**.
|
|
||||||
- **Register** the skill in `.claude-plugin/marketplace.json` under the `ourdigital-seo` plugin's `skills` array as `./custom-skills/35-seo-signal-validation`.
|
|
||||||
- **No new output directories** beyond the approved `custom-skills/35-seo-signal-validation/` folder (and its `code/scripts/fixtures/`).
|
|
||||||
- **Stateless, on-demand**: no cron/scheduler, no snapshot DB.
|
|
||||||
- **Notion writes via the notion-writer script only** — never Notion MCP write tools.
|
|
||||||
- **Never crawl/audit Marriott** for JHR — `sameAs` reference only.
|
|
||||||
- **Verify any Wikidata QID** against `Special:EntityData/{Q}.json` labels before trusting it (false-match guard: Q109455878 ≠ hotel, Q490787 ≠ Shinsegae Group).
|
|
||||||
- **Data-trust hierarchy**: 1st-party measured (GSC/GA4) > 3rd-party measured (backlinks, crawled rank) > 3rd-party modeled (estimated traffic).
|
|
||||||
- **Confidence cap**: third-party entities (no GSC/GA4 access) cannot reach `CONFIRMED` on traffic claims — at most `PARTIAL`, `ARTIFACT` only when live+entity reality clearly contradicts.
|
|
||||||
- **Verdict taxonomy**: `CONFIRMED | PARTIAL | ARTIFACT | INCONCLUSIVE`.
|
|
||||||
- **Branch**: all work commits to `feat/seo-signal-validation-skill` (already created; `DESIGN.md` already committed there).
|
|
||||||
- **Any client deliverable the skill emits** uses naming `{CODE}-{desc}-{class}-{YYYYMMDD}.{ext}`; KR client-facing content in Korean.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: `SKILL.md` — measurement half (frontmatter, classification, cascade L1–L2)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `custom-skills/35-seo-signal-validation/SKILL.md`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: nothing (first task).
|
|
||||||
- Produces: the `SKILL.md` file with frontmatter `name: seo-signal-validation`; section anchors `## Step 0`, `## The validation loop` with layers `L1`/`L2`; references the helper script path `code/scripts/gsc_signal_delta.py` (implemented in Task 3).
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write `SKILL.md` frontmatter + measurement sections**
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
---
|
|
||||||
name: seo-signal-validation
|
|
||||||
description: |
|
|
||||||
Validate whether a claimed SERP / Knowledge-Graph movement for a (term, entity)
|
|
||||||
is real, misattributed, an artifact, or unprovable — before reporting impact.
|
|
||||||
Triggers: validate serp signal, is this ranking real, prove SEO impact,
|
|
||||||
SEMrush surge real, signal validation, real impact check,
|
|
||||||
신호 검증, 순위 변화 진짜, 오가닉 급증 검증, 임팩트 검증.
|
|
||||||
---
|
|
||||||
|
|
||||||
# SEO Signal Validation
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Given a `(term/intent, entity)` pair — and optionally a **claim** (a third-party
|
|
||||||
tool's reported movement) or a **baseline** (a prior state) — return an
|
|
||||||
evidence-backed verdict on whether SERP and Knowledge-Graph impact is real.
|
|
||||||
Built because modeled third-party signals (SEMrush/Ahrefs estimated organic
|
|
||||||
traffic, position snapshots) are easy to over-trust. This skill makes the
|
|
||||||
measured → live → entity → attribution cascade a single repeatable procedure
|
|
||||||
ending in a defensible verdict and a client-safe narrative.
|
|
||||||
|
|
||||||
## When to use (boundary)
|
|
||||||
|
|
||||||
This is the **conductor**, not an instrument. It sequences and synthesizes the
|
|
||||||
three measurement skills — it does not duplicate them.
|
|
||||||
|
|
||||||
| Use instead | When |
|
|
||||||
|---|---|
|
|
||||||
| `20-seo-serp-analysis` | You only need SERP composition / features |
|
|
||||||
| `21-seo-position-tracking` | You only need rank over time |
|
|
||||||
| `28-seo-knowledge-graph` | You only need an entity-presence audit |
|
|
||||||
| **this skill** | You must adjudicate whether a *claimed movement* is real across layers |
|
|
||||||
|
|
||||||
## Step 0 — Classify entity + pick mode
|
|
||||||
|
|
||||||
1. **Entity ownership** (gates which layers exist):
|
|
||||||
- **First-party** — a site/property you own or have GSC/GA4 access to (e.g. JHR
|
|
||||||
`sc-domain:josunhotel.com`, GA4 `258308769`) → **L1 measured available**.
|
|
||||||
- **Third-party** — a competitor brand or a person you do not control →
|
|
||||||
**L1 unavailable**; lean on L2 + L3 + clearly-tiered estimates; apply the
|
|
||||||
confidence cap (see Verdict). If unclear, ask once.
|
|
||||||
2. **Mode** (thin wrappers over the same cascade):
|
|
||||||
- `adjudicate(claim)` — a 3rd-party tool reports a move; confirm/refute.
|
|
||||||
- `prove(baseline)` — after our change; before/after from GSC/GA4 history.
|
|
||||||
- `snapshot()` — no claim; "where do we really stand."
|
|
||||||
|
|
||||||
## The validation loop (cost-ordered cascade, short-circuiting)
|
|
||||||
|
|
||||||
Run cheapest-first; stop early when a layer is already decisive.
|
|
||||||
|
|
||||||
### L1 — Measured (first-party ground truth) → via `21-seo-position-tracking`
|
|
||||||
|
|
||||||
- **GSC** `mcp__dda__gsc_fetch_performance`: the term at **query level** (exact)
|
|
||||||
AND **site-wide**, for **recent vs prior** windows. Pull clicks / impressions /
|
|
||||||
position / CTR. **Day-normalize** (compare windows differ in calendar-day count).
|
|
||||||
Note **~43% query-level anonymization** — the disclosed subset ≠ the whole.
|
|
||||||
- **GA4** `mcp__dda__ga4_run_report`: `Organic Search` sessions monthly trend
|
|
||||||
(dims `yearMonth` + `sessionDefaultChannelGroup`, metric `sessions`). GA4
|
|
||||||
includes Naver + all engines — use it to test whether a "surge" exceeds normal
|
|
||||||
month-to-month variance.
|
|
||||||
- **Compute deltas with the helper** (deterministic, avoids ad-hoc parsing):
|
|
||||||
save each GSC pull, then run
|
|
||||||
`python3 code/scripts/gsc_signal_delta.py --recent <recent.tsv> --prior <prior.tsv> --recent-days N --prior-days M --claim-term "<term>"`.
|
|
||||||
It returns day-normalized site totals, top gainers/decliners, and whether the
|
|
||||||
claimed term is among the real movers.
|
|
||||||
- **SHORT-CIRCUIT:** if the claimed keyword has trivial clicks and a real
|
|
||||||
position nowhere near the claim → **ARTIFACT**; stop unless the caller wants
|
|
||||||
the full picture.
|
|
||||||
|
|
||||||
### L2 — Live SERP (3rd-party measured, point-in-time) → via `20-seo-serp-analysis`
|
|
||||||
|
|
||||||
- **Geo-correct Google render** via `claude-in-chrome` (`navigate` → `read_page`):
|
|
||||||
force `gl`/`hl` + correct geo, `pws=0`; **decline precise-location prompts**.
|
|
||||||
Confirm whether the domain actually holds the claimed position; capture the
|
|
||||||
feature landscape (ads, local map-pack, PAA, knowledge panel) that explains why
|
|
||||||
a brand site can't own a head term.
|
|
||||||
- **Cheap rank spot-check**: `mcp__ourseo__check_serp(keyword, domain)`.
|
|
||||||
- **[KR market]** Naver SERP composition: `our research naver serp` (blog / cafe /
|
|
||||||
지식iN / Smart Store / brand zone) — Semrush/Ahrefs don't model Naver.
|
|
||||||
````
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify frontmatter parses and required anchors exist**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills
|
|
||||||
python3 - <<'PY'
|
|
||||||
import sys, pathlib
|
|
||||||
p = pathlib.Path("custom-skills/35-seo-signal-validation/SKILL.md")
|
|
||||||
t = p.read_text(encoding="utf-8")
|
|
||||||
assert t.startswith("---\n"), "missing frontmatter"
|
|
||||||
fm = t.split("---\n",2)[1]
|
|
||||||
assert "name: seo-signal-validation" in fm, "bad name"
|
|
||||||
assert "Triggers:" in fm, "missing triggers"
|
|
||||||
for anchor in ["## Step 0", "## The validation loop", "### L1", "### L2",
|
|
||||||
"gsc_signal_delta.py"]:
|
|
||||||
assert anchor in t, f"missing: {anchor}"
|
|
||||||
print("OK SKILL.md measurement half")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
Expected: `OK SKILL.md measurement half`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills
|
|
||||||
git add custom-skills/35-seo-signal-validation/SKILL.md
|
|
||||||
git commit -m "feat(skill): seo-signal-validation SKILL.md measurement half (L1-L2)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: `SKILL.md` — decision half (L3 KG, L4 synthesis, verdict, output)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `custom-skills/35-seo-signal-validation/SKILL.md` (append after L2)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: the `SKILL.md` from Task 1 (appends to it).
|
|
||||||
- Produces: sections `### L3`, `### L4`, `## Verdict`, `## Standing skepticism rules`, `## Output`, `## Non-goals` with the four verdict labels verbatim.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Append the decision sections to `SKILL.md`**
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
### L3 — Entity / Knowledge Graph → via `28-seo-knowledge-graph`
|
|
||||||
|
|
||||||
A real impact event should leave corroborating traces in the entity layer, not
|
|
||||||
just a rank number. Five checks:
|
|
||||||
|
|
||||||
1. **Google KG API** entity match + `resultScore` —
|
|
||||||
`mcp__ourseo__search_knowledge_graph(query)` (uses `GOOGLE_KG_API_KEY`).
|
|
||||||
2. **Wikidata** QID presence + key claims — **verify the QID against
|
|
||||||
`Special:EntityData/{Q}.json` labels before trusting it** (false-match guard:
|
|
||||||
Q109455878 = office tower ≠ hotel; Q490787 = Shinsegae Inc. ≠ Group).
|
|
||||||
3. **Knowledge Panel** presence/attributes on the live entity-name SERP (Chrome).
|
|
||||||
4. **sameAs** consistency on the entity's `Organization`/`Person` JSON-LD.
|
|
||||||
5. **[KR]** Naver 백과사전 / 지식iN presence.
|
|
||||||
|
|
||||||
`mcp__ourseo__monitor_brand` supplements with brand-mention / brand-SERP ownership.
|
|
||||||
|
|
||||||
### L4 — Attribution synthesis
|
|
||||||
|
|
||||||
Cross-check: does the **measured delta (L1)** corroborate the **live reality
|
|
||||||
(L2)**, and does the **entity layer (L3)** move consistently? The query-clicks
|
|
||||||
delta names the true drivers (brand/seasonal vs the claimed term).
|
|
||||||
|
|
||||||
## Verdict
|
|
||||||
|
|
||||||
| Verdict | Condition |
|
|
||||||
|---|---|
|
|
||||||
| **CONFIRMED** | Measured + live + (where relevant) entity all corroborate movement attributable to the term/intent |
|
|
||||||
| **PARTIAL** | Real movement, but misattributed, or only some layers agree |
|
|
||||||
| **ARTIFACT** | Modeling/snapshot artifact — measured + live reality don't support it |
|
|
||||||
| **INCONCLUSIVE** | Insufficient data (query anonymized, GSC lag, no entity baseline, third-party entity with no measured access) — name what's missing + how to resolve |
|
|
||||||
|
|
||||||
**Confidence cap:** third-party entities (no L1) cannot reach CONFIRMED on traffic
|
|
||||||
claims — at most PARTIAL; ARTIFACT only when live+entity clearly contradict.
|
|
||||||
|
|
||||||
Every verdict ships an **evidence ledger** (per layer: finding + data-trust tier +
|
|
||||||
corroborates/contradicts) and a **client-safe narrative** (the defensible story).
|
|
||||||
|
|
||||||
## Standing skepticism rules
|
|
||||||
|
|
||||||
- Estimated organic traffic = **smoke-detector, not scale** (Σ est-volume × position-CTR curve).
|
|
||||||
- **Head-term over-fire**: one high-volume keyword at an estimated high rank inflates the whole modeled number.
|
|
||||||
- **KR Naver blind spot**: Semrush models Google only; misses much of Korean organic.
|
|
||||||
- **Single-geo/device snapshot** diverges from GSC's national average.
|
|
||||||
- **Trust hierarchy**: 1st-party measured > 3rd-party measured > 3rd-party modeled.
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
- **Always**: inline report — verdict + evidence ledger + client-safe narrative +
|
|
||||||
"what would raise confidence."
|
|
||||||
- **Optional**: archive to Notion *Working with AI DB* (`data_source_id
|
|
||||||
f8f19ede-32bd-43ac-9f60-0651f6f40afe`) via the **notion-writer script** (never
|
|
||||||
Notion MCP write). Type=Memo/Research, Topic=SEO, Account Code as relevant.
|
|
||||||
- **Optional**: if a new generalizable gotcha emerges, append a memory entry to
|
|
||||||
the active workspace's memory dir.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
No cron/scheduler, no snapshot DB, no new directories. Does not replace the three
|
|
||||||
instrument skills. Returns INCONCLUSIVE rather than fabricating when data is thin.
|
|
||||||
**Never crawls/audits Marriott for JHR** (sameAs only).
|
|
||||||
````
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify the four verdicts, confidence cap, and skepticism rules are present**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills
|
|
||||||
python3 - <<'PY'
|
|
||||||
import pathlib
|
|
||||||
t = pathlib.Path("custom-skills/35-seo-signal-validation/SKILL.md").read_text(encoding="utf-8")
|
|
||||||
for s in ["### L3", "### L4", "**CONFIRMED**", "**PARTIAL**", "**ARTIFACT**",
|
|
||||||
"**INCONCLUSIVE**", "Confidence cap", "smoke-detector, not scale",
|
|
||||||
"Special:EntityData", "## Output", "## Non-goals"]:
|
|
||||||
assert s in t, f"missing: {s}"
|
|
||||||
n = t.count("\n")
|
|
||||||
assert 130 <= n <= 320, f"SKILL.md length {n} lines outside expected band"
|
|
||||||
print(f"OK SKILL.md decision half ({n} lines)")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
Expected: `OK SKILL.md decision half (… lines)`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills
|
|
||||||
git add custom-skills/35-seo-signal-validation/SKILL.md
|
|
||||||
git commit -m "feat(skill): seo-signal-validation SKILL.md decision half (L3-L4, verdict, output)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: `gsc_signal_delta.py` helper + tests (TDD)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create test: `custom-skills/35-seo-signal-validation/code/scripts/test_gsc_signal_delta.py`
|
|
||||||
- Create: `custom-skills/35-seo-signal-validation/code/scripts/gsc_signal_delta.py`
|
|
||||||
- Create: `custom-skills/35-seo-signal-validation/code/scripts/requirements.txt`
|
|
||||||
- Create: `custom-skills/35-seo-signal-validation/code/CLAUDE.md`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: nothing at runtime.
|
|
||||||
- Produces: `compute_delta(recent: list[dict], prior: list[dict], recent_days: int, prior_days: int, claim_term: str|None=None, top_n: int=10) -> dict` and `load_gsc(path: str) -> list[dict]`; CLI `python3 gsc_signal_delta.py --recent --prior --recent-days --prior-days [--claim-term] [--top-n]`. Output dict keys: `site_totals`, `top_gainers`, `top_decliners`, `claim_term`, `verdict_hint`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Create `custom-skills/35-seo-signal-validation/code/scripts/test_gsc_signal_delta.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Tests for gsc_signal_delta. Run: `python3 test_gsc_signal_delta.py`
|
|
||||||
(also pytest-compatible). Stdlib only."""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
from gsc_signal_delta import compute_delta # noqa: E402
|
|
||||||
|
|
||||||
# Genesis fixture: JHR "호텔" — flat head term, growth all brand (2026-06 case).
|
|
||||||
RECENT = [
|
|
||||||
{"query": "호텔", "clicks": 5, "impressions": 572, "position": 11.6},
|
|
||||||
{"query": "grand josun busan", "clicks": 250, "impressions": 4000, "position": 1.2},
|
|
||||||
{"query": "조선호텔", "clicks": 300, "impressions": 6000, "position": 1.1},
|
|
||||||
]
|
|
||||||
PRIOR = [
|
|
||||||
{"query": "호텔", "clicks": 9, "impressions": 371, "position": 18.1},
|
|
||||||
{"query": "grand josun busan", "clicks": 49, "impressions": 1500, "position": 3.4},
|
|
||||||
{"query": "조선호텔", "clicks": 150, "impressions": 5000, "position": 1.3},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_claim_term_flagged_artifact():
|
|
||||||
out = compute_delta(RECENT, PRIOR, 28, 30, claim_term="호텔")
|
|
||||||
ct = out["claim_term"]
|
|
||||||
assert ct["found"] is True
|
|
||||||
assert ct["in_top_movers"] is False
|
|
||||||
assert ct["click_share_pct"] < 1.0
|
|
||||||
assert "ARTIFACT" in out["verdict_hint"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_top_gainer_is_brand_term():
|
|
||||||
out = compute_delta(RECENT, PRIOR, 28, 30, claim_term="호텔")
|
|
||||||
assert out["top_gainers"][0]["query"] == "grand josun busan"
|
|
||||||
assert out["top_gainers"][0]["delta_clicks"] == 201
|
|
||||||
|
|
||||||
|
|
||||||
def test_day_normalization():
|
|
||||||
out = compute_delta(RECENT, PRIOR, 28, 30)
|
|
||||||
assert out["site_totals"]["recent"]["clicks_per_day"] == 19.82 # 555/28
|
|
||||||
assert out["site_totals"]["prior"]["clicks_per_day"] == 6.93 # 208/30
|
|
||||||
|
|
||||||
|
|
||||||
def test_positive_days_required():
|
|
||||||
try:
|
|
||||||
compute_delta(RECENT, PRIOR, 0, 30)
|
|
||||||
except ValueError:
|
|
||||||
return
|
|
||||||
raise AssertionError("expected ValueError for non-positive days")
|
|
||||||
|
|
||||||
|
|
||||||
def _run():
|
|
||||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
|
||||||
for fn in fns:
|
|
||||||
fn(); print(f"PASS {fn.__name__}")
|
|
||||||
print(f"\n{len(fns)} passed")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
_run()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills/custom-skills/35-seo-signal-validation/code/scripts
|
|
||||||
python3 test_gsc_signal_delta.py
|
|
||||||
```
|
|
||||||
Expected: FAIL — `ModuleNotFoundError: No module named 'gsc_signal_delta'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write the implementation**
|
|
||||||
|
|
||||||
Create `custom-skills/35-seo-signal-validation/code/scripts/gsc_signal_delta.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Day-normalized GSC query delta + mover ranking for signal validation.
|
|
||||||
|
|
||||||
Reads two GSC query exports (recent, prior) — JSON list or TSV with a header row
|
|
||||||
containing query / clicks / impressions / position — and reports day-normalized
|
|
||||||
site totals, top gainers/decliners, and whether a claimed term is a real mover.
|
|
||||||
This is the deterministic L1/L4 core of the 35-seo-signal-validation skill.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def _norm_row(r: dict) -> dict:
|
|
||||||
def num(*keys, default=0.0):
|
|
||||||
for k in keys:
|
|
||||||
if k in r and r[k] not in (None, ""):
|
|
||||||
try:
|
|
||||||
return float(str(r[k]).replace(",", ""))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return default
|
|
||||||
query = (r.get("query") or r.get("term") or "")
|
|
||||||
if isinstance(r.get("keys"), list) and r["keys"]:
|
|
||||||
query = str(r["keys"][0])
|
|
||||||
return {
|
|
||||||
"query": str(query).strip(),
|
|
||||||
"clicks": num("clicks"),
|
|
||||||
"impressions": num("impressions", "impr"),
|
|
||||||
"position": num("position", "pos", default=0.0),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def load_gsc(path: str) -> list[dict]:
|
|
||||||
"""Parse a GSC export (JSON list/{rows:[...]} or TSV-with-header)."""
|
|
||||||
text = Path(path).read_text(encoding="utf-8").strip()
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
if text[0] in "[{":
|
|
||||||
data = json.loads(text)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
data = data.get("rows", [])
|
|
||||||
return [_norm_row(r) for r in data]
|
|
||||||
lines = text.splitlines()
|
|
||||||
header = [h.strip().lower() for h in lines[0].split("\t")]
|
|
||||||
rows = []
|
|
||||||
for line in lines[1:]:
|
|
||||||
if line.strip():
|
|
||||||
rows.append(_norm_row(dict(zip(header, line.split("\t")))))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _by_query(rows: list[dict]) -> dict:
|
|
||||||
return {r["query"]: r for r in rows if r["query"]}
|
|
||||||
|
|
||||||
|
|
||||||
def compute_delta(recent, prior, recent_days, prior_days,
|
|
||||||
claim_term=None, top_n=10) -> dict:
|
|
||||||
if recent_days <= 0 or prior_days <= 0:
|
|
||||||
raise ValueError("recent_days and prior_days must be positive")
|
|
||||||
r_by, p_by = _by_query(recent), _by_query(prior)
|
|
||||||
|
|
||||||
def totals(rows):
|
|
||||||
return {"clicks": sum(r["clicks"] for r in rows),
|
|
||||||
"impressions": sum(r["impressions"] for r in rows)}
|
|
||||||
rt, pt = totals(recent), totals(prior)
|
|
||||||
|
|
||||||
def per_day(total, days):
|
|
||||||
return round(total / days, 2)
|
|
||||||
|
|
||||||
def pct(new, old):
|
|
||||||
return round((new - old) / old * 100, 1) if old else None
|
|
||||||
|
|
||||||
r_cpd, p_cpd = per_day(rt["clicks"], recent_days), per_day(pt["clicks"], prior_days)
|
|
||||||
r_ipd, p_ipd = per_day(rt["impressions"], recent_days), per_day(pt["impressions"], prior_days)
|
|
||||||
|
|
||||||
deltas = []
|
|
||||||
for q in set(r_by) | set(p_by):
|
|
||||||
rc = r_by.get(q, {}).get("clicks", 0.0)
|
|
||||||
pc = p_by.get(q, {}).get("clicks", 0.0)
|
|
||||||
deltas.append({"query": q, "delta_clicks": rc - pc,
|
|
||||||
"recent_clicks": rc, "prior_clicks": pc})
|
|
||||||
deltas.sort(key=lambda d: d["delta_clicks"], reverse=True)
|
|
||||||
gainers = [d for d in deltas if d["delta_clicks"] > 0][:top_n]
|
|
||||||
decliners = sorted([d for d in deltas if d["delta_clicks"] < 0],
|
|
||||||
key=lambda d: d["delta_clicks"])[:top_n]
|
|
||||||
|
|
||||||
out = {
|
|
||||||
"site_totals": {
|
|
||||||
"recent": {**rt, "clicks_per_day": r_cpd,
|
|
||||||
"impressions_per_day": r_ipd, "days": recent_days},
|
|
||||||
"prior": {**pt, "clicks_per_day": p_cpd,
|
|
||||||
"impressions_per_day": p_ipd, "days": prior_days},
|
|
||||||
"clicks_per_day_pct": pct(r_cpd, p_cpd),
|
|
||||||
"impressions_per_day_pct": pct(r_ipd, p_ipd),
|
|
||||||
},
|
|
||||||
"top_gainers": gainers,
|
|
||||||
"top_decliners": decliners,
|
|
||||||
"claim_term": None,
|
|
||||||
"verdict_hint": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if claim_term:
|
|
||||||
gainer_terms = {g["query"] for g in gainers}
|
|
||||||
rc, pc = r_by.get(claim_term, {}), p_by.get(claim_term, {})
|
|
||||||
in_movers = claim_term in gainer_terms
|
|
||||||
share = (rc.get("clicks", 0.0) / rt["clicks"] * 100) if rt["clicks"] else 0.0
|
|
||||||
out["claim_term"] = {
|
|
||||||
"term": claim_term, "found": bool(rc or pc),
|
|
||||||
"recent": {"clicks": rc.get("clicks", 0.0),
|
|
||||||
"impressions": rc.get("impressions", 0.0),
|
|
||||||
"position": rc.get("position")},
|
|
||||||
"prior": {"clicks": pc.get("clicks", 0.0),
|
|
||||||
"impressions": pc.get("impressions", 0.0),
|
|
||||||
"position": pc.get("position")},
|
|
||||||
"in_top_movers": in_movers,
|
|
||||||
"click_share_pct": round(share, 2),
|
|
||||||
}
|
|
||||||
if not in_movers and share < 1.0:
|
|
||||||
out["verdict_hint"] = (
|
|
||||||
f"'{claim_term}' contributes {share:.2f}% of recent clicks and is "
|
|
||||||
f"absent from top movers -> claimed impact likely ARTIFACT; real "
|
|
||||||
f"movement is elsewhere (see top_gainers).")
|
|
||||||
elif in_movers:
|
|
||||||
out["verdict_hint"] = (
|
|
||||||
f"'{claim_term}' is among top movers -> claim plausibly CONFIRMED/"
|
|
||||||
f"PARTIAL; corroborate with live SERP + entity layer.")
|
|
||||||
else:
|
|
||||||
out["verdict_hint"] = (
|
|
||||||
f"'{claim_term}' has non-trivial share ({share:.2f}%) but is not a "
|
|
||||||
f"top mover -> PARTIAL; inspect attribution.")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
ap = argparse.ArgumentParser(description="GSC signal delta for signal validation")
|
|
||||||
ap.add_argument("--recent", required=True)
|
|
||||||
ap.add_argument("--prior", required=True)
|
|
||||||
ap.add_argument("--recent-days", type=int, required=True)
|
|
||||||
ap.add_argument("--prior-days", type=int, required=True)
|
|
||||||
ap.add_argument("--claim-term", default=None)
|
|
||||||
ap.add_argument("--top-n", type=int, default=10)
|
|
||||||
a = ap.parse_args(argv)
|
|
||||||
out = compute_delta(load_gsc(a.recent), load_gsc(a.prior),
|
|
||||||
a.recent_days, a.prior_days, a.claim_term, a.top_n)
|
|
||||||
json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
|
|
||||||
sys.stdout.write("\n")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills/custom-skills/35-seo-signal-validation/code/scripts
|
|
||||||
python3 test_gsc_signal_delta.py
|
|
||||||
```
|
|
||||||
Expected: `PASS test_claim_term_flagged_artifact` … `4 passed`
|
|
||||||
|
|
||||||
- [ ] **Step 5: Create `requirements.txt` and `code/CLAUDE.md`**
|
|
||||||
|
|
||||||
Create `custom-skills/35-seo-signal-validation/code/scripts/requirements.txt`:
|
|
||||||
```text
|
|
||||||
# gsc_signal_delta.py uses the Python 3 standard library only — no deps.
|
|
||||||
```
|
|
||||||
|
|
||||||
Create `custom-skills/35-seo-signal-validation/code/CLAUDE.md`:
|
|
||||||
```markdown
|
|
||||||
# seo-signal-validation — code environment notes
|
|
||||||
|
|
||||||
## Helper: scripts/gsc_signal_delta.py
|
|
||||||
Deterministic L1/L4 GSC delta. Feed it two saved GSC query exports (recent,
|
|
||||||
prior) as JSON or TSV (columns: query, clicks, impressions, position).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/gsc_signal_delta.py \
|
|
||||||
--recent recent.tsv --prior prior.tsv \
|
|
||||||
--recent-days 28 --prior-days 30 --claim-term "호텔"
|
|
||||||
```
|
|
||||||
Returns day-normalized site totals, top gainers/decliners, and a `verdict_hint`
|
|
||||||
(heuristic only — the final verdict is the skill's job, after L2/L3).
|
|
||||||
|
|
||||||
## Getting the exports
|
|
||||||
`mcp__dda__gsc_fetch_performance` (property pinned per workspace, e.g. JHR
|
|
||||||
`sc-domain:josunhotel.com`) → save the query-dimension rows to a file → run the
|
|
||||||
script. GSC anonymizes ~43% of query clicks; the disclosed subset ≠ the whole.
|
|
||||||
|
|
||||||
## Env / access
|
|
||||||
- `GOOGLE_KG_API_KEY` for `mcp__ourseo__search_knowledge_graph` (L3).
|
|
||||||
- GSC/GA4 only exist for first-party properties — third-party entities skip L1.
|
|
||||||
- Never crawl/audit Marriott for JHR (sameAs only).
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills
|
|
||||||
git add custom-skills/35-seo-signal-validation/code
|
|
||||||
git commit -m "feat(skill): gsc_signal_delta helper + tests + code notes"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Register in marketplace + reconcile DESIGN.md structure
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `.claude-plugin/marketplace.json` (add to `ourdigital-seo` → `skills`)
|
|
||||||
- Modify: `custom-skills/35-seo-signal-validation/DESIGN.md:§7` (replace `references/` layout with actual `code/` layout; mark Code-only)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: the skill folder from Tasks 1–3.
|
|
||||||
- Produces: a registered, discoverable skill; a spec whose §7 matches the built structure.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the skill path to the manifest**
|
|
||||||
|
|
||||||
In `.claude-plugin/marketplace.json`, inside the `ourdigital-seo` plugin's
|
|
||||||
`skills` array, add (keep numeric order; insert after the `34-seo-reporting-dashboard` entry):
|
|
||||||
```json
|
|
||||||
"./custom-skills/35-seo-signal-validation",
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify the manifest still parses and contains the entry**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills
|
|
||||||
python3 - <<'PY'
|
|
||||||
import json, pathlib
|
|
||||||
m = json.loads(pathlib.Path(".claude-plugin/marketplace.json").read_text())
|
|
||||||
seo = next(p for p in m["plugins"] if p["name"] == "ourdigital-seo")
|
|
||||||
assert "./custom-skills/35-seo-signal-validation" in seo["skills"], "not registered"
|
|
||||||
print("OK manifest valid + skill registered")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
Expected: `OK manifest valid + skill registered`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Reconcile `DESIGN.md` §7 with the real structure**
|
|
||||||
|
|
||||||
In `custom-skills/35-seo-signal-validation/DESIGN.md`, replace the §7 "Repo
|
|
||||||
layout & conventions" code block (the `references/...` sketch) with:
|
|
||||||
```text
|
|
||||||
35-seo-signal-validation/
|
|
||||||
SKILL.md self-contained: classification, 4-layer cascade,
|
|
||||||
5 KG checks, 4-way verdict, skepticism rules, output
|
|
||||||
DESIGN.md PLAN.md spec + plan (live with the skill; no new top-level dir)
|
|
||||||
code/
|
|
||||||
CLAUDE.md code-environment notes (env, export→script flow)
|
|
||||||
scripts/
|
|
||||||
gsc_signal_delta.py deterministic L1/L4 GSC delta + mover ranking
|
|
||||||
test_gsc_signal_delta.py
|
|
||||||
requirements.txt (stdlib only)
|
|
||||||
```
|
|
||||||
And add one line under it: `Target environment: Claude Code only (no desktop/ variant — matches precedent 95/96). Registered in .claude-plugin/marketplace.json under ourdigital-seo.`
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify the spec no longer references the old layout**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills
|
|
||||||
python3 - <<'PY'
|
|
||||||
import pathlib
|
|
||||||
t = pathlib.Path("custom-skills/35-seo-signal-validation/DESIGN.md").read_text(encoding="utf-8")
|
|
||||||
assert "references/\n evidence-cascade.md" not in t, "old layout still present"
|
|
||||||
assert "gsc_signal_delta.py" in t and "marketplace.json" in t, "structure not reconciled"
|
|
||||||
print("OK DESIGN.md §7 reconciled")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
Expected: `OK DESIGN.md §7 reconciled`
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills
|
|
||||||
git add .claude-plugin/marketplace.json custom-skills/35-seo-signal-validation/DESIGN.md
|
|
||||||
git commit -m "feat(skill): register seo-signal-validation in marketplace; reconcile spec layout"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Smoke test — genesis case end-to-end + consistency gate
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `custom-skills/35-seo-signal-validation/code/scripts/fixtures/jhr-hotel-recent.tsv`
|
|
||||||
- Create: `custom-skills/35-seo-signal-validation/code/scripts/fixtures/jhr-hotel-prior.tsv`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `gsc_signal_delta.py` CLI (Task 3) and the full `SKILL.md` (Tasks 1–2).
|
|
||||||
- Produces: a reproducible CLI smoke run proving the genesis "호텔" case yields an ARTIFACT-leaning hint; a final spec↔skill consistency check.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the genesis fixtures (TSV)**
|
|
||||||
|
|
||||||
Create `custom-skills/35-seo-signal-validation/code/scripts/fixtures/jhr-hotel-recent.tsv`:
|
|
||||||
```text
|
|
||||||
query clicks impressions position
|
|
||||||
호텔 5 572 11.6
|
|
||||||
grand josun busan 250 4000 1.2
|
|
||||||
조선호텔 300 6000 1.1
|
|
||||||
```
|
|
||||||
|
|
||||||
Create `custom-skills/35-seo-signal-validation/code/scripts/fixtures/jhr-hotel-prior.tsv`:
|
|
||||||
```text
|
|
||||||
query clicks impressions position
|
|
||||||
호텔 9 371 18.1
|
|
||||||
grand josun busan 49 1500 3.4
|
|
||||||
조선호텔 150 5000 1.3
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the CLI end-to-end and verify the verdict hint**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills/custom-skills/35-seo-signal-validation/code/scripts
|
|
||||||
python3 gsc_signal_delta.py --recent fixtures/jhr-hotel-recent.tsv \
|
|
||||||
--prior fixtures/jhr-hotel-prior.tsv --recent-days 28 --prior-days 30 \
|
|
||||||
--claim-term "호텔" | python3 - <<'PY'
|
|
||||||
import json, sys
|
|
||||||
o = json.load(sys.stdin)
|
|
||||||
assert o["claim_term"]["in_top_movers"] is False
|
|
||||||
assert o["claim_term"]["click_share_pct"] < 1.0
|
|
||||||
assert "ARTIFACT" in o["verdict_hint"]
|
|
||||||
assert o["top_gainers"][0]["query"] == "grand josun busan"
|
|
||||||
print("OK smoke: 호텔 → ARTIFACT-leaning; top mover = brand term")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
Expected: `OK smoke: 호텔 → ARTIFACT-leaning; top mover = brand term`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Consistency gate — every spec default maps to skill content**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills
|
|
||||||
python3 - <<'PY'
|
|
||||||
import pathlib
|
|
||||||
sk = pathlib.Path("custom-skills/35-seo-signal-validation/SKILL.md").read_text(encoding="utf-8")
|
|
||||||
# Default 1: 5 KG checks Default 2: 4 verdicts Default 3: output triple
|
|
||||||
for s in ["Google KG API", "Wikidata", "Knowledge Panel", "sameAs", "지식iN"]:
|
|
||||||
assert s in sk, f"KG check missing: {s}"
|
|
||||||
for s in ["CONFIRMED", "PARTIAL", "ARTIFACT", "INCONCLUSIVE"]:
|
|
||||||
assert s in sk, f"verdict missing: {s}"
|
|
||||||
for s in ["notion-writer", "evidence ledger", "client-safe narrative"]:
|
|
||||||
assert s.lower() in sk.lower(), f"output element missing: {s}"
|
|
||||||
# Default 5: triggers (KR + EN)
|
|
||||||
assert "신호 검증" in sk and "validate serp signal" in sk, "triggers missing"
|
|
||||||
print("OK all five approved defaults present in SKILL.md")
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
Expected: `OK all five approved defaults present in SKILL.md`
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/Project/our-claude-skills
|
|
||||||
git add custom-skills/35-seo-signal-validation/code/scripts/fixtures
|
|
||||||
git commit -m "test(skill): genesis 호텔 smoke fixtures + end-to-end ARTIFACT check"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
**1. Spec coverage** (each DESIGN.md section → task):
|
|
||||||
- §1 Purpose, §2 Boundary → Task 1 (SKILL.md Purpose/boundary). ✓
|
|
||||||
- §3 Engine (entity classification, L1–L4, short-circuit) → Tasks 1 (L1–L2) + 2 (L3–L4); L1/L4 delta computation → Task 3 script. ✓
|
|
||||||
- §4 Modes → Task 1 (Step 0 mode dispatch). ✓
|
|
||||||
- §5 Verdict + skepticism + confidence cap → Task 2. ✓
|
|
||||||
- §6 Output → Task 2. ✓
|
|
||||||
- §7 Repo layout → corrected in Task 4 (was wrong in spec); registration in Task 4. ✓
|
|
||||||
- §8 Non-goals → Task 2. ✓
|
|
||||||
- §9 Future options → intentionally not implemented (YAGNI). ✓
|
|
||||||
- Genesis verification → Task 5 smoke. ✓
|
|
||||||
|
|
||||||
**2. Placeholder scan:** No TBD/TODO; all code blocks complete; every test shows real assertions; commands show expected output. ✓
|
|
||||||
|
|
||||||
**3. Type consistency:** `compute_delta(recent, prior, recent_days, prior_days, claim_term=None, top_n=10)` and `load_gsc(path)` are referenced identically in Task 3 (definition + test) and Task 5 (CLI). Output keys (`site_totals`, `top_gainers`, `claim_term.in_top_movers`, `claim_term.click_share_pct`, `verdict_hint`) match across the implementation, the tests, and both smoke checks. Day-normalization fixtures (555/28=19.82, 208/30=6.93; gainer delta 201) are arithmetically consistent. ✓
|
|
||||||
|
|
||||||
**No gaps found.**
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
---
|
|
||||||
name: seo-signal-validation
|
|
||||||
description: |
|
|
||||||
Validate whether a claimed SERP / Knowledge-Graph movement for a (term, entity)
|
|
||||||
is real, misattributed, an artifact, or unprovable — before reporting impact.
|
|
||||||
Triggers: validate serp signal, is this ranking real, prove SEO impact,
|
|
||||||
SEMrush surge real, signal validation, real impact check,
|
|
||||||
신호 검증, 순위 변화 진짜, 오가닉 급증 검증, 임팩트 검증.
|
|
||||||
---
|
|
||||||
|
|
||||||
# SEO Signal Validation
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Given a `(term/intent, entity)` pair — and optionally a **claim** (a third-party
|
|
||||||
tool's reported movement) or a **baseline** (a prior state) — return an
|
|
||||||
evidence-backed verdict on whether SERP and Knowledge-Graph impact is real.
|
|
||||||
Built because modeled third-party signals (SEMrush/Ahrefs estimated organic
|
|
||||||
traffic, position snapshots) are easy to over-trust. This skill makes the
|
|
||||||
measured → live → entity → attribution cascade a single repeatable procedure
|
|
||||||
ending in a defensible verdict and a client-safe narrative.
|
|
||||||
|
|
||||||
## When to use (boundary)
|
|
||||||
|
|
||||||
This is the **conductor**, not an instrument. It sequences and synthesizes the
|
|
||||||
three measurement skills — it does not duplicate them.
|
|
||||||
|
|
||||||
| Use instead | When |
|
|
||||||
|---|---|
|
|
||||||
| `20-seo-serp-analysis` | You only need SERP composition / features |
|
|
||||||
| `21-seo-position-tracking` | You only need rank over time |
|
|
||||||
| `28-seo-knowledge-graph` | You only need an entity-presence audit |
|
|
||||||
| **this skill** | You must adjudicate whether a *claimed movement* is real across layers |
|
|
||||||
|
|
||||||
## Step 0 — Classify entity + pick mode
|
|
||||||
|
|
||||||
1. **Entity ownership** (gates which layers exist):
|
|
||||||
- **First-party** — a site/property you own or have GSC/GA4 access to (e.g. JHR
|
|
||||||
`sc-domain:josunhotel.com`, GA4 `258308769`) → **L1 measured available**.
|
|
||||||
- **Third-party** — a competitor brand or a person you do not control →
|
|
||||||
**L1 unavailable**; lean on L2 + L3 + clearly-tiered estimates; apply the
|
|
||||||
confidence cap (see Verdict). If unclear, ask once.
|
|
||||||
2. **Mode** (thin wrappers over the same cascade):
|
|
||||||
- `adjudicate(claim)` — a 3rd-party tool reports a move; confirm/refute.
|
|
||||||
- `prove(baseline)` — after our change; before/after from GSC/GA4 history.
|
|
||||||
- `snapshot()` — no claim; "where do we really stand."
|
|
||||||
|
|
||||||
## The validation loop (cost-ordered cascade, short-circuiting)
|
|
||||||
|
|
||||||
Run cheapest-first; stop early when a layer is already decisive.
|
|
||||||
|
|
||||||
### L1 — Measured (first-party ground truth) → via `21-seo-position-tracking`
|
|
||||||
|
|
||||||
- **GSC** `mcp__dda__gsc_fetch_performance`: the term at **query level** (exact)
|
|
||||||
AND **site-wide**, for **recent vs prior** windows. Pull clicks / impressions /
|
|
||||||
position / CTR. **Day-normalize** (compare windows differ in calendar-day count).
|
|
||||||
Note **~43% query-level anonymization** — the disclosed subset ≠ the whole.
|
|
||||||
- **GA4** `mcp__dda__ga4_run_report`: `Organic Search` sessions monthly trend
|
|
||||||
(dims `yearMonth` + `sessionDefaultChannelGroup`, metric `sessions`). GA4
|
|
||||||
includes Naver + all engines — use it to test whether a "surge" exceeds normal
|
|
||||||
month-to-month variance.
|
|
||||||
- **Compute deltas with the helper** (deterministic, avoids ad-hoc parsing):
|
|
||||||
save each GSC pull, then run
|
|
||||||
`python3 code/scripts/gsc_signal_delta.py --recent <recent.tsv> --prior <prior.tsv> --recent-days N --prior-days M --claim-term "<term>"`.
|
|
||||||
It returns day-normalized site totals, top gainers/decliners, and whether the
|
|
||||||
claimed term is among the real movers.
|
|
||||||
- **SHORT-CIRCUIT:** if the claimed keyword has trivial clicks and a real
|
|
||||||
position nowhere near the claim → **ARTIFACT**; stop unless the caller wants
|
|
||||||
the full picture.
|
|
||||||
|
|
||||||
### L2 — Live SERP (3rd-party measured, point-in-time) → via `20-seo-serp-analysis`
|
|
||||||
|
|
||||||
- **Geo-correct Google render** via `claude-in-chrome` (`navigate` → `read_page`):
|
|
||||||
force `gl`/`hl` + correct geo, `pws=0`; **decline precise-location prompts**.
|
|
||||||
Confirm whether the domain actually holds the claimed position; capture the
|
|
||||||
feature landscape (ads, local map-pack, PAA, knowledge panel) that explains why
|
|
||||||
a brand site can't own a head term.
|
|
||||||
- **Cheap rank spot-check**: `mcp__ourseo__check_serp(keyword, domain)`.
|
|
||||||
- **[KR market]** Naver SERP composition: `our research naver serp` (blog / cafe /
|
|
||||||
지식iN / Smart Store / brand zone) — Semrush/Ahrefs don't model Naver.
|
|
||||||
|
|
||||||
### L3 — Entity / Knowledge Graph → via `28-seo-knowledge-graph`
|
|
||||||
|
|
||||||
A real impact event should leave corroborating traces in the entity layer, not
|
|
||||||
just a rank number. Five checks:
|
|
||||||
|
|
||||||
1. **Google KG API** entity match + `resultScore` —
|
|
||||||
`mcp__ourseo__search_knowledge_graph(query)` (uses `GOOGLE_KG_API_KEY`).
|
|
||||||
2. **Wikidata** QID presence + key claims — **verify the QID against
|
|
||||||
`Special:EntityData/{Q}.json` labels before trusting it** (false-match guard:
|
|
||||||
Q109455878 = office tower ≠ hotel; Q490787 = Shinsegae Inc. ≠ Group).
|
|
||||||
3. **Knowledge Panel** presence/attributes on the live entity-name SERP (Chrome).
|
|
||||||
4. **sameAs** consistency on the entity's `Organization`/`Person` JSON-LD.
|
|
||||||
5. **[KR]** Naver 백과사전 / 지식iN presence.
|
|
||||||
|
|
||||||
`mcp__ourseo__monitor_brand` supplements with brand-mention / brand-SERP ownership.
|
|
||||||
|
|
||||||
### L4 — Attribution synthesis
|
|
||||||
|
|
||||||
Cross-check: does the **measured delta (L1)** corroborate the **live reality
|
|
||||||
(L2)**, and does the **entity layer (L3)** move consistently? The query-clicks
|
|
||||||
delta names the true drivers (brand/seasonal vs the claimed term).
|
|
||||||
|
|
||||||
## Verdict
|
|
||||||
|
|
||||||
| Verdict | Condition |
|
|
||||||
|---|---|
|
|
||||||
| **CONFIRMED** | Measured + live + (where relevant) entity all corroborate movement attributable to the term/intent |
|
|
||||||
| **PARTIAL** | Real movement, but misattributed, or only some layers agree |
|
|
||||||
| **ARTIFACT** | Modeling/snapshot artifact — measured + live reality don't support it |
|
|
||||||
| **INCONCLUSIVE** | Insufficient data (query anonymized, GSC lag, no entity baseline, third-party entity with no measured access) — name what's missing + how to resolve |
|
|
||||||
|
|
||||||
**Confidence cap:** third-party entities (no L1) cannot reach CONFIRMED on traffic
|
|
||||||
claims — at most PARTIAL; ARTIFACT only when live+entity clearly contradict.
|
|
||||||
|
|
||||||
Every verdict ships an **evidence ledger** (per layer: finding + data-trust tier +
|
|
||||||
corroborates/contradicts) and a **client-safe narrative** (the defensible story).
|
|
||||||
|
|
||||||
## Standing skepticism rules
|
|
||||||
|
|
||||||
- Estimated organic traffic = **smoke-detector, not scale** (Σ est-volume × position-CTR curve).
|
|
||||||
- **Head-term over-fire**: one high-volume keyword at an estimated high rank inflates the whole modeled number.
|
|
||||||
- **KR Naver blind spot**: Semrush models Google only; misses much of Korean organic.
|
|
||||||
- **Single-geo/device snapshot** diverges from GSC's national average.
|
|
||||||
- **Trust hierarchy**: 1st-party measured > 3rd-party measured > 3rd-party modeled.
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
- **Always**: inline report — verdict + evidence ledger + client-safe narrative +
|
|
||||||
"what would raise confidence."
|
|
||||||
- **Optional**: archive to Notion *Working with AI DB* (`data_source_id
|
|
||||||
f8f19ede-32bd-43ac-9f60-0651f6f40afe`) via the **notion-writer script** (never
|
|
||||||
Notion MCP write). Type=Memo/Research, Topic=SEO, Account Code as relevant.
|
|
||||||
- **Optional**: if a new generalizable gotcha emerges, append a memory entry to
|
|
||||||
the active workspace's memory dir.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
No cron/scheduler, no snapshot DB, no new directories. Does not replace the three
|
|
||||||
instrument skills. Returns INCONCLUSIVE rather than fabricating when data is thin.
|
|
||||||
**Never crawls/audits Marriott for JHR** (sameAs only).
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# seo-signal-validation — code environment notes
|
|
||||||
|
|
||||||
## Helper: scripts/gsc_signal_delta.py
|
|
||||||
Deterministic L1/L4 GSC delta. Feed it two saved GSC query exports (recent,
|
|
||||||
prior) as JSON or TSV (columns: query, clicks, impressions, position).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/gsc_signal_delta.py \
|
|
||||||
--recent recent.tsv --prior prior.tsv \
|
|
||||||
--recent-days 28 --prior-days 30 --claim-term "호텔"
|
|
||||||
```
|
|
||||||
Returns day-normalized site totals, top gainers/decliners, and a `verdict_hint`
|
|
||||||
(heuristic only — the final verdict is the skill's job, after L2/L3).
|
|
||||||
|
|
||||||
**Surge-tuning note**: `verdict_hint` and `in_top_movers` are calibrated for upward "surge" claims (movers ranked by click gain). For a claimed *drop*, inspect `top_decliners` directly rather than relying on the hint.
|
|
||||||
|
|
||||||
## Getting the exports
|
|
||||||
`mcp__dda__gsc_fetch_performance` (property pinned per workspace, e.g. JHR
|
|
||||||
`sc-domain:josunhotel.com`) → save the query-dimension rows to a file → run the
|
|
||||||
script. GSC anonymizes ~43% of query clicks; the disclosed subset ≠ the whole.
|
|
||||||
|
|
||||||
## Env / access
|
|
||||||
- `GOOGLE_KG_API_KEY` for `mcp__ourseo__search_knowledge_graph` (L3).
|
|
||||||
- GSC/GA4 only exist for first-party properties — third-party entities skip L1.
|
|
||||||
- Never crawl/audit Marriott for JHR (sameAs only).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
query clicks impressions position
|
|
||||||
호텔 9 371 18.1
|
|
||||||
grand josun busan 49 1500 3.4
|
|
||||||
조선호텔 150 5000 1.3
|
|
||||||
|
@@ -1,4 +0,0 @@
|
|||||||
query clicks impressions position
|
|
||||||
호텔 5 572 11.6
|
|
||||||
grand josun busan 250 4000 1.2
|
|
||||||
조선호텔 300 6000 1.1
|
|
||||||
|
@@ -1,160 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Day-normalized GSC query delta + mover ranking for signal validation.
|
|
||||||
|
|
||||||
Reads two GSC query exports (recent, prior) — JSON list or TSV with a header row
|
|
||||||
containing query / clicks / impressions / position — and reports day-normalized
|
|
||||||
site totals, top gainers/decliners, and whether a claimed term is a real mover.
|
|
||||||
This is the deterministic L1/L4 core of the 35-seo-signal-validation skill.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def _norm_row(r: dict) -> dict:
|
|
||||||
def num(*keys, default=0.0):
|
|
||||||
for k in keys:
|
|
||||||
if k in r and r[k] not in (None, ""):
|
|
||||||
try:
|
|
||||||
return float(str(r[k]).replace(",", ""))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return default
|
|
||||||
query = (r.get("query") or r.get("term") or "")
|
|
||||||
if isinstance(r.get("keys"), list) and r["keys"]:
|
|
||||||
query = str(r["keys"][0])
|
|
||||||
return {
|
|
||||||
"query": str(query).strip(),
|
|
||||||
"clicks": num("clicks"),
|
|
||||||
"impressions": num("impressions", "impr"),
|
|
||||||
"position": num("position", "pos", default=0.0),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def load_gsc(path: str) -> list[dict]:
|
|
||||||
"""Parse a GSC export (JSON list/{rows:[...]} or TSV-with-header)."""
|
|
||||||
text = Path(path).read_text(encoding="utf-8").strip()
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
if text[0] in "[{":
|
|
||||||
data = json.loads(text)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
data = data.get("rows", [])
|
|
||||||
return [_norm_row(r) for r in data]
|
|
||||||
lines = text.splitlines()
|
|
||||||
header = [h.strip().lower() for h in lines[0].split("\t")]
|
|
||||||
rows = []
|
|
||||||
for line in lines[1:]:
|
|
||||||
if line.strip():
|
|
||||||
rows.append(_norm_row(dict(zip(header, line.split("\t")))))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _by_query(rows: list[dict]) -> dict:
|
|
||||||
return {r["query"]: r for r in rows if r["query"]}
|
|
||||||
|
|
||||||
|
|
||||||
def compute_delta(recent, prior, recent_days, prior_days,
|
|
||||||
claim_term=None, top_n=10) -> dict:
|
|
||||||
if recent_days <= 0 or prior_days <= 0:
|
|
||||||
raise ValueError("recent_days and prior_days must be positive")
|
|
||||||
r_by, p_by = _by_query(recent), _by_query(prior)
|
|
||||||
|
|
||||||
def totals(rows):
|
|
||||||
return {"clicks": sum(r["clicks"] for r in rows),
|
|
||||||
"impressions": sum(r["impressions"] for r in rows)}
|
|
||||||
rt, pt = totals(recent), totals(prior)
|
|
||||||
|
|
||||||
def per_day(total, days):
|
|
||||||
return round(total / days, 2)
|
|
||||||
|
|
||||||
def pct(new, old):
|
|
||||||
return round((new - old) / old * 100, 1) if old else None
|
|
||||||
|
|
||||||
r_cpd, p_cpd = per_day(rt["clicks"], recent_days), per_day(pt["clicks"], prior_days)
|
|
||||||
r_ipd, p_ipd = per_day(rt["impressions"], recent_days), per_day(pt["impressions"], prior_days)
|
|
||||||
|
|
||||||
deltas = []
|
|
||||||
for q in set(r_by) | set(p_by):
|
|
||||||
rc = r_by.get(q, {}).get("clicks", 0.0)
|
|
||||||
pc = p_by.get(q, {}).get("clicks", 0.0)
|
|
||||||
deltas.append({"query": q, "delta_clicks": rc - pc,
|
|
||||||
"recent_clicks": rc, "prior_clicks": pc})
|
|
||||||
deltas.sort(key=lambda d: d["delta_clicks"], reverse=True)
|
|
||||||
gainers = [d for d in deltas if d["delta_clicks"] > 0][:top_n]
|
|
||||||
decliners = sorted([d for d in deltas if d["delta_clicks"] < 0],
|
|
||||||
key=lambda d: d["delta_clicks"])[:top_n]
|
|
||||||
|
|
||||||
out = {
|
|
||||||
"site_totals": {
|
|
||||||
"recent": {**rt, "clicks_per_day": r_cpd,
|
|
||||||
"impressions_per_day": r_ipd, "days": recent_days},
|
|
||||||
"prior": {**pt, "clicks_per_day": p_cpd,
|
|
||||||
"impressions_per_day": p_ipd, "days": prior_days},
|
|
||||||
"clicks_per_day_pct": pct(r_cpd, p_cpd),
|
|
||||||
"impressions_per_day_pct": pct(r_ipd, p_ipd),
|
|
||||||
},
|
|
||||||
"top_gainers": gainers,
|
|
||||||
"top_decliners": decliners,
|
|
||||||
"claim_term": None,
|
|
||||||
"verdict_hint": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if claim_term:
|
|
||||||
gainer_terms = {g["query"] for g in gainers}
|
|
||||||
rc, pc = r_by.get(claim_term, {}), p_by.get(claim_term, {})
|
|
||||||
in_movers = claim_term in gainer_terms
|
|
||||||
found = bool(rc or pc)
|
|
||||||
share = (rc.get("clicks", 0.0) / rt["clicks"] * 100) if rt["clicks"] else 0.0
|
|
||||||
out["claim_term"] = {
|
|
||||||
"term": claim_term, "found": found,
|
|
||||||
"recent": {"clicks": rc.get("clicks", 0.0),
|
|
||||||
"impressions": rc.get("impressions", 0.0),
|
|
||||||
"position": rc.get("position")},
|
|
||||||
"prior": {"clicks": pc.get("clicks", 0.0),
|
|
||||||
"impressions": pc.get("impressions", 0.0),
|
|
||||||
"position": pc.get("position")},
|
|
||||||
"in_top_movers": in_movers,
|
|
||||||
"click_share_pct": round(share, 2),
|
|
||||||
}
|
|
||||||
if not found:
|
|
||||||
out["verdict_hint"] = (
|
|
||||||
f"'{claim_term}' is absent from both GSC windows (no impressions / "
|
|
||||||
f"likely anonymized) -> INCONCLUSIVE, not refuted; confirm via live "
|
|
||||||
f"SERP + entity layer.")
|
|
||||||
elif not in_movers and share < 1.0:
|
|
||||||
out["verdict_hint"] = (
|
|
||||||
f"'{claim_term}' contributes {share:.2f}% of recent clicks and is "
|
|
||||||
f"absent from top movers -> claimed impact likely ARTIFACT; real "
|
|
||||||
f"movement is elsewhere (see top_gainers).")
|
|
||||||
elif in_movers:
|
|
||||||
out["verdict_hint"] = (
|
|
||||||
f"'{claim_term}' is among top movers -> claim plausibly CONFIRMED/"
|
|
||||||
f"PARTIAL; corroborate with live SERP + entity layer.")
|
|
||||||
else:
|
|
||||||
out["verdict_hint"] = (
|
|
||||||
f"'{claim_term}' has non-trivial share ({share:.2f}%) but is not a "
|
|
||||||
f"top mover -> PARTIAL; inspect attribution.")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
ap = argparse.ArgumentParser(description="GSC signal delta for signal validation")
|
|
||||||
ap.add_argument("--recent", required=True)
|
|
||||||
ap.add_argument("--prior", required=True)
|
|
||||||
ap.add_argument("--recent-days", type=int, required=True)
|
|
||||||
ap.add_argument("--prior-days", type=int, required=True)
|
|
||||||
ap.add_argument("--claim-term", default=None)
|
|
||||||
ap.add_argument("--top-n", type=int, default=10)
|
|
||||||
a = ap.parse_args(argv)
|
|
||||||
out = compute_delta(load_gsc(a.recent), load_gsc(a.prior),
|
|
||||||
a.recent_days, a.prior_days, a.claim_term, a.top_n)
|
|
||||||
json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
|
|
||||||
sys.stdout.write("\n")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# gsc_signal_delta.py uses the Python 3 standard library only — no deps.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Tests for gsc_signal_delta. Run: `python3 test_gsc_signal_delta.py`
|
|
||||||
(also pytest-compatible). Stdlib only."""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
from gsc_signal_delta import compute_delta # noqa: E402
|
|
||||||
|
|
||||||
# Genesis fixture: JHR "호텔" — flat head term, growth all brand (2026-06 case).
|
|
||||||
RECENT = [
|
|
||||||
{"query": "호텔", "clicks": 5, "impressions": 572, "position": 11.6},
|
|
||||||
{"query": "grand josun busan", "clicks": 250, "impressions": 4000, "position": 1.2},
|
|
||||||
{"query": "조선호텔", "clicks": 300, "impressions": 6000, "position": 1.1},
|
|
||||||
]
|
|
||||||
PRIOR = [
|
|
||||||
{"query": "호텔", "clicks": 9, "impressions": 371, "position": 18.1},
|
|
||||||
{"query": "grand josun busan", "clicks": 49, "impressions": 1500, "position": 3.4},
|
|
||||||
{"query": "조선호텔", "clicks": 150, "impressions": 5000, "position": 1.3},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_claim_term_flagged_artifact():
|
|
||||||
out = compute_delta(RECENT, PRIOR, 28, 30, claim_term="호텔")
|
|
||||||
ct = out["claim_term"]
|
|
||||||
assert ct["found"] is True
|
|
||||||
assert ct["in_top_movers"] is False
|
|
||||||
assert ct["click_share_pct"] < 1.0
|
|
||||||
assert "ARTIFACT" in out["verdict_hint"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_top_gainer_is_brand_term():
|
|
||||||
out = compute_delta(RECENT, PRIOR, 28, 30, claim_term="호텔")
|
|
||||||
assert out["top_gainers"][0]["query"] == "grand josun busan"
|
|
||||||
assert out["top_gainers"][0]["delta_clicks"] == 201
|
|
||||||
|
|
||||||
|
|
||||||
def test_day_normalization():
|
|
||||||
out = compute_delta(RECENT, PRIOR, 28, 30)
|
|
||||||
assert out["site_totals"]["recent"]["clicks_per_day"] == 19.82 # 555/28
|
|
||||||
assert out["site_totals"]["prior"]["clicks_per_day"] == 6.93 # 208/30
|
|
||||||
|
|
||||||
|
|
||||||
def test_absent_claim_term_inconclusive():
|
|
||||||
out = compute_delta(RECENT, PRIOR, 28, 30, claim_term="존재하지않는검색어")
|
|
||||||
assert out["claim_term"]["found"] is False
|
|
||||||
assert "INCONCLUSIVE" in out["verdict_hint"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_positive_days_required():
|
|
||||||
try:
|
|
||||||
compute_delta(RECENT, PRIOR, 0, 30)
|
|
||||||
except ValueError:
|
|
||||||
return
|
|
||||||
raise AssertionError("expected ValueError for non-positive days")
|
|
||||||
|
|
||||||
|
|
||||||
def _run():
|
|
||||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
|
||||||
for fn in fns:
|
|
||||||
fn(); print(f"PASS {fn.__name__}")
|
|
||||||
print(f"\n{len(fns)} passed")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
_run()
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
---
|
|
||||||
name: jamie-copy-trimmer
|
|
||||||
description: >-
|
|
||||||
Trims and sharpens Korean plastic-surgery / aesthetic marketing copy — headlines, body,
|
|
||||||
CTAs, names, slogans. Removes clichés and medical-ad compliance risks, suggests trendy,
|
|
||||||
catchy alternatives within 의료광고 심의 limits, and re-scores them. Use when copy feels
|
|
||||||
awkward or old, or to refine, name, or compliance-check any copy. Triggers: 카피 다듬어,
|
|
||||||
카피 트리밍, 네이밍 검토, 슬로건 다듬어, 심의 안전하게, copy trim, make it catchier, 제이미 카피.
|
|
||||||
For Jamie work, use with jamie-brand-guardian.
|
|
||||||
license: Proprietary (OurDigital internal)
|
|
||||||
---
|
|
||||||
|
|
||||||
# Jamie Copy Trimmer
|
|
||||||
|
|
||||||
Trim and sharpen Korean plastic-surgery / aesthetic-medical marketing copy against an industry expression corpus, within medical-advertising limits.
|
|
||||||
|
|
||||||
**Working language.** These directives are in English, but the copy you evaluate and produce is Korean — the language of the target market. Write all deliverable copy, alternatives, and the final report in Korean unless the user asks otherwise.
|
|
||||||
|
|
||||||
## Core philosophy (internalize this)
|
|
||||||
|
|
||||||
1. **The corpus is a map for AVOIDING clichés, not a library to COPY.** Frequently used means probably already stale. Use it to spot what everyone says and go elsewhere. Copying corpus phrases defeats the purpose, which is differentiation.
|
|
||||||
2. **The ceiling on wit is set by 의료광고 심의.** Korean plastic-surgery copy is constrained by rules against exaggeration, superlatives, and patient inducement. Raise the appeal, but anything past the compliance line is void. Compliance is a **gate, not a score** — if a single 🔴 risk expression remains, that option fails outright.
|
|
||||||
3. **Trim first, dazzle second.** Cutting redundancy, clichés, and risk is the primary job. Add restrained flair into the space you cleared. "Say less, land harder."
|
|
||||||
4. **Don't guess — mark [확인].** If procedure facts, target, channel, or brand tone are missing, ask the user or leave a `[확인]` note and move on. Never fill gaps with invention.
|
|
||||||
|
|
||||||
## Before you start (inputs)
|
|
||||||
|
|
||||||
Ask, or mark `[확인]`, if any of these are missing:
|
|
||||||
- **Copy text** and its type (headline / body / CTA / name / slogan)
|
|
||||||
- **Channel** (카카오플러스친구 / Instagram / blog / Naver / in-clinic POP / search ad) — tone and 심의 strictness differ by channel
|
|
||||||
- **Procedure & target** (standard procedure name, patient concern)
|
|
||||||
- **Brand tone** — for a specific brand (e.g., Jamie), that brand's guide (`jamie-brand-guardian`) overrides this skill's taste defaults
|
|
||||||
|
|
||||||
## Workflow (5 steps)
|
|
||||||
|
|
||||||
### 1) Diagnose — tag the original
|
|
||||||
Tag each phrase/expression. When unsure, read the matching corpus file.
|
|
||||||
|
|
||||||
| Tag | Meaning | Action |
|
|
||||||
|-----|---------|--------|
|
|
||||||
| 🟢 effective | Works, resonates | Keep |
|
|
||||||
| 🟡 cliché | Industry-stale, tired | Trim / replace (→ `corpus_cliche.md`) |
|
|
||||||
| 🔴 compliance risk | Exaggeration, superlative, inducement, comparison | Must remove/replace (→ `corpus_compliance_risk.md`) |
|
|
||||||
| ⚪ flat | Not wrong, but no hook | Sharpen (→ `witty_within_limits.md`) |
|
|
||||||
| 🟦 brand asset | Brand-owned phrase | Do not misjudge as cliché (e.g., Jamie's "티 안 나게") |
|
|
||||||
|
|
||||||
### 2) Trim
|
|
||||||
Remove all 🔴, replace 🟡, delete redundancy. Output of this step is a "safe, plain" version.
|
|
||||||
|
|
||||||
### 3) Elevate — add appeal within 심의 limits
|
|
||||||
Using techniques in `witty_within_limits.md`, give **2–3 alternatives per element**, each with a rationale (why it lands harder), matched to the channel's tone. Consult `corpus_examples.md` for technique coordinates — never copy the examples verbatim.
|
|
||||||
|
|
||||||
### 4) Re-score — 5-axis rubric
|
|
||||||
Score original vs. improved with `evaluation_rubric.md`. 심의 is a PASS/FAIL gate. In the brand-fit axis, state in one line **which brand attribute / association / asset** the copy strengthens (keeps qualitative judgment concrete rather than vague).
|
|
||||||
|
|
||||||
### 5) Recursive Improvement — evolve the corpus
|
|
||||||
Per `recursive_protocol.md` (Recursive Improvement Protocol), propose feeding this session's adopted/rejected expressions back into the corpus. Demote once-effective phrases that have become common to the cliché list.
|
|
||||||
|
|
||||||
## Output format (produce the report in Korean, using this structure)
|
|
||||||
|
|
||||||
```
|
|
||||||
## 카피 트리밍 결과 — [대상/채널]
|
|
||||||
|
|
||||||
### 1) 진단
|
|
||||||
- "원문 문구" → 🔴/🟡/⚪/🟢/🟦 사유
|
|
||||||
|
|
||||||
### 2) 트리밍 (담백한 안)
|
|
||||||
[군더더기·위험 제거 버전]
|
|
||||||
|
|
||||||
### 3) 대안 (심의 한도 내 감각안)
|
|
||||||
| 요소 | 원문 | 문제 | 개선안 2~3개 | 근거 |
|
|
||||||
|
|
||||||
### 4) 재평가 (5축, 1~5점 · 심의=게이트)
|
|
||||||
| 축 | 원문 | 추천안 | 코멘트 |
|
|
||||||
| 감각 / 차별성 / 브랜드적합성 / 심의(P·F) / 명료성 |
|
|
||||||
→ 강화되는 브랜드 자산: [속성/연상 명시]
|
|
||||||
|
|
||||||
### 5) 추천안 + 이유
|
|
||||||
|
|
||||||
### 6) 준비 점검 사항
|
|
||||||
- [확인] 심의 필요 / 정보 부재 / 사실 검증 필요 항목을 여기 모아 정리
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep risk items and open questions out of the body; collect them at the end under **준비 점검 사항** (brevity principle).
|
|
||||||
|
|
||||||
## Reference files (read as needed)
|
|
||||||
|
|
||||||
- `references/corpus_compliance_risk.md` — medical-ad risk expressions + safe replacements (**most important**)
|
|
||||||
- `references/corpus_cliche.md` — stale/old expressions to avoid (avoidance coordinates)
|
|
||||||
- `references/corpus_effective.md` — working patterns (application coordinates; don't copy verbatim)
|
|
||||||
- `references/corpus_examples.md` — analyzed real-world exemplar copy (technique coordinates; no imitation)
|
|
||||||
- `references/witty_within_limits.md` — making copy trendy/catchy inside 심의 limits, and the boundaries
|
|
||||||
- `references/evaluation_rubric.md` — the 5-axis re-scoring rubric and gate rules
|
|
||||||
- `references/recursive_protocol.md` — Recursive Improvement Protocol and corpus tagging schema
|
|
||||||
|
|
||||||
## Reminders
|
|
||||||
|
|
||||||
- Compliance judgment here is guidance, not legal advice. Always leave a `[확인]` recommending pre-publication 의료광고 자율심의.
|
|
||||||
- For a specific brand, that brand's tone guide wins over this skill's defaults.
|
|
||||||
- The corpus is a living document — even effective phrases go stale. Refresh it periodically via `recursive_protocol.md`.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Cliché / Stale Expressions (🟡) — Avoidance Coordinates
|
|
||||||
|
|
||||||
> **This list is for avoiding, not copying.** Frequently used = already tired.
|
|
||||||
> If a listed expression shows up in the copy, consider replacing it. The "refresh direction" is a lead for finding an alternative, not a ready-made line.
|
|
||||||
|
|
||||||
## A. Beauty/result show-off (stale)
|
|
||||||
| Cliché (Korean) | Why it's old | Refresh direction |
|
|
||||||
|-----------------|--------------|-------------------|
|
|
||||||
| 여신, 인형 같은, 리즈 갱신, 인생 리즈 | Ranks looks, unrealistic, 10-years-ago tone | Replace with a concrete everyday-change scene |
|
|
||||||
| 물광, 꿀광, 동안 미모 | Spent beauty buzzwords | Use the customer's concern language, not glow words |
|
|
||||||
| 확 달라진, 몰라보게, 환골탈태 | Exaggeration; borders on 심의 risk | Restrain and specify the degree of change |
|
|
||||||
|
|
||||||
## B. Self-flattering filler (empty)
|
|
||||||
| Cliché | Why it's old | Refresh direction |
|
|
||||||
|--------|--------------|-------------------|
|
|
||||||
| 특별한 당신을 위한, 당신만의 | Said to everyone = says nothing | Name a truly specific concern/situation |
|
|
||||||
| 아름다움을 완성하다, 당신의 아름다움 | Abstract, boilerplate | Make it concrete with verbs/scenes |
|
|
||||||
| 프리미엄, 명품, 하이엔드 | Unbacked elevation words | Replace with facts (technique, track record) |
|
|
||||||
|
|
||||||
## C. Pressure/urgency (also inducement risk)
|
|
||||||
| Cliché | Why it's old | Refresh direction |
|
|
||||||
|--------|--------------|-------------------|
|
|
||||||
| 지금 바로, 서두르세요, 놓치지 마세요 | Pressure cliché, lowers trust | Informational CTA ("편하게 문의해 주세요") |
|
|
||||||
| 마감 임박, 선착순, 단 O명 | Inducement/pressure, 심의 risk | Keep any time-limited notice plain, strip inducement |
|
|
||||||
| 후회 없는 선택, 결심만 하세요 | Decision-coercion tone | Leave the decision with the customer |
|
|
||||||
|
|
||||||
## D. Region/class clichés
|
|
||||||
| Cliché | Why it's old | Refresh direction |
|
|
||||||
|--------|--------------|-------------------|
|
|
||||||
| 강남 언니, 청담동 스타일, 강남 미인 | Regional cliché, fatigue | Replace region with specific result/experience |
|
|
||||||
|
|
||||||
## E. Over-used "safe" words (the irony)
|
|
||||||
| Expression | Caution | Refresh direction |
|
|
||||||
|------------|---------|-------------------|
|
|
||||||
| 자연스러운 / 자연스럽게 | The whole industry overuses it → loses distinctiveness | Keep the concept, vary the expression (scene/metaphor). ※ If a brand owns it as a slogan, treat as 🟦 |
|
|
||||||
| 1:1 맞춤, 나만의 디자인 | Now a standard phrase | Make the "what" of the customization concrete |
|
|
||||||
|
|
||||||
## F. Form habits (hurt readability)
|
|
||||||
- Exclamation spam (!!!), emoji overload, question-mark spam → one breath per sentence, restrained emoji
|
|
||||||
- All-caps / all-bold → emphasize in one place only
|
|
||||||
|
|
||||||
---
|
|
||||||
*Update rule: when you spot a newly-stale expression, add it here per `recursive_protocol.md`, and demote over-common "effective" phrases into this file.*
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Compliance Risk Dictionary (🔴) + Safe Replacements
|
|
||||||
|
|
||||||
> Based on Korean medical law and 의료광고 심의 (medical-ad review) standards. **This is not legal advice** — recommend pre-publication self-regulatory review. If any 🔴 remains, the option FAILS the rubric gate.
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
1. Absolute / superlative expressions
|
|
||||||
2. Effect-guarantee / definitive expressions
|
|
||||||
3. Patient-inducement expressions
|
|
||||||
4. Comparative / exclusivity expressions
|
|
||||||
5. Testimonials / reviews / before-after photos
|
|
||||||
6. Neologisms / non-standard procedure names
|
|
||||||
7. Missing mandatory disclosures
|
|
||||||
8. Safe-replacement technique
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Absolute / superlative (prohibited)
|
|
||||||
Overstating or misleading beyond objective fact is prohibited.
|
|
||||||
- Risk terms: `100%`, `완벽`, `완전`, `전혀`, `모든`, `유일`, `최고`, `국내 1위`, `NO.1`, `확실히`, `반드시`, `무조건` (and English `best`, `only`, `perfect`)
|
|
||||||
- Replace with: "대부분의 경우", "개인에 따라 차이가 있습니다", "~를 기대할 수 있습니다"; use numbers only with a cited basis.
|
|
||||||
|
|
||||||
## 2. Effect-guarantee / definitive (prohibited / caution)
|
|
||||||
- Risk terms: `부작용 없는`, `안전한` (as an absolute), `반영구`, `평생`, `재발 없음`, `100% 자연스러움`, `반드시 예뻐지는`
|
|
||||||
- Replace with: "부작용은 극히 드뭅니다", "오래 유지되는 편입니다", "자연스러운 결과를 기대할 수 있습니다" + **attach a side-effect disclosure**.
|
|
||||||
|
|
||||||
## 3. Patient inducement (Medical Act Art. 27(3) risk)
|
|
||||||
Offering economic benefit to induce/solicit patients carries strong illegality risk.
|
|
||||||
- Risk terms: `무료`, `공짜`, `1+1`, `이벤트가`, `할인 이벤트`, `선착순 할인`, `○○원` (price front-loaded as a lure), `후기 작성 시 할인/적립/사은품`, `친구 소개하면`
|
|
||||||
- Replace with: quote price only as "상담 시 안내"; keep any discount **decoupled** from reviews/referrals as a plain time-limited notice; request reviews only voluntarily with no compensation.
|
|
||||||
- Note: even phrasing like "얼마일까" has been flagged as inducement.
|
|
||||||
|
|
||||||
## 4. Comparative / exclusivity (prohibited)
|
|
||||||
- Risk terms: `타 병원보다`, `다른 곳과 다르게` (disparaging tone), `국내 유일`, `업계 최초` (unverified), any direct/indirect disparagement of competitors
|
|
||||||
- Replace with: "저희만의 방법으로"; state own strengths factually (uniqueness instead of comparison).
|
|
||||||
|
|
||||||
## 5. Testimonials / reviews / before-after photos (review-subject, caution)
|
|
||||||
- Risk: using patient reviews or before/after photos in ads; implying effect via testimonial
|
|
||||||
- Handle: advertising use is subject to 심의; before/after must meet rules (same conditions, no misleading) → `[확인]` for review.
|
|
||||||
|
|
||||||
## 6. Neologisms / non-standard procedure names (caution)
|
|
||||||
Neologisms and gimmicky phrasing are a top cause of 심의 rejection.
|
|
||||||
- Risk: unverified proprietary procedure names, trendy slang, jargon
|
|
||||||
- Replace with: standard procedure names first; pair a brand procedure name with its standard name.
|
|
||||||
|
|
||||||
## 7. Missing mandatory disclosures (formal requirement)
|
|
||||||
- Required: **advertiser (clinic name)**, **side-effect / caution disclosure**, and on large online platforms the **review-approval number and validity period**
|
|
||||||
- Missing these risks unreviewed-ad / labeling violations → confirm with a checklist.
|
|
||||||
|
|
||||||
## 8. Safe-replacement technique
|
|
||||||
- Absolute → qualified ("대부분", "개인차")
|
|
||||||
- Guarantee → expectation ("기대할 수 있습니다")
|
|
||||||
- Inducement → information ("상담 시 안내")
|
|
||||||
- Comparison → uniqueness ("저희만의")
|
|
||||||
- Always pair with a side-effect disclosure and the advertiser name.
|
|
||||||
|
|
||||||
---
|
|
||||||
*Sources: 대한의사협회 의료광고 심의기준; 의료광고 심의 금지 문구 안내; 강남언니 광고 가이드; related legal columns. Confirm the latest standard with the self-regulatory body.*
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Effective Patterns (🟢) — Application Coordinates
|
|
||||||
|
|
||||||
> **These are patterns, not finished lines.** Adapt the structure to the situation; pasting an example verbatim quickly turns it into a cliché.
|
|
||||||
|
|
||||||
## 1. Customer's own words (mirror the concern)
|
|
||||||
Naming the concern in the words a customer actually uses beats abstract praise.
|
|
||||||
- Pattern: [specific moment] + [discomfort]
|
|
||||||
- Ex: "웃을 때 잡히는 이마 주름", "눈뜨는 게 무거운 아침", "화장이 자꾸 접히는 눈가"
|
|
||||||
- Why: people who recognize themselves stop and read.
|
|
||||||
|
|
||||||
## 2. Scene & verb driven (restrain adjectives)
|
|
||||||
A scene or verb that reveals the result beats an adjective like "아름다운."
|
|
||||||
- Ex: "거울 앞에 서는 시간이 짧아졌다", "사진 찍을 때 앞줄에 선다"
|
|
||||||
- Why: show, don't tell.
|
|
||||||
|
|
||||||
## 3. Restrained numbers & facts (trust)
|
|
||||||
Verifiable facts are 심의-safe and persuasive, unlike exaggeration.
|
|
||||||
- Ex: "2008년부터", "회복 3일", "제가 직접 집도합니다"
|
|
||||||
- Caution: do not slide into superlatives/guarantees.
|
|
||||||
|
|
||||||
## 4. Honesty/humility as differentiation
|
|
||||||
When the category is full of hype, candor stands out.
|
|
||||||
- Ex: "안 되는 것도 말씀드립니다", "개선에 한계가 있을 수 있습니다"
|
|
||||||
- Why: trust is the hook.
|
|
||||||
|
|
||||||
## 5. Question hook (not misleading)
|
|
||||||
- Ex: "표정 주름, 한 번으로 될까요?", "왜 4개월마다일까요?"
|
|
||||||
- Caution: 심의 risk if the answer becomes an exaggeration/guarantee.
|
|
||||||
|
|
||||||
## 6. Rhythm / parallelism
|
|
||||||
- Ex (brand-asset example): "티 안 나게 수술하고, 티 나게 예뻐지는" — parallel/rhyme sticks in memory.
|
|
||||||
- Why: it must read cleanly aloud, with no stumble.
|
|
||||||
|
|
||||||
## 7. Channel-fit tone
|
|
||||||
- 카플친 / 알림톡: warm, thank-you tone, short
|
|
||||||
- Instagram: first-line hook, sensory and concise
|
|
||||||
- Blog / homepage: educational, evidence-led
|
|
||||||
- In-clinic POP: one line understood at a glance + a support line
|
|
||||||
|
|
||||||
---
|
|
||||||
*These coordinates only tell you which direction to aim. The actual line must be forged fresh each time.*
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# Analyzed Exemplar Copy — Technique Coordinates (No Imitation)
|
|
||||||
|
|
||||||
> A distilled bank of **real** Korean (and a few global) plastic-surgery / aesthetic copy examples, curated from web research and filtered by this skill's rubric (fresh · compliance-safe · catchy).
|
|
||||||
> **Use these to learn the technique and then write something new.** Do not paste them into client copy — several are brand-owned, and copying defeats differentiation.
|
|
||||||
> Each entry: the line · the technique to steal · a compliance note.
|
|
||||||
|
|
||||||
## How to read this file
|
|
||||||
Organized by **technique** (not by clinic), because the transferable asset is the technique. Compliance notes flag where a line would be risky if used directly in a Korean medical ad (superlatives, effect-guarantees, inducement, comparison). "🌐" marks a non-Korean example kept for inspiration only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Metaphor + brand-name fusion
|
|
||||||
Fuse the brand name with a metaphor so the name itself carries meaning. Highest-value, low-risk technique.
|
|
||||||
- **"예쁨이 자란다, 나무성형외과"** — brand name (나무/tree) + growth metaphor + rhythm. Compliance: safe (metaphor, no guarantee).
|
|
||||||
- **"I am Detailist" (바노바기)** — first-person identity claim ("detail = us"); differentiates on *attitude*, not effect. Safe.
|
|
||||||
- **"진화하는 피부질환, 연구하는 피부주치의" (차앤박)** — clean parallelism + "주치의 (personal doctor)" metaphor = expertise + intimacy. Safe.
|
|
||||||
- **"REWRITE YOUR STORY" (리쥬란)** — regeneration reframed as authoring your own story; strong emotion, no effect claim. Safe.
|
|
||||||
- **Steal this:** let the brand/procedure name do double duty; anchor an abstract benefit to a concrete metaphor.
|
|
||||||
|
|
||||||
## 2. Contrarian reframe (flip the category default)
|
|
||||||
Turn an industry assumption on its head.
|
|
||||||
- **"Slow Banobagi / 느린 만큼 더 안전한 성형" (바노바기)** — flips "fast & flashy"; the slowness *is* the trust. Compliance: safe (but "더" leans comparative — keep it about self, not others).
|
|
||||||
- **"줄였다는 느낌 말고, 맞춘 느낌!" (아이디병원, 콧볼)** — "not A, but B" contrast + rhythm; conveys philosophy without superlatives. Relatively safe.
|
|
||||||
- **"누군가를 위해 예뻐지지 않아" (낫포유)** — value flip toward self-determination; fresh MZ tone. Safe.
|
|
||||||
- **Steal this:** name the default the category shouts, then stake the opposite ground.
|
|
||||||
|
|
||||||
## 3. Everyday scene / customer language
|
|
||||||
Make the need self-evident with a concrete life moment in the customer's own words.
|
|
||||||
- **"오늘부로 보정 어플 삭제" (아이디병원, 윤곽)** — everyday detail (photo-retouch app) implies the result. Compliance: caution — it edges toward a result claim; soften.
|
|
||||||
- **"무더운 여름에 반팔티 한장만 입자" (아이디병원, 여유증)** — season + scene generate the need naturally. Relatively safe.
|
|
||||||
- **"심술보, 불독살, 처진볼살" (아이디병원, 중안부)** — lists the concern in exact customer words. Relatively safe (avoid shaming register).
|
|
||||||
- **Steal this:** open on the mirror/photo/clothing moment where the concern actually bites.
|
|
||||||
|
|
||||||
## 4. Subjecthood / self-determination
|
|
||||||
Make the customer the subject; the clinic is the helper.
|
|
||||||
- **"예쁘게 나답게" (AB성형외과)** — parallel + self-acceptance. Safe.
|
|
||||||
- 🌐 **"You... redefined." (Centra)** — ellipsis whitespace + "redefined" in one word; ultra-concise, adapts well to Korean. Safe.
|
|
||||||
- 🌐 **"Own Your Look" (BOTOX/Allergan)** — imperative agency. Safe in spirit.
|
|
||||||
- **Steal this:** put 당신/나 as the grammatical subject; frame surgery as the customer's decision, not the clinic's promise.
|
|
||||||
|
|
||||||
## 5. Indirect emotion (result → feeling; dodges effect-claims)
|
|
||||||
Point at how life feels afterward, not at the physical result — the safest way to be moving.
|
|
||||||
- **"세상이 나에게 친절해졌다" (본 아이템)** — change framed as how the world *treats* you. Safe, long resonance.
|
|
||||||
- **"당신의 피부에 자신감을" (RNME, 슈링크)** — abstract value (confidence). Safe.
|
|
||||||
- 🌐 **"Recapture the beauty of self-confidence."** — beauty = confidence; avoids appearance claims, matches Korea's 2026 trust-first shift. Safe.
|
|
||||||
- **Steal this:** shift the object from the face to the feeling/relationship; this both moves people and clears 심의.
|
|
||||||
|
|
||||||
## 6. Wordplay / rhyme mnemonic
|
|
||||||
Sound-based memorability tied to the brand.
|
|
||||||
- **"예쁘면 DA야! / 잘생기면 DA야!" (디에이)** — brand name + interjection pun, gendered variants for reach. Relatively safe (rhyme-led).
|
|
||||||
- **"당신의 뷰티메이트" (Beauty+Medical+Mate, 리앤영)** — coined word compresses a "companion" position. Safe.
|
|
||||||
- **Steal this:** find the pun that lives inside the brand name; make it repeatable.
|
|
||||||
|
|
||||||
## 7. Location / positioning anchor
|
|
||||||
Compress positioning into a place or association.
|
|
||||||
- **"신사역에 있는 쥬얼리 / 가슴 성형을 잘 하는 쥬얼리" (쥬얼리)** — place anchor + drives associated search. Compliance: caution — "잘 하는" implies superiority; soften.
|
|
||||||
- **Steal this:** anchor to a place/association the customer already navigates by.
|
|
||||||
|
|
||||||
## 8. Question hook / curiosity
|
|
||||||
Open a loop the reader wants closed.
|
|
||||||
- **"한 장의 시트가 피부를 얼마나 바꿀 수 있을까" (더우주)** — curiosity question, easy to transplant to procedure content openers. Safe.
|
|
||||||
- **Steal this:** ask the exact question the hesitant customer is already asking — but don't answer it with a guarantee.
|
|
||||||
|
|
||||||
## 9. Contrast / triad structure
|
|
||||||
Structural rhythm carries the message.
|
|
||||||
- 🌐 **"Look Better. Breathe Better. Sleep Better."** — triad; sells *function* (e.g., rhinoplasty) — functional benefit is comparatively 심의-safe. Safe.
|
|
||||||
- **"다시, 원인을 정확히 분석하다" (아이디병원, 재수술)** — process/trust framing for anxious re-op customers; fits the 2026 trend. Safe.
|
|
||||||
- **Steal this:** triads and "process over promise" reassure without claiming results.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## AVOID cluster — frequent clichés that overlap with 심의 risk
|
|
||||||
Seen repeatedly in the wild; low distinctiveness and usually risky. Details in `corpus_cliche.md` / `corpus_compliance_risk.md`.
|
|
||||||
- Price/inducement: 특가 · 파격 · 초특가 · "최대 OO% 할인" · 선착순 O명 · 후기 작성 시 할인
|
|
||||||
- Superlative/exclusivity: 최고 · 1위 · 유일 · 국내최초 · (EN) best / only / perfect
|
|
||||||
- Effect-guarantee: 예뻐진다(단정) · 흉터/부작용 없는 · 책임진료
|
|
||||||
- Shaming/objectifying: 넙데데 · 코끼리 다리 · "SIZE MATTERS" (전형적 성상품화 논란)
|
|
||||||
- Tired beauty words: 여신 · 리즈 갱신 · 인형 같은
|
|
||||||
|
|
||||||
## Market context (why the above matters)
|
|
||||||
Korean aesthetic marketing is shifting from **price competition → trust competition**: event/discount lines are shrinking, and story-led, indirect emotional appeals ("변화보다 자신감") both pass 심의 and perform better. English-market lines built on `best / only / results / perfect` should **not** be translated literally — they become superlative/effect-guarantee risks in Korea.
|
|
||||||
|
|
||||||
## Distilled principle
|
|
||||||
The safest *and* catchiest formula = **(a) metaphor/brand-name fusion + (b) subjecthood framing + (c) process/emotion instead of result claims** — and never superlatives, discount inducement, or comparison.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sources (traceability)
|
|
||||||
- 바노바기 공식 — https://www.banobagi.com/page/sub07_00
|
|
||||||
- 쥬얼리성형외과 인터뷰(채널톡) — https://channel.io/ko/blog/articles/cs-case-jewerly-e75ca530
|
|
||||||
- 리쥬란(나무위키) — https://namu.wiki/w/%EB%A6%AC%EC%A5%AC%EB%9E%80
|
|
||||||
- 차앤박(CNP) 브랜드스토리 — https://www.cnpskin.com/pc/cnp/about-us/brand-story.html
|
|
||||||
- 나무성형외과 공모전(위비티) — https://www.wevity.com/index_university.php?c=find&s=_university&gbn=viewok&gp=71&ix=55321
|
|
||||||
- 아이디병원 프로모션 — https://www.idhospital.com/promotion/onsale
|
|
||||||
- 디에이성형외과 — https://daprs.com/board/event/list
|
|
||||||
- 카피 모음(채널톡) — https://channel.io/ko/blog/articles/copy222-ffa64ebe
|
|
||||||
- 성형외과 광고 인사이트(신뢰 중심 전환, AMPM) — https://inside.ampm.co.kr/insight/13055
|
|
||||||
- 강남언니 광고 가이드 — https://blog.gangnamunni.com/post/ads-guide
|
|
||||||
- 글로벌 클리닉 슬로건 DB — http://www.textart.ru/advertising/slogans/plastic-surgery.html
|
|
||||||
- BOTOX "The One & Only" (AbbVie) — https://news.abbvie.com/2025-09-09-BOTOX-R-Cosmetic-onabotulinumtoxinA-Unveils-The-One-Only-Campaign
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 5-Axis Re-scoring Rubric
|
|
||||||
|
|
||||||
Score the original and the improved copy on the same basis to make "did it actually get better?" objective.
|
|
||||||
|
|
||||||
## Axes and scale (1–5 each; compliance is a gate)
|
|
||||||
|
|
||||||
| Axis | Question | 1 | 5 |
|
|
||||||
|------|----------|---|---|
|
|
||||||
| **Freshness / Hook** | Non-stale and eye-catching? | tired, boilerplate | makes you stop and read |
|
|
||||||
| **Distinctiveness** | Avoids the clichés everyone uses? | seen-it-before | only this clinic |
|
|
||||||
| **Brand fit** | Matches tone & brand assets? | off-tone | reinforces brand-ness |
|
|
||||||
| **Compliance (심의)** | Free of risk expressions? | — | — (PASS/FAIL gate) |
|
|
||||||
| **Clarity** | Understood instantly? | what does it say | one-pass clear |
|
|
||||||
|
|
||||||
## Compliance gate rule
|
|
||||||
- If any 🔴 (risk expression) exists → **FAIL** → that option cannot be adopted, no matter how high the other scores.
|
|
||||||
- Missing mandatory disclosure (advertiser / side-effects / review number) is also a FAIL → then `[확인]`.
|
|
||||||
|
|
||||||
## Brand-asset line (required)
|
|
||||||
When scoring brand fit, avoid stopping at a vague "feel." Write one line:
|
|
||||||
> Brand **attribute / association / asset** this copy strengthens: (e.g., a "naturalness" association, an "honest expert" attribute, a slogan asset)
|
|
||||||
|
|
||||||
This anchors qualitative judgment in a measurable direction instead of vague sentiment.
|
|
||||||
|
|
||||||
## Judgment / improvement priority
|
|
||||||
1. Compliance gate first (fix immediately if FAIL)
|
|
||||||
2. Clarity (if it doesn't read, appeal is moot)
|
|
||||||
3. Distinctiveness & freshness (remove clichés → strengthen hook)
|
|
||||||
4. Brand-fit fine-tuning
|
|
||||||
|
|
||||||
## Scoring example
|
|
||||||
```
|
|
||||||
| Axis | Original | Recommended |
|
|
||||||
| Freshness | 2 | 4 |
|
|
||||||
| Distinctiveness | 2 | 4 |
|
|
||||||
| Brand fit | 3 | 5 |
|
|
||||||
| Compliance | PASS | PASS |
|
|
||||||
| Clarity | 4 | 4 |
|
|
||||||
→ Strengthened asset: "표정 습관" reframe reinforces the "honest educator" attribute
|
|
||||||
```
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Recursive Improvement Protocol — Evolving the Corpus
|
|
||||||
|
|
||||||
This skill's taste standard is not fixed; it **learns every campaign**. That keeps pace with staleness and keeps the differentiation coordinates current.
|
|
||||||
|
|
||||||
## After each trimming session
|
|
||||||
1. **Adopted expressions** → if a newly-working pattern, add to `corpus_effective.md` (generalize the structure/principle; do not store the whole line).
|
|
||||||
2. **Rejected / trimmed expressions** → if stale, add to `corpus_cliche.md`; if a compliance risk, add to `corpus_compliance_risk.md`.
|
|
||||||
3. **Demotion**: move once-effective but now-common expressions from `corpus_effective` → `corpus_cliche`.
|
|
||||||
|
|
||||||
## Tagging schema (record on each addition)
|
|
||||||
| Field | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| expression | expression/pattern (prefer a generalized form over a single line) |
|
|
||||||
| class | effective / cliché / risk |
|
|
||||||
| reason | why this class (one line) |
|
|
||||||
| channel | channel it was mainly used in |
|
|
||||||
| date | added/updated date |
|
|
||||||
| source | campaign / client source |
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```
|
|
||||||
| "headline it with the customer's own concern" | effective | instant empathy, stops the scroll | Instagram/blog | 2026-07 | Jamie 표정케어 |
|
|
||||||
| "인생 리즈 갱신" | cliché | 10-yr-old beauty buzzword, no distinctiveness | all | 2026-07 | — |
|
|
||||||
```
|
|
||||||
|
|
||||||
## Periodic cleanup (quarterly recommended)
|
|
||||||
- Merge duplicates, delete dead entries
|
|
||||||
- If `corpus_effective` grows too large, review candidates for demotion
|
|
||||||
- Reflect changes in 심의 standards (check the self-regulatory body's notices → `[확인]`)
|
|
||||||
|
|
||||||
## Principle recap
|
|
||||||
- The corpus is for **avoidance / coordinates, not imitation**. Even `effective` entries mean "borrow the structure, write anew," not "reuse."
|
|
||||||
- Always confirm currency of compliance items. Judgments here are guidance, not legal advice.
|
|
||||||
|
|
||||||
## Optional user feedback loop
|
|
||||||
After a campaign ends, if the user shares which copy performed well/poorly (reactions, conversions, reviews), use that signal to re-tag effective/cliché. Performance data is the corpus's final arbiter.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Making Copy Trendy / Catchy Within 심의 Limits
|
|
||||||
|
|
||||||
> Premise: **the ceiling on wit is set by 의료광고 심의.** If the fun relies on exaggeration, superlatives, inducement, or comparison, it is void.
|
|
||||||
> Goal: restrained appeal that still catches the eye inside the rules.
|
|
||||||
|
|
||||||
## Six techniques that work
|
|
||||||
|
|
||||||
### 1. Win with specificity
|
|
||||||
Drop abstractions ("아름다움") for a concrete scene or object — the concreteness itself feels fresh.
|
|
||||||
- Flat: "자신감을 드립니다" → Sharp: "거울 보는 시간이 즐거워집니다"
|
|
||||||
|
|
||||||
### 2. Borrow the customer's words
|
|
||||||
Use the exact phrase the target types into a search box or community post as the headline.
|
|
||||||
- Ex: "표정 관리, 매번 큰맘 먹지 않아도"
|
|
||||||
|
|
||||||
### 3. Unexpected frame (reframe/twist)
|
|
||||||
Bend a familiar idea slightly, without creating a misunderstanding.
|
|
||||||
- Ex: "주름은 나이가 아니라 표정 습관" (educational reframe)
|
|
||||||
|
|
||||||
### 4. Rhythm / parallelism / rhyme
|
|
||||||
Make it stick through the pleasure of sound: parallel structure, triads, alliteration.
|
|
||||||
- Caution: never sacrifice meaning for rhyme.
|
|
||||||
|
|
||||||
### 5. Restrained humor (keep dignity)
|
|
||||||
Light but not cheap; never at the cost of the clinic's trust.
|
|
||||||
- Safe: situational empathy humor ("월요일 아침 눈꺼풀처럼 무거운")
|
|
||||||
- Risky: appearance-shaming, anxiety-baiting, self-deprecation
|
|
||||||
|
|
||||||
### 6. Whitespace and brevity
|
|
||||||
Don't say everything. Stop at one line and leave the rest to consultation.
|
|
||||||
|
|
||||||
## Boundaries — wit that does NOT work
|
|
||||||
- Appearance-shaming / anxiety-baiting ("이대로 괜찮으세요?" pressure)
|
|
||||||
- Superlatives dressed as humor ("완벽 변신 실화")
|
|
||||||
- Inducement-as-fun ("친구 데려오면 개이득") → 심의 / inducement violation
|
|
||||||
- Neologism / meme overuse → ages fast and is a top 심의-rejection cause
|
|
||||||
- Dignity-damaging or provocative gags
|
|
||||||
|
|
||||||
## Wit intensity by channel
|
|
||||||
| Channel | Wit allowance | Note |
|
|
||||||
|---------|---------------|------|
|
|
||||||
| Instagram | High (still 심의) | Focus on the first-line hook |
|
|
||||||
| Blog / homepage | Medium | Educational tone first; wit at the subhead level |
|
|
||||||
| 카플친 / 알림톡 | Low | Warm, thank-you tone; no heavy gags |
|
|
||||||
| In-clinic POP | Medium | One catchy line, understood instantly |
|
|
||||||
| Search ad | Low | Clarity and compliance first |
|
|
||||||
|
|
||||||
## Self-check questions
|
|
||||||
- Does this joke create a misunderstanding (effect / safety)?
|
|
||||||
- Is any inducement (economic-benefit emphasis) mixed in?
|
|
||||||
- Will it still be fresh in 6 months (not meme-dependent)?
|
|
||||||
- Does it read cleanly aloud, with no stumble?
|
|
||||||
@@ -4,7 +4,7 @@ version: 1.2.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "70"
|
agent-id: "70"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
description: Brand Guardian for D.intelligence (디인텔리전스). Reviews all D.intelligence documents, proposals, reports, blog posts, AI-generated content, presentations, and marketing materials for brand compliance. Checks tone & manner, message framework, service architecture accuracy, prohibited expressions, and AI/LLM output standards. Use this skill whenever creating or reviewing D.intelligence content — triggers include "D.intelligence", "디인텔리전스", "brand review", "brand check", "톤앤매너 검토", "브랜드 검토", "제안서 검토", "리포트 검토", "콘텐츠 검토", any mention of service modules (A1-A6, T1-T7, G1-G4), service categories (DI, MD, MPO, BVT), or the tagline "Analysis, Treatment & Growth". Also use when generating proposals, reports, blog posts, case studies, newsletter content, or any client-facing material for D.intelligence.
|
description: Brand Guardian for D.intelligence (디인텔리전스). Reviews all D.intelligence documents, proposals, reports, blog posts, AI-generated content, presentations, and marketing materials for brand compliance. Checks tone & manner, message framework, service architecture accuracy, prohibited expressions, and AI/LLM output standards. Use this skill whenever creating or reviewing D.intelligence content — triggers include "D.intelligence", "디인텔리전스", "brand review", "brand check", "톤앤매너 검토", "브랜드 검토", "제안서 검토", "리포트 검토", "콘텐츠 검토", any mention of service modules (A1-A6, T1-T7, G1-G4), service categories (DI, MD, MPO, BVT), or the tagline "Analysis, Treatment & Growth". Also use when generating proposals, reports, blog posts, case studies, newsletter content, or any client-facing material for D.intelligence.
|
||||||
autonomy: auto
|
autonomy: auto
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.2.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "70"
|
agent-id: "70"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
description: Brand Guardian for D.intelligence (디인텔리전스). Reviews all D.intelligence documents, proposals, reports, blog posts, AI-generated content, presentations, and marketing materials for brand compliance. Checks tone & manner, message framework, service architecture accuracy, prohibited expressions, and AI/LLM output standards. Use this skill whenever creating or reviewing D.intelligence content — triggers include "D.intelligence", "디인텔리전스", "brand review", "brand check", "톤앤매너 검토", "브랜드 검토", "제안서 검토", "리포트 검토", "콘텐츠 검토", any mention of service modules (A1-A6, T1-T7, G1-G4), service categories (DI, MD, MPO, BVT), or the tagline "Analysis, Treatment & Growth". Also use when generating proposals, reports, blog posts, case studies, newsletter content, or any client-facing material for D.intelligence.
|
description: Brand Guardian for D.intelligence (디인텔리전스). Reviews all D.intelligence documents, proposals, reports, blog posts, AI-generated content, presentations, and marketing materials for brand compliance. Checks tone & manner, message framework, service architecture accuracy, prohibited expressions, and AI/LLM output standards. Use this skill whenever creating or reviewing D.intelligence content — triggers include "D.intelligence", "디인텔리전스", "brand review", "brand check", "톤앤매너 검토", "브랜드 검토", "제안서 검토", "리포트 검토", "콘텐츠 검토", any mention of service modules (A1-A6, T1-T7, G1-G4), service categories (DI, MD, MPO, BVT), or the tagline "Analysis, Treatment & Growth". Also use when generating proposals, reports, blog posts, case studies, newsletter content, or any client-facing material for D.intelligence.
|
||||||
autonomy: auto
|
autonomy: auto
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ version: 1.2.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "71"
|
agent-id: "71"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
autonomy: auto
|
autonomy: auto
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ version: 1.2.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "71"
|
agent-id: "71"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
autonomy: auto
|
autonomy: auto
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "72"
|
agent-id: "72"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
autonomy: draft-and-wait
|
autonomy: draft-and-wait
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "72"
|
agent-id: "72"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
autonomy: draft-and-wait
|
autonomy: draft-and-wait
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "73"
|
agent-id: "73"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Quotation Manager for D.intelligence. Generates professional quotations
|
Quotation Manager for D.intelligence. Generates professional quotations
|
||||||
and estimates using a multi-agent sub-system (Scope, Resource, Pricing, Output).
|
and estimates using a multi-agent sub-system (Scope, Resource, Pricing, Output).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "73"
|
agent-id: "73"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Quotation Manager for D.intelligence. Generates professional quotations
|
Quotation Manager for D.intelligence. Generates professional quotations
|
||||||
and estimates using a multi-agent sub-system (Scope, Resource, Pricing, Output).
|
and estimates using a multi-agent sub-system (Scope, Resource, Pricing, Output).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "74"
|
agent-id: "74"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Service Architect for D.intelligence. Designs service scope and recommends
|
Service Architect for D.intelligence. Designs service scope and recommends
|
||||||
optimal module combinations through structured inquiry.
|
optimal module combinations through structured inquiry.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "74"
|
agent-id: "74"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Service Architect for D.intelligence. Designs service scope and recommends
|
Service Architect for D.intelligence. Designs service scope and recommends
|
||||||
optimal module combinations through structured inquiry.
|
optimal module combinations through structured inquiry.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "75"
|
agent-id: "75"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
autonomy: draft-and-wait
|
autonomy: draft-and-wait
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "75"
|
agent-id: "75"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
autonomy: draft-and-wait
|
autonomy: draft-and-wait
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "76"
|
agent-id: "76"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Back Office & HR Manager for D.intelligence. Handles invoicing, contracts,
|
Back Office & HR Manager for D.intelligence. Handles invoicing, contracts,
|
||||||
NDA, employment contracts, billing, HR operations, and compliance.
|
NDA, employment contracts, billing, HR operations, and compliance.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "76"
|
agent-id: "76"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Back Office & HR Manager for D.intelligence. Handles invoicing, contracts,
|
Back Office & HR Manager for D.intelligence. Handles invoicing, contracts,
|
||||||
NDA, employment contracts, billing, HR operations, and compliance.
|
NDA, employment contracts, billing, HR operations, and compliance.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "77"
|
agent-id: "77"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Account Manager for D.intelligence. Andrew's copilot for client relationship
|
Account Manager for D.intelligence. Andrew's copilot for client relationship
|
||||||
management — project monitoring, meeting prep, status reports, issue escalation.
|
management — project monitoring, meeting prep, status reports, issue escalation.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "77"
|
agent-id: "77"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Account Manager for D.intelligence. Andrew's copilot for client relationship
|
Account Manager for D.intelligence. Andrew's copilot for client relationship
|
||||||
management — project monitoring, meeting prep, status reports, issue escalation.
|
management — project monitoring, meeting prep, status reports, issue escalation.
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
# 78 — D.intelligence Campaign Designer
|
|
||||||
|
|
||||||
**Agent #78** in the [D.intelligence Agent Corps](../_dintel-shared/README.md).
|
|
||||||
Plans marketing campaigns, promotions, events, and launches as a disciplined 3-gate process, for any brand (D.intelligence, OurDigital, Jamie, or a client).
|
|
||||||
|
|
||||||
## What It Does
|
|
||||||
|
|
||||||
1. **Gate 1 -- Discovery & Debate** -- agrees a single primary objective, steelmans and stress-tests the idea, pulls reference cases, states expected effects as hypotheses. Produces a 1-page Decision Log.
|
|
||||||
2. **Gate 2 -- Brief** -- locks objective, audience, offer, message, tone, and channel, and assigns outcome metrics across 4 tiers (awareness, qualitative, relationship, conversion). Produces a 1-page Campaign Brief.
|
|
||||||
3. **Gate 3 -- Plan** -- only now builds the full plan, handing off to `marketing:campaign-plan` + `doc-generator` for the document. Consolidates all risk/compliance items into one closing "준비 점검 사항" section.
|
|
||||||
|
|
||||||
Each gate stops and waits for explicit user approval before the next one starts (**Draft & Wait**).
|
|
||||||
|
|
||||||
## Agent Corps Context
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| Agent # | 78 |
|
|
||||||
| Skill Name | `dintel-campaign-designer` |
|
|
||||||
| Version | 1.0.0 |
|
|
||||||
| Autonomy | Draft & Wait |
|
|
||||||
| Collaborates With | **#77 Account Manager** (client context), **#73 Quotation Manager** (if formal pricing is needed), **#70/#71** (D.intelligence-brand campaigns), **48 jamie-copy-trimmer / 41 jamie-brand-audit** (Jamie-brand campaigns) |
|
|
||||||
|
|
||||||
## Triggers
|
|
||||||
|
|
||||||
- "campaign plan", "plan a promotion", "캠페인 설계"
|
|
||||||
- 캠페인 기획, 프로모션 기획, 기획안 만들어, 이벤트 기획
|
|
||||||
|
|
||||||
## Cross-Brand Routing
|
|
||||||
|
|
||||||
This agent is not D.intelligence-exclusive -- the gates are brand-agnostic. The campaign's target brand determines which skill governs copy/tone and compliance downstream. See the routing table in `SKILL.md`.
|
|
||||||
|
|
||||||
## Universal Guardrails
|
|
||||||
|
|
||||||
1. **Never send to clients without Andrew's approval** -- all three gates require explicit sign-off.
|
|
||||||
2. **Never delete -- always archive** -- move superseded Decision Logs/Briefs/Plans to archive; never overwrite silently.
|
|
||||||
3. **Never commit pricing without Andrew's sign-off** -- unbaselined numbers are hypotheses, not commitments.
|
|
||||||
4. **Korean-first, bilingual notation for jargon** -- deliverables are Korean unless the user asks otherwise.
|
|
||||||
5. **Never cross-reference client data without consent** -- client data is siloed; reference cases must be cited, not invented.
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
78-dintel-campaign-designer/
|
|
||||||
├── SKILL.md # Canonical directive (root, loadable)
|
|
||||||
├── code/
|
|
||||||
│ └── CLAUDE.md # Claude Code pointer + quick reference
|
|
||||||
├── desktop/
|
|
||||||
│ └── SKILL.md # Claude Desktop mirror of the root directive
|
|
||||||
├── shared/
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ ├── gate1-decision-log.md
|
|
||||||
│ │ ├── gate2-campaign-brief.md
|
|
||||||
│ │ └── gate3-plan-outline.md
|
|
||||||
│ └── references/
|
|
||||||
│ └── debate-and-outcomes.md # Facilitation prompts + 4-tier outcome framework
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
---
|
|
||||||
name: dintel-campaign-designer
|
|
||||||
version: 1.0.0
|
|
||||||
last_updated: 2026-07-01
|
|
||||||
canon_compliance: v1.3
|
|
||||||
agent-id: "78"
|
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
|
||||||
description: |
|
|
||||||
Campaign Designer for D.intelligence. Plans marketing campaigns, promotions,
|
|
||||||
events, and launches as a 3-stage gate process -- Discovery & Debate -> Brief
|
|
||||||
-> Plan -- instead of jumping straight to a finished document. Use whenever
|
|
||||||
the user wants to plan a campaign, promotion, event, launch, or 기획안; start
|
|
||||||
here first, for any brand (D.intelligence, OurDigital, Jamie, or a client).
|
|
||||||
Triggers: 캠페인 기획, 프로모션 기획, 기획안 만들어, 이벤트 기획, campaign plan,
|
|
||||||
plan a promotion, 캠페인 설계. Produces a 1-page Decision Log (Gate 1) and
|
|
||||||
1-page Brief (Gate 2), each needing explicit user approval, before the full
|
|
||||||
plan (Gate 3).
|
|
||||||
autonomy: draft-and-wait
|
|
||||||
---
|
|
||||||
|
|
||||||
# D.intelligence Campaign Designer
|
|
||||||
|
|
||||||
> Agent #78 | `dintel-campaign-designer` v1.0.0 | D.intelligence Agent Corps
|
|
||||||
|
|
||||||
A disciplined way to plan campaigns: **debate and agree direction before producing any document.** Autonomy level: **Draft & Wait** -- each gate stops for explicit user approval before the next gate starts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ v1.3 정합성 — 단일 진실 (Single Source of Truth)
|
|
||||||
|
|
||||||
> **갱신일**: 2026-07-01 (v1.3 정합 적용) | **기준**: `knowledge-base/canon/` v1.0 + BRAND-GUIDE v1.3
|
|
||||||
|
|
||||||
**참조 의무 1순위** (충돌 시 canon이 우선) — *when the campaign is FOR D.intelligence itself*:
|
|
||||||
|
|
||||||
| Canon 문서 | 사용 시점 |
|
|
||||||
|-----------|---------|
|
|
||||||
| `knowledge-base/canon/brand-canon.md` v1.0 | 캠페인 톤 & 메시지 프레임 |
|
|
||||||
| `knowledge-base/canon/fact-sheet.md` v1.0 | 법인·연락처 표기 (기획서 표지/각주) |
|
|
||||||
| `knowledge-base/canon/service-architecture.md` v1.0 | 캠페인이 특정 모듈/패키지를 프로모션할 경우 |
|
|
||||||
| `knowledge-base/canon/naming-conventions.md` v1.0 | 산출물 파일명 |
|
|
||||||
| `knowledge-base/gotcha/01_outdated-facts.md` | 회피 대상 (주소·이메일·CEO 직함) |
|
|
||||||
|
|
||||||
### Cross-brand routing (this agent is NOT D.intelligence-exclusive)
|
|
||||||
|
|
||||||
Unlike most Agent Corps members, Campaign Designer runs the same 3-gate process regardless of which brand the campaign is for. The brand determines which skill governs tone/copy/compliance downstream -- confirm the target brand at Gate 1 if it isn't obvious:
|
|
||||||
|
|
||||||
| Campaign is for | Copy & tone | Compliance / brand review |
|
|
||||||
|------------------|-------------|---------------------------|
|
|
||||||
| D.intelligence | `dintel-brand-editor` (#71) | `dintel-brand-guardian` (#70) |
|
|
||||||
| Jamie Clinic | `jamie-copy-trimmer` (48) | `jamie-brand-audit` / "jamie-brand-guardian" (41) |
|
|
||||||
| OurDigital | `ourdigital-ad-manager` (07) | `ourdigital-brand-guide` (01) |
|
|
||||||
| Other client | Ask which brand guide applies -- do not assume D.intelligence defaults | Same |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent Corps Context
|
|
||||||
|
|
||||||
- **Agent #78** -- Campaign Designer
|
|
||||||
- **Downstream (Gate 3 handoff)**: the `marketing:campaign-plan` skill + `doc-generator` skill turn the approved outline into the finished document
|
|
||||||
- **Collaborates with**: Agent #77 (Account Manager) for client context, Agent #73 (Quotation Manager) if the plan needs formal pricing, Agent #70/#71 for D.intelligence-brand campaigns
|
|
||||||
- **Shared constants**: `_dintel-shared/src/dintel/brand.py` (colors, terminology) -- only relevant when the campaign is for D.intelligence itself
|
|
||||||
|
|
||||||
## Universal Guardrails
|
|
||||||
|
|
||||||
1. **Never send to clients without Andrew's approval** -- all three gates require explicit sign-off; never advance a gate on the agent's own initiative.
|
|
||||||
2. **Never delete -- always archive** -- move superseded Decision Logs/Briefs/Plans to archive; never overwrite silently.
|
|
||||||
3. **Never commit pricing without Andrew's sign-off** -- any budget/quantitative target in Gate 2/3 without a baseline is a hypothesis, not a commitment.
|
|
||||||
4. **Korean-first, bilingual notation** -- these directives are in English; deliverables (Decision Log, Brief, Plan) are written in Korean unless the user asks otherwise.
|
|
||||||
5. **Never cross-reference client data without consent** -- reference cases and precedents must be sourced/cited, not invented, and client data stays siloed by account.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why this exists
|
|
||||||
|
|
||||||
The recurring failure mode is going straight from a request to a finished plan. When direction isn't agreed first, plans come out bloated (everything crammed in), quant-skewed (arbitrary conversion targets), and tonally off. Gates fix this by forcing debate, then agreement, then documentation -- in that order.
|
|
||||||
|
|
||||||
## The three gates
|
|
||||||
|
|
||||||
Each gate is a checkpoint that **requires explicit user approval before advancing.** Do not skip ahead. At every gate, record rejected alternatives and dissenting views so the decision trail survives.
|
|
||||||
|
|
||||||
| Gate | Purpose | Output | Advance when |
|
|
||||||
|------|---------|--------|--------------|
|
|
||||||
| 1. Discovery & Debate | Agree the ONE primary objective; pressure-test the idea | Decision Log (1 page) | User confirms objective & decision |
|
|
||||||
| 2. Brief | Lock objective, audience, offer, outcomes, tone, message | Campaign Brief (1 page) | User approves the brief |
|
|
||||||
| 3. Plan | Build the full plan/document | Full plan (docx) | -- (deliverable) |
|
|
||||||
|
|
||||||
### Gate 1 -- Discovery & Debate
|
|
||||||
|
|
||||||
Goal: reach agreement on a single primary objective and stress-test the idea **before any document exists.**
|
|
||||||
|
|
||||||
Do:
|
|
||||||
- **Objective priority** -- pick ONE primary objective; everything else is secondary. Force the trade-off.
|
|
||||||
- **Debate both sides** -- steelman the idea, then argue against it as devil's advocate; respond to the strongest objection. Run a quick pre-mortem ("if this fails in 90 days, why?").
|
|
||||||
- **Reference cases** -- bring 1-3 real examples of what worked/failed elsewhere (use web research or connected tools); cite sources.
|
|
||||||
- **Expected effects as HYPOTHESES** -- not targets yet. State what you'd observe to know.
|
|
||||||
- **Gaps -> [확인]** -- don't invent missing facts.
|
|
||||||
- **Confirm the target brand** if it isn't obvious (see Cross-brand routing above) -- it determines which skills govern Gate 3.
|
|
||||||
|
|
||||||
Output: fill `shared/templates/gate1-decision-log.md`. Present it and ask the user to confirm/adjust before Gate 2. See `shared/references/debate-and-outcomes.md` for facilitation prompts.
|
|
||||||
|
|
||||||
### Gate 2 -- Brief
|
|
||||||
|
|
||||||
Goal: a 1-page agreement that will govern the plan.
|
|
||||||
|
|
||||||
Do:
|
|
||||||
- Convert the agreed decision into a tight brief: objective, audience, offer, core + supporting messages, tone, channels.
|
|
||||||
- **Assign outcome metrics across 4 tiers** (see below). Label any number without a baseline as a *hypothesis*. For each qualitative goal, name the brand **attribute / association / asset** it touches.
|
|
||||||
|
|
||||||
Output: fill `shared/templates/gate2-campaign-brief.md`. Gate: user approves before Gate 3.
|
|
||||||
|
|
||||||
### Gate 3 -- Plan
|
|
||||||
|
|
||||||
Only now build the full plan. Hand off to the `marketing:campaign-plan` skill and `doc-generator` for the document.
|
|
||||||
|
|
||||||
Enforce:
|
|
||||||
- **Brevity / one-topic-one-place** -- keep the body decision-relevant; put **all** risks, compliance, and due-diligence in ONE section at the very end: **"준비 점검 사항"**. Don't scatter them.
|
|
||||||
- **[확인] convention** -- mark any gap instead of guessing.
|
|
||||||
- **Copy & compliance** -- route through the skills named in Cross-brand routing above for the campaign's target brand.
|
|
||||||
|
|
||||||
Output: fill/expand `shared/templates/gate3-plan-outline.md`, then produce the document.
|
|
||||||
|
|
||||||
## The 4-tier outcome framework (used at Gate 2 & 3)
|
|
||||||
|
|
||||||
Diversify beyond conversion so goals are credible and not arbitrarily quantitative:
|
|
||||||
|
|
||||||
1. **Awareness / cognitive** -- reach, recall, branded-search lift
|
|
||||||
2. **Qualitative** -- message reaction, tone fit, consult quality -- *and name the brand asset it strengthens*
|
|
||||||
3. **Relationship / advocacy** -- revisit intent, referral/recommendation, voluntary reviews
|
|
||||||
4. **Quantitative conversion** -- inquiries, purchases -- *label as hypothesis if there's no baseline*
|
|
||||||
|
|
||||||
## Principles baked in (from recurring feedback)
|
|
||||||
|
|
||||||
- **Debate before document.** Gates 1-2 exist so Gate 3 is tight.
|
|
||||||
- **Brevity.** Risks/compliance consolidated in one end section, not repeated throughout.
|
|
||||||
- **Ask/confirm over completeness-anxiety.** Use `[확인]`.
|
|
||||||
- **Diversified, brand-equity-anchored outcomes.** No lonely conversion targets.
|
|
||||||
|
|
||||||
## Templates & references
|
|
||||||
|
|
||||||
- `shared/templates/gate1-decision-log.md` -- Gate 1 Decision Log (1p)
|
|
||||||
- `shared/templates/gate2-campaign-brief.md` -- Gate 2 Campaign Brief (1p)
|
|
||||||
- `shared/templates/gate3-plan-outline.md` -- Gate 3 Plan outline (maps to `marketing:campaign-plan`)
|
|
||||||
- `shared/references/debate-and-outcomes.md` -- facilitation prompts + the 4-tier outcome framework in depth
|
|
||||||
|
|
||||||
## Reminder
|
|
||||||
|
|
||||||
The gates are about sequencing decisions, not adding bureaucracy. If the user explicitly wants to skip to a draft, do so -- but tell them which gate you're bypassing and what risk that carries.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# D.intelligence Campaign Designer
|
|
||||||
|
|
||||||
> **Agent #78** | `dintel-campaign-designer` v1.0.0 | D.intelligence Agent Corps
|
|
||||||
> canon_compliance: v1.3 | last_updated: 2026-07-01
|
|
||||||
|
|
||||||
> Canonical directive: **`../SKILL.md`** (this file is a pointer, not a duplicate).
|
|
||||||
|
|
||||||
Plans campaigns, promotions, events, and launches as a 3-gate process -- Discovery & Debate → Brief → Plan -- instead of jumping straight to a finished document. Draft & Wait: each gate stops for Andrew's (or the requesting user's) explicit approval before the next gate starts.
|
|
||||||
|
|
||||||
## Agent Corps Context
|
|
||||||
|
|
||||||
- **Agent #78** -- Campaign Designer
|
|
||||||
- **Downstream**: `marketing:campaign-plan` + `doc-generator` produce the finished document after Gate 3 is approved
|
|
||||||
- **Cross-brand**: this agent is not D.intelligence-exclusive -- see the Cross-brand routing table in `../SKILL.md` for which brand skill governs copy/compliance
|
|
||||||
|
|
||||||
## Universal Guardrails
|
|
||||||
|
|
||||||
1. **Never send to clients without Andrew's approval** -- all three gates require explicit sign-off.
|
|
||||||
2. **Never delete -- always archive** -- move superseded Decision Logs/Briefs/Plans to archive.
|
|
||||||
3. **Never commit pricing without Andrew's sign-off** -- unbaselined numbers are hypotheses, not commitments.
|
|
||||||
4. **Korean-first, bilingual notation** -- deliverables are Korean unless asked otherwise.
|
|
||||||
5. **Never cross-reference client data without consent** -- client data stays siloed by account.
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
- **Gates**: 1 Discovery & Debate → 2 Brief → 3 Plan (each needs explicit approval to advance)
|
|
||||||
- **Templates**: `../shared/templates/gate1-decision-log.md`, `gate2-campaign-brief.md`, `gate3-plan-outline.md`
|
|
||||||
- **Facilitation guide**: `../shared/references/debate-and-outcomes.md`
|
|
||||||
- **Canon (authoritative, when campaign is for D.intelligence)**: `knowledge-base/canon/brand-canon.md` v1.0
|
|
||||||
|
|
||||||
See `../SKILL.md` for the full gate-by-gate workflow and the 4-tier outcome framework.
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
---
|
|
||||||
name: dintel-campaign-designer
|
|
||||||
version: 1.0.0
|
|
||||||
last_updated: 2026-07-01
|
|
||||||
canon_compliance: v1.3
|
|
||||||
agent-id: "78"
|
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
|
||||||
description: |
|
|
||||||
Campaign Designer for D.intelligence. Plans marketing campaigns, promotions,
|
|
||||||
events, and launches as a 3-stage gate process -- Discovery & Debate -> Brief
|
|
||||||
-> Plan -- instead of jumping straight to a finished document. Use whenever
|
|
||||||
the user wants to plan a campaign, promotion, event, launch, or 기획안; start
|
|
||||||
here first, for any brand (D.intelligence, OurDigital, Jamie, or a client).
|
|
||||||
Triggers: 캠페인 기획, 프로모션 기획, 기획안 만들어, 이벤트 기획, campaign plan,
|
|
||||||
plan a promotion, 캠페인 설계. Produces a 1-page Decision Log (Gate 1) and
|
|
||||||
1-page Brief (Gate 2), each needing explicit user approval, before the full
|
|
||||||
plan (Gate 3).
|
|
||||||
autonomy: draft-and-wait
|
|
||||||
---
|
|
||||||
|
|
||||||
# D.intelligence Campaign Designer
|
|
||||||
|
|
||||||
> Agent #78 | `dintel-campaign-designer` v1.0.0 | D.intelligence Agent Corps
|
|
||||||
|
|
||||||
A disciplined way to plan campaigns: **debate and agree direction before producing any document.** Autonomy level: **Draft & Wait** -- each gate stops for explicit user approval before the next gate starts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ v1.3 정합성 — 단일 진실 (Single Source of Truth)
|
|
||||||
|
|
||||||
> **갱신일**: 2026-07-01 (v1.3 정합 적용) | **기준**: `knowledge-base/canon/` v1.0 + BRAND-GUIDE v1.3
|
|
||||||
|
|
||||||
**참조 의무 1순위** (충돌 시 canon이 우선) — *when the campaign is FOR D.intelligence itself*:
|
|
||||||
|
|
||||||
| Canon 문서 | 사용 시점 |
|
|
||||||
|-----------|---------|
|
|
||||||
| `knowledge-base/canon/brand-canon.md` v1.0 | 캠페인 톤 & 메시지 프레임 |
|
|
||||||
| `knowledge-base/canon/fact-sheet.md` v1.0 | 법인·연락처 표기 (기획서 표지/각주) |
|
|
||||||
| `knowledge-base/canon/service-architecture.md` v1.0 | 캠페인이 특정 모듈/패키지를 프로모션할 경우 |
|
|
||||||
| `knowledge-base/canon/naming-conventions.md` v1.0 | 산출물 파일명 |
|
|
||||||
| `knowledge-base/gotcha/01_outdated-facts.md` | 회피 대상 (주소·이메일·CEO 직함) |
|
|
||||||
|
|
||||||
### Cross-brand routing (this agent is NOT D.intelligence-exclusive)
|
|
||||||
|
|
||||||
Unlike most Agent Corps members, Campaign Designer runs the same 3-gate process regardless of which brand the campaign is for. The brand determines which skill governs tone/copy/compliance downstream -- confirm the target brand at Gate 1 if it isn't obvious:
|
|
||||||
|
|
||||||
| Campaign is for | Copy & tone | Compliance / brand review |
|
|
||||||
|------------------|-------------|---------------------------|
|
|
||||||
| D.intelligence | `dintel-brand-editor` (#71) | `dintel-brand-guardian` (#70) |
|
|
||||||
| Jamie Clinic | `jamie-copy-trimmer` (48) | `jamie-brand-audit` / "jamie-brand-guardian" (41) |
|
|
||||||
| OurDigital | `ourdigital-ad-manager` (07) | `ourdigital-brand-guide` (01) |
|
|
||||||
| Other client | Ask which brand guide applies -- do not assume D.intelligence defaults | Same |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent Corps Context
|
|
||||||
|
|
||||||
- **Agent #78** -- Campaign Designer
|
|
||||||
- **Downstream (Gate 3 handoff)**: the `marketing:campaign-plan` skill + `doc-generator` skill turn the approved outline into the finished document
|
|
||||||
- **Collaborates with**: Agent #77 (Account Manager) for client context, Agent #73 (Quotation Manager) if the plan needs formal pricing, Agent #70/#71 for D.intelligence-brand campaigns
|
|
||||||
- **Shared constants**: `_dintel-shared/src/dintel/brand.py` (colors, terminology) -- only relevant when the campaign is for D.intelligence itself
|
|
||||||
|
|
||||||
## Universal Guardrails
|
|
||||||
|
|
||||||
1. **Never send to clients without Andrew's approval** -- all three gates require explicit sign-off; never advance a gate on the agent's own initiative.
|
|
||||||
2. **Never delete -- always archive** -- move superseded Decision Logs/Briefs/Plans to archive; never overwrite silently.
|
|
||||||
3. **Never commit pricing without Andrew's sign-off** -- any budget/quantitative target in Gate 2/3 without a baseline is a hypothesis, not a commitment.
|
|
||||||
4. **Korean-first, bilingual notation** -- these directives are in English; deliverables (Decision Log, Brief, Plan) are written in Korean unless the user asks otherwise.
|
|
||||||
5. **Never cross-reference client data without consent** -- reference cases and precedents must be sourced/cited, not invented, and client data stays siloed by account.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why this exists
|
|
||||||
|
|
||||||
The recurring failure mode is going straight from a request to a finished plan. When direction isn't agreed first, plans come out bloated (everything crammed in), quant-skewed (arbitrary conversion targets), and tonally off. Gates fix this by forcing debate, then agreement, then documentation -- in that order.
|
|
||||||
|
|
||||||
## The three gates
|
|
||||||
|
|
||||||
Each gate is a checkpoint that **requires explicit user approval before advancing.** Do not skip ahead. At every gate, record rejected alternatives and dissenting views so the decision trail survives.
|
|
||||||
|
|
||||||
| Gate | Purpose | Output | Advance when |
|
|
||||||
|------|---------|--------|--------------|
|
|
||||||
| 1. Discovery & Debate | Agree the ONE primary objective; pressure-test the idea | Decision Log (1 page) | User confirms objective & decision |
|
|
||||||
| 2. Brief | Lock objective, audience, offer, outcomes, tone, message | Campaign Brief (1 page) | User approves the brief |
|
|
||||||
| 3. Plan | Build the full plan/document | Full plan (docx) | -- (deliverable) |
|
|
||||||
|
|
||||||
### Gate 1 -- Discovery & Debate
|
|
||||||
|
|
||||||
Goal: reach agreement on a single primary objective and stress-test the idea **before any document exists.**
|
|
||||||
|
|
||||||
Do:
|
|
||||||
- **Objective priority** -- pick ONE primary objective; everything else is secondary. Force the trade-off.
|
|
||||||
- **Debate both sides** -- steelman the idea, then argue against it as devil's advocate; respond to the strongest objection. Run a quick pre-mortem ("if this fails in 90 days, why?").
|
|
||||||
- **Reference cases** -- bring 1-3 real examples of what worked/failed elsewhere (use web research or connected tools); cite sources.
|
|
||||||
- **Expected effects as HYPOTHESES** -- not targets yet. State what you'd observe to know.
|
|
||||||
- **Gaps -> [확인]** -- don't invent missing facts.
|
|
||||||
- **Confirm the target brand** if it isn't obvious (see Cross-brand routing above) -- it determines which skills govern Gate 3.
|
|
||||||
|
|
||||||
Output: fill `shared/templates/gate1-decision-log.md`. Present it and ask the user to confirm/adjust before Gate 2. See `shared/references/debate-and-outcomes.md` for facilitation prompts.
|
|
||||||
|
|
||||||
### Gate 2 -- Brief
|
|
||||||
|
|
||||||
Goal: a 1-page agreement that will govern the plan.
|
|
||||||
|
|
||||||
Do:
|
|
||||||
- Convert the agreed decision into a tight brief: objective, audience, offer, core + supporting messages, tone, channels.
|
|
||||||
- **Assign outcome metrics across 4 tiers** (see below). Label any number without a baseline as a *hypothesis*. For each qualitative goal, name the brand **attribute / association / asset** it touches.
|
|
||||||
|
|
||||||
Output: fill `shared/templates/gate2-campaign-brief.md`. Gate: user approves before Gate 3.
|
|
||||||
|
|
||||||
### Gate 3 -- Plan
|
|
||||||
|
|
||||||
Only now build the full plan. Hand off to the `marketing:campaign-plan` skill and `doc-generator` for the document.
|
|
||||||
|
|
||||||
Enforce:
|
|
||||||
- **Brevity / one-topic-one-place** -- keep the body decision-relevant; put **all** risks, compliance, and due-diligence in ONE section at the very end: **"준비 점검 사항"**. Don't scatter them.
|
|
||||||
- **[확인] convention** -- mark any gap instead of guessing.
|
|
||||||
- **Copy & compliance** -- route through the skills named in Cross-brand routing above for the campaign's target brand.
|
|
||||||
|
|
||||||
Output: fill/expand `shared/templates/gate3-plan-outline.md`, then produce the document.
|
|
||||||
|
|
||||||
## The 4-tier outcome framework (used at Gate 2 & 3)
|
|
||||||
|
|
||||||
Diversify beyond conversion so goals are credible and not arbitrarily quantitative:
|
|
||||||
|
|
||||||
1. **Awareness / cognitive** -- reach, recall, branded-search lift
|
|
||||||
2. **Qualitative** -- message reaction, tone fit, consult quality -- *and name the brand asset it strengthens*
|
|
||||||
3. **Relationship / advocacy** -- revisit intent, referral/recommendation, voluntary reviews
|
|
||||||
4. **Quantitative conversion** -- inquiries, purchases -- *label as hypothesis if there's no baseline*
|
|
||||||
|
|
||||||
## Principles baked in (from recurring feedback)
|
|
||||||
|
|
||||||
- **Debate before document.** Gates 1-2 exist so Gate 3 is tight.
|
|
||||||
- **Brevity.** Risks/compliance consolidated in one end section, not repeated throughout.
|
|
||||||
- **Ask/confirm over completeness-anxiety.** Use `[확인]`.
|
|
||||||
- **Diversified, brand-equity-anchored outcomes.** No lonely conversion targets.
|
|
||||||
|
|
||||||
## Templates & references
|
|
||||||
|
|
||||||
- `shared/templates/gate1-decision-log.md` -- Gate 1 Decision Log (1p)
|
|
||||||
- `shared/templates/gate2-campaign-brief.md` -- Gate 2 Campaign Brief (1p)
|
|
||||||
- `shared/templates/gate3-plan-outline.md` -- Gate 3 Plan outline (maps to `marketing:campaign-plan`)
|
|
||||||
- `shared/references/debate-and-outcomes.md` -- facilitation prompts + the 4-tier outcome framework in depth
|
|
||||||
|
|
||||||
## Reminder
|
|
||||||
|
|
||||||
The gates are about sequencing decisions, not adding bureaucracy. If the user explicitly wants to skip to a draft, do so -- but tell them which gate you're bypassing and what risk that carries.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Facilitation Guide — Debate & Outcomes
|
|
||||||
|
|
||||||
Deeper guidance for running Gate 1's debate and setting Gate 2's outcome metrics.
|
|
||||||
|
|
||||||
## Running the Gate 1 debate (don't rubber-stamp)
|
|
||||||
|
|
||||||
**Objective priority — force one.** If the user lists several goals (retention, cash, awareness), make them rank. Multiple co-equal objectives are the root of bloated, contradictory plans. Ask: "If we could only achieve one, which one?"
|
|
||||||
|
|
||||||
**Steelman, then devil's advocate.**
|
|
||||||
- Steelman: state the strongest possible case FOR the idea, better than the user did.
|
|
||||||
- Devil's advocate: argue AGAINST it — where it wastes money, annoys customers, cannibalizes, or breaks compliance.
|
|
||||||
- Then answer the single strongest objection. If it can't be answered, that's a finding.
|
|
||||||
|
|
||||||
**Pre-mortem.** "It's 90 days later and this flopped. Write the reason." Common failure seeds: wrong audience, offer too strong/weak, no follow-through, channel fatigue, compliance block.
|
|
||||||
|
|
||||||
**Reference cases.** Pull 1–3 concrete precedents (competitors, other categories, past campaigns). Cite sources. Ask what specifically transfers and what doesn't. Avoid "best practices" hand-waving.
|
|
||||||
|
|
||||||
**Hypotheses, not targets (yet).** At Gate 1, expected effects are hypotheses with an observation method: "We believe X will move Y; we'll know by watching Z." Numbers get committed only at Gate 2, and only with a baseline.
|
|
||||||
|
|
||||||
## The 4-tier outcome framework (Gate 2)
|
|
||||||
|
|
||||||
Brand marketing lives partly in perception, which is hard to measure — but "hard to measure" must not become "vague direction." Anchor each qualitative goal to a **brand-equity** element: an **attribute** (what the brand is), an **association** (what it evokes), or an **asset** (an owned phrase/symbol). That keeps it concrete.
|
|
||||||
|
|
||||||
| Tier | Examples of metrics | Note |
|
|
||||||
|------|--------------------|------|
|
|
||||||
| Awareness / cognitive | reach, impressions, branded-search lift, recall | leading indicators |
|
|
||||||
| Qualitative | message reaction, tone-fit, consult quality, sentiment | **name the brand attribute/association/asset it strengthens** |
|
|
||||||
| Relationship / advocacy | revisit intent, referral/recommendation, voluntary reviews | trust signals |
|
|
||||||
| Quantitative conversion | inquiries, bookings, purchases, revenue | **no baseline → label as hypothesis, not target** |
|
|
||||||
|
|
||||||
**Baseline rule.** A number without a baseline is a guess wearing a target's clothes. Either supply a baseline (past data, connected analytics) or explicitly label it a hypothesis / observation metric. This is what makes targets credible.
|
|
||||||
|
|
||||||
**Measurement method per qualitative goal.** For each soft metric, write how you'd observe it (survey item, review sentiment, consult-note tag). If you can't name a method, it's a direction, not a metric — say so.
|
|
||||||
|
|
||||||
## Quick gate checklist
|
|
||||||
- Gate 1 pass: one primary objective agreed; strongest objection answered; ≥1 reference case; effects framed as hypotheses; user approved.
|
|
||||||
- Gate 2 pass: brief fits one page; outcomes span the 4 tiers; baseline-less numbers labeled hypotheses; qualitative goals tied to brand assets; user approved.
|
|
||||||
- Gate 3: body decision-relevant; risks/compliance consolidated in "준비 점검 사항"; gaps marked [확인]; copy through jamie-copy-trimmer; compliance via jamie-brand-guardian.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 결정 로그 — Gate 1: Discovery & Debate
|
|
||||||
|
|
||||||
<!-- One page. Produced BEFORE any plan document. Fill it, present it, then get user approval to pass Gate 1.
|
|
||||||
Language: Korean deliverable. Keep it to one page. -->
|
|
||||||
|
|
||||||
## 0. 개요
|
|
||||||
- 캠페인/과제:
|
|
||||||
- 작성일 / 참여자:
|
|
||||||
|
|
||||||
## 1. 목적 우선순위 <!-- pick ONE primary; force the trade-off -->
|
|
||||||
- **주 목적 (1개):**
|
|
||||||
- 부 목적:
|
|
||||||
- 왜 이것이 1순위인가:
|
|
||||||
|
|
||||||
## 2. 디베이트 (찬 / 반) <!-- steelman both sides honestly -->
|
|
||||||
- 찬성 논거 (통하는 이유):
|
|
||||||
- 반대 논거 (악마의 변호인):
|
|
||||||
- 프리모템 — "90일 뒤 실패했다면 이유는?":
|
|
||||||
- 반론에 대한 대응:
|
|
||||||
|
|
||||||
## 3. 레퍼런스 사례 <!-- 1-3 real cases, cite source -->
|
|
||||||
- 사례 1 (출처): — 시사점:
|
|
||||||
- 사례 2 (출처): — 시사점:
|
|
||||||
|
|
||||||
## 4. 기대효과 (가설) <!-- hypotheses, NOT targets. baseline unknown = hypothesis -->
|
|
||||||
- 가설:
|
|
||||||
- 검증 방법 (무엇을 보면 아는가):
|
|
||||||
|
|
||||||
## 5. 기각된 대안 / 반대 의견 <!-- preserve the decision trail -->
|
|
||||||
- 검토했으나 채택 안 함: — 이유:
|
|
||||||
|
|
||||||
## 6. 결정
|
|
||||||
- 결정 사항:
|
|
||||||
|
|
||||||
## 7. 준비 점검 사항 <!-- consolidate gaps & risks here, not scattered -->
|
|
||||||
- [확인] (정보 부재 / 사실 검증 필요):
|
|
||||||
|
|
||||||
---
|
|
||||||
## 게이트 승인
|
|
||||||
- [ ] 의사결정자(원장 등) 승인 → **Gate 2 (브리프)** 진행
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# 캠페인 브리프 — Gate 2
|
|
||||||
|
|
||||||
<!-- One page. This is the agreement that governs the plan. Approve before Gate 3.
|
|
||||||
Carry forward the objective decided at Gate 1. Korean deliverable. -->
|
|
||||||
|
|
||||||
## 1. 목적 <!-- the single primary objective locked at Gate 1 -->
|
|
||||||
|
|
||||||
## 2. 타겟
|
|
||||||
- 1차 / 2차:
|
|
||||||
- 오디언스 프로파일 (한 문장):
|
|
||||||
|
|
||||||
## 3. 오퍼 / 핵심 제안
|
|
||||||
|
|
||||||
## 4. 메시지
|
|
||||||
- 핵심 메시지 (한 문장):
|
|
||||||
- 보조 메시지 (2~3):
|
|
||||||
|
|
||||||
## 5. 성과 지표 — 4층위 <!-- diversify; label baseline-less numbers as 가설; name the brand asset each qualitative goal touches -->
|
|
||||||
- 인지적 (도달 / 상기 / 브랜드 검색량):
|
|
||||||
- 정성적 (반응 / 톤 적합성) — **강화 브랜드 자산(속성·연상):**
|
|
||||||
- 관계·추천 (재방문 / 추천 의사 / 자발적 후기):
|
|
||||||
- 정량 전환 (문의 / 결제) — **[가설? baseline 유무]:**
|
|
||||||
|
|
||||||
## 6. 톤 & 채널 방향
|
|
||||||
- 톤:
|
|
||||||
- 채널 (우선순위):
|
|
||||||
|
|
||||||
## 7. 제약 / 컴플라이언스 (요약) <!-- summary only; full detail lives in the plan's 준비 점검 사항 -->
|
|
||||||
|
|
||||||
---
|
|
||||||
## 게이트 승인
|
|
||||||
- [ ] 승인 → **Gate 3 (기획서화)** 진행
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 기획서 아웃라인 — Gate 3
|
|
||||||
|
|
||||||
<!-- Build ONLY after Gate 2 approval. Hand off to the marketing campaign-plan skill + docx.
|
|
||||||
Two hard rules:
|
|
||||||
(1) Keep the body decision-relevant. Put ALL risks/compliance/due-diligence in ONE section at the END: "준비 점검 사항".
|
|
||||||
(2) Use [확인] for any gap instead of inventing.
|
|
||||||
Korean deliverable. -->
|
|
||||||
|
|
||||||
0. 한눈에 보기 (Executive summary)
|
|
||||||
1. 캠페인 개요 <!-- from the Brief -->
|
|
||||||
2. 프로그램 / 제품 구조 & 경제성
|
|
||||||
3. 타겟 & 메시지
|
|
||||||
4. 채널 전략
|
|
||||||
5. 주차별 액션플랜 / 콘텐츠 캘린더
|
|
||||||
6. 필요 콘텐츠 자산 <!-- copy runs through jamie-copy-trimmer -->
|
|
||||||
7. 성과지표 & 트래킹 <!-- 4층위, carried from the Brief -->
|
|
||||||
8. 리스크 & 대응
|
|
||||||
9. 다음 단계 (Next steps)
|
|
||||||
|
|
||||||
────────────────────────
|
|
||||||
▶ **준비 점검 사항** <!-- consolidate here: 의료광고 심의, 주의의무, 위험요인, 부가세/고지 표기, 그리고 모든 [확인] 항목 -->
|
|
||||||
- [확인] …
|
|
||||||
@@ -10,7 +10,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "79"
|
agent-id: "79"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
autonomy: triggered
|
autonomy: triggered
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "79"
|
agent-id: "79"
|
||||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||||
autonomy: triggered
|
autonomy: triggered
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
# ourdigital-okf — Claude Skill Design Spec
|
|
||||||
|
|
||||||
> **Status:** Approved (decisions resolved 2026-06-16) · **Author:** Claude Code (brainstorming skill)
|
|
||||||
> **Notion spec of record:** https://app.notion.com/p/381581e58a1e81128280f43839902dc8
|
|
||||||
> **Related:** OKF Reference Capture (Notion) · Local reference library at `~/Documents/reference-library/open-knowledge-format/`
|
|
||||||
|
|
||||||
A custom Claude skill, triggered by `/ourdigital-okf`, that **produces**, **visualizes**, and **validates**
|
|
||||||
Google Open Knowledge Format (OKF) v0.1 knowledge bundles. It puts Claude where it is strongest —
|
|
||||||
drafting and enriching concept documents — the work Google's reference implementation needs a full
|
|
||||||
Python ADK + Gemini agent to do.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Scope (finalized)
|
|
||||||
|
|
||||||
- **In scope:** produce · visualize · validate.
|
|
||||||
- **Dropped:** query/consume mode (decision 1 — largely native to Claude; not worth the surface area).
|
|
||||||
- **Out of scope:** wrapping Google's Python `enrichment_agent`; live BigQuery pulls.
|
|
||||||
|
|
||||||
## 2. Architecture — "Single skill + bundled utilities" (Approach A)
|
|
||||||
|
|
||||||
One coherent skill with three modes. Production *composes existing tooling* rather than reimplementing it,
|
|
||||||
and stays **MCP-agnostic** (decision 4):
|
|
||||||
|
|
||||||
| Need | Source |
|
|
||||||
| --- | --- |
|
|
||||||
| Data-source schemas | **Pasted or exported schema** — BigQuery DDL / `information_schema` dump, GA4 export schema, CSV/list of columns, JSON Schema, OpenAPI. No live MCP dependency. |
|
|
||||||
| Existing docs & markdown | direct file reads |
|
|
||||||
| Web-research topics | the `/reference-curator` pipeline (or Firecrawl) |
|
|
||||||
| Conformance + graph viz | two small bundled Python scripts (zero pip deps) |
|
|
||||||
|
|
||||||
## 3. Identity & install
|
|
||||||
|
|
||||||
- **Trigger:** `/ourdigital-okf` (decision 2 — part of the OurDigital skill family).
|
|
||||||
- **Source dir:** `/Users/ourdigital/Project/our-claude-skills/custom-skills/97-ourdigital-okf/`
|
|
||||||
- **Installs to:** `~/.claude/skills/ourdigital-okf/` via `install.sh` (symlink — existing pattern).
|
|
||||||
- **Conventions:** must follow OurDigital `ourdigital-*` skill rules (`_ourdigital-shared`, brand guide);
|
|
||||||
verified by `ourdigital-skill-creator` at the end (decision 6).
|
|
||||||
- **Activates on:** "OKF", "Open Knowledge Format", "knowledge bundle", "concept docs with YAML frontmatter",
|
|
||||||
produce/validate/visualize a bundle.
|
|
||||||
|
|
||||||
## 4. File layout (OurDigital `ourdigital-*` convention)
|
|
||||||
|
|
||||||
Follows the same structure as existing numbered OurDigital skills (e.g. `04-ourdigital-research`):
|
|
||||||
top-level `SKILL.md` + `README.md`, a `code/` variant (Claude Code), a `desktop/` variant
|
|
||||||
(Claude Desktop), and `docs/`.
|
|
||||||
|
|
||||||
```
|
|
||||||
97-ourdigital-okf/
|
|
||||||
├── SKILL.md # top-level canonical (YAML frontmatter: name, triggers, version, author, environment)
|
|
||||||
├── README.md # overview
|
|
||||||
├── DESIGN.md # this spec (repo copy — decision 5)
|
|
||||||
├── install.sh # symlink top-level SKILL.md → ~/.claude/skills/ourdigital-okf
|
|
||||||
├── code/
|
|
||||||
│ ├── SKILL.md # Claude Code variant: detailed produce/visualize/validate mode flows
|
|
||||||
│ ├── CLAUDE.md # code-pattern pointer
|
|
||||||
│ ├── references/
|
|
||||||
│ │ ├── okf-spec-v0.1.md # SPEC.md distilled into actionable authoring rules + conformance checklist
|
|
||||||
│ │ └── frontmatter-fields.md
|
|
||||||
│ ├── assets/
|
|
||||||
│ │ └── concept.md index.md log.md # templates
|
|
||||||
│ └── scripts/
|
|
||||||
│ ├── okf_common.py # shared frontmatter/link parsing (stdlib only)
|
|
||||||
│ ├── okf_validate.py # conformance + broken-link linter
|
|
||||||
│ ├── okf_viz.py # minimal viz.html generator (Cytoscape+marked via CDN)
|
|
||||||
│ ├── requirements.txt # documents zero runtime deps
|
|
||||||
│ └── tests/ # stdlib unittest + a mini fixture bundle
|
|
||||||
├── desktop/
|
|
||||||
│ ├── SKILL.md # Claude Desktop variant (leaner)
|
|
||||||
│ └── skill.yaml
|
|
||||||
└── docs/
|
|
||||||
├── CHANGELOG.md
|
|
||||||
└── IMPLEMENTATION-PLAN.md # the build plan
|
|
||||||
```
|
|
||||||
|
|
||||||
**Zero-dependency choice:** OKF frontmatter uses a tiny YAML subset (`key: value`, `[a, b]` lists),
|
|
||||||
so `okf_common.py` ships a minimal built-in parser — no PyYAML / pip install. Tests use stdlib
|
|
||||||
`unittest` (no pytest), keeping the whole skill installable-free.
|
|
||||||
|
|
||||||
## 5. Mode: produce (Claude-native)
|
|
||||||
|
|
||||||
1. Pick input adapter (schema / docs / research) and **confirm the target bundle directory before
|
|
||||||
creating anything** (honors the no-directory-without-consent rule).
|
|
||||||
2. Gather raw material per §2 — for the data adapter, ingest a pasted/exported schema (no live MCP call).
|
|
||||||
3. Plan a concept hierarchy (`datasets/`, `tables/`, `metrics/`, `references/`, `playbooks/` — as fits).
|
|
||||||
4. Draft one concept `.md` per concept: **required** `type` + recommended fields
|
|
||||||
(`title`, `description`, `resource`, `tags`, `timestamp`), bundle-relative cross-links
|
|
||||||
(`/path/concept.md`), and a `# Citations` section.
|
|
||||||
5. Auto-generate `index.md` per directory + root; optional `log.md`.
|
|
||||||
6. **Self-validate** with `okf_validate.py`; fix all errors before reporting done.
|
|
||||||
|
|
||||||
## 6. Mode: visualize (minimal first — decision 3)
|
|
||||||
|
|
||||||
`okf_viz.py --bundle <dir> [--out viz.html] [--name X]` → one self-contained HTML.
|
|
||||||
**v1 (minimal):** force-directed concept graph (Cytoscape), type-colored nodes, click a node to see its
|
|
||||||
rendered markdown + frontmatter. **Later iterations:** "cited by" backlinks, search box, type filter,
|
|
||||||
layout switch (parity with Google's viewer).
|
|
||||||
|
|
||||||
## 7. Validation (supporting)
|
|
||||||
|
|
||||||
`okf_validate.py <bundle>` checks: parseable frontmatter on every non-reserved `.md`; non-empty `type`;
|
|
||||||
`index.md` / `log.md` structure; **broken cross-link report** (warning, not failure). Text + JSON output,
|
|
||||||
meaningful exit code. Invoked automatically by produce, available standalone.
|
|
||||||
|
|
||||||
## 8. Built-in fixtures / regression test
|
|
||||||
|
|
||||||
Validator and visualizer are verified against **Google's own three sample bundles** already mirrored:
|
|
||||||
`~/Documents/reference-library/open-knowledge-format/okf/bundles/{crypto_bitcoin, ga4, stackoverflow}`.
|
|
||||||
Authoritative, conformant fixtures — no invented test data.
|
|
||||||
|
|
||||||
## 9. Success criteria
|
|
||||||
|
|
||||||
- `/ourdigital-okf produce` from a pasted schema, a docs folder, or a research topic emits a bundle that
|
|
||||||
passes `okf_validate.py` with **0 conformance errors**.
|
|
||||||
- `okf_viz.py` on any bundle opens a working graph in the browser.
|
|
||||||
- Both scripts validate/visualize Google's 3 reference bundles cleanly.
|
|
||||||
- Skill passes `ourdigital-skill-creator` consistency/rules check.
|
|
||||||
|
|
||||||
## 10. Build sequence
|
|
||||||
|
|
||||||
1. Scaffold `97-ourdigital-okf/` (SKILL.md, README, USER-GUIDE, install.sh, templates) to OurDigital conventions.
|
|
||||||
2. Distill `SPEC.md` → `reference/okf-spec-v0.1.md` authoring rules + checklist.
|
|
||||||
3. Write `okf_validate.py`; verify against the 3 Google sample bundles.
|
|
||||||
4. Write `okf_viz.py` (minimal); verify a working graph for those bundles.
|
|
||||||
5. Write the produce/visualize/validate mode flows in SKILL.md.
|
|
||||||
6. End-to-end test: produce a small bundle from a docs folder → validate → visualize.
|
|
||||||
7. `install.sh` into `~/.claude/skills/ourdigital-okf/`; smoke-test `/ourdigital-okf`.
|
|
||||||
8. Run `ourdigital-skill-creator` consistency check; fix any rule violations.
|
|
||||||
|
|
||||||
## 11. Resolved decisions (2026-06-16)
|
|
||||||
|
|
||||||
| # | Question | Decision |
|
|
||||||
|---|----------|----------|
|
|
||||||
| 1 | Keep query mode? | **No** — produce + visualize + validate only |
|
|
||||||
| 2 | Naming | **`ourdigital-okf`** |
|
|
||||||
| 3 | Viz scope | **Minimal graph first**, iterate |
|
|
||||||
| 4 | Data adapter | **Accept pasted/exported schema** (MCP-agnostic) |
|
|
||||||
| 5 | Spec home | Notion (of record) **+ repo `DESIGN.md` copy** |
|
|
||||||
| 6 | Build method | **writing-plans → implementation**, then `ourdigital-skill-creator` validity check |
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# ourdigital-okf
|
|
||||||
|
|
||||||
A custom OurDigital Claude skill for **Google Open Knowledge Format (OKF) v0.1** — produce,
|
|
||||||
validate, and visualize knowledge bundles (directories of markdown concept files with YAML
|
|
||||||
frontmatter).
|
|
||||||
|
|
||||||
## Modes
|
|
||||||
|
|
||||||
| Mode | What it does |
|
|
||||||
|------|--------------|
|
|
||||||
| **produce** | Claude drafts a conformant OKF bundle from a pasted/exported schema, a docs folder, or a `/reference-curator` research topic. |
|
|
||||||
| **validate** | Lints a bundle for OKF v0.1 conformance (parseable frontmatter + non-empty `type`); reports broken cross-links as warnings. |
|
|
||||||
| **visualize** | Renders a bundle as a self-contained interactive Cytoscape graph (`viz.html`). |
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Symlinks the skill into `~/.claude/skills/ourdigital-okf`. Invoke with `/ourdigital-okf`.
|
|
||||||
|
|
||||||
## Scripts (Python standard library only — no pip install)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 code/scripts/okf_validate.py <bundle> [--json]
|
|
||||||
python3 code/scripts/okf_viz.py --bundle <bundle> [--out viz.html] [--name "Name"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the tests from `code/scripts/`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m unittest discover -s tests
|
|
||||||
```
|
|
||||||
|
|
||||||
The suite is verified against Google's three reference bundles mirrored at
|
|
||||||
`~/Documents/reference-library/open-knowledge-format/okf/bundles/` (crypto_bitcoin, ga4,
|
|
||||||
stackoverflow).
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
- `SKILL.md` — canonical entry; `code/SKILL.md` — detailed Claude Code flows; `desktop/` — Claude Desktop variant.
|
|
||||||
- `code/references/` — distilled OKF spec + frontmatter reference.
|
|
||||||
- `code/assets/` — concept/index/log templates.
|
|
||||||
- `code/scripts/` — `okf_common.py`, `okf_validate.py`, `okf_viz.py` + tests.
|
|
||||||
- `DESIGN.md` — approved design spec. `docs/IMPLEMENTATION-PLAN.md` — build plan.
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
OKF spec and reference implementation:
|
|
||||||
<https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf>
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
---
|
|
||||||
name: ourdigital-okf
|
|
||||||
description: |
|
|
||||||
Produce, visualize, and validate Google Open Knowledge Format (OKF) v0.1
|
|
||||||
knowledge bundles. Activated with the "ourdigital" or "our" keyword for OKF work.
|
|
||||||
|
|
||||||
Triggers (ourdigital or our prefix):
|
|
||||||
- "ourdigital okf", "our okf"
|
|
||||||
- "ourdigital open knowledge format", "our knowledge bundle"
|
|
||||||
- "ourdigital okf 만들기", "our okf 검증", "our okf 시각화"
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Produce conformant OKF bundles from a pasted/exported schema, a docs folder, or a research topic
|
|
||||||
- Validate a bundle for OKF v0.1 conformance + broken-link report
|
|
||||||
- Visualize a bundle as a self-contained interactive graph (viz.html)
|
|
||||||
version: "1.0"
|
|
||||||
author: OurDigital
|
|
||||||
environment: Both
|
|
||||||
---
|
|
||||||
|
|
||||||
# OurDigital OKF
|
|
||||||
|
|
||||||
Work with **Open Knowledge Format (OKF) v0.1** — Google's open, vendor-neutral standard
|
|
||||||
for representing knowledge as a directory of markdown files with YAML frontmatter. Each
|
|
||||||
file is a *concept* (table, dataset, metric, playbook, API, reference); the file path is
|
|
||||||
its identity; markdown links make the directory a graph. The only required field is
|
|
||||||
`type`.
|
|
||||||
|
|
||||||
This skill has three modes:
|
|
||||||
|
|
||||||
- **produce** — Claude drafts a conformant OKF bundle from one of three inputs: a
|
|
||||||
pasted/exported **schema** (BigQuery DDL, GA4 export schema, CSV/JSON-Schema/OpenAPI), a
|
|
||||||
**docs/markdown** folder, or a **research topic** (via `/reference-curator`). It plans a
|
|
||||||
concept hierarchy, writes one `type`-bearing concept per file, cross-links them, and
|
|
||||||
generates `index.md` files — then self-validates.
|
|
||||||
- **validate** — lint a bundle for OKF v0.1 conformance (parseable frontmatter + non-empty
|
|
||||||
`type`) and report broken cross-links as warnings.
|
|
||||||
- **visualize** — render a bundle as a self-contained interactive graph in one HTML file.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Validate
|
|
||||||
python3 code/scripts/okf_validate.py <bundle> [--json]
|
|
||||||
|
|
||||||
# Visualize → writes <bundle>/viz.html (or --out PATH)
|
|
||||||
python3 code/scripts/okf_viz.py --bundle <bundle> [--name "Display Name"]
|
|
||||||
```
|
|
||||||
|
|
||||||
The scripts are Python standard-library only — no install needed.
|
|
||||||
|
|
||||||
## Where to look
|
|
||||||
|
|
||||||
- **Detailed mode flows:** `code/SKILL.md` (the Claude Code variant).
|
|
||||||
- **Authoring authority:** `code/references/okf-spec-v0.1.md` — read before producing.
|
|
||||||
- **Field guidance:** `code/references/frontmatter-fields.md`.
|
|
||||||
- **Templates:** `code/assets/{concept,index,log}.md`.
|
|
||||||
- **Design + plan:** `DESIGN.md`, `docs/IMPLEMENTATION-PLAN.md`.
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Confirm the output directory with the user before creating it.
|
|
||||||
- A bundle is "done" only after the validator reports CONFORMANT with zero errors.
|
|
||||||
- Verified against Google's reference bundles at
|
|
||||||
`~/Documents/reference-library/open-knowledge-format/okf/bundles/`.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# ourdigital-okf (Claude Code)
|
|
||||||
|
|
||||||
Use `SKILL.md` in this directory as the instruction set for producing, validating, and
|
|
||||||
visualizing Open Knowledge Format (OKF) v0.1 bundles.
|
|
||||||
|
|
||||||
- Scripts live in `scripts/` and use the Python standard library only (no pip install).
|
|
||||||
- Read `references/okf-spec-v0.1.md` before producing a bundle.
|
|
||||||
- Templates are in `assets/`.
|
|
||||||
- Always confirm the output directory with the user before creating it.
|
|
||||||
- A bundle is done only when `python3 scripts/okf_validate.py <bundle>` reports CONFORMANT
|
|
||||||
with zero errors.
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
---
|
|
||||||
name: ourdigital-okf
|
|
||||||
description: |
|
|
||||||
Produce, visualize, and validate Google Open Knowledge Format (OKF) v0.1
|
|
||||||
knowledge bundles. Activated with the "ourdigital" or "our" keyword for OKF work.
|
|
||||||
|
|
||||||
Triggers (ourdigital or our prefix):
|
|
||||||
- "ourdigital okf", "our okf"
|
|
||||||
- "ourdigital open knowledge format", "our knowledge bundle"
|
|
||||||
- "ourdigital okf 만들기", "our okf 검증", "our okf 시각화"
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Produce conformant OKF bundles from a pasted/exported schema, a docs folder, or a research topic
|
|
||||||
- Validate a bundle for OKF v0.1 conformance + broken-link report
|
|
||||||
- Visualize a bundle as a self-contained interactive graph (viz.html)
|
|
||||||
version: "1.0"
|
|
||||||
author: OurDigital
|
|
||||||
environment: Both
|
|
||||||
---
|
|
||||||
|
|
||||||
# OurDigital OKF
|
|
||||||
|
|
||||||
Produce, validate, and visualize **Open Knowledge Format (OKF) v0.1** bundles. OKF is an
|
|
||||||
open, vendor-neutral standard that represents knowledge as a directory of markdown files
|
|
||||||
with YAML frontmatter — each file is a *concept* (a table, dataset, metric, playbook, API,
|
|
||||||
reference), the file path is its identity, and ordinary markdown links turn the directory
|
|
||||||
into a graph. The only hard rule is a `type` field on every concept; everything else is
|
|
||||||
producer-defined and consumers tolerate the unknown.
|
|
||||||
|
|
||||||
**Before producing anything, read `references/okf-spec-v0.1.md`** — it is the authoring
|
|
||||||
authority (reserved filenames, frontmatter fields, cross-linking, conformance). Use
|
|
||||||
`references/frontmatter-fields.md` for per-field guidance and `assets/` for templates.
|
|
||||||
|
|
||||||
## Mode dispatch
|
|
||||||
|
|
||||||
Decide the mode from the request:
|
|
||||||
|
|
||||||
- **produce** — "make/build/generate an OKF bundle from …"
|
|
||||||
- **validate** — "check/validate/lint this bundle"
|
|
||||||
- **visualize** — "visualize/graph this bundle", "make a viz"
|
|
||||||
|
|
||||||
The scripts live in `scripts/` and use the Python standard library only (no pip install).
|
|
||||||
|
|
||||||
## Mode: produce
|
|
||||||
|
|
||||||
Claude drafts the concept documents directly — this is where the skill adds the most value.
|
|
||||||
|
|
||||||
1. **Pick the input adapter** and **confirm the output directory with the user before
|
|
||||||
creating it** (OurDigital rule: never create a directory without explicit consent —
|
|
||||||
show the full path and wait for approval). Input adapters:
|
|
||||||
- **Schema (pasted/exported)** — the user pastes or points to an exported schema:
|
|
||||||
BigQuery DDL or `information_schema` dump, a GA4 export schema, a CSV/JSON-Schema/
|
|
||||||
OpenAPI file, or a column list. Do **not** call a live MCP; work from the supplied
|
|
||||||
text so the skill stays portable.
|
|
||||||
- **Docs & markdown** — read a provided file or folder and reorganize its knowledge
|
|
||||||
into concepts.
|
|
||||||
- **Research topic** — invoke `/reference-curator` (or Firecrawl) to gather sources,
|
|
||||||
then distill them into concepts with citations.
|
|
||||||
2. **Plan the hierarchy.** Choose directories that fit the domain — typically
|
|
||||||
`datasets/`, `tables/`, `metrics/`, `references/`, `playbooks/`. One concept per file.
|
|
||||||
3. **Draft each concept** using `assets/concept.md` as the skeleton. Every concept MUST
|
|
||||||
have a non-empty `type`. Add `title` and a one-sentence `description`; add `resource`
|
|
||||||
when the concept maps to a real asset; add `tags` and `timestamp`. Favor structural
|
|
||||||
markdown (`# Schema` tables, `# Examples`, `# Citations`) over prose.
|
|
||||||
4. **Cross-link** related concepts with bundle-relative links (`/tables/customers.md`).
|
|
||||||
Express foreign keys, joins, and dependencies in prose next to the link.
|
|
||||||
5. **Generate `index.md`** for each directory and the bundle root (use `assets/index.md`),
|
|
||||||
listing children with their descriptions for progressive disclosure. Optionally add a
|
|
||||||
`log.md` (use `assets/log.md`).
|
|
||||||
6. **Self-validate and fix.** Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/okf_validate.py <bundle>
|
|
||||||
```
|
|
||||||
|
|
||||||
Resolve every conformance error before reporting the bundle done. Broken links are
|
|
||||||
warnings, not errors.
|
|
||||||
|
|
||||||
7. Offer to visualize the result (see below).
|
|
||||||
|
|
||||||
## Mode: validate
|
|
||||||
|
|
||||||
Run the linter and interpret the report:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/okf_validate.py <bundle> # human-readable
|
|
||||||
python3 scripts/okf_validate.py <bundle> --json # machine-readable
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Errors** (block conformance): missing/unparseable frontmatter, missing or empty
|
|
||||||
`type`. Exit code is `1` when any error exists, `0` when conformant.
|
|
||||||
- **Warnings** (informational): broken cross-links — a link whose target `.md` is not in
|
|
||||||
the bundle. Per the spec these are tolerated (not-yet-written knowledge), so report them
|
|
||||||
but do not treat them as failures.
|
|
||||||
|
|
||||||
Summarize the result for the user (concepts count, status, errors, warnings) and, if there
|
|
||||||
are errors, point to the exact concept and rule.
|
|
||||||
|
|
||||||
## Mode: visualize
|
|
||||||
|
|
||||||
Generate a self-contained interactive graph (one HTML file, Cytoscape + marked from a CDN,
|
|
||||||
no backend, no data leaves the page):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/okf_viz.py --bundle <bundle> [--out viz.html] [--name "Display Name"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Nodes are concepts colored by `type`; edges are cross-links; clicking a node renders its
|
|
||||||
markdown body and frontmatter in a side panel. This is the *minimal* viewer (graph +
|
|
||||||
detail panel) — search, type filters, and backlinks are deliberate future iterations.
|
|
||||||
Tell the user the output path and that they open it in any browser.
|
|
||||||
|
|
||||||
## Example: produce from a pasted schema
|
|
||||||
|
|
||||||
The user pastes a BigQuery DDL for `acme.sales.orders` and `acme.sales.customers` and
|
|
||||||
asks for a bundle. After confirming the output directory (e.g. `/tmp/sales-okf/`, with
|
|
||||||
the user's approval), the producer:
|
|
||||||
|
|
||||||
- Creates `datasets/sales.md` (`type: BigQuery Dataset`) describing the dataset and
|
|
||||||
linking to its tables.
|
|
||||||
- Creates `tables/orders.md` and `tables/customers.md` (`type: BigQuery Table`), each with
|
|
||||||
a `# Schema` table built from the DDL columns. In `orders.md`, the `customer_id` row
|
|
||||||
links to `[customers](/tables/customers.md)`, and a sentence notes the join key.
|
|
||||||
- Adds a root `index.md` and a `tables/index.md` listing each concept with its
|
|
||||||
`description`.
|
|
||||||
- Runs `python3 scripts/okf_validate.py /tmp/sales-okf`; on `CONFORMANT`, offers to
|
|
||||||
generate `viz.html`.
|
|
||||||
|
|
||||||
Nothing here requires a live database connection — the producer works entirely from the
|
|
||||||
pasted DDL, which keeps the skill portable across machines and accounts.
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- `references/okf-spec-v0.1.md` — distilled authoring rules + conformance checklist (read first).
|
|
||||||
- `references/frontmatter-fields.md` — per-field guidance and example `type` values.
|
|
||||||
- `assets/concept.md`, `assets/index.md`, `assets/log.md` — templates.
|
|
||||||
- `scripts/okf_common.py` — shared frontmatter/link parser (stdlib).
|
|
||||||
- `scripts/okf_validate.py` — conformance + broken-link linter.
|
|
||||||
- `scripts/okf_viz.py` — minimal graph visualizer.
|
|
||||||
- `scripts/tests/` — stdlib `unittest` suite; run `python3 -m unittest discover -s tests`
|
|
||||||
from `scripts/`. The suite is verified against Google's reference bundles under
|
|
||||||
`~/Documents/reference-library/open-knowledge-format/okf/bundles/`.
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Never create an output directory without explicit user confirmation of the path.
|
|
||||||
- Keep the scripts dependency-free; if a real bundle uses YAML the parser cannot handle,
|
|
||||||
extend `okf_common.py` minimally and re-run the test suite.
|
|
||||||
- A bundle is "done" only after `okf_validate.py` reports `CONFORMANT` with zero errors.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
type: <Concept type, e.g. BigQuery Table>
|
|
||||||
title: <Human-readable display name>
|
|
||||||
description: <One-sentence summary>
|
|
||||||
resource: <Canonical URI, omit for abstract concepts>
|
|
||||||
tags: [<tag>, <tag>]
|
|
||||||
timestamp: <ISO 8601, e.g. 2026-06-16T00:00:00Z>
|
|
||||||
---
|
|
||||||
|
|
||||||
# Schema
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `col` | TYPE | What it is. FK to [other](/tables/other.md). |
|
|
||||||
|
|
||||||
# Citations
|
|
||||||
|
|
||||||
[1] [Source title](https://example.com)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Group Heading
|
|
||||||
|
|
||||||
* [Title](relative-or-bundle-relative-path) - short description from the concept's frontmatter
|
|
||||||
* [Another concept](/tables/orders.md) - one row per completed order
|
|
||||||
|
|
||||||
# Subdirectories
|
|
||||||
|
|
||||||
* [datasets/](datasets/) - dataset-level concepts
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Update Log
|
|
||||||
|
|
||||||
## 2026-06-16
|
|
||||||
* **Initialization**: Created the bundle structure.
|
|
||||||
* **Creation**: Added the [orders table](/tables/orders.md).
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# OKF Frontmatter Field Reference
|
|
||||||
|
|
||||||
Per-field guidance for authoring OKF v0.1 concept frontmatter. Only `type` is required;
|
|
||||||
everything else is recommended or optional. Producers may add arbitrary extra keys —
|
|
||||||
consumers preserve them and never reject on unknown keys.
|
|
||||||
|
|
||||||
| Field | Required | Format | Example |
|
|
||||||
|-------|----------|--------|---------|
|
|
||||||
| `type` | **Yes** | short string | `BigQuery Table` |
|
|
||||||
| `title` | Recommended | string | `Customer Orders` |
|
|
||||||
| `description` | Recommended | one sentence | `One row per completed customer order.` |
|
|
||||||
| `resource` | Recommended | URI | `https://console.cloud.google.com/bigquery?p=acme&d=sales&t=orders` |
|
|
||||||
| `tags` | Optional | list of strings | `[sales, revenue]` or block `- sales` |
|
|
||||||
| `timestamp` | Optional | ISO 8601 datetime | `2026-05-28T14:30:00Z` |
|
|
||||||
|
|
||||||
## `type` — the only required field
|
|
||||||
|
|
||||||
A short string identifying the kind of concept. Consumers route, filter, and present on
|
|
||||||
it. Type values are **not** registered centrally; pick descriptive, self-explanatory
|
|
||||||
values. Common examples:
|
|
||||||
|
|
||||||
- `BigQuery Table`, `BigQuery Dataset` — data assets
|
|
||||||
- `Metric` — a derived/calculated measure
|
|
||||||
- `Reference` — a standalone external doc captured as a concept (often under `references/`)
|
|
||||||
- `Playbook`, `Runbook` — operational procedures
|
|
||||||
- `API Endpoint` — an API surface
|
|
||||||
|
|
||||||
Consumers MUST tolerate unknown `type` values (treat as generic concepts).
|
|
||||||
|
|
||||||
## Recommended fields (priority order)
|
|
||||||
|
|
||||||
- **`title`** — human-readable display name. If omitted, consumers derive one from the
|
|
||||||
filename.
|
|
||||||
- **`description`** — a single summarizing sentence. Used by `index.md` generators,
|
|
||||||
search snippets, and previews — keep it crisp.
|
|
||||||
- **`resource`** — a URI uniquely identifying the underlying asset. Omit for concepts
|
|
||||||
that describe abstract ideas (a metric, a business process) rather than a physical
|
|
||||||
resource.
|
|
||||||
- **`tags`** — cross-cutting categorization. Accepts inline (`[a, b]`) or block list
|
|
||||||
(`- a` on following lines) form.
|
|
||||||
- **`timestamp`** — ISO 8601 datetime of the last meaningful change.
|
|
||||||
|
|
||||||
## Extensions
|
|
||||||
|
|
||||||
Add any additional keys your producer needs (e.g. `owner`, `sensitivity`, `okf_version`).
|
|
||||||
Round-tripping consumers SHOULD preserve unknown keys and SHOULD NOT reject documents
|
|
||||||
that carry them.
|
|
||||||
|
|
||||||
## Notes for this skill's parser (`okf_common.py`)
|
|
||||||
|
|
||||||
The bundled validator/visualizer parse a small YAML subset that covers real OKF bundles:
|
|
||||||
`key: value`, inline lists `[a, b]`, block lists (`key:` then `- item`), folded
|
|
||||||
multi-line scalars (a value continued on indented lines), `>`/`|` block scalars, and
|
|
||||||
quoted scalars. It is not a full YAML engine — keep frontmatter to these shapes.
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# OKF v0.1 — Authoring Rules & Conformance Checklist
|
|
||||||
|
|
||||||
Distilled, actionable reference for producing and validating Open Knowledge Format
|
|
||||||
bundles. Authority: the full spec at
|
|
||||||
`~/Documents/reference-library/open-knowledge-format/okf/SPEC.md`. **Read this file
|
|
||||||
before producing a bundle.**
|
|
||||||
|
|
||||||
## Core model
|
|
||||||
|
|
||||||
- **Bundle** — a directory tree of markdown files. The unit of distribution.
|
|
||||||
- **Concept** — one markdown file = one unit of knowledge (a table, dataset, metric,
|
|
||||||
playbook, API, reference…).
|
|
||||||
- **Concept ID** — the bundle-relative file path with `.md` removed
|
|
||||||
(`tables/users.md` → `tables/users`). The path *is* the identity.
|
|
||||||
- Concepts form a **graph**, linked by ordinary markdown links — richer than the
|
|
||||||
parent/child implied by the directory tree.
|
|
||||||
|
|
||||||
## Reserved filenames (not concepts)
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `index.md` | Directory listing for **progressive disclosure** (§6). No frontmatter — except an optional bundle-root `index.md` may carry `okf_version: "0.1"`. |
|
|
||||||
| `log.md` | Update history: date-grouped (`YYYY-MM-DD`, newest first), entries lead with a bold verb (`**Update**`, `**Creation**`…). |
|
|
||||||
|
|
||||||
All other `.md` files are concept documents.
|
|
||||||
|
|
||||||
## Frontmatter
|
|
||||||
|
|
||||||
YAML block delimited by `---` at the very top of the file.
|
|
||||||
|
|
||||||
| Field | Status | Notes |
|
|
||||||
|-------|--------|-------|
|
|
||||||
| `type` | **REQUIRED** | Short string, e.g. `BigQuery Table`, `BigQuery Dataset`, `Metric`, `Playbook`, `Reference`, `API Endpoint`. Not centrally registered; consumers tolerate unknown types. |
|
|
||||||
| `title` | Recommended | Display name; else derived from filename. |
|
|
||||||
| `description` | Recommended | One sentence; used in index snippets/previews. |
|
|
||||||
| `resource` | Recommended | Canonical URI of the underlying asset; omit for abstract concepts. |
|
|
||||||
| `tags` | Optional | List of short strings (inline `[a, b]` or block `- a`). |
|
|
||||||
| `timestamp` | Optional | ISO 8601 last-modified time. |
|
|
||||||
| *(extensions)* | Optional | Any extra producer keys; preserve, never reject. |
|
|
||||||
|
|
||||||
## Body
|
|
||||||
|
|
||||||
Standard markdown. Prefer structural markdown (headings, tables, lists, fenced code)
|
|
||||||
over prose. Conventional section headings (use when applicable):
|
|
||||||
|
|
||||||
| Heading | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| `# Schema` | Columns/fields of an asset. |
|
|
||||||
| `# Examples` | Concrete usage, usually fenced code. |
|
|
||||||
| `# Citations` | Numbered external sources backing body claims. |
|
|
||||||
|
|
||||||
## Cross-linking
|
|
||||||
|
|
||||||
- **Bundle-relative (recommended):** leading `/`, from the bundle root —
|
|
||||||
`[customers](/tables/customers.md)`. Stable when files move within a subdirectory.
|
|
||||||
- **Relative:** `[other](./other.md)`.
|
|
||||||
- A link asserts an *untyped* relationship; the kind is conveyed by surrounding prose.
|
|
||||||
- **Broken links are tolerated** — they may represent not-yet-written knowledge. Never
|
|
||||||
an error.
|
|
||||||
|
|
||||||
## Conformance (§9) — what the validator enforces
|
|
||||||
|
|
||||||
A bundle is conformant if:
|
|
||||||
1. Every non-reserved `.md` has a parseable YAML frontmatter block.
|
|
||||||
2. Every frontmatter block has a non-empty `type`.
|
|
||||||
3. `index.md` / `log.md` follow their structure when present.
|
|
||||||
|
|
||||||
Consumers MUST NOT reject for: missing optional fields · unknown `type` values ·
|
|
||||||
unknown extra keys · broken cross-links · missing `index.md`.
|
|
||||||
|
|
||||||
Run: `python3 ../scripts/okf_validate.py <bundle>` (exit 0 = conformant; broken links
|
|
||||||
are warnings).
|
|
||||||
|
|
||||||
## Producer authoring rules
|
|
||||||
|
|
||||||
1. **One concept per file.** Choose a directory layout that fits the domain
|
|
||||||
(`datasets/`, `tables/`, `metrics/`, `references/`, `playbooks/`…).
|
|
||||||
2. **Always set `type`** — descriptive and self-explanatory.
|
|
||||||
3. Add `title` + a one-sentence `description` to every concept (drives indexes).
|
|
||||||
4. Set `resource` for concepts bound to a real asset; omit for abstract ones.
|
|
||||||
5. **Cross-link** related concepts with bundle-relative links; reference FKs/joins in
|
|
||||||
prose.
|
|
||||||
6. Add a `# Citations` section (numbered) whenever the body makes externally-sourced
|
|
||||||
claims; cite as URLs, bundle paths, or `references/<slug>` concepts.
|
|
||||||
7. Generate an `index.md` per directory (and the root) listing children with their
|
|
||||||
descriptions, for progressive disclosure.
|
|
||||||
8. **Self-validate** before declaring done; fix every conformance error.
|
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
`<major>.<minor>`. Minor = backward-compatible additions; major = breaking changes.
|
|
||||||
Bundles may declare `okf_version: "0.1"` in the root `index.md` frontmatter.
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"""Shared OKF v0.1 parsing utilities (Python standard library only)."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
RESERVED = {"index.md", "log.md"}
|
|
||||||
_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
|
|
||||||
_BLOCK_INDICATORS = {">", "|", ">-", "|-", ">+", "|+"}
|
|
||||||
|
|
||||||
|
|
||||||
class FrontmatterError(ValueError):
|
|
||||||
"""Raised when a frontmatter block is present but cannot be parsed."""
|
|
||||||
|
|
||||||
|
|
||||||
def split_frontmatter(text):
|
|
||||||
"""Return (raw_frontmatter, body). raw is None if there is no leading '---' block."""
|
|
||||||
if not text.startswith("---"):
|
|
||||||
return None, text
|
|
||||||
lines = text.splitlines()
|
|
||||||
if lines[0].strip() != "---":
|
|
||||||
return None, text
|
|
||||||
for i in range(1, len(lines)):
|
|
||||||
if lines[i].strip() == "---":
|
|
||||||
return "\n".join(lines[1:i]), "\n".join(lines[i + 1:])
|
|
||||||
raise FrontmatterError("opening '---' without a closing '---'")
|
|
||||||
|
|
||||||
|
|
||||||
def _scalar(value):
|
|
||||||
value = value.strip()
|
|
||||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
||||||
return value[1:-1]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def parse_yaml_subset(raw):
|
|
||||||
"""Parse the small YAML subset OKF bundles use in practice.
|
|
||||||
|
|
||||||
Handles: ``key: value``; inline lists ``key: [a, b]``; block lists
|
|
||||||
(``key:`` followed by ``- item`` lines); folded multi-line scalars
|
|
||||||
(a value continued on following indented lines); and ``>``/``|`` block
|
|
||||||
scalars. Surrounding quotes are stripped from scalar values. This is a
|
|
||||||
deliberately small parser — not a full YAML implementation.
|
|
||||||
"""
|
|
||||||
lines = raw.splitlines()
|
|
||||||
n = len(lines)
|
|
||||||
meta = {}
|
|
||||||
i = 0
|
|
||||||
while i < n:
|
|
||||||
line = lines[i]
|
|
||||||
stripped = line.strip()
|
|
||||||
if not stripped or stripped.startswith("#"):
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
if ":" not in line:
|
|
||||||
raise FrontmatterError("unparseable frontmatter line: %r" % line)
|
|
||||||
key, _, value = line.partition(":")
|
|
||||||
key, value = key.strip(), value.strip()
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
# Inline list: key: [a, b]
|
|
||||||
if value.startswith("[") and value.endswith("]"):
|
|
||||||
inner = value[1:-1].strip()
|
|
||||||
meta[key] = [s for s in (_scalar(x) for x in inner.split(",")) if s] if inner else []
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Empty value: either a block list (- item lines) or a continued scalar.
|
|
||||||
if value == "":
|
|
||||||
items = []
|
|
||||||
while i < n and lines[i].strip().startswith("-"):
|
|
||||||
items.append(_scalar(lines[i].strip()[1:].strip()))
|
|
||||||
i += 1
|
|
||||||
if items:
|
|
||||||
meta[key] = items
|
|
||||||
else:
|
|
||||||
cont = []
|
|
||||||
i = _collect_indented(lines, i, n, cont)
|
|
||||||
meta[key] = " ".join(cont)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Explicit block scalar indicator (> folded, | literal).
|
|
||||||
if value in _BLOCK_INDICATORS:
|
|
||||||
cont = []
|
|
||||||
i = _collect_indented(lines, i, n, cont)
|
|
||||||
meta[key] = ("\n" if value[0] == "|" else " ").join(cont)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Plain scalar, possibly folded across following indented lines.
|
|
||||||
parts = [value]
|
|
||||||
i = _collect_indented(lines, i, n, parts, skip_list_items=True)
|
|
||||||
meta[key] = _scalar(" ".join(parts))
|
|
||||||
return meta
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_indented(lines, i, n, out, skip_list_items=False):
|
|
||||||
"""Append stripped indented continuation lines to ``out``; return new index."""
|
|
||||||
while i < n and lines[i][:1].isspace() and lines[i].strip():
|
|
||||||
if skip_list_items and lines[i].strip().startswith("-"):
|
|
||||||
break
|
|
||||||
out.append(lines[i].strip())
|
|
||||||
i += 1
|
|
||||||
return i
|
|
||||||
|
|
||||||
|
|
||||||
def parse_frontmatter(text):
|
|
||||||
"""Return (meta, body). meta is None when no frontmatter block is present."""
|
|
||||||
raw, body = split_frontmatter(text)
|
|
||||||
if raw is None:
|
|
||||||
return None, text
|
|
||||||
return parse_yaml_subset(raw), body
|
|
||||||
|
|
||||||
|
|
||||||
def iter_concepts(bundle_dir):
|
|
||||||
"""Yield Path for every non-reserved .md file under bundle_dir, sorted."""
|
|
||||||
root = Path(bundle_dir)
|
|
||||||
for path in sorted(root.rglob("*.md")):
|
|
||||||
if path.name not in RESERVED:
|
|
||||||
yield path
|
|
||||||
|
|
||||||
|
|
||||||
def concept_id(bundle_dir, path):
|
|
||||||
"""Concept ID = bundle-relative path with the .md suffix removed."""
|
|
||||||
rel = Path(path).relative_to(Path(bundle_dir)).as_posix()
|
|
||||||
return rel[:-3] if rel.endswith(".md") else rel
|
|
||||||
|
|
||||||
|
|
||||||
def extract_links(body):
|
|
||||||
"""Return the list of link targets from markdown links in body."""
|
|
||||||
return _LINK_RE.findall(body)
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""OKF v0.1 conformance + broken-link validator (Python standard library only).
|
|
||||||
|
|
||||||
Conformance (SPEC.md section 9): every non-reserved .md has a parseable
|
|
||||||
frontmatter block with a non-empty `type`. Broken cross-links are reported as
|
|
||||||
warnings, never errors (consumers MUST tolerate them).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
||||||
from okf_common import (FrontmatterError, concept_id, extract_links,
|
|
||||||
iter_concepts, parse_frontmatter)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_link(bundle, path, target):
|
|
||||||
"""Return a Path for an internal .md link, or None for external/anchor/non-md."""
|
|
||||||
t = target.split("#", 1)[0].strip()
|
|
||||||
if not t or "://" in t or t.startswith("mailto:") or not t.endswith(".md"):
|
|
||||||
return None
|
|
||||||
if t.startswith("/"):
|
|
||||||
return bundle / t.lstrip("/")
|
|
||||||
return path.parent / t
|
|
||||||
|
|
||||||
|
|
||||||
def validate_bundle(bundle_dir):
|
|
||||||
bundle = Path(bundle_dir)
|
|
||||||
errors, warnings = [], []
|
|
||||||
concept_files = list(iter_concepts(bundle))
|
|
||||||
existing = {p.resolve() for p in bundle.rglob("*.md")}
|
|
||||||
for path in concept_files:
|
|
||||||
cid = concept_id(bundle, path)
|
|
||||||
text = path.read_text(encoding="utf-8")
|
|
||||||
try:
|
|
||||||
meta, body = parse_frontmatter(text)
|
|
||||||
except FrontmatterError as exc:
|
|
||||||
errors.append({"concept": cid, "rule": "frontmatter", "message": str(exc)})
|
|
||||||
continue
|
|
||||||
if meta is None:
|
|
||||||
errors.append({"concept": cid, "rule": "frontmatter",
|
|
||||||
"message": "missing YAML frontmatter block"})
|
|
||||||
continue
|
|
||||||
if not str(meta.get("type", "")).strip():
|
|
||||||
errors.append({"concept": cid, "rule": "type",
|
|
||||||
"message": "missing or empty required 'type' field"})
|
|
||||||
for target in extract_links(body):
|
|
||||||
resolved = _resolve_link(bundle, path, target)
|
|
||||||
if resolved is not None and resolved.resolve() not in existing:
|
|
||||||
warnings.append({"concept": cid, "rule": "broken_link",
|
|
||||||
"message": "link target not found: %s" % target})
|
|
||||||
return {
|
|
||||||
"bundle": str(bundle),
|
|
||||||
"concepts": len(concept_files),
|
|
||||||
"conformant": len(errors) == 0,
|
|
||||||
"errors": errors,
|
|
||||||
"warnings": warnings,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def format_report(report):
|
|
||||||
status = "CONFORMANT" if report["conformant"] else "NON-CONFORMANT"
|
|
||||||
lines = [
|
|
||||||
"OKF v0.1 validation: %s" % report["bundle"],
|
|
||||||
" concepts: %d status: %s" % (report["concepts"], status),
|
|
||||||
" errors: %d warnings: %d" % (len(report["errors"]), len(report["warnings"])),
|
|
||||||
]
|
|
||||||
for e in report["errors"]:
|
|
||||||
lines.append(" ERROR [%s] %s: %s" % (e["concept"], e["rule"], e["message"]))
|
|
||||||
for w in report["warnings"]:
|
|
||||||
lines.append(" WARN [%s] %s: %s" % (w["concept"], w["rule"], w["message"]))
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
ap = argparse.ArgumentParser(description="Validate an OKF v0.1 bundle.")
|
|
||||||
ap.add_argument("bundle", help="Path to the bundle directory")
|
|
||||||
ap.add_argument("--json", action="store_true", help="Emit a JSON report")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
report = validate_bundle(args.bundle)
|
|
||||||
print(json.dumps(report, indent=2) if args.json else format_report(report))
|
|
||||||
return 0 if report["conformant"] else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate a minimal, self-contained OKF bundle visualizer (stdlib only).
|
|
||||||
|
|
||||||
Emits one HTML file: a Cytoscape.js force-directed graph of the bundle's
|
|
||||||
concepts (nodes colored by `type`), with a side panel that renders the
|
|
||||||
selected concept's markdown body via marked. Both libraries load from a CDN;
|
|
||||||
the bundle is embedded as JSON, so no backend and no data leaves the page.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import html
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
||||||
from okf_common import (concept_id, extract_links, iter_concepts,
|
|
||||||
parse_frontmatter)
|
|
||||||
|
|
||||||
|
|
||||||
def _target_id(bundle, path, target, ids):
|
|
||||||
t = target.split("#", 1)[0].strip()
|
|
||||||
if not t or "://" in t or not t.endswith(".md"):
|
|
||||||
return None
|
|
||||||
if t.startswith("/"):
|
|
||||||
cid = t.lstrip("/")[:-3]
|
|
||||||
else:
|
|
||||||
rel = (path.parent / t).resolve().relative_to(Path(bundle).resolve())
|
|
||||||
cid = rel.as_posix()[:-3]
|
|
||||||
return cid if cid in ids else None
|
|
||||||
|
|
||||||
|
|
||||||
def build_graph(bundle_dir):
|
|
||||||
bundle = Path(bundle_dir)
|
|
||||||
concepts = list(iter_concepts(bundle))
|
|
||||||
ids = {concept_id(bundle, p) for p in concepts}
|
|
||||||
nodes, edges = [], []
|
|
||||||
for path in concepts:
|
|
||||||
cid = concept_id(bundle, path)
|
|
||||||
text = path.read_text(encoding="utf-8")
|
|
||||||
try:
|
|
||||||
meta, body = parse_frontmatter(text)
|
|
||||||
except Exception:
|
|
||||||
meta, body = None, text
|
|
||||||
meta = meta or {}
|
|
||||||
nodes.append({
|
|
||||||
"id": cid,
|
|
||||||
"type": str(meta.get("type", "Concept")),
|
|
||||||
"title": str(meta.get("title", Path(cid).name)),
|
|
||||||
"description": str(meta.get("description", "")),
|
|
||||||
"body": body,
|
|
||||||
})
|
|
||||||
for target in extract_links(body):
|
|
||||||
tid = _target_id(bundle, path, target, ids)
|
|
||||||
if tid and tid != cid:
|
|
||||||
edges.append({"source": cid, "target": tid})
|
|
||||||
return {"nodes": nodes, "edges": edges}
|
|
||||||
|
|
||||||
|
|
||||||
_HTML = """<!DOCTYPE html>
|
|
||||||
<html lang="en"><head><meta charset="utf-8">
|
|
||||||
<title>__NAME__ — OKF Viewer</title>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.2/cytoscape.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
||||||
<style>
|
|
||||||
body{margin:0;font-family:system-ui,sans-serif;display:flex;height:100vh}
|
|
||||||
#cy{flex:1;background:#fafafa}
|
|
||||||
#panel{width:380px;overflow:auto;padding:16px;border-left:1px solid #ddd;box-sizing:border-box}
|
|
||||||
#panel h2{margin:.2em 0;font-size:16px} table{border-collapse:collapse} td,th{border:1px solid #ddd;padding:2px 6px}
|
|
||||||
.type{display:inline-block;font-size:11px;background:#eef;color:#225;padding:2px 6px;border-radius:4px}
|
|
||||||
header{position:absolute;top:8px;left:12px;font-weight:600;color:#333}
|
|
||||||
</style></head><body>
|
|
||||||
<header>__NAME__</header><div id="cy"></div>
|
|
||||||
<div id="panel"><p>Click a concept node to view it.</p></div>
|
|
||||||
<script>
|
|
||||||
const DATA = __DATA__;
|
|
||||||
const colors=["#4e79a7","#f28e2b","#e15759","#76b7b2","#59a14f","#edc948","#b07aa1","#ff9da7","#9c755f","#bab0ac"];
|
|
||||||
const types=[...new Set(DATA.nodes.map(n=>n.type))];
|
|
||||||
const colorOf={}; types.forEach((t,i)=>colorOf[t]=colors[i%colors.length]);
|
|
||||||
const byId={}; DATA.nodes.forEach(n=>byId[n.id]=n);
|
|
||||||
const elements=[];
|
|
||||||
DATA.nodes.forEach(n=>elements.push({data:{id:n.id,label:n.title,color:colorOf[n.type]}}));
|
|
||||||
DATA.edges.forEach(e=>elements.push({data:{source:e.source,target:e.target}}));
|
|
||||||
const cy=cytoscape({container:document.getElementById('cy'),elements,
|
|
||||||
style:[{selector:'node',style:{'label':'data(label)','font-size':'9px','background-color':'data(color)','width':14,'height':14,'color':'#333'}},
|
|
||||||
{selector:'edge',style:{'width':1,'line-color':'#bbb','target-arrow-color':'#bbb','target-arrow-shape':'triangle','curve-style':'bezier','arrow-scale':0.7}}],
|
|
||||||
layout:{name:'cose',animate:false}});
|
|
||||||
cy.on('tap','node',evt=>{const n=byId[evt.target.id()];
|
|
||||||
document.getElementById('panel').innerHTML='<span class="type">'+n.type+'</span><h2>'+n.title+'</h2>'+
|
|
||||||
(n.description?'<p><em>'+n.description+'</em></p>':'')+'<hr>'+marked.parse(n.body||'');});
|
|
||||||
</script></body></html>"""
|
|
||||||
|
|
||||||
|
|
||||||
def render_html(graph, name="OKF Bundle"):
|
|
||||||
data = json.dumps(graph, ensure_ascii=False).replace("</", "<\\/")
|
|
||||||
return _HTML.replace("__NAME__", html.escape(name)).replace("__DATA__", data)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
ap = argparse.ArgumentParser(description="Visualize an OKF bundle as self-contained HTML.")
|
|
||||||
ap.add_argument("--bundle", required=True)
|
|
||||||
ap.add_argument("--out")
|
|
||||||
ap.add_argument("--name")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
bundle = Path(args.bundle)
|
|
||||||
graph = build_graph(bundle)
|
|
||||||
out = Path(args.out) if args.out else bundle / "viz.html"
|
|
||||||
out.write_text(render_html(graph, args.name or bundle.name), encoding="utf-8")
|
|
||||||
print("Wrote %s (%d nodes, %d edges)" % (out, len(graph["nodes"]), len(graph["edges"])))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# ourdigital-okf scripts use the Python standard library only.
|
|
||||||
# No third-party runtime dependencies. Tests use stdlib `unittest`.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
---
|
|
||||||
type: BigQuery Dataset
|
|
||||||
title: Sales
|
|
||||||
description: All sales-related tables for the retail business.
|
|
||||||
resource: https://console.cloud.google.com/bigquery?p=acme&d=sales
|
|
||||||
tags: [sales]
|
|
||||||
timestamp: 2026-05-28T00:00:00Z
|
|
||||||
---
|
|
||||||
|
|
||||||
The sales dataset contains [orders](/tables/orders.md) and [customers](/tables/customers.md).
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Datasets
|
|
||||||
|
|
||||||
* [Sales](datasets/sales.md) - All sales-related tables.
|
|
||||||
|
|
||||||
# Tables
|
|
||||||
|
|
||||||
* [Orders](tables/orders.md) - One row per completed order.
|
|
||||||
* [Customers](tables/customers.md) - One row per customer.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
type: BigQuery Table
|
|
||||||
title: Customers
|
|
||||||
description: One row per customer.
|
|
||||||
resource: https://console.cloud.google.com/bigquery?p=acme&d=sales&t=customers
|
|
||||||
tags: [sales, customers]
|
|
||||||
timestamp: 2026-05-28T00:00:00Z
|
|
||||||
---
|
|
||||||
|
|
||||||
# Schema
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|---------------|--------|-----------------------|
|
|
||||||
| `customer_id` | STRING | Unique customer id. |
|
|
||||||
|
|
||||||
Referenced by [orders](/tables/orders.md).
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
type: BigQuery Table
|
|
||||||
title: Orders
|
|
||||||
description: One row per completed customer order.
|
|
||||||
resource: https://console.cloud.google.com/bigquery?p=acme&d=sales&t=orders
|
|
||||||
tags: [sales, orders]
|
|
||||||
timestamp: 2026-05-28T00:00:00Z
|
|
||||||
---
|
|
||||||
|
|
||||||
# Schema
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|---------------|---------|------------------------------------------|
|
|
||||||
| `order_id` | STRING | Unique order identifier. |
|
|
||||||
| `customer_id` | STRING | FK to [customers](/tables/customers.md). |
|
|
||||||
|
|
||||||
Part of the [sales dataset](/datasets/sales.md).
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import sys, unittest
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from okf_common import (parse_frontmatter, parse_yaml_subset,
|
|
||||||
extract_links, concept_id, FrontmatterError)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFrontmatter(unittest.TestCase):
|
|
||||||
def test_parse_basic(self):
|
|
||||||
text = "---\ntype: BigQuery Table\ntitle: Orders\ntags: [sales, revenue]\n---\n\n# Body\n"
|
|
||||||
meta, body = parse_frontmatter(text)
|
|
||||||
self.assertEqual(meta["type"], "BigQuery Table")
|
|
||||||
self.assertEqual(meta["title"], "Orders")
|
|
||||||
self.assertEqual(meta["tags"], ["sales", "revenue"])
|
|
||||||
self.assertIn("# Body", body)
|
|
||||||
|
|
||||||
def test_url_value_with_colons(self):
|
|
||||||
meta, _ = parse_frontmatter("---\ntype: X\nresource: https://e.com/a?b=c\n---\nbody")
|
|
||||||
self.assertEqual(meta["resource"], "https://e.com/a?b=c")
|
|
||||||
|
|
||||||
def test_quoted_value(self):
|
|
||||||
self.assertEqual(parse_yaml_subset('okf_version: "0.1"')["okf_version"], "0.1")
|
|
||||||
|
|
||||||
def test_empty_list(self):
|
|
||||||
self.assertEqual(parse_yaml_subset("tags: []")["tags"], [])
|
|
||||||
|
|
||||||
def test_block_list(self):
|
|
||||||
raw = "type: BigQuery Dataset\ntags:\n- cryptocurrency\n- bitcoin\n- public data\ntimestamp: '2026-05-28T22:44:47+00:00'"
|
|
||||||
meta = parse_yaml_subset(raw)
|
|
||||||
self.assertEqual(meta["tags"], ["cryptocurrency", "bitcoin", "public data"])
|
|
||||||
self.assertEqual(meta["timestamp"], "2026-05-28T22:44:47+00:00")
|
|
||||||
|
|
||||||
def test_folded_multiline_scalar(self):
|
|
||||||
raw = "description: This dataset contains a complete history of the Bitcoin\n blockchain and updates every 10 minutes.\ntype: BigQuery Dataset"
|
|
||||||
meta = parse_yaml_subset(raw)
|
|
||||||
self.assertEqual(
|
|
||||||
meta["description"],
|
|
||||||
"This dataset contains a complete history of the Bitcoin blockchain and updates every 10 minutes.",
|
|
||||||
)
|
|
||||||
self.assertEqual(meta["type"], "BigQuery Dataset")
|
|
||||||
|
|
||||||
def test_no_frontmatter(self):
|
|
||||||
meta, body = parse_frontmatter("# Just markdown\n")
|
|
||||||
self.assertIsNone(meta)
|
|
||||||
self.assertEqual(body, "# Just markdown\n")
|
|
||||||
|
|
||||||
def test_unclosed_raises(self):
|
|
||||||
with self.assertRaises(FrontmatterError):
|
|
||||||
parse_frontmatter("---\ntype: X\n\nbody without close")
|
|
||||||
|
|
||||||
|
|
||||||
class TestLinksAndIds(unittest.TestCase):
|
|
||||||
def test_extract_links(self):
|
|
||||||
body = "See [a](/tables/a.md) and [b](./b.md) and [ext](https://x.com)."
|
|
||||||
self.assertEqual(extract_links(body), ["/tables/a.md", "./b.md", "https://x.com"])
|
|
||||||
|
|
||||||
def test_concept_id(self):
|
|
||||||
self.assertEqual(concept_id("/bundle", "/bundle/tables/users.md"), "tables/users")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import sys, tempfile, unittest
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from okf_validate import validate_bundle
|
|
||||||
|
|
||||||
FIX = Path(__file__).resolve().parent / "fixtures" / "mini_bundle"
|
|
||||||
GOOGLE = Path.home() / "Documents/reference-library/open-knowledge-format/okf/bundles"
|
|
||||||
|
|
||||||
|
|
||||||
def write_bundle(root, files):
|
|
||||||
for rel, content in files.items():
|
|
||||||
p = Path(root) / rel
|
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
p.write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidate(unittest.TestCase):
|
|
||||||
def test_mini_fixture_conformant(self):
|
|
||||||
report = validate_bundle(FIX)
|
|
||||||
self.assertTrue(report["conformant"], report["errors"])
|
|
||||||
self.assertGreaterEqual(report["concepts"], 3)
|
|
||||||
|
|
||||||
def test_missing_type_is_error(self):
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
write_bundle(d, {"tables/x.md": "---\ntitle: X\n---\nbody"})
|
|
||||||
report = validate_bundle(d)
|
|
||||||
self.assertFalse(report["conformant"])
|
|
||||||
self.assertTrue(any(e["rule"] == "type" for e in report["errors"]))
|
|
||||||
|
|
||||||
def test_missing_frontmatter_is_error(self):
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
write_bundle(d, {"tables/x.md": "# no frontmatter\n"})
|
|
||||||
self.assertFalse(validate_bundle(d)["conformant"])
|
|
||||||
|
|
||||||
def test_reserved_files_not_required_to_have_type(self):
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
write_bundle(d, {"index.md": "# Index\n* [x](/tables/x.md)\n",
|
|
||||||
"tables/x.md": "---\ntype: T\n---\nb"})
|
|
||||||
self.assertTrue(validate_bundle(d)["conformant"])
|
|
||||||
|
|
||||||
def test_broken_link_is_warning_not_error(self):
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
write_bundle(d, {"tables/x.md": "---\ntype: T\n---\nSee [y](/tables/y.md)."})
|
|
||||||
report = validate_bundle(d)
|
|
||||||
self.assertTrue(report["conformant"])
|
|
||||||
self.assertTrue(any(w["rule"] == "broken_link" for w in report["warnings"]))
|
|
||||||
|
|
||||||
|
|
||||||
class TestGoogleBundles(unittest.TestCase):
|
|
||||||
@unittest.skipUnless(GOOGLE.exists(), "Google reference bundles not present")
|
|
||||||
def test_google_sample_bundles_conformant(self):
|
|
||||||
for name in ("crypto_bitcoin", "ga4", "stackoverflow"):
|
|
||||||
bundle = GOOGLE / name
|
|
||||||
if not bundle.exists():
|
|
||||||
continue
|
|
||||||
report = validate_bundle(bundle)
|
|
||||||
self.assertTrue(
|
|
||||||
report["conformant"],
|
|
||||||
"%s non-conformant; first errors: %s" % (name, report["errors"][:3]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import sys, tempfile, unittest
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from okf_viz import build_graph, render_html, main
|
|
||||||
|
|
||||||
FIX = Path(__file__).resolve().parent / "fixtures" / "mini_bundle"
|
|
||||||
|
|
||||||
|
|
||||||
class TestViz(unittest.TestCase):
|
|
||||||
def test_build_graph_nodes_and_edges(self):
|
|
||||||
g = build_graph(FIX)
|
|
||||||
ids = {n["id"] for n in g["nodes"]}
|
|
||||||
self.assertIn("tables/orders", ids)
|
|
||||||
self.assertTrue(any(e["source"] == "tables/orders" and e["target"] == "tables/customers"
|
|
||||||
for e in g["edges"]))
|
|
||||||
|
|
||||||
def test_render_html_embeds_data_and_cdn(self):
|
|
||||||
html = render_html(build_graph(FIX), "Mini")
|
|
||||||
self.assertIn("cytoscape", html)
|
|
||||||
self.assertIn('"nodes"', html)
|
|
||||||
|
|
||||||
def test_render_html_escapes_script_close(self):
|
|
||||||
g = {"nodes": [{"id": "x", "type": "T", "title": "x",
|
|
||||||
"description": "", "body": "</script><b>hi</b>"}], "edges": []}
|
|
||||||
html = render_html(g, "X")
|
|
||||||
self.assertNotIn("</script><b>hi", html)
|
|
||||||
|
|
||||||
def test_main_writes_file(self):
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
out = Path(d) / "v.html"
|
|
||||||
self.assertEqual(main(["--bundle", str(FIX), "--out", str(out)]), 0)
|
|
||||||
self.assertTrue(out.exists() and out.stat().st_size > 500)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
name: ourdigital-okf
|
|
||||||
description: |
|
|
||||||
Produce, visualize, and validate Google Open Knowledge Format (OKF) v0.1
|
|
||||||
knowledge bundles. Activated with the "ourdigital" or "our" keyword for OKF work.
|
|
||||||
|
|
||||||
Triggers (ourdigital or our prefix):
|
|
||||||
- "ourdigital okf", "our okf"
|
|
||||||
- "ourdigital open knowledge format", "our knowledge bundle"
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Produce conformant OKF bundles from a pasted/exported schema, docs, or a research topic
|
|
||||||
- Validate a bundle for OKF v0.1 conformance + broken-link report
|
|
||||||
- Visualize a bundle as a self-contained interactive graph
|
|
||||||
version: "1.0"
|
|
||||||
author: OurDigital
|
|
||||||
environment: Desktop
|
|
||||||
---
|
|
||||||
|
|
||||||
# OurDigital OKF (Desktop)
|
|
||||||
|
|
||||||
Work with **Open Knowledge Format (OKF) v0.1** — an open standard that represents knowledge
|
|
||||||
as a directory of markdown files with YAML frontmatter. Each file is a *concept* (table,
|
|
||||||
dataset, metric, playbook, API, reference); the path is its identity; markdown links form a
|
|
||||||
graph. The only required frontmatter field is `type`.
|
|
||||||
|
|
||||||
## What this skill helps with
|
|
||||||
|
|
||||||
- **Produce** — draft a conformant OKF bundle from a pasted/exported schema (BigQuery DDL,
|
|
||||||
GA4 export schema, CSV/JSON-Schema/OpenAPI), from existing docs/markdown, or from a
|
|
||||||
research topic. Write one `type`-bearing concept per file, cross-link them with
|
|
||||||
bundle-relative links, and add an `index.md` per directory for progressive disclosure.
|
|
||||||
- **Validate** — check that every non-reserved `.md` has a parseable frontmatter block
|
|
||||||
with a non-empty `type`; treat broken cross-links as tolerated warnings.
|
|
||||||
- **Visualize** — render the bundle as a concept graph.
|
|
||||||
|
|
||||||
## OKF authoring rules (summary)
|
|
||||||
|
|
||||||
- One concept per file; `type` is required; add `title`, `description`, `resource`,
|
|
||||||
`tags`, `timestamp` when applicable.
|
|
||||||
- Reserved filenames: `index.md` (directory listing), `log.md` (date-grouped history).
|
|
||||||
- Cross-link with bundle-relative paths (`/tables/customers.md`); broken links are allowed.
|
|
||||||
- Conventional body headings: `# Schema`, `# Examples`, `# Citations`.
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
The validator and visualizer are Python standard-library scripts in `code/scripts/`. In
|
|
||||||
the Claude Desktop environment, run them from a terminal:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 code/scripts/okf_validate.py <bundle>
|
|
||||||
python3 code/scripts/okf_viz.py --bundle <bundle>
|
|
||||||
```
|
|
||||||
|
|
||||||
Always confirm the output directory with the user before creating a bundle. See
|
|
||||||
`code/references/okf-spec-v0.1.md` for the full authoring rules.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# Skill metadata (extracted from SKILL.md frontmatter)
|
|
||||||
|
|
||||||
name: ourdigital-okf
|
|
||||||
description: |
|
|
||||||
Produce, visualize, and validate Google Open Knowledge Format (OKF) v0.1 bundles.
|
|
||||||
Triggers: "ourdigital okf", "our okf", "open knowledge format", "knowledge bundle".
|
|
||||||
|
|
||||||
version: "1.0"
|
|
||||||
author: OurDigital
|
|
||||||
environment: Desktop
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Changelog — ourdigital-okf
|
|
||||||
|
|
||||||
## 1.0 — 2026-06-16
|
|
||||||
|
|
||||||
Initial release.
|
|
||||||
|
|
||||||
- **produce** / **validate** / **visualize** modes for Open Knowledge Format (OKF) v0.1 bundles.
|
|
||||||
- `okf_common.py` — stdlib YAML-subset frontmatter + link parser (inline/block lists, folded
|
|
||||||
and `>`/`|` block scalars, quoted scalars).
|
|
||||||
- `okf_validate.py` — conformance (parseable frontmatter + non-empty `type`) + broken-link
|
|
||||||
warnings; text/JSON output; meaningful exit code.
|
|
||||||
- `okf_viz.py` — minimal self-contained Cytoscape graph viewer (CDN libs, no backend).
|
|
||||||
- Test suite: 20 stdlib `unittest` tests. Verified CONFORMANT against Google's reference
|
|
||||||
bundles (crypto_bitcoin, ga4, stackoverflow).
|
|
||||||
- End-to-end verified: produce a 4-concept analytics bundle → validate (0 errors) →
|
|
||||||
visualize (4 nodes, 9 edges).
|
|
||||||
- `install.sh` symlinks the skill into `~/.claude/skills/ourdigital-okf`.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ourdigital-okf installer — symlinks the skill into ~/.claude/skills/ourdigital-okf
|
|
||||||
set -e
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
SKILLS_DIR="$HOME/.claude/skills"
|
|
||||||
TARGET="$SKILLS_DIR/ourdigital-okf"
|
|
||||||
|
|
||||||
mkdir -p "$SKILLS_DIR"
|
|
||||||
if [ -e "$TARGET" ] || [ -L "$TARGET" ]; then
|
|
||||||
echo "Removing existing $TARGET"
|
|
||||||
rm -rf "$TARGET"
|
|
||||||
fi
|
|
||||||
ln -s "$SCRIPT_DIR" "$TARGET"
|
|
||||||
echo "Linked $TARGET -> $SCRIPT_DIR"
|
|
||||||
echo "Verifying scripts (stdlib-only)…"
|
|
||||||
python3 "$SCRIPT_DIR/code/scripts/okf_validate.py" \
|
|
||||||
"$SCRIPT_DIR/code/scripts/tests/fixtures/mini_bundle" >/dev/null && echo "validate OK"
|
|
||||||
echo "Done. Invoke with /ourdigital-okf"
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# _dintel-shared — D.intelligence Agent Corps Shared Environment
|
# _dintel-shared — D.intelligence Agent Corps Shared Environment
|
||||||
|
|
||||||
Shared Python package and utilities used by all `dintel-` Claude Skills (agents 70-78, 88).
|
Shared Python package and utilities used by all `dintel-` Claude Skills (agents 70-77, 88).
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# D.intelligence Agent Corps — User Guide
|
# D.intelligence Agent Corps — User Guide
|
||||||
|
|
||||||
> **Version**: 1.1.0
|
> **Version**: 1.0.0
|
||||||
> **Updated**: 2026-07-01
|
> **Updated**: 2026-03-09
|
||||||
> **Author**: D.intelligence Engineering
|
> **Author**: D.intelligence Engineering
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
D.intelligence Agent Corps는 10개의 AI 에이전트로 구성된 스킬 번들입니다.
|
D.intelligence Agent Corps는 9개의 AI 에이전트로 구성된 스킬 번들입니다.
|
||||||
모든 에이전트는 D.intelligence의 브랜드 가이드, 서비스 아키텍처, 가격 정책을 공유하며,
|
모든 에이전트는 D.intelligence의 브랜드 가이드, 서비스 아키텍처, 가격 정책을 공유하며,
|
||||||
일관된 품질의 결과물을 생성합니다.
|
일관된 품질의 결과물을 생성합니다.
|
||||||
|
|
||||||
@@ -24,7 +24,6 @@ D.intelligence Agent Corps는 10개의 AI 에이전트로 구성된 스킬 번
|
|||||||
│ #75 Marketing Mgr ←── 콘텐츠 파이프라인 │
|
│ #75 Marketing Mgr ←── 콘텐츠 파이프라인 │
|
||||||
│ #76 Back Office Mgr ←── 계약/인보이스/HR │
|
│ #76 Back Office Mgr ←── 계약/인보이스/HR │
|
||||||
│ #77 Account Mgr ←── 고객 관계 관리 │
|
│ #77 Account Mgr ←── 고객 관계 관리 │
|
||||||
│ #78 Campaign Designer ←── 캠페인 3-게이트 기획 │
|
|
||||||
│ #88 Skill Update ←── 메타 에이전트 (일괄 변경)│
|
│ #88 Skill Update ←── 메타 에이전트 (일괄 변경)│
|
||||||
│ │
|
│ │
|
||||||
│ _dintel-shared/ ←── 공유 인프라 │
|
│ _dintel-shared/ ←── 공유 인프라 │
|
||||||
@@ -50,7 +49,7 @@ cd our-claude-skills/custom-skills/_dintel-shared/
|
|||||||
|-------|--------|---------|
|
|-------|--------|---------|
|
||||||
| **Auto** | #70 | Runs automatically when D.intelligence content is detected |
|
| **Auto** | #70 | Runs automatically when D.intelligence content is detected |
|
||||||
| **Auto + Ask** | #71 | Auto-activates, asks before making changes |
|
| **Auto + Ask** | #71 | Auto-activates, asks before making changes |
|
||||||
| **Draft & Wait** | #72, #73, #75, #76, #78 | Generates draft, then STOPS for Andrew's review |
|
| **Draft & Wait** | #72, #73, #75, #76 | Generates draft, then STOPS for Andrew's review |
|
||||||
| **Inquiry-driven** | #74 | Asks structured questions before recommending |
|
| **Inquiry-driven** | #74 | Asks structured questions before recommending |
|
||||||
| **Mixed** | #77 | Monitors autonomously, asks before client-facing actions |
|
| **Mixed** | #77 | Monitors autonomously, asks before client-facing actions |
|
||||||
| **Triggered** | #88 | Only runs when explicitly invoked by Andrew |
|
| **Triggered** | #88 | Only runs when explicitly invoked by Andrew |
|
||||||
@@ -358,36 +357,6 @@ B. 메시지 구조 (25/25)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### #78 Campaign Designer — 캠페인 디자이너
|
|
||||||
|
|
||||||
**What it does**: 캠페인/프로모션/이벤트/런칭 기획을 3-게이트 프로세스로 진행 — Discovery & Debate → Brief → Plan. 문서부터 만들지 않고, 방향에 먼저 합의합니다. D.intelligence 전용이 아니라 OurDigital·Jamie 등 어떤 브랜드의 캠페인이든 동일한 게이트로 진행합니다.
|
|
||||||
|
|
||||||
**When to use**:
|
|
||||||
- 캠페인/프로모션/이벤트/런칭 기획 요청
|
|
||||||
- 기획안을 바로 만들어달라는 요청이 와도, 방향 합의 없이 초안이 나온 전례가 있다면 먼저 게이트를 거치도록 제안
|
|
||||||
- Gate 2에서 정량 목표만 있고 정성/관계 지표가 없을 때
|
|
||||||
|
|
||||||
**Example use cases**:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. "이번 여름 프로모션 기획해줘"
|
|
||||||
→ Gate 1: 목적 1순위 확정 + 찬반 디베이트 + 프리모템 + 레퍼런스 사례
|
|
||||||
→ 결정 로그 제시 → 승인 대기
|
|
||||||
|
|
||||||
2. "Gate 1 승인, 브리프 만들어줘"
|
|
||||||
→ Gate 2: 오디언스/오퍼/메시지/톤 + 4층위 성과지표 확정
|
|
||||||
→ 캠페인 브리프 제시 → 승인 대기
|
|
||||||
|
|
||||||
3. "브리프 승인, 기획서 만들어줘"
|
|
||||||
→ Gate 3: marketing:campaign-plan + doc-generator로 문서화
|
|
||||||
→ 리스크/컴플라이언스는 "준비 점검 사항"에 일괄 정리
|
|
||||||
|
|
||||||
4. "Jamie 캠페인이야, 카피는 어디서 다듬어?"
|
|
||||||
→ Cross-brand routing: Jamie 브랜드 → 48 jamie-copy-trimmer(카피) + 41 jamie-brand-audit(컴플라이언스)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### #88 Skill Update — 스킬 업데이트 (메타 에이전트)
|
### #88 Skill Update — 스킬 업데이트 (메타 에이전트)
|
||||||
|
|
||||||
**What it does**: 공유 참조(브랜드, 가격, 서비스 모듈 등)가 변경될 때, 영향 받는 모든 스킬을 일괄 업데이트합니다.
|
**What it does**: 공유 참조(브랜드, 가격, 서비스 모듈 등)가 변경될 때, 영향 받는 모든 스킬을 일괄 업데이트합니다.
|
||||||
@@ -549,4 +518,3 @@ _dintel-shared/
|
|||||||
| Version | Date | Changes |
|
| Version | Date | Changes |
|
||||||
|---------|------|---------|
|
|---------|------|---------|
|
||||||
| 1.0.0 | 2026-03-09 | Initial release — 9 agents + shared infra |
|
| 1.0.0 | 2026-03-09 | Initial release — 9 agents + shared infra |
|
||||||
| 1.1.0 | 2026-07-01 | Added #78 Campaign Designer (Draft & Wait, cross-brand 3-gate campaign planning) — 10 agents + shared infra |
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,280 +0,0 @@
|
|||||||
# Notion Writer (Skill 32) — CLI Enhancements: File Uploads + Markdown Engine
|
|
||||||
|
|
||||||
> **Date**: 2026-06-27
|
|
||||||
> **Status**: Approved (brainstorming)
|
|
||||||
> **Scope**: Two additive features — (Tier 1) local file/image upload via the `ntn` CLI, and (Tier 2) an opt-in "markdown engine" that writes through Notion's native enhanced-markdown endpoints.
|
|
||||||
> **Predecessor**: 2026-04-27 Extended Block Coverage (callouts/toggles/columns/mentions), CLAUDE.md v1.2.0
|
|
||||||
> **Target version**: bump CLAUDE.md footer to v1.3.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Add two capabilities to `custom-skills/32-notion-writer/code/scripts/notion_writer.py`:
|
|
||||||
|
|
||||||
1. **Tier 1 — Local media uploads.** Today `` is effectively broken (the inline link regex leaves a stray `!` and renders the image as a text link). Make standalone image references work: local files are uploaded via `ntn files create` and embedded as Notion `image` blocks with a `file_upload` reference; remote URLs become `external` image blocks. This fills a genuine capability gap — the parser previously had no image support at all.
|
|
||||||
|
|
||||||
2. **Tier 2 — Markdown engine.** Add `--engine markdown` to write content through Notion's native enhanced-markdown endpoints (`POST /v1/pages` with `markdown`, `PATCH /v1/pages/:id/markdown`) instead of the local block converter. A dialect translator converts the skill's existing authoring syntax (GitHub alerts, Pandoc columns, `@[mentions]`) into Notion-flavored markdown so a single source document works in both engines.
|
|
||||||
|
|
||||||
The default engine stays `blocks` — every existing call and downstream script behaves identically. Both engines share one media-upload path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- **Re-platforming onto `ntn`** (Tier 3, rejected). The skill keeps its token-based SDK core; `ntn` is used only as a file-upload subprocess.
|
|
||||||
- **Reading pages back as markdown** (`GET /v1/pages/:id/markdown`). Not needed for a writer; parked.
|
|
||||||
- **Workspace-mismatch hard guard.** User chose plain shell-out; preflight surfaces the `ntn` target workspace as an informational note but does not block on mismatch.
|
|
||||||
- **Generic local attachments** (`<file>`, `<pdf>`, `<video>`, `<audio>`) in v1. The upload mechanism is media-type agnostic, but v1 wires only `![]()` images. Other media use the same walk later — parked.
|
|
||||||
- **Legacy markdown update commands** (`insert_content` for arbitrary positions, `replace_content_range`). Only `insert_content{position:end}` (append) and `replace_content` (replace) are used.
|
|
||||||
- **Bidirectional dialect translation** (Notion enhanced markdown → skill dialect). One direction only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architectural decisions (locked during brainstorming)
|
|
||||||
|
|
||||||
| Decision | Choice | Rationale |
|
|
||||||
|---|---|---|
|
|
||||||
| File-upload transport | **Shell out to `ntn files create --plain`** | User choice. The CLI does the full upload lifecycle in one command. |
|
|
||||||
| Upload-failure visibility | **Loud, not silent** — preflight checks `ntn` is installed + logged in and prints the target workspace; upload errors abort with the file path + `ntn` stderr | File uploads are workspace-scoped; a mismatch must surface, not silently misfire. |
|
|
||||||
| Markdown dialect handling | **Translate** skill dialect → Notion enhanced markdown | User choice — one authoring document works in both engines. |
|
|
||||||
| Engine selection | **`--engine {blocks,markdown}`, default `blocks`** | Zero regression for existing callers; opt-in to the new path. |
|
|
||||||
| Local-media trigger | **Auto-detect** local paths in `![]()`; remote URLs left external | User choice — most ergonomic; replaces the silent-strip behavior. |
|
|
||||||
| Parser purity | **Preserve it** — uploads happen in a post-parse block walk, never inside the parser | The 2026-04-27 design locked "parser makes no API calls"; honored by isolating impurity. |
|
|
||||||
| Markdown transport | **`client.request(...)` low-level calls**, not typed SDK methods | The markdown endpoints have no stable typed SDK methods; `request` is version-safe and avoids SDK coupling. |
|
|
||||||
| Notion API version | **`2026-03-11` for the markdown engine**; blocks engine keeps the SDK default (`2025-09-03`) | Markdown endpoints require the newer version; blocks path stays untouched. |
|
|
||||||
| Python env | **Create venv + `requirements.txt`** at the documented path | The documented venv doesn't exist; the skill can't currently run. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Module structure
|
|
||||||
|
|
||||||
Two new modules keep `notion_writer.py` from bloating; each has one purpose and is testable in isolation.
|
|
||||||
|
|
||||||
| File | Status | Role |
|
|
||||||
|---|---|---|
|
|
||||||
| `scripts/notion_writer.py` | modified | CLI surface, engine routing, post-parse media materialization, two-phase markdown writes |
|
|
||||||
| `scripts/_notion_compat.py` | modified | Version-aware `make_client`; markdown-endpoint helpers (`create_page_markdown`, `append_markdown`, `replace_markdown`) |
|
|
||||||
| `scripts/ntn_files.py` | **new** | `ntn files create` subprocess wrapper; preflight; `upload(path) -> file_upload_id` |
|
|
||||||
| `scripts/md_translate.py` | **new** | Pure functions: skill dialect → Notion enhanced markdown |
|
|
||||||
| `scripts/requirements.txt` | modified | Pin `notion-client`, `python-dotenv` |
|
|
||||||
| `scripts/venv/` | **new** | Created at documented path; not committed (gitignored) |
|
|
||||||
|
|
||||||
Dependencies between units:
|
|
||||||
- `notion_writer` depends on `_notion_compat`, `ntn_files`, `md_translate`.
|
|
||||||
- `md_translate` and the parser are pure (deterministic, no I/O).
|
|
||||||
- `ntn_files` owns all subprocess I/O.
|
|
||||||
- `_notion_compat` owns all Notion HTTP I/O.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tier 1 — Local media uploads (blocks engine)
|
|
||||||
|
|
||||||
### Parsing (pure)
|
|
||||||
Add an image detector to `_parse_lines`, ordered with the other block detectors. A **standalone** image line matches:
|
|
||||||
|
|
||||||
```python
|
|
||||||
IMAGE_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)\)\s*$')
|
|
||||||
```
|
|
||||||
|
|
||||||
`create_image_block(alt, target)` always emits the **external shape** regardless of whether `target` is local or remote:
|
|
||||||
|
|
||||||
```python
|
|
||||||
{"object":"block","type":"image",
|
|
||||||
"image":{"type":"external","external":{"url": target},
|
|
||||||
"caption": parse_rich_text(alt)}}
|
|
||||||
```
|
|
||||||
|
|
||||||
The parser stays pure and upload-unaware. Inline images inside a paragraph are out of scope (rare); only standalone image lines become image blocks.
|
|
||||||
|
|
||||||
### Materialization (impure, isolated)
|
|
||||||
After parsing and before sending, `notion_writer` runs a recursive block walk:
|
|
||||||
|
|
||||||
```python
|
|
||||||
materialize_local_media(blocks, base_dir, uploader) -> blocks
|
|
||||||
```
|
|
||||||
|
|
||||||
- Walks `blocks` and every nested `children` list (so images inside toggles/columns are covered).
|
|
||||||
- For each `image` block whose `external.url` is **not** `http(s):` and resolves to an existing file under `base_dir`:
|
|
||||||
- `file_upload_id = uploader.upload(resolved_path)`
|
|
||||||
- rewrite to `{"image":{"type":"file_upload","file_upload":{"id": file_upload_id}, "caption": ...}}`
|
|
||||||
- Remote/external images are left unchanged.
|
|
||||||
- A local path that does not exist on disk → abort with a clear error (`image references missing file: <path>`), since the user clearly intended a local embed.
|
|
||||||
|
|
||||||
`base_dir` = the `--file` argument's parent directory; for `--stdin`, the current working directory.
|
|
||||||
|
|
||||||
### Upload wrapper — `ntn_files.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
def preflight() -> WorkspaceInfo # cached; once per process
|
|
||||||
def upload(path: Path) -> str # returns file_upload_id
|
|
||||||
```
|
|
||||||
|
|
||||||
- `preflight()`:
|
|
||||||
- `shutil.which("ntn")` → if missing, raise with install hint (`curl -fsSL https://ntn.dev | bash`).
|
|
||||||
- `ntn api v1/users/me` (JSON) → parse `workspace_name` / `workspace_id`; print one informational line: `ntn → workspace "<name>"`. Does not block on mismatch (per locked decision). If the call fails (not logged in), raise with `ntn login` hint.
|
|
||||||
- `upload(path)`:
|
|
||||||
- `subprocess.run(["ntn","files","create","--plain"], stdin=open(path,"rb"), capture_output=True, text=True)`
|
|
||||||
- on non-zero exit → raise `NtnUploadError(path, stderr)`.
|
|
||||||
- parse stdout: the upload ID is the first tab-separated field of the first line.
|
|
||||||
- returns the ID. (Notion requires attaching within ~1 hour; the immediate block append in the same run is well inside that window.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tier 2 — Markdown engine
|
|
||||||
|
|
||||||
### Dialect translator — `md_translate.py`
|
|
||||||
|
|
||||||
`translate(content: str) -> str` converts the skill dialect to Notion enhanced markdown. Only three constructs differ; everything else passes through verbatim.
|
|
||||||
|
|
||||||
| Skill dialect | → Notion enhanced markdown |
|
|
||||||
|---|---|
|
|
||||||
| `> [!NOTE]` + contiguous `>` body | `<callout icon="ℹ️" color="blue_bg">\n\t<body>\n</callout>` |
|
|
||||||
| `::: columns` / `::: column` / `:::` | `<columns>\n\t<column>…</column>\n\t<column>…</column>\n</columns>` |
|
|
||||||
| `<details>` / `<summary>` toggle | `<details>\n<summary>…</summary>\n\t<children>\n</details>` — children **re-indented one tab** |
|
|
||||||
| `@[Title](id\|url)` | `<mention-page url="<resolved>">Title</mention-page>` |
|
|
||||||
| headings, lists, to-dos, quotes, fenced code, `---`, **bold**/*italic*/`code`/`~~strike~~`/links, pipe tables | **pass through unchanged** |
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Callout icon/color reuse the existing `ALERT_TYPES` map; Notion color names use the `_bg` suffix (`blue_bg`), not the block-API `blue_background`. A small adapter maps one to the other.
|
|
||||||
- Children inside `<callout>`, `<columns>`, and `<details>` are **tab-indented** one level (enhanced-markdown requirement — toggle and callout children "must be indented"). The translator emits tabs; this is why `<details>` is translated rather than passed through verbatim.
|
|
||||||
- Pipe tables pass through — Notion's own enhanced-markdown example renders a standard pipe table, so no `<table>` rewrite is needed in v1. (If live testing shows pipe tables are rejected, the fallback is a `<table>` rewrite — noted as the one open verification item.)
|
|
||||||
- Mention resolution reuses `extract_notion_id`; an unresolvable target degrades to plain `@Title` text (same posture as the blocks engine).
|
|
||||||
- The translator is line-oriented and reentrant for the two container constructs, mirroring `_parse_lines`.
|
|
||||||
|
|
||||||
### Transport helpers — `_notion_compat.py`
|
|
||||||
|
|
||||||
All via `client.request(...)` so no SDK typed-method dependency:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def make_client(api_key, notion_version=None) -> Client # version override added
|
|
||||||
def create_page_markdown(client, parent, properties, markdown) -> dict
|
|
||||||
# POST v1/pages body={parent, properties, markdown}
|
|
||||||
def append_markdown(client, page_id, markdown) -> dict
|
|
||||||
# PATCH v1/pages/{id}/markdown
|
|
||||||
# body={"type":"insert_content","insert_content":{"content":md,"position":{"type":"end"}}}
|
|
||||||
def replace_markdown(client, page_id, markdown, allow_deleting=False) -> dict
|
|
||||||
# PATCH v1/pages/{id}/markdown
|
|
||||||
# body={"type":"replace_content","replace_content":{"new_str":md,"allow_deleting_content":allow_deleting}}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Two-client split for version safety.** The markdown *write* helpers use a client built with `notion_version="2026-03-11"`. All schema/property/upsert-lookup operations (`resolve_data_source_id`, `get_schema`, `coerce_properties`, `find_existing_page`) continue on the **default-version** client, so the 2025-09-03 data-source behavior the skill already relies on is unchanged. Only the three markdown write calls cross to the newer version. The data-source parent (`{type:data_source_id,...}`) is expected to remain valid under `2026-03-11` (confirmed in the live smoke test).
|
|
||||||
|
|
||||||
### Routing by operation
|
|
||||||
|
|
||||||
| CLI op | blocks engine (default) | markdown engine |
|
|
||||||
|---|---|---|
|
|
||||||
| Create DB row (`-d -t`) | `pages.create(parent, properties, children=blocks)` | `create_page_markdown(parent, properties, translate(content))` |
|
|
||||||
| Append to page (`-p`) | `blocks.children.append` | `append_markdown(page_id, translate(content))` |
|
|
||||||
| Replace page (`-p -r`) | delete-all-blocks + append | `replace_markdown(page_id, translate(content))` |
|
|
||||||
| Upsert (`--upsert-by`) | unchanged (property update + body) | property update via SDK + body via `replace_markdown` |
|
|
||||||
|
|
||||||
The markdown `replace_content` path replaces the brittle paginated delete-every-block logic for that engine. `--upsert-by` lookup/property-coercion logic is shared unchanged; only the body write differs.
|
|
||||||
|
|
||||||
### Markdown engine + local images (two-phase write)
|
|
||||||
|
|
||||||
The markdown endpoints take a URL for ``, and a `file_upload` id is not a URL, so local images cannot be inlined into the markdown string. Handling, reusing the Tier 1 upload + append helpers:
|
|
||||||
|
|
||||||
1. Split standalone **local** image refs out of the content (same `IMAGE_RE`, local targets only; remote `` stay inline in the markdown); translate the remainder; write it (create / append / replace).
|
|
||||||
2. Upload the local images and append them as `image` blocks to the resulting page via `blocks.children.append`.
|
|
||||||
|
|
||||||
**Known limitation (documented):** in the markdown engine, local images land at the **end** of the page, not their original position. When image position matters, use the blocks engine. Remote images keep their position (they stay inline in the markdown).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Flags & versioning
|
|
||||||
|
|
||||||
New CLI flags:
|
|
||||||
|
|
||||||
| Flag | Default | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `--engine {blocks,markdown}` | `blocks` | Select the write path |
|
|
||||||
| `--notion-version VERSION` | engine-dependent | Override the API version (markdown defaults to `2026-03-11`) |
|
|
||||||
| `--allow-deleting-content` | off | Permit markdown `replace_content` to delete child pages/databases |
|
|
||||||
|
|
||||||
Validation: `--engine markdown` with `--upsert-by` on a property the markdown path can't update falls back to the shared coercion logic (no new restriction). `--allow-deleting-content` is ignored by the blocks engine (warn if combined).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
Permissive where the user's intent is ambiguous; loud where they clearly intended an action that failed.
|
|
||||||
|
|
||||||
| Failure | Behavior |
|
|
||||||
|---|---|
|
|
||||||
| `ntn` not installed | Abort before any write; install hint |
|
|
||||||
| `ntn` not logged in (`users/me` fails) | Abort; `ntn login` hint |
|
|
||||||
| `ntn files create` non-zero exit | Abort; print file path + `ntn` stderr |
|
|
||||||
| Local image path missing on disk | Abort; `image references missing file: <path>` |
|
|
||||||
| Markdown `replace`/selection no match | Route `validation_error` through `explain_api_error` with a markdown-specific hint |
|
|
||||||
| `replace_content` would delete child pages | Surface the API's affected-items list; suggest `--allow-deleting-content` |
|
|
||||||
| Synced-page update rejected | Clear message (synced pages can't be updated) |
|
|
||||||
| Unresolvable `@[mention]` | Degrade to plain `@Title` (both engines) |
|
|
||||||
| Two-phase image append fails after text write | Warn that text was written but images were not; non-zero exit |
|
|
||||||
|
|
||||||
`explain_api_error` gains markdown-endpoint cases; all other paths reuse existing handling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
TDD. New unit suites alongside the existing `test_parser.py` (28 tests, must stay green).
|
|
||||||
|
|
||||||
| Suite | Verifies |
|
|
||||||
|---|---|
|
|
||||||
| `test_parser.py` (extended) | `` → external image block; `` → external-shape with local URL pre-materialization; non-image `!` text unaffected; existing 28 stay green |
|
|
||||||
| `test_md_translate.py` (new) | callout (each alert type → icon+`_bg` color, tab-indented body); columns → `<columns>/<column>`; mention id+url+invalid; passthrough of headings/lists/code/tables/`<details>`; escaping |
|
|
||||||
| `test_ntn_files.py` (new) | `subprocess.run` mocked: command shape, `--plain` first-field parse, non-zero → `NtnUploadError`, preflight missing-`ntn`, preflight not-logged-in; local-vs-remote branch in `materialize_local_media` |
|
|
||||||
| `test_engine_routing.py` (new) | client `request` mocked: each op×engine calls the right path/method/body; markdown client built with `2026-03-11`; two-phase image append fires after text write |
|
|
||||||
|
|
||||||
**Live smoke test** (manual, end of implementation) against the "Working with AI" data source `f8f19ede-32bd-43ac-9f60-0651f6f40afe`, both engines:
|
|
||||||
1. `--test` connection.
|
|
||||||
2. Blocks engine: create a row with a local image → confirm `file_upload` image block renders.
|
|
||||||
3. Markdown engine: create a row from a doc using `[!NOTE]` + `:::columns` → confirm callout + columns render; confirm a local image appended at end.
|
|
||||||
4. Markdown engine `--replace` on the same page → confirm `replace_content` works.
|
|
||||||
|
|
||||||
This verifies the one open item (pipe-table passthrough) and the data-source parent under `2026-03-11`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File changes
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|---|---|
|
|
||||||
| `scripts/notion_writer.py` | `--engine`/`--notion-version`/`--allow-deleting-content` flags; image detector + `create_image_block`; `materialize_local_media` walk; markdown-engine routing; two-phase image append |
|
|
||||||
| `scripts/_notion_compat.py` | `make_client(notion_version=…)`; `create_page_markdown`/`append_markdown`/`replace_markdown`; markdown cases in `explain_api_error` |
|
|
||||||
| `scripts/ntn_files.py` | **new** — preflight + `upload` |
|
|
||||||
| `scripts/md_translate.py` | **new** — `translate` + construct translators |
|
|
||||||
| `scripts/test_md_translate.py`, `scripts/test_ntn_files.py`, `scripts/test_engine_routing.py` | **new** test suites |
|
|
||||||
| `scripts/test_parser.py` | image tests |
|
|
||||||
| `scripts/requirements.txt` | pin deps |
|
|
||||||
| `code/CLAUDE.md`, `SKILL.md` | document engines, file uploads, flags, the markdown-engine image limitation; v1.3.0 changelog |
|
|
||||||
| `.gitignore` | ensure `venv/` ignored |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of scope (parking lot)
|
|
||||||
|
|
||||||
- Generic local `<file>`/`<pdf>`/`<video>`/`<audio>` upload (mechanism ready; not wired in v1).
|
|
||||||
- `GET /v1/pages/:id/markdown` round-trip read.
|
|
||||||
- Workspace-mismatch hard guard (escalate preflight from warn → block) if it proves necessary in practice.
|
|
||||||
- Notion enhanced markdown → skill dialect (reverse translation), which would also serve the parked Phase 3b Notion-as-RAG export.
|
|
||||||
- `<table>` rewrite for pipe tables — only if live testing shows pipe tables are rejected by the markdown endpoint.
|
|
||||||
- Inline (non-standalone) images.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation transition
|
|
||||||
|
|
||||||
After user approval of this spec, invoke `superpowers:writing-plans`. The plan will likely sequence:
|
|
||||||
|
|
||||||
1. Create venv + `requirements.txt`; confirm existing 28 tests run green (baseline).
|
|
||||||
2. `ntn_files.py` + `test_ntn_files.py` (subprocess mocked).
|
|
||||||
3. Image detector + `create_image_block` + `materialize_local_media` + parser image tests (blocks engine Tier 1 complete; live single-image check).
|
|
||||||
4. `md_translate.py` + `test_md_translate.py` (pure, no API).
|
|
||||||
5. `_notion_compat` markdown helpers + version-aware client + `test_engine_routing.py`.
|
|
||||||
6. Wire `--engine`/flags + routing + two-phase image append in `notion_writer.py`.
|
|
||||||
7. Docs (CLAUDE.md/SKILL.md) + v1.3.0 bump.
|
|
||||||
8. Live smoke test (both engines) against the "Working with AI" DB; resolve the pipe-table verification item.
|
|
||||||
|
|
||||||
Each step is independently testable and revertable.
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"output_tokens": 47918}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"runs": [
|
|
||||||
{
|
|
||||||
"date": "2026-06-17",
|
|
||||||
"input_tokens": 171924,
|
|
||||||
"output_tokens": 0,
|
|
||||||
"files": 26,
|
|
||||||
"note": "OKF-scoped --update"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-06-17",
|
|
||||||
"input_tokens": 1306874,
|
|
||||||
"output_tokens": 301492,
|
|
||||||
"files": 875,
|
|
||||||
"note": "doc-layer via Gemini 3.1 Pro (~$6.23, not Claude tokens)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total_input_tokens": 1478798,
|
|
||||||
"total_output_tokens": 301492
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user